🐕

【Nuxt/Vue.js】React好きによるNuxt3におけるスキーマ駆動開発 | Offers Tech Blog

2022/09/08に公開

こんにちは、Offers を運営している株式会社 overflow の Software Engineer(主戦場はフロントエンド)の Kazuya です。今回は新規プロダクトの開発でご参画頂いている、nonsugarless さんにご寄稿いただいたテックブログです。

はじめに

こんにちは、フレキシブルメンバーとして Offers の運営会社 overflow をお手伝いしている nonsugarless です。
Nuxt3 の RC 版がリリースされ 4 ヶ月経過しました。 overflow では、新プロダクトのフロントエンドにいち早く Nuxt3 を導入し、元々 Nuxt2 の頃から行っていたスキーマ駆動開発を Nuxt3 でどう行うか知見が溜まってきたので、個人的ベストプラクティスとしてご紹介します。

スキーマ駆動開発については以下の記事もご紹介しているのでご覧ください。

https://zenn.dev/offers/articles/20220411-open-api-schema

なぜこれが必要なのか

いきなりですが Schema から生成されたコードの例をお見せします。

src/types/api/api.ts
/**
 *
 * @export
 * @interface UserResponse
 */
export interface UserResponse {
    /**
     *
     * @type {User}
     * @memberof UserResponse
     */
    user: User;
}

...

export class UserApi extends BaseAPI {
    /**
     *
     * @summary ユーザー新規登録
     * @param {string} userCode
     * @param {string} password
     * @param {string} passwordConfirmation
     * @param {string} name
     * @param {*} [options] Override http request option.
     * @throws {RequiredError}
     * @memberof UserApi
     */
    public createUser(userCode: string, password: string, passwordConfirmation: string, name: string, options?: any) {
        return UserApiFp(this.configuration).createUser(userCode, password, passwordConfirmation, name, options).then((request) => request(this.axios, this.basePath));
    }

    /**
     *
     * @summary ユーザー取得
     * @param {string} userCode
     * @param {*} [options] Override http request option.
     * @throws {RequiredError}
     * @memberof UserApi
     */
    public getUser(userCode: string, options?: any) {
        return UserApiFp(this.configuration).getUser(userCode, options).then((request) => request(this.axios, this.basePath));
    }
}

これは OpenAPI Generator の TypeScript-axios によって生成したもの[1] ですが、これをこのままフロントエンド扱うには以下のような問題があります。

  • loading, error などのリクエスト状況・結果を管理するステートがコンポーネント側に寄ってしまい、それが増えていくことによるコードの肥大化
  • 上記をコンポーネントやページごとに何度も定義する必要がある煩雑さ

これを解決するために下記のような構成で扱うことにしました。

コードの構成

まず図示するとこうなります。

コードの構成図

後に詳しく触れるのでまずは概要だけ

  1. Schema から生成されたコード(Schema コード
  2. Schema コードをまとめる service 層(service 層
    • API ドメインの設定などアプリケーション共通の設定もここで行う
  3. API リクエストのステート管理を抽象化した hook[2]抽象化 hook
    • GET とその他 HTTP メソッドでは管理したいものやタイミングが違うので hook を分ける
  4. エンドポイントごとに API リクエストを扱う hook(具象化 hook
    • 抽象化 hook に service 層から import した API のリクエスト処理を噛ませてエンドポイントごとに個別の hook を作る
  5. 画面・機能ごとに必要な具象化 hook をまとめた hook(画面 hook
    • 具象化 hook 1 つの情報で済む画面ならこのレイヤーは無くても良さそう

こうすることで、リクエスト単位のステート管理を共通化でき、かつリクエスト単位・機能単位でのスケーラビリティも担保できます。

※名称が長いため以降、それぞれの()内の名称で呼ぶことにします

実装

1. Schema コード

最初にお見せした例をそのまま使います

2. service 層(src/services/)

src/services/api.ts
import { AxiosInstance } from 'axios';
import { axiosInstance } from '@/lib/axios';
import { UserApi, ProjectApi } from '@/types/api';

export class ApiService {
  basePath: string;
  conf: Configuration;
  instance: AxiosInstance;

  constructor() {
    this.basePath = import.meta.env.VITE_API_URL;
    this.conf = new Configuration();
    this.instance = axiosInstance;
  }

  userApi() {
    return new UserApi(this.conf, this.basePath, this.instance);
  }
  projectApi() {
    return new ProjectApi(this.conf, this.basePath, this.instance);
  }
}

export const apiService = new ApiService();

最後の画面 hook で使うために ProjectApi をしれっと増やしてますが、やっていることは UserApi と同じです。

3. 抽象化 hook (src/composables/)

Nuxt3 では hook の置き場所は composables ディレクトリ です。[3]

GET とその他の情報を更新する HTTP メソッドではステートが変わるタイミングや扱い方が違うので、GET 用とその他用にそれぞれ hook を作ります。
便宜的にその他の方を Write 系と呼ぶことにします。(もっと良い命名がありそう)

GET

src/composables/useGet.ts
import { AxiosError, AxiosResponse } from 'axios';

export const useGet = <T extends (...args: unknown[]) => unknown, U>(
  key: string,
  doGet: (...T) => Promise<AxiosResponse<U>>
) => {
  const data = useState<U>(`${key}_get_data`, () => null);
  const loading = useState(`${key}_get_loading`, () => true);
  const error = useState<AxiosError>(`${key}_get_error`, () => null);

  const get = async ({ params }: { params?: Parameters<T> }) => {
    loading.value = true;
    error.value = null;
    try {
      const res = await doGet(...(params ?? []));
      data.value = res.data;
      return res.data;
    } catch (err) {
      error.value = err;
    } finally {
      loading.value = false;
    }
  };

  return {
    data: data.value,
    get,
    loading: readonly(loading).value,
    error: readonly(error).value
  };
};

ジェネリクスで API コールする関数とその返り値の型を渡すことで型の恩恵を受けられるようにしています。

今回はエラーの型はすっ飛ばしてますが、実際のプロダクトでは型引数を増やしてエラーの型を渡してあげた方が良いでしょう。
ちなみに useState はコンポーネント間でステートを共有できる hook です。[4]

Write 系

src/composables/useWrite.ts
import { AxiosError, AxiosResponse } from 'axios';

export const useWrite = <T extends (...args: unknown[]) => unknown, U = void>(
  key: string,
  doWrite: (...T) => Promise<AxiosResponse<U>>
) => {
  const loading = useState(`${key}_write_loading`, () => true);
  const error = useState<AxiosError>(`${key}_write_error`, () => null);

  const write = async ({
    params,
    onSuccess,
    onError
  }: {
    params: Parameters<T>;
    onSuccess?: (data: U) => void;
    onError?: (error: AxiosError) => void;
  }) => {
    loading.value = true;
    error.value = null;

    try {
      const res = await doWrite(params);
      onSuccess?.(res.data);
    } catch (err) {
      error.value = err;
      onError?.(err);
    } finally {
      loading.value = false;
    }
  };

  return {
    write,
    loading: readonly(loading).value,
    error: readonly(error).value
  };
};

4. 具象化 hook (src/composables/api/)

いよいよエンドポイントごとの hook です。
抽象化 hook の引数に API をコールする関数を渡して、任意のタイミングでも GET できるように getLazy というパラメータを加えています。
ちなみに、もしデータの整形が必要な場合ここか後述の画面 hook で行いましょう。

GET

src/composables/api/user/useGetUser.ts
import { useGet } from '@/composables/useGet';
import { apiService } from '@/services';
import { UserResponse, UserApi } from '@/types/api';

export const useGetUser = ({
  params,
  getLazy
}: {
  params: Parameters<UserApi['getUser']>;
  getLazy?: boolean;
}) => {
  const { get, data, loading, error } = useGet<
    UserApi['getUser'],
    UserResponse
  >(
    'getUser',
    apiService.userApi().getUser.bind(apiService.conf) // BaseAPIのConfigrationインスタンスをservice層で作ったものに紐づける
  );

  watchEffect(() => {
    if (getLazy) {
      return;
    }
    get({
      params
    });
  });

  return {
    user: data?.user,
    get,
    loading,
    error
  };
};

Write 系

src/composables/api/user/useCreateUser.ts
import { useWrite } from '@/composables/useWrite';
import { apiService } from '@/services';
import { UserApi, UserResponse } from '@/types/api';

export const useCreateUser = () => {
  const { write, loading, error } = useWrite<
    UserApi['createUser'],
    UserResponse
  >('createUser', apiService.userApi().createUser.bind(apiService.conf));

  return {
    write,
    loading,
    error
  };
};

5. 画面 hook (src/composables/page/)

最後に画面 hook です。
例えばユーザーとその所属するプロジェクトを表示し、ユーザーを追加できる機能を持つ画面 Foo があるとすると、それに対する hook はこのようになります。

src/composables/page/useFoo.ts
import { useGetProjects } from "@/composables/api/project/useGetProjects";
import { useCreateUser } from "@/composables/api/user/useCreateUser";
import { useGetUser } from "@/composables/api/user/useGetUser";

export const useFoo = (userId: string) => {
  const {
    user,
    loading: userLoading,
    error: userError,
  } = useGetUser({
    params: [userId],
    getLazy: false,
  });

  const {
    project,
    loading: projectLoading,
    error: projectError,
  } = useGetProjects({
    params: [userId],
    getLazy: false,
  });

  const { createUser } = useCreateUser();

  return {
    user,
    project,
    isLoadedAll: !userLoading && !projectLoading,
    errors: [userError, projectError],
    createUse,
  };
};

やっていて感じた Pros/Cons

Pros

  • レスポンスをゴニョゴニョするコードを隠蔽でき、コンポーネント内で気にしないで良い
  • スキーマが少々変わっても hook を変更しなくて良い

Cons

  • エンドポイントの数だけ hook を作るため、composables ディレクトリが肥大化しかねない
  • 通信までのレイヤーが増えることによるコードの追いづらさ

おわりに

今回は、Nuxt3 でのスキーマ駆動開発手法のご紹介させていただきました。composables が追加されたことで良くも悪くもかなりやんちゃできるようになったので、何かしらの設計を取り入れておくべきだとは思います。この記事が Nuxt3 を触られる方の参考になれば幸いです。

関連記事

https://zenn.dev/offers/articles/20220523-component-design-best-practice
https://zenn.dev/offers/articles/20220623-nuxt3-server-ua
https://zenn.dev/offers/articles/20220620-openapi-generator

脚注
  1. ジェネレータによっては Class ではなく関数で生成してくれるものもあります。 ↩︎

  2. Vue の世界観で言うと composable ですが、筆者は React 脳で頭に入ってこないので hook と呼びます。ちなみに composable とはステートフルなロジックをカプセル化して再利用する機能だそうです 。 ↩︎

  3. ここに置くことで import を省略できる Auto Imports を利用できますが、今回は分かりやすいようにちゃんと書きます。 ↩︎

  4. React と違いグローバルにステートを保存するため、第一引数にアプリケーション内で一意の key を要求します。 ↩︎

Offers Tech Blog

Discussion