公式Go RESTful APIチュートリアルのコードを改善する
原文は Ben Hoyt により に公開されました。 このブログを購読する
要約:本記事では、公式Goチュートリアル「Developing a RESTful API with Go and Gin」のコードを私なりに再実装した内容を紹介します。私のバージョンではいくつかの機能追加、不具合の修正、テストの追加を行い、Goの標準ライブラリのみを使用しています。
最近、新しいTutorial: Developing a RESTful API with Go and Ginを読みました。Goの他の優れたドキュメントと比べると、いくつか品質面で気になる点があり、公式ドキュメントが標準ライブラリを推奨するのではなくサードパーティライブラリ(Gin)を使っているのも意外でした。
そこで、標準ライブラリだけでコードを書き直し、ロックがなかったことによる並行性の問題を修正することにしました。また、入力のバリデーションやより適切なエラー処理など、実際のWebサービスなら当然備えているべき機能も追加しています。
書き直した後、Goの開発メーリングリストであるgolang-devでこれらの問題について質問したところ、GoのテクニカルリードであるRuss Cox氏から次のような返信がありました。
これは、Goのサードパーティパッケージのエコシステムを活用するチュートリアルをいくつか計画しているうちの最初のものです。広く使われており、一般的なユースケースを簡素化するパッケージを紹介することが目的でした。
なるほど、特にGo modulesが使える今となっては理にかなっています。私が見つけた具体的な問題点を伝えたところ、チュートリアルは現状のままにしておきたいとのことでした。彼の返信は参考になるものでした。
これらはすべて、この特定のチュートリアルの範囲外だと思います。実際のシステムではそもそもインメモリデータベースを使わないので、インメモリデータベースに対するロックがないことは大きな問題ではないでしょう。アルバムのバリデーションなどについても同様です。チュートリアルの目的は、特定のアイデア、この場合はRESTfulなJSONベースのAPIを説明することに焦点を絞り、短くまとめることです。実際のシステムに存在するであろう入力バリデーション、認証、その他の複雑な要素は意図的に省いています。あなたが指摘していることはいずれも指摘として妥当ですし、ブログ記事を書く時間をとってくれたことに感謝しますが、この特定のチュートリアルに追加すると焦点がぼやけてしまいます。
それはそれで妥当な考え方でしょう。しかし、チュートリアルにバグが含まれているのはやはり良くないと思います。並行性の問題を修正するか、少なくとも簡略化のためだと明記してほしいところです。多くの初心者がサンプルコードをコピーして学ぶ中で、重要な詳細をあいまいにすることはリスクがあります。そうしたコードでは、より良い前例を示すべきだと思います。
以下では、私のバージョンで加えた改善点について説明します。完全なソースコードはGitHubのbenhoyt/web-service-stdlibで公開しています。
改善点
以下は、元のコードで見つけて私のバージョンで変更・改善した点です(詳細なセクションへのリンク付き):
- 標準ライブラリ 前述のとおり、オリジナルはGinウェブフレームワークを使用しています。私のバージョンでは標準ライブラリのパッケージのみを使用しています。
- バリデーション オリジナルでは「新しいアルバムを作成」する際の入力バリデーションが(JSONであることの確認以外)一切行われておらず、IDが空、価格がマイナスといったアルバムでも簡単に追加できてしまいます。私のバージョンでは基本的なバリデーションを追加し、解析可能なバリデーションエラーをクライアントに返すようにしています。
- アルバムIDの一意性 既存のコードでは同じIDのアルバムを重複して追加できてしまい、
/albums/:idエンドポイントは最初に見つかったものを返します。これは問題です。おそらくPUT /albums/:idで指定IDのアルバムを更新するか、POST /albumsを使いつつ重複時にエラーを返すかすべきでしょう。私は後者のアプローチを採用しました。 - 並行性 オリジナルのコードではグローバルな
albumsスライスがロックなしで読み書きされるため、Webサービスに並行してアクセスできず、試みるとpanicが発生します。これはあくまでサンプルであり、実際のデータベースではこの問題は起きないことは理解していますが、シンプルなmutexロックを追加するだけで安全にすることは容易です。私のバージョンではロックを追加し、競合がないことを保証するテストも追加しています。 - 通貨の10進数表現 アルバムの
Priceフィールドはfloat64型です。通貨の値にバイナリ浮動小数点を使うのは適切ではありません。バイナリ浮動小数点では10進数の端数(セント)を正確に表現できず、計算時に丸め誤差が蓄積する可能性があるからです。Priceフィールドを整数のセント(固定小数点)に変更しました。 - JSONでのエラー返却 GinルーターのデフォルトのNot Foundエラーは
Content-Type: text/plainを返すため、エラーがテキストで返されます。一方、getAlbumByIDで明示的に返すNot FoundはJSONを返します。同様に、GinのBindJSONは不正な入力を受け取ってもJSON形式のエラーを返しません。私のバージョンではすべてのエラーをJSONで返すようにしています。 - Method Not Allowedの処理 Gin(少なくともチュートリアルのデフォルト設定では)は、URLは正しいがメソッドが見つからない場合に404 Not Foundではなく405 Method Not Allowedを返すべきところを404を返します。そうした場合に標準的な405ステータスを返すように修正しました。
- テスト オリジナルにはテストがありません。チュートリアルなのでそれは当然です。しかし、Goの
httptestライブラリを使えばHTTPハンドラのテストは非常に簡単で、エラーケースを含むすべての機能についてテストを追加しています。 - データベースインターフェース データベースのメソッド(
ErrDoesNotExistのような定義済みエラーを返せるもの)のための明示的なインターフェースを用意し、オリジナルと同様のインメモリ実装を組み合わせています。 - 関心の分離 オリジナルでは「データベース」のコードがハンドラのコードと混在していました。データベースインターフェースを導入したことで、私のバージョンではデータベースのコードがHTTPハンドラのコードから完全に分離され、エラー処理のテストや、必要になった際の本番データベースへの置き換えが容易になっています。
私のバージョンはコード量がかなり増えています(テストコード約300行に加え、本体も50行程度から約300行になっています)が、これは主に追加機能によるものです。より堅牢で保守しやすいコードを示せていると思います。
以下では、これらの各点についてもう少し詳しく見ていきます。
標準ライブラリ
GinはURLルーティング(URLパラメータを含む)と、JSONのマーシャル用の関数を2つ提供してくれます。私のバージョンでは、シンプルなルーティングコードを自作し、カスタムのJSONヘルパーを2つ追加しました。
以前、GoにおけるHTTPルーティングのさまざまなアプローチについて詳しく書きましたが、ここではルートが非常にシンプルなので、正規表現によるswitchアプローチを簡略化したものを使い、/albums/:idルートを正規表現で解析しています。そのため、標準ライブラリのhttp.ServeMuxすら必要ありません。
/albums/:idルートは正規表現なしでも比較的簡単に実装できますが、正規表現を使うことで、IDが1文字以上でスラッシュを含まないことを検証するといったエッジケースの処理が少しシンプルになります。
私のコードではHTTPメソッドも処理しており、適切に405 Method Not Allowedを返します。以下がルーティングコードの全体です。
// Regex to match "/albums/:id" (id must be one or more non-slash chars).
var reAlbumsID = regexp.MustCompile(`^/albums/([^/]+)$`)
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
s.log.Printf("%s %s", r.Method, path)
var id string
switch {
case path == "/albums":
switch r.Method {
case "GET":
s.getAlbums(w, r)
case "POST":
s.addAlbum(w, r)
default:
w.Header().Set("Allow", "GET, POST")
s.jsonError(w, http.StatusMethodNotAllowed, ErrorMethodNotAllowed, nil)
}
case match(path, reAlbumsID, &id):
switch r.Method {
case "GET":
s.getAlbumByID(w, r, id)
default:
w.Header().Set("Allow", "GET")
s.jsonError(w, http.StatusMethodNotAllowed, ErrorMethodNotAllowed, nil)
}
default:
s.jsonError(w, http.StatusNotFound, ErrorNotFound, nil)
}
}少々冗長ですが、非常に明確で明示的であり、サードパーティのルーターを設定してエラーをJSONで返したり405を正しく返したりする手間を省けます。
Ginがコードを短縮していたもう一つの部分は、IndentedJSONとBindJSONヘルパーで、それぞれJSONのマーシャルとアンマーシャルを行います。幸い、標準のencoding/jsonパッケージだけでJSONの扱いは非常に簡単です。エラー処理を行うための小さなヘルパー関数を2つ書きました。
// writeJSON marshals v to JSON and writes it to the response, handling
// errors as appropriate. It also sets the Content-Type header to
// "application/json".
func (s *Server) writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
s.log.Printf("error marshaling JSON: %v", err)
http.Error(w, `{"error":"`+ErrorInternal+`"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(status)
_, err = w.Write(b)
if err != nil {
// Very unlikely to happen, but log any error (not much more we can do)
s.log.Printf("error writing JSON: %v", err)
}
}
// readJSON reads the request body and unmarshals it from JSON, handling
// errors as appropriate. It returns true on success; the caller should
// return from the handler early if it returns false.
func (s *Server) readJSON(w http.ResponseWriter, r *http.Request, v interface{}) bool {
b, err := io.ReadAll(r.Body)
if err != nil {
s.log.Printf("error reading JSON body: %v", err)
s.jsonError(w, http.StatusInternalServerError, ErrorInternal, nil)
return false
}
err = json.Unmarshal(b, v)
if err != nil {
data := map[string]interface{}{"message": err.Error()}
s.jsonError(w, http.StatusBadRequest, ErrorMalformedJSON, data)
return false
}
return true
}レスポンスに直接ストリーミングするためにjson.Encoderを使うこともできました。しかし、エラー処理が少し厄介です。JSONのマーシャルでエラーが発生し、Encoder.Encodeがすでにレスポンスに何かを書き込んでしまっていると、200以外のHTTPステータスを返せなくなります。Albumのようなシンプルな構造体ではエラーは起きにくい(あり得ない?)ですが、一般的なケースではJSONエンコードはエラーを返す可能性があるため、まず構造体を[]byteにマーシャルしています。
同様に、アンマーシャルではjson.Decoderを使ってリクエストボディから直接読み取ることもできますが、あれは本来ストリーム用に設計されたものです。
Internal Server Errorではerrの値をログに記録している点に注目してください。レスポンスに含めると機密情報(あるいは単に情報過多)になり得るため、レスポンスに含めるのではなくログに残しています。
バリデーション
Webセキュリティ、あるいはソフトウェア全般における最初の原則の一つは、ユーザー入力を常に検証することです。バリデーションがなければ、IDなし、タイトルやアーティスト名なし、あるいはマイナスや途方もなく大きな価格のアルバムでも追加できてしまいます。
アルバム追加のエンドポイントに数行のバリデーションコードを追加し、クライアントが分かりやすいエラーメッセージを表示できるよう、構造化された形でバリデーションエラーを返すようにしました。以下がバリデーションコードの全体です。
// Validate the input and build a map of validation issues
type validationIssue struct {
Error string `json:"error"`
Message string `json:"message,omitempty"`
}
issues := make(map[string]interface{})
if album.ID == "" {
issues["id"] = validationIssue{"required", ""}
}
if album.Title == "" {
issues["title"] = validationIssue{"required", ""}
}
if album.Artist == "" {
issues["artist"] = validationIssue{"required", ""}
}
if album.Price < 0 || album.Price >= 100000 {
issues["price"] = validationIssue{"out-of-range",
"price must be between 0 and $1000"}
}
if len(issues) > 0 {
s.jsonError(w, http.StatusBadRequest, ErrorValidation, issues)
return
}このケースでは価格が0の場合を許容しています。0は「価格なし」、つまり無料や該当なし(例えば自宅のカタログなど)を意味すると考えたためです。
ドメイン固有言語を持つフレームワークなどは必要ありません。必要なことをチェックするためのシンプルなif文で十分です。フィールド名をキーとする問題点のマップを構築し、バリデーションの問題があればそれをJSONエラーとして呼び出し元に返します。バリデーションエラー時のレスポンスは次のようになります。
$ curl http://localhost:8080/albums -d '{"price":-1}'
{
"status": 400,
"error": "validation",
"data": {
"artist": {
"error": "required"
},
"id": {
"error": "required"
},
"price": {
"error": "out-of-range",
"message": "price must be between 0 and $1000"
},
"title": {
"error": "required"
}
}
}より大規模なWebサービスであれば、これをもう少し標準化し、適宜構造体にValidate() map[string]ValidationIssueのようなメソッドを追加するでしょう。ただ、同じ構造体でもコンテキストによって異なるバリデーションが必要な場合もあるので、このシンプルなアプローチがちょうど良いのかもしれません。
アルバムIDの一意性
前述のとおり、オリジナルのコードでは重複するIDでアルバムを追加してもエラーが返りません。例えば次のようになります。
$ curl http://localhost:8080/albums -d '{"id":"foo"}'
...
$ curl http://localhost:8080/albums -d '{"id":"foo"}'
...
$ curl http://localhost:8080/albums
[
...
{
"id": "foo",
"title": "",
"artist": "",
"price": 0
},
{
"id": "foo",
"title": "",
"artist": "",
"price": 0
}
]「データベース」が既に存在するIDを拒否するように修正しました。AddAlbumデータベースメソッドはこの場合ErrAlreadyExistsを返し、ハンドラ側ではそのエラーをチェックして409 Conflictで応答します。
// Database method:
func (d *MemoryDatabase) AddAlbum(album Album) error {
d.lock.Lock()
defer d.lock.Unlock()
if _, ok := d.albums[album.ID]; ok {
return ErrAlreadyExists
}
d.albums[album.ID] = album
return nil
}
// Handler error checking:
func (s *Server) addAlbum(w http.ResponseWriter, r *http.Request) {
// ... JSON parsing and validation ...
err := s.db.AddAlbum(album)
if errors.Is(err, ErrAlreadyExists) {
s.jsonError(w, http.StatusConflict, ErrorAlreadyExists, nil)
return
} else if err != nil {
s.log.Printf("error adding album ID %q: %v", album.ID, err)
s.jsonError(w, http.StatusInternalServerError, ErrorDatabase, nil)
return
}
s.writeJSON(w, http.StatusCreated, album)
}追記:コメントで指摘されたように、ユーザーがIDを設定するのではなく、データベース側で一意のアルバムIDを生成する方がさらに良いでしょう。
並行性
オリジナルのコードでは、誰かがアルバムをPOSTしている最中にGETエンドポイントにアクセスするとデータ競合が発生します。もちろんSQLデータベースを使えば、そうしたデータベース自体が並行性に対して安全なためこの問題は解決します。しかし、インメモリ構造にアクセスする際にmutexロックを追加するのは難しくありません。
ここではアルバムは追加されるよりも閲覧される頻度の方がはるかに高いはずなので、sync.RWMutexを使っています。読み取りの周りではRLock/RUnlockを、書き込みの周りではLock/Unlockを追加しました。
さらに興味深いことに、ロックがない場合にGoのrace detectorで失敗するテストを追加しました。確認するには、ロックとアンロックの呼び出しをコメントアウトしてgo test -raceを実行してみてください。
このテストでは多数のgoroutineを起動し、それぞれが読み取りと書き込みを含む3つのエンドポイントすべてを叩きます。
func TestConcurrentRequests(t *testing.T) {
server := newTestServer()
for i := 0; i < 100; i++ {
go func(i int) {
result := serve(t, server, newRequest(t, "GET", "/albums", nil))
ensureStatus(t, result, http.StatusOK)
albumID := "c" + strconv.Itoa(i)
body := `{"id": "` + albumID + `", "title": "T", "artist": "A"}`
result = serve(t, server, newRequest(t, "POST", "/albums", strings.NewReader(body)))
ensureStatus(t, result, http.StatusCreated)
result = serve(t, server, newRequest(t, "GET", "/albums/"+albumID, nil))
ensureStatus(t, result, http.StatusOK)
}(i)
}
}通貨の10進数表現
通貨の値を保存・操作するのにバイナリ浮動小数点を使うのは一般的に良くない考えです。10進数の端数(セント)を正確に保存できず、それらの値に対して演算を行うと誤差が蓄積するからです。
これを修正するため、アルバムのPriceフィールドをfloat64からintに変更し、整数のセントとして正確に保存できるようにしました。これは通貨の値を正確に保存する一般的な方法の一つです。別の方法としては、shopspring/decimalのような10進数演算ライブラリを使う方法もあります。
JSONでのエラー
WebサービスがNot Foundのようなエラーでも常にJSONを返す方が、APIクライアントにとっては扱いやすくなります。クライアントは常にレスポンスをJSONとしてデコードする単一のコードパスを持てるからです。
私のバージョンでは、前述のwriteJSONヘルパーを呼び出す小さなjsonErrorヘルパー関数を使って、常にエラーをJSONで返すようにしています。
// jsonError writes a structured error as JSON to the response, with
// optional structured data in the "data" field.
func (s *Server) jsonError(w http.ResponseWriter, status int,
error string, data map[string]interface{}) {
response := struct {
Status int `json:"status"`
Error string `json:"error"`
Data map[string]interface{} `json:"data,omitempty"`
}{
Status: status,
Error: error,
Data: data,
}
s.writeJSON(w, status, response)
}通常「data」フィールドは空ですが、Bad Requestエラーの場合は呼び出し元に何が間違っていたのかをもう少し詳しく伝えると役立ちます(例えば上記で示したバリデーションコードのように)。
Errorフィールドは、ErrorValidationのようなJSONエラーコード用に定義された定数の一つです。
Method Not Allowedの処理
非常に単純なことですが、Gin(チュートリアルのコードで使われているデフォルト設定では)は、URLは正しいのにメソッドが見つからない場合に405 Method Not Allowedではなく404 Not Foundを返します。
ルーティングコードで示したように、このような場合にHTTP 405ステータスを返すように変更しました。
テスト
サーバーのテストを多数追加しました。これらのテストではすべてのエンドポイントに加え、エラー時の挙動やバリデーションの問題なども検証しています。
テストカバレッジ(go test -coverprofileによる)は、最小限のmain関数と、実際にはほとんど発生しないwriteJSONのエラー処理の一部を除くすべてのコードをテストできていることを示しています。一般的に100%のテストカバレッジを目指すのは現実的な目標ではないと思いますが、ここではこれほど簡単に広範囲をカバーできたのは良かったです。
テストはすべて同じ基本パターンに従っています。テスト用サーバーを作成し、httptest.ResponseRecorderに対して1つ以上のリクエストを実行し、レスポンスが正しいか(ステータスコードとJSONデータ)を検証します。
新しいリクエストの作成、単一リクエストの実行、JSONレスポンスのアンマーシャルなどを行うためのテストヘルパーをいくつか実装しました(T.Helperでマークしています)。いずれも数行程度ですが、テストのボイラープレートを大幅に削減してくれます。
以下にテストの例と、ensureStatusヘルパーを示します。
func TestGetAlbums(t *testing.T) {
server := newTestServer()
result := serve(t, server, newRequest(t, "GET", "/albums", nil))
ensureStatus(t, result, http.StatusOK)
var got []testAlbum
unmarshalResponse(t, result, &got)
want := []testAlbum{
{ID: "a1", Title: "9th Symphony", Artist: "Beethoven", Price: 795},
{ID: "a2", Title: "Hey Jude", Artist: "The Beatles", Price: 2000},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("bad response: got vs want:\n%#v\n%#v", got, want)
}
}
func ensureStatus(t *testing.T, response *http.Response, want int) {
t.Helper()
if response.StatusCode != want {
t.Fatalf("bad status code: got %d, want %d", response.StatusCode, want)
}
}サーバーが使っているMemoryDatabase実装については、個別にテストしていないことに注意してください。代わりに、その機能はサーバー全体のテストの一部として検証しています。可能な場合は、インメモリのフェイクを使い、「モック」呼び出しを記録する面倒を避ける方が、シンプルで壊れにくいテストの書き方です。
これらのテストにおけるその他の興味深い点は次のとおりです。
- テーブル駆動型のサブテストの例:
TestGetAlbum - 前述の並行性テスト:
TestConcurrentRequests - エラー用のデータベースモックである
errorDatabaseを使い、ハンドラがデータベースエラー時に正しく500 Internal Server Errorを返すことを検証するテスト:TestDatabaseErrors
データベースインターフェース
Goのインターフェースは強力で、ややユニークです。データベース構造体のような具象型をさまざまなアクセスメソッドとともに実装できますが、実装側は何かを実装・継承することを明示する必要はありません。ただコードを書くだけです。
そして、データベースを使う側、この場合はServerが、必要なメソッドだけを持つインターフェースを定義します(実装のメソッドのサブセットであることもよくあります)。今回のケースでは次のようになります。
// Server is the album HTTP server.
type Server struct {
db Database
log *log.Logger
}
// Database is the interface used by the server to load and store albums.
type Database interface {
// GetAlbums returns a copy of all albums, sorted by ID.
GetAlbums() ([]Album, error)
// GetAlbumsByID returns a single album by ID, or ErrDoesNotExist if
// an album with that ID does not exist.
GetAlbumByID(id string) (Album, error)
// AddAlbum adds a single album, or ErrAlreadyExists if an album with
// the given ID already exists.
AddAlbum(album Album) error
}
var (
ErrDoesNotExist = errors.New("does not exist")
ErrAlreadyExists = errors.New("already exists")
)ご覧のとおり、ServerはDatabaseを持ちます。これは私が定義したMemoryDatabaseのようなインメモリのものでも、ディスク上のものでも、外部のSQLデータベースを使うものでも構いません。あるいは、データベースのエラー処理をテストするためにTestDatabaseErrorsで使ったような、常にエラーを返すerrorDatabaseである可能性もあります。
良いインターフェースを定義するには、ある程度のAPI設計が必要です。当初はerrorの戻り値なしで始め、AddAlbum関数は「実際に追加できたか」を示す真偽値を返していました。しかし、実際のデータベースではエラーを返す必要があるため、最初から適切なエラー処理を備えておく方が良いでしょう。
GetAlbumByIDやAddAlbumのドキュメントコメントが、アルバムが存在しない(あるいは既に存在する)場合に返される特別なエラー値を説明している点に注目してください。これによりハンドラはこのエラー値(==やerrors.Isを使って)を検査し、呼び出し元に適切なHTTPステータスコードを返すことができます。
より大規模なプロジェクトであれば、ServerやDatabaseはserverパッケージで、MemoryDatabaseは別のtestdbパッケージで定義されるでしょう。シンプルさのため(このプロジェクトは数百行程度のコードです)、すべてを単一のmain.goファイルにまとめています。Goにおける良い経験則は、必要になったときにだけパッケージを分割するということです。
データベースの実装
私のデータベース実装では、オリジナルのチュートリアルと同様にシンプルなインメモリデータベースを使っています。ただし、現在は(上記のDatabaseインターフェースを満たすために)構造体を使って実装し、並行性の問題を修正するためのロックを追加しています。全体は次のとおりです。
// MemoryDatabase is a Database implementation that uses a simple
// in-memory map to store the albums.
type MemoryDatabase struct {
lock sync.RWMutex
albums map[string]Album
}
// NewMemoryDatabase creates a new in-memory database.
func NewMemoryDatabase() *MemoryDatabase {
return &MemoryDatabase{albums: make(map[string]Album)}
}
func (d *MemoryDatabase) GetAlbums() ([]Album, error) {
d.lock.RLock()
defer d.lock.RUnlock()
// Make a copy of the albums map (as a slice)
albums := make([]Album, 0, len(d.albums))
for _, album := range d.albums {
albums = append(albums, album)
}
// Sort by ID so we return them in a defined order
sort.Slice(albums, func(i, j int) bool {
return albums[i].ID < albums[j].ID
})
return albums, nil
}
func (d *MemoryDatabase) GetAlbumByID(id string) (Album, error) {
d.lock.RLock()
defer d.lock.RUnlock()
album, ok := d.albums[id]
if !ok {
return Album{}, ErrDoesNotExist
}
return album, nil
}
func (d *MemoryDatabase) AddAlbum(album Album) error {
d.lock.Lock()
defer d.lock.Unlock()
if _, ok := d.albums[album.ID]; ok {
return ErrAlreadyExists
}
d.albums[album.ID] = album
return nil
}mutex以外でオリジナルの手法と大きく異なる点は、アルバムの保存にスライスではなくIDをキーとするmapを使っていることです。これによりIDによる検索を定数時間で行えます。
ただし、Goのmapには定義された反復順序がないため、GetAlbumsでは一貫した順序でアルバムを返すようにIDでソートしています。オリジナルのコードは(おそらく偶然)古いものから新しいものの順に返していました。実際のデータベースを使う場合は、おそらくタイトルのようなユーザーにとって意味のある基準で並べるためにORDER BY句を使うでしょう。
関心の分離
これはデータベースインターフェースからかなり自然に生まれることです。オリジナルのコードでは、JSONマーシャルのようなHTTPハンドラのコードがデータベースのコードと混在していました。データベースインターフェースによって関心の分離が強制され、データベースのエラー処理をテストしやすくなります。また、いずれ本番のデータベースに置き換える際にも容易になります。単にSQLDatabase構造体を追加し、そのメソッドをSQLクエリで実装すればよいのです。
まとめ
このコードを書き直して改善を試みるのは楽しい演習でしたし、楽しんでいただけた、あるいは何か学びがあったなら嬉しいです。より堅牢で保守しやすくなっていることを願っていますし、サードパーティの依存関係を学んだり更新したりする手間も省けます。
完全なソースコードはGitHubのbenhoyt/web-service-stdlibでご覧ください。
ご意見や、私のコード・本記事を改善するためのご提案がありましたら、ぜひお知らせください!
記事をランダムに読む
コメント
ログインしてコメントする