Movatterモバイル変換


[0]ホーム

URL:


LoginSignup
81

Go to list of users who liked

65

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Vue Composition API + TypeScriptで DI(依存性の注入), DIP(依存性逆転の原則) を実装してみる

Last updated atPosted at 2020-05-03

SOLID原則に出てくるDI(依存性の注入)とDIP(依存関係の逆転の原則)を勉強したので、自分なりにVue Composition API + TypeScriptでのコンポーネント設計に応用してみました。

🛠 DI, DIPとは?

最初にサンプルコードを元にDI(依存性の注入)とDIP(依存関係の逆転の原則)を復習します。
既にDI, DIPを知っている方はスキップしてこちらへ。

以下Engineクラスに依存するCarクラスをDI, DIPを用いてリファクタリングしていきます。

classCar{privateengine:Engine;constructor(privatetype:string){this.engine=newEngine(type);}move(){this.engine.start();// ..処理}}classEngine{constructor(publictype:string){}start(){// ..処理}}constcar=newCar("Honda");car.move();

CarクラスはEngineクラスをconstructor内でnewしています。そして、moveメソッドでCarクラスのstartメソッドを実行しています。
つまり、Carクラスのconstructor、moveメソッドはEngineクラスに深く依存しています。

この状態でCarクラスをユニットテストしようと思っても、内部でnewするEngineクラスの実装に影響されてしまいます。

image.png

DI(依存性の注入)

DIはDependency injection の略で日本語では依存性の注入といいます。

内部で使用するクラス(依存するクラス)を内部でnewするのではなく、クラスの初期化時に引数で渡す(注入する)。これがDIです。

DIをすることで
・ソフトウェアの階層をきれいに分離した設計が容易になる
・コードが簡素になり、開発期間が短くなる
・テストが容易になり、「テスト・ファースト」による開発スタイルを取りやすくなる
などのメリットがあります。

サンプルコードを、Carクラスのコンストラクタ内でEngineをnewするのではなく、EngineクラスのインスタンスをCarクラスの初期化時に渡すように修正します。

classCar{ // 初期化時に引数としてEngineのインスタンスを受け取るconstructor(privateengine:Engine){}move(){this.engine.start();// ..処理}}classEngine{constructor(publictype:string){}start(){// ..処理}}constengine=newEngine("Honda");// CarクラスにEngineのインスタンスを注入しているconstcar=newCar(engine);car.move();

こうすることで、依存するEngineを初期化時に動的に差し替えられるようになります。
Carクラスのユニットテストの際に、Engineをモックに差し替えるなども容易です。

ただ、まだCarクラスはEngineに依存している状態に変わりはありません。

image.png

DIP(依存関係の逆転の原則)

DIPはDependency inversion principle の略で日本語では依存関係逆転の原則といいます。
DIPの定義は以下の通りです。

1 上位レベルのモジュールは下位レベルのモジュールに依存すべきではない。両方とも抽象(abstractions)に依存すべきである。
2. 抽象は詳細に依存してはならない。詳細が抽象に依存すべきである。

(wikipedia 依存関係逆転の原則)

CarクラスはDIでEngineクラスへの依存を注入しているものの、まだCarが引数として定義している型はEngineクラスで具象に依存しています。
これではDIPを守れていないので、IStartableというインタフェース(抽象)を定義し、Carのコンストラクタの引数をIStartableに変更します。

interfaceIStartable{start:()=>void;}classCar{constructor(privateengine:IStartable){}move(){this.engine.start();// ..処理}}classEngineimplementsIStartable{constructor(publictype:string){}start(){// ..処理}}constengine=newEngine("Honda");constcar=newCar(engine);car.move();

Carクラスは具象クラスのEngineに依存せず、抽象クラスのIStartableへ依存するようになりました。
また、Engine自体もIstartableをimplementsすることで、IStartableへ依存するようになりました。

image.png

これでCarクラスをDIPに実装できました:tada:

🛠 Vue Composition API + TypeScriptでの実践

さてここからが本題。
DI, DIPをVue Composition API + TypeScriptで実践していきます。

まず、リファクタリング対象となるサンプルコードです。
TasksコンポーネントはsetupのタイミングでApiClientクラスをnewして、onMountedのタイミングでタスク一覧を取得しています。

apiClient.ts
import{Task}from"@/types/type";importaxios,{AxiosInstance}from"axios";exportclassApiClient{client:AxiosInstanceconstructor(){this.client=axios.create({baseURL:'https://example.com/api/v1',headers:{'X-Custom-Header':'foobar'}})}asyncfetchTasks(){const{data}=awaitthis.client.get<Task[]>('/tasks');returndata;}}
tasks.vue
<template><div><divv-for="task in tasks":key="task.id"class="task"><h2>{{task.title}}</h2><p>{{task.note}}</p></div></div></template><scriptlang="ts">import{defineComponent,ref,onMounted}from"@vue/composition-api";import{Task}from"@/types/type";import{ApiClient}from"@/apis/apiClient";exportdefaultdefineComponent({setup(){constclient=newApiClient();consttasks=ref<Task[]>([]);onMounted(async()=>{try{tasks.value=awaitclient.fetchTasks();}catch(e){console.log(e);}});return{tasks};}});</script>

この状態では先ほど説明したCarクラスの最初の状態と同じく、このTasksコンポーネントはApiClientクラスに密に結合しています。
そのため、急にAPiクライアントを差し替えることになった場合など、Tasks.vueに変更が必要です。

image.png

provide/injectでDI(依存性の注入)

Tasks.vueのApiClientへの依存を弱くするため、DIを使います。
Vue-Composition-APIでのDIはprovide/injectを使います。
APIの詳細は公式APIリファレンスのDependency Injection を参照してください。

役割的にはVue2系のprovide/injectと変わりません。

まず、Vueインスタンスをマウントするエントリーファイルのmain.tsで、ApiClientをprovideします。
また、provideにはキーが必要なので、ApiClientで新たに作成しexportするようにします。

apiClient.ts
import{Task}from"@/types/type";importaxios,{AxiosInstance}from"axios";exportconstDefaultApiClient=Symbol("api")exportclassApiClient{client:AxiosInstanceconstructor(){this.client=axios.create({baseURL:'https://example.com/api/v1',headers:{'X-Custom-Header':'foobar'}})}asyncfetchTasks(){const{data}=awaitthis.client.get<Task[]>('/tasks');returndata;}}
main.ts
importVuefrom"vue";importAppfrom"./App.vue";importVueCompositionApi,{provide}from"@vue/composition-api";import{ApiClient,DefaultApiClient}from"./apis/apiClient";Vue.use(VueCompositionApi);Vue.config.productionTip=false;constapiClient=newApiClient();newVue({render:h=>h(App),setup(){provide<ApiClient>(DefaultApiClient,apiClient);return{};}}).$mount("#app");

これでApiClientの注入ができたので、Task.vueでapiClientをinjectで参照するように修正します。

Page.vue
<template><div><divv-for="task in tasks":key="task.id"class="task"><h2>{{task.title}}</h2><p>{{task.note}}</p></div></div></template><scriptlang="ts">import{defineComponent,inject,ref,onMounted}from"@vue/composition-api";import{DefaultApiClient,ApiClient}from"@/apis/apiClient";import{Task}from"@/types/type";exportdefaultdefineComponent({setup(){constclient=inject<ApiClient>(DefaultApiClient);// injectの戻り値が T | void のUnion型のためif(!client){throw"provie missing.";}consttasks=ref<Task[]>([]);onMounted(async()=>{try{tasks.value=awaitclient.fetchTasks();}catch(e){console.log(e);}});return{tasks};}});</script>

これでTasks.vueは直接ApiClientをnewすることはなくなり、ApiClientへの依存性を弱められました。
ただ、まだTasks.vueでApiClientの型を参照しているので依存性は残ります。

image.png

InterfaceでDIP(依存関係の逆転の原則)

最後にDIPを実践し、Tasks.vueとApiClientの依存関係を改善します。
基本で説明した通り、DIPでは抽象であるインタフェースを定義して、具象クラス同士の結合を避けるのですね。
なので、最初にIApiインタフェースを定義します。
そして、provideのキーもIApiに移動します。

IApi.ts
import{Task}from"@/types/type";exportconstDefaultApiClient=Symbol("api");exportinterfaceIApi{fetchTasks:()=>Promise<Task[]>;}

そして、ApiClientはIApiをimplementsするように修正します。

apiClient.ts
import{IApi}from"@/interfaces/IApi";import{Task}from"@/types/type";importaxios,{AxiosInstance}from"axios";exportclassApiClientimplementsIApi{client:AxiosInstanceconstructor(){this.client=axios.create({baseURL:'https://example.com/api/v1',headers:{'X-Custom-Header':'foobar'}})}asyncfetchTasks(){const{data}=awaitthis.client.get<Task[]>('/tasks');returndata;}}

最後にmain.tsと、Tasks.vueをIApiへの依存になるように修正します。

main.ts
importVuefrom"vue";importAppfrom"./App.vue";importVueCompositionApi,{provide}from"@vue/composition-api";import{DefaultApiClient,IApi}from"./interfaces/IApi";import{ApiClient}from"./apis/apiClient";Vue.use(VueCompositionApi);Vue.config.productionTip=false;constapiClient=newApiClient();newVue({render:h=>h(App),setup(){provide<IApi>(DefaultApiClient,apiClient);return{};}}).$mount("#app");
Tasks.vue
<template><div><divv-for="task in tasks":key="task.id"class="task"><h2>{{task.title}}</h2><p>{{task.note}}</p></div></div></template><scriptlang="ts">import{defineComponent,inject,ref,onMounted}from"@vue/composition-api";import{DefaultApiClient,IApi}from"@/interfaces/IApi";import{Task}from"@/types/type";exportdefaultdefineComponent({setup(){constclient=inject<IApi>(DefaultApiClient);// injectの戻り値が T | void のUnion型のためif(!client){throw"provie missing.";}consttasks=ref<Task[]>([]);onMounted(async()=>{try{tasks.value=awaitclient.fetchTasks();}catch(e){console.log(e);}});return{tasks};}});</script>

これでTasks.vueは直接具象クラスのApiClientに依存することはなく、抽象クラス(インタフェース)のIApiへ依存することになりました。:tada:

image.png

🛠活用例

DI、DIPを実践することで疎結合なコンポーネントができたので、この設計がどのように活用できるのか考えてみます。

API差し替えでの活用

この状態であれば突然「Axiosをfetch APIに変更する」となっても、Tasks.vueに変更は不要です。
新しくIApiをimplementsしたfetchApiClient.tsを作成し、main.tsでapiClientと差し替えてprovideすれば完了します。

fetchApiClient.ts
import{IApi}from"@/interfaces/IApi";import{Task}from"@/types/type";exportclassFetchApiClientimplementsIApi{BASE_URL="https://example.com/api/v1";asyncfetchTasks():Promise<Task[]>{constresponse=awaitfetch(`${this.BASE_URL}/tasks`);returnawaitresponse.json();}}
main.ts
importVuefrom"vue";importAppfrom"./App.vue";importVueCompositionApi,{provide}from"@vue/composition-api";import{DefaultApiClient,IApi}from"./interfaces/IApi";import{FetchApiClient}from"@/apis/fetchApiClient";Vue.use(VueCompositionApi);Vue.config.productionTip=false;constapiClient=newFetchApiClient();// ここでnew ApiClient()と差し替えるだけnewVue({render:h=>h(App),setup(){provide<IApi>(DefaultApiClient,apiClient);return{};}}).$mount("#app");

コンポーネントのユニットテストでの活用

また、ApiClientとTasksコンポーネントが疎結合となるのでユニットテストも容易になります。
IApiをimplementsしたモッククライアントを作ればAPIクライアントを容易に置き換えられます。

Tasks.spec.ts
import{createLocalVue,shallowMount}from"@vue/test-utils";importTasksfrom"../../src/components/Tasks.vue";importVueCompositionApifrom"@vue/composition-api";import{DefaultApiClient,IApi}from"../../src/interfaces/IApi";constlocalVue=createLocalVue();localVue.use(VueCompositionApi);// IApiを実装したモックのクライアントclassMockClientimplementsIApi{asyncfetchTasks(){return[{id:1,title:"買い物",note:"人参、牛乳",completed:false},{id:2,title:"掃除",note:"リビング",completed:false}];}}describe("Tasks.vue",()=>{it("タスク一覧を表示する",async()=>{constmockClient=newMockClient();constwrapper=shallowMount(Tasks,{localVue,provide:{[DefaultApiClient]:mockClient}});awaitwrapper.vm.$nextTick();awaitwrapper.vm.$nextTick();consttasks=wrapper.findAll(".task");expect(tasks.length).toBe(2);expect(tasks.at(1).text()).toContain("掃除");expect(tasks.at(1).text()).toContain("リビング");});});

終わりに

以上、「Vue-Composition-API + TypeScriptで実践するDI(依存性の注入), DIP(依存性逆転の原則)」でした。

正直なところ、設計周りは大の苦手でSOLID原則もまだまだ勉強中です。これで理解が正しいのか自信はないです..。ただ、「記事を書いてマサカリ受けることで、より理解を深められるのでは?」という気持ちで思い切って書いてみました。
指摘、コメント大歓迎です。よろしくお願いします。

参考

以下記事とても参考にさせて頂きました:pray:

81

Go to list of users who liked

65
0

Go to list of comments

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
81

Go to list of users who liked

65

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?


[8]ページ先頭

©2009-2025 Movatter.jp