DBの値をフロントエンドやバックエンドで定数として扱う際のリスクと改善策

概要

今まで、DBの値をFrontendで定数として持って使うケースや、テーブル更新のコストが高いと判断してBackendで値を保持・運用するケースをたまに見てきました。
もちろん軽量なプロジェクトやプロトタイプの製作では有効な場合もありますが、保守性や変更容易性を損なってしまうこともあるので、それについて記載します。

DBの値を定数で持つ例

例えばユーザーが配送の梱包サイズをセレクトボックスなどで選択するUIがあったとします。
これを定数で運用するとどうなるか見ていきます。

フロントエンドの例

// 梱包サイズの定数(実際は定数ファイルなどで定義されていることが多いと思います)
const PACKAGE_SIZES = [
  { id: 1, name: "小" },
  { id: 2, name: "中" },
  { id: 3, name: "大" }
];

// セレクトボックスでの表示
const PackageSizeSelect = () => (
  <select>
    {PACKAGE_SIZES.map(size => (
      <option key={size.id} value={size.id}>{size.name}</option>
    ))}
  </select>
);

このプログラムの何が問題かというと、梱包サイズが増えた場合にフロントエンドの改修が必ず必要になります。
さらに、DBでidやnameが更新された時、合わせてフロントエンドも変更しないと追従できません。
もしかしたら将来、FEで定数として持っていることを知らないエンジニアが、FEを合わせて更新することを忘れてしまう可能性もあり得ます。

もちろん、追加や更新がなく、FEで持っても問題ないケースも存在すると思います。

バックエンドの例

次にバックエンドで定数でもってしまう例を考えてみます。
バックエンドでこうした実装になってしまう例としては、例えば梱包サイズにtypeを表示する要件が追加されたが、運用されているテーブルのカラム追加はコストが高いので、コードで補完しよう、というようなときだと思います(自分の経験上)。

// 梱包サイズとそのタイプをPHPで定義
$packageSizes = $stmt->fetchAll(PDO::FETCH_ASSOC);

// 各梱包サイズにタイプを動的に追加
foreach ($packageSizes as $key => $size) {
    switch ($size['id']) {
        case 1:
            $packageSizes[$key]['type'] = '軽量';
            break;
        case 2:
            $packageSizes[$key]['type'] = '標準';
            break;
        case 3:
            $packageSizes[$key]['type'] = '重量';
            break;
    }
}

// 梱包サイズとタイプをクライアントに送るAPIエンドポイントの例
$app->get('/package-sizes', function ($request, $response) use ($packageSizes) {
    return $response->withJson($packageSizes);
});

この場合、typeをテーブルの情報として持っておらず、PHPで持っているので、テーブルからは読み取れず、実装を見ないとなぜこう表示されているのか分かりません。
またtypeの追加や更新があった場合に、カラムを追加していればSQLの追加や更新だけで対応できますが、この例だと毎回PHPの修正とデプロイが必要になります。

FE、BEの改修がいらなくなるパターンを考える

ではどうすれば良いかというと、BEはテーブルの値を返すだけ、FEはBEから返す値を使うだけ、という構成にすれば良いと思います。

フロントエンド

  const [sizes, setSizes] = useState<PackageSize[]>([]);

  useEffect(() => {
    // APIから梱包サイズのデータを取得する想定
    fetch('/api/package-sizes')
      .then(response => response.json())
      .then(data => setSizes(data))
      .catch(error => console.error('Error fetching package sizes:', error));
  }, []);

  return (
    <ul>
      {sizes.map(size => (
        <li key={size.id}>
          ID: {size.id}, 名前: {size.name}, タイプ: {size.type}
        </li>
      ))}
    </ul>
  );

実装は簡略化して書いてます。
カラムが追加されて、それを新しく表示する場合は対応が必要ですが、梱包のidやname, typeが追加、更新、削除されてもFEの改修は必要がなくなりました。

サーバーサイド

$packageSizes = $stmt->fetchAll(PDO::FETCH_ASSOC);

$app->get('/package-sizes', function ($request, $response) use ($packageSizes) {
    return $response->withJson($packageSizes);
});

バックエンドも梱包のidやname, typeが追加、更新、削除されても改修は基本必要なくなりました。
もちろんこの実装は簡略化しており、更新内容によってはValueObjectの更新だったり、カラム追加などではDTOの更新などをする必要はあると思います。

総合的には毎回FEやBE対応をする必要がなくなって、SQL対応のみで良くなるケースが増え、保守性や変更容易性を損なわない仕組みになったと思います。

複合インデックスの効果と注意点

概要

インデックスには複合インデックスというものがあります。
これは単一のカラムではなく、複数のカラムを組み合わせてインデックスを貼るもので、組み合わせで検索されるカラムに貼ると効率的です。
複合インデックスを使うにあたって、いくつか注意点があるのでまとめてみました。

参考

MySQL :: MySQL 8.0 リファレンスマニュアル :: 8.3.6 マルチカラムインデックス

複合インデックスの働き

以下のテーブルを用意します。

CREATE TABLE Users (
    UserID INT AUTO_INCREMENT PRIMARY KEY,
    Email VARCHAR(255) NOT NULL,
    Name VARCHAR(100) NOT NULL,
    Age INT,
    Gender VARCHAR(10),
    Status VARCHAR(50),
    UNIQUE(Email),
    INDEX Email_IDX (Email),
    INDEX Name_Status_IDX (Name, Status)
);

複合インデックスはNameとStatusに貼っています(この組み合わせの検索が多いと仮定)。
この時、どのような検索でインデックスが働くか見てみます。実行環境はMySQL8.0です。

SQL

keyが使用されているか確認します。

NameをWhere句に指定

explain select * from Users where Name = 'taro';

結果

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE Users null ref Name_Status_IDX Name_Status_IDX 402 const 1 100.00 null

これはシンプルにName_Status_IDXが使われています。

StatusをWhere句に指定

explain select * from Users where Status = 'active';

結果

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE Users null ALL null null null null 1 100.00 Using where

keyはnullとなっていて、インデックスが効いていません。 これは、最初にインデックス指定されたNameに基づいてインデックスが作成されて、それに基づいてさらにStatusのインデックスが作成されているためです。そのため、Status単体のインデックスは効いていません。

NameとStatusをandでWhere句に指定

explain select * from Users where Name = 'taro' and Status = 'active';

結果

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE Users null ref Name_Status_IDX Name_Status_IDX 605 const,const 1 100.00 null

こちらはNameとStatusがandで検索条件に入っているため、複合インデックスが効いています。

NameとStatusをorでWhere句に指定

explain select * from Users where Name = 'taro' or Status = 'active';

結果

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE Users null ALL Name_Status_IDX null null null 1 100.00 Using where

Name or Statusとそれぞれの検索を行うようになっており、Nameのインデックスを元にStatusのインデックスを辿る、という動作ができないためインデックスは使用されていません。

まとめ

複合インデックスの挙動を理解することで、意図せずインデックスが使用されないケースを防ぐことが大事ですね。

anyとunknownの違いとどちらを使うべきかについて

概要

TypeScriptにはanyとunknownという型があります。
これらは型チェックを無効にするものですが、動作には大きく違いがあります。

anyとunknownの動作の違い

例えば以下の実装があったとします。 ( v5.3.3の環境で実行しています)

const un: unknown = 'hello'
const an: any = 'hello'

console.log(un.length) // 'un' is of type 'unknown'.
console.log(an.length) // 5

console.log(un.charAt(3)) // 'un' is of type 'unknown'.
console.log(an.charAt(3)) // "l" 

unknownは代入された変数が持つ型(今回はstring)のプロパティやメソッドを使用できませんが、anyは使うことができます。

逆に言えば、プロパティやメソッドを使う必要がない場合は、anyではなくunknownを使った方が意図しない使い方を縛れるというメリットがあります。
なのでanyである必要がないと判断した場合、unknownを使う方が良さそうです。

ReactでコンポーネントからMarkDown ファイルを呼び出す

概要

marked - npmを使用してNext.jsのプロジェクトから.mdファイルを試しに呼び出してみたことの備忘録です。
markedのバージョンは "marked": "^10.0.0",です。
公式ではdemoページを見ることができます(左がmdファイルで右が出力されるHTML)。

手順

まずはプロジェクトにインストールします。

npm i marked

プロジェクトから読み込める場所に.mdファイルを用意します。

次に、コンポーネントから以下のように呼び出します。

import fs from 'fs'
import path from 'path'
import { marked } from 'marked'
import DOMPurify from 'dompurify'

const markdown = fs.readFileSync(filePath, 'utf8') // .mdファイルの読み込み
const content = marked(markdown) // markedに渡すとstringが返ってきます

const cleanContent = DOMPurify.sanitize(content)
  return (
    <div className='course-container p-8'>
      {/* HTMLコンテンツを安全にレンダリングする */}
      <div dangerouslySetInnerHTML={{ __html: cleanContent }} />
    </div>
  )

DOMPurifyはDOMを安全にsanitizeしてくれるライブラリで、必須ではありません。markedの返り値をそのまま使うこともできます。
ただmdファイルから生成されたコンテンツにXSS攻撃などの脆弱性があった時にsanitizeしてくれるので、外部ファイルなどからDOMを生成するときに覚えておくと良いと思います。

useStateは必要最低限が良い

概要

必要不可欠でない state 変数をすべて削除するという記事を読んで覚えておきたいと思ったのでブログに書こうと思います。
そもそもuseStateは、UIを操作するための状態管理の仕組みです。
例えば、フォームが送信中であることや、入力エラーがあることを表現するために、以下のコードを用意します。

const [isSubmitting, setIsSubmitting] = useState(false)
const [isError, setIsError] = useState(false)

フォーム送信のtry catch文があったとしたら、以下のようなsetterになると思います(雰囲気を掴む程度のコードです)。

    try {
      setIsSubmitting(true);
      await submit(value);
    } catch (err) {
      setIsError(true);
    } finally {
      setIsSubmitting(false);
    }

なお完全な余談ですが、useStateに限らず予期しないエラーや考慮不足によって延々と送信中でローディングがグルグルする処理を見たのは1度や2度ではないので、注意したいですね。自分も新人の頃はやった記憶が、、。

useStateの注意点

useStateの注意点としては、やはり状態管理はシンプルに保つ点だと思います。
複雑な状態管理は、意識しないといけない状態が多くあり、状態ごとに矛盾が生じないようにしなければなりません。
そのため、見通しも悪くバグが生じやすいコードになってしまうと考えられます。

不必要なuseStateを削除する

必要不可欠でない state 変数をすべて削除するの記事では、stateを減らす考え方について解説されているので紹介します。
元々、7つのstateがありました。これを不必要なものを削っていきます。

const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [isEmpty, setIsEmpty] = useState(true);
const [isTyping, setIsTyping] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);

この state で矛盾は生じないか?

以下のように解説されています。

例えば、isTyping と isSubmitting の両方が true となることはありえません。矛盾がある state とは通常、state の制約が十分でないことを意味します。2 つのブール値の組み合わせは 4 通りありますが、有効な状態に対応するのは 3 つだけです。このような「ありえない」state を削除するためには、これらをまとめて、typing、submitting、または success の 3 つの値のうちどれかでなければならない status という 1 つの state にすればよいでしょう。

それぞれの状態を矛盾なく管理しなければいけない状況を避けて、かつstatusの状態だけ見れば良いようにしています。

同じ情報が別の state 変数から入手できないか?

以下のように解説されています。

もうひとつの矛盾の原因は、isEmpty と isTyping が同時に true にならないことです。これらを別々の state 変数にすることで、同期がとれなくなり、バグが発生する危険性があります。幸い、isEmpty を削除して、代わりに answer.length === 0 をチェックすることができます。

stateで管理せずとも分かることは、わざわざstateにしないようにしています。

別の state 変数の逆を取って同じ情報を得られないか?

以下のように解説されています。

isError は不要です。なぜなら代わりに error !== null をチェックできるからです。

errorのstateからエラーかどうか分かるため、isErrorは不必要ですね。

結果

不必要なstateを削って、7つから3つに減りました。

const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [status, setStatus] = useState('typing'); // 'typing', 'submitting', or 'success'

とりあえずstateに入れておくのは手軽ではありますが、結局後々管理が辛くなりがちなので、必要な分だけ管理する思考は大切ですね。

useEffectはなるべく使わない方が良いのかということについて

概要

Reactの公式ページにはエフェクトは必要ないかもしれないというページがあります。
必要のない場面でuseEffectを使うことを避けるという記事で、この記事について自分の経験や理解したことについて書こうと思います。

必要のない箇所でuseEffectを使う

useEffect自体は必要ない場面ではなるべく使わない、という知識がなかった時に自分がやってしまったアンチパターンを振り返ってみます。
自分が追加したコードで、ユーザーが入力した値をuseEffectで監視して入力値によってフォームの制御などを行う処理がありました。
これは公式の不要なエフェクトの削除方法 という項目で、以下のように書かれています。

ユーザイベントの処理にエフェクトは必要ありません。例えば、ユーザが製品を購入したときに /api/buy POST リクエストを送信し、通知を表示したいとします。購入ボタンのクリックイベントハンドラでは、何が起こったかが正確にわかります。エフェクトが実行される時点では、ユーザが何をしたのか(例えば、どのボタンがクリックされたのか)はもうわかりません。したがって、通常は対応するイベントハンドラでユーザイベントを処理するべきです。

しかし、過去の自分は既存のuseEffectに新しいdependenciesと処理を追加しました。
これをすると何が起きるのかと言うと、まず処理が追いづらかったです。イベントハンドラは直感的ですが、useEffectで項目の監視をすると、いつどんな時にdependenciesの値が変更されるのか常に意識する必要がありました。
しかも複数のdependenciesを扱っていたため、一つ処理を変えるだけでも常に複数項目を意識しなければならないため、処理が追いづらい上にバグを混入させやすいというコードになっていました。
このような経験をしていたため、エフェクトは必要ないかもしれないという公式ページを見てかなり内容に腑が落ちました。

特に

コンポーネント内の生の useEffect の呼び出しが少なければ少ないほど、アプリケーションのメンテナンスは容易になります。

という記述は、その通りと感じました。

公式が記事の内容をまとめてくれているので引用します

  • レンダー中に計算できるものであれば、エフェクトは必要ない。

  • 重たい計算をキャッシュするには、useEffect の代わりに useMemo を追加する。

  • コンポーネントツリー全体の state をリセットするには、異なる key を渡す。

  • prop の変更に応じて一部の state をリセットする場合、レンダー中に行う。

  • コンポーネントが表示されたために実行されるコードはエフェクトに、それ以外はイベントハンドラに入れる。

  • 複数のコンポーネントの state を更新する必要がある場合、単一のイベントで行うことが望ましい。

  • 異なるコンポーネントの state 変数を同期しようと思った際は、常に state のリフトアップを検討する。

  • エフェクトでのデータフェッチは可能だが、競合状態を回避するためにクリーンアップを実装する必要がある。

hooks全般に言えることですが、いつ使うべきか、そして使わないべきかは意識した開発を行いたいですね。

useMemoの役割、いつ使うべきかと注意点について

概要

useMemoの役割や使わない方が良い場合についてまとめようと思います

useMemoの役割

useMemoは計算結果のキャッシュを保持して高速化する目的で使用されます。

  const tasks = useMemo(
    () => filterTasks(term),
    [term]
  );

上記の例では初回レンダー時に計算結果がキャッシュされて、dependenciesに登録されたtermの値が変更されたら、再計算して計算した値をキャッシュします。

細かい説明はここで書ききらず公式にお任せします。

useMemoをいつ使うべきか

useMemoを使う上で、公式では使うケースは以下に限られると書かれているので引用します。
とりあえずuseMemoを使う、という運用は、重大な害はないがコードが読みにくくなるデメリットがあると公式で言及されています。

useMemo を利用した最適化が力を発揮するのは、以下のような、ほんの一部のケースに限られます。

  • useMemo で行う計算が著しく遅く、かつ、その依存値がほとんど変化しない場合。

  • 計算した値を、memo でラップされたコンポーネントの props に渡す場合。この場合は、値が変化していない場合には再レンダーをスキップしたいでしょう。メモ化することで、依存値が異なる場合にのみコンポーネントを再レンダーさせることができます。

  • その値が、後で何らかのフックの依存値として使用されるケース。例えば、別の useMemo の計算結果がその値に依存している場合や、useEffect がその値に依存している場合などです。

これらのケース以外では、計算を useMemo でラップすることにメリットはありません。

改めて公式を見てみると、キャッシュが効くからとりあえず使おう、という意識ではなく、適切な状況でしっかり使うことを意識するのが大事ですね。
また、計算コストが高いかどうかを見分ける方法 も解説されており、1ms 以上の場合はメモ化を検討しても良いと書かれています。

注意点について

Strict Modeの開発環境の場合、useMemoは二回呼び出されます https://ja.react.dev/reference/react/useMemo#my-calculation-runs-twice-on-every-re-render