くらしのマーケット開発ブログ

「くらしのマーケット」を運営する、みんなのマーケット株式会社のテックブログ。積極採用中です。

設計の原則の紹介とくらしのマーケットのシステム

f:id:curama-tech:20181220111408p:plain

自称webエンジニアの高橋です。ソフトウェアを設計するにあたってよく知られている原則に、SOLID原則というものがあります。これは、

  • 単一責任の原則(Single Responsibility Principle)
  • オープン・クローズドの原則(Open-Closed Principle)
  • リスコフの置換原則(Liskov Substitution Principle)
  • インターフェース分離の原則(Interface Segregation Principle)
  • 依存関係逆転の原則(Dependency Inversion Principle)

の5つの原則の頭文字を並べたものです。

本記事では、くらしのマーケットのプロダクションコードの一部を例/反例として、上の原則の紹介をしてみます。なお、以下に紹介するコードは全てTypeScriptです。

単一責任の原則

単一責任の原則はモジュール(クラス)を変更する理由は1つ以上存在してはならないという原則です。 例えば以下のようなコードがAngularのコンポーネントとして定義されているとします。

@Component({
    selector: "terry-ticket-list",
    templateUrl: "./terryTicketList.html",
    styleUrls: ["./terryTicketList.scss"]
})
export class TerryTicketListComponent implements OnInit {
    // 省略

    private getTerryTickets(
        params: GetTerryTicketListParameters,
    ): void {
      // 100行に渡るajax処理
    }
}

Angularのコンポーネントはviewに対する最小限のロジックのみを書くべきですが、このクラスではデータ取得処理を長々とコンポーネントに記述してしまっています。 例えばこの状態で、データ取得処理のモジュールを入れ替えたいという要件が発生した場合(例えば@angular/http@angular/common/httpに変更したいときなど!)、 viewには直接関係ない理由でこのコンポーネントを修正する必要が出てきます。

この場合は以下のように ticketApiService 等の別のモジュールを作成して、処理を委譲してしまうことで解決できそうです。

@Component({
    selector: "terry-ticket-list",
    templateUrl: "./terryTicketList.html",
    styleUrls: ["./terryTicketList.scss"]
})
export class TerryTicketListComponent implements OnInit {
    constructor(
        private ticketApiService: TicketApiService,
    ) {
    }

    // 省略
    private getTerryTickets(
        params: GetTerryTicketListParameters
    ): void {
      return this.ticketApiService(params);
    }
}

これで、viewに関するロジックとデータ取得のロジックが分離されました。

オープン・クローズドの原則

オープン・クローズドの原則は、モジュール(クラス)は拡張に対して開いていなければならず、修正に対して閉じていなければならないという原則です。 ところで、拡張に対して開いているとはどのような状態でしょうか?

export class TerryTicketDetailComponent implements OnInit {
    @ViewChild("placeholder", { read: ViewContainerRef })
    public placeholder: ViewContainerRef;
    private componentRef: ComponentRef<TerryCardComponent>;

   // 省略
    private initChildComponent(): void {
        let currentComponent: typeof TerryCardComponent;
        switch (this.terryTicket.ticket.terryTicketTypeId) {
            case TerryTicketTypes.AlertPostBadReview:
                currentComponent = CardPostBadReviewComponent;
                break;
                // 省略
        }
        const componentFactory = this.compiler.resolveComponentFactory<TerryCardComponent>(currentComponent);
        this.componentRef = this.placeholder.createComponent(componentFactory);
    }
}

例えば上のコンポーネントでは、initChildComponent関数の中で子コンポーネントをswitch文で動的に設定しています。ここで、子コンポーネントをさらに追加したい場合は、case文を新たに追加して修正する必要があります。 このままでもストラテジーパターン的な実装ではありますが、さらに責務を分離するなら、以下のような関数を定義すると良いでしょう。

export class TerryTicketDetailComponent implements OnInit {
    @ViewChild("placeholder", { read: ViewContainerRef })
    public placeholder: ViewContainerRef;
    private componentRef: ComponentRef<TerryCardComponent>;

   // 省略
    private initChildComponent(): void {
        const currentComponent = this.dispatch(terryTicketTypeId)
        const componentFactory = this.compiler.resolveComponentFactory<TerryCardComponent>(currentComponent);
        this.componentRef = this.placeholder.createComponent(componentFactory);
    }

    private dispatch(ticketTypeId: TerryTicketTypeIds): TerryCardComponent {
        const components = this.getComponents();
        for (const component of components) {
          if (ticketTypeId === component.ticketTypeId) {
            return component;
          }
        }
        return defaultComponent;
    }
}

ここでinitChildComponentメソッドに着目すると、componentを追加(== 拡張)する際に、コードの変更(== 修正)が必要なくなったことがわかります。つまり、initChildComponentレベルで、拡張に対して開いているということになります。なお、クラスレベルでオープン・クローズドの原則を担保する場合は、dispatch関数を別クラスに定義することで、さらに責務を分離できるでしょう。 また、例えばここで getComponentsメソッドを動的にあるディレクトリ配下のcomponentを取得するような処理にするようにすれば、ファイルを追加するだけで上記のコードをオープン・クローズドの原則に従った形で拡張することができます(実際はそこまでせずに、getComponentsの関数の中は追記可とする設計でも、ある程度は結合度を下げられるのでそれで十分という場合もあるかと思います)。

リスコフの置換原則

リスコフの置換原則は、派生型(子クラス)は基本型(親クラス)と置換可能でなければならないという原則です。子クラスで不用意に親クラスのメソッドを上書きしたりすると、子クラスと親クラスが置換不可能になってしまい、クラスのis-A関係が崩れてしまいます。

私達のコードではリスコフの置換原則に反したコードは見つかりませんでしたが、今後も注意してコーディングしていきたいです。

インターフェース分離の原則

インターフェース分離の原則は、クライアントに、クライアントが利用しないメソッドへの依存を強制してはならない。という原則です。 例えばメソッドAとメソッドBがあるインターフェースを利用するクライアントが常にメソッドAしか実行しない場合、メソッドBへの依存は無駄となってしまいます。

export interface IApiPageLogic {
    reverseGeocoding(
        locationIds: string[],
        serviceTypeId?: string,
        pattern?: MasterApiModels.AreaFetchingPatterns
    ): Promise<LocationInfoWithRelaseFlag[]>;
    getStoreAccessToken(
        storeId: string,
        platform: TwilioPlatforms
    ): Promise<CapabilityToken | undefined>;
}

上記のインターフェースは地理情報の処理メソッドと認証情報の取得メソッドが同居しています。そして、これれらのメソッドを必要としているクライアントも別々のクラスです。

この場合は、

export interface IGeocodingLogic {
    reverseGeocoding(
        locationIds: string[],
        serviceTypeId?: string,
        pattern?: MasterApiModels.AreaFetchingPatterns
    ): Promise<LocationInfoWithRelaseFlag[]>;
}


export interface IAuthTokenLogic {
    getStoreAccessToken(
        storeId: string,
        platform: TwilioPlatforms
    ): Promise<CapabilityToken | undefined>;
}

このように、ユースケースを考慮して別々のインターフェースとして分離したほうがより良い設計かと思います。

依存関係逆転の原則

依存関係逆転の原則は、 上位レベルのモジュールは下位レベルのモジュールに依存すべきではない。両方とも抽象に依存すべきである。 という原則です。

くらしのマーケットのNode.jsのコードでは、現在DIの仕組みを取り入れており、上位のモジュールは下位のモジュールのインターフェースに依存するようになっています。

@Dependency.register()
export class CalendarManager implements ICalendarManager {
    static $inject: string[] = ["CalendarApi"];
    constructor(private calendarApi: ICalendarApi) {}
    // メソッド定義
}

例えばこのCalendarManagerクラスは、CalendarApiをインジェクションしていますが、その機能には ICalendarApiインターフェース経由でしかアクセスできません。この場合、コンストラクタ引数のcalendarApiがどのように具体的に実装されているか、CalendarManagerクラスは知ることはできません。このようにして、上位モジュールと下位モジュールの結合度を下げることができます。

まとめ

以上、くらしのマーケットのコードを例にして、SOLID原則の紹介をしてみました。これからも設計を意識しながら、保守しやすいコードを目指して開発していきたいです。 くらしのマーケットのシステムに興味がある方はコーポレートサイト https://www.minma.jp/までお気軽にご連絡ください。

「話だけ聞きたい!」ってことでオフィスに遊びにきていただくことも可能です。 話を聞いてみる / みんなのマーケット株式会社

参考