Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

useSWRとDDDの相性について考えてみた

概要

こんにちは。サーバーサイドエンジニアの窪田です。

今回はフロントエンドのuseSWR というhooksの性質と、バックエンドがDDDで設計されている場合の相性について考えていこうと思います。

リンク先で述べられている通り、useSWRの名前はstale-while-revalidateに由来します。 「まずキャッシュからデータを返し(stale)、次にフェッチリクエストを送り(revalidate)、最後に最新のデータを持ってくるという戦略です。」と述べられています。

Next.jsの台頭とhooksの主流化により、データ取得にuseSWRやreact-queryを用いてキャッシュ戦略を取る場合が増えてきました。 フロントエンドの設計方針もこれにより大きく変化してきました。 特に状態管理はRedux主体だったところからhooksでの管理に変わってきました。

このフロントエンドの設計パターンの変化について言及する記事は多く存在する一方で、バックエンドの設計方針との相性について述べられた記事は少なく感じます。 ヤプリはサーバーサイドエンジニアがフロントエンドの設計や開発に携わることも多く、サーバー、クライアント一体の設計について考えることがよくあります。

そこで、今回はDDDで設計したバックエンドとuseSWRというhooksの相性について考えます。 なぜDDDなのかというと、ヤプリではDDDをバックエンドの設計パターンとして採用しているためです。 また、フロントエンドを戦術的DDDのパターンで設計している事例がよくありますが、それについては今回触れず、単純にバックエンドのサーバーサイドアプリケーションが戦術的DDDのパターンで設計・実装されている場合について考えます。

useSWR(useSWRV)の概要

前述したようにvercel社がReact向けに開発したSWRというライブラリがあり、useSWRというhooksを提供しています。 swr.vercel.app

また、このSWRのVue用のライブラリとしてswrvというライブラリがあります。

docs-swrv.netlify.app

ヤプリではReact, Vueのどちらのフレームワークも使っていますが、メインではVueを使っているので、 ここではswrvで例を示します。

useSWRVを使った例

サーバー側からrandomな数字を取得してページ上に表示するアプリケーションを考えます。

view層

// pages/swr-example/index.vue
<script setup>
import {useSwrExample} from "../../../hooks/useSwrExample";

const { fetchWithUseSWRV } = useSwrExample();
const {
  data,
  isValidating,
  error,
  mutate,
} = fetchWithUseSWRV();

function reFetch() {
  mutate()
}
</script>

<template>
  <div>
    <ClientOnly>
      <h2>useSWRVを使う場合</h2>
      <div>
        <p>randomValue(useSWRV): {{ isValidating ? "validating..." : data.randomNumber }}</p>
      </div>
      <div v-if="error">
        <p>{{ error }}</P>
      </div>
      <button @click="reFetch">
        fetchWithUseSWRV
      </button>
    </ClientOnly>
  </div>
</template>

hooks

// hooks/useSwrExample.ts
import useSWRV from "swrv";

const wait = (ms: number) => new Promise((res) => setTimeout(res, ms))
interface RandomNumberData {
  randomNumber: number;
}

// 便宜的にサーバ側との通信する関数として用意
const _fetchRandomNumberData = async (): Promise<RandomNumberData> => {
  await wait(1000)
  const random = Math.floor(Math.random() * 100)
  if (random % 3 === 0) {
    throw new Error('error!!!')
  }
  return {
    randomNumber: random,
  }
}

// hooks本体
export const useSwrExample = () => {
  const fetchWithUseSWRV = () => {
    const { data, error, isValidating, mutate } = useSWRV('fetchWithUseSWRV', _fetchRandomNumberData);
    return { data, isValidating, error, mutate };
  }
  return {
    fetchWithUseSWRV,
  }
}

以上のように実装できます。 useSwrExample hooksの中でuseSWRVを使って、取得データ(data), エラー情報(error), 取得中のステータス(isValidating), refetch用の関数(mutate)を一発で持ってこれるところがポイントです。 詳しい仕様については世の中に記事が溢れているので、ここでは深掘りしません。

アプリケーションの動作は以下のgifのようになります。

一方で、useSWRVではなくnuxt3標準のuseAsyncDataを使って同じことをする例を示します。

view層

// pages/swr-example/index.vue
<script setup>
import {useSyncDataExample} from "../../../hooks/useSyncDataExample";
const {
  fetchWithUseAsyncData,
  state,
  errorMessage,
  isValidating: isValidatingAsyncData,
} = useSyncDataExample();

</script>
<template>
  <div>
    <ClientOnly>
      <h2>useSyncDataを使う場合</h2>
      <div>
        <p>randomValue(useAsyncData): {{ isValidatingAsyncData ? "validating..." : state }}</p>
      </div>
      <div v-if="errorMessage !== null">
        <p>{{ errorMessage }}</P>
      </div>
      <button @click.prevent="fetchWithUseAsyncData">
        fetchWithUseAsyncData
      </button>
    </ClientOnly>
  </div>
</template>

hooks

import {useAsyncData, useState} from "nuxt/app";

const wait = (ms: number) => new Promise((res) => setTimeout(res, ms))
interface RandomNumberData {
  randomNumber: number;
}

const _fetchRandomNumberData = async (): Promise<RandomNumberData> => {
  await wait(1000)
  const random = Math.floor(Math.random() * 100)
  if (random % 3 === 0) {
    throw new Error('error!!!')
  }
  return {
    randomNumber: random,
  }
}

// hooks本体
export const useSyncDataExample = () => {
  // 状態をもつ
  const state = useState<number | null>("state", () => {
    return null;
  })
  const setState = (v: number | null) => {
    state.value = v;
  }

  const isValidating = useState<boolean>("isValidating", () => {
    return false;
  });
  const setIsValidating = (v: boolean) => {
    isValidating.value = v;
  }

  const errorMessage = useState<string | null>('error', () => {
    return null;
  });
  const setError = (e: string | null) => {
    errorMessage.value = e
  }

  const fetchWithUseAsyncData = async () => {
    setIsValidating(true);
    const { data, error } = await useAsyncData(
      'fetchWithUseAsyncData',
      _fetchRandomNumberData,
    )
    setIsValidating(false);

    if (error.value) {
      setIsValidating(false);
      setError(error.value.message)
      setState(null)
      return;
    }

    if (data.value) {
      setError(null)
      setState(data.value?.randomNumber)
    }
  }

  return {
    fetchWithUseAsyncData,
    state,
    errorMessage,
    isValidating
  }
}

hooks内部で、state, isValidating, errorという状態をuseStateによって定義し、これらの状態を管理することが必要になります。 必然的にSWRを使った時よりもコードは長くなります。

useSWRとDDDとの相性

では、DDDとの相性を考えていきます。 結論的には以下の点で相性は良いと思っています。

  • 状態管理の全てをバックエンドに移譲している点
  • 楽観的更新の仕組みがDDDのパフォーマンス観点のデメリットをカバーしてくれる点

状態管理の全てをバックエンドに移譲している点

useSWRはキャッシュ戦略の一環として語られることがほとんどですが、それ以外の観点も考えてみます。 まず、個人的にこのhooksはあらゆる状態管理をバックエンドに移譲している設計であると解釈しています。 useSWRによって返されるdata, error, isValidatingについてはそのままview層で使える形にしてあります。 これは、バックエンドでそのままview層で表示できるdataを用意するという思想なのだと思っています。

実際、上で示した通り、useAsyncDataを使う場合必要になった状態管理はしなくてよく、コード量もかなり減ります。 クライアント側ではサーバーからのレスポンスをそのままほぼ無加工で使うからサーバー側で良い感じのレスポンスを返してください!という思想だと思っています。

これはDDDのdomain層に全ての業務領域の知識を集約するという考え方に非常にマッチします。

少し前に主流だったSPA + Reduxでフロントエンドで複雑な状態管理を行う設計は実はDDDとはそれほど相性が良くありませんでした。 なぜなら、クライアント側で業務領域の知識を持つことが多くなるからです。 フォーム入力のvalidation等は全てフロントエンドで完結することになり、サーバー側のdomain層にない知識が存在しない状態になります。 これにより、フロントエンドでDDDライクな設計をする事例も多く存在していた印象です。

一方で、バックエンドに全ての複雑な処理を移譲するuseSWRの考え方はDDDのメリットがより際立ちかなり相性は良いと考えられます。

楽観的更新の仕組みがDDDのパフォーマンス観点のデメリットをカバーしてくれる点

DDDのデメリットの一つにモデリングによってパフォーマンスが悪化する可能性がある点が挙げられます。 例えば、ユーザー(User)というモデルとユーザーがかく記事(Article)というドメインentityがあったとして、これらは別の集約であるとモデリングしたとします。 更新系のAPIの中で、これらのentityを再構築する際に

-- user repository内
SELECT * FROM users WHERE id = 1;

-- article repository内
SELECT * FROM articles WHERE user_id = 1;

という2個のクエリが発行されることになります。

SELECT * FROM users u INNER JOIN articles a ON a.user_id = u.id and u.id = 1;

何も考えなければ、この1個のクエリで同じ情報は取得できます。 簡単な例を示しましたが、複数のテーブルが絡んできたり、リスト取得をする場合は無視できないパフォーマンスの差が出ることがあります。(モデリングを変更することで対応することになります)。

このように、戦術的DDDの設計は更新APIについてentityの再構築コストの分、重い処理になります。

UI上でその更新処理の長い時間をユーザーに感じさせない楽観的更新の仕組みがswrにはあります。 swr.vercel.app

もちろん自前で作ることは可能ですが、swrvにおいてはuseSWRVから返されるmutateを使い、更新APIをcallすると同時にswrvに変更を通知することで簡単に実現できます。 また、rollbackの仕組みももちろん備えています。 rollbackの仕組みまで自前で作ると結構大変なので、swrvの仕組みに乗るのは良い選択肢に思えます。

クライアント・サーバ間のデータのやり取りは以下のようになります。

従来

swrを用いた場合

違いとしては、swr使用時には更新時に、更新APIから変更後のデータを取得しない点です。 キャッシュ戦略で最適化されたGET APIから更新後のデータを取得します。 従来は更新後にGET APIによってデータ取得するのはアンチパターンですが、swrはそのデメリットを楽観的更新の仕組みとキャッシュ最適化によって補っています。 これは同時にDDDのパフォーマンスにおけるデメリットも補ってくれると考えています。

useSWRとDDDを組み合わせる時の注意点

DDDとの相性は良さそうだということを述べましたが、 そもそも、プロダクトとしてフロントで表示する情報がデータストアの情報との整合性が強く求められる場合、useSWRをわざわざ使う必要はないです。 特にuseSWR(useSWRV)を使うメリットがない場合、 useFetch, useSyncData等のhooksで情報を取得し、自前のフロントエンドの状態管理の仕組みでフロントの情報は管理する選択肢をとるべきだとは思います。 ちなみに、useFetch, useSyncDataを使った場合も、バックエンドに業務領域の複雑な処理を押し付けるように設計すれば上で述べていたuseSWRとDDDの相性の良さによる恩恵と同じような恩恵は受けられます。 つまり、useSWRとDDDの相性は良いが、前提としてプロダクトの特性を考えてそもそもuseSWRを使うかどうかはよく吟味するべきだということです。

まとめ

useSWR(useSWRV)を含めた最近のフロントエンドの設計とバックエンドのDDD設計の組み合わせについては意外と述べた記事は少なかったため考えてみました。 ヤプリでは組織体制的にはサーバーサイドエンジニア、フロントエンドエンジニアにチームが分かれています。 しかし、2チームの距離は近く、よく密にコミュニケーションを取ります。 サーバーサイドエンジニアでもWebのフロントエンドの開発も日常的にします。 そのため、バックエンドの設計・技術とフロントエンドの設計・技術の相性を考えることもよくあります。 このようなバックエンド、フロントエンドの垣根なくプロダクトの設計について考えられる点はヤプリで働く上で面白い点の1個だと思っています。 少しでも興味があればぜひカジュアル面談でお話しできればと思います!!

open.talentio.com