RAKUS Developers Blog | ラクス エンジニアブログ

株式会社ラクスのITエンジニアによる技術ブログです。

GoFの次に覚えたいデザインパターン ~Type Objectパターン~

ユーザーによるカスタマイズ

楽楽精算開発部の id:smdr3s です。主に Java を使ったサーバーサイドを担当しています。

弊社のサービスである 楽楽精算 は、その名のとおり経費精算のサービスです。 交通費や出張費、交際費といったさまざまな経費を申請でき、上司や経理担当部署による承認を経て、経理処理の完了までをサポートしています。

基本的には経費精算に関わる処理が主な機能ですが、上記中の申請~承認の処理、いわゆる承認フローを経費精算以外の業務に使用することもできます。この機能は 汎用ワークフロー と呼ばれ、お客様(管理者ユーザー)が自由に申請書の種別を作成、中身をカスタマイズし、承認フローを関連づけておくことができます。
例えば稟議書、押印申請、重要資産アクセス申請など、さまざまな種別の申請書を作成できます。 そして(一般ユーザーが)それぞれの種別の申請書を使用して申請を行い、設定された承認フローに沿って承認、決裁を行うことができます。

このように、ビジネスアプリケーションではユーザーが自由にカスタマイズした雛形を作成し、その雛形から多数の書類を作成する、といった要求があることがあります。
特にB2BSaaSプロダクトではユーザー企業ごとに規程が異なるものの、個別に実装を行うことはないため、ユーザー自身がカスタマイズを行える機能の必要性は高まります。

型と実体(クラスとインスタンス

さて、多くのアプリケーションでは、決められた型に沿った実体を生成し、その実体を処理しています。 クラスベースのオブジェクト指向言語で書かれたプログラム上では、型がクラス、実体がインスタンスになります。

経費精算アプリケーションで言えば「交通費精算申請書」という「型」を表すクラスが実装されており、個々の申請ごとにこのクラスの実体であるインスタンスを作成(new)して処理します。

一方、先ほどの汎用ワークフロー機能で作成する申請書の種別のような、ユーザーが自由に作成する「型」は、そのままクラスとして実装することはできません。 クラスは実装時に作成する必要がありますが、その仕様は実行時にユーザーが指定するまでわからないためです。

Type Object パターン

このような場合に役立つのが Type Object パターン です。

Type Object パターンでは、実行時に作成された「型」の情報を「オブジェクト」に入れて保持し、そのオブジェクトを他のオブジェクト(インスタンス)から参照させることで「型」としての情報を与えます。型のメタ情報をオブジェクトにしているとも言えます。

経費精算アプリケーションを例に、Type Object パターンを使用してみます。

アプリケーションの要件は以下のとおりです。

  • 既定で交通費精算と経費精算の申請書を作成できる。
    • 交通費精算の申請書には「行先、交通機関、金額」の入力項目がある。
    • 経費精算の申請書には「品名、単価、数量、金額」の入力項目がある。
  • 自由に申請書の種別を追加できる。(カスタム申請書)
    • カスタム申請書には、申請書名を設定できる。
    • カスタム申請書には、任意の数の入力項目を追加でき、それぞれに項目名を設定する。

申請書クラス

まずは各申請書の共通の構成を抜き出し、それをもとに申請書の親クラスを作成します。

  • 申請書名(申請書の種別ごとに決まっている)
  • 複数の入力項目
    • 項目名(申請書の種別ごとに決まっている)
    • 項目値(個々の申請書ごとに入力する)
public abstract class Application {
    private Map<String, Object> fields = new HashMap<>();   // 入力項目

    public abstract String getApplicationName();    // 申請書名
    public abstract String[] getFieldNames();       // 項目名リスト

    // 項目値設定
    public void setField(String name, Object value) {
        if (Arrays.asList(getFieldNames()).contains(name)) {
            fields.put(name, value);
        }
    }

    // 項目値取得
    public Object getFieldValue(String name) {
        return fields.get(name);
    }
}

既定の申請書のクラス

申請書の種別=型、と考えると、既定の種別の申請書であれば型の仕様はすでに確定しているため、クラスを実装することが可能です。

交通費精算クラスや経費精算クラスは以下のように実装できます。

// 交通費精算クラス
public class TransportApplication extends Application {
    @Override
    public String getApplicationName() {
        return "交通費精算";
    }

    @Override
    public String[] getFieldNames() {
        return new String[] {"行先", "交通機関", "金額"};
    }
}

// 経費精算クラス
public class ExpenseApplication extends Application {
    @Override
    public String getApplicationName() {
        return "経費精算";
    }

    @Override
    public String[] getFieldNames() {
        return new String[] {"品名", "単価", "数量", "金額"};
    }
}

もちろん、これらのクラスは newインスタンスを作成することができ、そのインスタンスは当然そのクラスの情報を持っています。

public class Main {
    public static void main(String[] args) {
        // 交通費精算の申請書インスタンスを作成
        Application app1 = new TransportApplication();
        // 交通費精算の申請書インスタンスは交通費精算の型の情報を持つ
        System.out.println(app1.getApplicationName());  // "交通費精算"
        System.out.println(app1.getFieldNames()[0]);    // "行先"

        // 経費精算の申請書インスタンスを作成
        Application app2 = new ExpenseApplication();
        // 経費精算の申請書インスタンスは経費精算の型の情報を持つ
        System.out.println(app2.getApplicationName());  // "経費精算"
    }
}

カスタム申請書のクラスの検討

次にカスタム申請書の実装を行います。 当然、カスタム申請書は実装時には申請書の型の仕様が決まっていないため、種別ごとの値をハードコードすることはできません。

// カスタム申請クラス(実装不可)
public class CustomApplication extends Application {
    @Override
    public String getApplicationName() {
        return ????;    // 実装時に申請書名は不明
    }

    @Override
    public String[] getFieldNames() {
        return ????;    // 実装時にどんな項目があるか不明
    }
}

申請書の実体生成時にカスタム申請書の型の情報を渡して直接インスタンスを生成するのはどうでしょうか。

// カスタム申請クラス(直接生成)
public class CustomApplication extends Application {
    private final String applicationName;   // カスタム申請書の申請書名
    private final String[] fieldNames;      // カスタム申請書の項目名リスト

    // 生成時に型の情報を渡す
    public CustomApplication(String applicationName, String[] fieldNames) {
        this.applicationName = applicationName;
        this.fieldNames = filedNames;
    }

    @Override
    public String getApplicationName() {
        return applicationName;
    }

    @Override
    public String[] getFieldNames() {
        return fieldNames;
    }
}

これでもインスタンスにカスタム申請書の型の情報を持たせることはできていますが、これは単に「カスタム申請クラスのインスタンス」を作成しているだけになっています。そのインスタンスがどのカスタム申請書の種別か、すなわち「型」自体の情報がなくなってしまっています。
(この例では applicationName でカスタム申請書の種別を判別できる可能性がありますが、それはたまたまそうなっているだけで、型の識別子としては不十分です。)

Type Object パターンを適用

そこで、Type Object パターンを適用します。

まず、「型」を表すクラスを作成します。ここではカスタム申請書の種別ごとの「型」ですので、その設定を入れられるクラスにします。

// カスタム申請書の型クラス
public class CustomType {
    private final String applicationName;   // カスタム申請書の申請書名
    private final String[] fieldNames;      // カスタム申請書の項目名リスト

    // 生成時に型としての情報を渡し、保持する
    public CustomType(String applicationName, String[] fieldNames) {
        this.applicationName = applicationName;
        this.fieldNames = fieldNames;
    }

    public String getApplicationName() {
        return applicationName;
    }

    public String[] getFieldNames() {
        return fieldNames;
    }
}

そして、この型クラスのオブジェクトへの参照を持つカスタム申請書クラスを作成します。

// カスタム申請書クラス
public class CustomApplication extends Application {
    private final CustomType customType;    // 型オブジェクトへの参照を持つ

    // 生成時に型オブジェクトを渡し、型を持たせる
    public CustomApplication(CustomType customType) {
        this.customType = customType;
    }

    @Override
    public String getApplicationName() {
        return customType.getApplicationName();
    }

    @Override
    public String[] getFieldNames() {
        return customType.getFieldNames();
    }
}

基本的なクラスの準備は以上です。実際にカスタム申請書の種別を作成し、その申請書のインスタンスを作成してみます。

public class Main {
    public static void main(String[] args) {
        // カスタム申請書の種別「稟議書」を作成
        CustomType proposal = new CustomType("稟議書", new String[] {"件名", "内容"});

        // 稟議書の申請書インスタンスを作成
        CustomApplication app1 = new CustomApplication(proposal);
        // 稟議書の申請書インスタンスは稟議書の型の情報を持つ
        System.out.println(app1.getApplicationName());  // "稟議書"
        System.out.println(app1.getFieldNames()[0]);    // "件名"

        // 同じ種別の複数のインスタンスを作成可能
        CustomApplication app2 = new CustomApplication(proposal);

        // 別の種別「押印申請」を作成可能
        CustomType stamp = new CustomType("押印申請", new String[] {"書類種別", "相手方社名"});
        // 押印申請の申請書インスタンスを作成
        CustomApplication app3 = new CustomApplication(stamp);
        // 押印申請の申請書インスタンスは押印申請の型の情報を持つ
        System.out.println(app3.getApplicationName());  // "押印申請"
    }
}

CustomTypeインスタンスに注目してください。 これが、型を表すオブジェクト Type Object として CustomApplicationインスタンスにカスタム申請書の種別という「型」を与えています。 これにより CustomApplicationインスタンスは与えられた型の情報を持つようになっています。

型を表すオブジェクトである CustomTypeインスタンスは使い回しが可能です。 (むしろ同じ型であれば CustomTypeインスタンスは同一であることが望ましいです。システム全体でユニークとなるよう管理が必要です。)

このコードでは説明のため CustomApplicationインスタンスCustomApplication として扱っていますが、もちろん親クラスである Application として扱うこともできます。そうすればカスタム申請書の各種別も、既定の申請書も同一のインターフェースで扱うことができ、ポリモーフィズムが捗ります。

まとめ

Type Object パターン を使用すると、実行時に動的に型を作成し、その型のオブエクトを作成することができます。
作成した型は一般的なオブジェクトですので容易に管理、再利用が可能です。

アプリケーションに動的にひな形を作成するカスタマイズ性が求められたときには、Object Typeパターンで型管理を導入できないか、ぜひ検討してみてください。

関連するデザインパターン

Flyweight パターン

共通のインスタンスを他のクラスの複数のインスタンスから参照するという点は Flyweight パターンと共通です。 また、型オブジェクトを型ごとにシングルトンにする際に Flyweight パターンの実装が参考になるかもしれません。

Interpreter パターン、Command パターン

型ごとにクラスを実装する際には実装クラスごとにメソッドに自由にロジックを記述することができますが、Type Object パターンでは個別にメソッドを記述することはできないため、型オブジェクトごとにロジックをカスタマイズすることはできません。
型オブジェクトを作成する際に設定できる情報はコンストラクタメソッドに渡せるような第一級オブジェクトに限られますので、型ごとにロジックを変えたい場合は Interpreter パターンや Command パターンなどと組み合わせるのが有効です。

参考文献

  1. Nystorm, Robert. Game Programming Patterns. Genever Benning, 2014, 354p. https://gameprogrammingpatterns.com/
  2. Johnson, Ralph; Woolf, Bobby. "The Type Object Pattern". 1996. http://www.cs.ox.ac.uk/people/jeremy.gibbons/dpa/typeobject.pdf
  3. Gamma, Erich; Helm, Richard; Johnson, Ralph; Vlissides, John. オブジェクト指向における再利用のためのデザインパターン(改訂版). 吉田和樹, 本位田真一監修. SBクリエイティブ, 1999, 418p.
Copyright © RAKUS Co., Ltd. All rights reserved.