OOAD範例:配置類設計

在不少應用程序中,咱們都須要一個配置類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類讀取配置

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);
    }

由於咱們在獲取配置值時,必須進行下面的工做:

  • 檢索key是否存在
  • 檢索value是否存在
  • 若是配置項是字符串類型,必須進行字符集轉換
  • 若是配置項不是字符串類型,必須進行類型轉換
  • 若是須要類型轉換,必須處理轉換異常
  • 必須分別處理有缺省值和完好省值兩種狀況
  • 當有缺省值,但轉換失敗時,是返回缺省值,仍是拋出解析異常?必須靈活應對這兩種狀況。

2. 重複

因爲沒有封裝,每次訪問屬性值時都必須輸入相似上面的一大段代碼,而不僅是一行方法調用。所以,系統中充滿着重複的代碼。業界已經公認,重複是萬惡之源。

3. 僵化

咱們不能靈活應對多種狀況,例如是否有缺省值,日期格式是什麼,解析失敗時是返回缺省值,仍是拋出解析異常等等決策,都直接硬編碼到代碼之中,不能根據現實須要靈活調整。

4. 脆弱

因爲代碼複雜,多個關注點相互纏繞,系統很是脆弱,時刻可能由於考慮不周而產生邏輯錯誤。

直接使用Properties,咱們獲得的是一個可讀性差、可維護性差、複雜、僵化而脆弱的系統。

設計一個通用的Configuration類

針對直接採用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類

特化的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中。

總結

在這個項目中主要應用了兩個設計技巧:

  • 封裝
  • 泛化與特化

封裝

在這個項目中,咱們在兩個地方基於不一樣的目的應用了封裝。

  1. 經過Configuration封裝Properties, 目的是隱藏技術複雜性,減輕客戶端代碼的負擔。
  2. 經過AppConfiguration封裝Configuration,目的是更契合特定領域的須要,面向意圖編程。

泛化(generalization)與特化(specialization)

在本項目中,咱們同時提供了通用的配置類Configuration和專門領域的配置類AppConfiguration。Configuration是AppConfiguration的泛化,AppConfiguration是Configuration的特化。

Configuration的目標是重用,所以必須更通常化,能夠提供任意多個設置項,提供更多的可配置內容(例如dateFormat, defaultWhenParseFailed等),以即可以靈活應用於多種不一樣的環境;AppConfiguration的目標是緊密契合當前項目的須要,所以有一個固定的設置項集合,而且剔除了沒必要要的靈活性(dateFormat直接設置爲yyyy-MM-dddefaultWhenParseFailed直接設置爲true)。

最重要的是記住:

泛化以擴大外延——在大多數項目中獲得重用;
特化以增長內涵——高度契合當前項目的須要。

範例代碼能夠從https://git.oschina.net/yyang/configuration下載。

相關文章
相關標籤/搜索