こんにちは。
Web・iOSエンジニアの三浦です。
今回は、TypeScriptでセットアップしたNuxt.jsにおけるアーキテクチャについて、実際に私が使っているものを一例として紹介します。
はじめに
Vue.jsはViewでのバインディングに非常に長けているJavaScriptのフレームワークであり、コンポーネントにて適切にデータを取得することができれば、Vue.jsの作法に従いきれいにコードを書くことが可能です。
一方で、コンポーネントにデータを渡すまで、すなわち例えばAPIからのデータの取得や整形などの、MVVMで言うModel部分については特にVue.js側でフレームは用意されておらず、自ら構造を考える必要があります。
シンプルなアプリケーションであれば問題ありませんが、複雑性が増すほどきちんとModel部分の構造を考える必要が出てくるでしょう。
ここでは、実際に私がVue.jsのフレームワークであるNuxt.jsを使う上で、Model部分の構造をどのように設定しているかを紹介していきます。
Vue.jsとNuxt.jsの紹介
具体的な紹介に入る前に、まずVue.jsとNuxt.jsについて説明します。
Vue.jsとは
Vue.jsはJavaScriptのフレームワークの一つであり、Viewへの変数等のバインディングに優れたフレームワークです。
各ページやそのパーツをコンポーネントという単位に分け、それらを組み合わせてアプリケーションを作成します。
Nuxt.jsとは
Nuxt.jsはVue.jsのフレームワークであり、Vue.jsが本来持つ機能を活かしつつ、ルーティングやレンダリングなど様々な追加機能を提供してくれます。
セットアップ時にコードフォーマッターやユニットテストの設定等も同時に行うことができ、Nuxt.jsをインストールすれば開発に必要な一通りの準備が整うと言って良いでしょう。
使用するアーキテクチャ
Vue.jsを使う以上必然的に全体のアーキテクチャはMVVMになるわけですが、Model部分に関してはクリーンアーキテクチャを意識して作りました。
クリーンアーキテクチャには、
- 役割ごとの機能の分離
- DIによる依存性逆転
などの特徴があり、コードの可読性の向上やユニットテストのしやすさの向上に寄与します。
ディレクトリ構造
結果からいうと、ディレクトリ構造は以下のようになりました。
.
├── model
│ ├── persistence
│ │ ├── persistence1.ts
│ │ └── persistence2.ts
│ ├── repository
│ │ ├── repository1.ts
│ │ └── repository2.ts
│ └── service
│ └── service1.ts
├── pages
│ └── sample.vue
├── plugins
│ └── service.ts
├── types
│ └── index.d.ts.ts
└── test
└── model
├── repository
│ ├── repository1.spec.ts
│ └── repository2.spec.ts
└── service
└── service1.spec.ts
順に説明していきます。
MVVMのModel
modelディレクトリ配下にて、MVVMでいうModel部分を担当します。
persistence
persistenceは、外部のストレージやAPIと直接やり取りする役割を持ちます。
クリーンアーキテクチャで言うとrepositoryがそれを担当する場合もありますが、あえてpersistenceとして分離することで、以下の利点を得ることができます。
- repositoryが直接ストレージやAPIとのやり取りをする場合、受け取ったデータをエンティティとして変換したりバリデーションしたりする役割も兼務することになるが、それを分離することができる
- ユニットテスト実行時、persistenceを擬似的にストレージやAPI本体と捉えることで、ストレージやAPI自体のモックを用意しなくてもpersistenceのモックを用意するだけでrepositoryのユニットテストを実行できる
ファイルの中身は以下のようになっています。
persistence1
export interface Persistence1 {
get(): string
}
export class Persistence1Impl implements Persistence1 {
get(): string {
return 'Persistence1のデータを取得'
}
}
persistence2
export interface Persistence2 {
get(): string
}
export class Persistence2Impl implements Persistence2 {
get(): string {
return 'Persistence2のデータを取得'
}
}
repository
repositoryは、persistenceが外部から取得したデータを受け取って整形やバリデーション等を行い、アプリケーション内で使用できる形に変換します。
多くの場合、 persistence : repository = 1 : 1
になるでしょう。
変換だけならTranslaterのようなものを作ってもいいですが、その他バリデーション処理等もここで行う想定なのでrepositoryとして切り分けています。
ファイルの中身は以下のようになっています。
repository1
import { Persistence1 } from '~/model/persistence/persistence1'
export interface Repository1 {
get(): string
}
export class Repository1Impl implements Repository1 {
private readonly persistence1: Persistence1
constructor(persistence1: Persistence1) {
this.persistence1 = persistence1
}
get(): string {
return 'Repository1経由で' + this.persistence1.get()
}
}
repository2
import { Persistence2 } from '~/model/persistence/persistence2'
export interface Repository2 {
get(): string
}
export class Repository2Impl implements Repository2 {
private readonly persistence2: Persistence2
constructor(persistence2: Persistence2) {
this.persistence2 = persistence2
}
get(): string {
return 'Repository2経由で' + this.persistence2.get()
}
}
service
serviceは、1~複数のrepositoryを使用して各ページに必要なデータを取得・必要に応じて整形し、Vueコンポーネントにそのデータを渡します。
そのため、基本的に ページ : service = 1 : 1
になるイメージです。
ファイルの中身は以下のようになっています。
service1
import { Repository1 } from '~/model/repository/repository1'
import { Repository2 } from '~/model/repository/repository2'
export interface Service1 {
get1(): string
get2(): string
}
export class Service1Impl implements Service1 {
private readonly repository1: Repository1
private readonly repository2: Repository2
constructor(repository1: Repository1, repository2: Repository2) {
this.repository1 = repository1
this.repository2 = repository2
}
get1(): string {
return 'Service1から' + this.repository1.get()
}
get2(): string {
return 'Service1から' + this.repository2.get()
}
}
MVVMのV/VM
ここまで見てくださった方は、どこでこれらをDIするのか疑問に思われているかと思いますが、先にMVVMにおけるV/VMを担当するpagesディレクトリ配下を見ていきます。
pagesはNuxt.jsにもとからあるディレクトリで、ここに各ページのViewとViewModelの処理を書いていきます。
今回のmodel配下からデータを取得しているsample.vueファイルは、以下のようになっています。
<template>
<div>
<p>
{{ data1 }}
</p>
<p>
{{ data2 }}
</p>
</div>
</template>
<script lang="ts">
import Vue from 'vue'
interface DataType {
data1: string
data2: string
}
export default Vue.extend({
name: 'Index',
data(): DataType {
return {
data1: this.$service.service1.get1(),
data2: this.$service.service1.get2(),
}
},
})
</script>
このようにすることで、以下のように出力されます。
とはいえ、現状のコードだけではこのようには表示されません。
以下の部分の設定が抜けているからです。
data1: this.$service.service1.get1(),
data2: this.$service.service1.get2(),
そしてこれは、今回のDI方法にも関連する部分となっています。
上記の設定やDIは、plugins配下で実現しています。
DI設定
Nuxt.jsでは inject()
というものが用意されており、これを使用することで変数などをグローバルに登録することができます。
この機能をpluginsディレクトリ配下で使うことで、pages配下で行ったような記法やDIを実現します。
plugins/service.tsは、以下のような記述となっています。
import { Context } from '@nuxt/types'
import { Inject } from '@nuxt/types/app'
import { Persistence1Impl } from '~/model/persistence/persistence1'
import { Persistence2Impl } from '~/model/persistence/persistence2'
import { Repository1Impl } from '~/model/repository/repository1'
import { Repository2Impl } from '~/model/repository/repository2'
import { Service1, Service1Impl } from '~/model/service/service1'
export interface Service {
service1: Service1
}
export default function ({ $axios }: Context, inject: Inject): void {
const service: Service = {
service1: getService1(),
}
inject('service', service)
}
function getService1(): Service1 {
const persistence1 = new Persistence1Impl()
const persistence2 = new Persistence2Impl()
const repository1 = new Repository1Impl(persistence1)
const repository2 = new Repository2Impl(persistence2)
return new Service1Impl(repository1, repository2)
}
このようにここでmodel配下のコードを予め組み立てて inject
するようにしておき、Nuxt.js標準の nuxt.config.js
で
plugins: ['@/plugins/service'],
のように設定することで、Vue.jsのアプリケーション初期化前にこの処理が実行されるので、グローバルにmodel配下の処理が登録されます。
inject
した変数等は $ + {引数で渡した文字列名}
で登録されるので、今回の場合実行時は
this.$service.*
の形で参照できるようになります。
ちなみにTypescriptの場合、これだけでは型が判別できないので、typesディレクトリ配下で型定義を行います。
types/index.d.tsにて、
import { Service } from '~/plugins/service'
declare module 'vue/types/vue' {
interface Vue {
readonly $service: Service
}
}
declare module 'vuex' {
interface Store<S> {
readonly $service: Service
}
}
のように記述しておき、こちらもNuxt.js標準の tsconfig.json
で
{
*,
"types": [
*,
"types/index.d"
],
*
}
のように記述することで、型を判別してくれるようになります。
pluginsでDI行うその他の利点
上記で示した点以外にも、pluginsでDIを行うことの利点があります。
それは、model配下にNuxt.jsのContext情報を持っていくことができることです。
例えば今回の例だと、
import { Context } from '@nuxt/types'
import { Inject } from '@nuxt/types/app'
import { NuxtAxiosInstance } from '@nuxtjs/axios'
import { Persistence1Impl } from '~/model/persistence/persistence1'
import { Persistence2Impl } from '~/model/persistence/persistence2'
import { Repository1Impl } from '~/model/repository/repository1'
import { Repository2Impl } from '~/model/repository/repository2'
import { Service1, Service1Impl } from '~/model/service/service1'
export interface Service {
service1: Service1
}
export default function ({ $axios }: Context, inject: Inject): void {
const service: Service = {
service1: getService1($axios),
}
inject('service', service)
}
function getService1($axios: NuxtAxiosInstance): Service1 {
// persistenceのコンストラクタで $axios: NuxtAxiosInstance を受け取る処理が必要
const persistence1 = new Persistence1Impl($axios)
const persistence2 = new Persistence2Impl($axios)
const repository1 = new Repository1Impl(persistence1)
const repository2 = new Repository2Impl(persistence2)
return new Service1Impl(repository1, repository2)
}
このようにすることで、NuxtAxiosをmodel配下でも使用できるようになります。
ユニットテスト
ここまでで一通り処理ができるようになりましたが、せっかくなのでユニットテストをどうやるかまで紹介していきます。
ユニットテスト用のファイルは、こちらもNuxt.js標準のtestディレクトリ配下に作成します。
Vue.jsでユニットテストを行う場合、View部分からテストを行おうとすると、ユニットテスト上でViewをマウントしたり操作する必要があり少し面倒です。
もちろん必要性があればやるべきだとは思いますが、とりあえず最低限のユニットテストで良いのであれば、今回の構成の場合model配下をテストすれば最低限と言えると思います。
ファイルを見てもらえば分かる通り、今回はすべてInterfaceを経由してアクセスしていく形にしているので、ユニットテストも容易です。
以下のようになっています。
service/service1.spec
import { Repository1 } from '~/model/repository/repository1'
import { Repository2 } from '~/model/repository/repository2'
import { Service1, Service1Impl } from '~/model/service/service1'
describe('Service1', () => {
let service1: Service1
class Repository1Mock implements Repository1 {
get(): string {
return 'テスト1'
}
}
class Repository2Mock implements Repository2 {
get(): string {
return 'テスト2'
}
}
beforeAll(() => {
const repository1Mock = new Repository1Mock()
const repository2Mock = new Repository2Mock()
service1 = new Service1Impl(repository1Mock, repository2Mock)
})
it('get string from repository1', () => {
const actualResult = service1.get1()
const expectedResult = 'Service1からテスト1'
expect(actualResult).toEqual(expectedResult)
})
it('get string from repository2', () => {
const actualResult = service1.get2()
const expectedResult = 'Service1からテスト2'
expect(actualResult).toEqual(expectedResult)
})
})
repository/repository1.spec
import { Persistence1 } from '~/model/persistence/persistence1'
import { Repository1Impl } from '~/model/repository/repository1'
describe('Repository1', () => {
class Persistence1Mock implements Persistence1 {
get(): string {
return 'テスト'
}
}
it('get string from persistence1', () => {
const persistence1Mock = new Persistence1Mock()
const repository1 = new Repository1Impl(persistence1Mock)
const actualResult = repository1.get()
const expectedResult = 'Repository1経由でテスト'
expect(actualResult).toEqual(expectedResult)
})
})
repository/repository2.spec
import { Persistence2 } from '~/model/persistence/persistence2'
import { Repository2Impl } from '~/model/repository/repository2'
describe('Repository2', () => {
class Persistence2Mock implements Persistence2 {
get(): string {
return 'テスト'
}
}
it('get string from persistence1', () => {
const persistence2Mock = new Persistence2Mock()
const repository2 = new Repository2Impl(persistence2Mock)
const actualResult = repository2.get()
const expectedResult = 'Repository2経由でテスト'
expect(actualResult).toEqual(expectedResult)
})
})
さいごに
以上が、私が現在使っているアーキテクチャになります。
もちろんこれらはかなり簡略化していますので、例えば必要なエンティティがあればmodel配下に entity
ディレクトリを作ってそこに作るようにしたり、今回私が示したレイヤーが多すぎる・少なすぎるのであれば適宜追加・削除して貰えればと思います。
この形が必ずしも正解だとは思っていませんが、何かしらの参考になれば幸いです。