sgykfjsm.github.com

GoでJSON APIを書く

JSONを返すRESTful APIを作ることになったので諸々の復習を兼ねてMaking a RESTful JSON API in Goを読む。そのままだとつまらないのでところどころ微妙にアレンジしながらやってみる。

A Basic Web Server

RESTfulなAPI Serverを作る場合、当然の事ながらWeb Serverとして提供することになる。周知の通り、Goの場合はnet/httpを使って簡単にWeb Server Applicationを作ることが出来る。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import (
  "fmt"
  "html"
  "log"
  "net/http"
)

func main() {
  http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
      fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path))
  })

  log.Fatal(http.ListenAndServe(":8080", nil))
}

上記のソースコードはGo Web Applicationのテンプレートと言っても過言ではないので、スニペットなどに登録しておくと良い。

さて、上記のコードは以下のコマンドでプログラムとして起動させることができる。

1
$ go run main.go

起動後、もう1つターミナルを開いて以下のようにcurlでアクセスすると、レスポンスが返ってきてWeb Serverとして動作していることが確認できる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ curl -vvv localhost:8080/hello/world
*   Trying ::1...
* Connected to localhost (::1) port 8080 (#0)
> GET /hello/world HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.43.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Sat, 12 Mar 2016 10:51:45 GMT
< Content-Length: 21
< Content-Type: text/plain; charset=utf-8
<
* Connection #0 to host localhost left intact
Hello, "/hello/world"

Adding a Router

通常、APIには複数の処理を実装し、各処理に応じたURLを割り当てる。このようなroutingの実装を行うためのライブラリは標準で提供されているが、一般的にはgorilla/muxjulienschmidt/httprouterが使われていることが多い。元記事は前者のgorilla/muxを使っているが、今回はjulienschmidt/httprouterを使うことにする。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import (
  "fmt"
  "html"
  "log"
  "net/http"

  "github.com/julienschmidt/httprouter"
)

func Index(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
  fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path))
}

func main() {
  router := httprouter.New()
  router.GET("/:path", Index)

  log.Fatal(http.ListenAndServe(":8080", router))
}

httprouterのroutingは結構厳密なので、元記事とはやや異なるソースとなる。具体的にはrouter.GET("/:path", Index)としているところ。こうしないと、例えばlocalhost:8080/hello_worldとしたときにIndexハンドラへ処理が流れていかない。また、router.GET("/", Index)とすると、localhost:8080/あるいはlocalhost:8080とリクエストする必要があるため。

さて、上記のコードでは外部パッケージを利用しているので、プログラムを起動する前に以下のようにしてパッケージをインストールする必要がある。

1
$ go get "github.com/julienschmidt/httprouter"

インストール後、プログラムを起動すると、以下のようにして動作を確認することができる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ curl -vvv localhost:8080/hello_world
*   Trying ::1...
* Connected to localhost (::1) port 8080 (#0)
> GET /hello_world HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.43.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Sat, 12 Mar 2016 11:16:22 GMT
< Content-Length: 21
< Content-Type: text/plain; charset=utf-8
<
* Connection #0 to host localhost left intact
Hello, "/hello_world"

Creating Some Basic Routes

httprouterの基本的な使い方を把握したので、それっぽくいくつかroutingを実装する。元記事に倣い、ToDoアプリケーションを作ると想定してみると、以下のようなコードになる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package main

import (
  "fmt"
  "log"
  "net/http"

  "github.com/julienschmidt/httprouter"
)

func Index(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
  fmt.Fprintf(w, "Welcmoe!")
}

func TodoIndex(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
  fmt.Fprintf(w, "Todo Index!")
}

func TodoShow(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  fmt.Fprintf(w, "Todo show: %s", ps.ByName("todoId"))
}

func main() {
  router := httprouter.New()
  router.GET("/", Index)
  router.GET("/todos", TodoIndex)
  router.GET("/todos/:todoId", TodoShow)

  log.Fatal(http.ListenAndServe(":8080", router))
}

見ての通り、2つのエンドポイント(あるいはルート)が追加されている。

  • localhost:8080/todos
    • Todoの一覧を表示するルート
  • localhost:8080/todos/:todoId
    • :todoIdで指定したTodoの項目を表示するルート

すでに触れているが、ここでは:todoIdをURL指定の中に追加している。こうすることによって、例えばlocalhost:8080/todos/123とリクエストしたときに123という文字列がtodoIdに割り当てられることになる。割り当てられたtodoIdps.ByName()という関数を使って取り出すことができる。

今回はcurlでの実行例を省略する。

A Basic Model

次にこのプログラムで取り扱うデータのモデルを定義する。Goの場合はstructを使って定義するのが一般的だ。他の言語であればclassで定義することが一般的かもしれない。ちなみに、mapを使うことで定義の宣言を(ある程度)省略することが可能だが、Goではmapはスレッドセーフではなく、goroutineなどを使ってconcurrentに処理を行うことが多い処理では慎重に使う必要があるため、個人的にはmapを使うのはオススメしない(参考: Why are map operations not defined to be atomic?)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
  "fmt"
  "log"
  "net/http"
  "time"

  "github.com/julienschmidt/httprouter"
)

type Todo struct {
  Name      string    `json:"name"`
  Completed bool      `json:"completed"`
  Due       time.Time `json:"due"`
}

type Todos []Todo

// 以降は上掲のコードと同じなので省略する

type Todos []Todoではstructを使って宣言していないが、直観的にTodoのスライスだとわかるはず。また、今回はJSON形式でレスポンスを返すと予めわかっているのでstructのプロパティにjsonタグをつけている。

Send Back Some JSON

データモデルを定義したので、このデータモデルの使い方を確認する。使い方の部分だけを抜粋すると、以下のようになる。

1
2
3
4
5
6
7
8
func TodoIndex(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
  todos := Todos{
      Todo{Name: "Write presentation"},
      Todo{Name: "Host meetup"},
  }

  json.NewEncoder(w).Encode(todos)
}

上記を追加して(もちろんimport "encoding/json"も忘れずに)、プログラムを起動してレスポンスを確認してみる。ここでは出力を整形するためにjqを使っている。

1
2
3
4
5
6
7
8
9
10
11
12
13
$ curl --silent localhost:8080/todos | jq
[
  {
    "name": "Write presentation",
    "completed": false,
    "due": "0001-01-01T00:00:00Z"
  },
  {
    "name": "Host meetup",
    "completed": false,
    "due": "0001-01-01T00:00:00Z"
  }
]

上記では、CompletedDueにそれぞれ値を割り当てていないので、初期値が出力されている。また、元記事とは微妙に室力がプロパティのキー値が異なっている。これは先述したJSONタグでの定義によるもの。

A Better Model

すでに上記で触れているので省略。

OK, We Need to Split This Up!

この時点ではソースコードは50行弱だが、若干のリファクタリングを行う。具体的には以下のファイル群に処理を分ける。元記事とは異なり、routes.goは無い。なんかあんまり実用性があるように思えなかったので。httprouterだと同じような実装ができないからっていうのもあるけど。なお、このようにファイルを分割した場合はgo getで起動するよりも一度go buildして実行バイナリを生成してから動作確認をしたほうがハマりにくい。

  • main.go
  • handlers.go
  • todo.go

main.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import (
  "log"
  "net/http"

  "github.com/julienschmidt/httprouter"
)

func main() {
  router := httprouter.New()
  router.GET("/", Index)
  router.GET("/todos", TodoIndex)
  router.GET("/todos/:todoId", TodoShow)

  log.Fatal(http.ListenAndServe(":8080", router))
}

handlers.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
  "encoding/json"
  "fmt"
  "net/http"

  "github.com/julienschmidt/httprouter"
)

func Index(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
  fmt.Fprintf(w, "Welcmoe!")
}

func TodoIndex(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
  todos := Todos{
      Todo{Name: "Write presentation"},
      Todo{Name: "Host meetup"},
  }

  if err := json.NewEncoder(w).Encode(todos); err != nil {
      panic(err)
  }
}

func TodoShow(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  fmt.Fprintf(w, "Todo show: %s", ps.ByName("todoId"))
}

todo.go

1
2
3
4
5
6
7
8
9
10
11
package main

import "time"

type Todo struct {
  Name      string    `json:"name"`
  Completed bool      `json:"completed"`
  Due       time.Time `json:"due"`
}

type Todos []Todo

Even Better Routing

元記事ではroutingの設定をstructとしてデータモデル化することでより良いルーティングができるよ!っと言っている(と理解した)。今回はスキップ。

Outputting a Web Log

Web Applicationのロギングについて。元記事では独自にロガーを実装している。httprouterではこのような共通関数の仕組みをMiddlewareとして実装できる仕組みを提供している。元記事と同じような実装を以下のようにした。

logger.go

基本的な考え方として、Middlewareは各URLへのリクエストに対して行う処理をハンドラとして受け取り、受け取ったハンドラを実行する前または後に処理を記述すればよい。よって、MiddlewareはハンドラとMiddlewareが必要とする情報を引数として受け取るだけで良い。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import (
  "log"
  "net/http"
  "time"

  "github.com/julienschmidt/httprouter"
)

var logger = func(method, uri, name string, start time.Time) {
  log.Printf("\"method\":%q  \"uri\":%q    \"name\":%q   \"time\":%q", method, uri, name, time.Since(start))
}

func Logging(h httprouter.Handle, name string) httprouter.Handle {
  return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
      start := time.Now()
      h(w, r, ps)
      logger(r.Method, r.URL.Path, name, start)
  }
}

Middlewareの実装例としては以下を参考にすること。

Applying the Logger Decorator

上掲のlogger.goを組み込むには単純に以下のようにすれば良い。

main.go

1
2
3
router.GET("/", Logging(Index, "index"))
router.GET("/todos", Logging(TodoIndex, "todo-index"))
router.GET("/todos/:todoId", Logging(TodoShow, "todo-show"))

This Routes File is Crazy … Let’s Refactor

スキップ。

Taking Some Responsibility

これでようやく今回のアプリケーションのボイラーテンプレートが出来たので、各ハンドラをWeb Applicationっぽくしていく。まずはTodoIndexのレスポンスを改善する。現在の状態でレスポンスを詳細に見てみる(curlのオプションに-vvvまたは-D -というオブションをつけて実行する)と、以下のように出力されているはず。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ curl --silent localhost:8080/todos -vvv
*   Trying ::1...
* Connected to localhost (::1) port 8080 (#0)
> GET /todos HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.43.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Sat, 12 Mar 2016 13:44:09 GMT
< Content-Length: 149
< Content-Type: text/plain; charset=utf-8  ## <- コレに注目
<
[{"name":"Write presentation","completed":false,"due":"0001-01-01T00:00:00Z"},{"name":"Host meetup","completed":false,"due":"0001-01-01T00:00:00Z"}]
* Connection #0 to host localhost left intact

レスポンスのContent-Typeがtext/plain; charset=utf-8となっているのが確認できる。今回はJSON APIを作るのが目的なので、application/json; charset=UTF-8としてレスポンスを返したい。なので、以下のようにコードを少し追加する。

1
2
3
4
5
6
7
8
9
10
11
12
13
func TodoIndex(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
  todos := Todos{
      Todo{Name: "Write presentation"},
      Todo{Name: "Host meetup"},
  }

  w.Header().Set("Content-Type", "application/json; charset=UTF-8") // <- Added
  w.WriteHeader(http.StatusOK) // <- Added

  if err := json.NewEncoder(w).Encode(todos); err != nil {
      panic(err)
  }
}

// <- Addedとコメントしている行が追加されている。直観的にわかると思うけど、レスポンスヘッダにContent-Typeとステータスコードを設定しているだけ。buildしなおしてからcurlでアクセスしてみる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
*   Trying ::1...
* Connected to localhost (::1) port 8080 (#0)
> GET /todos HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.43.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=UTF-8  ## <- コレに注目
< Date: Sat, 12 Mar 2016 13:52:46 GMT
< Content-Length: 149
<
[{"name":"Write presentation","completed":false,"due":"0001-01-01T00:00:00Z"},{"name":"Host meetup","completed":false,"due":"0001-01-01T00:00:00Z"}]
* Connection #0 to host localhost left intact

レスポンスヘッダが意図したものになっていることが確認できる。

Wait, Where is my Database?

Web Applicationにデータベースはつきもの。ここでは簡易なモックデータベースを使ってデータベースを使った処理を仮実装してみる。

repo.go

モックデータベースの処理はrepo.goに記述することにする。ここでは擬似的なCreate/Read/Delete処理を実装している。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package main

import "fmt"

var (
  todos     Todos
  currentID int
)

func init() {
  RepoCreateTodo(Todo{Name: "Write presentation"})
  RepoCreateTodo(Todo{Name: "Host meetup"})
}

func RepoFindTodo(id int) Todo {
  for _, t := range todos {
      if t.ID == id {
          return t
      }
  }
  return Todo{}
}

func RepoCreateTodo(t Todo) Todo {
  currentID += 1
  t.ID = currentID
  todos = append(todos, t)
  return t
}

func RepoDestroyTodo(id int) error {
  for i, t := range todos {
      if t.ID == id {
          todos = append(todos[:i], todos[i+1:]...)
          return nil
      }
  }

  return fmt.Errorf("Could not find Todo with id of %d to delete", id)
}

Add ID to Todo

repo.goではTodoの項目を探すためにIDという概念を利用しているので、データモデルにIDを追加しておく。

1
2
3
4
5
6
7
8
9
10
11
12
package main

import "time"

type Todo struct {
  ID        int       `json:"id"`
  Name      string    `json:"name"`
  Completed bool      `json:"completed"`
  Due       time.Time `json:"due"`
}

type Todos []Todo

Update our TodoIndex

repo.goinit.goにて初期値を与えるようにし、また、todosはグローバル変数として利用できるようになった(本当は良くない)のでhandlers.goTodoIndexを以下のように修正する。

1
2
3
4
5
6
7
8
func TodoIndex(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
  w.Header().Set("Content-Type", "application/json; charset=UTF-8")
  w.WriteHeader(http.StatusOK)

  if err := json.NewEncoder(w).Encode(todos); err != nil {
      panic(err)
  }
}

Posting JSON

スキップ

The Create endpoint

モックデータベースの処理を実装したので、それを利用するエンドポイントを追加する。なお、元記事ではRepoCreateTodoを使ったエンドポイントのみを実装しているが、せっかくなのでRepoFindTodoRepoDestroyTodoを使ったエンドポイントも実装する。

TodoShow

まずはTodoShowから。ps.ByNameで対象のIDを受け取りバリデーションを兼ねてstrconv.Atoiで変換する。変換したIDが数字でなければ422を返す。知らなかったけど、このようなリクエストの形式としては正しいが意味的に間違っている場合(今回の場合だと数字であるべき箇所に数値(int)に変換できない文字列を含む場合)は422 Unprocessable Entityを返すのが一般的らしい。変換したIDをRepoFindTodoに渡して結果をもらう。空っぽだったら404、何か入っていれば200を返す。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func TodoShow(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  idParam := ps.ByName("todoId")
  id, err := strconv.Atoi(idParam)
  if err != nil {
      w.Header().Set("Content-Type", "application/json; charset=UTF-8")
      w.WriteHeader(422) // unprocessable entity
      if err := json.NewEncoder(w).Encode(err); err != nil {
          panic(err)
      }
      return
  }

  t := RepoFindTodo(id)
  if t.ID == 0 && t.Name == "" {
      w.Header().Set("Content-Type", "application/json; charset=UTF-8")
      w.WriteHeader(http.StatusNotFound)
      return
  }

  w.Header().Set("Content-Type", "application/json; charset=UTF-8")
  w.WriteHeader(http.StatusOK)
  if err := json.NewEncoder(w).Encode(t); err != nil {
      panic(err)
  }
  return
}

TodoCreate

TodoCreateは元記事とほとんど同じだけど、RESTful APIっぽくするためにLocationをヘッダーに追加している。元記事でも触れているが、io.LimitReader(r.Body, 1048576)は巨大なリクエストを受け取らないようにするため。1048576は1MiB(メビバイト)。これもまた知らなかったけど、最近はこっちのほうがモダンなのかな。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func TodoCreate(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  var todo Todo

  body, err := ioutil.ReadAll(io.LimitReader(r.Body, 1048576)) // 1MiB
  if err != nil {
      panic(err)
  }
  defer r.Body.Close()

  if err := json.Unmarshal(body, &todo); err != nil {
      w.Header().Set("Content-Type", "application/json; charset=UTF-8")
      w.WriteHeader(500)
      if err := json.NewEncoder(w).Encode(err); err != nil {
          panic(err)
      }
      return
  }

  t := RepoCreateTodo(todo)
  location := fmt.Sprintf("http://%s/%d", r.Host, t.ID)
  w.Header().Set("Content-Type", "application/json; charset=UTF-8")
  w.Header().Set("Location", location)
  w.WriteHeader(http.StatusCreated)
  if err := json.NewEncoder(w).Encode(t); err != nil {
      panic(err)
  }
  return
}

TodoDelete

実はDELETEメソッドの処理を作ったことがなかった。というか、Web Applicationを作る機会自体があんまり無いんだけど。削除の場合、特に返却すべき内容は無いので、204 Not Contentを返している。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func TodoDelete(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  idParam := ps.ByName("todoId")
  id, err := strconv.Atoi(idParam)
  if err != nil {
      w.Header().Set("Content-Type", "application/json; charset=UTF-8")
      w.WriteHeader(500)
      if err := json.NewEncoder(w).Encode(err); err != nil {
          panic(err)
      }
      return
  }

  if err := RepoDestroyTodo(id); err != nil {
      w.Header().Set("Content-Type", "application/json; charset=UTF-8")
      w.WriteHeader(http.StatusNotFound)
      if err := json.NewEncoder(w).Encode(err); err != nil {
          panic(err)
      }
      return
  }

  w.WriteHeader(204) // 204 No Content
  return
}

Things We Didn’t Do

  • Version Control
    • gitとかの話ではなくAPIとしてのバージョンのこと。例えば互換性を破壊するような変更を行うことが予想される場合は、/api/v1/prefixのようなエンドポイントで設定したほうが良いかもしれない。
  • Authentication
    • パブリックあるいはオープンなAPIで無いのならば、認証は設けたほうが良い。元記事ではJSON web tokensの利用を推奨している。

また、元記事ではeTagsを使ってキャッシュの仕組みを盛り込み、スケーリング可能なアプリケーションの実装を提案している。具体的な実装については以下が参考になりそう(ちゃんと読んでない)。

What Else is Left?

他にやるべきこととしては以下が挙げられる。

  • リファクタリング
  • 各々の処理をファイルをパッケージに分割する
  • テスト
  • 適切なエラーメッセージ
    • これは独自に追加した項目なんだけど、例えば今はstrconv.Atoiでエラーが発生した場合にエラーをそのままクライアントにかえしているので、これはやはり適切なエラーメッセージに変えたほうが良い。

おまけ1 - 同じような処理をまとめる

ハンドラの処理を実装しているhandlers.goを眺めていると気づくが、ハンドラの処理の構成は主に以下のようになっている。

  • 事前処理: 引数のバリデーションチェック
  • 主処理: モックデータベースを使った処理
  • 事後処理: レスポンスヘッダーと返却するHTTPステータスコードの設定

主処理は事前処理にてチェックされた値を受け取るという若干の依存はあるものの、それぞれの関心事は以下のように分けることが出来て、それぞれ分離させて共通化出来そうなことに気づく。

  • 事前処理: クライアントからのリクエストの中身に興味がある
  • 主処理: 指定されたTodoのIDを使ったデータベースの操作に興味がある
  • 事後処理: クライアントに返却するレスポンスに興味がある

ここでロギング処理をMiddlewareとして実装したことを思い出す。今回の事前処理と事後処理もロギングと同様にMiddlewareとして実装することでコードの重複を減らし、ハンドラの関心事を主処理に専念させることができそうだ。

decorator.go

事前処理と事後処理を記述するdecorator.goというファイルを用意する。やるべきことはhandlers.goに記述していた処理をコピペするだけ。多少修正しているが、以下のようになる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package main

import (
  "encoding/json"
  "net/http"
  "strconv"

  "github.com/julienschmidt/httprouter"
)

func IDShouldBeInt(h httprouter.Handle, name string) httprouter.Handle {
  return CommonHeaders(func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
      idParam := ps.ByName("todoId")
      _, err := strconv.Atoi(idParam)
      if err != nil {
          w.Header().Set("Content-Type", "application/json; charset=UTF-8")
          w.WriteHeader(500)
          if err := json.NewEncoder(w).Encode(err); err != nil {
              return
          }
          return
      }

      h(w, r, ps)
  }, name)
}

func CommonHeaders(h httprouter.Handle, name string) httprouter.Handle {
  return Logging(func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
      w.Header().Set("Content-Type", "application/json; charset=UTF-8")
      h(w, r, ps)
  }, name)
}

_, err := strconv.Atoi(idParam)では変換された値を捨てている。これは事前処理では変換された値を使わないから。また、見ての通り、ソースのシグネチャというか、基本的な構造はlogger.goで実装したものと何も変わらないことに気づくはず。非常にシンプルにコードが書けているし、関数の処理内容もわかりやすくなったと思う。

ただ、これで良いかと言われると、やや苦しいところがある。それは事前処理の中でレスポンスを返却しているところだ。処理の内容からしてここではパラメータが不正であることだけを呼び出し元に通知して良い感じに事後処理に結果を渡すことができればよいのだけど、今の時点では妥協しておく(というか良いやり方を思いつかなかった)。

handlers.go

上記の通り、事前処理と事後処理をハンドラから取り除いたのでhandlers.goは以下のようになる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
package main

import (
  "encoding/json"
  "fmt"
  "io"
  "io/ioutil"
  "net/http"
  "strconv"

  "github.com/julienschmidt/httprouter"
)

func Index(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
  fmt.Fprintf(w, "Welcmoe!")
}

func TodoIndex(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
  w.WriteHeader(http.StatusOK)

  if err := json.NewEncoder(w).Encode(todos); err != nil {
      panic(err)
  }
}

func TodoShow(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  id, _ := strconv.Atoi(ps.ByName("todoId"))
  t := RepoFindTodo(id)
  if t.ID == 0 && t.Name == "" {
      w.WriteHeader(http.StatusNotFound)
      return
  }

  w.WriteHeader(http.StatusOK)
  if err := json.NewEncoder(w).Encode(t); err != nil {
      panic(err)
  }
}

func TodoCreate(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  var todo Todo

  body, err := ioutil.ReadAll(io.LimitReader(r.Body, 1048576)) // 1MiB
  if err != nil {
      panic(err)
  }
  defer r.Body.Close()

  if err := json.Unmarshal(body, &todo); err != nil {
      w.WriteHeader(500)
      if err := json.NewEncoder(w).Encode(err); err != nil {
          panic(err)
      }
      return
  }

  t := RepoCreateTodo(todo)
  location := fmt.Sprintf("http://%s/%d", r.Host, t.ID)
  w.Header().Set("Location", location)
  w.WriteHeader(http.StatusCreated)
  if err := json.NewEncoder(w).Encode(t); err != nil {
      panic(err)
  }
}

func TodoDelete(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  id, _ := strconv.Atoi(ps.ByName("todoId"))
  if err := RepoDestroyTodo(id); err != nil {
      w.WriteHeader(http.StatusNotFound)
      if err := json.NewEncoder(w).Encode(err); err != nil {
          panic(err)
      }
      return
  }

  w.Header().Del("Content-Type")
  w.WriteHeader(204) // 204 No Content
}

ちょっとすっきりしたけど、処理結果に応じてHTTPステータスコードが変わるため今の時点では主処理から取り除くことが出来ないので、ちょっと中途半端になっている。

main.go

それぞれの主処理に応じて用意したMiddlewareを割り当てる。多少は可読性が上がったかも?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import (
  "log"
  "net/http"

  "github.com/julienschmidt/httprouter"
)

func main() {
  router := httprouter.New()
  router.GET("/", Logging(Index, "index"))
  router.GET("/todos", CommonHeaders(TodoIndex, "todo-index"))
  router.GET("/todos/:todoId", IDShouldBeInt(TodoShow, "todo-show"))
  router.POST("/todos", CommonHeaders(TodoCreate, "todo-create"))
  router.DELETE("/todos/:todoId", IDShouldBeInt(TodoDelete, "todo-delete"))

  log.Fatal(http.ListenAndServe(":8080", router))
}

ここまでのまとめ

ある程度はそれらしくなったけど、主処理の中でレスポンスヘッダ―を設定していたりするため現状ではやや不満が残る内容になった。これは主処理と事後処理が完全に分断されているためであり次回はその辺りを考慮に入れてリファクタリングを行なう。