はじめに
FBC Press: Sinatra ― リクエストとレスポンスから理解する Web アプリケーション開発
本書は、プログラミングスクール FjordBootCamp(フィヨルドブートキャンプ) の教材として作成された、Sinatra を使って Web アプリケーション開発の基礎を学ぶ教科書です。
私たちは日ごろ、ブラウザを通してさまざまな Web アプリケーションを使っています。フォームに入力して送信すれば内容が保存され、リンクを開けば別の画面が表示されます。保存した内容を編集したり、削除したりすることもできます。こうした操作をするとき、その処理がどこで、どのように行われているかを意識する機会はあまりありません。
しかし、自分で Web アプリケーションを作るには、画面の裏で交わされるリクエストとレスポンスを理解する必要があります。ブラウザが何を送り、サーバーが何を返すのか。URL と Ruby の処理はどう結び付くのか。フォームから送った値はどこへ届くのか。こうした関係が分かれば、フレームワークが変わっても、動作を順に追えるようになります。
本書では、こうした Web アプリケーションの仕組みを、小さなアプリを自分で作りながら学びます。そのために使うのが、Ruby の Web アプリケーションフレームワークである Sinatra です。Sinatra の機能を網羅することではなく、Sinatra を通して Web アプリケーション開発の基礎を身につけることを目指します。
なぜ Sinatra で学ぶのか
Web アプリケーションフレームワークには、Rails のように多くの機能を備えたものもあります。それでも本書が Sinatra を選ぶのは、ここで学びたいことが「リクエストとレスポンスの往復」そのものだからです。
Sinatra は、小さなフレームワークです。たとえば get "/" do ... end と書けば、「GET / というリクエストに、この処理で応える」という対応が、そのままコードに表れます。URL と HTTP メソッド、そして Ruby の処理の結び付きが、余計な仕組みに隠れず目に見えます。
また、Sinatra のアプリは小さく保てます。本書では、映画の情報を登録したり閲覧したりできる「映画図鑑」を作ります。その中心となる Ruby のコードは、基本的に app.rb という一つのファイルに収まります。アプリ全体を一度に見渡せるので、一つのリクエストが届いてからレスポンスが返るまでを、最初から最後まで自分で追えます。ブラウザとサーバーの間で実際に何が起きているのかを確かめながら学ぶ本書にとって、この見通しのよさは大きな利点です。
Rails のような大きなフレームワークは、多くの処理を自動で行い、開発を速くします。その一方で、リクエストがどう処理されるのかは、便利な仕組みの内側に隠れます。往復の流れをまだつかんでいない段階では、その自動化がかえって理解を遠ざけてしまうことがあります。まず基礎を確かめるには、動きが見えやすい小さなフレームワークが向いています。
そして、Sinatra で身につけた「ブラウザ・Web サーバー・アプリケーションの往復」という見方は、Sinatra だけのものではありません。Sinatra も Rails も、Rack という共通の土台の上で動いています(第2章で説明します)。ここで学ぶ関係は、後で Rails など別のフレームワークへ進んでも、そのまま生きてきます。
本書で作る映画図鑑
本編では、映画の情報を登録する「映画図鑑」を一章ずつ作ります。映画は、タイトル、監督、公開年、ジャンル、紹介文を持ちます。完成時には、次の操作ができるようになります。
- 登録されている映画の一覧を見る
- 一件の映画の詳しい情報を見る
- 新しい映画を登録する
- 登録した映画を編集する
- 登録した映画を削除する
これらの操作の仕組みに集中できるよう、映画のデータは JSON ファイルへ保存します。ログイン機能、画像のアップロード、外部の映画 API、データベースは扱いません。多くの機能を備えた完成品を作ることよりも、一つのリクエストがどのように処理されるかを追える大きさに保つことを優先するためです。
映画図鑑に一つずつ機能を加えながら、それを支える Web の仕組みを学びます。具体的には、URL と処理を結び付けるルーティング、ERB テンプレート、フォーム、データの作成・読み取り・更新・削除(CRUD)、JSON 保存、HTTP メソッドを扱います。さらに、フォームの再送信を防ぐ PRG パターン、利用者の入力を安全に表示するための XSS 対策、見つからないことを伝える 404 ページも実装します。最後には、同じ知識を別の題材へ応用できる状態を目指します。
手を動かして作るハンズオン
本書はハンズオン形式です。完成したコードを読むだけの本ではありません。第2章で自分の作業用ディレクトリを用意し、そこへ一つずつファイルを作り、コマンドを実行しながら、映画図鑑を少しずつ育てていきます。
各章では、追加・変更するコードを示し、そのたびにアプリを動かして結果を確かめます。実際に手を動かして同じ操作を再現することで、コードの一行が画面や通信のどこに現れるのかを、自分の目で確認できます。読み進める前に、まずはターミナルとエディタを開ける状態にしておいてください。
想定する読者
本書は、次の内容を学習済みの方を対象にしています。
- Ruby の基本文法
- HTML と CSS の基礎
- Git と GitHub の基本操作
Web アプリケーション開発と Rails の経験は前提にしません。REST の事前学習も必須ではありません。どの URL と HTTP メソッドを使うかは、映画図鑑の実装に必要な範囲で説明します。
実際の通信を見ながら進める
本書では、Google Chrome の DevTools にある Network パネルを繰り返し使います。コードと画面だけでなく、ブラウザが送った HTTP メソッド、サーバーが返したステータスコード、リダイレクト前後のリクエストを観察するためです。Firefox などのブラウザにも同様の機能がありますが、画面上の名称と操作手順は Chrome を基準にします。
コードを書いたら、まず動かして終わりにせず、Network パネルでその動作を確かめます。そこに表示される情報が、Ruby のコードとブラウザの画面をつなぐ手掛かりになります。
第1部 Web アプリケーションの入口
Web アプリケーションを初めて作るときは、Ruby のコードやフレームワークの書き方へ目が向きがちです。しかし、そのコードが何に応じて実行され、結果がどこへ返るのかが分からなければ、画面が表示されないときに調べる場所を判断できません。
第1部では、まずブラウザとサーバーの間にあるリクエストとレスポンスを捉えます。その後、Sinatra で小さなサーバー側の処理を作り、URL と Ruby のコードが結び付くところまで進みます。
この段階では、まだ映画を登録しません。先に Web アプリケーション全体の往復を見ておくことで、後のフォームや JSON 保存を、ばらばらの書き方ではなく一続きの処理として読めるようにします。
第1章 Web アプリケーションはどこで動いているのか
完成した映画図鑑では、一覧から映画を選び、詳しい情報を見たり、新しい映画を登録したりできます。ブラウザに表示された画面だけを見ると、映画図鑑そのものがブラウザの中で動いているように感じるかもしれません。
ところが、本書で作る映画図鑑では、映画を探す処理や保存する処理は Ruby のプログラムが担当します。ブラウザと Ruby のプログラムは、どのようにつながるのでしょうか。この章では、ブラウザが情報を送り、サーバーから結果を受け取るまでの流れをたどります。
1.1 ブラウザだけでは映画図鑑は動かない
ブラウザは HTML を読み取り、見出し、文章、リンク、フォームなどを画面に表示します。CSS を読み取って見た目を整えることもできます。HTML と CSS のファイルが手元にあれば、ファイルを直接ブラウザで開いて内容を確認できます。
一方、映画図鑑には、画面を表示するだけでは終わらない処理があります。
- 保存されている映画を読み込む
- 指定された一件を探す
- フォームから送られた映画を保存する
- 指定された映画を更新または削除する
本書では、これらをサーバー側の Ruby プログラムで処理します。ブラウザは Ruby のプログラムを直接実行しません。ブラウザから処理を依頼し、処理結果を受け取ります。
ブラウザ側で JavaScript を動かす Web アプリケーションもありますが、本書では JavaScript を使いません。まず、ブラウザとサーバー側のプログラムが HTTP で情報をやり取りする基本形に集中します。
1.2 URL はファイル名ではなくリクエストの宛先
HTML ファイルを直接開いたとき、ブラウザのアドレスバーには次のような URL が表示されます。これは macOS での例です。ファイルの場所を表す部分は OS によって異なります。
file:///Users/your-name/movie-catalog/index.html
末尾の index.html は、手元にあるファイルを指しています。それに対して、本書で作る映画図鑑へアクセスするときは、次のような URL を使います。
http://localhost:4567/movies
この URL の各部分には役割があります。
| 部分 | 例 | 役割 |
|---|---|---|
| スキーム | http | URL をどの仕組みで扱うかを表す |
| ホスト | localhost | 接続する相手を表す |
| ポート番号 | 4567 | 接続先で待ち受ける入口を表す |
| パス | /movies | 相手へ示すリクエストの宛先を表す |
localhost は、今使っているコンピューター自身を表す名前です。映画図鑑をローカル環境で起動すると、ブラウザと Ruby のプログラムは同じコンピューター上で動きます。それでも、ブラウザは URL を使って相手へアクセスします。
スキームが http なら、ブラウザは HTTP を使って相手へアクセスします。file なら、ネットワーク上の相手へリクエストを送るのではなく、手元のファイルを開きます。
ここで重要なのは、/movies が movies.html というファイルを表すとは限らないことです。サーバー側のプログラムは、/movies へのアクセスを受け取ったときに実行する処理を決められます。URL のパスと処理を対応付ける仕組みをルーティングと呼びます。第2章では、Sinatra を使って実際にこの対応を作ります。
アドレスバーを見て、今開いている本書の URL と、手元の HTML ファイルを直接開いたときの URL を比べてみてください。https:// または http:// で始まっているか、file:// で始まっているかによって、ブラウザがどこから内容を得たのかを区別できます。
1.3 リクエストとレスポンスの往復
ブラウザが URL へアクセスすると、ブラウザから Web アプリケーションへリクエストが送られます。リクエストは「この宛先について、この方法で処理してほしい」という依頼です。
リクエストを受け取った Web アプリケーションは処理を行い、ブラウザへレスポンスを返します。レスポンスには、処理が成功したかどうかを表す情報や、ブラウザに表示してほしい HTML などが含まれます。
この関係では、ブラウザがクライアント、リクエストを待ち受けてレスポンスを返す側がサーバーです。サーバーという言葉は、コンピューターそのものを指す場合と、リクエストを受け付けるプログラムを指す場合があります。本書では主に後者の意味で使います。
映画一覧を開くときの流れを、この図に当てはめると次のようになります。
- ブラウザが
/moviesへリクエストを送る。 - Web アプリケーションがリクエストを受け取る。
- Web アプリケーションが映画の一覧を含むレスポンスを作る。
- ブラウザがレスポンスを受け取り、HTML を画面に表示する。
Ruby のコードが動くのは 2 と 3 の側です。ブラウザが受け取るのは Ruby のソースコードではなく、Ruby の処理によって作られたレスポンスです。この境界が分かると、「Ruby の変数を変更したのに、なぜブラウザの画面へ直接反映されないのか」といった疑問を切り分けやすくなります。
1.4 HTTP のメッセージをテキストとして読む
ブラウザと Web アプリケーションは、勝手な形式で情報を送り合っているわけではありません。HTTP という共通の取り決めに従います。このような通信上の取り決めをプロトコルと呼びます。
HTTP/1.1 のリクエストは、必要な部分だけに絞ると次のように読めます。実際のブラウザは、この例にないヘッダーも送ります。
GET /movies HTTP/1.1
Host: localhost:4567
Accept: text/html
最初の行にある GET は、処理の目的を示す HTTP メソッドです。/movies はリクエストの宛先となるパスです。2 行目以降の Host や Accept はヘッダーで、接続先や受け取りたい内容についての追加情報を伝えます。
このリクエストに対するレスポンスは、次のように読めます。
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
<h1>映画図鑑</h1>
200 OK はステータスコードと呼ばれ、リクエストが成功したことを表します。Content-Type ヘッダーは、レスポンス本文がどの種類のデータであるかを表します。空行より後ろの <h1>映画図鑑</h1> がレスポンスの本文です。
ブラウザはこの HTML を解釈し、「映画図鑑」という見出しとして画面に表示します。ブラウザに見えている画面と、サーバーが返したレスポンス本文は同じものではありません。画面は、ブラウザがレスポンス本文を解釈した結果です。
ここでは、次の対応を読み取れれば十分です。
| 確認するもの | リクエストまたはレスポンスでの例 | 分かること |
|---|---|---|
| HTTP メソッド | GET | どのような目的のリクエストか |
| パス | /movies | どの宛先へのリクエストか |
| ステータスコード | 200 OK | 処理がどのような結果になったか |
| ヘッダー | Content-Type: text/html | メッセージについての追加情報 |
| レスポンス本文 | <h1>映画図鑑</h1> | ブラウザへ返された内容 |
サーバーが「リクエストされたものを見つけられなかった」と伝えるためのステータスコードが 404 Not Found です。エラー時にも、ステータスコード、ヘッダー、本文を持つレスポンスを返せます。映画図鑑でも、存在しない URL や映画に対して 404 を返す処理を第10章で実装します。
HTTP のバージョンについて
この例では、構造を文字として追いやすい HTTP/1.1 の形式を使っています。HTTP/2 と HTTP/3 は通信時の内部表現が異なりますが、HTTP メソッド、ステータスコード、ヘッダーといった意味は引き継がれています。本書ではバージョンごとの通信方式には踏み込みません。
1.5 HTML を誰が用意するか
ここまでの説明から、「HTML ファイルを直接開く方法は Web ではなく、サーバーが HTML を返す方法だけが Web である」と考えるのは正確ではありません。Web サーバーが、あらかじめ用意された HTML ファイルをそのまま返す場合もあります。
違いを整理すると、次の三つに分けられます。
| 開き方 | HTTP のやり取り | HTML の用意の仕方 |
|---|---|---|
| 手元の HTML ファイルを直接開く | ない | 手元のファイルをブラウザが読む |
| Web サーバー上の静的な HTML を開く | ある | 用意済みのファイルをサーバーが返す |
| 映画図鑑の画面を開く | ある | Ruby の処理が保存データを使って HTML を作る |
ブラウザが最終的に HTML を解釈する点は共通しています。異なるのは、HTML を誰が、どの時点で用意するかです。
映画図鑑では、同じ /movies へアクセスしても、映画を新しく登録した後には一覧の内容が変わります。あらかじめ固定した HTML ファイルを返すのではなく、その時点で保存されている映画からレスポンス本文を作るためです。このように、リクエストや保存データに応じて返す内容を組み立てられることが、これから作る Web アプリケーションの重要な性質です。
1.6 映画図鑑で作るリクエストの入口
映画図鑑には、一覧、詳細、登録、編集、削除の画面や処理を用意します。すべてを一つの画面、一つの処理へ詰め込まず、目的に応じた URL と HTTP メソッドへ分けます。
完成時の主な操作は次のとおりです。
| 操作 | ブラウザで起こすこと | Web アプリケーションの処理 |
|---|---|---|
| 一覧を見る | 一覧の URL を開く | 保存されている映画を読み込んで表示する |
| 詳細を見る | 一件の映画へのリンクを開く | ID で映画を探して表示する |
| 登録する | フォームを送信する | 新しい ID を付けて映画を保存する |
| 編集する | 入力済みのフォームを送信する | ID が一致する映画を更新する |
| 削除する | 削除用のフォームを送信する | ID が一致する映画を削除する |
映画は最初から、データを一意に識別する ID、タイトル、監督、公開年、ジャンル、紹介文を持ちます。一覧では一部の情報だけを見せ、詳細では一件の情報をまとめて見せます。この違いを作ることで、URL が映画の一覧と一件の映画のどちらを指すかも具体的に考えられます。
この章では、まだコードを書きません。第2章で Sinatra を起動し、最初のリクエストを受け取るところから始めます。先にブラウザとサーバーの境界を押さえておくと、Sinatra の get を単なる Ruby の書き方ではなく、「どのリクエストへ、どのレスポンスを返すか」という対応として読めます。
1.7 Network パネルで往復を確かめる
ここまで説明したリクエストとレスポンスは、Chrome DevTools の Network パネルで観察できます。まず、http:// または https:// で開いている本書のページを対象に確認します。アドレスバーが file:// で始まっている場合は、本書の公開ページを開いてください。リポジトリから確認している場合は、mdbook serve で表示された URL を使います。
- Chrome で本書のページを開いたまま DevTools を開く。macOS では
Command + Option + I、Windows と Linux ではControl + Shift + Iを押す。 Networkパネルを選ぶ。- DevTools を開いたままページを再読み込みする。
- リクエストの一覧から、現在のページに対応する
documentまたはDocの行を選ぶ。 HeadersタブとResponseタブを開く。
Network パネルは、開いている間に発生したリクエストを記録します。パネルを開く前のリクエストが表示されていない場合があるため、開いた後に再読み込みします。
Chrome のバージョンや表示言語によって、Doc、document、タブの名前などが少し異なる場合があります。現在のページと同じ URL の行を手掛かりに選んでください。
選んだリクエストで、次の項目を探してください。
| 表示場所 | 項目 | 確認すること |
|---|---|---|
Headers の General | Request URL | どの URL へリクエストを送ったか |
Headers の General | Request Method | どの HTTP メソッドを使ったか |
Headers の General | Status Code | どの結果が返ったか |
Headers | Request Headers | ブラウザが送った追加情報 |
Headers | Response Headers | サーバーが返した追加情報 |
Response または Preview | レスポンス本文 | ブラウザが受け取った内容 |
最初からすべてのヘッダーを理解する必要はありません。まずは Request URL、Request Method、Status Code の三つを見つけてください。その三つだけでも、「どこへ」「どの方法で」リクエストを送り、「どの結果が」返ったかを読み取れます。
見つけた値を使い、「ブラウザは〇〇へ GET リクエストを送り、〇〇というステータスコードのレスポンスを受け取った」と一文で説明してみてください。
Response に表示された HTML と、ブラウザに表示されている画面も比べてみます。HTML のタグがそのまま画面に並んでいるのではなく、ブラウザが見出しや段落として解釈していることを確認できます。
最後に、次の説明のどこが誤っているかを考えてみてください。
ブラウザは URL から Ruby のコードを受け取り、その Ruby コードを実行して映画一覧を表示する。
ブラウザが送るのはリクエストです。Ruby のコードはサーバー側でリクエストを処理し、レスポンスを作ります。ブラウザが受け取って解釈するのは、そのレスポンスです。
次章では、この往復の右側にある Web アプリケーションを Sinatra で起動します。GET /movies というリクエストと Ruby の処理を結び付け、Network パネルで自分のアプリのレスポンスを確認します。
さらに学ぶ
HTTP には、HTTP/1.1、HTTP/2、HTTP/3 という複数のバージョンがあります。実際の Web ではどれか一つだけが使われているわけではなく、接続先や環境に応じて使い分けられています。公開されている Web サイトでは HTTP/2 や HTTP/3 も広く使われていますが、本書で起動するローカルの Sinatra アプリとの通信には HTTP/1.1 が使われます。
普段、利用者やアプリケーション開発者がバージョンを選んでリクエストを書く必要はありません。対応するバージョンをブラウザとサーバーが判断するためです。実際に使われたバージョンは、Network パネルのリクエスト一覧を右クリックして Protocol の列を表示すると確認できます。http/1.1、h2、h3 は、それぞれ HTTP/1.1、HTTP/2、HTTP/3 を表します。
最初に押さえたいのは、どのバージョンにも共通するリクエストとレスポンスの意味です。この章では、通信内容を文字として読みやすい HTTP/1.1 の例を使い、メソッド、パス、ステータスコード、ヘッダー、本文を学びました。Web アプリケーションを作り始めるうえでは、まずこの共通部分を理解すれば十分です。複数のリクエストを効率よく運ぶ仕組みや通信性能を詳しく調べる段階で、HTTP/2 や HTTP/3 の違いへ進むとよいでしょう。
参考資料
- HTTP: ハイパーテキスト転送プロトコル - MDN Web Docs
- ウェブサーバーとは - MDN Web Docs
- Network features reference - Chrome for Developers
- RFC 9110: HTTP Semantics
- RFC 9112: HTTP/1.1
- RFC 9113: HTTP/2
- RFC 9114: HTTP/3
第2章 Sinatra をはじめる
第1章では、ブラウザがリクエストを送り、Web アプリケーションがレスポンスを返す往復を見ました。この章では、その往復のサーバー側を初めて作ります。
最初に返す内容は、「映画図鑑」という短い文字列だけです。Sinatra アプリを実際に起動し、URL、Sinatra のルート、ブラウザに返る内容の対応を一つずつ追います。
2.1 Sinatra が結ぶリクエストと Ruby
Sinatra は、Ruby で Web アプリケーションを作るためのフレームワークです。2007 年に、ソフトウェア開発者の Blake Mizerany(ブレイク・マイゼラニー)が作り始めました。その後も多くの開発者がコードやドキュメントの改善を重ね、現在まで使われる OSS として育てています。
Sinatra を使うと、第1章で説明したリクエストのうち、HTTP メソッドとパスの組み合わせを Ruby の処理へ対応付けられます。
たとえば、次のコードは GET /movies というリクエストに対応します。
get "/movies" do
"映画図鑑"
end
このような対応をルートと呼びます。コードの各部分は、次の意味を持ちます。
| コード | 対応するもの |
|---|---|
get | HTTP メソッドの GET |
"/movies" | リクエストのパス /movies |
do から end | リクエストが一致したときに実行する処理 |
"映画図鑑" | レスポンス本文になる文字列 |
get は、Sinatra が用意する Ruby のメソッドです。Web アプリケーションのルートを短く書けるように、Sinatra はこのような専用の書き方を提供しています。特定の用途に合わせて用意された書き方のまとまりを DSL(Domain-Specific Language) と呼びます。英語の展開を覚えることよりも、get が HTTP の GET と Ruby の処理を結び付けている点を押さえてください。
このコードがブラウザで実行されるわけではありません。Sinatra アプリが GET /movies を受け取るとブロックを実行し、最後に評価された文字列を使ってレスポンスを作ります。
2.2 作業用ディレクトリを用意する
本書は、映画図鑑というサンプルアプリを、自分の手で一から作りながら進めるハンズオンです。コードを読むだけでなく、実際にファイルを作り、コマンドを実行しながら読み進めてください。
ここからは、映画図鑑を作るための新しいディレクトリを用意します。ターミナルで、作業したい場所へ移動してから、次のコマンドを実行します。
mkdir sinatra-movies
cd sinatra-movies
git init
mkdir は sinatra-movies という空のディレクトリを作ります。cd で、そのディレクトリへ移動します。git init は、このディレクトリを Git で管理し始めるためのコマンドです。これから作るファイルは、すべてこの sinatra-movies ディレクトリの中に置きます。
現在いるディレクトリは、次のコマンドで確認できます。
pwd
出力の末尾が sinatra-movies なら、以降のコマンドを実行する作業用ディレクトリにいます。
2.3 バージョンをそろえる三つのファイル
コードを書く前に、本書と同じ環境を使えるようにします。Ruby も Gem も、バージョンによって使える書き方や動作が変わることがあります。バージョンがずれていると、本書のとおりに書いても画面や出力が違ったり、エラーになったりします。そうした食い違いを避け、誰の手元でも同じ結果になるように、最初に使うバージョンを本書とそろえます。バージョンに関わるファイルは、次の三つです。
| ファイル | 決めるもの | 本書での例 |
|---|---|---|
.ruby-version | Ruby 本体のバージョン | Ruby 4.0.6 |
Gemfile | 必要な Gem と許容するバージョン範囲 | Sinatra 4.2 系 |
Gemfile.lock | 実際に使う Gem の組み合わせ | Sinatra 4.2.1 |
このうち .ruby-version と Gemfile は、これから自分で作ります。Gemfile.lock は、次の 2.4 で bundle install を実行すると自動で作られます。
まず、sinatra-movies ディレクトリに .ruby-version を作り、次の一行を書きます。
4.0.6
Ruby のバージョン管理ツールは、このファイルに対応していれば、ディレクトリで使う Ruby を選ぶときに参照します。.ruby-version があるだけで Ruby 本体がインストールされるわけではありません。ターミナルで確認します。
ruby -v
出力の先頭が ruby 4.0.6 であることを確認してください。異なる場合は、これまで使ってきた Ruby のバージョン管理方法で 4.0.6 を用意してから進みます。本書では、Ruby のインストール方法やバージョン管理ツールの比較は扱いません。
次に、Gemfile を作ります。Gem とは、Ruby のライブラリを再利用しやすい形にまとめて配布する仕組み、またはそのまとまりです。他の人が作った機能を Gem として取り込むことで、Web サーバーやルーティングなどを自分でゼロから書かずに済みます。この後で使う Sinatra や Puma も Gem として提供されています。
Gemfile は、そのアプリで使う Gem を宣言するファイルです。次の内容を書きます。
source "https://rubygems.org"
ruby "4.0.6"
gem "puma", "~> 8.0.0"
gem "rackup", "~> 2.3.0"
gem "sinatra", "~> 4.2.0"
ここでは三つの Gem を宣言しています。それぞれの役割は次のとおりです。
- Sinatra は、Ruby で Web アプリケーションを書くためのフレームワークです。2.1 で見た
get "/movies"のようなルーティングをはじめ、リクエストを処理してレスポンスを返す機能を提供します。本書の主役となる Gem です。 - Puma は、ブラウザから届く HTTP リクエストを受け付ける Web サーバーです。リクエストを Sinatra のアプリへ渡し、返ってきたレスポンスをブラウザへ送り返します。
- rackup は、Sinatra が Puma を起動するときに使う補助的な Gem です。それ自体が別のサーバーになるわけではありません。
この三つがどう連携するのかは、アプリを最初に動かした後、2.7 で図を使って確かめます。
gem "sinatra", "~> 4.2.0" の ~> 4.2.0 は、使ってよいバージョンの範囲を指定する書き方です。ここでは 4.2 系(4.2.0 以上 4.3.0 未満)のパッチバージョンを許容します。バージョンをまったく指定しないと、後日 bundle install したときに大きく異なるバージョンが入り、書き方や動作が変わってしまうことがあります。範囲を指定しておくことで、機能を壊すような更新を避けつつ、細かな修正だけを取り込めます。Gemfile だけでは 4.2 系のどのバージョンが選ばれるかまでは決まらないため、それを固定するのが次の Gemfile.lock です。
Gemfile.lock には、Bundler が実際に選んだ Gem とバージョンが記録されます。このファイルは、次の 2.4 で bundle install を実行すると作られ、Sinatra 4.2.1、Puma 8.0.2、rackup 2.3.1 などのバージョンが固定されます。Gemfile.lock は Bundler が更新するため、手では編集しません。Git へコミットして、同じリポジトリを使う人が同じ組み合わせを利用できるようにします。
2.4 Bundler で必要な Gem を用意する
Bundler は、Gem を管理するためのツールです。これも Gem の一つで、Ruby を入れると一緒に使えるようになります。一つの Gem が別の Gem に依存していることは珍しくなく、それらのバージョンを手作業で合わせるのは大変です。Bundler は、Gemfile に書いた宣言をもとに、互いにかみ合う Gem の組み合わせを解決し、その結果を Gemfile.lock に記録します。これにより、同じ Gemfile.lock を使う人は、全員が同じ Gem の組み合わせで動かせます。
まず、Bundler のバージョンを確認します。
bundle -v
本書では Bundler 4.0.16 で動作を確認しています。異なるバージョンが表示された場合は、次のコマンドで 4.0.16 をインストールします。
gem install bundler -v 4.0.16
インストール後、sinatra-movies ディレクトリでもう一度 bundle -v を実行し、出力に 4.0.16 が含まれることを確認します。続いて、必要な Gem をインストールします。
bundle install
初回は Gem のダウンロードに時間がかかることがあります。最後に Bundle complete! と表示され、エラーなくコマンドが終了すれば準備できています。このとき、選ばれた Gem の組み合わせが Gemfile.lock に書き出されます。すでにインストール済みの場合も、Bundler は現在の状態を確認します。
2.5 最初のルートを app.rb に書く
app.rb は、映画図鑑のサーバー側の処理を書く中心的な Ruby ファイルです。app は application を短くした名前です。Sinatra がこのファイル名を必須としているわけではありませんが、本書では役割が分かりやすいように app.rb へ統一します。
このファイルに Sinatra の読み込みやルートを書き、後で ruby app.rb と実行します。まず、sinatra-movies ディレクトリに app.rb を作り、次のコードを書きます。
require "sinatra"
get "/" do
"映画図鑑を作ります"
end
require "sinatra" によって Sinatra を読み込み、get などの DSL を使えるようにします。
"/" は URL のルートとなるパスです。GET / を受け取ると、Sinatra はこのブロックを実行します。ブロックの最後の文字列 "映画図鑑を作ります" がレスポンス本文になります。
第1章で見た対応に当てはめてみます。
GET / HTTP/1.1
Host: localhost:4567
このリクエストに get "/" が一致し、レスポンス本文として次の文字列が返ります。
映画図鑑を作ります
まだ HTML の要素は返していません。まずは、文字列を返す最小のルートが動くことを確認します。
2.6 Puma で Sinatra アプリを起動する
app.rb があるディレクトリで、次のコマンドを実行します。
bundle exec ruby app.rb
ruby app.rb は、今作った app.rb を Ruby で実行する指定です。先頭の bundle exec は、Gemfile と Gemfile.lock で管理している Gem を使える状態にして、後ろのコマンドを実行します。単に ruby app.rb とした場合、環境に別のバージョンの Sinatra が入っていれば、意図しない組み合わせが選ばれる可能性があります。本書では起動方法を一つにそろえるため、常に bundle exec を付けます。
起動に成功すると、Sinatra 4.2.1、Puma 8.0.2、Ruby 4.0.6 といった情報に続いて、次のような待ち受け先が表示されます。細かな出力は環境によって異なります。
Listening on http://127.0.0.1:4567
コマンド入力へ戻らないのは、処理が止まったからではありません。Puma がポート 4567 でリクエストを待ち受けているためです。このターミナルはそのままにして、Chrome で次の URL を開きます。
http://localhost:4567/
画面に「映画図鑑を作ります」と表示されれば、GET / のリクエストと get "/" のルートがつながっています。Network パネルでも Request Method が GET、Status Code が 200 OK、Response が「映画図鑑を作ります」であることを確認してください。
アプリを停止するときは、起動したターミナルで Control + C を押します。本書の構成では、コードを変更しても起動中のアプリへ自動では反映されません。これから app.rb を変更するたびに、Control + C で停止し、同じ起動コマンドをもう一度実行します。
起動時に Address already in use と表示された場合は、別のターミナルで前に起動したアプリが動き続けていないか確認します。詳しい切り分けは、付録D「よくあるエラー」で扱います。
2.7 Puma、Rack、Sinatra の受け渡し
起動時の表示には、Sinatra と Puma という名前がありました。Gemfile に書いた Sinatra、Puma、rackup は、それぞれ同じ役割を持つものではありません。
図の下向きの矢印は、ブラウザから Sinatra へ届くリクエストです。上向きの矢印は、Sinatra からブラウザへ戻るレスポンスです。
ブラウザから届いた HTTP リクエストを最初に受け付けるのが Puma です。Puma は、Ruby の Web アプリケーションへリクエストを渡し、返されたレスポンスをブラウザへ送ります。
Web サーバーと Web アプリケーションの間では、情報をどの形で受け渡すかをそろえる必要があります。その共通のインターフェースを定めるのが Rack です。図では Rack を、Puma や Sinatra と同じ実行主体の箱ではなく、両者が従う共通の取り決めを表す境界として示しています。
Sinatra は Rack のインターフェースに従う Rack アプリケーションです。Sinatra は Rack との細かな受け渡しを担当しながら、私たちには get "/movies" のようなルーティングの書き方を提供します。Rails も Rack の上で動く Web アプリケーションです。今後 Rails を使うときにも、ブラウザ、Web サーバー、Rack、アプリケーションという関係は残ります。
rackup は、本書の起動方法で Sinatra が Puma を起動するために使うサーバーハンドラーを提供します。アプリを起動するために必要な Gem ですが、別のサーバーとして起動するわけではありません。
本書では、require "sinatra" と書き、ルートをトップレベルに定義する クラシックスタイル(Classic Style) を使います。一つの小さなアプリを一つの app.rb から始めるため、クラスを定義せずルーティングへ集中できる書き方を選びます。別の書き方であるモジュラースタイル(Modular Style)の比較は、本書の範囲外です。
2.8 URL ごとに別のルートを選ぶ
映画図鑑の入口には /movies というパスを使います。app.rb の末尾へ、二つ目のルートを追加します。
get "/movies" do
"映画図鑑"
end
アプリを再起動し、二つの URL を順に開きます。
http://localhost:4567/
http://localhost:4567/movies
/ では「映画図鑑を作ります」、/movies では「映画図鑑」と表示されます。同じ Puma と Sinatra のプロセスへ送った GET リクエストでも、パスが異なるため別のルートが選ばれます。
ルートはパスだけで決まるものではありません。Sinatra では、HTTP メソッドとパスの組み合わせが一致するルートを探します。この章では GET だけを使いますが、後の章では同じ /movies というパスへ POST を送るルートも作ります。
2.9 / から /movies へ移動させる
映画図鑑の一覧は /movies で表示する方針です。利用者がルート URL の / を開いたときも、/movies へ移動するようにします。
get "/movies" は残したまま、get "/" のブロックだけを次のように変更します。
get "/" do
redirect "/movies"
end
redirect は Sinatra が用意するヘルパーメソッドです。この場合、/movies の内容を GET / のレスポンス本文として返すわけではありません。ブラウザへ「次は /movies をリクエストしてください」と伝えるリダイレクトレスポンスを返します。
アプリを再起動し、アドレスバーへ次の URL を入力します。
http://localhost:4567/
最終的に「映画図鑑」と表示され、アドレスバーが http://localhost:4567/movies へ変わります。画面だけを見ると一度のアクセスで /movies が表示されたように見えますが、裏では二つのリクエストが発生しています。
2.10 Network パネルで二つの GET を見る
Network パネルを開いたまま、もう一度 http://localhost:4567/ へアクセスします。記録されたリクエストを時刻順に見ると、次の流れを確認できます。
GET /に対して302 Foundが返り、/moviesへ移動するよう伝える。GET /moviesに対して200 OKが返り、「映画図鑑」という本文を受け取る。
最初の GET / を選び、Headers の Response Headers にある Location を探します。
Location: http://localhost:4567/movies
302 Found はリダイレクトを表すステータスコードの一つです。ブラウザは Location ヘッダーを読み、新しい GET /movies リクエストを送ります。二つ目の 200 OK のレスポンス本文を受け取ってから、「映画図鑑」を画面へ表示します。
起動したターミナルのログにも、次のような二行が表示されます。
"GET / HTTP/1.1" 302
"GET /movies HTTP/1.1" 200
日時や処理時間など、前後の表示は環境によって異なります。ここで見るのは、HTTP メソッド、パス、ステータスコードです。
リダイレクトを使う理由やステータスコードの選び方は、フォームからデータを変更した後に重要になります。第8章で PRG パターンとして改めて説明します。この章では、リダイレクトが「別の URL へ移動した画面」ではなく、次のリクエストを促すレスポンスであることを押さえます。
redirect "/movies" が、get "/movies" のブロックをその場で呼び出しているわけではありません。最初のレスポンスを受け取ったブラウザが、別の GET /movies を送ることで二つ目のルートが実行されます。この区別は、Network パネルの二つの行とターミナルの二つのログで確認できます。
ここで見た流れを、「ブラウザ」「302 のレスポンス」「二つ目の GET」という三つの言葉を使って一文で説明してみてください。/movies の本文が最初のレスポンスに含まれている、という説明になっていないかも確認します。
2.11 この章のコードを確認する
この章の終了時点で、app.rb は次の内容になります。
require "sinatra"
get "/" do
redirect "/movies"
end
get "/movies" do
"映画図鑑"
end
二つのルートを、リクエストとレスポンスへ対応付けると次のようになります。
GET /:redirect "/movies"を実行し、302 と/moviesを示すLocationヘッダーを返す。GET /movies:"映画図鑑"を評価し、200 と「映画図鑑」という本文を返す。
次章では、"映画図鑑" という文字列を HTML の画面へ育てます。app.rb に長い HTML を直接書き続けず、ERB テンプレートと views/ ディレクトリを使って、Ruby のデータから映画一覧を作ります。そのときは、レスポンスヘッダーの Content-Type も Network パネルで確認します。
さらに学ぶ
Rack は、Ruby の Web サーバーと Web アプリケーションが同じ方法でリクエストとレスポンスを受け渡すための共通インターフェースです。共通のインターフェースがあることで、Sinatra は Puma からリクエストを受け取れます。
本章では、Rack を独立したサーバーやフレームワークとして操作しませんでした。Sinatra を使うと、Rack の受け渡しを直接書かずにルーティングへ集中できます。後の章で method override を使うときには、フォームから届いたリクエストを Sinatra のルートへ渡す前に Rack が処理する例を見ます。
参考資料
- Sinatra: About
- Sinatra: README - Getting Started, Routes, Return Values, Browser Redirect, Modular vs. Classic Style, Rack Middleware
- sinatra 0.1.5 - RubyGems.org
- Bundler: Gemfile
- Bundler: bundle exec
- Rack: a Ruby Webserver Interface
- sinatra 4.2.1 - RubyGems.org
- puma 8.0.2 - RubyGems.org
第2部 画面表示とフォーム
第2部では、Sinatra から HTML を返し、ブラウザに画面を表示します。
第3章では、文字列だけのレスポンスから ERB テンプレート、レイアウト、CSS を使った画面へ進みます。
第4章では、フォームからリクエストを送る仕組みを学びます。送信された値を params で確認し、Network タブで Form Data を観察します。
第3章 HTML をレスポンスとして返す
第2章では、GET /movies に対して「映画図鑑」という文字列を返しました。ブラウザには表示されましたが、まだ見出しも一覧もありません。
この章では、レスポンス本文として HTML を返します。最初は短い HTML を Ruby の文字列として返し、その後で ERB テンプレートへ移します。最後に、Ruby の配列とハッシュで用意した映画データを ERB で HTML に埋め込み、共通のレイアウトを使った映画一覧を表示します。
この章は、第2章で作った sinatra-movies ディレクトリでそのまま続けます。第3章から読み始める場合は、第2章の最後(2.11)に示した app.rb を用意した状態から始めてください。
3.1 文字列ではなく HTML を返す
第2章の app.rb は、次の状態で終わりました。
require "sinatra"
get "/" do
redirect "/movies"
end
get "/movies" do
"映画図鑑"
end
"映画図鑑" は、レスポンス本文になる文字列です。レスポンス本文に HTML を入れれば、ブラウザはその HTML を解釈して表示します。
試しに、get "/movies" のブロックを次のように変更します。
get "/movies" do
"<h1>映画図鑑</h1>"
end
アプリを再起動し、Chrome で次の URL を開きます。
http://localhost:4567/movies
ブラウザには、大きな見出しとして「映画図鑑」と表示されます。Network パネルで GET /movies を選び、Response を見ると、次の HTML がレスポンス本文として届いていることを確認できます。
<h1>映画図鑑</h1>
ここで重要なのは、ブラウザが Ruby のコードを読んでいるわけではない、という点です。Ruby のコードはサーバー側で実行され、ブラウザには実行結果の HTML が返ります。
ただし、HTML が少し長くなるだけで、Ruby の文字列として書くのは読みにくくなります。
get "/movies" do
"<h1>映画図鑑</h1><ul><li>月面喫茶</li><li>北風のリズム</li></ul>"
end
これは読みにくさを確認するための一時的な例です。最終的なコードには残しません。HTML は HTML として書けるファイルへ分けましょう。そのために ERB を使います。
3.2 views/ と ERB テンプレート
ERB は、HTML の中に Ruby の処理を埋め込めるテンプレートです。Sinatra では、views/ ディレクトリに置いた ERB ファイルを erb メソッドで表示できます。
sinatra-movies ディレクトリに views ディレクトリを作り、その中に index.erb を作ります。
.
├── app.rb
└── views/
└── index.erb
views/index.erb に、次の HTML を書きます。
<h1>映画図鑑</h1>
次に、app.rb の get "/movies" を変更します。
get "/movies" do
erb :index
end
erb :index は、views/index.erb を読み込み、その結果をレスポンス本文として返します。:index は Ruby のシンボルです。Sinatra の erb メソッドでは、テンプレート名をシンボルで指定します。
アプリを再起動して /movies を開くと、先ほどと同じように「映画図鑑」が見出しとして表示されます。見た目は同じですが、HTML を Ruby の文字列から views/index.erb へ移せました。
ERB では、次の二つの書き方をよく使います。
| ERB の書き方 | 役割 |
|---|---|
<% Ruby の処理 %> | Ruby の処理を実行する。結果は画面に出力しない。 |
<%= Ruby の式 %> | Ruby の式を評価し、結果を HTML に出力する。 |
この章では、映画の配列を繰り返すために <% %> を使い、映画のタイトルや公開年を出力するために <%= %> を使います。
3.3 layout.erb に共通の HTML 構造を書く
今の views/index.erb には、h1 だけがあります。しかし、HTML 文書としては本来、doctype、html、head、body などの外枠も必要です。
それらを各画面の ERB に毎回書くと、画面が増えたときに同じ HTML が重複します。Sinatra では、views/layout.erb を用意すると、既定で各テンプレートの外側に使われます。
views/layout.erb を作り、次の内容を書きます。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>映画図鑑</title>
</head>
<body>
<header>
<a href="/movies">映画図鑑</a>
</header>
<main>
<%= yield %>
</main>
</body>
</html>
<%= yield %> の位置に、views/index.erb の内容が入ります。layout.erb は HTML 文書全体の共通構造を持ち、index.erb は映画一覧画面に固有の中身だけを持ちます。
views/index.erb には、html、head、body を書きません。これらは layout.erb の役割です。個別ビューに同じ外枠を書くと、画面ごとに HTML 文書が重複し、後から直す場所も増えてしまいます。
アプリを再起動して /movies を開き、Network パネルで Response を確認してください。レスポンス本文には、layout.erb の外枠と index.erb の見出しが組み合わさった HTML が返っています。
3.4 映画一覧の静的な HTML を書く
映画図鑑なので、一覧画面には映画を並べます。まずは Ruby のデータを使わず、静的な HTML として書きます。
views/index.erb を次のように変更します。
<h1>映画一覧</h1>
<p>登録されている映画を一覧で表示します。</p>
<table>
<thead>
<tr>
<th scope="col">タイトル</th>
<th scope="col">公開年</th>
<th scope="col">ジャンル</th>
</tr>
</thead>
<tbody>
<tr>
<td>月面喫茶</td>
<td>2042</td>
<td>SF</td>
</tr>
<tr>
<td>北風のリズム</td>
<td>2038</td>
<td>ドラマ</td>
</tr>
</tbody>
</table>
ここでは、一覧にタイトル、公開年、ジャンルだけを表示します。映画データには監督や紹介文もありますが、一覧画面にすべての情報を詰め込む必要はありません。
一覧画面は、複数の映画を見比べて目的の映画へ進むための入口です。監督や紹介文まで含む詳しい情報は、後の章で作る詳細画面に表示します。一覧と詳細で情報量を分けると、画面ごとの役割がはっきりします。
3.5 Ruby の配列とハッシュから表示する
静的な HTML だけでは、映画が増えるたびに tr を手で増やす必要があります。Web アプリケーションでは、サーバー側のデータから HTML を組み立てます。
この章ではまだファイル保存をしません。まずは app.rb の中に、映画の配列を用意します。
ここで、Ruby の配列とハッシュを短く振り返ります。配列は、複数の値を順番にまとめるためのものです。ハッシュは、キーと値の組み合わせで一つのデータを表します。これから書くコードでは、映画 1 件をハッシュで表し、そのハッシュを配列に入れて複数の映画をまとめます。
require "sinatra" の下、ルート定義より前に、次のコードを追加します。この章では、アプリ起動時に用意する仮の映画データとして扱います。
movies = [
{
"title" => "月面喫茶",
"director" => "山田アキラ",
"year" => "2042",
"genre" => "SF",
"description" => "月面にある小さな喫茶店を舞台にした物語。"
},
{
"title" => "北風のリズム",
"director" => "佐藤ミナ",
"year" => "2038",
"genre" => "ドラマ",
"description" => "雪の町で古い楽器を修理する人々を描く。"
},
{
"title" => "週末ロケット",
"director" => "鈴木トオル",
"year" => "2040",
"genre" => "コメディ",
"description" => "町工場の仲間たちが小さなロケット作りに挑む。"
}
]
外側の [ と ] が配列です。その中にある一組ずつの { と } が、映画 1 件を表すハッシュです。例えば "title" => "月面喫茶" は、"title" というキーと "月面喫茶" という値の組み合わせです。この映画の配列全体を、movies という変数に代入しています。
続いて、get "/movies" を次のように変更します。
get "/movies" do
@movies = movies
erb :index
end
@movies はインスタンス変数です。Sinatra のルート内で代入したインスタンス変数は、ERB テンプレートから参照できます。ここでは、app.rb で用意した映画の配列を views/index.erb で使えるようにしています。
views/index.erb の tbody を、次のように変更します。
<tbody>
<% @movies.each do |movie| %>
<tr>
<td><%= movie["title"] %></td>
<td><%= movie["year"] %></td>
<td><%= movie["genre"] %></td>
</tr>
<% end %>
</tbody>
<% @movies.each do |movie| %> は、映画の件数分だけ Ruby の繰り返しを実行します。この行自体は HTML に出力しないため、<% %> を使います。
<%= movie["title"] %> は、映画ハッシュの "title" に対応する値を HTML に出力します。値を画面に出すため、<%= %> を使います。
この章の映画データは、教材の中で用意した固定データです。そのため、ここでは値をそのまま出力しています。後の章で利用者が入力した値を表示するときは、ブラウザに HTML として解釈されないようエスケープする必要があります。安全な表示の方法は第5章で扱います。
アプリを再起動し、/movies を開いてください。3 件の映画が一覧に表示されます。movies の配列に 1 件追加して再起動すると、views/index.erb の tr を増やさなくても行が増えます。
ERB の書き間違いで 500 Internal Server Error が表示された場合は、ブラウザだけでなく、アプリを起動しているターミナルのログも見てください。詳しいデバッグ方法は第11章で扱います。
3.6 映画が持つ 5 つの情報
映画図鑑で扱う映画は、次の属性を持ちます。
| キー | 画面上の名前 | 役割 |
|---|---|---|
title | タイトル | 映画のタイトル。 |
director | 監督 | 監督名。 |
year | 公開年 | 公開された年。 |
genre | ジャンル | 映画のジャンル。 |
description | 紹介文 | 映画についての短い説明。 |
これらは、後の章で利用者がフォームから入力する項目でもあります。
この章の一覧画面では、title、year、genre だけを使います。director と description は、後の詳細画面で使います。ここでは、一覧に必要な情報だけを ERB へ渡して表示する流れに集中します。
3.7 public/ に CSS を置く
今の一覧は HTML としては表示できますが、画面の区切りが分かりにくい状態です。最低限の CSS を追加します。
Sinatra では、既定で public/ ディレクトリに置いた静的ファイルをブラウザへ返せます。ここでいう静的ファイルとは、Sinatra のルートでリクエストごとに組み立てるのではなく、そのまま配信するファイルです。CSS や画像などが該当します。
次の場所に CSS ファイルを作ります。
public/
└── stylesheets/
└── application.css
views/layout.erb の head に、CSS を読み込む link 要素を追加します。
<link rel="stylesheet" href="/stylesheets/application.css">
ブラウザはこの href を見て、CSS ファイルを取得するための新しいリクエストを送ります。ファイルは public/stylesheets/application.css にありますが、URL は /stylesheets/application.css です。public というディレクトリ名は URL に含めません。
Chrome で次の URL を開くと、CSS ファイルの内容を直接確認できます。
http://localhost:4567/stylesheets/application.css
これは、public/ がブラウザから直接参照できるファイルの置き場所であることを意味します。後の章で扱う保存用 JSON は、public/ には置きません。利用者が登録したデータを、ブラウザから直接読める場所へ置かないためです。保存データは第5章で data/movies.json に置きます。
3.8 最小限の CSS を追加する
public/stylesheets/application.css に、次の CSS を書きます。
body {
margin: 0;
color: #222222;
font-family: system-ui, sans-serif;
line-height: 1.7;
background: #f7f7f4;
}
.site-header {
border-bottom: 1px solid #dddddd;
background: #ffffff;
}
.site-title {
display: inline-block;
padding: 16px 24px;
color: #1f4f5f;
font-size: 20px;
font-weight: 700;
text-decoration: none;
}
.site-title:hover {
text-decoration: underline;
}
.site-main {
width: min(100% - 32px, 880px);
margin: 32px auto;
}
h1 {
margin: 0 0 16px;
font-size: 28px;
line-height: 1.3;
}
.table-scroll {
overflow-x: auto;
}
.movie-table {
width: 100%;
margin-top: 24px;
border-collapse: collapse;
background: #ffffff;
min-width: 520px;
}
.movie-table th,
.movie-table td {
padding: 12px 14px;
border: 1px solid #dddddd;
text-align: left;
vertical-align: top;
}
.movie-table th {
background: #edf3f4;
}
views/layout.erb の header と main に、CSS 用のクラスを付けます。
<header class="site-header">
<a class="site-title" href="/movies">映画図鑑</a>
</header>
<main class="site-main">
<%= yield %>
</main>
views/index.erb の table にもクラスを付け、外側を div で囲みます。
<div class="table-scroll">
<table class="movie-table">
...
</table>
</div>
この章の CSS は、デザインを深く学ぶためのものではありません。一覧、本文、ナビゲーションの区切りが分かり、表の項目を読みやすくするための最小限の指定です。
3.9 Network パネルで HTML と CSS を見る
アプリを再起動し、/movies を開きます。Network パネルを開いた状態で再読み込みすると、少なくとも次の二つのリクエストが見えます。
| リクエスト | 役割 |
|---|---|
GET /movies | Sinatra のルートが処理し、HTML を返す。 |
GET /stylesheets/application.css | public/ にある CSS ファイルを返す。 |
GET /movies を選び、Headers の Content-Type と Response を確認します。Content-Type には、たとえば text/html;charset=utf-8 のように text/html を含む値が表示されます。Response には、layout.erb と index.erb から作られた HTML が入っています。
次に GET /stylesheets/application.css を選びます。こちらの Content-Type には text/css を含む値が表示され、Response には CSS の内容が入っています。HTML の中に CSS ファイルの中身が埋め込まれているのではありません。ブラウザは、HTML の link 要素を見つけて、CSS ファイルを取得するための別の GET リクエストを送っています。
第2章では GET /movies が短い文字列を返していました。この章では、同じ GET /movies が HTML 文書を返すようになりました。URL とルートの対応は同じでも、ルートの処理を変えることでレスポンス本文の内容が変わります。
3.10 この章のコードを確認する
この章の最後に、ファイルの役割を整理します。
| ファイル | 役割 |
|---|---|
app.rb | ルートを定義し、映画データを @movies としてテンプレートへ渡す。 |
views/layout.erb | HTML 文書全体の共通構造を持つ。 |
views/index.erb | 映画一覧画面に固有の HTML を組み立てる。 |
public/stylesheets/application.css | ブラウザから直接取得される CSS を置く。 |
章終了時点の app.rb は次の状態です。
require "sinatra"
movies = [
{
"title" => "月面喫茶",
"director" => "山田アキラ",
"year" => "2042",
"genre" => "SF",
"description" => "月面にある小さな喫茶店を舞台にした物語。"
},
{
"title" => "北風のリズム",
"director" => "佐藤ミナ",
"year" => "2038",
"genre" => "ドラマ",
"description" => "雪の町で古い楽器を修理する人々を描く。"
},
{
"title" => "週末ロケット",
"director" => "鈴木トオル",
"year" => "2040",
"genre" => "コメディ",
"description" => "町工場の仲間たちが小さなロケット作りに挑む。"
}
]
get "/" do
redirect "/movies"
end
get "/movies" do
@movies = movies
erb :index
end
views/layout.erb は次の状態です。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>映画図鑑</title>
<link rel="stylesheet" href="/stylesheets/application.css">
</head>
<body>
<header class="site-header">
<a class="site-title" href="/movies">映画図鑑</a>
</header>
<main class="site-main">
<%= yield %>
</main>
</body>
</html>
views/index.erb は次の状態です。
<h1>映画一覧</h1>
<p>登録されている映画を一覧で表示します。</p>
<div class="table-scroll">
<table class="movie-table">
<thead>
<tr>
<th scope="col">タイトル</th>
<th scope="col">公開年</th>
<th scope="col">ジャンル</th>
</tr>
</thead>
<tbody>
<% @movies.each do |movie| %>
<tr>
<td><%= movie["title"] %></td>
<td><%= movie["year"] %></td>
<td><%= movie["genre"] %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
public/stylesheets/application.css は次の状態です。
body {
margin: 0;
color: #222222;
font-family: system-ui, sans-serif;
line-height: 1.7;
background: #f7f7f4;
}
.site-header {
border-bottom: 1px solid #dddddd;
background: #ffffff;
}
.site-title {
display: inline-block;
padding: 16px 24px;
color: #1f4f5f;
font-size: 20px;
font-weight: 700;
text-decoration: none;
}
.site-title:hover {
text-decoration: underline;
}
.site-main {
width: min(100% - 32px, 880px);
margin: 32px auto;
}
h1 {
margin: 0 0 16px;
font-size: 28px;
line-height: 1.3;
}
.table-scroll {
overflow-x: auto;
}
.movie-table {
width: 100%;
margin-top: 24px;
border-collapse: collapse;
background: #ffffff;
min-width: 520px;
}
.movie-table th,
.movie-table td {
padding: 12px 14px;
border: 1px solid #dddddd;
text-align: left;
vertical-align: top;
}
.movie-table th {
background: #edf3f4;
}
app.rb は、リクエストに合うルートを選び、表示に使うデータを用意します。views/index.erb は、そのデータを使って映画一覧の HTML を組み立てます。views/layout.erb は、全画面に共通する HTML 文書の外枠を担当します。
最後に、自分の言葉で確認してみてください。app.rb、views/layout.erb、views/index.erb は、それぞれ何を担当しているでしょうか。
次章では、表示するだけでなく、ブラウザから映画の情報を送るフォームを作ります。
さらに学ぶ
この章では、Sinatra の ERB テンプレート、レイアウト、静的ファイル配信を必要な範囲だけ使いました。より詳しい仕組みを知りたい場合は、次の観点で調べると理解を広げられます。
- Sinatra のテンプレート機能では、テンプレートの場所やレイアウトの指定を変更できます。
- ERB には、この章で扱った
<% %>と<%= %>以外の記法もあります。 - CSS は HTML とは別のリクエストで取得され、ブラウザが HTML と組み合わせて表示します。
参考資料
- ◎ Sinatra 公式 Views / Templates: https://sinatrarb.com/intro.html#Views%20/%20Templates
- ◎ Sinatra 公式 Static Files: https://sinatrarb.com/intro.html#Static%20Files
- ◎ Ruby 公式 ERB: https://docs.ruby-lang.org/en/4.0/ERB.html
- ○ MDN Content-Type: https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Content-Type
- ○ MDN CSS の第一歩: https://developer.mozilla.org/ja/docs/Learn/CSS/First_steps
第4章 フォームはリクエストを作る
第3章では、Ruby の配列とハッシュから映画一覧の HTML を作りました。今の映画図鑑は、サーバーが用意した映画を表示できます。しかし、ブラウザから新しい映画の情報を送る方法はまだありません。
この章では、映画を登録するためのフォームを作ります。ここでの目的は、保存ではありません。フォームの HTML と実際の通信を見比べながら、送信先や HTTP メソッドがどのように決まり、入力した値がどのキーで Sinatra の params に届くのかを確認します。
この章は、第3章から続けて同じ sinatra-movies ディレクトリで進めます。第4章から読み始める場合は、第3章の最後(3.10)に示したコードを用意した状態から始めてください。
4.1 登録画面 GET /movies/new
まず、映画一覧から登録画面へ移動できるようにします。views/index.erb の説明文の下に、次のリンクを追加します。
<p>
<a class="button-link" href="/movies/new">新しい映画を登録</a>
</p>
見た目はボタンのようにしますが、HTML としてはリンクです。リンクをクリックすると、ブラウザは GET /movies/new を送ります。まだ対応するルートがないため、このままでは登録画面を表示できません。
app.rb に、次のルートを追加します。
get "/movies/new" do
erb :new
end
views/new.erb を作り、まずは見出しだけを書きます。
<h1>映画登録</h1>
<p>登録したい映画の情報を入力します。</p>
アプリを再起動し、/movies を開いて「新しい映画を登録」をクリックします。/movies/new で登録画面が表示されます。Network パネルでは、リンクのクリックによって GET /movies/new が送られ、200 OK の HTML が返ることを確認してください。
ここでは、リンクによる画面移動なので GET です。これから作るフォーム送信では、入力した値をサーバーへ送るために POST を使います。
4.2 form の action と method
フォームは、ブラウザに HTTP リクエストを作らせるための HTML です。最小のフォームを見てみます。
<form action="/movies" method="post">
<label for="title">タイトル</label>
<input type="text" id="title" name="title">
<button type="submit">送信内容を確認</button>
</form>
form の二つの属性が、送信されるリクエストを決めます。
| 属性 | 役割 | このフォームでの意味 |
|---|---|---|
action | 送信先のパス | /movies に送る |
method | 送信に使う HTTP メソッド | POST で送る |
HTML では method="post" と小文字で書いています。HTTP メソッドとして説明するときは POST と大文字で書きます。
このフォームの送信ボタンを押すと、ブラウザは次のようなリクエストを作ります。
POST /movies HTTP/1.1
Host: localhost:4567
Content-Type: application/x-www-form-urlencoded
title=月面喫茶
実際のリクエストには、ほかにもヘッダーが含まれます。ここでは、フォームの action、method、入力欄の値がリクエストに反映されることに注目してください。
4.3 name が params のキーになる
フォーム部品には、id と name がよく出てきます。
<label for="title">タイトル</label>
<input type="text" id="title" name="title">
id は、HTML の中で要素を識別するための属性です。ここでは label の for="title" と input の id="title" が対応し、ラベルと入力欄を結び付けています。
name は、フォーム送信時のキーを決める属性です。name="title" の入力欄に「月面喫茶」と入力して送信すると、Sinatra では次のように取り出せます。
params["title"]
params は、Sinatra が用意するパラメーターの入れ物です。ハッシュのようにキーを指定して値を取り出せます。本書では、フォームから送られた値を params["title"] のように文字列キーで扱います。
ここで混同しやすいのは、label の表示文字列や id が params のキーになるわけではない、という点です。キーを決めるのは name です。
4.4 映画の登録フォーム
映画図鑑では、利用者が次の 5 項目を入力します。
| 画面上の項目 | フォーム部品 | name |
|---|---|---|
| タイトル | input type="text" | title |
| 監督 | input type="text" | director |
| 公開年 | input type="text" | year |
| ジャンル | select | genre |
| 紹介文 | textarea | description |
views/new.erb を次の内容に変更します。
<h1>映画登録</h1>
<p>登録したい映画の情報を入力します。</p>
<form class="movie-form" action="/movies" method="post">
<div class="form-field">
<label for="title">タイトル</label>
<input type="text" id="title" name="title">
</div>
<div class="form-field">
<label for="director">監督</label>
<input type="text" id="director" name="director">
</div>
<div class="form-field">
<label for="year">公開年</label>
<input type="text" id="year" name="year">
</div>
<div class="form-field">
<label for="genre">ジャンル</label>
<select id="genre" name="genre">
<option value="アクション">アクション</option>
<option value="コメディ">コメディ</option>
<option value="ドラマ">ドラマ</option>
<option value="ホラー">ホラー</option>
<option value="SF">SF</option>
<option value="アニメーション">アニメーション</option>
<option value="その他">その他</option>
</select>
</div>
<div class="form-field">
<label for="description">紹介文</label>
<textarea id="description" name="description" rows="5"></textarea>
</div>
<div class="form-actions">
<button type="submit">送信内容を確認</button>
<a href="/movies">一覧へ戻る</a>
</div>
</form>
select の選択肢は option で作ります。この章では、画面に表示する日本語と送信される値を同じにしています。たとえば「コメディ」を選ぶと、genre の値として "コメディ" が送られます。このフォームでは、最初の選択肢である「アクション」が初期状態で選ばれています。
textarea は複数行の入力欄です。input と違い、初期値を書く場合は value 属性ではなく、開始タグと終了タグの間に書きます。この章では空のままにしておきます。
まだ入力チェックはしません。空欄のまま送るとどう届くのかも、フォームの仕組みを理解する材料になります。タイトル必須の入力チェックは第5章で扱います。
4.5 POST /movies で送信値を確認する
今のまま送信ボタンを押すと、ブラウザは POST /movies を送ります。しかし、app.rb にはまだ POST /movies に対応するルートがありません。
app.rb に次のルートを追加します。
post "/movies" do
content_type :text
params.inspect
end
post "/movies" は、POST /movies に対応するルートです。get "/movies" と同じパスですが、HTTP メソッドが違うため別のルートとして扱われます。
params.inspect は、params の中身を確認しやすい文字列にします。この章では、送信された値が Sinatra に届いたことを確認するためだけに使います。まだ映画は保存されません。この post "/movies" は第4章だけの確認用コードで、第5章で保存処理とリダイレクトに置き換えます。
content_type :text は、レスポンスを HTML ではなくテキストとして返す指定です。第5章で安全な表示を扱う前に、利用者が入力した値を HTML として返すことを避けるため、この章の確認用レスポンスではテキストとして表示します。
アプリを再起動し、/movies/new でフォームを送信してみます。たとえば、次のように入力します。
| 項目 | 入力例 |
|---|---|
| タイトル | 星降る駅 |
| 監督 | 田中ユイ |
| 公開年 | 2041 |
| ジャンル | ドラマ |
| 紹介文 | 夜行列車の終着駅を舞台にした物語。 |
送信後、ブラウザには次のようなテキストが表示されます。
{"title" => "星降る駅", "director" => "田中ユイ", "year" => "2041", "genre" => "ドラマ", "description" => "夜行列車の終着駅を舞台にした物語。"}
これは完成画面ではありません。フォームから送られた値を確認するための一時的な表示です。次章で、この値を映画データとして保存する処理へ進めます。
params.inspect の表示順は重要ではありません。注目するのは、title、director、year、genre、description というキーと、入力した値が届いていることです。
タイトルを空欄で送ると、"title" => "" のように空文字として届きます。空文字を保存してよいかどうかは、サーバー側で確認する必要があります。タイトル必須の入力チェックは第5章で扱います。
確認用レスポンスからフォームへ戻るには、ブラウザの戻るボタンを使ってください。第5章では、送信後に別の画面へ移動する処理へ変えます。
4.6 Network パネルで Form Data を見る
フォーム送信は、画面だけでなく Network パネルでも確認します。
Chrome で /movies/new を開き、Network パネルを開いた状態でフォームを送信してください。リクエスト一覧から movies を選びます。確認する項目は次のとおりです。
| 見る場所 | 確認すること |
|---|---|
| Headers | Request Method が POST である |
| Headers | Request URL のパスが /movies である |
| Headers | Status Code が 200 OK である |
| Headers | Request Headers に Content-Type: application/x-www-form-urlencoded が含まれる |
| Headers | Response Headers に Content-Type: text/plain が含まれる |
| Payload | Form Data に title、director、year、genre、description がある |
| Response | params.inspect のテキストが返っている |
Network パネルの表示名は Chrome のバージョンによって少し変わることがあります。Form Data が見つからない場合は、選択した POST /movies の Payload を確認してください。
フォームの HTML と Network パネルを対応させると、次のようになります。
| HTML | Network パネル / Sinatra |
|---|---|
<form action="/movies"> | Request URL が /movies |
<form method="post"> | Request Method が POST |
name="title" | Form Data と params のキー title |
| 入力したタイトル | Form Data と params["title"] の値 |
ここで、id="title" やラベルの「タイトル」という文字が送信キーになるわけではないことをもう一度確認してください。送信キーは name="title" で決まります。
POST にしただけで、送信内容が秘密になるわけではありません。Network パネルを見れば、このように送信された値を確認できます。ここでは安全性ではなく、フォームがどのリクエストを作るのかを観察しています。
4.7 HTML フォームが直接送れるメソッドは GET と POST
HTTP リクエストとしてサーバーへ送る HTML フォームでは、method に get または post を指定するのが基本です。
GET は、情報の取得に使います。第4章の最初に作った GET /movies/new は、登録フォームを表示するためのリクエストです。
POST は、サーバーへデータを送るときに使います。この章の POST /movies は、映画フォームの値を送るリクエストです。まだ保存はしていませんが、送信の意味としては POST を使います。
後の章では、既存の映画を更新するために PATCH、削除するために DELETE を使います。ただし、HTML フォームは PATCH や DELETE を直接送れません。そのため、第7章で method override という仕組みを使います。
ここでは、次の区別を押さえてください。
| 操作 | この章で使う HTTP メソッド |
|---|---|
| 登録画面を表示する | GET |
| 登録フォームの値を送る | POST |
POST で送ったからといって、それだけでデータが保存されるわけではありません。保存するには、サーバー側で受け取った値をデータとして追加する処理が必要です。それを次章で作ります。
4.8 この章のコードを確認する
この章の最後に、追加したファイルとルートを確認します。
| 追加・変更したもの | 役割 |
|---|---|
GET /movies/new | 映画登録フォームを表示する |
views/new.erb | 映画登録フォームの HTML |
POST /movies | フォームから送られた値を一時的に確認する |
views/index.erb | 登録画面へのリンクを追加する |
public/stylesheets/application.css | フォームの見た目を整える |
章終了時点の app.rb は次の状態です。
require "sinatra"
movies = [
{
"title" => "月面喫茶",
"director" => "山田アキラ",
"year" => "2042",
"genre" => "SF",
"description" => "月面にある小さな喫茶店を舞台にした物語。"
},
{
"title" => "北風のリズム",
"director" => "佐藤ミナ",
"year" => "2038",
"genre" => "ドラマ",
"description" => "雪の町で古い楽器を修理する人々を描く。"
},
{
"title" => "週末ロケット",
"director" => "鈴木トオル",
"year" => "2040",
"genre" => "コメディ",
"description" => "町工場の仲間たちが小さなロケット作りに挑む。"
}
]
get "/" do
redirect "/movies"
end
get "/movies" do
@movies = movies
erb :index
end
get "/movies/new" do
erb :new
end
post "/movies" do
content_type :text
params.inspect
end
この post "/movies" は第4章だけの確認用レスポンスです。第5章では、params.inspect を返すのではなく、受け取った値を映画データとして保存し、別の画面へ移動する処理へ置き換えます。
views/index.erb は次の状態です。
<h1>映画一覧</h1>
<p>登録されている映画を一覧で表示します。</p>
<p>
<a class="button-link" href="/movies/new">新しい映画を登録</a>
</p>
<div class="table-scroll">
<table class="movie-table">
<thead>
<tr>
<th scope="col">タイトル</th>
<th scope="col">公開年</th>
<th scope="col">ジャンル</th>
</tr>
</thead>
<tbody>
<% @movies.each do |movie| %>
<tr>
<td><%= movie["title"] %></td>
<td><%= movie["year"] %></td>
<td><%= movie["genre"] %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
views/new.erb は次の状態です。
<h1>映画登録</h1>
<p>登録したい映画の情報を入力します。</p>
<form class="movie-form" action="/movies" method="post">
<div class="form-field">
<label for="title">タイトル</label>
<input type="text" id="title" name="title">
</div>
<div class="form-field">
<label for="director">監督</label>
<input type="text" id="director" name="director">
</div>
<div class="form-field">
<label for="year">公開年</label>
<input type="text" id="year" name="year">
</div>
<div class="form-field">
<label for="genre">ジャンル</label>
<select id="genre" name="genre">
<option value="アクション">アクション</option>
<option value="コメディ">コメディ</option>
<option value="ドラマ">ドラマ</option>
<option value="ホラー">ホラー</option>
<option value="SF">SF</option>
<option value="アニメーション">アニメーション</option>
<option value="その他">その他</option>
</select>
</div>
<div class="form-field">
<label for="description">紹介文</label>
<textarea id="description" name="description" rows="5"></textarea>
</div>
<div class="form-actions">
<button type="submit">送信内容を確認</button>
<a href="/movies">一覧へ戻る</a>
</div>
</form>
第4章では、public/stylesheets/application.css に次の CSS を追加しました。
a {
color: #1f4f5f;
}
.button-link,
button {
display: inline-block;
border: 1px solid #1f4f5f;
border-radius: 4px;
padding: 8px 14px;
color: #ffffff;
font: inherit;
text-decoration: none;
background: #1f4f5f;
cursor: pointer;
}
.button-link:hover,
button:hover {
background: #173c48;
}
.movie-form {
max-width: 640px;
margin-top: 24px;
}
.form-field {
margin-bottom: 18px;
}
.form-field label {
display: block;
margin-bottom: 6px;
font-weight: 700;
}
.form-field input,
.form-field select,
.form-field textarea {
box-sizing: border-box;
width: 100%;
border: 1px solid #c9c9c9;
border-radius: 4px;
padding: 8px 10px;
font: inherit;
background: #ffffff;
}
.form-field textarea {
resize: vertical;
}
.form-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-top: 24px;
}
最後に、次の問いを自分の言葉で確認してください。
- フォームの送信先は、どの HTML 属性で決まりますか。
params["genre"]のキーgenreは、どの HTML 属性から来ていますか。POST /moviesのレスポンスに値が表示されても、まだ映画が保存されていないのはなぜですか。
次章では、params.inspect で確認した値を使い、JSON ファイルへ映画を保存します。
さらに学ぶ
この章では、フォームから POST リクエストを送り、Sinatra の params で受け取るところまでを扱いました。さらに詳しく学ぶ場合は、次の観点を調べてください。
- フォームデータは既定で
application/x-www-form-urlencodedという形式で送信されます。 - ファイルアップロードでは
multipart/form-dataを使いますが、本書では扱いません。 - HTML フォームの
methodにはdialogもありますが、HTTP リクエスト送信としてのGET/POSTとは目的が異なります。
参考資料
- ◎ MDN
<form>: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/form - ◎ MDN Sending form data: https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Forms/Sending_and_retrieving_form_data
- ◎ MDN Forms and buttons in HTML: https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Structuring_content/HTML_forms
- ◎ MDN POST request method: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods/POST
- ○ Sinatra 公式: https://sinatrarb.com/intro.html
第3部 CRUD とファイル保存
第3部では、映画図鑑にデータの作成、読み取り、更新、削除を加えます。この四つの基本操作を、それぞれの英語の頭文字から CRUD と呼びます。
第5章では、フォームから送られた映画を JSON ファイルへ保存します。リクエストをまたいでデータを残すために、Ruby の配列とハッシュを JSON として読み書きします。
第6章では、一覧と詳細を分け、映画 1 件を ID で探します。
第7章では、編集と削除を追加します。HTML フォームから送った POST を、Sinatra 側で PATCH や DELETE として扱う方法も確認します。
第5章 JSON ファイルに映画を保存する
第4章では、映画登録フォームから送られた値を params.inspect で確認しました。フォームから値が届くことは分かりましたが、まだ映画は登録されていません。画面を再読み込みしても、アプリを再起動しても、新しい映画は残りません。
この章では、フォームから届いた値を Ruby のハッシュにまとめ、それぞれの映画を区別する ID を加えて data/movies.json へ保存します。これにより、登録した映画が次のリクエストやアプリの再起動後にも残るようになります。
保存しなければ次のリクエストで消える
第4章の POST /movies は、次のような確認用コードでした。
post "/movies" do
content_type :text
params.inspect
end
これは、送信された値をレスポンスとして返しているだけです。Ruby の変数に入れた値も、レスポンスとして返した文字列も、そのままでは次のリクエストへ引き継がれません。
Web アプリケーションでデータを残すには、リクエストの処理が終わった後も残る場所へ保存する必要があります。本書ではデータベースへ進む前の段階として、JSON ファイルへ保存します。
data/movies.json を作る
保存用の JSON ファイルは、public/ ではなく data/ に置きます。
data/
movies.json
public/ は、CSS や画像のようにブラウザから直接取得できる静的ファイルを置く場所です。利用者が登録したデータをブラウザから直接読める場所へ置く必要はありません。アプリが読み書きする保存データは、data/ に分けて置きます。
第3章で app.rb に書いていた映画データを、data/movies.json へ移します。このとき、それぞれの映画を区別するための id を初めて加えます。同じタイトルの映画が登録されることもあるため、タイトルとは別に、一件ずつ異なる値を持たせます。
ここで使う長い文字列は、UUID(Universally Unique Identifier)と呼ばれる形式の ID です。映画の内容を表す文字列ではなく、十分に重なりにくい値を作れるようにした形式です。この小さなアプリなら 1、2、3 のような連番も使えますが、新しい映画を加えるたびに次の番号を決める必要があります。本書では、既存の ID から次の番号を調べずに作れる UUID を使います。次の JSON にある 3 つの ID は、教材用にあらかじめ用意した固定値です。
[
{
"id": "b6f5e1c4-4b5f-4a7f-8f8f-3d9d3ef9d001",
"title": "月面喫茶",
"director": "山田アキラ",
"year": "2042",
"genre": "SF",
"description": "月面にある小さな喫茶店を舞台にした物語。"
},
{
"id": "b6f5e1c4-4b5f-4a7f-8f8f-3d9d3ef9d002",
"title": "北風のリズム",
"director": "佐藤ミナ",
"year": "2038",
"genre": "ドラマ",
"description": "雪の町で古い楽器を修理する人々を描く。"
},
{
"id": "b6f5e1c4-4b5f-4a7f-8f8f-3d9d3ef9d003",
"title": "週末ロケット",
"director": "鈴木トオル",
"year": "2040",
"genre": "コメディ",
"description": "町工場の仲間たちが小さなロケット作りに挑む。"
}
]
Ruby のハッシュではキーに => を使っていました。JSON ではキーと値の間に : を使います。JSON ファイルの中身は Ruby の配列そのものではなく、Ruby から読み込んで配列やハッシュとして扱えるデータです。
JSON を読み込む
app.rb の先頭で、JSON を扱う標準ライブラリを読み込みます。
require "json"
require "sinatra"
続いて、保存ファイルの場所を定数にします。
MOVIES_FILE = File.join(__dir__, "data", "movies.json")
__dir__ は、この app.rb が置かれているディレクトリです。どのディレクトリからアプリを起動しても、app.rb から見た data/movies.json を指せるようにしています。File.join を使うと、文字列を手でつなぐよりもファイルパスの意図が明確になります。
映画データを読み込むメソッドを追加します。
def load_movies
JSON.parse(File.read(MOVIES_FILE))
end
File.read(MOVIES_FILE) は JSON ファイルの中身を文字列として読み込みます。JSON.parse は、その文字列を Ruby の配列とハッシュへ変換します。
この章では、リポジトリに data/movies.json が存在する前提で進めます。JSON の書き方を壊してしまった場合の切り分けは、第11章と付録のよくあるエラーで扱います。
一覧画面では、これまでの movies 変数ではなく、ファイルから読み込んだ結果を使います。
get "/movies" do
@movies = load_movies
erb :index
end
ここまで変更して bundle exec ruby app.rb を起動し、/movies にアクセスしてください。見た目は第4章と同じですが、映画データの置き場所は app.rb から data/movies.json へ変わっています。
UUID で ID を作る
新しい映画を保存するときも、アプリ側で UUID を作ります。利用者がフォームへ入力する値ではありません。
この章では Ruby 標準ライブラリの SecureRandom.uuid を使います。
require "securerandom"
例えば、次のような文字列が作られます。
SecureRandom.uuid
#=> "c55c1d37-f3cf-469e-a746-a3044279c716"
UUID の詳しい仕組みはこの章では扱いません。ここでは、SecureRandom.uuid を呼び出すたびに、新しい映画を区別するための ID を作れることを押さえます。
フォームの値を映画データにする
第4章のフォームでは、title、director、year、genre、description という名前で値を送りました。この値を映画データのハッシュにまとめるメソッドを作ります。
def movie_params
{
"title" => params["title"].to_s,
"director" => params["director"].to_s,
"year" => params["year"].to_s,
"genre" => params["genre"].to_s,
"description" => params["description"].to_s
}
end
params["title"] のように、フォーム部品の name と同じキーで値を取り出します。ここでは to_s を付けて、値がない場合でも文字列として扱えるようにしています。
JSON ファイルへ書き戻す
読み込んだ映画配列に新しい映画を追加したら、JSON ファイルへ書き戻します。
def save_movies(movies)
File.write(MOVIES_FILE, "#{JSON.pretty_generate(movies)}\n")
end
JSON.pretty_generate は、Ruby の配列やハッシュを読みやすい JSON 文字列へ変換します。File.write は、その文字列をファイルへ書き込みます。
JSON.parse は JSON 文字列を Ruby の配列やハッシュへ変換します。JSON.pretty_generate は Ruby の配列やハッシュを JSON 文字列へ変換します。読み込みと書き込みで向きが逆になります。
JSON.pretty_generate を使うと、保存された JSON を人間が読みやすくなります。登録後に data/movies.json を開いて確認する教材では、1 行に詰め込まれた JSON よりも扱いやすくなります。
この章の保存方法は、毎回ファイル全体を読み込み、配列を変更し、ファイル全体を書き戻す方法です。小さなローカル教材アプリとしては理解しやすい方法ですが、データが増えたり複数人が同時に使ったりする場合には限界があります。この限界は、第12章で振り返ります。
タイトルを必須にする
映画図鑑では、タイトルが空の映画を登録できないようにします。HTML の required 属性を使うこともできますが、ブラウザ側の機能だけに頼ってはいけません。リクエストはブラウザ以外からも送れます。サーバー側でも確認します。
POST /movies を次のように変更します。
post "/movies" do
@movie = movie_params
@errors = []
if @movie["title"].strip.empty?
@errors << "タイトルを入力してください"
return erb :new
end
movies = load_movies
movie = { "id" => SecureRandom.uuid }.merge(@movie)
movies << movie
save_movies(movies)
redirect "/movies"
end
strip.empty? は、空文字だけでなく、空白だけの入力も空として扱うために使っています。
タイトルが空のときは、保存しません。リダイレクトもしません。@errors にメッセージを入れ、登録フォームをもう一度表示します。
タイトルが入っているときは、merge で UUID の入ったハッシュとフォームの値を一つにまとめ、movie へ代入します。movies << movie の << は、右側の movie を左側の配列 movies の末尾へ追加する演算子です。その配列を JSON ファイルへ保存し、最後に redirect "/movies" で一覧画面へ移動します。
同じコードにある @errors << "タイトルを入力してください" も、@errors 配列の末尾へエラーメッセージを追加しています。
ここではまだ、このリダイレクトを PRG という名前では説明しません。まずは「保存後に別の URL へ移動するレスポンス」として使います。なぜ状態を変えるリクエストの後にこの形にするのか、リダイレクトが再送信をどう防ぐのかは、第8章で扱います。
入力済みの値をフォームに戻す
タイトルが空だったとき、フォームを空に戻してしまうと、読者は入力した監督名や紹介文をもう一度入力しなければなりません。エラー時には、入力済みの値をフォームに戻します。
フォームへ入力値を戻す前に、利用者入力を安全に表示するための h ヘルパーを追加します。
require "rack/utils"
helpers do
def h(value)
Rack::Utils.escape_html(value)
end
end
Sinatra の ERB では、<%= %> に書いた値が自動で HTML エスケープされるとは考えません。h は、HTML として特別な意味を持つ文字を、文字として表示できる形へ変換します。例えば < は < のような文字参照になります。
GET /movies/new では、空の映画データとエラー配列を用意します。
get "/movies/new" do
@movie = {}
@errors = []
erb :new
end
views/new.erb のフォームでは、@movie の値を value 属性や textarea の中身へ入れます。
<input type="text" id="title" name="title" value="<%= h(@movie["title"]) %>">
textarea は value 属性ではなく、開始タグと終了タグの間に値を書きます。
<textarea id="description" name="description" rows="5"><%= h(@movie["description"]) %></textarea>
select は、選ばれていた項目に selected を付けます。
<option value="ドラマ" <%= "selected" if @movie["genre"] == "ドラマ" %>>ドラマ</option>
エラーメッセージもフォームの上に表示します。
<% unless @errors.empty? %>
<div class="error-messages" role="alert">
<p>入力内容を確認してください。</p>
<ul>
<% @errors.each do |error| %>
<li><%= h(error) %></li>
<% end %>
</ul>
</div>
<% end %>
保存した値を一覧に表示する
一覧画面でも h を使います。
<td><%= h(movie["title"]) %></td>
<td><%= h(movie["year"]) %></td>
<td><%= h(movie["genre"]) %></td>
XSS の危険を実際に見るのは第9章です。この章では、保存した利用者入力を表示する時点から、安全な表示の形を使っておきます。
登録後の動きを Network タブで見る
サーバーを起動し、/movies/new から新しい映画を登録してください。
登録に成功すると、ブラウザは一覧画面へ移動します。Network タブでは、次の流れを確認できます。
POST /movies
GET /movies
POST /movies のレスポンスは、HTML そのものではなく、別の URL へ移動する指示です。この環境では 303 See Other として確認できます。その指示を受けて、ブラウザが GET /movies を送ります。
次に、タイトルを空にして送信してください。この場合は保存されず、登録フォームが表示されます。Network タブでは、リダイレクト後の GET /movies は発生しません。POST /movies のレスポンスとして、エラーメッセージ付きのフォームが返ります。
data/movies.json も確認してください。登録に成功した映画だけが、UUID 付きで追加されています。タイトル空欄の送信では、JSON ファイルは変わりません。
この章の完成コード
この章の最後の app.rb は次の形です。
require "json"
require "rack/utils"
require "securerandom"
require "sinatra"
MOVIES_FILE = File.join(__dir__, "data", "movies.json")
helpers do
def h(value)
Rack::Utils.escape_html(value)
end
end
def load_movies
JSON.parse(File.read(MOVIES_FILE))
end
def save_movies(movies)
File.write(MOVIES_FILE, "#{JSON.pretty_generate(movies)}\n")
end
def movie_params
{
"title" => params["title"].to_s,
"director" => params["director"].to_s,
"year" => params["year"].to_s,
"genre" => params["genre"].to_s,
"description" => params["description"].to_s
}
end
get "/" do
redirect "/movies"
end
get "/movies" do
@movies = load_movies
erb :index
end
get "/movies/new" do
@movie = {}
@errors = []
erb :new
end
post "/movies" do
@movie = movie_params
@errors = []
if @movie["title"].strip.empty?
@errors << "タイトルを入力してください"
return erb :new
end
movies = load_movies
movie = { "id" => SecureRandom.uuid }.merge(@movie)
movies << movie
save_movies(movies)
redirect "/movies"
end
views/index.erb では、映画の値を h で表示します。
<h1>映画一覧</h1>
<p>登録されている映画を一覧で表示します。</p>
<p>
<a class="button-link" href="/movies/new">新しい映画を登録</a>
</p>
<div class="table-scroll">
<table class="movie-table">
<thead>
<tr>
<th scope="col">タイトル</th>
<th scope="col">公開年</th>
<th scope="col">ジャンル</th>
</tr>
</thead>
<tbody>
<% @movies.each do |movie| %>
<tr>
<td><%= h(movie["title"]) %></td>
<td><%= h(movie["year"]) %></td>
<td><%= h(movie["genre"]) %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
views/new.erb では、エラーメッセージと入力済みの値を表示します。
<h1>映画登録</h1>
<p>登録したい映画の情報を入力します。</p>
<% unless @errors.empty? %>
<div class="error-messages" role="alert">
<p>入力内容を確認してください。</p>
<ul>
<% @errors.each do |error| %>
<li><%= h(error) %></li>
<% end %>
</ul>
</div>
<% end %>
<form class="movie-form" action="/movies" method="post">
<div class="form-field">
<label for="title">タイトル</label>
<input type="text" id="title" name="title" value="<%= h(@movie["title"]) %>">
</div>
<div class="form-field">
<label for="director">監督</label>
<input type="text" id="director" name="director" value="<%= h(@movie["director"]) %>">
</div>
<div class="form-field">
<label for="year">公開年</label>
<input type="text" id="year" name="year" value="<%= h(@movie["year"]) %>">
</div>
<div class="form-field">
<label for="genre">ジャンル</label>
<select id="genre" name="genre">
<option value="アクション" <%= "selected" if @movie["genre"] == "アクション" %>>アクション</option>
<option value="コメディ" <%= "selected" if @movie["genre"] == "コメディ" %>>コメディ</option>
<option value="ドラマ" <%= "selected" if @movie["genre"] == "ドラマ" %>>ドラマ</option>
<option value="ホラー" <%= "selected" if @movie["genre"] == "ホラー" %>>ホラー</option>
<option value="SF" <%= "selected" if @movie["genre"] == "SF" %>>SF</option>
<option value="アニメーション" <%= "selected" if @movie["genre"] == "アニメーション" %>>アニメーション</option>
<option value="その他" <%= "selected" if @movie["genre"] == "その他" %>>その他</option>
</select>
</div>
<div class="form-field">
<label for="description">紹介文</label>
<textarea id="description" name="description" rows="5"><%= h(@movie["description"]) %></textarea>
</div>
<div class="form-actions">
<button type="submit">登録する</button>
<a href="/movies">一覧へ戻る</a>
</div>
</form>
エラーメッセージのために、CSS も追加します。
.error-messages {
max-width: 640px;
border: 1px solid #b3261e;
border-radius: 4px;
padding: 12px 16px;
color: #5f1a16;
background: #fff0ee;
}
.error-messages p {
margin: 0 0 8px;
font-weight: 700;
}
.error-messages ul {
margin: 0;
padding-left: 24px;
}
確認しよう
/movies/newからタイトルを入れて映画を登録する。- Network タブで
POST /moviesの後にGET /moviesが発生していることを確認する。 - 送信前後で
data/movies.jsonを開き、UUID 付きの映画が追加されたことを確認する。 - タイトルを空にして送信し、エラーメッセージが表示され、入力済みの監督名や紹介文が残ることを確認する。
- タイトル空欄の送信では、
data/movies.jsonが変わらないことを確認する。
考えてみよう
- なぜタイトルや配列の位置ではなく、UUID を ID にするのでしょうか。
- なぜ保存用 JSON を
public/に置かないのでしょうか。 - なぜ入力チェックを HTML の
required属性だけに任せないのでしょうか。 - 保存後に直接 HTML を返すのではなく、なぜ別の URL へ移動させているのでしょうか。
さらに学ぶ
- ◎ Ruby JSON: https://docs.ruby-lang.org/ja/latest/library/json.html
- ◎ Ruby SecureRandom: https://docs.ruby-lang.org/ja/latest/library/securerandom.html
- ◎ Rack Utils: https://rack.github.io/rack/main/Rack/Utils.html
- ◎ Sinatra: https://sinatrarb.com/intro.html
- ◎ MDN HTTP リダイレクト: https://developer.mozilla.org/ja/docs/Web/HTTP/Redirections
第6章 一覧と詳細でリソースを分ける
第5章では、映画を data/movies.json に保存できるようにしました。登録に成功すると、一覧画面へ戻るところまで作りました。
この章では、映画の一覧と詳細を分けます。Web では、URL で指し示して扱う対象をリソースと呼びます。/movies を映画の集合、/movies/:id を 1 件の映画として扱い、ID で映画を探して詳細画面を表示します。
/movies は映画の集合を表す
現在の /movies は、登録されている映画の一覧を表示します。
get "/movies" do
@movies = load_movies
erb :index
end
@movies には、複数の映画が入ります。つまり /movies は、映画 1 件ではなく、映画の集合を表す URL です。
一覧画面では、タイトル、公開年、ジャンルだけを表示します。監督や紹介文まで一覧に出すと、情報量が増えすぎます。すべてを一覧に詰め込むのではなく、1 件の映画を詳しく見るための画面を作ります。
/movies/:id は 1 件の映画を表す
1 件の映画を表す URL は、次の形にします。
/movies/:id
:id は、実際の URL では映画の ID に置き換わります。
/movies/b6f5e1c4-4b5f-4a7f-8f8f-3d9d3ef9d001
Sinatra では、ルートに :id のように書くと、その部分を params["id"] として取り出せます。
get "/movies/:id" do
params["id"]
end
このルートは、/movies/new より前に書くと意図しない動きになることがあります。/movies/new も :id の形に見えるからです。本書のコードでは、get "/movies/new" より後に get "/movies/:id" を置きます。具体的なルートを先に書き、変化する部分を含むルートを後に書く、と覚えておくと追いやすくなります。
ID で映画を探す
詳細画面では、JSON から読み込んだ映画配列の中から、ID が一致する 1 件を探します。
app.rb に find_movie メソッドを追加します。
def find_movie(id)
load_movies.find { |movie| movie["id"] == id }
end
find は、条件に合う最初の要素を返します。ここでは、映画の "id" と URL から届いた id が一致する映画を探しています。
一致する映画がなければ、find は nil を返します。
この章では、分かりやすさを優先して find_movie の中で毎回 JSON ファイルを読み込んでいます。小さなローカル教材アプリでは問題ありませんが、データが増えた場合の効率やファイル保存の限界は第12章で扱います。
詳細画面を表示する
URLで指定された ID と一致する映画があるとは限りません。映画が見つからない場合は、Sinatra の halt を使ってその場で処理を止め、404 ステータスコードとメッセージをレスポンスとして返します。
この処理を含む GET /movies/:id を追加します。
get "/movies/:id" do
@movie = find_movie(params["id"])
halt 404, "映画が見つかりません" if @movie.nil?
erb :show
end
@movie に 1 件の映画を入れ、views/show.erb を表示します。
halt 404, "映画が見つかりません" の行は、@movie が nil の場合だけ実行されます。ここでは専用の 404 ページはまだ作りません。存在しない ID に対して 404 のレスポンスを返すところまでを扱います。404 ページは第10章で作ります。
views/show.erb を作ります。
<h1><%= h(@movie["title"]) %></h1>
<dl class="movie-detail">
<div>
<dt>監督</dt>
<dd><%= h(@movie["director"]) %></dd>
</div>
<div>
<dt>公開年</dt>
<dd><%= h(@movie["year"]) %></dd>
</div>
<div>
<dt>ジャンル</dt>
<dd><%= h(@movie["genre"]) %></dd>
</div>
<div>
<dt>紹介文</dt>
<dd class="movie-description"><%= h(@movie["description"]) %></dd>
</div>
</dl>
<p>
<a href="/movies">一覧へ戻る</a>
</p>
詳細画面では、タイトル、監督、公開年、ジャンル、紹介文を表示します。一覧に出していなかった監督と紹介文も、ここで確認できるようにします。
第5章で導入した h ヘルパーを、詳細画面でも使います。映画の各項目は、フォームから入力された値です。タイトルだけでなく、監督、公開年、ジャンル、紹介文も HTML エスケープして表示します。
紹介文の改行は CSS で扱う
紹介文に改行が含まれている場合、HTML ではそのまま改行として表示されるとは限りません。
Ruby 側で次のように <br> を追加する方法は、この教材では使いません。
description.gsub("\n", "<br>")
利用者入力から HTML を組み立てると、エスケープとの関係が複雑になります。表示のための改行は、CSS で扱います。
.movie-description {
white-space: pre-line;
}
@media (max-width: 600px) {
.movie-detail div {
grid-template-columns: 1fr;
}
}
white-space: pre-line を使うと、テキスト中の改行を表示に反映できます。紹介文の文字列そのものは、h ヘルパーで安全に表示します。
詳細画面の見た目のために、次の CSS も追加します。
.movie-detail {
max-width: 720px;
margin: 24px 0;
background: #ffffff;
}
.movie-detail div {
display: grid;
grid-template-columns: 120px 1fr;
border: 1px solid #dddddd;
border-bottom: 0;
}
.movie-detail div:last-child {
border-bottom: 1px solid #dddddd;
}
.movie-detail dt,
.movie-detail dd {
margin: 0;
padding: 12px 14px;
}
.movie-detail dt {
font-weight: 700;
background: #edf3f4;
}
一覧から詳細へ移動する
一覧画面から詳細画面へ移動できるようにします。views/index.erb の表に「操作」列を追加します。
<th scope="col">操作</th>
各行に詳細リンクを追加します。
<td><a href="/movies/<%= h(movie["id"]) %>">詳細</a></td>
href に映画の ID を埋め込むことで、1 件の映画を表す URL へ移動できます。ID は利用者が直接入力した値ではありませんが、HTML に出力する値として h を通します。
登録後は詳細画面へ移動する
第5章では、登録成功後に一覧画面へ戻していました。
redirect "/movies"
詳細画面ができたので、登録した映画をすぐ確認できるように、登録後は詳細画面へ移動します。
POST /movies の後半を次のように変更します。
movies = load_movies
movie = { "id" => SecureRandom.uuid }.merge(@movie)
movies << movie
save_movies(movies)
redirect "/movies/#{movie["id"]}"
新しく作った映画の ID を使って、/movies/:id へリダイレクトしています。
Network タブでは、次の流れを確認できます。
POST /movies
303 See Other
GET /movies/:id
第5章では GET /movies へ移動していました。この章からは、登録した 1 件を確認するために GET /movies/:id へ移動します。
存在しない ID を確認する
ブラウザで、存在しない ID を含む URL にアクセスしてみます。
http://localhost:4567/movies/not-found
映画が見つからないため、レスポンスのステータスコードは 404 になります。Network タブで、GET /movies/not-found のステータスコードを確認してください。
ここで大事なのは、画面に表示される文字列だけではありません。HTTP レスポンスとして 404 が返っていることです。
この章の完成コード
この章の最後の app.rb は次の形です。
require "json"
require "rack/utils"
require "securerandom"
require "sinatra"
MOVIES_FILE = File.join(__dir__, "data", "movies.json")
helpers do
def h(value)
Rack::Utils.escape_html(value)
end
end
def load_movies
JSON.parse(File.read(MOVIES_FILE))
end
def save_movies(movies)
File.write(MOVIES_FILE, "#{JSON.pretty_generate(movies)}\n")
end
def find_movie(id)
load_movies.find { |movie| movie["id"] == id }
end
def movie_params
{
"title" => params["title"].to_s,
"director" => params["director"].to_s,
"year" => params["year"].to_s,
"genre" => params["genre"].to_s,
"description" => params["description"].to_s
}
end
get "/" do
redirect "/movies"
end
get "/movies" do
@movies = load_movies
erb :index
end
get "/movies/new" do
@movie = {}
@errors = []
erb :new
end
get "/movies/:id" do
@movie = find_movie(params["id"])
halt 404, "映画が見つかりません" if @movie.nil?
erb :show
end
post "/movies" do
@movie = movie_params
@errors = []
if @movie["title"].strip.empty?
@errors << "タイトルを入力してください"
return erb :new
end
movies = load_movies
movie = { "id" => SecureRandom.uuid }.merge(@movie)
movies << movie
save_movies(movies)
redirect "/movies/#{movie["id"]}"
end
views/index.erb は次の形です。
<h1>映画一覧</h1>
<p>登録されている映画を一覧で表示します。</p>
<p>
<a class="button-link" href="/movies/new">新しい映画を登録</a>
</p>
<div class="table-scroll">
<table class="movie-table">
<thead>
<tr>
<th scope="col">タイトル</th>
<th scope="col">公開年</th>
<th scope="col">ジャンル</th>
<th scope="col">操作</th>
</tr>
</thead>
<tbody>
<% @movies.each do |movie| %>
<tr>
<td><%= h(movie["title"]) %></td>
<td><%= h(movie["year"]) %></td>
<td><%= h(movie["genre"]) %></td>
<td><a href="/movies/<%= h(movie["id"]) %>">詳細</a></td>
</tr>
<% end %>
</tbody>
</table>
</div>
views/show.erb は次の形です。
<h1><%= h(@movie["title"]) %></h1>
<dl class="movie-detail">
<div>
<dt>監督</dt>
<dd><%= h(@movie["director"]) %></dd>
</div>
<div>
<dt>公開年</dt>
<dd><%= h(@movie["year"]) %></dd>
</div>
<div>
<dt>ジャンル</dt>
<dd><%= h(@movie["genre"]) %></dd>
</div>
<div>
<dt>紹介文</dt>
<dd class="movie-description"><%= h(@movie["description"]) %></dd>
</div>
</dl>
<p>
<a href="/movies">一覧へ戻る</a>
</p>
確認しよう
/moviesの一覧から詳細リンクをクリックし、/movies/:idに移動することを確認する。- 詳細画面に、タイトル、監督、公開年、ジャンル、紹介文が表示されることを確認する。
/movies/not-foundにアクセスし、Network タブで 404 を確認する。/movies/newから映画を登録し、登録後に作成した映画の詳細画面へ移動することを確認する。
考えてみよう
- なぜ一覧画面にすべての属性を表示しないのでしょうか。
/moviesと/movies/:idは、どちらも映画に関係する URL ですが、何が違うのでしょうか。- 存在しない ID に対して、なぜ通常の一覧画面ではなく 404 を返すのでしょうか。
次章では、この詳細画面から編集と削除へ進みます。1 件の映画を ID で扱えるようになったことで、既存の映画を変更したり削除したりする準備が整いました。
さらに学ぶ
- ◎ MDN GET: https://developer.mozilla.org/ja/docs/Web/HTTP/Methods/GET
- ◎ Sinatra: https://sinatrarb.com/intro.html
第7章 編集と削除で CRUD を完成させる
第6章では、映画の一覧と詳細を分け、ID で 1 件の映画を表示できるようにしました。
この章では、既存の映画を編集・削除できるようにします。その過程で、HTML フォームが直接送れる HTTP メソッドの制約と、POST を PATCH や DELETE として扱う仕組みを確認します。
CRUD はデータへの基本操作
CRUD は、データに対する基本的な操作を表す言葉です。
| 操作 | 意味 | 映画図鑑での例 |
|---|---|---|
| Create | 作成 | 映画を登録する |
| Read | 読み取り | 一覧や詳細を表示する |
| Update | 更新 | 映画情報を編集する |
| Delete | 削除 | 映画を削除する |
第6章までで、作成と読み取りは実装しました。この章では、更新と削除を追加します。
method override を有効にする
HTML フォームが直接送信できる HTTP メソッドは、GET と POST です。PATCH や DELETE をフォームの method 属性に直接指定することはできません。
そこで、フォーム自体は POST で送り、hidden input の _method に本来扱いたいメソッドを書きます。
<input type="hidden" name="_method" value="patch">
Sinatra 側では、Rack の method override を有効にします。
enable :method_override
これにより、POST で送られてきたリクエストに _method=patch が含まれていると、Sinatra のルートでは patch "/movies/:id" として扱えるようになります。
Network タブでは、ブラウザが送ったリクエストは POST として見えます。Form Data に _method=patch や _method=delete が含まれていることを確認します。Rack を通過した後に PATCH や DELETE として処理されたことは、Sinatra のログで確認します。
編集画面を表示する
編集画面は、既存の映画を変更するためのフォームです。表示するだけなので HTTP メソッドは GET です。
get "/movies/:id/edit" do
@movie = find_movie(params["id"])
halt 404, "映画が見つかりません" if @movie.nil?
@errors = []
erb :edit
end
GET /movies/:id/edit は編集フォームを表示するためのルートです。ここではまだデータを更新しません。
このルートも、GET /movies/:id より前に書きます。/movies/:id/edit は、1 件の映画を表す URL に /edit が付いた形です。具体的なルートを先に書くことで、Sinatra が意図したルートへ到達しやすくなります。
編集フォームを作る
views/edit.erb を作ります。登録フォームとよく似ていますが、送信先と _method が違います。
<form class="movie-form" action="/movies/<%= h(@movie["id"]) %>" method="post">
<input type="hidden" name="_method" value="patch">
フォームの method は post です。_method に patch を入れることで、Sinatra 側では PATCH /movies/:id として扱います。
編集フォームでは、登録済みの値を最初から入れておきます。
<input type="text" id="title" name="title" value="<%= h(@movie["title"]) %>">
textarea も登録済みの紹介文を入れます。
<textarea id="description" name="description" rows="5"><%= h(@movie["description"]) %></textarea>
登録フォームと編集フォームには共通する部分が多くあります。実務では部分テンプレートにまとめることもありますが、この章では送信先や _method の違いを読みやすくするため、別々の ERB として書きます。
更新処理を作る
既存の映画を更新するには、JSON から読み込んだ配列の中から ID が一致する映画を探し、そのハッシュを書き換えて保存します。
まず、読み込んだ配列の中から映画を探すメソッドを追加します。
def find_movie_from(movies, id)
movies.find { |movie| movie["id"] == id }
end
第6章の find_movie は、メソッドの中で JSON ファイルを読み込んでいました。更新では、読み込んだ配列を書き換えて保存する必要があるため、すでに読み込んだ movies から探すメソッドを使います。
PATCH /movies/:id を追加します。
patch "/movies/:id" do
movies = load_movies
movie = find_movie_from(movies, params["id"])
halt 404, "映画が見つかりません" if movie.nil?
@movie = movie.merge(movie_params)
@errors = []
if @movie["title"].strip.empty?
@errors << "タイトルを入力してください"
return erb :edit
end
movie.merge!(@movie)
save_movies(movies)
redirect "/movies/#{movie["id"]}"
end
movie_params には、フォームから届いたタイトル、監督、公開年、ジャンル、紹介文だけが入ります。ID は含まれません。
movie.merge(movie_params) は、元の映画データにフォームから届いた値を重ねた新しいハッシュを作ります。元の映画データに入っていた ID は残ります。タイトルが空なら保存せず、編集フォームを再表示します。
入力に問題がなければ、movie.merge!(@movie) で配列の中にある映画ハッシュを書き換えます。merge は新しいハッシュを作り、merge! は元のハッシュを書き換えます。その後、配列全体を JSON ファイルへ保存します。
更新に成功したら、映画詳細画面へリダイレクトします。
この章では、更新後に直接 HTML を返さず、詳細画面へ移動する形を使います。このリダイレクトが再送信を防ぐ意味は、第8章で PRG として捉え直します。
詳細画面から編集へ進む
詳細画面に編集リンクを追加します。
<a class="button-link" href="/movies/<%= h(@movie["id"]) %>/edit">編集する</a>
これで、詳細画面から編集画面へ移動できます。
詳細画面から削除する
削除は、詳細画面にフォームを置いて行います。HTML フォームは DELETE を直接送れないため、フォームの method は post にし、hidden input で _method=delete を送ります。
<form action="/movies/<%= h(@movie["id"]) %>" method="post">
<input type="hidden" name="_method" value="delete">
<button type="submit" class="danger-button">削除する</button>
</form>
この教材では、削除確認画面や JavaScript の確認ダイアログは使いません。誤操作対策は大切ですが、この章では HTTP メソッド、フォーム、ルーティングの関係に集中します。
views/show.erb の下部は次の形になります。
<div class="page-actions">
<a href="/movies">一覧へ戻る</a>
<a class="button-link" href="/movies/<%= h(@movie["id"]) %>/edit">編集する</a>
<form action="/movies/<%= h(@movie["id"]) %>" method="post">
<input type="hidden" name="_method" value="delete">
<button type="submit" class="danger-button">削除する</button>
</form>
</div>
page-actions は、詳細画面の主な操作をまとめるためのクラスです。一覧へ戻るリンク、編集リンク、削除フォームが離れすぎないよう、CSS で横並びにします。
削除処理を作る
DELETE /movies/:id を追加します。
delete "/movies/:id" do
movies = load_movies
movie = find_movie_from(movies, params["id"])
halt 404, "映画が見つかりません" if movie.nil?
movies.delete(movie)
save_movies(movies)
redirect "/movies"
end
ID が一致する映画を見つけ、配列から削除し、JSON ファイルへ保存します。削除後は、削除した映画の詳細画面には戻れません。映画一覧へリダイレクトします。
削除後のリダイレクトも、第8章で PRG の流れとして見直します。
Network タブとログで確認する
編集フォームから更新すると、Network タブでは次のように見えます。
POST /movies/:id
Form Data: _method=patch
303 See Other
GET /movies/:id
ブラウザが実際に送っているのは POST です。Sinatra のログでは、Rack の method override を通過した後の PATCH /movies/:id を確認できます。
削除も同じです。
POST /movies/:id
Form Data: _method=delete
303 See Other
GET /movies
Network タブだけを見て「PATCH や DELETE が送られていない」と判断しないでください。HTML フォームの制約により、ブラウザは POST を送ります。_method を見て Rack がメソッドを読み替え、Sinatra の patch や delete のルートへ届きます。
この章の完成コード
この章の最後の app.rb は次の形です。
require "json"
require "rack/utils"
require "securerandom"
require "sinatra"
enable :method_override
MOVIES_FILE = File.join(__dir__, "data", "movies.json")
helpers do
def h(value)
Rack::Utils.escape_html(value)
end
end
def load_movies
JSON.parse(File.read(MOVIES_FILE))
end
def save_movies(movies)
File.write(MOVIES_FILE, "#{JSON.pretty_generate(movies)}\n")
end
def find_movie(id)
load_movies.find { |movie| movie["id"] == id }
end
def find_movie_from(movies, id)
movies.find { |movie| movie["id"] == id }
end
def movie_params
{
"title" => params["title"].to_s,
"director" => params["director"].to_s,
"year" => params["year"].to_s,
"genre" => params["genre"].to_s,
"description" => params["description"].to_s
}
end
get "/" do
redirect "/movies"
end
get "/movies" do
@movies = load_movies
erb :index
end
get "/movies/new" do
@movie = {}
@errors = []
erb :new
end
get "/movies/:id/edit" do
@movie = find_movie(params["id"])
halt 404, "映画が見つかりません" if @movie.nil?
@errors = []
erb :edit
end
get "/movies/:id" do
@movie = find_movie(params["id"])
halt 404, "映画が見つかりません" if @movie.nil?
erb :show
end
post "/movies" do
@movie = movie_params
@errors = []
if @movie["title"].strip.empty?
@errors << "タイトルを入力してください"
return erb :new
end
movies = load_movies
movie = { "id" => SecureRandom.uuid }.merge(@movie)
movies << movie
save_movies(movies)
redirect "/movies/#{movie["id"]}"
end
patch "/movies/:id" do
movies = load_movies
movie = find_movie_from(movies, params["id"])
halt 404, "映画が見つかりません" if movie.nil?
@movie = movie.merge(movie_params)
@errors = []
if @movie["title"].strip.empty?
@errors << "タイトルを入力してください"
return erb :edit
end
movie.merge!(@movie)
save_movies(movies)
redirect "/movies/#{movie["id"]}"
end
delete "/movies/:id" do
movies = load_movies
movie = find_movie_from(movies, params["id"])
halt 404, "映画が見つかりません" if movie.nil?
movies.delete(movie)
save_movies(movies)
redirect "/movies"
end
views/show.erb は次の形です。
<h1><%= h(@movie["title"]) %></h1>
<dl class="movie-detail">
<div>
<dt>監督</dt>
<dd><%= h(@movie["director"]) %></dd>
</div>
<div>
<dt>公開年</dt>
<dd><%= h(@movie["year"]) %></dd>
</div>
<div>
<dt>ジャンル</dt>
<dd><%= h(@movie["genre"]) %></dd>
</div>
<div>
<dt>紹介文</dt>
<dd class="movie-description"><%= h(@movie["description"]) %></dd>
</div>
</dl>
<div class="page-actions">
<a href="/movies">一覧へ戻る</a>
<a class="button-link" href="/movies/<%= h(@movie["id"]) %>/edit">編集する</a>
<form action="/movies/<%= h(@movie["id"]) %>" method="post">
<input type="hidden" name="_method" value="delete">
<button type="submit" class="danger-button">削除する</button>
</form>
</div>
views/edit.erb は次の形です。
<h1>映画編集</h1>
<p>登録済みの映画情報を変更します。</p>
<% unless @errors.empty? %>
<div class="error-messages" role="alert">
<p>入力内容を確認してください。</p>
<ul>
<% @errors.each do |error| %>
<li><%= h(error) %></li>
<% end %>
</ul>
</div>
<% end %>
<form class="movie-form" action="/movies/<%= h(@movie["id"]) %>" method="post">
<input type="hidden" name="_method" value="patch">
<div class="form-field">
<label for="title">タイトル</label>
<input type="text" id="title" name="title" value="<%= h(@movie["title"]) %>">
</div>
<div class="form-field">
<label for="director">監督</label>
<input type="text" id="director" name="director" value="<%= h(@movie["director"]) %>">
</div>
<div class="form-field">
<label for="year">公開年</label>
<input type="text" id="year" name="year" value="<%= h(@movie["year"]) %>">
</div>
<div class="form-field">
<label for="genre">ジャンル</label>
<select id="genre" name="genre">
<option value="アクション" <%= "selected" if @movie["genre"] == "アクション" %>>アクション</option>
<option value="コメディ" <%= "selected" if @movie["genre"] == "コメディ" %>>コメディ</option>
<option value="ドラマ" <%= "selected" if @movie["genre"] == "ドラマ" %>>ドラマ</option>
<option value="ホラー" <%= "selected" if @movie["genre"] == "ホラー" %>>ホラー</option>
<option value="SF" <%= "selected" if @movie["genre"] == "SF" %>>SF</option>
<option value="アニメーション" <%= "selected" if @movie["genre"] == "アニメーション" %>>アニメーション</option>
<option value="その他" <%= "selected" if @movie["genre"] == "その他" %>>その他</option>
</select>
</div>
<div class="form-field">
<label for="description">紹介文</label>
<textarea id="description" name="description" rows="5"><%= h(@movie["description"]) %></textarea>
</div>
<div class="form-actions">
<button type="submit">更新する</button>
<a href="/movies/<%= h(@movie["id"]) %>">詳細へ戻る</a>
</div>
</form>
第7章では、CSS に次のスタイルを追加します。
.danger-button {
border-color: #8c1d18;
background: #8c1d18;
}
.danger-button:hover {
background: #681410;
}
.page-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-top: 24px;
}
.page-actions form {
margin: 0;
}
ここまでで、映画図鑑には一覧、詳細、登録、編集、削除が揃いました。次章では、登録・更新・削除の後に使ってきたリダイレクトを、PRG パターンとして捉え直します。
確認しよう
- 詳細画面から編集画面へ移動できることを確認する。
- タイトルを変更して更新し、詳細画面に変更後の値が表示されることを確認する。
- タイトルを空にして更新し、保存されずに編集フォームが再表示されることを確認する。
- Network タブで、更新時に POST と
_method=patchが見えることを確認する。 - Sinatra のログで、更新が PATCH として処理されていることを確認する。
- 詳細画面から削除し、一覧画面へ戻ることを確認する。
- Network タブで、削除時に POST と
_method=deleteが見えることを確認する。 - Sinatra のログで、削除が DELETE として処理されていることを確認する。
- 削除後に、削除した映画の詳細 URL へアクセスすると 404 になることを確認する。
考えてみよう
- なぜ編集フォームの表示は GET で、更新処理は PATCH なのでしょうか。
- なぜ HTML フォームは PATCH や DELETE を直接送れないのに、Sinatra では
patchやdeleteのルートを書けるのでしょうか。 - 削除後に、削除した映画の詳細画面ではなく一覧画面へ移動するのはなぜでしょうか。
さらに学ぶ
- ◎ MDN PATCH: https://developer.mozilla.org/ja/docs/Web/HTTP/Methods/PATCH
- ◎ MDN DELETE: https://developer.mozilla.org/ja/docs/Web/HTTP/Methods/DELETE
- ◎ Rack MethodOverride: https://rack.github.io/rack/main/Rack/MethodOverride.html
- ◎ Sinatra configuration: https://sinatrarb.com/configuration
第4部 HTTP の動きを読めるようにする
第4部では、完成に近づいた映画図鑑を使って、HTTP の動きをより詳しく観察します。
第8章では、登録、更新、削除のあとにリダイレクトする理由を、POST、リダイレクト、GET をつなぐ PRG パターンとして整理します。
第9章では、利用者入力をそのまま HTML として表示する危険を確認し、出力時のエスケープを学びます。
第10章では、存在しない映画や URL に 404 を返し、404 と 500 を切り分けます。
第8章 リダイレクトは二つのリクエストをつなぐ
第7章までで、映画図鑑には一覧、詳細、登録、編集、削除が揃いました。登録、更新、削除の後には、すでに redirect を使っています。
この章では、これまで使ってきたリダイレクトを、POST、リダイレクト、GET を順につなぐ PRG パターンとして捉え直します。状態を変えるリクエストの後に直接 HTML を返さない理由を、実際の通信から確かめます。
先に使っていたリダイレクト
映画図鑑では、登録成功後に詳細画面へ移動しています。
post "/movies" do
# 省略
redirect "/movies/#{movie["id"]}"
end
更新成功後も、詳細画面へ移動します。
patch "/movies/:id" do
# 省略
redirect "/movies/#{movie["id"]}"
end
削除成功後は、削除した映画の詳細画面には戻れないため、一覧画面へ移動します。
delete "/movies/:id" do
# 省略
redirect "/movies"
end
ここまでは「処理が終わった後に別の画面へ移動するための仕組み」として使ってきました。この章では、リダイレクトが HTTP のリクエストをどう分けているかを見ます。
PRG パターンとは
PRG は、Post/Redirect/Get の略です。
POST
↓
Redirect
↓
GET
状態を変えるリクエストの後に、直接 HTML を返さず、表示用の GET へリダイレクトする形です。
名前は Post/Redirect/Get ですが、この本では、状態を変える処理の後に表示用の GET へ移る設計として扱います。更新や削除でも、最後は表示用の GET へ移動します。
映画図鑑では、登録だけでなく、更新や削除でも同じ考え方を使っています。
POST /movies
↓
303 See Other
↓
GET /movies/:id
PATCH /movies/:id
↓
303 See Other
↓
GET /movies/:id
DELETE /movies/:id
↓
303 See Other
↓
GET /movies
更新と削除では、Network タブ上は PATCH や DELETE ではなく、POST と _method として見えます。この章では、全体の設計を先に図で見てから、Network タブと Sinatra ログで実際の見え方を確認します。
直接 HTML を返すと何が困るのか
もし登録処理の最後で、リダイレクトせずに HTML を直接返したらどうなるでしょうか。
例えば、次のようなコードを一時的に想像します。これは説明のための比較コードです。本文を読みながら写す必要はありません。
post "/movies" do
@movie = movie_params
@errors = []
if @movie["title"].strip.empty?
@errors << "タイトルを入力してください"
return erb :new
end
movies = load_movies
movie = { "id" => SecureRandom.uuid }.merge(@movie)
movies << movie
save_movies(movies)
"登録しました"
end
この場合、ブラウザに表示されているページは POST /movies の結果です。その画面で再読み込みすると、ブラウザはもう一度 POST /movies を送ろうとします。
同じ登録処理が再実行されると、同じような映画がもう一度保存される可能性があります。更新なら同じ更新が再送信され、削除なら削除リクエストが再送信されます。
この比較コードは、仕組みを理解するための一時的な例です。映画図鑑の完成コードには残しません。
登録後の流れを見る
Chrome DevTools の Network タブを開き、/movies/new から映画を登録してください。リダイレクト前後のリクエストを見失う場合は、Network タブの Preserve log を有効にしてから操作すると追いやすくなります。
Network タブには、次の流れが表示されます。
POST /movies
303 See Other
GET /movies/:id
POST /movies は、映画を保存するリクエストです。このレスポンスは、詳細画面の HTML ではありません。別の URL を見るように指示するリダイレクトレスポンスです。
この環境では、ステータスコードとして 303 See Other を確認できます。303 See Other は、別の URL を GET で取りに行くよう示すリダイレクトです。
その後、ブラウザは GET /movies/:id を送ります。画面に表示される映画詳細は、この GET へのレスポンスです。
再読み込みで何が起きるか
登録後に表示されている詳細画面で、ブラウザの再読み込みをしてください。
再読み込みで送られるのは、表示中の詳細画面への GET です。
GET /movies/:id
POST /movies は再実行されません。つまり、再読み込みしても同じ映画がもう一度登録されることはありません。
PRG パターンは、処理後の見た目を整えるためだけのものではありません。状態を変えるリクエストと、結果を表示する GET を分けることで、再読み込み時の再送信を避けています。
また、状態を変える処理と表示を別のリクエストに分けると、それぞれの役割も明確になります。POST、PATCH、DELETE はデータを変えるための処理であり、GET は結果を表示するための処理です。
更新後の流れを見る
次に、映画の詳細画面から編集画面へ移動し、映画を更新してください。
HTML フォームは PATCH を直接送れません。そのため、Network タブでは次のように見えます。
POST /movies/:id
Form Data: _method=patch
303 See Other
GET /movies/:id
ブラウザが送る HTTP メソッドは POST です。Form Data に _method=patch が含まれています。
一方、Sinatra のログでは、Rack の method override を通過した後のリクエストとして、次のように確認できます。
"PATCH /movies/:id HTTP/1.1" 303
Network タブの POST と、Sinatra ログの PATCH は矛盾していません。ブラウザは POST を送り、Rack が _method=patch を見て、Sinatra へ PATCH として渡しています。
更新後に表示されている詳細画面で再読み込みすると、送られるのは GET /movies/:id です。更新処理は再実行されません。
再実行されていないことは、Sinatra のログに新しい PATCH が増えないことで確認できます。
削除後の流れを見る
削除も同じ考え方です。詳細画面の削除フォームは、POST と _method=delete を送ります。
Network タブでは次のように見えます。
POST /movies/:id
Form Data: _method=delete
303 See Other
GET /movies
Sinatra のログでは、Rack の method override を通過した後のリクエストとして確認できます。
"DELETE /movies/:id HTTP/1.1" 303
削除後は、削除した映画の詳細画面ではなく一覧画面へリダイレクトします。削除した映画はもう存在しないためです。
一覧画面で再読み込みすると、送られるのは GET /movies です。削除処理は再実行されません。
再実行されていないことは、Sinatra のログに新しい DELETE が増えないことで確認できます。
GET / のリダイレクトとの違い
第2章では、GET / から /movies へリダイレクトしました。
get "/" do
redirect "/movies"
end
これは、アプリの入口を /movies にそろえるためのリダイレクトです。状態を変える処理の後ではありません。
一方、この章で見ているリダイレクトは、登録、更新、削除の後に使っています。状態を変えるリクエストを終えた後、表示用の GET へ移るためのリダイレクトです。
同じ redirect でも、使う場面によって意味が変わります。
この章の完成コード
この章では、完成コードに残す変更はありません。第7章までのコードをそのまま使い、リダイレクトの意味を HTTP の流れとして確認します。
確認するルートは次の 3 つです。
post "/movies" do
# 省略
redirect "/movies/#{movie["id"]}"
end
patch "/movies/:id" do
# 省略
redirect "/movies/#{movie["id"]}"
end
delete "/movies/:id" do
# 省略
redirect "/movies"
end
確認しよう
- 映画を登録し、Network タブで
POST /movies、303 See Other、GET /movies/:idを確認する。 - 登録後の詳細画面で再読み込みし、送られるのが
GET /movies/:idであることを確認する。 JSON ファイルの件数が増えないことも確認する。 - 映画を更新し、Network タブで POST と
_method=patch、Sinatra のログで PATCH を確認する。 - 更新後の詳細画面で再読み込みし、更新処理が再実行されないことを確認する。
- 映画を削除し、Network タブで POST と
_method=delete、Sinatra のログで DELETE を確認する。 - 削除後の一覧画面で再読み込みし、削除処理が再実行されないことを確認する。
考えてみよう
- なぜ登録、更新、削除の後に直接 HTML を返さないのでしょうか。
- Network タブでは POST と表示されるのに、Sinatra のログでは PATCH や DELETE と表示されるのはなぜでしょうか。
GET /から/moviesへのリダイレクトと、登録後のリダイレクトは何が違うのでしょうか。
さらに学ぶ
- ◎ MDN HTTP リダイレクト: https://developer.mozilla.org/ja/docs/Web/HTTP/Redirections
- ◎ MDN 303 See Other: https://developer.mozilla.org/ja/docs/Web/HTTP/Status/303
- ◎ Sinatra: https://sinatrarb.com/intro.html
第9章 利用者の入力はそのまま HTML にしない
第8章では、登録・更新・削除の後にリダイレクトし、表示用の GET へ移る理由を学びました。
この章では、表示する値そのものに注目します。映画図鑑のタイトル、監督、公開年、ジャンル、紹介文は、すべて利用者が入力できる値です。その値をそのまま HTML にしたときに起きる問題を確かめ、h ヘルパーで安全に表示できる形へ変換します。
映画データはすべて利用者入力
映画図鑑では、次の値をフォームから登録・編集できます。
- タイトル
- 監督
- 公開年
- ジャンル
- 紹介文
これらは、アプリの作者があらかじめ用意した固定文字列ではありません。利用者が入力できる値です。
利用者入力を HTML として表示するときは、ブラウザに「HTML の一部」ではなく「文字」として解釈させる必要があります。そのために、第5章で h ヘルパーを導入しました。
ジャンルは画面上では選択肢から選びますが、サーバーにはリクエストの値として届きます。ブラウザの開発者ツールや別の HTTP クライアントから送られる可能性もあるため、表示するときはほかの入力値と同じようにエスケープします。
helpers do
def h(value)
Rack::Utils.escape_html(value)
end
end
h は、HTML で特別な意味を持つ文字を文字参照へ変換します。例えば < は <、" は " のように変換されます。
XSS とは
XSS は、Cross-site scripting の略です。利用者入力などをきっかけに、ブラウザへ意図しない HTML や JavaScript を解釈させてしまう問題です。JavaScript が実行される場合だけでなく、タグ構造や属性値が壊れることも問題になります。
この章では、攻撃手法を広く学ぶのではなく、Web アプリケーションを作るときの基本として、利用者入力をそのまま HTML にしないことを学びます。
安全なローカル環境で、危険な表示を一時的に作って確認します。確認後は、必ず安全なコードへ戻します。
確認用に登録した危険な文字列も、確認後に削除するか、退避しておいた data/movies.json へ戻してください。危険な確認用データを残したまま次の章へ進まないようにします。
安全な状態で入力してみる
まず、現在の安全なコードのまま、次のタイトルを持つ映画を登録してみます。
<script>alert("xss")</script>
登録後の詳細画面では、アラートは実行されません。タイトルとして、文字がそのまま見えるはずです。
HTML レスポンスを見ると、実際には次のように文字参照へ変換されています。
<script>alert("xss")</script>
これは、views/show.erb でタイトルを h に通しているからです。
<h1><%= h(@movie["title"]) %></h1>
ブラウザは < を「タグの始まり」ではなく、文字の < として表示します。
危険な表示を一時的に作る
ここからは、危険を理解するために一時的にコードを変更します。この変更は完成コードには残しません。確認が終わったら、必ず元に戻してください。
views/show.erb のタイトル表示を、次のように一時的に変更します。
<h1><%= @movie["title"] %></h1>
h を外すと、保存されたタイトルが HTML としてそのままレスポンスに入ります。
もう一度、タイトルに次の値を入れた映画の詳細画面を開きます。
<script>alert("xss")</script>
ブラウザがスクリプトとして解釈すると、アラートが表示されます。これが、利用者入力をそのまま HTML にする危険です。
もしアラートが表示されない場合でも、レスポンス HTML に <script> がそのまま入っていれば、利用者入力が HTML として解釈される状態になっています。アラートが出ることそのものより、入力値が HTML の一部になってしまうことが問題です。
確認できたら、すぐに元へ戻します。
<h1><%= h(@movie["title"]) %></h1>
属性値で壊れる入力
XSS は、<script> を本文に入れる場合だけの問題ではありません。HTML のどこへ出力するかによって、壊れ方が変わります。
編集フォームでは、タイトルを value 属性へ出力しています。
<input type="text" id="title" name="title" value="<%= h(@movie["title"]) %>">
ここに出力される値で試したい入力は、次のようなものです。
"><script>alert("xss")</script>
h を使っていれば、" や < は文字参照になり、属性値を壊しません。
危険な例として、編集フォームで一時的に h を外すと、属性値を閉じてから別のタグを差し込めてしまいます。
<input type="text" id="title" name="title" value="<%= @movie["title"] %>">
この変更も、確認後は必ず元に戻します。
<input type="text" id="title" name="title" value="<%= h(@movie["title"]) %>">
textarea で壊れる入力
紹介文は textarea に表示されます。
<textarea id="description" name="description" rows="5"><%= h(@movie["description"]) %></textarea>
ここでは、次のような入力を確認します。
</textarea><script>alert("xss")</script>
h を使っていれば、</textarea> は終了タグとして解釈されず、文字として表示されます。
危険な例として一時的に h を外すと、textarea を閉じたうえで別のタグを差し込めてしまいます。
<textarea id="description" name="description" rows="5"><%= @movie["description"] %></textarea>
確認後は、必ず元へ戻します。
<textarea id="description" name="description" rows="5"><%= h(@movie["description"]) %></textarea>
<script> だけ防げばよいわけではない
ここまで見たように、危険なのは <script> という文字列だけではありません。
同じ利用者入力でも、出力先によって必要な注意が変わります。
- HTML の本文に出す
- HTML 属性値に出す
textareaの中に出す
本書では、これらをすべて Rack::Utils.escape_html を呼び出す h ヘルパーで扱います。これは、HTML の中へ文字として表示するための基本的なエスケープです。
ただし、h があらゆる場所で万能という意味ではありません。JavaScript の文字列や URL など、別の文脈へ値を埋め込む場合は、その文脈に合った扱いが必要です。本書では、HTML の本文、属性値、textarea に文字として表示する範囲を扱います。
映画図鑑で h を使う場所
映画図鑑では、利用者入力を表示する場所で h を使います。
一覧画面では、タイトル、公開年、ジャンルを表示しています。
<td><%= h(movie["title"]) %></td>
<td><%= h(movie["year"]) %></td>
<td><%= h(movie["genre"]) %></td>
詳細画面では、すべての属性を表示しています。
<h1><%= h(@movie["title"]) %></h1>
<dd><%= h(@movie["director"]) %></dd>
<dd><%= h(@movie["year"]) %></dd>
<dd><%= h(@movie["genre"]) %></dd>
<dd class="movie-description"><%= h(@movie["description"]) %></dd>
登録フォームや編集フォームで入力済みの値を戻すときも、h を使います。
<input type="text" id="title" name="title" value="<%= h(@movie["title"]) %>">
<textarea id="description" name="description" rows="5"><%= h(@movie["description"]) %></textarea>
ERB の <%= %> は、値を出力するための書き方です。自動で安全な HTML にしてくれる、と考えてはいけません。本書では、利用者入力を表示するときに明示的に h を使います。
改行表示は CSS で扱う
紹介文の改行表示は、第6章で追加した CSS で扱っています。
.movie-description {
white-space: pre-line;
}
Ruby 側で次のように HTML を作る方法は使いません。
description.gsub("\n", "<br>")
利用者入力をもとに HTML を組み立てると、エスケープとの関係が複雑になります。文字は h で安全に表示し、見た目の改行は CSS で扱います。
この章の完成コード
この章では、完成コードに残す変更はありません。第8章までの安全なコードを維持します。
最終的に、危険な確認用コードが残っていないことを確認してください。
<h1><%= h(@movie["title"]) %></h1>
<input type="text" id="title" name="title" value="<%= h(@movie["title"]) %>">
<textarea id="description" name="description" rows="5"><%= h(@movie["description"]) %></textarea>
危険を確認するために h を外した場合は、必ず戻してから次へ進みます。
次章では、利用者入力だけでなく、URL で指定された映画 ID も信用しすぎないことを扱います。存在しない ID や存在しない URL に対して、アプリがどう応答するかを見ていきます。
確認しよう
- タイトルに
<script>alert("xss")</script>を入れて映画を登録し、安全なコードではアラートが実行されないことを確認する。 - レスポンス HTML で
<や"が文字参照になっていることを確認する。 - ローカル環境で一時的に
hを外し、危険な表示を確認する。 - 確認後、必ず
hを戻す。 "><script>alert("xss")</script>をタイトルに入れ、編集フォームで属性値が壊れないことを確認する。</textarea><script>alert("xss")</script>を紹介文に入れ、編集フォームでtextareaが壊れないことを確認する。
考えてみよう
- なぜ
<script>という文字列だけを禁止しても十分ではないのでしょうか。 - なぜ ERB の
<%= %>だけで安全だと考えてはいけないのでしょうか。 - なぜ紹介文の改行表示を Ruby の
gsubではなく CSS で扱うのでしょうか。
さらに学ぶ
- ◎ OWASP XSS: https://owasp.org/www-community/attacks/xss/
- ◎ MDN Cross-site scripting: https://developer.mozilla.org/ja/docs/Glossary/Cross-site_scripting
- ◎ Rack Utils: https://rack.github.io/rack/main/Rack/Utils.html
- ◎ Ruby ERB: https://docs.ruby-lang.org/ja/latest/library/erb.html
第10章 見つからないものには 404 を返す
第9章では、利用者入力をそのまま HTML にしないことを学びました。この章では、URL で指定されたものが見つからない場合を扱います。
存在しない URL と存在しない映画 ID に 404 を返す処理を作り、アプリのバグによる 500 との違いを確認します。最後に、ここまで作ってきた映画図鑑の完成状態も見直します。
404 は「見つからない」というレスポンス
404 は、リクエストされたリソースが見つからないことを表す HTTP ステータスコードです。
映画図鑑では、次のような場合に 404 を返します。
- 存在しない URL にアクセスした。
- 存在しない映画 ID の詳細画面にアクセスした。
- 存在しない映画 ID の編集画面にアクセスした。
- 存在しない映画 ID を更新・削除しようとした。
大事なのは、画面に「見つかりません」と表示することだけではありません。HTTP レスポンスのステータスコードが 404 になっていることです。
404 ページを作る
views/not_found.erb を作ります。
<h1>ページが見つかりません</h1>
<p>指定されたページ、または映画は見つかりませんでした。</p>
<p>
<a class="button-link" href="/movies">映画一覧へ戻る</a>
</p>
この ERB には、html や body は書きません。layout.erb が共通の HTML 構造を持っているためです。個別の ERB には、その画面固有の中身だけを書きます。
存在しない URL を扱う
Sinatra では、どのルートにも一致しないリクエストを not_found で扱えます。
not_found do
erb :not_found
end
これで、例えば次の URL にアクセスしたときに 404 ページが表示されます。
http://localhost:4567/unknown
Network タブで、このリクエストのステータスコードが 404 Not Found になっていることを確認してください。
この 404 ページは、存在しない URL と存在しない映画 ID の両方で使います。本書では、404 の種類ごとにページを出し分けることはしません。まずは、見つからないものに 404 ステータスと戻る導線を返すことを重視します。
存在しない映画 ID を扱う
存在しない URL だけではなく、存在しない映画 ID も 404 として扱います。
詳細画面では、ID で映画を探しています。
get "/movies/:id" do
@movie = find_movie(params["id"])
if @movie.nil?
status 404
return erb :not_found
end
erb :show
end
@movie が nil なら、その ID に一致する映画はありません。status 404 でレスポンスのステータスコードを 404 にし、erb :not_found で 404 ページを返します。
not_found は、どのルートにも一致しなかったときに使われます。一方、/movies/:id のようにルートには一致したものの、その中で映画が見つからない場合は、アプリ側で status 404 を指定して 404 ページを返します。
次のような URL にアクセスして確認してください。
http://localhost:4567/movies/not-found
画面だけでなく、Network タブのステータスコードを見ます。
編集・更新・削除でも 404 を返す
存在しない映画 ID は、詳細画面だけで発生するわけではありません。
編集画面でも、映画が見つからなければ 404 を返します。
get "/movies/:id/edit" do
@movie = find_movie(params["id"])
if @movie.nil?
status 404
return erb :not_found
end
@errors = []
erb :edit
end
更新と削除でも同じです。
patch "/movies/:id" do
movies = load_movies
movie = find_movie_from(movies, params["id"])
if movie.nil?
status 404
return erb :not_found
end
# 省略
end
delete "/movies/:id" do
movies = load_movies
movie = find_movie_from(movies, params["id"])
if movie.nil?
status 404
return erb :not_found
end
# 省略
end
見つからない ID を通常の処理に混ぜると、別のエラーにつながります。見つからないものは、見つからないものとして 404 を返します。
この章では、各ルートに同じような 404 処理が何度か出てきます。今は、どの入口で映画が見つからない可能性があるかを読みやすくするため、重複を残しています。あとから整理するなら、共通メソッドへ切り出すこともできます。
404 と 500 の違い
404 は、リクエストされたリソースが見つからないことを表します。
一方、500 は、サーバー側で予期しないエラーが起きたことを表します。
例えば、app.rb の末尾に一時的に次のようなルートを追加すると、アクセス時に例外が発生します。
get "/error-example" do
raise "確認用のエラー"
end
このルートへアクセスすると、アプリの中で例外が起きます。これは「見つからない」ではなく、サーバー側のエラーです。Network タブでは 500 Internal Server Error として確認できます。
この確認用ルートは、動作確認が終わったら必ず削除してください。映画図鑑の完成コードには残しません。
映画図鑑の完成状態を確認する
ここまでで、映画図鑑には次の機能が揃いました。
| 機能 | ルート |
|---|---|
| トップから一覧へ移動 | GET / |
| 一覧表示 | GET /movies |
| 新規登録画面 | GET /movies/new |
| 登録処理 | POST /movies |
| 詳細表示 | GET /movies/:id |
| 編集画面 | GET /movies/:id/edit |
| 更新処理 | PATCH /movies/:id |
| 削除処理 | DELETE /movies/:id |
| 存在しない URL | 404 |
| 存在しない映画 ID | 404 |
完成状態では、次のファイルがあります。
.
├── app.rb
├── data/
│ └── movies.json
├── public/
│ └── stylesheets/
│ └── application.css
└── views/
├── edit.erb
├── index.erb
├── layout.erb
├── new.erb
├── not_found.erb
└── show.erb
起動に必要な環境ファイルも確認します。
.
├── .ruby-version
├── Gemfile
└── Gemfile.lock
確認する観点は次のとおりです。
bundle exec ruby app.rbで起動できる。- 映画データは
data/movies.jsonに保存される。 data/movies.jsonはpublic/に置かれていない。- ID は
SecureRandom.uuidで作られる。 - 登録、更新、削除の後はリダイレクトする。
- HTML フォームの PATCH、DELETE は
_methodを使う。 - タイトル必須チェックが登録と更新にある。
- 利用者入力は
hヘルパーで表示される。 - 紹介文の改行表示は CSS の
white-space: pre-lineで扱う。 - 存在しない URL と存在しない映画 ID は 404 になる。
この時点で、映画図鑑は小さな CRUD アプリケーションとして一通り動きます。第11章からは、動かないときにどこを見るかを学びます。
次章では、画面表示、Network タブ、Sinatra のログ、JSON ファイルを分けて見ながら、問題がどこで起きているかを切り分けます。
この章の完成コード
第10章で追加した views/not_found.erb は次の形です。
<h1>ページが見つかりません</h1>
<p>指定されたページ、または映画は見つかりませんでした。</p>
<p>
<a class="button-link" href="/movies">映画一覧へ戻る</a>
</p>
app.rb には、次の 404 関連の処理が入ります。
get "/movies/:id" do
@movie = find_movie(params["id"])
if @movie.nil?
status 404
return erb :not_found
end
erb :show
end
not_found do
erb :not_found
end
編集、更新、削除でも、映画が見つからない場合は同じように status 404 と erb :not_found を返します。
確認しよう
/unknownにアクセスし、404 ページと404 Not Foundを確認する。/movies/not-foundにアクセスし、404 ページと404 Not Foundを確認する。/movies/not-found/editにアクセスし、404 ページと404 Not Foundを確認する。- 存在しない映画 ID に対して更新や削除を送り、404 になることを確認する。
- 一時的な
/error-exampleルートで 500 を確認し、確認後に必ず削除する。 - 映画図鑑の完成状態のファイル、ルート、主要機能を照合する。
考えてみよう
- なぜ見つからない映画 ID を空の詳細画面として表示しないのでしょうか。
- 404 と 500 は、どちらもエラーに見えますが、何が違うのでしょうか。
- 画面にエラーメッセージが出ていても、Network タブでステータスコードを見る必要があるのはなぜでしょうか。
さらに学ぶ
- ◎ MDN 404 Not Found: https://developer.mozilla.org/ja/docs/Web/HTTP/Status/404
- ◎ MDN 500 Internal Server Error: https://developer.mozilla.org/ja/docs/Web/HTTP/Status/500
- ◎ Sinatra: https://sinatrarb.com/intro.html
第5部 調べ方と次の学習
第5部では、完成した映画図鑑を使って、問題の切り分け方と次の学習への進み方を整理します。
第11章では、画面、Network タブ、Sinatra のログ、ERB、JSON ファイルを分けて見ながら、動かない原因を事実から探す方法を学びます。
第12章では、JSON ファイル保存の限界を振り返り、なぜ次にデータベース設計を学ぶ必要があるのかを考えます。
第11章 動かないときは境界を見る
第10章までで、映画図鑑は小さな CRUD アプリケーションとして一通り動く状態になりました。
この章では、新しい機能を増やしません。動かないときに、ブラウザ、HTTP、Sinatra、ERB、JSON ファイルを順に調べ、どこで問題が起きているかを事実から切り分ける方法を整理します。
まず再現手順を短くする
デバッグでは、いきなり原因を当てようとしません。まず、何をすると何が起きるのかを短くします。
例えば、次のように書ける状態を目指します。
1. /movies/new を開く。
2. タイトルに「テスト映画」と入力する。
3. 登録する。
4. 詳細画面ではなく 404 ページが表示される。
「なんか動かない」では、見る場所を決められません。URL、操作、期待した結果、実際の結果を分けます。
ブラウザの表示と Network タブを分けて見る
ブラウザに表示された画面は、結果の一部です。Web アプリケーションでは、画面の裏で HTTP リクエストとレスポンスが発生しています。
Network タブでは、次を確認します。
- URL
- HTTP メソッド
- ステータスコード
- リクエストヘッダー
- レスポンスヘッダー
- Form Data
- リダイレクト前後のリクエスト
例えば、登録後に詳細画面へ移動しない場合は、まず POST /movies が送られているかを見ます。次に、レスポンスが 303 See Other か、Location がどこを指しているかを見ます。
画面だけを見て「登録に失敗した」と決めつけないでください。data/movies.json には保存されているが、リダイレクト先の URL が間違っている、ということもあります。
Sinatra のログを見る
bundle exec ruby app.rb でアプリを起動しているターミナルには、Sinatra のログが出ます。
ログは操作するたびに増えていきます。操作の前にログの最後の行を見ておき、操作した直後に追加された行を確認すると、どの操作に対応するログなのかを見失いにくくなります。
例えば、更新時には次のようなログを確認できます。
"PATCH /movies/b6f5e1c4-4b5f-4a7f-8f8f-3d9d3ef9d001 HTTP/1.1" 303
Network タブでは POST と _method=patch が見えます。Sinatra のログでは、Rack の method override を通過した後の PATCH が見えます。
削除も同じです。
"DELETE /movies/b6f5e1c4-4b5f-4a7f-8f8f-3d9d3ef9d003 HTTP/1.1" 303
ログを見ると、どのルートまで届いたのか、どのステータスコードを返したのかが分かります。
params と JSON ファイルを見る
フォームから値が届いているかを調べるときは、params を確認します。
第4章では、確認用に次のようなコードを書きました。
post "/movies" do
content_type :text
params.inspect
end
完成コードへ戻す必要があるため、この確認用コードを残してはいけません。ただ、切り分けの考え方として、フォームの name と params のキーを見比べることは重要です。
確認用コードを入れたら、動作確認後に必ず差分を戻します。デバッグのために一時的に書いたコードを残すと、後の章で説明している完成コードと実際のコードがずれてしまいます。
保存後に問題が起きているなら、data/movies.json を見ます。
- 送信した値が JSON に追加されているか。
- UUID があるか。
- タイトルが空のデータが保存されていないか。
- 更新した値に書き換わっているか。
- 削除した映画が消えているか。
画面に表示されない場合でも、JSON に保存されているなら、保存処理ではなく表示側を疑います。JSON に保存されていないなら、フォーム送信、入力チェック、保存処理を疑います。
JSON ファイルを手で直して確認したくなることもあります。その場合は、まずコピーを取ってから編集してください。カンマや引用符を一つ消しただけでも JSON として読めなくなり、別のエラーを増やしてしまいます。
ERB のエラーを読む
ERB に文法ミスがあると、画面には 500 が表示されることがあります。
例えば、views/show.erb で <% end %> が足りない、引用符が閉じていない、変数名を間違えた、といった場合です。
このときは、ブラウザの画面だけでなく、ターミナルのエラーメッセージを読みます。エラーには、ファイル名や行番号が含まれていることがあります。
エラーメッセージは最後に読むものではありません。最初に読む事実の一つです。
layout.erb と各 ERB の役割を確認する
画面が二重に崩れているときは、layout.erb と各ビューの役割を確認します。
views/layout.erb は、共通の HTML 構造を持ちます。
<!doctype html>
<html lang="ja">
<head>
...
</head>
<body>
...
<%= yield %>
</body>
</html>
views/index.erb や views/show.erb には、その画面固有の中身だけを書きます。各 ERB に html や body を重ねて書くと、HTML の構造が崩れます。
layout.erb は外枠、各 ERB は中身です。この役割を分けて確認します。
見た目が表示されていても、HTML として確認すべき点は残ります。フォームでは label と入力欄が対応しているか、リンクやボタンの文言だけで行き先や操作が分かるかも確認対象です。
症状ごとに最初に見る場所
よくある症状と、最初に見る場所をまとめます。
| 症状 | 最初に見る場所 |
|---|---|
| フォームを送っても値が届かない | Network タブの Form Data、フォーム部品の name |
| 登録後に増えない | POST /movies のステータス、data/movies.json |
| 更新しても変わらない | Network タブの _method=patch、Sinatra ログ、JSON |
| 削除しても残る | Network タブの _method=delete、Sinatra ログ、JSON |
| 404 になる | URL、映画 ID、JSON 内の ID |
| 500 になる | ターミナルのエラーメッセージ、ERB の行番号 |
| 画面が崩れる | HTML 構造、layout.erb と各 ERB、CSS |
| 入力したタグが動いてしまう | h ヘルパーが外れていないか |
この表は、答えの一覧ではありません。最初に事実を集める場所を選ぶための表です。
よくある失敗を切り分ける
例えば、JSON ファイルを public/ に置いてしまった場合、ブラウザから直接読める場所に保存データを置くことになります。保存場所の問題は、画面ではなくディレクトリ構成を見る必要があります。
POST 後に直接 HTML を返してしまった場合、再読み込みで再送信が起きる可能性があります。これは、Network タブで POST 後に GET へ移っているかを見る必要があります。
紹介文の改行表示を Ruby の gsub("\n", "<br>") で作ると、利用者入力と HTML 生成が混ざります。これは、表示の見た目だけではなく、XSS とエスケープの観点で確認します。
問題の種類によって、見る境界は変わります。
付録のエラー集へ進む
本書の付録には、よくあるエラーをまとめます。
ただし、付録を読む前に、まず自分で次を確認してください。
- どの操作で再現するか。
- Network タブでは何が起きているか。
- Sinatra のログには何が出ているか。
- JSON ファイルはどう変わっているか。
- ERB のエラーメッセージは何を指しているか。
会話やレビューで質問するときも、この情報があると状況を伝えやすくなります。
質問するときは、「再現手順」「期待した結果」「実際の結果」「Network タブで見えたメソッドとステータスコード」「ターミナルに出たログ」を分けて書くと、相手が同じ状況を追いやすくなります。
次の章では、ここで確認した data/movies.json に注目します。JSON ファイルを読んで切り分けられるようになったうえで、ファイル保存にはどのような限界があるのかを考えます。
この章の完成コード
この章では、完成コードに残す変更はありません。第10章までに完成した映画図鑑を使って、問題の切り分け方を学びました。
確認しよう
- 映画を登録し、Network タブ、Sinatra ログ、
data/movies.jsonの 3 つを見比べる。 - 映画を更新し、Network タブの
_method=patchと Sinatra ログの PATCH を見比べる。 - 存在しない映画 ID にアクセスし、Network タブの 404 と JSON 内の ID を見比べる。
views/show.erbを読むだけで、layout.erbと役割が分かれていることを確認する。
考えてみよう
- 画面だけを見て原因を決めると、どのような間違いが起きそうでしょうか。
- JSON に保存されているのに画面に出ない場合、どこを疑えばよいでしょうか。
- 500 が出たとき、なぜまずターミナルのエラーメッセージを見るのでしょうか。
さらに学ぶ
- ◎ MDN ブラウザ開発者ツール: https://developer.mozilla.org/ja/docs/Learn/Common_questions/Tools_and_setup/What_are_browser_developer_tools
- ◎ Sinatra: https://sinatrarb.com/intro.html
第12章 ファイル保存の先にデータベースがある
第11章では、映画図鑑が動かないときに、画面、Network タブ、Sinatra のログ、ERB、JSON ファイルを分けて見る方法を学びました。
この章では、新しい機能を増やしません。映画図鑑で作ってきたものを振り返り、JSON ファイルによる保存の限界を、同時更新、データ量、関連、整合性の四つの観点から考えます。
映画図鑑でできるようになったこと
ここまでで、映画図鑑は小さな Web アプリケーションとして一通り動く状態になりました。
- 映画の一覧を表示する。
- 映画の詳細を表示する。
- フォームから映画を登録する。
- 登録済みの映画を編集する。
- 登録済みの映画を削除する。
- JSON ファイルへ保存する。
- GET、POST、PATCH、DELETE を使い分ける。
- 登録、更新、削除後にリダイレクトする。
- 利用者入力をエスケープして表示する。
- 見つからない映画や URL に 404 を返す。
- Network タブとログで動きを確認する。
この本の目的は、映画図鑑を実用的なサービスとして完成させることではありません。Sinatra を使って、Web アプリケーションがリクエストを受け取り、処理し、レスポンスを返す流れを自分の手で確かめることでした。
その目的に対して、JSON ファイル保存は十分に役立ちました。リクエストごとに消える Ruby の変数だけではなく、次のリクエストでも残るデータを扱えるようになったからです。
1 ファイル全体を読み書きしている
映画図鑑では、映画データを data/movies.json に保存しています。
保存処理は、次のような流れです。
def load_movies
JSON.parse(File.read(MOVIES_FILE))
end
def save_movies(movies)
File.write(MOVIES_FILE, "#{JSON.pretty_generate(movies)}\n")
end
この実装では、映画を 1 件だけ更新する場合でも、まず movies.json 全体を読み込みます。その後、Ruby の配列とハッシュを変更し、最後にファイル全体を書き戻します。
映画が数件なら、この方法は分かりやすく、学習用として扱いやすいです。しかし、映画が数千件、数万件に増えたらどうでしょうか。1 件を変更するだけでも、毎回すべてのデータを読み書きすることになります。
ファイル保存は、データが増えても必要な部分だけを効率よく探す仕組みを自分で用意しなければなりません。
同時に書き込むと壊れる可能性がある
本書の映画図鑑は、単一ユーザーがローカル環境で使う前提でした。
もし複数人が同時に使うアプリケーションにしたら、どうなるでしょうか。
例えば、次のような順番が起きるかもしれません。
1. A さんのリクエストが movies.json を読む。
2. B さんのリクエストも movies.json を読む。
3. A さんのリクエストが映画を追加して movies.json を書き戻す。
4. B さんのリクエストが、古い内容をもとに movies.json を書き戻す。
この場合、A さんが追加した映画が、B さんの書き戻しで失われる可能性があります。さらにタイミングによっては、ファイルの途中までしか書かれず、JSON として読めない状態になるかもしれません。
同時に書き込まれてもデータを壊さない仕組みは、Web アプリケーションでは重要です。ファイル保存にも対策の方法はありますが、本書では排他制御を扱いませんでした。ここでは問題を知るだけに留め、対策の実装は次の学習へ送ります。一般的な Web アプリケーションでは、このような問題を扱うためにデータベースを使うことが多くなります。
データが増えたときに探しにくい
映画図鑑では、ID で映画を探すときに配列を順番に見ています。
def find_movie(id)
load_movies.find { |movie| movie["id"] == id }
end
これも、映画が数件なら問題になりません。
しかし、データが増えると、探すたびに全件を確認することになります。タイトルで検索したい、公開年で絞り込みたい、ジャンルごとに並べたい、といった要求が出てくると、Ruby のコード側に検索や並び替えの処理が増えていきます。
本書では検索、絞り込み、並び替え、ページネーションを扱いませんでした。それらは、Web アプリケーションとして自然な機能ですが、ファイル保存のまま進めると、保存方式の限界と実装の複雑さが同時に出てきます。
データ同士の関連を扱いにくい
映画図鑑では、映画 1 件の中に監督名やジャンルを文字列として保存しました。
{
"id": "c55c1d37-f3cf-469e-a746-a3044279c716",
"title": "月面喫茶",
"director": "山田アキラ",
"year": "2042",
"genre": "SF",
"description": "月面にある小さな喫茶店を舞台にした物語。"
}
この形は、映画 1 件だけを表示するには分かりやすいです。
しかし、次のようなことをしたくなると、話が変わります。
- 同じ監督の映画を一覧したい。
- ジャンルを自由入力ではなく、別の一覧から選ばせたい。
- 映画ごとに複数のレビューを持たせたい。
- レビューを書いた人を管理したい。
このような場合、映画、監督、ジャンル、レビュー、ユーザーのように、データ同士の関係を考える必要があります。
ここで挙げたレビューやユーザーは、関連を考えるための例です。本書の映画図鑑には、レビュー投稿やログイン機能は追加しません。
JSON でも複雑な構造は作れます。けれども、どこに何を入れるか、同じ情報を重複して持たないか、片方を更新したときにもう片方も正しく変わるかを、自分で管理しなければなりません。
整合性を守る仕組みが足りない
整合性とは、データ同士のつじつまが合っていることです。「あるはずのデータがない」「同じ意味の値が場所によって違う」といった状態を避けるための考え方です。
例えば、映画が削除されたのに、その映画 ID を指すレビューだけが残っていたら、データのつじつまが合っていません。ジャンル名を「SF」から「サイエンスフィクション」へ変更したいとき、映画ごとに保存された文字列をすべて書き換えなければならないなら、書き換え漏れが起きるかもしれません。
本書の映画図鑑は、1 種類のデータだけを扱いました。そのため、整合性の問題を深く考えずに済みました。
アプリケーションが大きくなると、「データをどう分けるか」「どのデータがどのデータを参照するか」「消してよいデータと消してはいけないデータは何か」を考える必要があります。ここから先が、データベース設計の入口です。
ER 図や正規化はここでは扱わない
この章では、ER 図や正規化の説明には進みません。
ER 図は、データ同士の関係を図で表すためのものです。正規化は、データの重複や不整合を減らすために、データの分け方を整理する考え方です。どちらも重要ですが、本書の中心ではありません。
本書では、まず Web アプリケーションの流れを学びました。
- ブラウザからリクエストが送られる。
- Sinatra がルートを選ぶ。
paramsから入力値を受け取る。- Ruby の配列とハッシュを操作する。
- ERB で HTML を作る。
- レスポンスを返す。
- Network タブで HTTP を観察する。
この流れを自分で作ったからこそ、次にデータベースを学ぶ意味が分かります。データベースは、Web アプリケーションの外側に突然出てくるものではありません。保存、検索、更新、削除をより安全に、効率よく、関係を保って行うための仕組みです。
Rails で隠れて見えにくくなるもの
Rails では、データベース操作の多くを Active Record が扱います。
例えば、Rails では次のようなコードでデータを保存する場面があります。
movie = Movie.new(title: "月面喫茶")
movie.save
この短いコードの裏側では、データベースへ値を保存する処理が行われます。Active Record は、Ruby オブジェクトとデータベースのテーブルを対応させ、関連、バリデーション、データベース操作を扱うための仕組みです。
Rails を使うと、JSON ファイルを読み込んで、配列を探し、ファイル全体を書き戻すコードは見えにくくなります。これは便利です。しかし、見えないからといって、Web アプリケーションの基本が消えるわけではありません。
Rails に進んでも、次の考え方は残ります。
- リクエストには URL と HTTP メソッドがある。
- フォームから送られた値はサーバー側で受け取る。
- 保存後はリダイレクトして、再送信を避ける。
- 利用者入力は表示時に安全に扱う。
- 見つからないデータには適切なステータスを返す。
- データの保存先には、扱いやすさと限界がある。
Sinatra で手を動かして作った経験は、Rails で隠れている処理を想像する助けになります。
次に学ぶこと
映画図鑑は、本書の範囲では完成です。
ただし、Web アプリケーション開発の学習はここで終わりではありません。次に学ぶとよいのは、データベース設計です。
データベース設計では、例えば次のようなことを考えます。
- 映画、監督、レビューのようなデータをどう分けるか。
- それぞれのデータがどのような属性を持つか。
- データ同士がどのように関係するか。
- 重複や不整合をどう減らすか。
その後で Rails を学ぶと、Active Record の便利さだけでなく、その裏にあるデータの構造も理解しやすくなります。
本書で作った映画図鑑は、複雑なアプリケーションではありません。しかし、Web アプリケーションの基本を一つずつ見える形で作りました。別の題材を作るときも、URL、フォーム、保存、表示、リダイレクト、エスケープ、エラー応答の流れを同じように設計できます。この経験を、次の題材や次のフレームワークへ持っていってください。
確認しよう
app.rbのload_moviesとsave_moviesを読み、1 件の更新でもmovies.json全体を読み書きしていることを確認する。data/movies.jsonを開き、映画 1 件がどのようなハッシュとして保存されているかを確認する。- 映画にレビューを追加するとしたら、JSON のどこに入れるかを考える。すぐに実装しなくてよい。
考えてみよう
- JSON ファイル保存のまま、複数人が同時に映画を登録すると、どのような問題が起きそうでしょうか。
- 映画とレビューを別々のデータとして扱う場合、どの値で結びつける必要があるでしょうか。
- Rails で
movie.saveと書けるようになったとき、本書で書いたどの処理が見えにくくなるでしょうか。
この章の完成コード
この章では、完成コードに残す変更はありません。第10章までに完成した映画図鑑を使って、ファイル保存の限界と次の学習へのつながりを確認しました。
さらに学ぶ
- ◎ SQLite Appropriate Uses For SQLite: https://www.sqlite.org/whentouse.html
- ○ Rails Guides Active Record Basics: https://guides.rubyonrails.org/active_record_basics.html
SQLite の資料は、データベースにも用途や向き不向きがあることを知る入口として読むとよいでしょう。Rails Guides は、Rails に進んだときに Active Record がどのような役割を持つのかを知る入口です。
付録
付録は、本編を読む上では必須ではありません。
本編では、Sinatra を使って Web アプリケーションの基本を学ぶことに集中しました。ここでは、自分のアプリケーションを第三者に渡したり、公開したり、提出物として整えたりするときに役立つ内容をまとめます。
FBC の提出前には、FBC 側の案内に従って必要な付録も確認してください。
付録A README
README は、リポジトリを開いた人が最初に読む説明です。
アプリケーションが手元では動いていても、第三者が git clone したあとに起動できなければ、再現可能な状態とは言えません。README には、アプリケーションの概要と、起動までに必要な手順を書きます。
README に書くこと
小さな Sinatra アプリケーションなら、まず次の内容があれば十分です。
- アプリケーションの概要
- 必要な Ruby バージョン
- セットアップ手順
- 起動方法
- ブラウザで開く URL
- 利用している保存方法
例えば、映画図鑑なら次のように書けます。
# 映画図鑑
映画の情報を登録、表示、編集、削除できる Sinatra アプリケーションです。
## 必要なもの
- Ruby 4.0.6
- Bundler 4.0.16
## セットアップ
```sh
bundle install
```
## 起動方法
```sh
bundle exec ruby app.rb
```
起動後、ブラウザで <http://localhost:4567/> を開きます。
## データ保存
映画データは `data/movies.json` に保存します。
実際の README では、使っている Ruby や Bundler のバージョンを、自分のリポジトリに合わせて書きます。本書の映画図鑑では、.ruby-version、Gemfile、Gemfile.lock にバージョンを記録しています。
起動コマンドを一つにする
README では、起動方法を一つに統一します。
本書では、次のコマンドに統一しました。
bundle exec ruby app.rb
ruby app.rb、bundle exec ruby app.rb、rackup のように複数の起動方法を並べると、初めて読む人はどれを使えばよいか迷います。実際に確認した手順を一つ書く方が親切です。
bundle exec を付ける
Bundler を使うアプリケーションでは、README のコマンドにも bundle exec を付けます。
bundle exec ruby app.rb
bundle exec を付けると、Gemfile.lock に記録されたバージョンの gem を使ってコマンドを実行できます。手元の環境に別バージョンの gem が入っていても、README に書いた手順で再現しやすくなります。
README に書きすぎない
README は、本編の代わりにすべてを説明する場所ではありません。
例えば、PRG パターン、XSS 対策、method override の詳しい説明まで README に書く必要はありません。README には、第三者がアプリケーションの目的を理解し、起動して確認するために必要な情報を置きます。
確認しよう
- 新しいディレクトリへリポジトリを clone したつもりで、README の手順だけを読んで起動できるか確認する。
- 起動コマンドに
bundle execが付いているか確認する。 - 保存データの場所が
public/ではなくdata/であることを確認する。
付録B RuboCop
RuboCop は、Ruby のコードを静的に確認するためのツールです。
本編では、Web アプリケーションの仕組みを理解することを優先したため、RuboCop は扱いませんでした。自分のアプリケーションを提出物や公開物として整える段階で、Ruby コードの書き方を確認するために使います。
RuboCop で分かること
RuboCop は、例えば次のような点を指摘します。
- インデント
- 空行
- 使っていない変数
- 長すぎる行
- 条件分岐の書き方
- Ruby の一般的なスタイル
RuboCop は、アプリケーションが正しく動くことを保証するツールではありません。HTTP メソッド、PRG、XSS 対策、JSON 保存が正しいかは、本文で学んだように動作確認と Network タブでも確認します。
導入例
RuboCop を使う場合は、Gemfile に開発用の gem として追加します。
group :development do
gem "rubocop", require: false
end
追加したら、依存関係をインストールします。
bundle install
実行するときは、Bundler 経由で実行します。
bundle exec rubocop
指摘を読む
RuboCop の指摘は、機械的にすべて自動修正すればよいものではありません。
特に初学者のうちは、次の順で確認します。
- どのファイルの何行目を指摘しているか。
- 何が問題だと言っているか。
- 自分のコードをどう直すと読みやすくなるか。
- 直したあともアプリケーションが動くか。
自動修正を使う場合も、差分を確認します。
bundle exec rubocop -A
-A は強い自動修正です。予想より大きな変更が入ることがあります。実行後は、git diff で何が変わったかを必ず確認してください。
本文の学習とは役割が違う
RuboCop は、Ruby コードの形を整える助けになります。
一方で、次のようなことは RuboCop だけでは判断できません。
- POST 後にリダイレクトしているか。
PATCHとDELETEが method override で届いているか。- 利用者入力を表示時にエスケープしているか。
data/movies.jsonをpublic/に置いていないか。
コードを整えることと、Web アプリケーションとして正しく動くことは別の観点です。両方を確認します。
付録C ERB Lint
ERB Lint は、ERB テンプレートを確認するためのツールです。
本編では、ERB の構造を読みながら、layout.erb と各ビューの役割を分けて学びました。ERB Lint は、そのようなテンプレートの書き方を機械的に確認する補助として使えます。
ERB Lint で分かること
ERB Lint は、設定によって次のような点を確認できます。
- ERB の構文
- HTML の構造
- インデント
- 不要な空白
- テンプレート内の Ruby コードの書き方
ただし、ERB Lint もアプリケーションの動作を保証するものではありません。フォーム送信、リダイレクト、エスケープ、404 は、実際に動かして確認します。
導入例
ERB Lint を使う場合は、Gemfile に開発用の gem として追加します。
group :development do
gem "erb_lint", require: false
end
追加したら、依存関係をインストールします。
bundle install
実行例です。
bundle exec erblint views
設定ファイルを用意する場合は、利用するルールをプロジェクトに合わせて決めます。最初から厳しすぎる設定にすると、テンプレートの理解よりも指摘対応が中心になってしまうことがあります。
RuboCop との違い
RuboCop は Ruby ファイルを中心に確認します。
ERB Lint は、ERB テンプレートを確認します。
映画図鑑で言えば、app.rb は RuboCop、views/index.erb や views/show.erb は ERB Lint の対象になります。
よく見る観点
ERB を確認するときは、ツールを使う前に次の点も自分で見ます。
layout.erbだけにhtml、head、bodyがあるか。- 各ビューに、その画面固有の中身だけが書かれているか。
- フォームの
labelと入力欄が対応しているか。 - 利用者入力を表示する箇所で
hヘルパーを使っているか。 - 紹介文の改行表示を Ruby で
<br>に変換していないか。
ツールは、読めるテンプレートを書くための補助です。ツールの指摘を消すことだけを目的にしないでください。
付録D よくあるエラー
この付録では、初学者がつまずきやすい点をまとめます。
エラーが出たときは、まず第11章の考え方に戻ります。画面だけで判断せず、再現手順、Network タブ、Sinatra のログ、JSON ファイル、ERB のエラーメッセージを分けて確認します。
LoadError が出る
LoadError は、必要なファイルや gem を読み込めないときに出ます。
まず確認することは次のとおりです。
bundle installを実行したか。- コマンドに
bundle execを付けているか。 Gemfileに必要な gem が書かれているか。- ファイル名のつづりが合っているか。
本書の起動コマンドは次です。
bundle exec ruby app.rb
ルートが見つからない
存在しない URL にアクセスすると、404 が返ります。
例えば、/movie と /movies は別の URL です。ルート定義とブラウザの URL を見比べます。
get "/movies" do
@movies = load_movies
erb :index
end
/movies/:id のようなルートでは、:id に入る値が data/movies.json の id と一致しているかも確認します。
params のキーが想定と違う
フォームから送信されるキーは、フォーム部品の name 属性で決まります。
<input type="text" name="title">
この場合、Sinatra 側では params["title"] として受け取ります。
値が届かないときは、Network タブの Form Data と、フォーム部品の name を見比べます。id 属性や label の文字ではなく、name 属性を見るのがポイントです。
JSON ファイルが壊れた
data/movies.json は JSON として正しい形でなければ読み込めません。
例えば、カンマや引用符が一つ欠けただけでも壊れます。
[
{
"title": "月面喫茶"
}
]
手で直す場合は、先にコピーを取ります。原因を切り分けている途中で JSON を壊すと、元の問題と別の問題が混ざってしまいます。
404 と 500 を混同する
404 は、指定されたものが見つからないときのレスポンスです。
500 は、サーバー側で例外などが起きたときのレスポンスです。
404 のときは、URL、ルート定義、映画 ID、JSON 内の ID を見ます。500 のときは、ターミナルのエラーメッセージを読みます。ファイル名や行番号が出ていることがあります。
public/ に保存データを置いてしまう
public/ は、ブラウザから直接参照できる静的ファイルを置く場所です。
保存データを public/ に置くと、アプリケーションを通さずにブラウザから読めてしまいます。映画図鑑の保存データは、次の場所に置きます。
data/
movies.json
CSS は public/、保存データは data/ と分けます。
layout.erb と各 ERB の両方に html や body を書く
layout.erb は共通の外枠です。
各ビューには、その画面固有の中身だけを書きます。views/index.erb や views/show.erb に html、head、body を重ねて書くと、HTML の構造が崩れます。
POST 後に HTML を直接返してしまう
登録、更新、削除のあとに HTML を直接返すと、ブラウザの再読み込みで同じ処理が再送信される可能性があります。
本書では、登録、更新、削除のあとにリダイレクトしました。
POST /movies
↓
303 See Other
↓
GET /movies/:id
Network タブで、状態を変えるリクエストのあとに GET へ移っているか確認します。
Network タブを見ずに画面だけで判断する
画面に表示された内容は、結果の一部です。
Web アプリケーションでは、その裏で HTTP リクエストとレスポンスが発生しています。原因を探すときは、Network タブで URL、HTTP メソッド、ステータスコード、Form Data、リダイレクト前後のリクエストを確認します。
紹介文の改行を Ruby で <br> に変換する
紹介文の改行を表示したいからといって、利用者入力へ gsub("\n", "<br>") のような加工をするのは避けます。
表示のために HTML を作る処理と、利用者入力を安全に扱う処理が混ざってしまうためです。
映画図鑑では、CSS の white-space: pre-line; を使って改行を表示しました。
利用者入力をエスケープせず表示する
利用者が入力した値をそのまま HTML として出力すると、XSS の原因になります。
映画図鑑では、表示時に h ヘルパーを使いました。
<%= h(@movie["title"]) %>
入力値を信用するのではなく、出力先の HTML 文脈に合わせてエスケープします。
付録E さらに学ぶための資料案内
本編の各章では、必要な範囲だけを説明しました。
ここでは、本書の範囲を越えて学びたいときの入口をまとめます。すべてを一度に読む必要はありません。自分がいま知りたいことに合わせて参照してください。
Sinatra
- Sinatra 公式ドキュメント: https://sinatrarb.com/intro.html
Sinatra のルーティング、テンプレート、設定、ヘルパーなどを確認できます。本書では使わなかった機能も多く載っています。
Rack
- Rack 公式リポジトリ: https://github.com/rack/rack
- Rack: https://rack.github.io/
Rack は、Ruby の Web サーバーと Web アプリケーションをつなぐ共通の仕組みです。本書では、Sinatra と Web サーバーの間に Rack があること、method override に Rack が関係していることを扱いました。
HTTP
- MDN HTTP: https://developer.mozilla.org/ja/docs/Web/HTTP
- RFC 9110 HTTP Semantics: https://www.rfc-editor.org/info/rfc9110
HTTP メソッド、ステータスコード、ヘッダー、リダイレクトについて詳しく知りたいときに参照します。RFC は仕様書なので、最初から全部読む必要はありません。
ブラウザ開発者ツール
- MDN ブラウザ開発者ツール: https://developer.mozilla.org/ja/docs/Learn/Common_questions/Tools_and_setup/What_are_browser_developer_tools
Network タブ以外にも、HTML、CSS、コンソールなどを確認する機能があります。Firefox などのブラウザにも同じような開発者ツールがあります。
REST
- 『Webを支える技術』
本書では、REST の理論を深く説明せず、URL、HTTP メソッド、CRUD の対応を実装として扱いました。REST の背景や設計思想を学びたい場合は、専門の資料で学ぶとよいでしょう。
セキュリティ
- OWASP Cross Site Scripting Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
- OWASP Cross-Site Request Forgery Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
本書では XSS の基本だけを扱い、CSRF は扱いませんでした。Web セキュリティは範囲が広いため、公式に近い資料を参照しながら少しずつ学びます。
テスト
- Rack::Test: https://github.com/rack/rack-test
本編ではテストを扱いませんでした。Sinatra アプリケーションのリクエストとレスポンスをテストしたい場合、Rack::Test が入口になります。
CSV
本書では JSON を保存形式として使いました。CSV は表形式のデータと相性がよい形式です。データ構造によって向き不向きがあります。
データベース
- SQLite Appropriate Uses For SQLite: https://www.sqlite.org/whentouse.html
- Rails Guides Active Record Basics: https://guides.rubyonrails.org/active_record_basics.html
JSON ファイル保存の限界を感じたら、データベース設計へ進みます。Rails を学ぶときは、Active Record がデータベースとのやり取りをどのように扱うのかを意識すると、本書で学んだ保存処理とのつながりが見えやすくなります。
おわりに
本書を最後まで読んでいただき、ありがとうございました。
本書では、映画図鑑を少しずつ育てながら、Web アプリケーションの基本を学びました。ブラウザがリクエストを送り、Sinatra が Ruby の処理を選び、レスポンスを返す。最初は一つの文字列だったレスポンスが、ERB を使った画面になり、フォームから届いた値を JSON ファイルへ保存できるようになりました。さらに、一覧・詳細・登録・編集・削除、リダイレクト、安全な表示、404、問題の切り分けまでを一つずつ実装しました。
繰り返し確かめてきたのは、「ブラウザが何を送り、サーバーが何を返したのか」という往復です。Sinatra のメソッド名や細かな書き方を忘れることはあっても、URL、HTTP メソッド、ステータスコード、ヘッダー、本文を分けて見れば、動きを追い直せます。この見方は、Rails をはじめとする別のフレームワークでも役立ちます。
映画図鑑は、実用的なサービスと比べれば小さなアプリです。しかし、小さいからこそ、リクエストが届いてからレスポンスが返るまでを見渡せました。これからデータベース、認証、テスト、デプロイなどを学ぶときも、新しい仕組みがこの往復のどこに加わるのかを考えてみてください。
Sinatra も、Ruby も、本書で利用した多くの道具も、人が作り、改善を続けている OSS です。ドキュメントを書いた人、不具合を報告した人、コードを直した人など、多くの貢献によって私たちはそれらを利用できます。使い方に慣れたら、ソースコードや Issue を読んだり、気づいたことを報告したりすることも、OSS と関わる一つの方法です。
技術はこれからも変化します。バージョンが変わり、本書の画面や手順と異なる場面に出会ったときは、エラーメッセージや実際の通信を観察し、公式ドキュメントを確認してください。本書で身につけた「見える事実から順にたどる」という姿勢が、次の学習を進める助けになればうれしく思います。
それでは、次は自分の題材で、小さな Web アプリケーションを作ってみてください。
FjordBootCamp について
本書は、プログラミングスクール FjordBootCamp(フィヨルドブートキャンプ) の教材として作成されました。
FjordBootCamp は、現役のソフトウェアエンジニアが学習を支える、日本語のオンラインプログラミングスクールです。答えを覚えるだけではなく、自分で調べ、考え、解決へ進む力を育てることを大切にしています。受講生、卒業生、メンターが関わるコミュニティの中で学べることや、OSS コミュニティとのつながりを重視していることも特徴です。
Rails エンジニアコースとフロントエンドエンジニアコースがあり、どちらも最終的には自分で考えた Web サービスのリリースを目指します。仲間やメンターと一緒に、Web 開発をさらに体系的に学びたい方は、公式サイトをご覧ください。
主要な一次情報
本書を読み終えた後も、分からないことや変化したことを調べる入口として利用できます。
ライセンス
本書の本文・原稿は、MIT License で公開されています。
Copyright (c) 2026 FjordBootCamp