🪢

Next.js App Routerの"use server"雑感 : Rails視点から

2024/10/13に公開
1

はじめに

Next.jsにServer Actionが新しく導入されました。サーバ上の関数をブラウザから直接呼び出すようなコードの書き味を提供するもので、非常に魅力のあるコンセプトだと私は思っています。ただしサーバ上で実行されるコードとブラウザで実行されるコードの境界が曖昧で"use server"のセキュリティ上の懸念もよく議論されています

一方で、私の先日の記事Next.jsで簡単なCRUDアプリを作りながら気になったセキュリティ: Railsの視点からで、私はこの"use server"問題には言及しませんでした。まだ非常に新しい話題でかつNext.js側の対応も進行中だというのもありますが、実は個人的にあまり気にならないのが最大の理由です。

気にならなくなったきっかけは、Server ActionをRuby on Railsのコントローラと同じように考え始めたことです。こうすることで、Ruby on Rails開発者として培ってきた癖と習慣がそのまま適応できるように感じました。そうしたらほぼストレスがなくなりました(Railsを書く時と同じぐらいの気の使い方で済むようになりました)。本記事では、この辺りを紹介したいと思います。

なお前記事でもお伝えしたとおり、私はNext.jsの専門家ではありません。Ruby on Railsが大好きで、Hotwireを普及させたいと思っているぐらいの人です。あくまでもその視点からの内容なので、お気づきの点がありましたらコメントやXでご教示ください。

Server Actionは何をするのか?

Server Actionの仕組みは大雑把に言うと下図のようになります。

  1. 普通のMPAと同じように、HTMLのformからサーバに向けてHTTPのPOSTリクエストを投げます
  2. POSTリクエストのエンドポイントは現在のURLと同じになります
  3. コード上はupdateUserAction()メソッドを呼び出すように指定していますが、ブラウザでは2377...65c08などと記述されています(この番号はアプリに固有の番号のようです。私が知る限り、セッションは異なっていても番号は同じです)。サーバ内ではupdateUserAction: 2377...65c08という対応づけがされています。そこでブラウザは2377...65c08だけをリクエストと一緒に送ります
  4. サーバは2377...65c08を受け取り、処理内容はupdateUserAction()メソッドだと知り、そしてこのメソッドを実行します

このように、Server Actionというのは普通のformのPOSTリクエストとよく似ています。大きく違うのは処理内容の指定の仕方です。Server Actionの場合は、1ステップ、名前の変換みたいなのが入ります。

  1. 普通のREST APIへのリクエストなら、例えば/api/users/1にPATCHのリクエストを飛ばします。処理方法は/api/users/1のエンドポイントに記述されています
  2. Server Actionのリクエストは現在のページ(例だと/users/1/edit)にPOSTのリクエストを飛ばします。サーバはこの処理を2377...65c08に対応するメソッド(ここではupdateUserAction())に渡します

普通のformのPOSTリクエストとServer Actionがこれだけよく似ているならば、Ruby on Railsの知見を当てはめることができそうです。

Server ActionはRailsのコントローラ相当

ごく単純に考えると、Next.js Server Actionの役割は以下になります。

  1. ブラウザからリクエストデータを受け取ること
  2. データをモデルなどに適切に引き渡し、変更をデータベース等に反映すること
  3. 処理の成功・失敗に応じて、ブラウザに適切なフィードバックをすること

Ruby on Railsのコントローラは通常、併せて下記の仕事もこなします。

  1. セキュリティ上のリスクをブロック。具体的にはモデル等に渡してはならない不正なデータのブロック
  2. 認証: アクセスしているユーザの特定と保証
  3. 認可: 上記のユーザに、処理に必要な権限があるかどうかの確認

Server Actionは新しいコンセプトなので、私が勉強し始めた当初は、セキュリティ上の責務を一部省略できるのではないかという期待を持っていました。その後半年フォローしていますが、残念ながらそんなことはなさそうです。Ruby on Railsのコントローラと同様に、Server Actionであってもすべての責務を担う必要があると考えるべきでしょう。

当たり前と言えば当たり前で、ガッカリすることもないのですが、Server Actionは魔法ではなかったということだと思います。

そう考えるとServer Actionの設計はこうなる

Next.jsのServer ActionがRuby on Railsのコントローラと同じであるならば、注意するべきポイントも同じようになります。そうすると設計も参考にできる可能性があります。そっくり真似る必要はありませんが、十分に参考になりそうです。

  1. Ruby on Railsの開発者はコントローラに無駄なpublicメソッドを作りません。意図せずに外部からアクセスできるエンドポイントを作りたくないからです。これは基本中の基本のさらに基本であり、ジュニアでも驚くほどよく叩き込まれています。Publicなメソッド(エンドポイントになるもの)とPrivateなメソッドの区別は重要なので、publicはファイル前半にまとめ、privateは必ず後半にまとめます。(今のRuby on Railsはコントローラのアクションがpublicである他にroutes.rbにルートを書かないとエンドポイントが公開されません。2段階必要です。しかし昔はデフォルトでpublicなものはすべて公開されました。今のServer Actionと同じです。だからこそRailsユーザは一人残らず気を遣うのだと思います)
  2. 同様にNext.jsの開発者も、"use server"をつけたモジュールから無駄なexportをしないように徹底できるはずです。exportしている関数をモジュールの前半にまとめ、exportしないものを後半にまとめることも徹底した方が間違えにくいでしょう
  3. Ruby on Railsでは、コントローラは他のコードと明確に区別され、管理されます。場所は通常/app/controllersフォルダにありますし、名前も必ず*_controller.rbで終わります。コントローラはその他のコードとは全然違うという認識があります[1]
  4. 同様にNext.jsの開発者は"use server"がついたモジュールが明確に区別できるように管理した方が良いでしょう。ただしNext.js App Routerの場合はcolocationが推奨されていますので、別途Server Action専用のフォルダを作るよりは、ファイル名の命名規則を徹底する方が適切かもしれません。
  5. "use server"は別途、関数の中に記述することができます。こうするとServer Componentの中にServer Actionを記述できます。Ruby on Rails的に考えるとコントローラはまとめておきたいので、気になります。できることならやらない方が良いと思います

設計のまとめ

Next.jsのところだけ簡単にまとめると、下記の3点に集約されます。

  • "use server"をつけたモジュールからは無駄なexportはしないこと。exportする関数としないものがわかりやすいようにまとめること
  • "use server"をつけたモジュールがどこにあるかをパッとわかるようにファイル名を工夫すること。それ以外のファイルに"use server"はつけないこと
  • Colocationを考えると、Server Actionは例えば/app/users/actions.ts/app/posts/actions.tsなどのようにまとめると良さそう
  • Railsのコントローラでやるべきこと、例えば認証・認可、そして不正なデータのブロックは怠らないこと

デモアプリで見てみる

デモアプリはGitHubに公開しています

下図のようにuserspostsというテーブルを中心にCRUDを行っています。Server Actionはすべて各フォルダの中のactions.tsファイルに入っています[2]

それぞれのactions.tsファイルからexportされているのは、Server Actionだけです(users側のソースコード, posts側のソースコード)。Server Actionがまとまっていますので、すべてのactionでvalidationが実行されていることが簡単に確認できます。またusers側では認可(permission)制御をしているものの、posts側は何もしていないのもチェックできます(単に未実装なだけです)。

さらに下記のように"use server"で全文検索すると、"use server"の文字列はactions.tsという名前のファイルにしか存在しないことも調べられます。こうやって、チームの各エンジニアが決まりに沿って開発を進めていることもすぐに確認できます。

振り返って、"use server"ってなに?

公式ドキュメントに記載されている通り、"use server"はクライアントサイドのコードから呼び出せる、サーバサイドの関数をマークします

もちろんこっちが100%正しい記述です。私は「"use server"はRailsのコントローラと同じだよ」なんて無責任に言っていますが、私の発言よりは公式ドキュメントの記述の方が圧倒的に正しいです。

でも正しいことと、正確に伝わることは全く別物です。どんなに正確であっても、わかりにくく、イメージしにくいものであれば誤って伝わってしまいます。そして間違っているけれどもわかりやすい解釈の方がむしろ広がってしまいます。「"use server" == サーバで処理される」という誤解はもうすでにかなり広がってしまっていますが、それはなぜかというと、間違った解釈の方がわかりやすいからです。

世の中は残念ながら、正確性よりもわかりやすさの方が勝ちます

"use server"の正しい使い方を広げ、App routerによるセキュリティ問題を防いだり、あるいはNext.jsが引き続き初心者に優しいフレームワークであり続けるためには、「解釈」や「理論」を繰り返しても効果は限定的でしょう。やらなければならないのは「正しいイメージ」や「正しい形」を示すことではないかと思います。 私がそう考えるのはRailsのScaffoldやRailsチュートリアルを始めたとした優れた教材によるところがとても大きいのですが、「解釈」が間違っていても、コードは正しく書けるぐらいの「形」を作ってあげるのが良いと思います。(もちろんそれがRailsに近いものである必要はありません)

そろそろ正しい「形」を議論するタイミングではないかと私は思います。

そして実際問題、Ruby on Railsの開発者も解釈・理論はわかっていないけど、結果的にセキュリティ上問題のないコードを書く人が多いです。それが「形」の力ではないかと思います。

最後に、Server Actionにはめちゃくちゃ普及して欲しいです。いちいちJSON APIエンドポイントを書くのは辛いです。

ちょっと蛇足アップデート: "Thin Server Actions"?

公開翌日に思いついた蛇足、新規に記事を書くほどでもなかったので元記事をアップデートして紹介します

上記でNext.jsのServer ActionがRailsのコントローラに似ているんじゃないか?と述べましたが、その他にRailsのコントローラで気をつけるべきポイントは何でしょうか?

Railsエンジニアなら真っ先に思いつくのが"Thin Controllers, Fat Models"です。なにしろLaravelを作ったTaylor OtwellもThin Controller, Fat Modelを推奨しています

Server Actionも同様に"Thin"にするのが良さそうです。"use server"で目印をつけ、名前をactions.tsとしたServer Actionsファイルに記述するコードは、なるべく少なくした方が良さそうです。ロジックはなるべく外部の関数に持たせ、それをimportして使うのが良いでしょう。Railsコントローラを"Thin"にする理由をほぼそのまま持ってきているだけなのですが、このようにすることで下記のメリットが得られると思います。

  • (チャレンジしましたが)Server Actionは自動テストが書きにくいです。Railsはコントローラテスト専用の仕組みを用意していますが、Next.jsにはそれがありません。Railsですらコントローラテストは面倒ですが、Next.jsのServer Actionのテストはもっともっと大変です。テストができない場合のベストの対策は、テストが必要なロジックを含むコードを書かないことです。Server Action内のロジックは最小限にとどめた方が良さそうです
  • ロジックが複雑になった場合は、リファクタリングをして複数の関数に分割するのが基本中の基本です。でも"use server"が宣言されていると、ちょっとしたことでその関数はエンドポイントになってしまいますので、関数を増やすことに慎重になってしまいます。対策としては複雑なロジックは外部のモジュールに追い出すのが良さそうです
  • 単純に見通しが改善します

Railsであれば、複雑なロジックはModelに追い出します。それで"Fat model"が生まれます。Railsは"Fat model"を作るのがベストプラクティスです。Next.jsの場合はあまりクラスを作らずに、関数中心にロジックを組み立てることが多いかと思いますので、それに最適なモノをfatにしていけば良いのではないかと思います。

なおFat ModelのコンセプトはRailsの世界でも少し誤解されています。LaravelのTaylor Otwellも強調していますが、これはModelのファイルがデカいという意味ではありません。Modelのインタフェースが広いだけの話であり、実装としては積極的に他のクラスに移譲したりします。大事なところなので、引用を貼っておきます

脚注
  1. Railsのコントローラが他のコードと全然違うかについて、私はメソッドが呼び出される仕組みについて注目しています。Martin Fowlerは、フレームワークとライブラリの違いはInversion of Controlだと述べています。コントローラのアクションはフレームワークによって呼び出され、返り値はフレームワークによって処理されるということだと理解しています。Inversion of Controlを使うと、ルールはフレームワークが決めます。Ruby on Railsの場合、コントローラのインスタンス変数の意味、public/privateの意味は普通のオブジェクトと全く異なってしまいます。Server Actionもその類だと私は思います。 ↩︎

  2. Next.jsの公式のチュートリアルでもServer Actionを分けて、/lib/actions.tsに収納しています。残念ながらアプリが大きくなれば、Server Actionをすべて1つのファイルに入れるのは無理です。その時にどうすれば良いかは示されていません。私の例ではServer Actionは/app/users/actions.tsおよび/app/posts/actions.tsに収納し、アプリが拡大しても対応可能にしています。またServer ActionをRailsのコントローラと同列に考えると、こっちの方が自然であり、アプリが小さくても採用すべきだと思います(Railsのコントローラはviewのグループに対応するため)。 ↩︎

Discussion

GakkieGakkie

Thin Server Actionsのイメージは以下のコードの認識であっていますか?

// /actions.ts
"use server";

import { fetchUserData } from "./services/userService";

export async function getUserData(userId: string) {
  // Server Actionの中では極力ロジックを少なくする
  return await fetchUserData(userId);
}
// /services/userService.ts
export async function fetchUserData(userId: string) {
  // 実際のビジネスロジックやデータの取得は外部の関数で処理
  // ここでAPI呼び出しやデータベースアクセスを行う
  const response = await fetch(`https://api.example.com/users/${userId}`);
  if (!response.ok) {
    throw new Error("ユーザーデータの取得に失敗しました");
  }
  return await response.json();
}
// /app/page.tsx
import { getUserData } from "../actions";

export default async function UserPage({ params }: { params: { userId: string } }) {
  try {
    const userData = await getUserData(params.userId);
    return (
      <div>
        <h1>{userData.name}</h1>
        <p>{userData.email}</p>
      </div>
    );
  } catch (error) {
    return <div>エラーが発生しました: {error.message}</div>;
  }
}