JavaScript
angular

依存関係逆転の原則をAngularコンポーネントに適用する

More than 1 year has passed since last update.

Angularでコンポーネントとアプリを分離して開発していて、そのコンポーネントの開発で依存関係逆転の原則(Dependency Inversion Principle)を使うといい具合にコンポーネントを定義できるのではないかと気づきました。いいかもしれないし悪いかもしれないのでいろいろご意見いただけたらうれしいです :pray:

前提

どんなAngular開発の現場でもメリットがあるわけではなく、以下の前提を想定します。

  • 規模が割りと大きい
    • 全体を整えるために記述がある程度冗長になっても許される
  • コンポーネントとアプリケーションを分離している
    • フォルダ単位とか、コンポーネントを別ライブラリにしているなど
    • ビジネスロジックとコンポーネントが混ざるのを防ぐためとか、コンポーネントを複数のプロダクトで共有するとか

単純な実装

Todoを扱うようなコンポーネントをAngularで作るときに、一番単純に作るとこんな感じでしょうか。

todos.component.ts
@Component({
  template: // TODOリストを表示、と新規TODOを追加するフォームがある
})
export TodosComponent {
  todos$: Observable<Todo[]>;
  constructor(private http: HttpClient) {
    this.todos$ = http.get<Todo[]>('/todos');
  }

  addTodo(text: string) {
    this.http.post('/todos', { text })
      .subscribe(res => {
        // 200だったら成功とか、400だったら原因がbodyのJSONに書いてあるとか
      });
  }
}

この実装の問題

  • コンポーネントがHTTPに依存している
    • URLの変更でTODOコンポーネントを直すのはなんか変

コンポーネントにロジックを書くなとは公式ドキュメントにも書いてありますので、次のような実装は一番よくあるんじゃないでしょうか。

よくやる実装

todos.component.ts
@Component({
  template: // TODOリストを表示、と新規TODOを追加するフォームがある
})
export TodosComponent {
  todos$: Observable<Todo[]>;
  constructor(private service: TodosService) {
    this.todos$ = service.fetchTodos();
  }

  addTodo(text: string) {
    this.service.addTodo(text)
      .subscribe(res => {
        // 200だったら成功とか、400だったら原因がbodyのJSONに書いてあるとか
        // あるいはHTTPのステータスコードを意味のあるunion typeにしていたりとか
      });
  }
}

Todoの取得と追加をTodoServiceに委譲します。これはこれで問題ないと思います。個人的にはコンポーネントとサービスで仕様が分割されたような感じがして、次の依存関係逆転を使ったパターンを思いつきました。

依存関係逆転を使った実装

todos.component.ts
export abstract class TodosComponentService {
  abstract fetchTodos(): Observable<Todo[]>;
  abstract addTodo(): Promise<'success' | 'too-many-characters' | 'too-many-todos'>;
}

@Component({
  template: // TODOリストを表示、と新規TODOを追加するフォームがある
})
export TodosComponent {
  todos$: Observable<Todo[]>;
  constructor(private service: TodosComponentService) {
    this.todos$ = service.fetchTodos();
  }

  addTodo(text: string) {
    this.service.addTodo(text)
      .subscribe(res => {
        // 200だったら成功とか、400だったら原因がbodyのJSONに書いてあるとか
        // あるいはHTTPのステータスコードを意味のあるunion typeにしていたりとか
      });
  }
}

TodoService を abstract なサービスに置き換えて、コンポーネントと同じファイルに宣言します。またサービスがこのコンポーネント専用になるので、名前も TodosComponentService としてみました。こうするとサービスを実装するクラスが必要になるので、以下のような実装をします。 TodoHttpServiceGET /todos とかをするHttpClientを使ったクラスだと思ってください。

todos-component.service.impl.ts
@Injectable()
export class TodosComponentServiceImpl implements TodosComponentService {
  constructor(private http: TodoHttpService) { }

  fetchTodos(): Observable<Todo[]> {
    return this.http.fetchTodos();
  }

  addTodo(text: string): Promise<'success' | 'too-many-characters' | 'too-many-todos'> {
    return this.http.addTodo(text)
      .toPromise();
  }
}
app.component.ts
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  providers: [
    { provide: TodosComponentService, useClass: TodosComponentServiceImpl }
  ]
})
export class AppComponent {
}

AngularがDIの仕組みを自前で持ってるからできる荒業(?)ですね :joy:
こうして、以下のような関係になりました。

構築された関係

TodosComponent (TodosComponentService)

  • Todoを扱うプレゼンテーションロジックだけが書いてある
  • Todoをどうやって取得するかは知らない
  • 知らないが、 Todo[] をObservableで提供してほしいし、Todoの追加時に文字数かTodoの数を超過するとエラーになるなどのアプリケーションの仕様は知っているので、それを TodosComponentService を実装した誰かにお願いする

TodosComponentServiceImpl

  • TodosComponentとTodosComponentServiceが要求するロジックについて知っている
  • それを満たせそうなTodosHttpServiceの存在も知っている
  • この例ではTodosHttpServiceだけだが、コンポーネントの要求を満たすためにいろんなサービスをDIしてObservableをこねくり回したり
  • このコンポーネント専用で他では使えない

TodosHttpService

  • GET /todosPOST /todos などのサーバーサイドの仕様を知っていて、それをメソッドとして公開している
  • コンポーネントの仕様は知らない
  • 再利用性が高い

AppComponent (TodosComponentの上位コンポーネント)

  • 子コンポーネントにTodosComponentがいることを知っている
  • TodosComponentServiceImplがTodosComponentServiceを実装していることを知っているので、 providers に書いてサービスインスタンスを供給してやる
    • (AppModuleの providers でも良さそうですが、他で使われないComponentServiceなのでDIのスコープを限定するためにAppComponentというか上位のコンポーネントがいいのかなと)

メリット

  • Todoに関する仕様がTodosComponentを見ればひと目でわかる
    • 仕様変更があった際も、まずその変更に沿ってこのコンポーネントと abstract なサービスを変更しテストを修正し、その次にこのサービスを extends もしくは implements して実際の実装を書いているサービスを修正するという流れになり、なんかきれいな気がする
  • 外部(HTTPなど)とのやり取りもabstract classで宣言的に書かれていて、実装詳細を気にしなくていい
  • Componentのテスタビリティが上がる
    • テストのときはComponentServiceに適当なモックを突っ込んでやればいい、楽
  • コンポーネントのデモアプリが実装しやすい
    • コンポーネントを完全に分離していて、コンポーネントの各状態が見れるようなデモアプリを作っているとテストと同様にモックしやすく、 fetchTodos() { return Observable.of(fakeTodos).delay(1000); } みたいにしやすい
  • それぞれの役割が明確になる
    • Component: プレゼンテーションロジック、シンプル
    • HttpService: エンドポイントとJSONの形などの管理、シンプル。 Angular Model Patternのようなキャッシュ的な実装もしてもよさそう
    • ComponentService: つなぎ役、複雑
  • ComponentServiceは、複雑だけど @ComponentHttpClient 非依存になる
    • Isolated unit testがつかえるので、 TestBed. configureTestingModule みたいな長大な前準備なしに service = new TodosComponentSerivce(fakeTodosHttp) のように単にclassを作るAngular非依存のテストが書ける
  • (メリットというかコンポーネントとアプリを完全に分離している場合、こうでもしないとなんでも @Input で渡すのも良くないし繋ぎ込むのが大変 :innocent: )

デメリット

  • 冗長になる
    • XXXComponentServiceとそのImplだらけになる
    • { provide: TodosComponentService, useClass: TodosComponentServiceImpl } がたくさん
      • 命名は改善できそうだけど…

雑なサンプル実装

https://github.com/adwd/angular-abstract-component-service-sample

まとめ

コンポーネントのロジックはServiceに逃がすというのは公式ドキュメントにも書いてありますが、この依存関係逆転を使ったパターンだと仕様はコンポーネントが握っているという感じがしてなんか納得感がありました。冗長にはなるので小規模なアプリには適していないですが大規模になってくるとこういうアプローチもありなんじゃないでしょうか?