sgykfjsm.github.com

GoでJSON APIを書く Part2

前回の続き。全体的にちょっとアレなところが多いので、もう少しリファクタリングを行なう。今回のリファクタリングの元ネタは以下。元ネタのほうが説明が簡潔だしコードが綺麗なので、元ネタを読めばコレを読む必要は無い。

主処理の結果を受け取りたい。

見出しの通りなんだけど、例えば現在のロギング内容にステータスコードを出力したいと思ったら、主処理から処理結果を受け取り、HTTPステータスコードを取り出す必要がある。また、事後処理にHTTPステータスコードを設定させるようにするのも同様だ。しかし、現在のハンドラからは処理結果を受け取ることができない。なぜならば、各Middlewareはhttprouter.Handleをハンドラとして定義しており、httprouter.Handleは以下のように定義されているからだ。

1
2
3
4
// Handle is a function that can be registered to a route to handle HTTP
// requests. Like http.HandlerFunc, but has a third parameter for the values of
// wildcards (variables).
type Handle func(http.ResponseWriter, *http.Request, Params)

つまりハンドラには返り値が無い。というわけで、今回の目的を実現するには、まずハンドラの定義を自分で変えなければならない。また、ハンドラを自分で定義するとhttprouterを使い続けるのが難しくなるため、別の方法でroutingを実装する必要がある。これについては後述する。

アプローチを考える。

考えを整理しよう。大雑把に考えると、主処理はリクエストを受け取って処理結果を返却してくれればよい。例えば以下の様な感じだ。

1
2
3
4
5
6
func DoSomething(r *http.Request) Result {

    // Do something

    return Result
}

実際のところ、レスポンスを生成する処理は主処理の役割ではないため引数からw http.ResponseWriterを削除した。また、httprouterが使えなくなるため、ps httprouter.Paramsも削除した。

Resultの部分は事後処理のことを考えると、HTTP Responseと対応してるっぽく定義したほうが良さそうだ。また、現在の事後処理はレスポンスヘッダーの設定を担当しているが、いっそレスポンスに関することを全て対応して欲しい。この考えを元にResultResponseに変更し、Responseをstructとして以下のように定義する。

1
2
3
4
5
type Response struct {
    status int          // HTTPステータスコードに対応する
    body   []byte       // レスポンスボディに対応する
    header http.Header  // HTTPレスポンスヘッダーに対応する
}

Responseをもう少し考える。今回の主処理のレスポンスは以下のように分類できる。

  • GETメソッドの結果を200 OKでJSONレスポンスを返す
  • POSTメソッドの結果は204 CreatedでをJSONレスポンスを返す
  • DELETEメソッドの場合は何も返却しない
  • エラーが発生した場合はJSONでエラーメッセージを返す
  • 上記に挙げた処理はそれぞれ基本的なレスポンス生成処理に基づく

また、Responseでやりたいことはレスポンス生成とロギングのためにHTTPステータスコードを取り出せるようにすることだ。この時点では具体的な実装は未定なので、やりたいことをinterfaceとして記述する。

1
2
3
4
type Response interface {
  Write(w http.ResponseWriter) // レスポンスを生成
  Status() int                 // ステータスコードを取り出す
}

ここでinterfaceの名前が先ほど定義したstructと被ってしまった。レスポンスを定義するstructはinterfaceを実装するようにしていたほうが何かと便利なので、interfaceのほうを尊重してstructの名前をNormalResponseとする。主処理はこのResponse interfaceだけを意識するようにしておけば、レスポンス側の実装に影響を受けにくくなる。

response.go

以上を踏まえて、Responseを作る処理は以下のようになる。

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
package main

import (
  "encoding/json"
  "log"
  "net/http"
)

type Response interface {
  Write(w http.ResponseWriter)
  Status() int
}

type NormalResponse struct {
  status int         // HTTPステータスコードに対応する
  body   []byte      // レスポンスボディに対応する
  header http.Header // HTTPレスポンスヘッダーに対応する
}

func (r *NormalResponse) Write(w http.ResponseWriter) {
  header := w.Header()
  for k, v := range r.header {
      header[k] = v
  }
  w.WriteHeader(r.status)
  w.Write(r.body)
}

func (r *NormalResponse) Status() int {
  return r.status
}

func (r *NormalResponse) Header(key, value string) *NormalResponse {
  r.header.Set(key, value)
  return r
}

func Empty(status int) *NormalResponse {
  return Respond(status, nil)
}

func Json(status int, body interface{}) *NormalResponse {
  return Respond(status, body).Header("Content-Type", "application/json")
}

func Created(status int, body interface{}, location string) *NormalResponse {
  return Json(status, body).Header("Location", location)
}

func Error(status int, message string, err error) *NormalResponse {
  log.Printf("%s, %s", message, err)
  return Respond(status, message).Header("Content-Type", "application/json")
}

func Respond(status int, body interface{}) *NormalResponse {
  var b []byte
  var err error
  switch t := body.(type) {
  case string:
      b = []byte(t)
  default:
      if b, err = json.Marshal(body); err != nil {
          return Error(http.StatusInternalServerError, "failed marshalling json", err)
      }
  }

  return &NormalResponse{
      status: status,
      body:   b,
      header: make(http.Header),
  }
}

この処理で注目すべき点は各処理のbodyの型がinterfaceとなっていることだ。引数をinterfaceで抽象化することで多くのデータ型に対応することが可能となる。

Respondは各処理からレスポンスを生成するための材料を受け取る。switchの文を見てわかるように、body.(type)でデータ型を取り出し、それぞれのデータ型に合わせて処理を分岐させている。

また、RespondNormalResponseを返却する際に、make(http.Header)NormalResponse.headerに割り当てている。これにより、Respondを利用する各処理の内部でレスポンスヘッダーを設定することが可能となっている。

Middlewareを修正する

上記のように事後処理を実装したが、これにより、decorator.goに定義したCommonHeadersが不要となる。また、忘れてしまいそうになるが、今回の修正では独自のハンドラを定義しなければならない。先述したように主処理に必要なのはr *http.RequestだけでResponse interfaceを返却するだけなので、以下のように定義できる。

1
type MyHandle func(*http.Request) Response

これに基づき、decorator.gologger.goを以下のように修正する。

decorator.go

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

import (
  "net/http"
  "strconv"

  "github.com/gorilla/mux"
)

type MyHandle func(*http.Request) Response

func IDShouldBeInt(h func(r *http.Request) Response, name string) MyHandle {
  return Logging(func(r *http.Request) Response {
      _, err := strconv.Atoi(mux.Vars(r)["todoId"])
      if err != nil {
          return Error(422, "todoId should be number", err)
      }

      return h(r)
  }, name)
}

上記を見てわかると思うけど、github.com/gorilla/muxを使っている。これはhttprouterの代わりとしているため。実際には、この時点ではr.URL.Query().Get("todoId")でパラメータを取り出していた。

さて、ここでの修正は以下のとおりだ。

  • CommonHeadersの削除
  • IDShouldBeIntは削除されたCommonHeadersの代わりにLoggingを設定
  • レスポンス生成処理を削除

これだけを書いてもちょっとわかりにくいので参考までに修正前のコードを抜粋する。比較するとかなりシンプルになったことがわかる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// これは修正前のコード
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)
}

logger.go

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

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

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

func Logging(h func(r *http.Request) Response, name string) MyHandle {
  return func(r *http.Request) Response {
      start := time.Now()
      result := h(r)
      logger(r.Method, r.URL.Path, name, result.Status(), start)
      return result
  }
}

ここでようやく主処理から結果を受け取れるようになったことが確認できると思う。

主処理を修正する

ここまでくればやることは自ずと決まる。つまり、以下のことをやれば良い。

  • httprouterに関する部分を削除する。
  • 各処理の返却時にはResponse interfaceのデータを返却する。
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
package main

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

  "github.com/gorilla/mux"
)

func Index(r *http.Request) Response {
  return Respond(http.StatusOK, "Welcmoe")
}

func TodoIndex(r *http.Request) Response {
  return Json(http.StatusOK, todos)
}

func TodoShow(r *http.Request) Response {
  id, _ := strconv.Atoi(mux.Vars(r)["todoId"])
  t := RepoFindTodo(id)
  if t.ID == 0 && t.Name == "" {
      return Empty(http.StatusNotFound)
  }

  return Json(http.StatusOK, t)
}

func TodoCreate(r *http.Request) Response {
  var todo Todo

  body, err := ioutil.ReadAll(io.LimitReader(r.Body, 1048576)) // 1MiB
  if err != nil {
      return Error(http.StatusInternalServerError, "request body is too large", err)
  }
  defer r.Body.Close()

  if err := json.Unmarshal(body, &todo); err != nil {
      return Error(http.StatusInternalServerError, "failed marshalling json", err)
  }

  t := RepoCreateTodo(todo)
  location := fmt.Sprintf("http://%s/%s/%d", r.Host, r.URL.Path, t.ID)

  return Created(http.StatusCreated, t, location)
}

func TodoDelete(r *http.Request) Response {
  id, _ := strconv.Atoi(mux.Vars(r)["todoId"])
  if err := RepoDestroyTodo(id); err != nil {
      return Empty(http.StatusNotFound)
  }

  return Empty(204) // 204 No Content
}

main.goを修正する

何度も繰り返しているようにhttprouterはもう使えない。標準のmuxを使ってもいいけどMethodによるルーティングなどを自分で実装するのはいかにも面倒くさい。すでにネタバレしているけど、今回のような状況ではgithub.com/gorilla/muxが使いやすい。

また、今回は独自のハンドラを定義したことを思い出して欲しい。このままでは支障があるため、func(http.ResponseWriter, *http.Request)を返却するラッパーを用意しなければならない。実はResponseをHTTPレスポンスとして出力する処理が記述されていないが、このラッパーに記述することで最後にHTTPレスポンスを生成することができるようになる。つまり、今後、新たなMiddlewareを実装するとしてもそれらはHTTPレスポンスへの書き出しを意識しなくて良くなるということだ。

main.go

以上を踏まえると、main.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
package main

import (
  "log"
  "net/http"

  "github.com/gorilla/mux"
)

func decorator(h func(r *http.Request) Response) func(http.ResponseWriter, *http.Request) {
  return func(w http.ResponseWriter, r *http.Request) {
      result := h(r)
      result.Write(w)
  }
}

func main() {
  r := mux.NewRouter()
  r.HandleFunc("/", decorator(Logging(Index, "index"))).Methods("GET")
  r.HandleFunc("/todos", decorator(Logging(TodoIndex, "todo-index"))).Methods("GET")
  r.HandleFunc("/todos/{todoId}", decorator(IDShouldBeInt(TodoShow, "todo-show"))).Methods("GET")
  r.HandleFunc("/todos", decorator(Logging(TodoCreate, "todo-create"))).Methods("POST")
  r.HandleFunc("/todos/{todoId}", decorator(IDShouldBeInt(TodoDelete, "todo-delete"))).Methods("DELETE")

  http.Handle("/", r)

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

main.goに関しては以前よりもごちゃごちゃしてしまった…。ちょっと丸括弧の数が多すぎるか。このあたりは今後の課題だなぁ。

おさらい

以上、GoでJSON APIを作る方法を見てきた。まぁまぁ良い感じのコードになったんじゃないかなーとは思うけど、最後のmain.goはかなりいただけない感じになってしまった。まぁこの辺は自力でちゃんとやろうとするよりも素直にWeb Frameworkを使ったほうが良いと思う。ここまでやっといてなんだけど。しかし、Goの世界ではまだデファクトスタンダードとなるような安定したWeb Frameworkが無いのもまた事実で、シンプルなAPI程度ならばまだスクラッチで実装するほうが良いと個人的には思う。その場合、パッケージの選定には注意したい。httprouterのように優れた性能であっても利用者に一定の制約(多くの場合は問題にならないし、むしろメリットのほうが大きいが)を課すことがある。そういった制約の中でどこまで何ができるのかを早々に見極めないとハマることになるし、Goは続々と色々なパッケージが作られているのでさっさと見切りをつけてパッケージを乗り換えたほうが良い。

今回のコードは https://gist.github.com/sgykfjsm/1dd9a8eee1f70a7068c9 にアップした。興味があれば見てみると良い。