APIリクエストとデータ表示

はじめに

Next.jsから以前に作成したRailsのエンドポイントに向けてAPIのリクエストを送信し、user情報を取得する処理を実装します。また、取得したuser情報を画面に反映させていきます。

Next.jsにおけるバックエンド側のアーキテクチャの実装も進めていきます。

ゴールの確認

Railsのエンドポイントを実行するコードを実装する

Next.jsのfetchの挙動をざっくり把握する

Next.jsのバックエンド周りのアーキテクチャを構築する

ここでの差分はgithubにあげています。困った際には参考にしてみてください

APIリクエストを実行してみる

apiリクエストを実行するためにfetchメソッドを作成していきます。
get /api/v1/userが以前作成したapiのエンドポイントになるので、こちらに対してリクエストを投げていきます。(pageコンポーネントのfunctionをasyncにするのをお忘れなく!)

import CareerTopTemplate from "@/features/careers/components/career-top-template";

export default async function TopPage() {
  const res = await fetch("http://backend:3000/api/v1/user");
  console.log(res);
  return <CareerTopTemplate />;
}

この状態でトップページをレンダリングしてみましょう。

backend-1   | [ActionDispatch::HostAuthorization::DefaultResponseApp] Blocked hosts: backend:3000
frontend-1  | Response {
frontend-1  |   status: 403,
frontend-1  |   statusText: 'Forbidden',
frontend-1  |   headers: Headers {
frontend-1  |     'content-type': 'text/html; charset=UTF-8',
frontend-1  |     'content-length': '5385'
frontend-1  |   },
frontend-1  |   body: ReadableStream { locked: false, state: 'readable', supportsBYOB: true },
frontend-1  |   bodyUsed: false,
frontend-1  |   ok: false,
frontend-1  |   redirected: false,
frontend-1  |   type: 'basic',
frontend-1  |   url: 'http://backend:3000/api/v1/user'
frontend-1  | }

こんなようなログが表示されていませんでしょうか?
読み解いていくと、responseの結果が403でForbiddenです。一番最初にはbackend-1のログがあるので、リクエストはbackendには到達しているのですが、backendが403を返してしまいレスポンスを正常に受け取れていません。
その理由はBlocked hosts: backend:3000の部分です。

そもそも、画面を開いたりpostmanでリクエストを作成していたときはlocalhost:3000にしていたのにここではbackend:3000にしているのはrailsもnext.jsもdocker composeの中で動作しているためです。docker composeで立ち上げた複数のコンテナは、このコンテナ同士が接続できるようにnetworkというものを形成します。このnetworkにより、network内部のコンテナから同一networkの別コンテナにリクエストするときはコンテナ名をドメインに指定することでnetworkが名前解決しれくれます。
そのため、backendをドメインに指定しています。

ですが、railsはdevelop状態ではlocalhostがドメインで来るようにセキュリティの設定がされており、別のドメインでアクセスしようとすると不正なリクエストが来たと判断されてしまうようです。
そこで、これを回避するためにrailsの設定を変更する必要があります

require "active_support/core_ext/integer/time"

Rails.application.configure do
  # Settings specified here will take precedence over those in config/application.rb.  
....

  # Uncomment if you wish to allow Action Cable access from any origin.
  # config.action_cable.disable_request_forgery_protection = true

  # next.jsからのbackend:3000を受け入れる
  config.hosts = ["backend:3000", "localhost:3000"]
end

configのhostsに対して有効なドメインを登録しています。
この差分を入れ、rails restartをしましょう

もう一度fetchのコードを動かすためにレンダリングしてみます

backend-1   | [active_model_serializers] Rendered UserSerializer with ActiveModelSerializers::Adapter::Attributes (0.47ms)
backend-1   | Completed 200 OK in 23ms (Views: 3.4ms | ActiveRecord: 3.3ms | Allocations: 9615)
backend-1   |
backend-1   |
frontend-1  | Response {
frontend-1  |   status: 200,
frontend-1  |   statusText: '',
frontend-1  |   headers: Headers {
frontend-1  |     'cache-control': 'max-age=0, private, must-revalidate',
frontend-1  |     'content-type': 'application/json; charset=utf-8',
frontend-1  |     etag: 'W/"29d61539c9c53c9ccbb30f9c9cf8bc8d"',
frontend-1  |     'referrer-policy': 'strict-origin-when-cross-origin',
frontend-1  |     'server-timing': 'sql.active_record;dur=22.53, start_processing.action_controller;dur=0.27, instantiation.active_record;dur=7.48, render.active_model_serializers;dur=0.67, process_action.action_controller;dur=23.29',
frontend-1  |     'transfer-encoding': 'chunked',
frontend-1  |     vary: 'Accept',
frontend-1  |     'x-content-type-options': 'nosniff',
frontend-1  |     'x-download-options': 'noopen',
frontend-1  |     'x-frame-options': 'SAMEORIGIN',
frontend-1  |     'x-permitted-cross-domain-policies': 'none',
frontend-1  |     'x-request-id': '7ad6b39b-765f-458c-8828-c7158954ce2c',
frontend-1  |     'x-runtime': '0.099272',
frontend-1  |     'x-xss-protection': '0'
frontend-1  |   },
frontend-1  |   body: ReadableStream { locked: false, state: 'readable', supportsBYOB: true },
frontend-1  |   bodyUsed: false,
frontend-1  |   ok: true,
frontend-1  |   redirected: false,
frontend-1  |   type: 'default',
frontend-1  |   url: 'http://backend:3000/api/v1/user'
frontend-1  | }

正常に200レスポンスが返るようになりました。

では次にresponseの中のuser情報を取得します
await res.json()でresponseをjsonでparseした結果をconsoleに入れます。

import CareerTopTemplate from "@/features/careers/components/career-top-template";

export default async function TopPage() {
  const res = await fetch("http://backend:3000/api/v1/user");
  const user = await res.json();
  console.log(user);
  return <CareerTopTemplate />;
}
frontend-1  | {
frontend-1  |   name: 'John Smith',
frontend-1  |   email: 'foobar@mail.com',
frontend-1  |   postal_code: '1111111',
frontend-1  |   address: '1234 Main St'
frontend-1  | }

このようにuser情報が取得できています。

Next.jsのキャッシュ

ちゃんと動的になっているか確認してみます。localhost:3000/userからnameとemailを更新してもう一度レンダリングしてログを確認します。

前回と取得できるuser情報は変わりましたでしょうか?おそらく変わらないと思います。

これはfetchによるキャッシュが効いているためです。
本来生のjavascriptやtypescriptで利用されるfetchにはそのような機能はないのですが、nextjsはこのfetchメソッドを上書きしてfetchにキャッシュ機能を持たせています。
そのキャッシュの動作として、最初の一度目のリクエストを得たらその後はずっとそれを保持して、以降はbackendにリクエストを投げずに最初の結果を返し続けます。
そのため、backend側で値が更新されてもそれが反映されません。
これがあることで、nextjsを本番運用したときにbackendになるサーバーに対して不用意にリクエストを投げることがなくなり、backendを多大なトラフィックから守ってくれたり、backendがリクエスト毎の従量課金系の外部サービスだった場合にはコストを大幅に削減してくれます。
とはいえ、開発中はこれだと困ることが多いです。そこでキャッシュを無効化しておきます。

 const res = await fetch("http://backend:3000/api/v1/user", {
    cache: "no-store",
  });

これでもう一度動かしてみましょう

backend-1   |   User Load (1.2ms)  SELECT `users`.* FROM `users` ORDER BY `users`.`id` ASC LIMIT 1
backend-1   |   ↳ app/controllers/api/v1/users_controller.rb:9:in `user'
backend-1   | [active_model_serializers] Rendered UserSerializer with ActiveModelSerializers::Adapter::Attributes (1.27ms)
backend-1   | Completed 200 OK in 13ms (Views: 2.7ms | ActiveRecord: 2.1ms | Allocations: 970)
backend-1   |
backend-1   |
frontend-1  | {
frontend-1  |   name: 'John Smith updated',
frontend-1  |   email: 'foobar-updated@mail.com',
frontend-1  |   postal_code: '1111111',
frontend-1  |   address: '1234 Main St'
frontend-1  | }

更新されるようになりました。

user変数をコンポーネントに渡していき、値を画面に表示までしてみましょう

export default async function TopPage() {
  const res = await fetch("http://backend:3000/api/v1/user", {
    cache: "no-store",
  });
  const user = await res.json();
  console.log(user);
  return <CareerTopTemplate user={user} />;
}
import Header from "@/components/header/header";
import CareerList from "@/features/careers/components/career-list";

export default function CareerTopTemplate(props: any) {
  const { user } = props;
  return (
    <div>
      <Header user={user} />
      <CareerList />
    </div>
  );
}
import styles from "@/components/header/header.module.scss";
import MailIcon from "../icons/mail";

export default function Header(props: any) {
  const { user } = props;
  return (
    <header className={styles.header}>
      <div className={styles.container}>
        <div className={styles.profile}>
          <div className={styles.userName}>{user.name}</div>
          <div className={styles.email}>
            <div className={styles.icon}>
              <MailIcon />
            </div>
            <div>{user.email}</div>
          </div>
        </div>
        <ul className={styles.navBar}>
          <li className={`${styles.navItem} ${styles.active}`}>career</li>
          <li className={styles.navItem}>portfolios</li>
          <li className={styles.navItem}>blogs</li>
        </ul>
      </div>
    </header>
  );
}

表示されるようになりました!

アーキテクチャ設計

これで完成でも機能としては問題ないのかもしれませんが、実装が増えたたとき、あっという間にpage.tsxのコード量が半端じゃないことになります。また、backendのレスポンスが変更されたりしたらすぐに壊れてしまい、直すのが大変になります。

役割ごとにコードを分割して保守性の高いコードに仕上げていきます。

まず、page.tsxでuser情報をとるときはある関数を実行し、その結果がもうuserになっていると嬉しいです。

import CareerTopTemplate from "@/features/careers/components/career-top-template";
import { getUser } from "@/features/users/actions";

export default async function TopPage() {
  const user = await getUser();
  return <CareerTopTemplate user={user} />;
}

ここで使われるgetUserを作成します。
features/usersのディレクトリと、その下にactionsのディレクトリを作成し、その下にindex.tsを作成します

"use server";

import userRepository from "@/infrastructure/repositories/user-repository";

export const getUser = async () => {
  const user = await userRepository.getUser();
  return user;
};

一行目の”user server”はnextjsのサーバーアクションの機能を使って、この関数がサーバーサイド側で実行されることを担保します(今回の場合、page.tsxで実行されるのでなくてもサーバーサイドで実行されることになります)

次にuserRepositoryを作成していきます。
src下にinfrastructureディレクトリを作成し、その下にrepositoriesディレクトリをいれます。その下にuser-repository.tsを作成します。

import backendClient from "@/lib/backend-client";
import User from "@/models/user";

class UserRepository {
  async getUser() {
    const res = await backendClient.get("/api/v1/user");
    if (res.ok) {
      const data = await res.json();
      return this.toModel(data);
    } else {
      return User.empty();
    }
  }

  private toModel(data: { name: string; email: string }) {
    return new User(data.name, data.email);
  }
}

const userRepository = new UserRepository();
export default userRepository;

次にbackendClientを作成していきます。
src下にlibディレクトリを作成して、backend-client.tsを作成します。

class BackendClient {
  private DOMAIN = process.env.BACKEND_DOMAIN;
  async get(path: string) {
    return await fetch(`${this.DOMAIN}${path}`);
  }
}

const backendClient = new BackendClient();
export default backendClient;

backendのドメインを環境変数において、開発環境と本番環境のときで値を切り替えたいです。
frontend下に.env.developmentファイルを作成します

BACKEND_DOMAIN=http://backend:3000

次にuserRepositoryで参照されていたmodelを作成します
src下にmodelsディレクトリを作成し、その下にuser.tsを作成します

export default class User {
  constructor(private _name: string, private _email: string) {}

  static empty() {
    return new this("empty name", "empty@mail.com");
  }

  get name() {
    return this._name;
  }

  get email() {
    return this._email;
  }
}

static empty()はuserRepositoryで使用しています。もし、apiが何らかの理由でエラーになったときにnextjs内でエラーを起こして画面表示に失敗するのではなく、empty nameとして表示させてます。これが今回のアプリケーションの挙動として適切かはさておき、こんなパターンもあるよという意味でやっています。

エラーが起きたとしても無理に動作を継続させるようなコードは基本的に望ましくないと考えています。理由はエラーが起きているのに画面を使っている間、アプリケーションは動作しているのに意図しない挙動が起きているような使用感になり、エラーの検知が遅れユーザー体験が悪い状態が放置されやすくなるためです。意図しない挙動が起きている場合は基本的にエラーを大々的に起こさせて早期発見・修復に務めるべきです。
一方、エラーの回避をすべき場合ももちろんあります。小さなエラーのせいでその画面の他の機能も使えなくなるのを避けるためや、頻繁にエラーが発生しうるようなapiにアクセスしていて、その都度エラーを発生させているとユーザー体験が悪化してしまうような場合、エラーが返ることもアプリケーションの通常の利用の中で想定されるような場合です。
これらのときはエラーをログに吐き出しつつ、アプリケーションが落ちないように例外をキャッチしていくことが求められます。

コンポーネントに渡しているpropsの型がanyのままなので、適切な型を作成して割り当てていきます。

import User from "@/models/user";

export type CareerTopTemplateProps = {
  user: User;
};
import User from "@/models/user";

export type HeaderProps = {
  user: User;
};

作成した型をそれぞれのコンポーネントに当てていきます

// frontend/src/careers/components/career-top-template.tsx
import { CareerTopTemplateProps } from "@/features/careers/types";
export default function CareerTopTemplate(props: CareerTopTemplateProps) {


// frontend/src/components/header/header.tsx
import { HeaderProps } from "@/components/header/type";
export default function Header(props: HeaderProps) {

実装は以上です。

もう一度レンダリングしてみて、エラーや動作に変化がないことを確認してください

ディレクトリ構成は現在下記の通りです。

feature/
→機能ごとのロジックやコンポーネントなどを管理する

infrastructure/
→外部システムへのアクセスを行う

models/
→機能やオブジェクトを表現するために用いる概念(userとか)

lib/
→ライブラリの設定・自作ライブラリ

おわりに

Next.jsでの外部APIリクエストとその結果の画面表示を実装していきました。
今回の実装内容をもとに今後の実装も組み立てていきます。

アプリケーションの設計について触れましたが、これが一概に正解とも言えないです。
他の記事ではもっと簡略化されていたり、むしろより厳格な設計になっているかもしれません。
どんな設計にするかは、アプリケーションの規模と参加するメンバーの技術量に応じて選択されるのがよいです。正直、ここで作成したアプリケーションの設計は今作成しようとしているアプリケーションの規模に考えるとオーバーだと思います。
repositoryを採用せず、actionの中でjsonパースやresponseの中身に応じだ分岐をやってしまってもよかったとは思います。商用のアプリケーションなんかは運用していると後発的にやりたいことが増えてきます。開発当初の想定よりも大きめのアプリケーションを想定した設計にしておくのを私はおすすめしています。

コメント

タイトルとURLをコピーしました