htmx の BFF(Backend for Frontend)設計:コントローラ分割とパーシャルの責務
htmx を使った Web アプリケーション開発では、従来の SPA とは異なるアーキテクチャが求められます。特に、バックエンドとフロントエンドの境界線をどこに引くか、どのようにコントローラを設計するかは、保守性と拡張性に直結する重要な判断です。
本記事では、htmx を活用した BFF パターンにおけるコントローラの分割方法と、パーシャル HTML の責務について、実践的なアプローチを解説していきます。
背景
htmx とは何か
htmx は、HTML 属性を使って AJAX リクエストや DOM 操作を行えるライブラリです。JavaScript を書かずに、宣言的な方法でインタラクティブな Web アプリケーションを構築できます。
typescript<!-- htmx の基本的な使い方 -->
<button
  hx-get="/api/users"
  hx-target="#user-list"
  hx-swap="innerHTML"
>
  ユーザー一覧を読み込む
</button>
<div id="user-list">
  <!-- ここにユーザー一覧が挿入されます -->
</div>
この例では、ボタンをクリックすると /api/users へ GET リクエストが送信され、レスポンスの HTML が #user-list 要素の内側に挿入されます。
BFF パターンの必要性
従来の SPA では、フロントエンドが JSON API を呼び出し、クライアント側でレンダリングを行います。一方、htmx ではサーバーが HTML を返すため、フロントエンド専用の API 層が必要になるのです。
以下の図は、htmx を使った場合のアーキテクチャの全体像を示します。
mermaidflowchart TB
  browser["ブラウザ<br/>(htmx)"] -->|"HTML リクエスト"| bff["BFF 層<br/>(コントローラ)"]
  bff -->|"データ取得"| service["サービス層<br/>(ビジネスロジック)"]
  service -->|"永続化"| db[("データベース")]
  bff -->|"HTML レンダリング"| template["テンプレート<br/>(パーシャル)"]
  template -->|"HTML レスポンス"| browser
図で理解できる要点
- BFF 層がフロントエンドとバックエンドの橋渡しを担当
 - テンプレートエンジンでサーバーサイドレンダリングを実施
 - サービス層はビジネスロジックに専念
 
この構成により、フロントエンドの要求に最適化された HTML を効率的に提供できます。
従来の MVC との違い
従来の MVC パターンでは、1 つのコントローラが画面全体を担当していました。しかし htmx では、画面の一部分だけを更新する「パーシャル更新」が中心となるため、設計方針を見直す必要があります。
| # | 項目 | 従来の MVC | htmx + BFF | 
|---|---|---|---|
| 1 | レスポンス形式 | 完全な HTML ページ | パーシャル HTML 片 | 
| 2 | コントローラの粒度 | 画面単位 | 機能・コンポーネント単位 | 
| 3 | 状態管理 | セッション中心 | サーバー側で完結 | 
| 4 | ルーティング | ページ遷移ベース | エンドポイント細分化 | 
課題
コントローラの肥大化
htmx を使うと、画面の各部分に対応するエンドポイントが増えます。これらを 1 つのコントローラにまとめると、数百行に及ぶ巨大なファイルになってしまいます。
typescript// アンチパターン: すべてを 1 つのコントローラに詰め込む
export class UserController {
  // ユーザー一覧表示
  async listUsers(req, res) {
    /* ... */
  }
  // ユーザー検索
  async searchUsers(req, res) {
    /* ... */
  }
  // ユーザー詳細
  async getUserDetail(req, res) {
    /* ... */
  }
  // ユーザー編集フォーム
  async editUserForm(req, res) {
    /* ... */
  }
  // ユーザー更新
  async updateUser(req, res) {
    /* ... */
  }
  // ユーザー削除確認
  async deleteConfirm(req, res) {
    /* ... */
  }
  // ... さらに続く
}
このような設計では、以下の問題が発生します。
責務の曖昧さ
パーシャル HTML を返すエンドポイントと、完全なページを返すエンドポイントが混在すると、どこで何を担当しているのか分かりにくくなります。
typescript// どちらも同じコントローラ内に存在
async showUserPage(req, res) {
  // 完全な HTML ページを返す
  return render('users/index.html', data);
}
async loadUserList(req, res) {
  // パーシャル HTML を返す
  return render('users/_list.html', data);
}
以下の図は、責務が曖昧になった場合の問題点を示しています。
mermaidflowchart LR
  request["リクエスト"] --> controller["巨大なコントローラ"]
  controller --> page["完全ページ"]
  controller --> partial1["パーシャル A"]
  controller --> partial2["パーシャル B"]
  controller --> partial3["パーシャル C"]
  controller --> api["JSON API"]
  style controller fill:#ffcccc
図で理解できる要点
- 1 つのコントローラが多様な形式のレスポンスを担当
 - 役割が不明瞭で保守が困難
 - テストの複雑度が増大
 
テストの複雑化
エンドポイントが増えると、それぞれに対するテストケースも増加します。また、パーシャルとページ全体のテストを同じファイルに書くと、テストコードの見通しが悪くなります。
解決策
コントローラの分割戦略
コントローラは「機能」と「レスポンス形式」の 2 軸で分割するのが効果的です。
1. 機能別の分割
ユーザー管理、商品管理など、ドメインごとにコントローラを分けます。
typescript// controllers/users/
// ├── UserPageController.ts       // 完全ページ用
// ├── UserListController.ts       // 一覧パーシャル用
// ├── UserDetailController.ts     // 詳細パーシャル用
// └── UserFormController.ts       // フォーム関連
このディレクトリ構造により、関連するコントローラを 1 箇所にまとめつつ、それぞれの責務を明確化できます。
2. レスポンス形式別の分割
完全なページを返すコントローラと、パーシャル HTML を返すコントローラを分離します。
typescript// ページ全体を返すコントローラ
import { Request, Response } from 'express';
export class UserPageController {
  // ユーザー管理画面の初期表示
  async index(req: Request, res: Response) {
    // 初期データの取得
    const users = await this.userService.getUsers();
    // 完全な HTML ページをレンダリング
    return res.render('users/index.html', { users });
  }
}
上記は初期ページロード時に使用されるコントローラです。画面全体の HTML を生成し、htmx による動的な更新の土台を提供します。
typescript// パーシャル HTML を返すコントローラ
import { Request, Response } from 'express';
export class UserListController {
  // ユーザー一覧のパーシャル更新
  async list(req: Request, res: Response) {
    // クエリパラメータから検索条件を取得
    const { search, page = 1, limit = 20 } = req.query;
    // データ取得
    const users = await this.userService.searchUsers({
      search: search as string,
      page: Number(page),
      limit: Number(limit),
    });
    // パーシャル HTML のみをレンダリング
    return res.render('users/_list.html', { users });
  }
}
このコントローラは htmx からの AJAX リクエストに応答し、画面の一部分だけを更新するための HTML 片を返します。
パーシャルの命名規則
パーシャル HTML ファイルには、アンダースコアのプレフィックスを付けることで、完全なページと区別できます。
typescript// views/users/
// ├── index.html          // 完全ページ
// ├── _list.html          // 一覧パーシャル
// ├── _detail.html        // 詳細パーシャル
// ├── _form.html          // フォームパーシャル
// └── _search_bar.html    // 検索バーパーシャル
この命名規則により、ファイル一覧を見ただけで、どれがパーシャルか判断できます。
ルーティングの設計
エンドポイントの URL にも規則を設けることで、API の見通しが良くなります。
typescript// routes/users.ts
import express from 'express';
import { UserPageController } from './controllers/users/UserPageController';
import { UserListController } from './controllers/users/UserListController';
const router = express.Router();
const pageController = new UserPageController();
const listController = new UserListController();
// 完全ページ用のエンドポイント
router.get('/users', pageController.index);
// パーシャル用のエンドポイント(/partials プレフィックス)
router.get('/partials/users/list', listController.list);
router.get(
  '/partials/users/:id/detail',
  listController.detail
);
export default router;
/partials プレフィックスを付けることで、パーシャル専用のエンドポイントであることが一目瞭然になります。
以下の図は、ルーティング設計の全体像を示します。
mermaidflowchart TD
  root["/users"] -->|"初回アクセス"| page["UserPageController<br/>完全 HTML"]
  partial_root["/partials/users/*"] --> list_route["/list"]
  partial_root --> detail_route["/:id/detail"]
  partial_root --> form_route["/:id/form"]
  list_route --> list_ctrl["UserListController"]
  detail_route --> detail_ctrl["UserDetailController"]
  form_route --> form_ctrl["UserFormController"]
  list_ctrl --> list_tmpl["_list.html"]
  detail_ctrl --> detail_tmpl["_detail.html"]
  form_ctrl --> form_tmpl["_form.html"]
図で理解できる要点
- 完全ページとパーシャルで URL 体系を分離
 - コントローラとテンプレートが 1 対 1 で対応
 - エンドポイントの役割が URL から推測可能
 
サービス層との連携
コントローラは HTTP リクエストの処理とレスポンスの生成に専念し、ビジネスロジックはサービス層に委譲します。
typescript// services/UserService.ts
export class UserService {
  constructor(private userRepository: UserRepository) {}
  // ビジネスロジック: ユーザー検索
  async searchUsers(params: SearchParams) {
    const { search, page, limit } = params;
    // 検索条件の構築
    const where = search
      ? { name: { contains: search } }
      : {};
    // データ取得
    const users = await this.userRepository.findMany({
      where,
      skip: (page - 1) * limit,
      take: limit,
    });
    return users;
  }
}
サービス層では、データの取得や加工といったビジネスロジックを実装します。コントローラから独立しているため、別のコントローラからも再利用できます。
typescript// コントローラでサービスを利用
export class UserListController {
  constructor(private userService: UserService) {}
  async list(req: Request, res: Response) {
    // サービス層にロジックを委譲
    const users = await this.userService.searchUsers({
      search: req.query.search as string,
      page: Number(req.query.page || 1),
      limit: Number(req.query.limit || 20),
    });
    // レンダリングに専念
    return res.render('users/_list.html', { users });
  }
}
この分離により、コントローラはシンプルになり、テストも書きやすくなります。
具体例
ユーザー管理画面の実装
実際にユーザー管理画面を構築する例を見ていきましょう。
完全ページのコントローラ
typescript// controllers/users/UserPageController.ts
import { Request, Response } from 'express';
import { UserService } from '../../services/UserService';
export class UserPageController {
  constructor(private userService: UserService) {}
  // ユーザー管理画面の初期表示
  async index(req: Request, res: Response) {
    try {
      // 初期データの取得(最初の 20 件)
      const users = await this.userService.getUsers({
        page: 1,
        limit: 20,
      });
      // 完全な HTML ページをレンダリング
      return res.render('users/index.html', {
        users,
        title: 'ユーザー管理',
      });
    } catch (error) {
      console.error('Error loading user page:', error);
      return res.status(500).render('error.html', {
        message: 'ページの読み込みに失敗しました',
      });
    }
  }
}
このコントローラは、ユーザー管理画面の初回アクセス時に呼び出されます。画面全体の HTML を生成し、初期データを埋め込んで返します。
パーシャルのコントローラ
typescript// controllers/users/UserListController.ts
import { Request, Response } from 'express';
import { UserService } from '../../services/UserService';
export class UserListController {
  constructor(private userService: UserService) {}
  // ユーザー一覧のパーシャル更新
  async list(req: Request, res: Response) {
    try {
      const {
        search,
        page = '1',
        limit = '20',
      } = req.query;
      // サービス層でデータ取得
      const users = await this.userService.searchUsers({
        search: search as string,
        page: parseInt(page as string, 10),
        limit: parseInt(limit as string, 10),
      });
      // パーシャル HTML をレンダリング
      return res.render('users/_list.html', { users });
    } catch (error) {
      console.error('Error loading user list:', error);
      return res
        .status(500)
        .send('<div class="error">読み込みエラー</div>');
    }
  }
}
htmx からの検索やページネーションのリクエストに応答し、ユーザー一覧部分のみを更新するための HTML を返します。
詳細表示のコントローラ
typescript// controllers/users/UserDetailController.ts
import { Request, Response } from 'express';
import { UserService } from '../../services/UserService';
export class UserDetailController {
  constructor(private userService: UserService) {}
  // ユーザー詳細のパーシャル表示
  async detail(req: Request, res: Response) {
    try {
      const { id } = req.params;
      // ユーザー情報の取得
      const user = await this.userService.getUserById(id);
      if (!user) {
        return res
          .status(404)
          .send(
            '<div class="error">ユーザーが見つかりません</div>'
          );
      }
      // 詳細パーシャルをレンダリング
      return res.render('users/_detail.html', { user });
    } catch (error) {
      console.error('Error loading user detail:', error);
      return res
        .status(500)
        .send('<div class="error">読み込みエラー</div>');
    }
  }
}
ユーザーの詳細情報を表示するパーシャルを返すコントローラです。一覧から詳細への遷移時に使用されます。
テンプレートの実装
完全ページのテンプレート
html<!-- views/users/index.html -->
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>{{ title }}</title>
    <script src="https://unpkg.com/htmx.org@1.9.10"></script>
  </head>
  <body>
    <div class="container">
      <h1>{{ title }}</h1>
      <!-- 検索バー -->
      <div class="search-section">
        <input
          type="text"
          name="search"
          placeholder="ユーザー名で検索"
          hx-get="/partials/users/list"
          hx-trigger="keyup changed delay:500ms"
          hx-target="#user-list"
          hx-include="[name='search']"
        />
      </div>
      <!-- ユーザー一覧(初期データを含む) -->
      <div id="user-list">
        {% include "users/_list.html" %}
      </div>
    </div>
  </body>
</html>
完全ページのテンプレートでは、HTML の基本構造を定義し、htmx の設定を行います。検索バーにはデバウンス処理を設定し、入力の 500ms 後に自動検索が実行されます。
パーシャルのテンプレート
html<!-- views/users/_list.html -->
<table class="user-table">
  <thead>
    <tr>
      <th>ID</th>
      <th>名前</th>
      <th>メール</th>
      <th>操作</th>
    </tr>
  </thead>
  <tbody>
    {% for user in users %}
    <tr>
      <td>{{ user.id }}</td>
      <td>{{ user.name }}</td>
      <td>{{ user.email }}</td>
      <td>
        <button
          hx-get="/partials/users/{{ user.id }}/detail"
          hx-target="#detail-modal"
          hx-swap="innerHTML"
        >
          詳細
        </button>
      </td>
    </tr>
    {% endfor %}
  </tbody>
</table>
パーシャルテンプレートは、一覧のテーブル部分のみを定義します。このファイルは完全ページからも、AJAX リクエストからも使用されます。
以下の図は、テンプレートの構造と関係性を示します。
mermaidflowchart TB
  index["index.html<br/>(完全ページ)"] -->|"include"| list["_list.html"]
  index -->|"include"| search["_search_bar.html"]
  ajax_list["AJAX リクエスト<br/>(一覧更新)"] -->|"レンダリング"| list
  ajax_detail["AJAX リクエスト<br/>(詳細表示)"] -->|"レンダリング"| detail["_detail.html"]
  style index fill:#e1f5ff
  style list fill:#fff4e1
  style search fill:#fff4e1
  style detail fill:#fff4e1
図で理解できる要点
- 完全ページがパーシャルを組み合わせて構成される
 - 同じパーシャルが初期ロードと AJAX で再利用される
 - テンプレートの責務が明確に分離されている
 
フォーム送信の実装
フォームの表示と送信処理も、コントローラを分けて実装します。
フォーム表示のコントローラ
typescript// controllers/users/UserFormController.ts
import { Request, Response } from 'express';
import { UserService } from '../../services/UserService';
export class UserFormController {
  constructor(private userService: UserService) {}
  // 編集フォームの表示
  async editForm(req: Request, res: Response) {
    try {
      const { id } = req.params;
      // 既存ユーザー情報の取得
      const user = await this.userService.getUserById(id);
      if (!user) {
        return res
          .status(404)
          .send(
            '<div class="error">ユーザーが見つかりません</div>'
          );
      }
      // フォームパーシャルをレンダリング
      return res.render('users/_form.html', {
        user,
        action: `/api/users/${id}`,
        method: 'PUT',
      });
    } catch (error) {
      console.error('Error loading edit form:', error);
      return res
        .status(500)
        .send('<div class="error">読み込みエラー</div>');
    }
  }
}
フォーム表示用のコントローラでは、既存データを取得してフォームに埋め込みます。
フォーム送信のコントローラ
typescript// controllers/users/UserUpdateController.ts
import { Request, Response } from 'express';
import { UserService } from '../../services/UserService';
export class UserUpdateController {
  constructor(private userService: UserService) {}
  // ユーザー情報の更新
  async update(req: Request, res: Response) {
    try {
      const { id } = req.params;
      const { name, email } = req.body;
      // バリデーション
      if (!name || !email) {
        return res
          .status(400)
          .render('users/_form_error.html', {
            errors: {
              name: !name ? '名前は必須です' : null,
              email: !email ? 'メールは必須です' : null,
            },
          });
      }
      // 更新処理
      const updatedUser = await this.userService.updateUser(
        id,
        {
          name,
          email,
        }
      );
      // 成功時は更新された詳細を返す
      return res.render('users/_detail.html', {
        user: updatedUser,
      });
    } catch (error) {
      console.error('Error updating user:', error);
      return res
        .status(500)
        .send(
          '<div class="error">更新に失敗しました</div>'
        );
    }
  }
}
更新処理では、バリデーションエラー時にはエラー表示パーシャルを、成功時には更新された詳細パーシャルを返します。
フォームのテンプレート
html<!-- views/users/_form.html -->
<form
  hx-{{
  method|lower
  }}="{{ action }}"
  hx-target="#detail-section"
  hx-swap="innerHTML"
>
  <div class="form-group">
    <label for="name">名前</label>
    <input
      type="text"
      id="name"
      name="name"
      value="{{ user.name }}"
      required
    />
  </div>
  <div class="form-group">
    <label for="email">メール</label>
    <input
      type="email"
      id="email"
      name="email"
      value="{{ user.email }}"
      required
    />
  </div>
  <div class="form-actions">
    <button type="submit">保存</button>
    <button
      type="button"
      hx-get="/partials/users/{{ user.id }}/detail"
      hx-target="#detail-section"
    >
      キャンセル
    </button>
  </div>
</form>
フォームには htmx の属性を設定し、送信時に AJAX で処理されるようにします。成功時には自動的に詳細表示に切り替わります。
エラーハンドリングの実装
エラー時にも適切な HTML パーシャルを返すことで、ユーザー体験を向上させます。
typescript// controllers/users/UserListController.ts(エラーハンドリング追加版)
export class UserListController {
  constructor(private userService: UserService) {}
  async list(req: Request, res: Response) {
    try {
      const {
        search,
        page = '1',
        limit = '20',
      } = req.query;
      const users = await this.userService.searchUsers({
        search: search as string,
        page: parseInt(page as string, 10),
        limit: parseInt(limit as string, 10),
      });
      // 結果が空の場合
      if (users.length === 0) {
        return res.render('users/_empty.html', {
          message: search
            ? '検索結果が見つかりませんでした'
            : 'ユーザーが登録されていません',
        });
      }
      return res.render('users/_list.html', { users });
    } catch (error) {
      console.error('Error loading user list:', error);
      // エラーパーシャルを返す
      return res.status(500).render('users/_error.html', {
        message: 'データの読み込みに失敗しました',
        retry: true,
      });
    }
  }
}
エラー時やデータが空の場合にも、専用のパーシャルテンプレートを用意することで、適切なフィードバックを提供できます。
html<!-- views/users/_error.html -->
<div class="error-message">
  <p>{{ message }}</p>
  {% if retry %}
  <button
    hx-get="/partials/users/list"
    hx-target="#user-list"
  >
    再読み込み
  </button>
  {% endif %}
</div>
エラー表示のパーシャルでは、再試行ボタンも提供し、ユーザーが自力で復旧できるようにします。
ディレクトリ構成のまとめ
最終的なディレクトリ構成は以下のようになります。
textsrc/
├── controllers/
│   └── users/
│       ├── UserPageController.ts       # 完全ページ
│       ├── UserListController.ts       # 一覧パーシャル
│       ├── UserDetailController.ts     # 詳細パーシャル
│       ├── UserFormController.ts       # フォーム表示
│       └── UserUpdateController.ts     # 更新処理
├── services/
│   └── UserService.ts                  # ビジネスロジック
├── routes/
│   └── users.ts                        # ルーティング定義
└── views/
    └── users/
        ├── index.html                  # 完全ページ
        ├── _list.html                  # 一覧パーシャル
        ├── _detail.html                # 詳細パーシャル
        ├── _form.html                  # フォームパーシャル
        ├── _empty.html                 # 空状態パーシャル
        └── _error.html                 # エラーパーシャル
この構成により、各ファイルの責務が明確になり、保守性の高いコードベースを実現できます。
まとめ
htmx を使った BFF 設計では、従来の MVC パターンとは異なるアプローチが求められます。本記事で解説した設計原則をまとめると、以下のようになります。
設計原則
| # | 原則 | 内容 | 
|---|---|---|
| 1 | コントローラの分割 | 機能とレスポンス形式で分割し、責務を明確化 | 
| 2 | パーシャルの命名 | アンダースコアプレフィックスで識別しやすく | 
| 3 | URL 設計 | /partials プレフィックスでエンドポイントを分類 | 
| 4 | サービス層の活用 | ビジネスロジックをコントローラから分離 | 
| 5 | エラーハンドリング | 専用パーシャルで適切なフィードバックを提供 | 
得られるメリット
この設計パターンを採用することで、以下のメリットが得られます。
まず、コードの見通しが良くなり、どのファイルがどの責務を持つか一目で分かるようになります。新しいメンバーがプロジェクトに参加した際も、迷わず必要なコードを見つけられるでしょう。
次に、テストが書きやすくなります。各コントローラが単一の責務に集中しているため、テストケースもシンプルになり、カバレッジの向上にもつながりますね。
さらに、パーシャルの再利用性が高まります。同じパーシャルを初期ロード時と AJAX リクエスト時の両方で使用できるため、コードの重複を避けられます。
最後に、拡張性が向上します。新しい機能を追加する際も、既存のコントローラに手を加えることなく、新しいコントローラとパーシャルを追加するだけで済むのです。
実践のポイント
実際にこの設計を導入する際は、以下の点に注意してください。
最初から完璧な分割を目指す必要はありません。まずは 1 つの機能で試してみて、チームに合った形に調整していくのが良いでしょう。
また、パーシャルの粒度は、画面の更新単位に合わせて決めます。頻繁に更新される部分は独立したパーシャルにし、そうでない部分は大きめにまとめても構いません。
エラーハンドリングは、ユーザー体験に直結する重要な要素です。単にエラーメッセージを表示するだけでなく、再試行ボタンや代替手段を提供することで、より良い UX を実現できます。
htmx と BFF パターンを組み合わせることで、シンプルかつ保守性の高い Web アプリケーションを構築できます。ぜひこの設計パターンを活用して、効率的な開発を進めてください。
関連リンク
articleWebSocket が「200 OK で Upgrade されない」原因と対処:プロキシ・ヘッダー・TLS の落とし穴
articleWebRTC 本番運用の SLO 設計:接続成功率・初画出し時間・通話継続率の基準値
articleAstro のレンダリング戦略を一望:MPA× 部分ハイドレーションの強みを図解解説
articleWebLLM が読み込めない時の原因と解決策:CORS・MIME・パス問題を総点検
articleVitest ESM/CJS 混在で `Cannot use import statement outside a module` が出る技術対処集
articleテスト環境比較:Vitest vs Jest vs Playwright CT ― Vite プロジェクトの最適解
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来