個人開発でMokurenというChrome拡張を作っています。最近、アーキテクチャを再設計する機会があり折角なので情報共有ということでQiita の記事にしてみようと思います。
技術周り
使っている技術周りです。
- chrome extension
- Vue.js
- TypeScript
- Apollo
- Firebase
Mokurenとは
この記事を読み進めていくにあたって、Mokurenというプロダクトを知っている必要はないですが一応紹介させてください。
MokurenはGitHubのIssueを扱いやすくするChrome拡張です。リリースした記事も書いてあるのでよければ読んでください
今までのコード
では本題に入っていきます。今回は名前を入力して、ラベルを作成するコードを例にしてみます。input
に名前を記載して、@click
したらラベルが作成される仕様です。
とりあえず作ってみちゃおうで始まる個人開発ではよく見る光景ではないでしょうか??(流石にここまで酷いのはないか)
このコードの良くない部分はcreateLabel
メソッドがいろんな情報を知りすぎている点です。
- Apolloを使うことを知っている
- どのMutationを使うか知っている
- パラメータの形式を知っている
- レスポンスの形を知っている
- AppLabelモデルを作成する方法を知っている
こう言ったコードで起こりうるのは1.再利用しずらい
、2.テスト書きづらい
、3.仕様の変更に弱い
などがあると思います。
このコードをベースとしてアーキテクチャを再設計してみたいと思います。
<template>
<div>
<input v-model="name"></input>
<button @click="createLabel">create</button>
</div>
</template>
<script>
import { Component, Vue } from 'vue-property-decorator';
@Component({
components: {},
})
export default class AppView extends Vue {
name: string = "";
async createLabel(): Promise<void> {
const params = {
name: "name",
}
try {
// ※ラベル作成APIを叩く処理は他の部分でも使い回す可能性がある
const res = await apolloClient.mutate({
mutation: CreateLabelMutation,
variables: params,
});
// ※レスポンスからAppLabelを作成するコードは他にもあるはず
const newLabel = new AppLabel(name: res.name, id: res.id);
// 成功のダイアログを表示
Message.success("created ${newLabel.name}");
} catch(e) {
Message.error("faild create label");
}
}
}
</script>
プログラミングに求めるもの
アーキテクチャーを考え直す前に、プログラミングに求めるものを考えてみました。
普段、実装する中で3つのことを気にかけています。
- **テストが書きやすい。**自分以外の人が使うことを前提としているため、バグだらけでは良いプロダクトとは言えません。そのためにもテストが書きやすい必要があります
- **仕様の変更への強さ。**作って終わりのプロダクトでなければ、プロダクトの改修についていけるコードではないといけません。
- **実装の速さ。**プロダクトとして仮説検証のサイクルを早くしていきたいため、実装の速さも求められます。
1や2が大事なのは言うまでもないですが、個人開発とは言え3の実装の速さも大事だと思っています。コードを書いていればお金がもらえる普段の仕事とは違い、ユーザーからのフィードバックが得られるかわからない状況の中作り続ける個人開発はモチベーションとの戦いでもあるため、早くユーザーへ届けることも大事となります。
再設計しました
再設計をするにあたってクリーンアーキテクチャを採用しました。
しかし、そのまま使うのではなく、不要な部分は省き、逆に足りないところは足しています。
今回は重要なところだけ絞って説明していきます。
(クリーンアーキテクチャについてはこちらがわかりやすかったです)
Vueインスタンス
Vueインスタンスはextends Vue
している部分です。先程のダメなコード例で紹介した部分ですが、知っている情報を絞って役割を明確にします。今回の役割は、
- サービスロジックを呼び出す。クリーンアーキテクチャの
Controller
。 - データの加工。クリーンアーキテクチャの
Presenter
。 - Storeのdispatch
です。
VueインスタンスはクリーンアーキテクチャでいうところのPresenter
とController
の役割を担っています。またStoreのdispatch
を実行する役割を持っています。
なぜ、クリーンアーキテクチャでいうところのPresenter
とController
を別で実装しないかというと、そのコストに合わないと感じたからです。
<template>
<div>
<input v-model="name" />
<button @click="createLabel">create</button>
</div>
</template>
<script>
import { Component, Vue } from 'vue-property-decorator';
@Component({
components: {},
})
export default class AppView extends Vue {
name: string = "";
async createLabel(): Promise<void> {
inputData = new LabelCreateInputData(this.name);
const interactor = inject(Keys.LabelUseCaseKey)
try {
const newLabel = await interactor.create(inputData);
Message.success("created ${newLabel.name}");
} catch(e) {
Message.error("faild create label");
}
}
}
<script>
InputBoundary & Interactor
クリーンアーキテクチャのInputBoundary
とInteractor
です。
ここではサービスロジックを扱います。
このあと紹介するRepositoryのインターフェイス参照することで、テスト時に厄介となるAPIのモックを簡単に扱えるようにしています。
export interface LabelUseCase {
async create(inputData: LabelCreateInputData): Promise<AppLabel>;
}
export class LabelInteractor implements LabelUseCase {
// インターフェイスを参照
constructor(readonly labelRepository: ILabelRepository) {}
async create(inputData: LabelCreateInputData): Promise<AppLabel> {
const params = new LabelCreateApiParams({name: inputData.name});
const label = await this.labelRepository.create(params);
return label;
}
}
Repository & RepositoryParam
クリーンアーキテクチャのGateway
に当たる部分です。
Mokurenでは諸事情により、Rails
、Firebase
、GitHub API
を使っています。APIの参照先をRepositoryで隠すことによってサービスロジックでどのAPIかを意識せずにすみます。こうすることで、例えばAPIの参照先が変わったとしても変更するのはRepositoryだけで済みます。
(ちなみにMokurenでも、段々とFirebaseからRailsに移行していきたいのです)
export interface ILabelRepository {
async create(params: LabelCreateApiParams): Promise<AppLabel>;
}
export class LabelRepository implements ILabelRepository {
async create(params: LabelCreateApiParams): Promise<AppLabel> {
const res = await apolloClient.query({
query: CreateLabelMutation,
variables: params.toMap(),
});
return LabelFactory.fromQueryRes(res);
}
}
export class LabelCreateApiParams implements ApiParams {
constructor(readonly name: string) {}
toMap(): any {
return {
"name": name,
};
}
}
実際のディレクトリ
実際のディレクトリはこんな感じにあるかと思います。
ちなみにView
とcomponents
はどちらも.vue
ファイルが置かれるのですが、その使い分けは
View→完全自己完結型(親からは何も受け取らず自身のView+ViewModelで全て完結するもの。propsを受け付けない。)
components→半自己完結型(必須パラメータのみを親から受け取り、決まった処理を行う共通パーツ。)
でわける想定です。詳しくはこちらを参考にしています。
- content_script
- background
- repository
- label
- interface_label_repository
- inmemory_label_repository
- label_repository
- view
- component
- usecase
- label
- interface_label_usecase
- label_usecase
- input_data
- entity
- label
- label
- api_client(factory?)
テストはどこまで書くか?
Interfaceを使うことでテストが書きやすくなります。とは言え、全てのコードにテストを書いていたらキリがないです。
ではどこをテスト書けば良いでしょうか。
自分の意見としては、Interactor。なぜなら一番変更が多いから。あとViewとかは画面を見ながら実装するからバグに気付きやすいけど、Interactorは気づかないケースも多いかもです。
データを扱うサービスとして大事なのはデータの不整合がないこと。サーバーはデータを扱うことが多いのでテストをしっかり書くが、フロントはサボりがち。Interactorに関してはデータを扱うこともあるのでしっかり書くべきかなと思いました。
参考
実装クリーンアーキテクチャ
クリーンアーキテクチャ完全に理解した
Vue.jsでComposition APIを使ってクリーンアーキテクチャ
Blog