👋

Quasarのbootでアダプターの依存性を注入する

2024/07/05に公開

はじめに

フロントエンドの開発をするときに、SPAなどではバックエンドサービスのAPIとやりとりすることが多いと思います。しかし新機能を追加する際などバックエンドサービスがまだないこともあると思います。
そのようなときに、APIとの疎通部分をアダプターパターンでモックなどに簡単に切り替えられるように実装しておくと開発がスムーズになります。
QuasarでSPAを開発したときに、アダプターの依存性の注入をbootで行い、なかなか気に入っている実装になりましたので、ご紹介します。

Quasarのboot

Quasarのbootディレクトリに置かれるファイルは、アプリケーションが起動する際に実行される特定の初期化処理を設定するためのファイルです。これにより、アプリケーション全体で使用するプラグインやミドルウェア、グローバル設定などを一元的に管理することができます。

ディレクトリ構成

以下のディレクトリ構成は例としてブログ記事(Post)というコアドメインにフォーカスしたものになります。他にも[ユーザー(User)]や[カテゴリー(Category)]などがある場合、コアドメインごとにファイルを分けて管理します。

プロジェクトルート
└── src
    ├── adapter
    │   ├── axios
    │   │   └── AxiosPostAdapter.ts
    │   ├── mock
    │   │   └── MockPostAdapter.ts
    │   ├── session
    │   │   └── SessionPostAdapter.ts
    │   └── PostAdapterInterfacce.ts
    ├── api
    │   └── PostApi.ts
    ├── boot
    │   └── api.ts
    └── pages
        └── posts
            └── indexPage.ts

ディレクトリ構成の説明

  • src/adapter/ の直下に axios mock session という3種類のアダプターを作ります。
    • axios・・・HTTPリクエストでバックエンドと通信してレスポンスを返すもの。
    • mock・・・静的な固定レスポンスを返すもの。
    • session・・・SessionStorageとやりとりしてレスポンスを返すもの。
  • src/adapter/PostAdapterInterfacce.ts はアダプターのインターフェースです。
  • src/api の直下に依存性が注入できるようになっているクライアントであるapiファイルを置きます。
  • src/boot.api.ts で全てのクライアントに依存性を注入してvueが扱うapiインスタンスを作ります。

アダプターインターフェース

PostのAPIが持つべきアクションのインターフェースだけを定義します。実装はアダプターのほうに書きます。
今回はブログ記事の取得と作成だけにフォーカスします。

src/adapter/PostAdapterInterfacce.ts
export interface PostAdapterInteface {
  // ブログ記事の取得
  getPost(id: string): Promise<PostGetAttributes>;
  // ブログ記事の作成
  createPost(attributes: PostCreateAttributes): Promise<PostGetAttributes>;
}

axiosアダプター

axiosを使った実装を書きます。

src/adapter/axios/AxiosPostAdapter.ts
import { api } from 'boot/axios';
import { axiosErrorHandler } from 'src/error/handler/axiosErrorHandler';
export class AxiosPostAdapter implements PostAdapterInteface {
  // ブログ記事の取得
  async getPost(id: string): Promise<PostGetAttributes> {
    try {
      const res = await api.get(`/posts/${id}`);
      return res.data;
    } catch (e: any) {
      throw axiosErrorHandler(e);
    }
  }
  // ブログ記事の作成
  async createPost(attributes: PostCreateAttributes): Promise<PostGetAttributes> {
    try {
      const res = await api.post('/posts', attributes);
      return res.data;
    } catch (e: any) {
      throw axiosErrorHandler(e);
    }
  }
}

mockアダプター

mockを使った実装を書きます。ポイントは外部ファイルで用意しておいた静的なデータを呼び出して返している部分と、通信が発生しているときのように1秒遅延させている点と、Promiseを返している点です。アダプターインターフェースでPromiseを返すことが期待されているためそれに合わせています。

src/adapter/mock/MockPostAdapter.ts
import { Posts } from 'src/mock/posts';
export class MockPostAdapter implements PostAdapterInteface {
  getPost(id: string): Promise<PostGetAttributes> {
    return new Promise(function (resolve) {
      setTimeout(() => {
        try {
          const posts = Posts;
          const res = posts.find((item: { id: string }) => item.id == id);
          if (!res) throw new NotFoundException();
          resolve(res);
        } catch (e) {
          throw new Exception();
        }
      }, 1000);
    });
  }
  createPost(attributes: PostCreateAttributes): Promise<PostGetAttributes> {
    return new Promise(function (resolve) {
      setTimeout(() => {
        try {
          const posts = Posts;
          const res = posts.find((item: { id: string }) => item.id == '1');
          if (!res) throw new NotFoundException();
          resolve(res);
        } catch (e) {
          throw new Exception();
        }
      }, 1000);
    });
  }
}

sessionアダプター

sessionを使った実装を書きます。ポイントは実際にデータを作成して保存できるようにするために、createPostではidを生成しています。

src/adapter/session/SessionPostAdapter.ts
import { SessionStorage, uid } from 'quasar';
export class SessionStoragePostAdapter implements PostAdapterInteface {
  getPost(id: string): Promise<PostGetAttributes> {
    return new Promise(function (resolve) {
      setTimeout(() => {
        try {
          const stringPosts: string | null = SessionStorage.getItem('posts');
          if (!stringPosts) throw new NotFoundException();
          const posts = JSON.parse(stringPosts);
          const res = posts.find((item: { id: string }) => item.id == id);
          if (!res) throw new NotFoundException();
          resolve(res);
        } catch (e) {
          throw new Exception();
        }
      }, 1000);
    });
  }
  createPost(attributes: PostCreateAttributes): Promise<PostGetAttributes> {
    return new Promise(function (resolve) {
      setTimeout(() => {
        try {
          const stringPosts: string | null = SessionStorage.getItem('posts');
          let posts = [];
          if (stringPosts) {
            posts = JSON.parse(stringPosts);
          }
          const Id = uid();
          const input = {
            id: Id,
            title: attributes.title,
            body: attributes.body,
          };

          const post = new PostModel(input);
          posts.push(post.getAttributes());
          SessionStorage.set('posts', JSON.stringify(posts));
          resolve(post.getAttributes());
        } catch (e) {
          throw new Exception();
        }
      }, 2000);
    });
  }
}

api(クライアント)

アダプターを呼び出すクライアントであるapiです。今回はアダプターの話題なので、省略してしまっているのですが、ここでアダプターが返した内容をそのままvueに伝えるのではなく、その内容を元にモデルを生成して返しているところもこだわりポイントだったりします。

src/api/PostApi.ts
export class PostApiClass {
  adapter: PostAdapterInteface;
  constructor(adapter: PostAdapterInteface) {
    this.adapter = adapter;
  }
  async getPost(id: string): Promise<PostModel> {
    try {
      const post = await this.adapter.getPost(id);
      return new PostModel(post);
    } catch (e: any) {
      throw apiErrorHandler(e);
    }
  }
  async createPost(attributes: PostCreateAttributes): Promise<PostModel> {
    try {
      const post = await this.adapter.createPost(attributes);
      return new PostModel(post);
    } catch (e: any) {
      throw apiErrorHandler(e);
    }
  }
}

boot(依存性の注入)

使いたいアダプターをbootで指定しています。envファイルを読み取って切り替えられるようにするのもありだと思います。

src/boot/api.ts
/********
 * Postドメイン
 ********/
import { PostApiClass } from 'src/api/PostApi';
// axiosを使う場合
import { AxiosPostAdapter } from 'src/adapter/mock/AxiosPostAdapter';
const PostApi = new PostApiClass(new AxiosPostAdapter());
// mockを使う場合
//import { MockPostAdapter } from 'src/adapter/mock/MockPostAdapter';
//const PostApi = new PostApiClass(new MockPostAdapter());
// sessionを使う場合
//import { SessionPostAdapter } from 'src/adapter/mock/SessionPostAdapter';
//const PostApi = new PostApiClass(new SessionPostAdapter());

export { PostApi };

vueからの呼び出し

ブログ記事を作成する際のvueからの呼び出しイメージです。vueでは、どのアダプターに接続されているのかは意識する必要はなく、アダプターを切り替えたとしてもvueファイルをいじる必要はありません。

src/pages/posts/indexPage.vue
<script setup lang="ts">
import { PostApi } from 'src/boot/api';
const createPost = async (postCreateAttributes: PostCreateAttributes): Promise<void> => {
  try {
    await PostApi.createPost(postCreateAttributes);
  } catch (e) {
    error(e);
  }
};
</script>

おわりに

少し長くなってしまい、わかりづらかったかもしれませんが、いかがでしたでしょうか。今回はaxios、mock、sessionだけでしたが、axiosではなく、fetchに変えたいなと思った時でも、アダプターを増やして、bootで切り替えるだけでよいので、便利だと感じています。
日々改良を加えてもっとよい実装ができるようになりたいです。

レスキューナウテックブログ

Discussion