2015年6月21日日曜日

[依存性挿入][実装]DIの簡単なサンプル

 依存性挿入(DI)は、あるクラスが依存しているクラス(メンバで持っている他のクラス)を自動で設定する仕組みである。現在ではSpringやSeasar2などで実装されているが、DIの仕組みそのものはそれほど難しい構造を持っているわけではない。Javaのリフレクションの仕組みを使って実装する。以下、簡単なDIのサンプル。

package di;

import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;

import util.AppLog;
import util.PropUtil;
import util.SvFile;
import exception.ApplicationInternalException;

//---------------------------------------------------------------------------//
// このクラスの使い方
// 1. DI設定のタブ区切りファイル "di.tsv" を作成します。
// 2. DI設定は次のように記述します。
//    "[DI設定するクラスの名前]"[tab]"[パッケージ]"
//    実際の設定は、こんな感じです。
//    "Service"    "service.impl"
//    "Repository"    "domain"
//    "Dao"    "dao.impl"
// 3. 上記2.のように記述した場合、"service.impl"パッケージにある
//    "Service"という文字列を含むクラスを検索し、インスタンスを作成します。
//    作成したインスタンスは"XXXService"というクラス名をキーとして、
//    本クラスのマップに保存されます。
//    クラス名が"XXXServiceImpl"だとしても、設定ファイルに"Service"
//    で登録されていれば、マップには"XXXService"で登録します。
// 4. マップに保存されたクラス全てを調べ、"setXXXService"という名前
//    を持つメソッドがあれば設定します。
// 以上のようにして、DIを行います。
// DIするために必要な命名規則は、次の通りです。
//   (1) インターフェース名、クラス名の末尾を揃える。
//        "XXXService"、"XXXRepository"、"XXXDao"などのようにDI設定
//        で検索できるよう、インターフェース名、クラス名を揃えます。
//   (2) インターフェースを用いる場合は、"XXXServiceImpl"などのように
//       DI設定のキーワードの後に"Impl"などをつけて、クラスを実装してください。
//       また、クラスを実装するパッケージは"service.impl"などインターフェース
//       とは別のパッケージとしてください。
//   (3) DI設定させたいメンバがある場合は、必ずpublicメソッドで
//       "setXXXService"というメソッドを用意してください。
//---------------------------------------------------------------------------//

/**
 * DI設定クラス
 */
public class DiConfig {

    /** シングルトンのインスタンス */
    private static DiConfig diConfig = null;

    /**
     * シングルトンのインスタンスを取得します。
     *
     * @return インスタンス
     */
    public static DiConfig getInstance() {
        if (diConfig == null) {
            diConfig = new DiConfig();
        }
        return diConfig;
    }

    /** DIマップ */
    private Map<String, Object> diMap = null;

    /**
     * 名前を指定して、DIインスタンスを取得します。
     *
     * @param name
     *            取得するDIインスタンス名
     * @return DIインスタンス
     */
    public Object getDiInstance(String name) {
        return diMap.get(name);
    }

    /**
     * コンストラクタ
     */
    private DiConfig() {
        try {
            loadDb();
            injectDependency();
        } catch (IOException e) {
            throw new ApplicationInternalException(
                    PropUtil.get("msg.err.dbLoad"));
        }
    }

    /**
     * 設定を読み込み、依存性注入を実施します。
     */
    private void injectDependency() {

        // 設定が存在しなければエラーとする
        List<List<String>> csvLineList = getSvLineList();
        if (csvLineList == null || csvLineList.size() == 0) {
            throw new ApplicationInternalException(
                    PropUtil.get("msg.err.noDiConfig"));
        }

        // DI基本パスを取得する
        String diBasePath = PropUtil.get("di.config.basePath");
        if (diBasePath == null || diBasePath.isEmpty()) {
            throw new ApplicationInternalException(
                    PropUtil.get("msg.err.noDiConfigBasePath"));
        }

        // 基本パスがjarファイルである場合は、jarファイルを参照する
        if (diBasePath.contains(".jar")) {
            injectDependencyFromJar(diBasePath, csvLineList);
            return;
        }

        // 全てのDI設定を読み込むまでループ
        diMap = new HashMap<String, Object>();
        for (List<String> csvLine : csvLineList) {

            // CSV行の設定値が2つでない場合はエラーとする
            if (csvLine.size() != 2) {
                throw new ApplicationInternalException(
                        PropUtil.get("msg.err.ngConfig"));
            }

            // DIのキーワードとDIパッケージを元に、DI設定を検索する
            searchDiSetting(diBasePath, csvLine.get(0), csvLine.get(1));
        }

        // DI設定に基づき、インジェクションを実行する
        injectByDiSetting();
    }

    /**
     * DIキーワードでDIパッケージを検索し、見つかったクラスを記憶します。
     *
     * @param diBasePath
     *            DI基本パス
     * @param diKeyword
     *            DIキーワード
     * @param diPackage
     *            DIパッケージ
     */
    private void searchDiSetting(String diBasePath, String diKeyword,
            String diPackage) {

        // DI基本パス+DIパッケージの位置にある全てのクラスファイルを取得する
        File dir = new File(diBasePath + "/" + diPackage.replaceAll("\\.", "/"));
        File[] fileList = dir.listFiles();
        if (fileList == null || fileList.length == 0) {
            return;
        }

        // DIキーワードでDIパッケージを検索し、見つかったクラスを記憶する
        for (File file : fileList) {

            // ディレクトリは処理対象外とする
            if (file.isDirectory()) {
                continue;
            }

            // ファイル名に[DIキーワード]が含まれる場合、DI設定として保存する
            if (file.getName().contains(diKeyword)) {
                try {
                    String className = file.getName().substring(0,
                            file.getName().indexOf(diKeyword))
                            + diKeyword;
                    String fullPathClassName = diPackage
                            + "."
                            + file.getName().substring(0,
                                    file.getName().indexOf(".class"));
                    Object newInstance = Class.forName(fullPathClassName)
                            .newInstance();
                    diMap.put(className, newInstance);
                } catch (Exception e) {
                    throw new ApplicationInternalException(
                            PropUtil.get("msg.err.createDiInstanceFailed"));
                }
            }
        }
    }

    /**
     * DI設定にあるパッケージを再帰的に操作し、依存性注入を実行します。
     */
    private void injectByDiSetting() {

        // DIマップ内の全てのクラスを処理するまでループ
        for (String diClassName : diMap.keySet()) {

            // インジェクション可能なものにインジェクションを行う
            for (String targetClassName : diMap.keySet()) {

                // メソッド配列を取得する
                Method[] methods = diMap.get(targetClassName).getClass()
                        .getMethods();

                // メソッドにDIできるものがあればインジェクションを行う
                for (Method method : methods) {
                    if (method.getName().equals("set" + diClassName)) {
                        try {
                            method.invoke(diMap.get(targetClassName),
                                    diMap.get(diClassName));
                        } catch (Exception e) {
                            throw new ApplicationInternalException(
                                    PropUtil.get("msg.err.setDiInstanceFailed"));
                        }
                    }
                }
            }
        }
    }

    /**
     * jarファイルからDI設定条件に合致するクラスを探しだし、依存性挿入を行います。
     *
     * @param jarFileName
     *            jarファイル名
     * @param csvLineList
     *            CSV行リスト
     */
    private void injectDependencyFromJar(String jarFileName,
            List<List<String>> csvLineList) {

        try {
            // jarファイル内の全エントリを取得する
            File file = new File(jarFileName);
            JarFile jarFile = new JarFile(file);
            Enumeration<JarEntry> jarEntries = jarFile.entries();

            // jarファイル内の全エントリから、クラスのエントリのみを抽出する
            List<String> classNameList = new ArrayList<String>();
            while (jarEntries.hasMoreElements()) {

                // jarファイル内のエントリを取得し、クラスでなければループの先頭に戻る
                JarEntry jarEntry = jarEntries.nextElement();
                if (!jarEntry.getName().endsWith(".class")) {
                    continue;
                }

                // クラス名を求める
                String className = jarEntry.getName().substring(0,
                        jarEntry.getName().lastIndexOf(".class"));
                className = className.replace("/", ".");
                classNameList.add(className);
            }

            // 全てのDI設定を読み込むまでループ
            diMap = new HashMap<String, Object>();
            for (List<String> csvLine : csvLineList) {

                // CSV行の設定値が2つでない場合はエラーとする
                if (csvLine.size() != 2) {
                    throw new ApplicationInternalException(
                            PropUtil.get("msg.err.ngConfig"));
                }

                // DIのキーワードとDIパッケージを元に、DI設定を検索する
                searchDiSettingFromJar(file, csvLine.get(0), csvLine.get(1),
                        classNameList);
            }

            // DI設定に基づき、インジェクションを実行する
            injectByDiSetting();

        } catch (Exception e) {
            throw new ApplicationInternalException(e);
        }
    }

    /**
     * jarファイル内からDI設定に合致するクラスを探し、DIマップを作成します。
     *
     * @param file
     *            jarファイル
     * @param diKeyword
     *            DIキーワード
     * @param diPackage
     *            DIパッケージ
     * @param classNameList
     *            クラス名リスト
     */
    private void searchDiSettingFromJar(File file, String diKeyword,
            String diPackage, List<String> classNameList) {

        try {
            // jarファイルのURLを取得する
            URL[] urls = new URL[] { file.toURI().toURL() };

            // クラスローダを作成する
            ClassLoader classLoader = URLClassLoader.newInstance(urls);

            // クラス名リストを検索し、DIキーワードに合致するクラスを探し出す
            for (String className : classNameList) {

                // DIキーワードが見つからない場合は、ループの先頭に戻る
                if (!className.contains(diKeyword)) {
                    continue;
                }

                // 当該クラスがDIパッケージで示されるパッケージにない場合は、ループの先頭に戻る
                if (!className.contains(diPackage)) {
                    continue;
                }

                // 名前からクラスを取得する
                Class<?> cls = Class.forName(className, true, classLoader);

                // クラスのインスタンスを取得する
                Object newInstance = cls.newInstance();

                // DIマップに作成したインスタンスを追加する
                String diClassName = className.substring(className
                        .lastIndexOf(".") + 1);
                diClassName = diClassName.substring(0,
                        diClassName.indexOf(diKeyword))
                        + diKeyword;
                diMap.put(diClassName, newInstance);

                // TODO 消す
                // +
                AppLog.getInstance().debug(
                        "diClassName : " + diClassName + " newInstance : "
                                + newInstance.getClass().getName());
                // -

            }

        } catch (Exception e) {
            throw new ApplicationInternalException(e);
        }
    }

    /** データベース */
    private SvFile db = null;

    /**
     * データベースのインスタンスを取得します。
     *
     * @return
     * @throws IOException
     */
    private SvFile getDb() throws IOException {
        if (db == null) {
            db = new SvFile();
        }
        return db;
    }

    /**
     * SV行データリストを取得します。
     *
     * @return 行データリスト
     */
    private List<List<String>> getSvLineList() {

        // SV行データリストを取得する
        List<List<String>> svLineList;
        try {
            svLineList = getDb().getSvLineList();
        } catch (IOException e) {
            throw new ApplicationInternalException(
                    PropUtil.get("msg.err.dbRef"));
        }

        // SV行データリストを呼び出し側に戻す
        return svLineList;
    }

    /**
     * データベースからデータをロードします。
     *
     * @throws IOException
     */
    private void loadDb() throws IOException {

        // ファイルを開いてみる
        String fileName = PropUtil.get("di.config.fileName");
        File f = new File(fileName);

        // ファイルが存在しない場合は新規作成する
        if (!f.exists()) {
            FileWriter fw = new FileWriter(f);
            fw.close();
        }

        // データベースファイルを開く
        getDb().loadFile(PropUtil.get("di.config.fileName"));
    }
}

[業務ロジック][機能単位][実装][サービス]SaveFileServiceImpl

 サービスの実装クラスサンプル。前述のDomainDrivenService、SaveFileServiceの両方に対応し、バッチからの呼び出し、ログファイルからのサービス実行、APIとしてのサービス呼び出しのいずれにも対応している。
 コード中の「メタデータリポジトリ」のような「~リポジトリ」クラスは、ドメイン駆動のドメイン層に当たるクラスであり、業務ロジックの本体を記述する。ドメイン層のクラスは通常、DIの仕組みを使って自動的に設定する。

package service.impl;

import service.DomainDrivenService;
import service.SaveFileService;
import service.param.ServiceInputParameter;
import service.param.ServiceOutputParameter;
import service.param.SipSaveFileService;
import service.param.SopSaveFileService;
import domain.MetaDataRepository;

/**
 * ファイル保存サービス(実装)
 */
public class SaveFileServiceImpl implements DomainDrivenService,
        SaveFileService {

    /** メタデータリポジトリ */
    private MetaDataRepository metaDataRepository = null;

    public MetaDataRepository getMetaDataRepository() {
        return metaDataRepository;
    }

    public void setMetaDataRepository(MetaDataRepository metaDataRepository) {
        this.metaDataRepository = metaDataRepository;
    }

    @Override
    public ServiceOutputParameter doService(ServiceInputParameter sip) {

        // 入力パラメータからサブスタンスIDを取得する
        String fileName = ((SipSaveFileService) sip).getFileName();

        // サービスを実行する
        doService(fileName);

        // サービス出力パラメータを作成する
        SopSaveFileService sop = new SopSaveFileService();

        // 出力パラメータを作成し、呼び出し側に戻す
        return sop;
    }

    /*
     * (非 Javadoc)
     *
     * @see service.SaveFileService#doService(java.lang.String)
     */
    @Override
    public void doService(String fileName) {

        // リポジトリの保存メソッドを呼び出す
        metaDataRepository.saveFile(fileName);
    }
}

[業務ロジック][機能単位][実装][サービス]SaveFileService

 全てのサービスはドメイン駆動サービス(DomainDrivenService)という共通のインターフェースを持つが、それはこれまでに記述した通りAPI呼び出しとバッチ実行の両方に対応するためである。通常、プログラムの内部で呼び出すサービスは個別のパラメータを持った業務ロジックであり、次のように引数・戻り値を任意に決めることができる。

package service;

/**
 * ファイル保存サービス
 */
public interface SaveFileService {

    /**
     * サービスを実行します。
     *
     * @param fileName
     *            ファイル名
     */
    public void doService(String fileName);
}

[業務ロジック][機能単位][実装][サービス]SipSaveFileService

 サービス入力パラメータという抽象クラスを継承する、具象クラス。サービス入力パラメータに関する基本操作は、当該クラスで規定するが、個々のサービスを実行するために必要なデータの取り出し等は、このように具象クラスを別途記述する。

package service.param;

/**
 * ファイル保存サービス入力パラメータ
 */
public class SipSaveFileService extends ServiceInputParameter {

    /**
     * コンストラクタ
     *
     * @param fileName
     *            ファイル名
     */
    public SipSaveFileService(String fileName) {

        // スーパークラスの処理を実行する
        super("SipSaveFileService");

        // 入力引数をパラメータとして設定する
        setString("fileName", fileName);

        // 入力引数をログに記録する
        exportCsvFile(createCsvFilePath());
    }

    @Override
    protected void initializeParameterUnitList() {

        // 入力引数のパラメータユニットを追加する
        addParameterUnit(new ParameterUnit("fileName", "ファイル名", "String"));
    }

    /**
     * 入力パラメータからファイル名を作成し、呼び出し側に戻します。
     *
     * @return ファイル名
     */
    public String getFileName() {
        return getString("fileName");
    }
}

[業務ロジック][機能単位][実装][サービス]ServiceOutputParameter

 サービス出力パラメータクラス。サービス出力結果が、次のサービスの入力となることもあることから、サービス出力についてもログに記録する仕組みが必要であるが、既にServiceParameterクラスにて実装が完了しているため、本クラスは特にメソッドを規定していない。(サービス出力パラメータであることを示すためのプレースホルダとしての役割のみ)

package service.param;

/**
 * サービス出力パラメータ<br>
 * 本クラスは固有の属性を持ちませんが、サービス出力パラメータは<br>
 * 全て本クラスを継承させて作るため、プレースホルダとしての意味を持っています。
 */
public abstract class ServiceOutputParameter extends ServiceParameter {

    /**
     * コンストラクタ
     *
     * @param parameterName
     *            パラメータ名
     */
    public ServiceOutputParameter(String parameterName) {
        super(parameterName);
    }
}

[業務ロジック][機能単位][実装][サービス]ServiceInputParameter

 サービス入力パラメータクラス。バッチ実行を考慮し、ログファイルから入力パラメータを作成するメソッドを持っている。これまでに紹介しているサービス呼び出しの仕組みは、画面操作からでも、バッチからでも同じサービスを呼び出すため、ログにサービス入力内容、サービス出力内容を記録しておけば、問題が起きた状況をいつでも再現できることになる。
 ログの内容からサービス入力パラメータを作成する仕組みを実装することにより、問題状況をバッチで簡単に再現することができるようになる。

package service.param;

import java.io.IOException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;

import util.SvFile;
import exception.ApplicationInternalException;

/**
 * サービス入力パラメータ<br>
 * 本クラスは固有の属性を持ちませんが、サービス入力パラメータは<br>
 * 全て本クラスを継承させて作るため、プレースホルダとしての意味を持っています。
 */
public abstract class ServiceInputParameter extends ServiceParameter {

    /**
     * コンストラクタ
     *
     * @param parameterName
     *            パラメータ名
     */
    public ServiceInputParameter(String parameterName) {
        super(parameterName);
    }

    /**
     * CSVファイルパスで示されるCSVファイルを開き、パラメータユニットの 設定に従ってサービス入力パラメータを設定します。
     *
     * @param filePath
     *            CSVファイルパス
     * @param skipFirstLine
     *            最初の行を読み飛ばす場合はtrue、そうでなければfalse
     */
    public void importCsvFile(String filePath, boolean skipFirstLine) {

        try {
            // CSVファイルを開き、内容をロードする
            SvFile svFile = new SvFile();
            svFile.loadFile(filePath, ",", skipFirstLine);

            // パラメータユニット設定に従い、全ての入力パラメータをCSVファイルから取得する
            importSvFile(svFile, 0);

        } catch (IOException e) {
            throw new ApplicationInternalException(e);
        }
    }

    /**
     * CSVファイルパスで示されるCSVファイルを開き、パラメータユニットの 設定に従ってサービス入力パラメータを設定します。
     * CSVファイルパスのみを指定した場合、CSVファイルの最初の1行はヘッダ行として読み飛ばされます。
     *
     * @param filePath
     *            CSVファイルパス
     */
    public void importCsvFile(String filePath) {

        // デフォルトでは最初の1行をヘッダ行とみなす
        importCsvFile(filePath, true);
    }

    /**
     * TSVファイルパスで示されるCSVファイルを開き、パラメータユニットの 設定に従ってサービス入力パラメータを設定します。
     *
     * @param filePath
     *            TSVファイルパス
     * @param skipFirstLine
     *            最初の行を読み飛ばす場合はtrue、そうでなければfalse
     */
    public void importTsvFile(String filePath, boolean skipFirstLine) {

        try {
            // TSVファイルを開き、内容をロードする
            SvFile svFile = new SvFile();
            svFile.loadFile(filePath, "\t", skipFirstLine);

            // パラメータユニット設定に従い、全ての入力パラメータをCSVファイルから取得する
            importSvFile(svFile, 0);

        } catch (IOException e) {
            throw new ApplicationInternalException(e);
        }
    }

    /**
     * TSVファイルパスで示されるCSVファイルを開き、パラメータユニットの 設定に従ってサービス入力パラメータを設定します。
     * TSVファイルパスのみを指定した場合、TSVファイルの最初の1行はヘッダ行として読み飛ばされます。
     *
     * @param filePath
     *            TSVファイルパス
     */
    public void importTsvFile(String filePath) {

        // デフォルトでは最初の1行をヘッダ行とみなす
        importTsvFile(filePath, true);
    }

    /**
     * SVファイル(CSV or TSV)から入力パラメータを取得します。
     *
     * @param svFile
     *            SVファイル
     * @param lineIndex
     *            行インデックス(先頭行を読み飛ばさない場合は1行目が0、先頭行を読み飛ばす設定の場合は2行目が0となる)
     */
    public void importSvFile(SvFile svFile, int lineIndex) {

        // SVファイルの内容を取得する
        List<List<String>> svLineList = svFile.getSvLineList();

        // パラメータユニット設定に従い、全ての入力パラメータをTSVファイルから取得する
        for (int i = 0; i < getParameterUnitList().size(); i++) {

            // パラメータユニットの列とSVカラムの並びは同じであると仮定する
            ParameterUnit parameterUnit = getParameterUnitList().get(i);
            List<String> svLine = svLineList.get(lineIndex);

            // 型に応じた入力値設定を行う
            if ("String".equalsIgnoreCase(parameterUnit.getType())) {
                String value = svLine.get(i);
                setString(parameterUnit.getPhysicalName(), value);
            }
            if ("int".equalsIgnoreCase(parameterUnit.getType())
                    || "Integer".equalsIgnoreCase(parameterUnit.getType())) {
                Integer value = Integer.valueOf(svLine.get(i));
                setInt(parameterUnit.getPhysicalName(), value);
            }
            if ("long".equalsIgnoreCase(parameterUnit.getType())
                    || "Long".equalsIgnoreCase(parameterUnit.getType())) {
                Long value = Long.valueOf(svLine.get(i));
                setLong(parameterUnit.getPhysicalName(), value);
            }
            if ("Double".equalsIgnoreCase(parameterUnit.getType())) {
                Double value = Double.valueOf(svLine.get(i));
                setDouble(parameterUnit.getPhysicalName(), value);
            }
            if ("Date".equalsIgnoreCase(parameterUnit.getType())) {
                Date value = getDateByDateString(svLine.get(i));
                setDate(parameterUnit.getPhysicalName(), value);
            }
        }
    }

    /**
     * 日付文字列を解釈して、Date型に変換します。 許容する書式は次の通りです。<br>
     * 1. yyyy/MM/dd HH:mm:ss<br>
     * 2. yyyy/MM/dd<br>
     * 3. yy-MM-dd HH:mm:ss<br>
     * 4. yy-mm-dd<br>
     *
     * @param dateString
     *            日付文字列
     * @return 日付
     */
    private Date getDateByDateString(String dateString) {

        try {
            // 許容する日付書式を列挙する
            String[] formats = new String[] { "yyyy/MM/dd HH:mm:ss",
                    "yyyy/MM/dd", "yy-MM-dd HH:mm:ss", "yy-mm-dd" };

            // 許容する日付書式で日付と解釈できるかどうか試す
            Date date = null;
            ParseException lastException = null;
            for (int i = 0; i < formats.length; i++) {

                try {
                    // 日付と解釈できる文字列であればreturnする
                    SimpleDateFormat sdf = new SimpleDateFormat(formats[i]);
                    date = sdf.parse(dateString);
                    return date;

                    // 最後の例外を記憶しておく
                } catch (ParseException e) {
                    lastException = e;
                }
            }

            // ここまでreturnできていなければ最後の例外をスローする
            throw lastException;

        } catch (ParseException e) {
            throw new ApplicationInternalException(e);
        }
    }
}

[業務ロジック][機能単位][実装][サービス]ApplicationInternalException

 前述したServiceParameterクラスで使う、アプリケーション内部例外。Javaの共通機能が出した例外だが、それが業務ロジックの実行中に発行されたものであることを明示するために、独自のクラスを定義している。このクラスは所定の例外について、業務ロジックがハンドリングしていることを示すためのものである。設計手法によるが、IOExceptionなどのJava内部例外はそのままスローしても構わない。

package exception;

/**
 * アプリケーション内部例外
 */
public class ApplicationInternalException extends RuntimeException {

    /** デフォルトシリアルバージョン */
    private static final long serialVersionUID = 1L;

    /**
     * アプリケーション内部例外を初期化します。
     *
     * @param e
     *            例外
     */
    public ApplicationInternalException(Exception e) {

        // スーパークラスのコンストラクタを呼び出す
        super(e);
    }

    /**
     * アプリケーション内部例外を初期化します。
     *
     * @param msg
     *            エラーメッセージ
     */
    public ApplicationInternalException(String msg) {

        // スーパークラスのコンストラクタを呼び出す
        super(msg);
    }
}

[業務ロジック][機能単位][実装][サービス]BusinessRuleViolationException

[業務ロジック][機能単位][実装][サービス]BusinessRuleViolationException
 前述したServiceParameterクラスで使う、ビジネスルール違反例外。Javaの共通機能が出す例外ではなく、業務ロジックがビジネスルールに違反したために出た例外であることを示すため、独自のクラスを定義している。

package exception;

/**
 * ビジネスルール違反例外
 */
public class BusinessRuleViolationException extends Exception {

    /** デフォルトシリアルバージョン */
    private static final long serialVersionUID = 1L;

    /**
     * ビジネスルール違反例外を初期化します。
     *
     * @param e
     *            例外
     */
    public BusinessRuleViolationException(Exception e) {

        // スーパークラスのコンストラクタを呼び出す
        super(e);
    }

    /**
     * ビジネスルール違反例外を初期化します。
     *
     * @param msg
     *            エラーメッセージ
     */
    public BusinessRuleViolationException(String msg) {

        // スーパークラスのコンストラクタを呼び出す
        super(msg);
    }
}

[業務ロジック][機能単位][実装][サービス]PropUtil

 前述したServiceParameterクラスで使う、プロパティユーティリティ。ログ出力先など、プロパティファイルとして内容を管理すべき事項がある場合、プロパティファイルにアクセスできるようにユーティリティクラス化されている。プロパティファイルを読むだけであるため、シングルトンクラスとしている。
 シングルトンクラスを使う場合は、静的領域にあるクラスはガベージコレクションの対象にならないことに注意する。静的クラスがマップやリストを持っており、そこのマップやリストが更に別のマップやリストの一部となっている場合、階層配下のものも含めて全てシングルトンクラスが使っている状態と判断され、通常ならばガベージコレクションでメモリが解放されるところ、いつまでもメモリが解放されずに OutOfMemoryException を起こすことになる。
 原則として静的領域にマップやリストなどを持たない、必要以上にシングルトンなどの静的クラスを作らないことが重要である。

package util;

/**
 * プロパティユーティリティ
 */
public class PropUtil {

    /** プロパティ */
    private static Prop prop;

    /**
     * アプリケーションプロパティから値を取得します。
     *
     * @param key
     *            キー
     * @return アプリケーションプロパティの値
     */
    public static String get(String key) {
        if (prop == null) {
            prop = new Prop("metaedit.properties");
        }
        return prop.get(key);
    }

    /**
     * シングルトンのためのプライベートコンストラクタです。
     */
    private PropUtil() {
    }
}

[業務ロジック][機能単位][実装][サービス]ParameterUnit

 前述したServiceParameterクラスで使う、ここのパラメータ単位もクラスとして規定する。パラメータは物理名、論理名、型を持っており、サービスパラメータクラス内の各項目がどのような意味を持っているのか、ログに出力できるようになっている。

package service.param;

/**
 * パラメータの物理名、論理名、型の組み合わせです。
 */
public class ParameterUnit {

    /**
     * コンストラクタ
     */
    public ParameterUnit(String physicalName, String logicalName, String type) {
        this.physicalName = physicalName;
        this.logicalName = logicalName;
        this.type = type;
    }

    /** パラメータの物理名 */
    private String physicalName = null;

    public String getPhysicalName() {
        return physicalName;
    }

    /** パラメータの論理名 */
    private String logicalName = null;

    public String getLogicalName() {
        return logicalName;
    }

    /** パラメータの型 */
    private String type = null;

    public String getType() {
        return type;
    }
}

[業務ロジック][機能単位][実装][サービス]ServiceParameter

前述したサービス入力パラメータ(ServiceInputParameter)とサービス出力パラメータ(ServiceOutputParameter)には共通する役割(ロール)があるため、スーパークラス(ServiceParameter)を切り出している。

ServiceParameter

├ServiceInputParameter
└ServiceOutputParameter

スーパークラス(ServiceParameter)の実装は、次の通り。

package service.param;

import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import util.PropUtil;
import util.SvFile;
import exception.ApplicationInternalException;

/**
 * サービスパラメータ
 */
public abstract class ServiceParameter {

    /** パラメータ名 */
    private String parameterName = null;

    public String geParameterName() {
        return parameterName;
    }

    public void setParameterName(String serviceName) {
        this.parameterName = serviceName;
    }

    /** パラメータユニットのリスト */
    private List<ParameterUnit> parameterUnitList = null;

    protected List<ParameterUnit> getParameterUnitList() {
        return parameterUnitList;
    }

    protected void setParameterUnitList(List<ParameterUnit> parameterUnitList) {
        this.parameterUnitList = parameterUnitList;
    }

    /** 文字列マップ */
    private Map<String, String> stringMap = null;

    /** 整数マップ */
    private Map<String, Integer> integerMap = null;

    /** 整数(long)マップ */
    private Map<String, Long> longMap = null;

    /** 浮動小数マップ */
    private Map<String, Double> doubleMap = null;

    /** 日付マップ */
    private Map<String, Date> dateMap = null;

    /** オブジェクトマップ */
    private Map<String, Object> objectMap = null;

    /**
     * コンストラクタ
     */
    public ServiceParameter(String parameterName) {

        // メンバを初期化する
        this.parameterName = parameterName;
        parameterUnitList = new ArrayList<ParameterUnit>();
        stringMap = new HashMap<String, String>();
        integerMap = new HashMap<String, Integer>();
        longMap = new HashMap<String, Long>();
        doubleMap = new HashMap<String, Double>();
        dateMap = new HashMap<String, Date>();
        objectMap = new HashMap<String, Object>();

        // パラメータユニットリストを初期化する
        initializeParameterUnitList();
    }

    /**
     * パラメータユニットリストを初期化します。
     */
    protected abstract void initializeParameterUnitList();

    /**
     * パラメータユニットを追加します。
     *
     * @param parameterUnit
     *            パラメータユニット
     */
    protected void addParameterUnit(ParameterUnit parameterUnit) {
        getParameterUnitList().add(parameterUnit);
    }

    /**
     * 文字列を設定します。
     *
     * @param key
     *            キー
     * @param value
     *            設定する文字列
     */
    public void setString(String key, String value) {
        stringMap.put(key, value);
    }

    /**
     * 整数を設定します。
     *
     * @param key
     *            キー
     * @param value
     *            設定する整数
     */
    public void setInt(String key, Integer value) {
        integerMap.put(key, value);
    }

    /**
     * 整数をint型として設定します。
     *
     * @param key
     *            キー
     * @param value
     *            設定する整数(int型)
     */
    public void setIntVal(String key, int value) {
        setInt(key, Integer.valueOf(value));
    }

    /**
     * 整数を設定します。
     *
     * @param key
     *            キー
     * @param value
     *            設定する整数
     */
    public void setLong(String key, Long value) {
        longMap.put(key, value);
    }

    /**
     * 整数をlong型として設定します。
     *
     * @param key
     *            キー
     * @param value
     *            設定する整数(long型)
     */
    public void setLongVal(String key, long value) {
        setLong(key, Long.valueOf(value));
    }

    /**
     * 浮動小数を設定します。
     *
     * @param key
     *            キー
     * @param value
     *            設定する浮動小数点
     */
    public void setDouble(String key, Double value) {
        doubleMap.put(key, value);
    }

    /**
     * 浮動小数をdouble型として設定します。
     *
     * @param key
     *            キー
     * @param value
     *            設定する浮動小数点(double型)
     */
    public void setDoubleVal(String key, double value) {
        setDouble(key, Double.valueOf(value));
    }

    /**
     * 日付を設定します。
     *
     * @param key
     *            キー
     * @param value
     *            設定する日付
     */
    public void setDate(String key, Date value) {
        dateMap.put(key, value);
    }

    /**
     * 日付をlong型として設定します。
     *
     * @param key
     *            キー
     * @param value
     *            設定する日付(long型)
     */
    public void setDateVal(String key, long value) {
        setDate(key, new Date(value));
    }

    /**
     * オブジェクトを設定します。
     *
     * @param key
     *            キー
     * @param value
     *            設定するオブジェクト
     */
    public void setObject(String key, Object value) {
        objectMap.put(key, value);
    }

    /**
     * 文字列を取得します。
     *
     * @param key
     *            キー
     * @return 文字列
     */
    public String getString(String key) {
        return stringMap.get(key);
    }

    /**
     * 整数を取得します。
     *
     * @param key
     *            キー
     * @return 整数
     */
    public Integer getInt(String key) {
        return integerMap.get(key);
    }

    /**
     * 整数をint型として取得します。
     *
     * @param key
     *            キー
     * @return 整数(int型)
     */
    public int getIntVal(String key) {
        return getInt(key).intValue();
    }

    /**
     * 整数を取得します。
     *
     * @param key
     *            キー
     * @return 整数
     */
    public Long getLong(String key) {
        return longMap.get(key);
    }

    /**
     * 整数をlong型として取得します。
     *
     * @param key
     *            キー
     * @return 整数(long型)
     */
    public int getLongVal(String key) {
        return getLong(key).intValue();
    }

    /**
     * 浮動小数を取得します。
     *
     * @param key
     *            キー
     * @return 浮動小数
     */
    public Double getDouble(String key) {
        return doubleMap.get(key);
    }

    /**
     * 浮動小数をdouble型として取得します。
     *
     * @param key
     *            キー
     * @return 浮動小数(double型)
     */
    public double getDoubleVal(String key) {
        return getDouble(key).doubleValue();
    }

    /**
     * 日付を取得します。
     *
     * @param key
     *            キー
     * @return 日付
     */
    public Date getDate(String key) {
        return dateMap.get(key);
    }

    /**
     * 日付をlong型として取得します。
     *
     * @param key
     *            キー
     * @return 日付(long型)
     */
    public long getDateVal(String key) {
        return getDate(key).getTime();
    }

    /**
     * オブジェクトを取得します。
     *
     * @param key
     *            キー
     * @return オブジェクト
     */
    public Object getObject(String key) {
        return objectMap.get(key);
    }

    //
    // 以下、CSVファイル出力は当初出力パラメータのみとしていたが、
    // 入力パラメータもCSVとして残すことでバッチ実行による再現試験を
    // 容易にするため、本クラスにメソッドを移動した。
    //

    /**
     * CSVファイルパスで示されるCSVファイルを開き、パラメータユニットの設定に従ってパラメータを書き込みます。
     * CSVファイルパスのみを指定した場合、CSVファイルの最初の1行はヘッダ行として読み飛ばされます。
     *
     * @param filePath
     *            CSVファイルパス
     */
    public void exportCsvFile(String filePath) {

        // デフォルトでは最初の1行をヘッダ行とみなす
        exportCsvFile(filePath, true);
    }

    /**
     * CSVファイルパスで示されるCSVファイルを開き、パラメータユニットの設定に従ってパラメータを書き込みます。
     *
     * @param filePath
     *            CSVファイルパス
     * @param skipFirstLine
     *            最初の行を読み飛ばす場合はtrue、そうでなければfalse
     */
    public void exportCsvFile(String filePath, boolean skipFirstLine) {

        // パラメータユニット設定に従い、ヘッダ行のデータを作成する
        List<List<String>> svLineList = new ArrayList<List<String>>();
        svLineList.add(getSvFileHeader());

        // パラメータユニット設定に従い、全てのパラメータをSV行データに変換する
        getSvLineList(svLineList);

        // 作成したSV行でファイルを作成し、セーブする
        save(filePath, ",", svLineList);
    }

    /**
     * TSVファイルパスで示されるCSVファイルを開き、パラメータユニットの 設定に従ってサービス入力パラメータを書き込みます。
     * TSVファイルパスのみを指定した場合、TSVファイルの最初の1行はヘッダ行として読み飛ばされます。
     *
     * @param filePath
     *            TSVファイルパス
     */
    public void exportTsvFile(String filePath) {

        // デフォルトでは最初の1行をヘッダ行とみなす
        exportTsvFile(filePath, true);
    }

    /**
     * TSVファイルパスで示されるCSVファイルを開き、パラメータユニットの設定に従ってサービス入力パラメータを書き込みます。
     *
     * @param filePath
     *            TSVファイルパス
     * @param skipFirstLine
     *            最初の行を読み飛ばす場合はtrue、そうでなければfalse
     */
    public void exportTsvFile(String filePath, boolean skipFirstLine) {

        // パラメータユニット設定に従い、ヘッダ行のデータを作成する
        List<List<String>> svLineList = new ArrayList<List<String>>();
        svLineList.add(getSvFileHeader());

        // パラメータユニット設定に従い、全ての出力パラメータをSV行データに変換する
        getSvLineList(svLineList);

        // 作成したSV行でファイルを作成し、セーブする
        save(filePath, "\t", svLineList);
    }

    /**
     * パラメータユニット設定に従い、SVファイルのヘッダを作成します。
     *
     * @return SVファイルのヘッダ行
     */
    public List<String> getSvFileHeader() {

        // パラメータユニット設定に従い、SVファイルのヘッダを作成する
        List<String> svLine = new ArrayList<String>();
        for (int i = 0; i < getParameterUnitList().size(); i++) {

            // パラメータユニットの列とSVカラムの並びは同じであると仮定する
            ParameterUnit parameterUnit = getParameterUnitList().get(i);

            // 型に応じた入力値設定を行う
            String headerElement = parameterUnit.getLogicalName() + "( "
                    + parameterUnit.getPhysicalName() + " : "
                    + parameterUnit.getType() + " )";
            svLine.add(headerElement);
        }

        // 作成したSV行データを呼び出し側に戻す
        return svLine;
    }

    /**
     * SVファイル(CSV or TSV)に追加する出力パラメータ、全行のデータを取得します。
     *
     * @param svLineList
     */
    protected void getSvLineList(List<List<String>> svLineList) {

        // 事前に設定されているデータに基づき、SV行を作成する
        svLineList.add(getSvLine());
    }

    /**
     * SVファイル(CSV or TSV)に追加する出力パラメータ行データを取得します。
     */
    public List<String> getSvLine() {

        // パラメータユニット設定に従い、全ての入力パラメータをTSVファイルから取得する
        List<String> svLine = new ArrayList<String>();
        for (int i = 0; i < getParameterUnitList().size(); i++) {

            // パラメータユニットの列とSVカラムの並びは同じであると仮定する
            ParameterUnit parameterUnit = getParameterUnitList().get(i);

            // 型に応じた入力値設定を行う
            if ("String".equalsIgnoreCase(parameterUnit.getType())) {
                svLine.add(getString(parameterUnit.getPhysicalName()));
            }
            if ("int".equalsIgnoreCase(parameterUnit.getType())
                    || "Integer".equalsIgnoreCase(parameterUnit.getType())) {
                if (getInt(parameterUnit.getPhysicalName()) == null) {
                    svLine.add("");
                } else {
                    svLine.add(getInt(parameterUnit.getPhysicalName())
                            .toString());
                }
            }
            if ("long".equalsIgnoreCase(parameterUnit.getType())
                    || "Long".equalsIgnoreCase(parameterUnit.getType())) {
                if (getLong(parameterUnit.getPhysicalName()) == null) {
                    svLine.add("");
                } else {
                    svLine.add(getLong(parameterUnit.getPhysicalName())
                            .toString());
                }
            }
            if ("Double".equalsIgnoreCase(parameterUnit.getType())) {
                if (getDouble(parameterUnit.getPhysicalName()) == null) {
                    svLine.add("");
                } else {
                    svLine.add(getDouble(parameterUnit.getPhysicalName())
                            .toString());
                }
            }
            if ("Date".equalsIgnoreCase(parameterUnit.getType())) {
                if (getDate(parameterUnit.getPhysicalName()) == null) {
                    svLine.add("");
                } else {
                    svLine.add(getDate(parameterUnit.getPhysicalName())
                            .toString());
                }
            }
        }

        // 作成したSV行データを呼び出し側に戻す
        return svLine;
    }

    /**
     * 引数で 指定されたファイルパス、デリミタ、SV行データリストでSVファイルを作成し、保存します。
     * SVファイルクラスの同名メソッドの単純ラッピングですが、例外をアプリケーション内部例外とし、 スローステートメントを書かなくてよい点が異なります。
     *
     * @param filePath
     *            ファイルパス
     * @param delm
     *            デリミタ
     * @param svLineList
     *            SV行データリスト
     */
    public void save(String filePath, String delm, List<List<String>> svLineList) {

        try {
            // 作成したSV行でファイルを作成し、セーブする
            SvFile svFile = new SvFile();
            svFile.save(filePath, delm, svLineList);

        } catch (IOException e) {
            throw new ApplicationInternalException(e);
        }
    }

    /**
     * CSVファイルパスを作成します。
     *
     * @return CSVファイルパス
     */
    public String createCsvFilePath() {

        // ログ記録の基本パスを取得する
        String logBasePath = PropUtil.get("serviceLog.basePath");

        // 現在日時(yyyy_MM_dd_HH_mm_ss)とサービス名を組み合わせ、CSVファイルパスを作成する
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy_MM_dd_HH_mm_ss");
        return logBasePath + "/" + sdf.format(new Date()) + "_"
                + geParameterName() + ".csv";
    }
}

 ここで主に規定しているのは、サービスパラメータクラスをログに記録するための仕組みである。業務ロジック(サービス)は、入力に対する期待した出力が得られることを以って「正常」と判断できるため、自動テストを実装する場合は入力パラメータと出力パラメータをログに記録し、テキスト比較で妥当性を自動で検証する仕組みが不可欠である。
 そのため業務ロジックに渡すパラメータは入力/出力を問わず、ログ記録の仕組みが必要である。また、サービスパラメータをわざわざ抽象クラスを使ってまで抽象化する理由は、バッチ実行の仕組みを考慮してのことである。サービスをバッチから呼び出すにあたってバッチロジックを毎度実装するのはメンテナンス性の面からしても効率が悪い。入力データも出力データもログに記録できているのだから、サービス名さえ分かればバッチファイルだけでバッチ処理を実行できる仕組みが望ましい。そこでサービスのパラメータを個別のクラスとしてもよいが、もう一歩踏み込んで抽象化された共通のインターフェースとした方がよいと判断し、このようなロジック構成となった。

[業務ロジック][機能単位][実装][サービス]DomainDrivenService

 直前までのいくつかの記事で、大規模システムの設計について記述してきた。本項以降では、実際に呼び出す業務ロジックが実装として最終的にどのようになるか、Javaを使って解説する。ここで紹介するのは1つのサンプルであり、必ずこうでなければならないというものではない。
 業務ロジックは最終的には画面からサーブレットなどを経由してAPIとして呼び出すか、バッチから呼び出すことになる。ソフトウェアの実装としては、両方の呼び出しに最初から対応した形式であると使い勝手が良い。ここでは業務ロジックの機能単位を「サービス」と呼び、サービスを呼び出す方法について記述する。
 まずサービスインターフェースの実装は、次の通りである。

package service;

import service.param.ServiceInputParameter;
import service.param.ServiceOutputParameter;

/**
 * ドメイン駆動サービス
 */
public interface DomainDrivenService {

    /**
     * サービスを実行します。
     *
     * @param sip
     *            サービス入力パラメータ
     * @return サービス出力パラメータ
     */
    public ServiceOutputParameter doService(ServiceInputParameter sip) throws BusinessRuleViolationException, ApplicationInternalException;
}

 ポイントは、次の通り。
 (1) パッケージは「service」などとし、それと分かるところに配置する。
 (2) サービスは、必ずServiceInputParameterを引数とする。
 (3) サービスは、必ずServiceOutputParameterを戻り値とする。
 (4) ビジネスルール違反時、BusinessRuleViolationExceptionをスローする。
 (5) プログラム的なエラーが起きた場合、ApplicationInternalExceptionをスローする。

 上記インターフェースではドメイン駆動の仕組みを使うサービスに、共通のインターフェースを与えるものである。ドメイン駆動は必須ではないが、過去の記事にある通りメンテナンス性が高い手法であるため、採用している。全てのサービス実行を「doService」という名前のメソッドで抽象化している。サービス入力パラメータ、サービス出力パラメータについても同様にクラスとすることで抽象化している。(パラメータのクラスについては、別途掲載する)

[大規模システムの設計]管理単位の決定とメンテナンス

 大規模システムの序盤で考慮しなければならない点について、さらに記述していく。設計者は業務も知り、なおかつソフトウェアの管理についても分かっていなくてはならない。今日のシステム開発においては、経理でも生産管理でも物流でも、ほぼ例外なく業務ロジックに従って永続化データを作成・変更するのが原則である。業務を行った結果のデータを永続化データとしてデータベースに登録し、ステータスの変更を管理したり、大量のデータを元に集計を行ったりする。これは従来電子計算機システムを使わずに行っていた業務を電子計算機によるシステムに置き換え、更にデータ集計など電子計算機を使ったシステムならでは実現できる機能を追加して、よりよい業務の運営を図るものである。あくまでも業務を中心に置き、業務の視点から機能を検討しなければならない。
 使う側にとって分かりやすいように、システムを業務として可視化することが設計者にまず求められる。それゆえ前述したような機能相関図を見やすく、分かりやすいものとして提示しなければならないのである。上位設計での成果物はあくまでも業務に特化したものであり、各機能単位がどのような業務を実現するものなのかは業務ロジックとして記述しなければならない。そのための1つの手法として、オブジェクト指向設計のユースケース図及びユースケース記述がある。
 ユースケースはシステムが受け持つ機能がどこまでかはっきり見えるよう境界線を引くためのものである。こと大規模システムともなれば、ユーザがシステムを操作することはもとより、他のシステムとの連携も考慮しなければならない。ユーザや他システムをアクターとして表現し、システムの各機能をユースケースにまとめていく。これによって、システムが受け持たなければならない責任範囲がどのようなものかを明確化する。たとえば経理データをユーザが手入力しなければならないのか、それとも別のシステムが作ったCSVデータを一定時間ごとに自動で取り込み、エラーが起きた場合だけユーザに入力を促すのか、一口にシステムといっても責任範囲や役割は様々である。システム境界線の明確化は、非常に重要なのである。
 ユースケースを用いない場合でも、システムを大まかな機能単位に分けるのが最上位の設計である。このとき、設計者が注意しなければならないのは、業務として機能分割を考えるということだけではない。メンテナンスと将来拡張についての考慮も行う必要がある。業務ロジックはユーザ入力や他システムからの入力を待つものであってはならない。システムとは「変わっていく」ものである。ユーザの手入力を想定していた機能が「そんな面倒なことやってられない」となって、自動入力に切り替わることは日常茶飯事なのだ。業務ロジックの中で入力待ちを行ってはいけない。業務ロジックとは、ユーザ入力や他システムからの入力があった後に走るロジックのことである。「ユーザは経理データの必要項目を画面で入力する」 → 「システムは仕訳を行う」となり、業務ロジックとして「仕訳を行う」「仕訳」といったものが出てこなければおかしい。全ての業務の流れをそのまま業務ロジックにしてしまうと、硬直化した変更しづらいシステムになってしまう。仮に業務の流れ全てを1つのサービスにするとしても、その中でサブ機能呼び出しを行うように設計しなければ、ちょっとした変更にも耐えられないシステムになってしまう。

 (1) ユーザは、経理データを入力する。
 (2) システムは、仕訳を行う。
 (3) システムは、仕訳結果を表示する。
 (4) ユーザは、仕訳結果を確認し、問題なければ仕訳の確定を行う。
 (5) システムは、仕訳結果を保存する。

 たとえばユースケース記述がこのようになっていた場合、(2)(3)(5)は全て異なる業務ロジックとすべきである。(2)と(3)を分けないと仕訳をバッチ機能から使えなくなるし、(2)と(5)を分離しないと業務ロジックとしての「仕訳」と永続化層への保存(データベースへの登録)が同じ業務になってしまう。間違いがあった場合に問題となるため、分ける必要があるだろう。
 システムを使う側が電子計算機の技術者でなければ、(2)(3)(5)の違いは分からない。だからこそ、そのような内部構造を知らない人にも分かりやすいよう、業務ロジックを分解しておく必要がある。上記のような記載を行って初めて、(4)の段階で操作を中断したら仕訳結果がデータベースに残らないことが分かるのである。
 またこのように業務ロジックを策定していくに当たっては、常に「管理単位」を意識すべきである。業務ロジックは、システムの内部を知らない人にも分かりやすく分割するものであり、かつシステムとして今後メンテナンスしていく単位でもあるからである。前述した通り、数千数万にも及ぶサブルーチン(クラス、メソッド、マクロ等)が存在する大規模システムが管理可能性を失わないためには、全ての機能単位でテストを自動化しておく必要がある。
 上記(1)~(5)のユースケース記述を見て、自動テストを組む単位(管理単位)を考えておかなければならない、ということである。この場合、ユーザ入力の(1)と(4)を自動化しないと、テストを自動化することは不可能である。ユーザ入力は、呼び出す業務ロジックに対する入力に置き換えることができるので、このような場合は次のような自動テストケースを想定する。

  A. ユーザが正しい経理データを入力した想定で、(2)の仕訳を実行する。
  B. ユーザが不正な経理データを入力した想定で、(2)の仕訳を実行する。
  C. ユーザが仕訳を確定した想定で、(5)の保存を実行する。
  D. ユーザが仕訳を確定しない想定で、(5)でも保存を実行しない。

 (2)と(5)の業務ロジックの入力データをあらかじめ用意しておくことで、ユーザ入力がなくても自動でテストを実施することができる。上記D.により仕訳結果の保存だけでなく、ユーザによるキャンセルも機能として必要であることが分かる。このような場合はユースケースなら例外処理の項に記述する。
 以上、大規模システムの設計について具体例を交えて概説してきた。ポイントをまとめると、次の通り。

   (A) システム化対象の業務全体を7~10程度の機能単位に分割する。
   (B) 必要に応じて階層的に更に機能単位を分割する。
   (C) 各機能単位では業務の観点で、業務ロジック及び永続化データを策定する。
   (D) 全ての機能単位において、テスト自動化等のメンテナンス性を考慮する。

 どのように規模が大きく複雑なシステムであっても、最終的には管理可能な、そして理解可能なコードの集合体として管理・運営されるものであることを忘れないようにしたい。それは上記(A)~(D)のような、当たり前の手続きを丁寧に行っていくことで、必ず実現することができる。設計者が最も注意しなければならないのは、管理可能性を失っていないか常に確認することである。

2015年6月7日日曜日

[大規模システムの設計]特性の把握(2)

 前述した3つの記事は、いずれも概念的な説明に終始していた。「では具体的にどうするのか」という点についての記述していく。
 どのような大規模システムにも共通する汎用的な技法は限られているが、概ね次の通りである。

 (1) システムの大枠を7~10程度のサブシステムに分割する。
 (2) 必要に応じて、更に階層的にサブシステムを分割する。
 (3) 各サブシステムはAPIもしくはバッチから実施可能なエントリを持たなければならない。
 (4) 各サブシステムは、分割した全ての単位において入力に対する出力を想定したテストを作成する。
 (5) 上記(4)のテストは全て自動で実施し、結果の検証も自動でできなければならない。
 (6) 上記(5)の自動テストは、概ね3時間以内に完了できなければならない。

 あなたが大規模システムの設計者で、2つ以上クラスが上の上司(たとえば専務取締役、常務取締役、執行役員など)に、システム概要を説明しなければならないとき、いきなりA3かA2のビッグサイズの紙に数百に及ぶ機能を矢印で結んだ資料を出してはいけない。
 先にも解説した通り、大規模システムとはいかなるときでも管理可能性を失ってはならず、管理する側にとって煩雑なものであってはならないのである。人間が一度に把握でき記憶できる事項は多くて7~10程度、これを大きく逸脱した機能分割の図を出すのは危険である。数百もの機能が書かれた図を出されて、すぐにそれを理解しなさいというのは、管理可能性をその時点で失っているものと見なされる。
 少しでも「他人が見る」ということを考慮しているならば、大規模システムの機能分割は階層的なものにせねばならず、各階層も10~20の数を超えた複雑なものをなるべく避けるべきであるということが分かるだろう。A3の紙にびっしり書かれた数百の機能相関図を見て、「優秀だ」と判断されることはない。階層的に書かれた、見る人にとって分かりやすい程度の矢印で結ばれている機能相関図を出して初めて、レビューがスタートするのである。
 その他の事項についても、補足説明を記述する。
 上記(3)は実装を意識した不正な設計ではないので注意されたい。最終的にWindowsタスクスケジューラ、cron、JP1などで呼び出す単位を決める、という意味である。この単位はサービスと言ったり、ジョブといったり、タスクといったり、プロジェクトによりまちまちであるが、設計書と実際に実行するプログラム単位が揃っていないと混乱を招くので、この条項は必須である。「設計は実際にプログラムを実行する単位の記述ではありません。JP1で実施する実際のソフトウェアと、設計は別物です」というのは設計者としては正しいかも知れないが、管理者からは煙たがられるだろう。呼び出す機能単位もパラメータ分からず、JP1のジョブ設計などできないからである。このようなプロジェクトに混乱をもたらす設計及びその成果物は、いずれ意味をなさない資料とならざるを得ない。
 上記(4)~(6)は、大規模システムの変更可能性を最大限にするための仕組みである。JUnitテストに代表されるホワイトボックステストではないので注意されたい。もちろん条件分岐網羅の回帰テストができることは必要であるが、それに加えて更に、実装ではなく設計レベルの回帰テストの仕組みも用意する、ということである。
 JUnitその他のツールによるテストはホワイトボックステストであり、実装の構造に準じて作られるものであるが、上記(4)~(6)のテストはブラックボックステストである。「設計に定める機能仕様からすると、この入力に対してはこの出力、この入力に対してはこのエラーが出るはずである」といったことを自動テストとしてまとめていくものである。
 このテストは分割した機能の全てについて実施しなければならない。たとえば最上位を7つの単位に分割し、それをさらに5~10くらいの単位に分割する階層構造があった場合、最上位の7つの単位にも自動テストが必要であり、その下にある全ての階層にも自動テストが必要である。これは機能の再利用性にとって、非常に重要だからである。プログラムの変更を考えるとき、「既存機能を呼び出したい」というニーズが必ずあるはずである。このようなとき、機能単位ごとに完全な回帰テストの仕組みを持っていれば、既存機能を呼び出して意図した動きとなるか、それともインターフェース等で問題が起きるか、すぐにテストすることができる。この仕組みを持つことによって初めて、数百から数千に分割された機能を、途中のインターフェース等の問題を気にすることなく、呼び出すことができるようになる。もし問題が起きたら、既存機能のコードを流用して問題が起きない新規コードを作成し、既存機能をそのまま使ってよいという結論に到達したら、新しく追加した機能(もしくは変更した機能)そのものの回帰テストを新たに作り直せばよい。
 また上記(5)~(6)にあるが回帰テストは完全に自動化し、3時間程度で実施できなければならない。つまり、システム全体の完全なブラックボックステストを毎日実施できるようにしなければならないということである。もしもバグが出たのならば、そのバグを検証するためのテストを追加していくことにより、品質はほぼ完全にバグがゼロの、理想的な状態に近づけることができる。JUnit等による実装レベルでのホワイトボックステスト、機能単位で実施するブラックボックステスト、過去に起きたバグが起きないことを検証するための回帰テストを随時、短時間で実施できる仕組みを整えることで、大規模システムの品質を常に安全な状態に保つことができる。
 大規模システムは後戻りができない厳しいものであるからこそ、このような初期段階での考慮・ノウハウを大切にする必要がある。その他、プロジェクトの特性に応じて考慮しなければならない点は数多く存在する。携帯電話のハードウェアを駆動するためのミドルウェアと、JavaによるWebアプリケーションでは、同じ大規模システムでも全く異なる特性があることに気付くだろう。上記に挙げたような最終的な品質や、その後のメンテナンスを左右する大切な要素を決して忘れることなく、なおかつプロジェクトの特性に合わせた設計前の考慮事項を検討していかなければならない。

[大規模システムの設計]特性の把握(1)

 大規模システムは一般に、変更が難しいものとされる。数万~数十万、ときに数百万ステップに及ぶ実装コードで作られた複雑な仕組みは、一度組み上げてしまうと依存関係を持つモジュールが非常に多くなり、ほんの少し変えるにも多大な労力を必要とするからである。
 しかしながら、余りに硬直してしまったシステムはメンテナンスに重大な影響を及ぼしかねない。システムはライフサイクルを持つ「生き物」であり、時代の流れや求められる機能の変化に対応できるものでなければならない。たとえば、20年前ならまだフロッピーディスクにバックアップデータを出力するプログラムや、フロッピーディスクからバッチの入力データを受け付けるシステムは成立しただろう。だが現在ではそもそも、フロッピーディスクにPCが対応していないことが多く、今後も対応が打ち切られる方向性であることは確実である。
 これは何もフロッピーディスクという今となっては珍しくなった機器だけにいえることではない。SCSIやRS232C、ディスプレイのアナログ入力ポート、昔のプリンタポートのように、以前は当たり前だったが今ではサポートされないことが多くなっている機器は多い。システムは変化に柔軟に対応できるものでなければならない。
 すなわち、メンテナンス性という側面を無視して、大規模システムの設計を始めてはならないのである。将来どのように変化に対応していくかを考えた構造をあらかじめ決めておかなければ、硬直化した何万ステップというソフトウェアだけが後に残される結果となる。こうなってからでは全体の構造を変えることは困難である。
 しかしながら非常に残念なことに、大規模システムは変更が難しいという特性を考えずに設計が進んでしまっている例が散見される。何億、何十億という開発費を投じて作成するものがどうして硬直化したシステムになってしまうか、その理由は簡単である。設計技法を解説した書物に「設計は実装の影響を受けるものであってはならず、実装の都合に合わせて設計を変えてはいけない」という原理原則が書かれているからである。実装の都合で設計を変えてはならないという原理原則が、メンテナンス性の考慮を「実装の都合に合わせる不正行為」と考えさせてしまうのである。結果、設計とは理想形の業務実現にのみ特化したものでなければならない、という理屈となり、メンテナンス性はプロジェクトが始まった当初から無視される。優秀な実装者、実装も分かる優れた設計者が途中で何とか修正してくれればよいが、そのような途中修正はそもそも「ルール違反」である。原理原則に従って厳格に進めていくプロジェクトならば、そもそもメンテナンス性を先に考慮しなければならない。
 こと大規模システム開発となれば、ルール作り・ルールの順守がともに重要であることは疑いがない。だからこのような原理原則を定め、それに従うことはとても重要である。しかし間違えてはいけないことがある。大規模システム開発を命じる企業その他組織体の経営陣は、硬直化したメンテナンス困難な産物にお金をかけようとしているわけではない。些細に思えることが、後で致命的な事態を招きかねないのが、大規模システムの特性である。この場合、メンテナンス性を事前に考慮する必要があった。大規模システム開発は、考えもなく簡単に進めてはいけないのである。こうしたノウハウを持っていなければ、後から致命的な事態になって、しかもその状況をどうにもできなくなる。
 大規模システムの特性について解説してきた。
 (1) 最初の段階で考慮抜けや判断ミスがあると、大規模システムそのものがダメになる。
 (2) 最初にルールを決め、それを守らなければ大規模システム開発は混乱する。
 何億、何十億というお金を投じる開発プロジェクトの責任を持つことは容易ではない。ここには必ず専門的なノウハウが必要になるのである。