🪢

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

2024/10/13に公開
2

はじめに

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[2]が推奨されていますので、別途Server Action専用のフォルダ(例えば/actions)を作るよりは、ファイル名の命名規則を徹底する方が適切かもしれません(例えば/usersルート関連のServer Actionを収めたファイルはapp/users/フォルダに配置し、名前をactions.tsとするなど)。
  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ファイルに入っています[3]

それぞれの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. ColocationはReactのファイル構成の原則の一つで、「一般的には、よく一緒に変更するファイルを近くに置いておくのは良い」という考えに基づいています。Next.jsのApp Routerではsafe colocationを謳っており、Pages Routerよりもcolocationしやすくなっています。 ↩︎

  3. 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>;
  }
}
NaofumiNaofumi

ありがとうございます。

私は実はあまりServiceを書かず、Ruby on Railsでオブジェクト指向的なドメインモデルを使ってThin controllerを普段は書いています。Serviceの使い方に少し難があるかもしれませんが、回答させていただきます。

  • actions.ts => services/userService.tsに早めに処理を持っていくのは、その通りだと思います。私の考え方と同じです。
  • ただし上記で示していただいたのは、/app/page.tsxuserData取得のためのServer Actionです。実はこの場合はServer Actionを介する必要はなく、/app/page.tsxから直接fetchUserData()を呼び出す方がベターです。Server Actionは主にブラウザのformからcreate/update/deleteなどの変更系の処理をするためのものですので、上記の例では不要だと思います
  • データの取得に比べて、更新系の処理はバリデーションエラーの処理などが入るため、何倍もややこしくなります。Server ActionをThinにすることを考える上では、どうしても更新系で議論する必要があります

上記を考慮した上で、Server Actionの更新系を書くときに私であればどうするかを解説したいと思います。

app/users/actions.tscreateUserAction()から出発します

app/users/actions.ts
export async function createUserAction(
  previousState: ValidationUserErrors,
  formData: FormData
) {
  // Explicitly specify the fields that we want to include from formData.
  // Do not use Object.fromEntries(formData) without whitelisting, since this
  // would be vulnerable to mass-assignment attacks.
  const {validatedFields, errors} = validateUser({
    name: formData.get("name") as string | null,
    email: formData.get("email") as string,
  })
  const currentUser = await authenticateAndReturnCurrentUser()
  if (!userPermission("create", null, currentUser)) { return redirect("/sessions/create") }

  if (validatedFields.success) {
    await createUser(validatedFields.data)

    revalidatePath("/")
    redirect("/users")
  } else {
    return errors
  }
}

これはServer Actionですが、複数の責務を果たしています。

  1. ブラウザのformから送信されてきたデータのバリデーションをしています(validateUser()のところ)
  2. ログインしているUser(currentUser)を取得しています(authenticateAndReturnCurrentUser()のところ)
  3. 認可、つまりcurrentUserに新規ユーザを作成する権限があるかどうかを確認しています(userPermission()のところ)
  4. バリデーションが成功していた場合はcreateUser()で新しいUserを作成しています
  5. その後にredirectをします
  6. バリデーションのエラーがあったら、errorsを返します

これだけの処理があった場合、Thin Server Actionとして相応しいかどうかは議論の余地があります。「多すぎる」と思えば、これをすべてまとめてServiceに持っていくことも可能ですし、「Server Actionはこれぐらいの処理があっても良い」と思えばここにに残します。なお、validateUser, authenticateAndReturnCurrentUser, userPermission, createUserの処理はすべてactions.tsの外の関数に移譲していますので、このままでもかなりActionをThinにする努力はされているかと思います。

私の意見としては、Ruby on RailsのようにControllerテストを書く仕組みが充実しているならば、これだけの処理をさせても良いと思います。しかしNext.jsの場合は仕組みが用意されておらず、テストを書くのが難しいため、Server Actionとしては処理が多すぎると思います。

実際にやってみると、Server Actionのテストが書きにくい理由は、下記の点です。

  • authenticateAndReturnCurrentUser()ではセッション情報を使うので、これをモックしないといけないこと
  • redirect()revalidatePath()もモックしないといけないこと

これだけモックが多いと、私は不安になってしまいます。モックが多いとテストの信頼性がどうしても落ちてしまうためです。

テストのしやすさを改善せずにServiceを切り出しても、単にコードの場所を動かしただけで、あまりメリットのないものになってしまいます(Server Actionの場合は誤ってexportしないで済むメリットは残りますが、一般にはメリットがないです)。逆に上記のモック部分が不要な関数ならテストが書きやすいで、これを考慮して、私は下記のようにServiceを切り出すかもしれません。

app/users/actions.ts
export async function createUserAction(
  previousState: ValidationUserErrors,
  formData: FormData
) {
  const currentUser = await authenticateAndReturnCurrentUser()
  if (!userPermission("create", null, currentUser)) { return redirect("/sessions/create") }

  const successOrErrors = await createUserService(formData)

  if (successOrErrors === "success") {
    revalidatePath("/")
    redirect("/users")
  } else {
    return successOrErrors
  }
}
app/users/services.ts
import {validateUser} from "@/app/users/user_validator"
import {createUser} from "@/app/repositories/user_repository"

export async function createUserService(formData: FormData) {
  const {validatedFields, errors} = validateUser({
    name: formData.get("name") as string | null,
    email: formData.get("email") as string,
  })

  if (validatedFields.success) {
    await createUser(validatedFields.data)

    return "success"
  } else {
    return errors
  }
}

successOrErrorsを返すところがわかりにくくなっていますし、フレームワークによっては最初からこう作られているものもありますので(例えばStruts2)、そんなに変ではないと思います。

Serviceの切り出し方は人によって変わると思いますし、その背景には何を優先するかの違いがあると思います。私はテストしやすくなることは最優先だと考えますので、上記のように分けました。

もし参考になれば嬉しいです