在不少應用程序中,咱們都須要一個配置類Configuration,一般從一個文本文件中讀入配置信息,根據配置調整應用的行爲。經過這樣的方式,咱們能夠用相同的代碼來適應不一樣的環境,達到靈活性的目標。java
本文探索如何設計好這樣的配置類。咱們的重點不在於設計的產物——配置類——自己,而是在設計中的權衡取捨,以及取捨的原則。git
這裏咱們以從一個conf.properties文件中讀取配置信息爲例,以不一樣的方式讀取配置信息。apache
這個文件的內容以下:編程
birthday=2002-05-11 size=15 closed=true locked = false salary=12.5 name=張三 noneValue=
配置文件是一個標準的屬性文件,在一個配置文件中有一系列的配置項,每一個配置項的形式是key=value,其中key爲配置項的名稱,value爲配置項的值。配置項有字符串、數值、布爾、日期等多種類型。api
爲了方便,本文直接使用單元測試做爲配置類的客戶代碼,即配置類的消費者。app
如何在應用代碼中獲取配置信息?如下提供三種方法,咱們隨後將分析各自的優缺點。單元測試
JDK中,已經提供了一個Properties類,能夠用於從屬性文件中讀取配置信息。測試
客戶代碼以下:ui
package yang.yu.configuration; import org.junit.Before; import org.junit.Test; import java.io.InputStream; import java.io.UnsupportedEncodingException; import java.util.Properties; import static org.assertj.core.api.Assertions.assertThat; public class PropertiesTest { private String confFile = "/conf.properties"; private Properties properties; @Before public void setUp() throws Exception { properties = new Properties(); try(InputStream in = getClass().getResourceAsStream(confFile)) { properties.load(in); } } @Test public void testGetString() { String name = null; try { String temp = properties.getProperty("name"); name = new String(temp.getBytes("iso-8859-1"), "utf-8"); System.out.println(name); } catch (UnsupportedEncodingException e) { e.printStackTrace(); } assertThat(name).isEqualTo("張三"); } @Test public void testGetStringWithDefault() { String defaultValue = "abc"; String temp = properties.getProperty("notExists"); String name = null; if (temp == null) { name = defaultValue; } else { try { name = new String(temp.getBytes("iso-8859-1"), "utf-8"); } catch (UnsupportedEncodingException e) { e.printStackTrace(); } } assertThat(name).isEqualTo("abc"); } @Test public void testGetInt() { String temp = properties.getProperty("size"); if (temp == null) { throw new ConfigurationKeyNotFoundException(); } int value = -1; try { value = Integer.parseInt(temp); } catch (NumberFormatException e) { throw new RuntimeException("Cannot parse string '" + temp + "' to int"); } assertThat(value).isEqualTo(15); } @Test public void testGetIntWithDefault() { int defaultValue = 0; int value = -1; String temp = properties.getProperty("size"); if (temp == null) { value = defaultValue; } try { value = Integer.parseInt(temp); } catch (NumberFormatException e) { throw new RuntimeException("Cannot parse string '" + temp + "' to int"); } assertThat(value).isEqualTo(15); } ...... }
在這裏,咱們用單元測試做爲配置的客戶代碼。咱們經過Properties類從類路徑下的conf.properties文件中讀取配置信息,而後經過Properties類的getProperty()方法獲取相應的配置項。this
雖然從功能上來講,經過Properties類訪問配置項沒有任何問題,可是客戶代碼卻很是複雜,下面一一討論。
1. 繁瑣
爲了獲取一個配置值,必須像下面這樣輸入不少行代碼:
@Test public void testGetIntWithDefault() { int defaultValue = 0; int value = -1; String temp = properties.getProperty("size"); if (temp == null) { value = defaultValue; } try { value = Integer.parseInt(temp); } catch (NumberFormatException e) { throw new RuntimeException("Cannot parse string '" + temp + "' to int"); } assertThat(value).isEqualTo(15); }
由於咱們在獲取配置值時,必須進行下面的工做:
2. 重複
因爲沒有封裝,每次訪問屬性值時都必須輸入相似上面的一大段代碼,而不僅是一行方法調用。所以,系統中充滿着重複的代碼。業界已經公認,重複是萬惡之源。
3. 僵化
咱們不能靈活應對多種狀況,例如是否有缺省值,日期格式是什麼,解析失敗時是返回缺省值,仍是拋出解析異常等等決策,都直接硬編碼到代碼之中,不能根據現實須要靈活調整。
4. 脆弱
因爲代碼複雜,多個關注點相互纏繞,系統很是脆弱,時刻可能由於考慮不周而產生邏輯錯誤。
直接使用Properties,咱們獲得的是一個可讀性差、可維護性差、複雜、僵化而脆弱的系統。
針對直接採用Properties作配置類的問題,封裝是一個很是有效的解決辦法。咱們能夠設計一個通用的Configuration類,在該類中封裝了異常處理、字符集編碼轉換、類型轉換、缺省值處理等等方面,使得客戶代碼能夠很是簡單、直接,系統的可讀性、可維護性和可靠性都獲得大幅提高。
配置類的代碼以下:
package yang.yu.configuration; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import yang.yu.configuration.internal.PropertiesFileUtils; import java.io.*; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; import java.util.Hashtable; import java.util.Properties; public class Configuration { private static final Logger LOGGER = LoggerFactory.getLogger(Configuration.class); private static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd"; private Hashtable<String, String> hashtable; private String dateFormat = DEFAULT_DATE_FORMAT; private boolean defaultWhenParseFailed = false; //當類型轉換失敗時是否返回缺省值 public static Builder builder() { return new Builder(); } private Configuration(Hashtable<String, String> hashtable) { this.hashtable = hashtable; } public void setDateFormat(String dateFormat) { this.dateFormat = dateFormat; } public void setDefaultWhenParseFailed(boolean defaultWhenParseFailed) { this.defaultWhenParseFailed = defaultWhenParseFailed; } public String getString(String key, String defaultValue) { Assert.notBlank(key, "Key is null or empty!"); String value = hashtable.get(key); return StringUtils.isBlank(value) ? defaultValue : value; } public String getString(String key) { Assert.notBlank(key, "Key is null or empty!"); if (!hashtable.containsKey(key)) { throw new ConfigurationKeyNotFoundException("Configuration key '" + key + "' not found!"); } return hashtable.get(key); } public int getInt(String key, int defaultValue) { if (!hashtable.containsKey(key)) { return defaultValue; } String value = hashtable.get(key); try { return Integer.parseInt(value); } catch (NumberFormatException e) { if (defaultWhenParseFailed) { return defaultValue; } throw new ConfigurationValueParseException("'" + value + "' cannot be parsed to int"); } } public int getInt(String key) { if (!hashtable.containsKey(key)) { throw new ConfigurationKeyNotFoundException("Configuration key '" + key + "' not found!"); } String value = hashtable.get(key); try { return Integer.parseInt(value); } catch (NumberFormatException e) { throw new ConfigurationValueParseException("'" + value + "' cannot be parsed to int"); } } public long getLong(String key, long defaultValue) { if (!hashtable.containsKey(key)) { return defaultValue; } String value = hashtable.get(key); try { return Long.parseLong(value); } catch (NumberFormatException e) { if (defaultWhenParseFailed) { return defaultValue; } throw new ConfigurationValueParseException("'" + value + "' cannot be parsed to long"); } } public long getLong(String key) { if (!hashtable.containsKey(key)) { throw new ConfigurationKeyNotFoundException("Configuration key '" + key + "' not found!"); } String value = hashtable.get(key); try { return Long.parseLong(value); } catch (NumberFormatException e) { throw new ConfigurationValueParseException("'" + value + "' cannot be parsed to long"); } } public double getDouble(String key, double defaultValue) { if (!hashtable.containsKey(key)) { return defaultValue; } String value = hashtable.get(key); try { return Double.parseDouble(value); } catch (NumberFormatException e) { if (defaultWhenParseFailed) { return defaultValue; } throw new ConfigurationValueParseException("'" + value + "' cannot be parsed to double"); } } public double getDouble(String key) { if (!hashtable.containsKey(key)) { throw new ConfigurationKeyNotFoundException("Configuration key '" + key + "' not found!"); } String value = hashtable.get(key); try { return Double.parseDouble(value); } catch (NumberFormatException e) { throw new ConfigurationValueParseException("'" + value + "' cannot be parsed to double"); } } public boolean getBoolean(String key, boolean defaultValue) { if (!hashtable.containsKey(key)) { return defaultValue; } String value = hashtable.get(key); if ("true".equalsIgnoreCase(value)) { return true; } if ("false".equalsIgnoreCase(value)) { return false; } if (defaultWhenParseFailed) { return Boolean.parseBoolean(value); } throw new ConfigurationValueParseException("'" + value + "' cannot be parsed to boolean"); } public boolean getBoolean(String key) { if (!hashtable.containsKey(key)) { throw new ConfigurationKeyNotFoundException("Configuration key '" + key + "' not found!"); } String value = hashtable.get(key); if ("true".equalsIgnoreCase(value)) { return true; } if ("false".equalsIgnoreCase(value)) { return false; } throw new ConfigurationValueParseException("'" + value + "' cannot be parsed to boolean"); } public Date getDate(String key, Date defaultValue) { if (!hashtable.containsKey(key)) { return defaultValue; } String value = hashtable.get(key); try { return new SimpleDateFormat(dateFormat).parse(value); } catch (ParseException e) { if (defaultWhenParseFailed) { return defaultValue; } throw new ConfigurationValueParseException("'" + value + "' cannot be parsed to date"); } } public Date getDate(String key) { if (!hashtable.containsKey(key)) { throw new ConfigurationKeyNotFoundException("Configuration key '" + key + "' not found!"); } String value = hashtable.get(key); try { return new SimpleDateFormat(dateFormat).parse(value); } catch (ParseException e) { throw new ConfigurationValueParseException("'" + value + "' cannot be parsed to date"); } } public static class Builder { private boolean defaultWhenParseFailed = false; //當類型轉換失敗時是否返回缺省值 private Hashtable<String, String> hashtable = new Hashtable(); private String dateFormat = "yyyy-MM-dd"; private PropertiesFileUtils pfu = new PropertiesFileUtils("utf-8"); public Builder fromClasspath(String confFile) { String path = getClass().getResource(confFile).getFile(); return fromFile(path); } public Builder fromFile(String confFile) { return fromFile(new File(confFile)); } public Builder fromFile(File confFile) { if (!confFile.exists()) { throw new ConfigurationFileNotFoundException(); } if (!confFile.canRead()) { throw new ConfigurationFileReadException("Read configuration file is not permitted!"); } InputStream in = null; try { in = new FileInputStream(confFile); Properties props = new Properties(); props.load(in); hashtable = pfu.rectifyProperties(props); LOGGER.debug("Load configuration from {} at {}", confFile.getAbsolutePath(), new Date()); } catch (IOException e) { throw new ConfigurationFileReadException("Cannot load config file: " + confFile, e); } finally { if (in != null) { try { in.close(); } catch (IOException e) { throw new ConfigurationException("Cannot close input stream.", e); } } } return this; } public Builder dateFormat(String dateFormat) { this.dateFormat = dateFormat; return this; } public Builder defaultWhenParseFailed(boolean defaultWhenParseFailed) { this.defaultWhenParseFailed = defaultWhenParseFailed; return this; } public Configuration build() { if (hashtable.isEmpty()) { throw new ConfigurationException("Configuration source not specified!"); } Configuration result = new Configuration(hashtable); result.setDateFormat(dateFormat); result.setDefaultWhenParseFailed(defaultWhenParseFailed); return result; } } }
經過將異常處理、類型轉換、字符編碼轉換等等功能集中在Configuration類中統一處理,客戶端代碼卸下這些繁重的、重複性的、易於出錯的負擔,變得很是簡單明瞭:
package yang.yu.configuration; import org.junit.Before; import org.junit.Test; import java.io.File; import java.util.Calendar; import java.util.Date; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.offset; public class ConfigurationTest { private String confFile = "/conf.properties"; private Configuration instance; //more ///////////////////////////////////////////3. Int型配置項 //////////////////3.1. Int型配置項,完好省值 /** * key存在,value存在,格式正確,應當返回value */ @Test public void get_int_without_defaultValue_happy() { assertThat(instance.getInt("size")).isEqualTo(15); } /** * key存在,value存在,格式不正確,應當拋出ConfigurationValueParseException異常 */ @Test(expected = ConfigurationValueParseException.class) public void get_int_without_defaultValue_and_with_invalid_value() { instance.getInt("name"); } /** * key存在,value不存在,應當拋出ConfigurationValueParseException異常 */ @Test(expected = ConfigurationValueParseException.class) public void get_int_without_defaultValue_and_without_value() { instance.getInt("noneValue"); } /** * key不存在,應當拋出ConfigurationKeyNotFoundException異常 */ @Test(expected = ConfigurationKeyNotFoundException.class) public void get_int_without_defaultValue_and_without_key() { instance.getInt("noneKey"); } /////////////3.2. Int型配置項,有缺省值 /** * key存在, value存在,格式正確,應當返回value */ @Test public void get_int_with_defaultValue_and_with_value() { assertThat(instance.getInt("size", 1000)).isEqualTo(15); } /** * key存在,value存在,格式不正確,defaultWhenParseFailed=true,應當返回缺省值 */ @Test public void get_int_with_defaultValue_and_with_invalid_value_and_defaultWhenParseFailed_is_true() { instance.setDefaultWhenParseFailed(true); assertThat(instance.getInt("name", 1000)).isEqualTo(1000); } /** * key存在,value存在,格式不正確,defaultWhenParseFailed=false,應當拋出ConfigurationValueParseException異常 */ @Test(expected = ConfigurationValueParseException.class) public void get_int_with_defaultValue_and_with_invalid_value_and_defaultWhenParseFailed_is_false() { instance.setDefaultWhenParseFailed(false); instance.getInt("name", 1000); } /** * key存在,value不存在,defaultWhenParseFailed=true,應當返回缺省值 */ @Test public void get_int_with_defaultValue_and_without_value_and_defaultWhenParseFailed_is_true() { instance.setDefaultWhenParseFailed(true); assertThat(instance.getInt("noneValue", 1000)).isEqualTo(1000); } /** * key存在,value不存在,defaultWhenParseFailed=false,應當拋出ConfigurationValueParseException異常 */ @Test(expected = ConfigurationValueParseException.class) public void get_int_with_defaultValue_and_without_value_and_defaultWhenParseFailed_is_false() { instance.setDefaultWhenParseFailed(false); instance.getInt("noneValue", 1000); } /** * key不存在,應當返回缺省值 */ @Test public void get_int_with_defaultValue_and_without_key() { assertThat(instance.getInt("noneKey", 1000)).isEqualTo(1000); } //more }
要獲取一個整數值,咱們只須要這樣調用:
int size = configuration.getInt("size");
若是想要在配置項不存在時返回缺省值,咱們只須要這樣調用:
int size = configuration.getInt("size", 0);
讀取配置信息是一個通用性的需求,幾乎在每一個項目中都有這樣的須要。抽象出Configuration類以後,咱們能夠將它做爲公司級通用類庫,供給全部的項目引用,使得其餘項目再也不須要編寫重複的配置文件讀取代碼。
可是,直接使用通用的Configuration類,仍有不盡人意的方面。例如,我要讀取Boolean類型的closed配置項,必須這樣編寫代碼:
if (configuration.getBoolean("closed")) { ...... }
這樣的語句仍然與個人領域語言隔了一層,是面向實現而不是面向意圖的。我真正須要的是這樣的代碼:
if (configuration.isClosed()) { ...... }
closed再也不是一個由5個字母組成的字符串,而是領域語言isClosed()的一部分。
下面說明怎樣設計這樣的一個面向客戶領域的、特化的Configuration類。
特化的AppConfiguration類採用客戶領域的語言編寫,將配置項中無心義的字符串key轉化爲方法名稱:
package yang.yu.configuration; import java.util.Date; public class AppConfiguration { private static String confFile = "/conf.properties"; private static final String KEY_BIRTHDAY = "birthday"; private static final String KEY_SIZE = "size"; private static final String KEY_CLOSED = "closed"; private static final String KEY_LOCKED = "locked"; private static final String KEY_SALARY = "salary"; private static final String KEY_NAME = "name"; private static final double HIGH_SALARY_THRESHOLD = 10000; private Configuration configuration; public AppConfiguration() { this.configuration = Configuration.builder() .fromClasspath(confFile) .dateFormat("yyyy-MM-dd") .defaultWhenParseFailed(true) .build(); } public AppConfiguration(Configuration configuration) { this.configuration = configuration; } public Date birthday() { return configuration.getDate(KEY_BIRTHDAY); } public int size() { return configuration.getInt(KEY_SIZE); } public boolean isClosed() { return configuration.getBoolean(KEY_CLOSED); } public boolean isLocked() { return configuration.getBoolean(KEY_LOCKED); } public double salary() { return configuration.getDouble(KEY_SALARY); } public boolean isHighSalaryLevel() { return salary() >= HIGH_SALARY_THRESHOLD; } public String name() { return configuration.getString("name"); } }
客戶端代碼更加簡單、直接,可讀性更強:
package yang.yu.configuration; import org.junit.Before; import org.junit.Test; import java.util.Calendar; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.offset; public class AppConfigurationTest { private AppConfiguration instance; @Before public void setUp() throws Exception { instance = new AppConfiguration(); } @Test public void birthday() throws Exception { Date expected = DateUtils.createDate(2002, Calendar.MAY, 11); assertThat(instance.birthday()).isEqualTo(expected); } @Test public void size() throws Exception { assertThat(instance.size()).isEqualTo(15); } @Test public void isClosed() throws Exception { assertThat(instance.isClosed()).isTrue(); } @Test public void isLocked() throws Exception { assertThat(instance.isLocked()).isFalse(); } @Test public void salary() throws Exception { assertThat(instance.salary()).isCloseTo(12.5, offset(0.00001)); } @Test public void isHighSalaryLevel() throws Exception { assertThat(instance.isHighSalaryLevel()).isFalse(); } @Test public void name() throws Exception { assertThat(instance.name()).isEqualTo("張三"); } }
提供應用特定的配置類好處很明顯:
1. 經過將配置項從字符串轉換成方法名,減小了錯誤錄入的可能
若是咱們這些讀取配置:
boolean closed = configuration.getBoolean("closed");
若是不當心,很容易把字符串closed寫錯。要命的是,這個錯誤IDE和編譯器都沒法捕獲,只有在運行時纔會發現。
這樣的配置:
boolean closed = configuration.isClosed();
就不太可能寫錯,即便寫錯了,IDE和編譯器會替你發現。
2. 面向領域編程
在分析設計的時候,咱們應該經過在軟件中採用問題域的詞彙來做爲軟件類、方法、屬性、變量的名稱來縮小問題域和解決方案域之間的語義距離。
configuration.isClosed();
比起
configuration.getBoolean("closed");
更接近問題域的語言。
3. 支持衍生配置項
在AppConfiguration類中,我建立了一個衍生配置項:
public boolean isHighSalaryLevel() { return salary() >= HIGH_SALARY_THRESHOLD; }
該配置項並不直接存儲於conf.properties中。
在這個項目中主要應用了兩個設計技巧:
在這個項目中,咱們在兩個地方基於不一樣的目的應用了封裝。
在本項目中,咱們同時提供了通用的配置類Configuration和專門領域的配置類AppConfiguration。Configuration是AppConfiguration的泛化,AppConfiguration是Configuration的特化。
Configuration的目標是重用,所以必須更通常化,能夠提供任意多個設置項,提供更多的可配置內容(例如dateFormat, defaultWhenParseFailed等),以即可以靈活應用於多種不一樣的環境;AppConfiguration的目標是緊密契合當前項目的須要,所以有一個固定的設置項集合,而且剔除了沒必要要的靈活性(dateFormat直接設置爲yyyy-MM-dd,defaultWhenParseFailed直接設置爲true)。
最重要的是記住:
泛化以擴大外延——在大多數項目中獲得重用; 特化以增長內涵——高度契合當前項目的須要。