概要
今回の記事について
仕事で新しくGolangを触ることになりました、社内で使うツールを作ることになったのですがそのツールのFE部分をNode.js(仮)、API部分をGolangで実装することになりました
筆者は主にPHP(Slim framework)、Scala(Play framework)を触ったことがあり、いやいやフレームワークはとりあえずでも使った方が拡張性もあるし良いのでは?とは思っている派だったのですが、今回は社内用のツール、しかもDBを叩いて表示を行うぐらいの簡単なWebアプリケーションを想定しているため、それぐらいだったらフレームワークは使わず標準パッケージ(net/http)でも良いのでは?という意見をチームメンバーから頂いたことで今回の記事を書くことになりました
したがって「超小規模のGolang REST APIを作成する時でもフレームワークを使うべきかどうか」という観点で調べていくことにします
考え方の道筋
まずはフレームワークを使うとしたらどのフレームワークを使うかを考え、フレームワークを使わずにAPIを実装した場合と、使った場合でどのような違いがあるかを見ていきたいと思います
あくまでも判断材料の1つにすることを目的とします
サンプルで利用するフレームワークについて
フレームワークの紹介・比較サイトは色々あり、それぞれでさまざまなフレームワークが紹介されていますが、今回は「Echo framework」を使おうと思います
選定した理由としては以下になります
- フレームワークの紹介・比較サイトでほぼほぼEchoが登場している
- 国内での事例が多く、何かあった時は参考にできることが多そう
- 小規模アプリケーション向けということが多く記載されており、詰まるところはシンプルで使いやすいのではと判断した
Echoでどのようなことができるかは公式サイトを見てもらえればと思います
Echo - High performance, extensible, minimalist Go web framework
今回作成するサンプルアプリケーションについて
要件
- 2つのAPIを用意する
- 何かしらのAPIを叩いて取得した結果をログに出力する
- DBを叩いて取得した結果をログに出力する
- DBのパスワード等は環境変数から受け取れる
- レスポンスはJson
- GETパラメーターで受け取ったものを1部処理に利用する
- エラー処理
- GETパラメーターのバリデーションチェックを行う
- 指定したHTTPメソッド以外のリクエストがきた場合はエラーとする
レスポンスの例
{
code: 2000,
message: "OK"
}
net/httpを利用してサンプルAPIを実装する
ソースコード
1ファイルで記述したのでGistに記載しました
https://gist.github.com/tomsato/64c22ebf60927fb17a5ec2bb5e595c13
実行方法
$ DB_USERNAME=username DB_PASSWORD=password go run sample-go-api.go
この状態で以下のページにアクセスすることでレスポンスを取得できる
- http://localhost:8080/person?user=hoge
- http://localhost:8080/request?key=huge
※ ローカルにMySQLを用意している必要があり
次から実装の説明をしていきます
エントリポイントの登録
http.HandleFunc
でエントリポイントの登録をします
第2引数にハンドラー関数を記述することになりますが、handlerに共通の変数(今回は環境変数)を渡したかったので、3つの引数をもつfunctionを引数にとり、func(ResponseWriter, *Request)を返すようにしています
func main() {
http.HandleFunc("/request", makeHandler(requestHandler))
http.HandleFunc("/person", makeHandler(personHandler))
http.ListenAndServe(":8080", nil)
}
func makeHandler(fn func(http.ResponseWriter, *http.Request, appEnvironment)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fn(w, r, getAppEnvironment())
}
}
環境変数の取得、Json変換
環境変数を取得する関数を用意して、取得した結果を構造体に格納し、各ハンドラーに渡す
// 環境変数の定義
type appEnvironment struct {
username string
password string
}
func getAppEnvironment() appEnvironment {
if len(os.Getenv("DB_USERNAME")) == 0 || len(os.Getenv("DB_PASSWORD")) == 0 {
panic("appEnvironment not setting.")
}
return appEnvironment{
username: os.Getenv("DB_USERNAME"),
password: os.Getenv("DB_PASSWORD"),
}
}
構造体からJsonレスポンス用に []byte
と都度変換することになるが、変換時にエラーチェックすることになり、都度記述するのは冗長なので関数化
func createResponsJson(a *apiRequestResponse) []byte {
json, err := json.Marshal(*a)
if err != nil {
panic("json can not marshal.")
}
return json
}
APIを叩いてレスポンスを返す
methodのバリデーションチェックや、GETパラメーターのバリデーションチェックもこの中で行う
http.Get
にてHTTPリクエストを行い、取得した結果をログに出力している
func requestHandler(w http.ResponseWriter, r *http.Request, env appEnvironment) {
// headerセット
w.Header().Set("Content-Type", "application/json; charset=utf-8")
// methodバリデーション
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
w.Write(createResponsJson(&apiRequestResponse{Code: 2001, Message: "Method not allowed."}))
return
}
// クエリパラメータ取得&バリデーションチェック
paramKey := r.URL.Query().Get("key")
if len(paramKey) == 0 {
w.WriteHeader(http.StatusBadRequest)
w.Write(createResponsJson(&apiRequestResponse{Code: 2002, Message: "key paramater not found."}))
return
}
// HTTPリクエストを行い結果をログに出力
resp, _ := http.Get("http://httpbin.org/get?key=" + paramKey)
if resp == nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write(createResponsJson(&apiRequestResponse{Code: 2003, Message: "HTTP Request failed."}))
return
}
defer resp.Body.Close()
byteArray, _ := ioutil.ReadAll(resp.Body)
fmt.Printf("log: %s", byteArray)
// レスポンスデータの生成
w.Write(createResponsJson(&apiRequestResponse{Code: 2000, Message: "OK"}))
}
DBを叩いてレスポンスを返す
この中でもmethodのバリデーションチェックや、GETパラメーターのバリデーションチェックも行っている
またDBから値を取得しログに出力を行っている
func personHandler(w http.ResponseWriter, r *http.Request, env appEnvironment) {
// headerセット
w.Header().Set("Content-Type", "application/json; charset=utf-8")
// methodバリデーション
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
w.Write(createResponsJson(&apiRequestResponse{Code: 2001, Message: "Method not allowed."}))
return
}
// クエリパラメータ取得&バリデーションチェック
paramUser := r.URL.Query().Get("user")
if len(paramUser) == 0 {
w.WriteHeader(http.StatusBadRequest)
w.Write(createResponsJson(&apiRequestResponse{Code: 2002, Message: "user paramater not found."}))
return
}
// DBからデータを取得して結果をログに出力
db, err := sql.Open("mysql", "root:"+env.password+"@/"+env.username)
if err != nil {
panic(err.Error())
}
defer db.Close()
rows, err := db.Query("SELECT * FROM person")
if err != nil {
panic(err.Error())
}
defer rows.Close()
for rows.Next() {
var person person
err := rows.Scan(&person.id, &person.name)
if err != nil {
panic(err.Error())
}
fmt.Println(person.id, person.name)
}
err = rows.Err()
if err != nil {
panic(err.Error())
}
// レスポンスデータの生成
w.Write(createResponsJson(&apiRequestResponse{Code: 2000, Message: "OK"}))
}
net/httpについて思ったところ
課題に思ったところ
- エラーハンドリングは都度実装する必要があり面倒
- GET Methodだけ対応しているなどのHTTPメソッドのチェック処理の記述が都度必要
/hoge
など存在しないエントリポイントを叩いた場合には404 page not found
というレスポンスが返ってくるためJsonでレスポンスを返したい場合は実装が必要
- レスポンス用の構造体から
[]byte
への変換が面倒 - 各ハンドラーに共通の変数(今回は環境変数)を渡し方法として1つ関数(makeHandler)を挟むことになるため面倒
良いと思ったところ
http.HandleFunc
にてエントリポイントが登録できエントリポイント毎に関数分けできるのでわかりやすい- GETパラメーターの取得も簡単にできた
net/http
だけで意外と簡単に色々できることがわかった
Echoフレームワークを利用してサンプルAPIを実装する
ソースコード
net/http
版のAPIとほぼ同等の機能を実装したサンプルAPIをEchoフレームワークを用いて実装していきます
1ファイルで記述したのでGistに記載しました
https://gist.github.com/tomsato/c946d7a428e6d2bb7596f240978ecd29
実行方法
$ DB_USERNAME=username DB_PASSWORD=password go run sample-go-echo-api.go
この状態で以下のページにアクセスすることでレスポンスを取得できる
- http://localhost:8080/person?user=hoge
- http://localhost:8080/request?key=huge
※ ローカルにMySQLを用意している必要があり
次から実装の説明をしていきます
エントリポイントの登録
echo.New()
にてEchoのインスタンスを作成し、e.GET
などでエントリポイントを作成していく
net/http
の時と同様にhandlerに共通の変数(環境変数)を渡すためにmakeHandler
を用意している
func main() {
e := echo.New()
e.GET("/request", makeHandler(requestHandler))
e.GET("/person", makeHandler(personHandler))
e.HTTPErrorHandler = customHTTPErrorHandler
e.Logger.Fatal(e.Start(":8080"))
}
func makeHandler(fn func(c echo.Context, env appEnvironment) error) echo.HandlerFunc {
return func(c echo.Context) error {
return fn(c, getAppEnvironment())
}
}
環境変数の取得、Json変換
環境変数の取得はnet/http
サンプルの時と同じ
Json変換は後ほど紹介するc.JSON
に構造体をそのまま渡せるようになったので不要になった
APIを叩いてレスポンスを返す
レスポンスJson用の構造体をc.JSON
に記載するだけ、なのでContent-Typeの指定はいらなくなる
func requestHandler(c echo.Context, env appEnvironment) error {
// クエリパラメータ取得&バリデーションチェック
paramKey := c.QueryParam("key")
if len(paramKey) == 0 {
return c.JSON(http.StatusOK, apiRequestResponse{Code: 2002, Message: "key paramater not found."})
}
// HTTPリクエストを行い結果をログに出力
resp, _ := http.Get("http://httpbin.org/get?key=" + paramKey)
if resp == nil {
return c.JSON(http.StatusOK, apiRequestResponse{Code: 2003, Message: "HTTP Request failed."})
}
defer resp.Body.Close()
byteArray, _ := ioutil.ReadAll(resp.Body)
fmt.Printf("log: %s", byteArray)
return c.JSON(http.StatusOK, apiRequestResponse{Code: 2000, Message: "OK"})
}
e.GET
でエントリポイントを登録しているためMethodバリデーションは不要で、Echoで用意しているデフォルトのエラーハンドリングが適用される
従ってエラーレスポンスをカスタマイズしたい場合は以下のように記述する
func customHTTPErrorHandler(err error, c echo.Context) {
code := http.StatusInternalServerError
if he, ok := err.(*echo.HTTPError); ok {
code = he.Code
}
if code == http.StatusMethodNotAllowed {
// 405エラーの場合にレスポンスをカスタマイズする
c.JSON(code, apiRequestResponse{Code: 2001, Message: "Method not allowed."})
} else {
// 405以外のエラーが発生した場合は以下の内容でレスポンスする
c.JSON(code, apiRequestResponse{Code: 5000, Message: "Internal server error."})
}
}
DBを叩いてレスポンスを返す
func personHandler(c echo.Context, env appEnvironment) error {
// クエリパラメータ取得&バリデーションチェック
paramUser := c.QueryParam("user")
if len(paramUser) == 0 {
return c.JSON(http.StatusOK, apiRequestResponse{Code: 2002, Message: "user paramater not found."})
}
// DBからデータを取得して結果をログに出力
db, err := sql.Open("mysql", "root:"+env.password+"@/"+env.username)
if err != nil {
panic(err.Error())
}
defer db.Close()
rows, err := db.Query("SELECT * FROM person")
if err != nil {
panic(err.Error())
}
defer rows.Close()
for rows.Next() {
var person person
err := rows.Scan(&person.id, &person.name)
if err != nil {
panic(err.Error())
}
fmt.Println(person.id, person.name)
}
err = rows.Err()
if err != nil {
panic(err.Error())
}
return c.JSON(http.StatusOK, apiRequestResponse{Code: 2000, Message: "OK"})
}
Echoフレームワークについて思ったところ
課題に思ったところ
フレームワークを使うことに対して
- カスタムエラーハンドリングはどうやるんだっけ、パラメータはどう取得するんだっけ、など疑問が生じた場合は都度調べる必要があるため、やはりフレームワークとしての学習コストは発生する
- フレームワークに脆弱性が発生した場合は都度アップデートが必要なので保守コストが発生する
Echoに対して
- 公式サイトが英語
良いと思ったところ
- デフォルトでエラーハンドリングされ、カスタマイズするにも簡単
- レスポンス用に変換する必要がなくなった
- 導入も簡単で、使い方もシンプルで
net/http
と同じ感覚で利用できた - リクエストデータを構造体にバインドさせることができそう
- 認証機能やテンプレート機能など色々な機能がありそうなので、今後機能拡張する場合や新規にWebページ表示用のアプリケーションを作成したい場合にもEchoを使えそう
まとめ
今回はAPIを叩いてレスポンスを返す、DBを叩いてレスポンスを返すなどシンプルなことしかしていないためフレームワークを使う優位性についてはそこまで感じることはできなかったように感じます
またGolangの標準パッケージ(net/http)が強力なため、今回のようなAPIについてはそれで十分に感じ、そもそもGolangの良さはシンプルさとも言われているためわざわざフレームワークを使う必要はないかもしれません
一方でやはり便利なところはあり、様々な機能があることから今後幅広くEchoを使っていく予定があるならば率先して利用するのはありなのかと思います
コメントを書く
コメント一覧