MoTLab -Mobility Technologies Engineering Blog-MoTLab -Mobility Technologies Engineering Blog-

Nuxt.jsでクリーンアーキテクチャ

Nuxt
September 29, 2020

はじめまして、バックオフィスシステム第二グループの辻田です。

参画してからもう少しで3ヶ月目を迎えようとしています。主に運用改善系のお仕事をさせていただいていて、インフラとサーバーサイドを触ることが多いです。

今回は、わたしの大好きなNuxt.jsにいま勉強中のクリーンアーキテクチャを当てはめてサンプルを実装してみたので紹介したいと思います。業務で実際に試した内容ではないのですが、今後機会があれば挑戦したいと思っていますし、こんなことしてるメンバーもいるんだなあくらいの温度感で読んでいただければと思います。

使用技術

Nuxt.js 2.14.0

TypeScript 4.0.2

SWAPI https://swapi.dev/

  • Vue3がリリースされたてですが、Nuxtはまだ対応中なので 2.14.0 のバージョンを使用しています。(今回紹介するのはほぼtsファイルなのであまりVueのバージョンは関係ないですが。。)
  • API連携のサンプルのために、無料で使用できるSWAPIを使用しています。

サンプルソースはこちらのGithubに公開しています。

https://github.com/misaosyushi/nuxt-ca-example

構成

Nuxt.js固有のディレクトリやファイルなどについては説明を省略します。今回はルートディレクトリに /core というディレクトリを作成し、その下にクリーンアーキテクチャを参考にしたクラスたちを置いています。

core/
├── domain  // Entity(Enterprise Business Rules)ドメインオブジェクトや値オブジェクトなど
│   └── Film
│       ├── Film.ts
│       ├── Films.ts
│       ├── index.ts
│       └── vo
│           └── index.ts
├── infrastructure  // Gateway(Interface Adapters)外部APIとのやり取りやフォーマットの変換などの実装
│   └── axios
│       ├── Film
│       │   ├── AxiosFilmRepository.ts
│       │   └── index.ts
│       └── axiosConfig.ts
├── interface  // インターフェースをまとめるディレクトリ
│   ├── presenter
│   │   └── Film
│   │       ├── IFilmListPresenter.ts
│   │       └── index.ts
│   ├── repository
│   │   └── Film
│   │       ├── IFilmRepository.ts
│   │       └── index.ts
│   └── usecase
│       └── Film
│           ├── IFilmListUseCase.ts
│           └── index.ts
├── presenter  // Presenter(Interface Adapters)画面表示用にデータを加工するなどに実装
│   └── Film
│       ├── FilmListPresenter.ts
│       ├── FilmListViewModel.ts
│       └── index.ts
└── usecase  // UseCase(Application Business Rules)アプリケーション固有のビジネスロジックなどの実装
    └── Film
        ├── FilmListInteractor.ts
        └── index.ts

各レイヤーの実装

各レイヤーの実装について紹介していきます。

Entity

クリーンアーキテクチャの図でいう一番内側に当たるのがEntityです。ビジネスそのものを表現するオブジェクトやビジネスロジックがここに所属します。

Entityは外側のレイヤーに一切依存せず、httpクライアント, UI, フレームワークなどの変更の影響を受けることがないようにします。

import { EpisodeId, Title, OpeningCrawl, ReleaseDate } from './vo'

export class Film {
  readonly episodeId: EpisodeId
  readonly title: Title
  readonly openingCrawl: OpeningCrawl
  readonly releaseDate: ReleaseDate

  constructor(episodeId: EpisodeId, title: Title, openingCrawl: OpeningCrawl, releaseDate: ReleaseDate) {
    this.episodeId = episodeId
    this.title = title
    this.openingCrawl = openingCrawl
    this.releaseDate = releaseDate
  }
}

サービスによると思いますが自分の経験上、フロントにビジネスロジックが存在するパターンがあまりなかったので、今回はメソッドを持たないシンプルなオブジェクトになっています。

export class EpisodeId {
  readonly value: number
  constructor(value: number) {
    this.value = value
  }
}

export class Title {
  readonly value: string
  constructor(value: string) {
    this.value = value
  }
}

export class OpeningCrawl {
  readonly value: string
  constructor(value: string) {
    this.value = value
  }
}

export class ReleaseDate {
  readonly value: string
  constructor(value: string) {
    this.value = value
  }
}

Value Objectに関しても同様です。これなら普通にプリミティブ型で定義したほうがシンプルになって良さそうですが、せっかくなので定義してみました。

UseCase

アプリケーション固有のロジックはUsecaseに所属します。アプリケーションとしてユースケースを実現させるためのロジックをここに定義するイメージです。

このレイヤーはhttpクライアント, UI, フレームワークなどの変更の影響を受けることがないようにします。

import { Films } from '@/core/domain/Film'

export interface IFilmListUseCase {
  handle(): Promise<Films>
}
import { IFilmListUseCase } from '@/core/interface/usecase/Film'
import { Films } from '@/core/domain/Film'
import { IFilmRepository } from '@/core/interface/repository/Film'

export class FilmListInteractor implements IFilmListUseCase {
  private readonly repository: IFilmRepository

  constructor(repository: IFilmRepository) {
    this.repository = repository
  }

  handle(): Promise<Films> {
    return this.repository.getAll()
  }
}

UseCaseの実装はInteractorで行います。

外側のレイヤー(このサンプルだとRepository層)を参照すると依存のルールを破ってしまうので、DIPを使って依存関係を解消します。

Gateways

Interface Adapters レイヤーに属するGatewaysでは、外部サービスのフォーマットからUseCaseやEntityが使用する便利なフォーマットに変換するアダプターの役割を担います。

また、データベースや外部サービスが何かについて知っているのはこのレイヤーに限定する必要があり、データの永続化や外部システムとの連携も担当します。

SWAPIから作品一覧を取得するためのリポジトリは以下のように定義します。

import { Films } from '@/core/domain/Film'

export interface IFilmRepository {
  getAll(): Promise<Films>
}
import { Films } from '@/core/domain/Film'
import { IFilmRepository } from '@/core/interface/repository/Film'
import { AxiosResponse } from 'axios'
import axios from '../axiosConfig'

export class AxiosFilmRepository implements IFilmRepository {
  async getAll(): Promise<Films> {
    const res: AxiosResponse<Films> = await axios.get<Films>('/films/')
    return res.data
  }
}

このRepositoryではhttpクライアントにaxiosを使用してAPIからデータを取得し、ボディの中身を返すような実装になっています。

Presenter

PresenterもGatewayと同じ Interface Adapters というレイヤーに属していて、外側と内側がやり取りしやすいフォーマットに変換するアダプターの役割を担いますが、表示用のデータに加工するのがPresenterの主な役割です。

import { FilmListViewModel } from '@/core/presenter/Film'

export interface IFilmListPresenter {
  execute(): Promise<FilmListViewModel[]>
  sortByEpisodeId(films: FilmListViewModel[]): FilmListViewModel[]
}
export type FilmListViewModel = {
  episodeId: number
  title: string
  openingCrawl: string
  releaseDate: string
}

今回はvueファイルで扱いやすい形式の type をViewModelとして定義しました。

import { IFilmListPresenter } from '@/core/interface/presenter/Film'
import { Film } from '@/core/domain/Film'
import { FilmListInteractor } from '@/core/usecase/Film'
import { FilmListViewModel } from './FilmListViewModel'

export class FilmListPresenter implements IFilmListPresenter {
  private readonly interactor: FilmListInteractor

  constructor(interactor: FilmListInteractor) {
    this.interactor = interactor
  }

  async execute(): Promise<FilmListViewModel[]> {
    const res = await this.interactor.handle()
    return res.results.map((film: Film) => {
      return {
        episodeId: Number(film.episodeId),
        title: String(film.title),
        openingCrawl: String(film.openingCrawl),
        releaseDate: String(film.releaseDate).replace(/-/g, '/'),
      }
    })
  }

  sortByEpisodeId(films: FilmListViewModel[]): FilmListViewModel[] {
    return films.sort((a, b) => {
      if (a.episodeId < b.episodeId) return -1
      if (a.episodeId > b.episodeId) return 1
      return 0
    })
  }
}

Interacorから取得したデータをView用のtypeに変換して返す実装にしてみました。あと、表示用にソートするメソッドもここで実装しています。

NuxtのpluginでDI

Nuxtのプラグインを使用して、VueインスタンスとNuxtのContextに関数としてインジェクトして、DIっぽいことを実現します。

import { Plugin } from '@nuxt/types'
import { AxiosFilmRepository } from '@/core/infrastructure/axios/Film'
import { FilmListInteractor } from '@/core/usecase/Film'
import { FilmListPresenter } from '@/core/presenter/Film'

declare module '@nuxt/types' {
  interface Context {
    $filmListPresenter(): FilmListPresenter
  }
}

declare module 'vue/types/vue' {
  interface Vue {
    $filmListPresenter(): FilmListPresenter
  }
}

const myPlugin: Plugin = (context, inject) => {
  // Nuxtのcontextへのインジェクト
  context.$filmListPresenter = () => new FilmListPresenter(new FilmListInteractor(new AxiosFilmRepository()))

  // Vueインスタンスへのインジェクト
  inject('filmListPresenter', () => new FilmListPresenter(new FilmListInteractor(new AxiosFilmRepository())))
}

export default myPlugin

declare module でVueとContextのメタデータの型を拡張することで、vueファイルで使用する際に補完が効くようになります。

プラグインを有効にするためは nuxt.config.js へ定義したプラグインを追加します。

今回は composition-apifilm-inject という2つのプラグインを使用しています。

plugins: ['@/plugins/composition-api', '@/plugins/film-inject'],

vueファイルから呼び出す

最後に、vueファイルからPresenterを呼び出します。

<script lang="ts">
import { defineComponent, SetupContext, watchEffect, ref } from '@vue/composition-api'
import { FilmListViewModel } from '@/core/presenter/Film'

export default defineComponent({
  setup(_, context: SetupContext) {
    const films = ref<FilmListViewModel[]>([])
    const selected = ref<number[]>([])
    
    const presenter = context.root.$filmListPresenter()
    
    watchEffect(async () => {
      const res: FilmListViewModel[] = await presenter.execute()
      films.value = presenter.sortByEpisodeId(res)
    })
    
    return {
      films,
      selected,
    }
  },
})
</script>

VueインスタンスへインジェクトしたPresenterを使用するには context.root.$filmListPresenter() で呼び出すことができます。

これで presenter.execute() すればaxiosでAPIからデータを取得 -> 表示用のフォーマットに加工 されたデータを受け取ることができました!

vueファイルに表示用のためのロジックを書かなくて済むので、肥大化の防止にもなって良さそうです。

まとめ

依存関係が一方通行になっているおかげで、httpクライアントを別のものに変えたい、とかREST API -> GraphQLに変えたい、といった場合でもRepositoryの入れ替えだけで他は影響を受けないのがプラガプルで良いなと思いました。

ただフロントはビジネスロジックが存在しない場合が多いと思いますし、実際には細かい表示用のロジックがたくさん必要でどこまでPresenterに切り出すか?みたいな課題もありそうで、フロントをクリーンアーキテクチャに当てはめるにはアレンジが必要だなと感じました。他にもエラーハンドリングはどこで行うか?やVuexを使用するときはどのレイヤーに置くべきか?など考えないといけないことがまだたくさんありますが、今回はここまでにしたいと思います。

フロントでのクリーンアーキテクチャはまだまだ情報が少ないと思うので、もし何かの参考になれば幸いです。読んでいただきありがとうございました。

参考文献

https://www.amazon.co.jp/dp/B07FSBHS2V/

https://qiita.com/nrslib/items/a5f902c4defc83bd46b8

https://qiita.com/RikutoYamaguchi/items/7d265c58cb1a37969921

https://qiita.com/ttiger55/items/50d88e9dbf3039d7ab66