はじめに
地域の窓口では、小規模な落とし物コレクションを管理しています。準備済みのWebページはアイテムを表示できますが、サンプルAPIは1件のレコードしか返しません。実際のGoレコードを一覧と詳細のレスポンスにつなぎ、利用者が正しい受け取り場所を見つけられるようにします。
Goの関数、構造体、スライス、マップ、モジュール、および基本的なターミナルのパス操作を理解していることが前提です。HTTP、JSON、ハンドラーのテストはここで説明します。ブラウザー環境にはGoと完成済みのページが用意されています。この単元は独立して開始し、別のラボのファイルは不要です。読解や通常の間違いへの対応を含めて約55分を見込んでください。これは著者による見積もりです。
一覧を保ちながらアイテムを1件選ぶ
このステップでは、カード1枚のコレクションが動く状態を保ちながら、実際のアイテムの詳細を追加します。Workspaceタブから始めてください。シェルは/home/labex/project/lost-foundを開きます。後から戻った場合は、次のcdでこの場所に戻れます。
Goは/usr/local/go/binにインストールされています。exportは、既存の$PATHを保持しながら、このディレクトリを現在のシェルのコマンド検索パスに追加します。これは現在のターミナルセッションに適用されるため、新しいシェルでは繰り返してください。グローバルなシェル設定は変更しません。
cd /home/labex/project/lost-found
export PATH=/usr/local/go/bin:$PATH
curlはリクエストを送信します。-iはレスポンスヘッダーを含め、単一引用符はURLをひとまとまりに保ちます。GETはリソースの読み取りを要求します。/api/itemsはコレクションを表し、127.0.0.1:8080は同じマシン上で動くサービスです。まず通常のサンプルを使います。
curl -i 'http://127.0.0.1:8080/api/items'
200 OK、Content-Type: application/jsonヘッダー、および図書館にある青い傘LF-101を含む配列が返るはずです。HTTPステータスはリクエストの結果を示し、本文はデータを運びます。JSONでは配列に角括弧、オブジェクトに波括弧を使います。200だけでは、完全なコレクションがある証拠にはなりません。Lost and Foundに切り替えてカード1枚を確認し、Workspaceに戻ります。
catはファイルを編集せずに表示します。ソースレコード、入力の構造体、小さな初期ハンドラーを読みます。
cat items.json
cat main.go
cat handler.go
レコードは6件あります。ItemはID、Name、Category、Locationをまとめています。大文字で始まるエクスポート済みのフィールドはエンコードできます。`json:"id"`のようなタグは小文字のJSONキーを指定します。main.goはコレクションを読み込み、準備済みのページを配信し、アイテムのスライスをcatalogHandlerに渡します。main.go、go.mod、items.json、serve.py、web/を保持し、編集するのはhandler.goと追加するGoファイルやテストだけにしてください。
コードを変える前に、LF-101とLF-103へのリクエストの違いを予測します。名前はどちらもBlue umbrellaですが、場所はLibrary deskとCafe counterです。どのフィールドならレコードを1件だけ選べるでしょうか。2番目のIDを要求します。
curl -i 'http://127.0.0.1:8080/api/items/LF-103'
初期ハンドラーは、まだ最初のレコードの配列を返します。レスポンスは届いていますが、正しい詳細ではありません。現在の一覧レスポンスを利用できるままにして、IDで選んだオブジェクトを返すのが課題です。どのパスにも最初のオブジェクトを返す方法で済ませないでください。
ハンドラーは、リクエストごとに呼び出されるGoコードです。r *http.Requestは受信リクエストを指します。w http.ResponseWriterは書き込み先で、Headerでヘッダーを設定し、書き込みで本文を送ります。http.Handlerはこの振る舞いを表し、http.HandlerFuncは関数をそのインターフェースに適合させます。返される無名関数は渡されたitemsを記憶しています。HTTPパーサーを書く必要はありません。
編集前にコピーを保存します。cpはファイルをコピーします。拡張子を意図的に.backupにしているのは、.goだとGoがパッケージをコンパイルするときに定義が重複するためです。
cp handler.go handler.go.backup
ターミナルエディタで実際のファイルを開きます。コマンドだけに適用するLC_ALL=C.UTF-8により、nanoがアクセント付きの文字も含めてUTF-8として解釈します。このエディタの起動だけに作用し、グローバルなシェル設定には影響しません。
LC_ALL=C.UTF-8 nano handler.go
nanoでCtrlは、Controlを押しながら指定のキーを押す意味です。矢印キーで移動します。Ctrl+Wを押し、catalogHandlerを入力してEnterを押すと関数が見つかります。途中の検索はCtrl+Cで取り消せます。通常の入力、Backspace、Deleteで編集します。Ctrl+Kは現在の行を切り取るので、置き換えるつもりの行だけで使ってください。画面下のショートカットの^はControlを表します。既存のハンドラーを編集しており、2つ目のプログラムを作るわけではありません。
返される関数内で、既存のContent-Typeヘッダーの後に次の判断を実装します。分岐を1つずつ作ってください。
まずr.URL.Pathを読みます。/api/itemsと等しい場合、初期のitems[:1]を配列としてエンコードする処理を保ち、すぐにreturnします。これにより、詳細を作る間も通常のカード1枚の一覧を保てます。
次に、それ以外の場合は/api/items/プレフィックスを認識します。既存のインポートの横に標準ライブラリのstringsを追加してください。strings.HasPrefix(path, prefix)はプレフィックスを確認し、strings.TrimPrefix(path, prefix)はそれを取り除きます。残ったIDをローカル変数に保存します。
その後、for _, item := range itemsで渡されたスライスをたどります。名前ではなくitem.IDと要求されたIDを比較します。一致したら、そのitemオブジェクトをエンコードしてすぐにreturnします。json.NewEncoder(w).Encode(value)は選んだGoの値をJSONとして書きます。スライスは配列、構造体1件はオブジェクトになります。ループはitems[0]だけでなく、すべてのレコードを調べる必要があります。
どのIDにも一致しない場合や、パスがそれらのルート外である場合は、JSONエラーを書く前に404を設定します。w.WriteHeader(http.StatusNotFound)でステータスを設定します。JSONで扱える小さなエラー値にはmap[string]string{"error": "item not found"}があります。error文字列は空でないことが必要です。このマップをエンコードしてください。エラーの後に別の一覧を書いてはいけません。
レスポンスの最初の書き込みで通常は200が確定するため、異なるステータスはエンコード前に設定します。Encodeはerrorを返し、nilは失敗がないことを示します。この例ではすべての値がJSONで扱える文字列です。それでもクライアントが切断すると書き込みは失敗し得ます。本文の送信開始後に2つ目のレスポンスを送っても接続は直せません。早期のreturnは2つの回答の追記も防ぎます。
Ctrl+Oで保存し、既存のファイル名handler.goをEnterで確認して、Ctrl+Xで終了します。保存したファイルを確認します。
cat handler.go
違うファイルを開いた場合は保存せず終了します。Ctrl+Xを押し、保存を聞かれたらNを押します。pwdで場所を調べ、最初のcdで戻って正しいファイル名を開き直してください。保存の確認で意図しない名前が出たら、Ctrl+Cで取り消せます。編集ミスを補うためにソースデータを上書きしないでください。
gofmt -wは指定したGoファイルを直接整形します。準備済みのserve.pyは、コードをビルドしてから、この作業環境で記録したサーバーだけを置き換えます。ハンドラーの実装は提供しません。ビルドエラー時は以前の動作するプロセスが残るため、古いブラウザーレスポンスは新コードがコンパイルできた証拠にはなりません。
gofmt -w handler.go
python3 serve.py
Lost and Found ready on port 8080が表示されるはずです。整形やコンパイルに失敗したら、ファイル名と行番号を読みます。nano handler.goを開き直し、Ctrl+Wで近くの式を探し、その部分だけを修正して保存・終了し、両コマンドを繰り返します。動作するロジックは残し、後の進捗を初期バックアップで上書きしないでください。No such fileが出たら最初のcdで戻ります。サービスの診断情報は.work/server.logにあり、cat .work/server.logで読めます。入力ミスはVMをリセットする理由ではありません。
curl -i 'http://127.0.0.1:8080/api/items/LF-103'
配列や図書館の傘ではなく、idがLF-103、locationがCafe counterのオブジェクト1件を期待します。通常の一覧が引き続きサンプル配列を返すことも確認します。
curl -i 'http://127.0.0.1:8080/api/items'
動作するページのコレクション形式を保ちながら、選択処理を追加できました。次のステップで、意図的に一覧を全レコードへ広げます。
完全なコレクションを返す
このステップでは、作成した詳細を失わずに、1件の一覧を完全な読み取り専用カタログに変えます。Workspaceの/home/labex/project/lost-foundから始めてください。
編集前にコレクション分岐のスライス式を読みます。items[:1]はインデックス1より前までの先頭部分を選ぶので、残り5件を隠しています。エンコーダーにitemsそのものを渡したら何が返るか予測してください。6件をハードコードした配列と違い、この引数は別のスライスが渡されても動きます。
LC_ALL=C.UTF-8 nano handler.go
Ctrl+WとEnterでitems[:1]を探します。コレクションでエンコードする値だけを、入力スライス全体へ変えます。JSONヘッダー、詳細のループ、存在しないIDのエラー、早期のreturnを保ってください。Ctrl+OとEnterで同じファイル名に保存し、Ctrl+Xで終了します。
空のコレクションは[]にエンコードし、nullにはしません。空でnilではないスライスは[]、nilのスライスはnullになります。この単元の独立した空の入力は[]Item{}です。空の結果をアイテム不在のエラーに置き換えないでください。一覧と詳細は意味が違います。配列の順序とオブジェクトのキー順は自由ですが、各IDに属する名前、カテゴリー、場所の対応は保つ必要があります。
gofmt -w handler.go
python3 serve.py
コレクションを要求し、次に別々のIDで両方の傘を要求します。
curl -i 'http://127.0.0.1:8080/api/items'
curl -i 'http://127.0.0.1:8080/api/items/LF-101'
curl -i 'http://127.0.0.1:8080/api/items/LF-103'
配列に6件すべての完全なレコードがあるはずです。名前はどちらもBlue umbrellaですが、LF-101は引き続きLibrary desk、LF-103はCafe counterに属します。どちらかの詳細が入れ替わったら、IDの比較と、ループのどの値をエンコードしているかを調べます。
次のリクエストは、意図的に存在しないIDを使います。空のリストではなく、HTTP 404と空でないerror文字列を含むJSONオブジェクトを期待します。curlは通常、HTTPエラーレスポンスを受信しても正常終了します。レスポンスを受け取ることと、接続できないことは異なります。
curl -i 'http://127.0.0.1:8080/api/items/no-such-id'
このハンドラーには、まだフィルタリングもHTTPメソッドの制限もありません。それらは次のガイド付きラボで導入します。通常のホームページや任意の200を、詳細エンドポイントが動く証拠にしないでください。コレクションの範囲を変えながら、一覧と詳細を一緒に保つことができました。
識別情報を区別できるテストを書く
このステップでは、2つ目の実サーバーを動かさずに、自分で焦点を絞ったハンドラーテストを設計し、その後実際のページを使います。同じディレクトリのWorkspaceから始めてください。
まず、テストで見つけるべき誤りを考えます。ハンドラーは要求されたIDではなく、最初に名前が一致したアイテムを返してしまうかもしれません。Webページのコレクションとは別に、同名でIDと場所が異なる小さなレコード2件を自分で選びます。2番目のIDを要求します。返るべきステータス、ID、場所を書き出してください。この独立したfixtureにより、実サービスの固定レスポンスでは失敗し、準備済みの6件以外でも役立つテストになります。
Goは_test.goで終わるファイルからテストを見つけます。テスト関数はTestで始まり、t *testing.Tを受け取ります。nanoでテストファイルを作ります。
LC_ALL=C.UTF-8 nano handler_test.go
package mainから始めます。encoding/json、net/http/httptest、testingをインポートします。たとえばfunc TestSecondIdentity(t *testing.T) { ... }という関数を追加し、省略部分をテストの文に置き換えます。本体は4つの部分からなります。
まず、main.goのエクスポート済みフィールド名を使い、2レコードの[]Itemのfixtureを作ります。items.jsonを読み込んだり変更したりせず、このfixtureをcatalogHandlerへ渡します。
次に、2番目のレコードの詳細リクエストを作ります。httptest.NewRequest("GET", path, nil)はリクエストを作り、最後の引数はリクエスト本文がないことを意味します。httptest.NewRecorder()はレスポンスレコーダーを返します。ハンドラーのServeHTTP(recorder, request)を呼ぶと、実際のハンドラーを実行し、ヘッダー、ステータス、本文を取得できます。
その後、var got Itemを宣言し、json.Unmarshal(recorder.Body.Bytes(), &got)でデコードします。&gotはフィールドの保存先アドレスです。返されたerrorを先に調べます。不正なJSONを、有効な空の構造体と取り違えてはいけません。たとえばif err != nil { t.Fatal(err) }は、読みやすい失敗を表示してテストを止めます。
続いて、期待するステータスと識別情報を検証します。recorder.Codeにステータス、got.IDとgot.Locationにデコードしたフィールドがあります。fixtureの2番目のレコードと比較します。ifとt.Fatalfで実際の値を表示して失敗させてください。%dは数値、%+vは構造体のフィールドを表示します。単に何らかのJSONがあることだけを検証してはいけません。
自分で選んだIDと場所を、リクエストと検証で一貫して使います。fixture、実行、検証を同じテスト関数にまとめ、推論を追いやすくしてください。Ctrl+OとEnterで保存し、Ctrl+Xで終了します。環境は合格するテストを代わりに用意してくれません。
go test -v .はこのパッケージのテストを実行します。-vはテスト名を表示し、.は現在のパッケージを選択します。
gofmt -w handler_test.go
go test -v .
自分のテスト名に続いてPASSが表示されるはずです。失敗時には実際のステータスやアイテムが表示されるようにします。失敗したら、fixtureに期待する関係とハンドラーのどちらが誤っているか判断し、nanoでその部分だけを直して再実行します。コンパイルエラーは確認するファイルと行を示します。ハンドラーの修正後はpython3 serve.pyで再ビルドし、ブラウザーにも現在のコードを使わせてください。この作業環境に合格するGoハンドラーテストを少なくとも1つ残します。最終チェックはテストを独立して実行し、別の入力レコードでもAPIを確認します。
Lost and Foundに切り替えて再読み込みします。6 items foundを期待します。LF-103のView itemを開いて参照番号と受け取り場所を読み、Back to collectionを選びます。2本の傘は区別できるままにします。この単元はコレクションと詳細の閲覧を提供し、次のガイド付きラボで検索コントロールとAPIのフィルタリングを追加します。

6枚のカードとそれぞれの参照番号を確認してください。ページは稼働中のAPIを使い、予備のコレクションは持ちません。修正が必要ならWorkspaceへ戻ります。実用的なカタログと、実際の識別ミスを発見するために自分でfixtureと検証を選んだテストができました。
まとめ
GoレコードをJSONにエンコードし、パスに応じて一覧と詳細のレスポンスを選び、同名のアイテムをそれぞれのIDと対応付けたまま、存在しないアイテムに実際のエラーステータスを返しました。また、独立したhttptestテストを作成し、稼働中のWebページを使用しました。検索とより厳密なエラー処理は、次のガイド付きラボで独立して取り組みます。



