はじめまして、バックオフィスシステム第二グループの辻田です。
参画してからもう少しで3ヶ月目を迎えようとしています。主に運用改善系のお仕事をさせていただいていて、インフラとサーバーサイドを触ることが多いです。
今回は、わたしの大好きなNuxt.jsにいま勉強中のクリーンアーキテクチャを当てはめてサンプルを実装してみたので紹介したいと思います。業務で実際に試した内容ではないのですが、今後機会があれば挑戦したいと思っていますし、こんなことしてるメンバーもいるんだなあくらいの温度感で読んでいただければと思います。
Nuxt.js 2.14.0
TypeScript 4.0.2
SWAPI https://swapi.dev/
サンプルソースはこちらの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は外側のレイヤーに一切依存せず、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に所属します。アプリケーションとしてユースケースを実現させるためのロジックをここに定義するイメージです。
このレイヤーは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を使って依存関係を解消します。
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も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のプラグインを使用して、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-api と film-inject という2つのプラグインを使用しています。
plugins: ['@/plugins/composition-api', '@/plugins/film-inject'],
最後に、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