Go to list of users who liked
More than 5 years have passed since last update.
Vue Composition API + TypeScriptで DI(依存性の注入), DIP(依存性逆転の原則) を実装してみる
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クラスの実装に影響されてしまいます。
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に依存している状態に変わりはありません。
DIP(依存関係の逆転の原則)
DIPはDependency inversion principle
の略で日本語では依存関係逆転の原則
といいます。
DIPの定義は以下の通りです。
1 上位レベルのモジュールは下位レベルのモジュールに依存すべきではない。両方とも抽象(abstractions)に依存すべきである。
2. 抽象は詳細に依存してはならない。詳細が抽象に依存すべきである。
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へ依存するようになりました。
これでCarクラスをDIPに実装できました
🛠 Vue Composition API + TypeScriptでの実践
さてここからが本題。
DI, DIPをVue Composition API + TypeScriptで実践していきます。
まず、リファクタリング対象となるサンプルコードです。
TasksコンポーネントはsetupのタイミングでApiClientクラスをnewして、onMountedのタイミングでタスク一覧を取得しています。
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;}}
<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に変更が必要です。
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するようにします。
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;}}
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で参照するように修正します。
<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の型を参照しているので依存性は残ります。
InterfaceでDIP(依存関係の逆転の原則)
最後にDIPを実践し、Tasks.vueとApiClientの依存関係を改善します。
基本で説明した通り、DIPでは抽象であるインタフェースを定義して、具象クラス同士の結合を避けるのですね。
なので、最初にIApiインタフェースを定義します。
そして、provideのキーもIApiに移動します。
import{Task}from"@/types/type";exportconstDefaultApiClient=Symbol("api");exportinterfaceIApi{fetchTasks:()=>Promise<Task[]>;}
そして、ApiClientはIApiをimplementsするように修正します。
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への依存になるように修正します。
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");
<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へ依存することになりました。
🛠活用例
DI、DIPを実践することで疎結合なコンポーネントができたので、この設計がどのように活用できるのか考えてみます。
API差し替えでの活用
この状態であれば突然「Axiosをfetch APIに変更する」となっても、Tasks.vueに変更は不要です。
新しくIApiをimplementsしたfetchApiClient.tsを作成し、main.tsでapiClientと差し替えてprovideすれば完了します。
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();}}
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クライアントを容易に置き換えられます。
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原則もまだまだ勉強中です。これで理解が正しいのか自信はないです..。ただ、「記事を書いてマサカリ受けることで、より理解を深められるのでは?」という気持ちで思い切って書いてみました。
指摘、コメント大歓迎です。よろしくお願いします。
参考
以下記事とても参考にさせて頂きました
Register as a new user and use Qiita more conveniently
- You get articles that match your needs
- You can efficiently read back useful information
- You can use dark theme