【go】echoでapiサーバーを実装するときに最低限必要そうなことをまとめておく

以前にgoでのapiサーバーの実装を調べたけどすでに色々忘れかけてるので手順などまとめておく。使おうと思った理由としては以下の要件が厳しめで、phpとかでは厳しいと思ったため。
・レスポンスの高速化
APIサーバー数の最小化

あと、時間が空くと環境作ったりやらも忘れてしまうので合わせてメモしておく。で、実際にやりたかったことは以下

macにgoをインストールして開発環境構築

http://kimagureneet.hatenablog.com/entry/2017/10/23/002705
こちらのとおりにgvm(Go Version Manger)を使ってプロジェクト毎にGoのバージョンを変更できるようにする。今回はバージョン1.9で進めます。
また、goではGOPATHという環境変数で指定されているディレクトリ以下にプロジェクトを作っていくのだがこちらもプロジェクト毎に設定できるようにしたい。
そこでgvmを使うとディレクトリ毎にGOPATHも設定できるので使わない手はなさそう。

# echo_sampleという名前で設定作成
$ gvm pkgset create echo_sample

# 設定の一覧にecho_sampleが追加されていることを確認
$ gvm pkgset list
gvm go package sets (go1.9)
    echo_sample
=>  global

echo_sampleの設定を編集

$ gvm pkgenv echo_sample
・・・
# GOPATHにプロジェクトのパス(/Users/hoge/echo_sample)を追加
export GOPATH; GOPATH="/Users/hoge/.gvm/pkgsets/go1.9/echo_sample:$GOPATH"
→
export GOPATH; GOPATH="/Users/hoge/.gvm/pkgsets/go1.9/echo_sample:/Users/hoge/echo_sample:$GOPATH"

# PATHにプロジェクトのパス+bin(/Users/hoge/echo_sample/bin)を追加
export PATH; PATH="/Users/hoge/.gvm/pkgsets/go1.9/echo_sample/bin:${GVM_OVERLAY_PREFIX}/bin:{PATH}"
→
export PATH; PATH="/Users/hoge/.gvm/pkgsets/go1.9/echo_sample/bin:${GVM_OVERLAY_PREFIX}/bin:/Users/hoge/echo_sample/bin:${PATH}"
・・・

echo_sampleの設定を反映

# 設定を反映
$ gvm pkgset use echo_sample

# 設定されたことを確認
$ gvm pkgset list
gvm go package sets (go1.9)
=>  echo_sample
    global

# 設定が反映されていることを確認
$ go env | grep -i gopath
GOPATH="/Users/hoge/.gvm/pkgsets/go1.9/echo_sample:/Users/hoge/echo_sample:/Users/techbeans/.gvm/pkgsets/go1.9/global"

とりあえずこれで開発の準備ができた。

dep(パッケージ管理ソフト)導入

パッケージ管理は、glideからdepに移行されてるぽい。depはgo公式で開発されているので今後はdepを使うようにした方がよさそう。go getでglobalにdepをインストールする。

$ go get -u github.com/golang/dep/cmd/dep

depは、goのソースコードが1つもないと動かないようなのでとりあえずhello.goを用意する。
※ensureで、「no dirs contained any Go code」と怒れられるみたい。

src/app/hello.go

package main
import "fmt"
func main() {
  fmt.Printf("Hello")
}

を作成後に以下のコマンドで初期化とechoのインストールを行う。

$ cd src/app

# 初期化処理
$ dep init

# echoのインストール
$ dep ensure -add github.com/labstack/echo

src/app/vendor以下にechoがinstallされる。今後、外部のパッケージを追加する際は同じ要領で追加していく。

ルーティングを設定してget, post, put, deleteメソッドに対応してjsonを返す

とりあえず、echoのインストールまでできたので実際にプログラムを書いていく。echoの公式ページにhello worldがあるので、こちらを試してみる。

app/server.go

package main

import (
  "net/http"
  "github.com/labstack/echo"
)

func main() {
  e := echo.New()
  e.GET("/", func(c echo.Context) error {
    return c.String(http.StatusOK, "Hello, World!")
  })
  e.Logger.Fatal(e.Start(":1323"))
}

ビルドして起動

# ビルド
$ go build -o bin/server src/app/server.go

# 起動
$ server (./bin/serverだけどpathを通しているのでserverでOK)

ブラウザで「http://localhost:1323」を表示して、「Hello, World!」と表示されればOK。とりあえずechoが動作することが確認できたので、次は/api/membersというURLにget, post, put, deleteでアクセスした際にレスポンスをかえすようにする。

server.go

package main

import (
  "net/http"
	
  "github.com/labstack/echo"
)

func main() {
  e := echo.New()

  // /api/members でアクセス
  api := e.Group("/api")
  {
    api.GET("/members", func(c echo.Context) error {
      return c.String(http.StatusOK, "members GET")
    })
    api.POST("/members", func(c echo.Context) error {
      return c.String(http.StatusOK, "members POST")
    })
    api.PUT("/members", func(c echo.Context) error {
      return c.String(http.StatusOK, "members PUT")
    })
    api.DELETE("/members", func(c echo.Context) error {
      return c.String(http.StatusOK, "members POST")
    })
  }
  e.Logger.Fatal(e.Start(":1323"))
}

Groupを使うと階層毎にまとめられる。この例では単純なのでこれで良いが実際にはもっとコード量も増えてくるのでファイルを分割する。
ここでは以下を分ける方針とする。まぁこの辺りの分け方や構成はどんな形がベストなのかまだわからないけれども。。
・ルーティング処理
・各コントローラ

また、apiを想定しているのでレスポンスもjsonにする。

route/router.go

package route

import (
  "app/controller"
  "github.com/labstack/echo"
)

func Init() *echo.Echo {
	
  e := echo.New()

  api := e.Group("/api")
  {
    api.GET("/members", controller.GetMembers())
    api.POST("/members", controller.PostMembers())
    api.PUT("/members", controller.PutMembers())
    api.DELETE("/members", controller.DeleteMembers())
  }
  return e
}

controller/member.go

package controller

import(
  "net/http"

  "github.com/labstack/echo"
)

type Member struct {
  Id int `json:"id"`
  Name string `json:"name"`
}

func GetMembers() echo.HandlerFunc {
  return func(c echo.Context) error {
    members := []Member {
      {Id: 1, Name: "ユーザー1"},
      {Id: 2, Name: "ユーザー2"},
    }
    return c.JSON(http.StatusOK, members)
  }
}

// POST, PUT, DELETEも同じ形で・・・

server.go

package main

import (
  "app/route"
)

func main() {
  e := route.Init()
  e.Logger.Fatal(e.Start(":1323"))
}

JSONのレスポンスは適当な形だが、実際に作り込む際はフォーマットも検討が必要になると思う。

環境毎にconfigファイルを定義

開発環境と本番環境とでDBの設定ファイル等を分けたりはよくやると思うがそれをechoでもやる。
今回は、設定をyamlで管理できるgopkg.in/yaml.v2 こちらを使ってみる。

まずはパッケージをインストール

$ cd src/app
$ dep ensure -add gopkg.in/yaml.v2

configというディレクトリを作ってconfigパッケージとして管理しようと思う。

src/app/config/environment.yml

development:
  db:
    user: dev_user
    password: dev_pass
    name: dev_db

production:
  db:
    user: pro_user
    password: pro_pass
    name: pro_db

設定を読み込むためのパッケージを作る
src/app/config/environment.yml

package config

import(
  "io/ioutil"
  yaml "gopkg.in/yaml.v2"
)

var Config Conf

type Environment struct {
  Development Conf `yaml:"development"`
  Production Conf `yaml:"production"`
}

type Conf struct {
  Database Database `yaml:"db"`
}

type Database struct {
  User string `yaml:"user"`
  Password string `yaml:"password"`
  Name string `yaml:"name"`
}

func SetEnvironment(env string) {
	
  buf, err := ioutil.ReadFile("src/app/config/environment.yml")
  if err != nil {
    panic(err)
  }

  var environment Environment
	
  err = yaml.Unmarshal(buf, &environment)
  if (err != nil) {
    panic(err)
  }

  if (env == "development") {
    Config = environment.Development
  } else {
    Config = environment.Production
  }
}

実際に呼び出してみる。

src/app/server.go

package main

import (
  "app/route"
  "app/config"

  "flag"
  "fmt"
)

func main() {
  // 設定ファイル読込
  setConfig()

  // 確認用出力
  c := config.Config.Database
  fmt.Printf("DBユーザー::%s", c.User)

  ・・・
}

// コマンドライン引数でproが指定された場合は本番環境の設定を取得
func setConfig() {
  env := "development"
  flag.Parse()
  if args := flag.Args(); 0 < len(args) && args[0] == "pro" {
    env = "production"
  }
  config.SetEnvironment(env)
}

ビルドして起動してみると、期待したどおりの動きとなることが確認できる。

$ server
DBユーザー::dev_user

$ server pro
DBユーザー::pro_user

gormでmysqlを使用

「go mysql」で検索するとgormというormマッパーがけっこう使われているぽいのでこちらを使ってみる。まずは、先ほど作った設定ファイルからDB情報を取得して接続するところまでを実装する。db関連の処理はapp/dbフォルダ以下にまとめてdbパッケージとして管理したいと思う。

gormのインストール

$ cd src/app
$ dep ensure -add github.com/jinzhu/gorm
$ dep ensure -add github.com/go-sql-driver/mysql

app/db/db.go

package db

import(
  "app/config"
  "fmt"
  "github.com/jinzhu/gorm"
  _ "github.com/jinzhu/gorm/dialects/mysql"
)

var db *gorm.DB
var err error

func Init() {
	
  c := config.Config.Database
	
  user := c.User
  password := c.Password
  dbname := c.Name
	
  db, err = gorm.Open("mysql", user + ":" + password + "@/" + dbname + "?charset=utf8&parseTime=True&loc=Local")
  if err != nil {
    panic(fmt.Sprintf("[Error]: %s", err))
  }
}

func GetConnection() *gorm.DB {
  return db
}

サーバー起動時にDB接続処理を行うようにする。

app/server.go

package main

import (
  "app/route"
  "app/config"
  "app/db"

  "flag"
)

func main() {

  setConfig()

  db.Init()

  ・・・
}

コントローラでDBからデータを取得する。

app/db/member.go

package db

import(
  "github.com/jinzhu/gorm"
  _ "github.com/jinzhu/gorm/dialects/mysql"
)

type Member struct {
  gorm.Model
  Name string
}

app/controller/members.go

package controller

import(
  "app/db"
  "net/http"
	
  "github.com/labstack/echo"
)

func GetMembers() echo.HandlerFunc {
  return func(c echo.Context) error {

    members := []db.Member{}
    db := db.GetConnection()
    data := db.Find(&members)
		
    return c.JSON(http.StatusOK, data)
  }
}
・・・

gormの細かい使い方は都度調べる必要があるが、イメージはこんなかんじでmembersテーブルの全レコードをかえす形のapiが作れた。

ログ出力

アプリ独自のログ出力できるようにする。logrusというパッケージを使う。ここでは単純に初期化だけ行なっているが、フォーマットや複数ファイルにログを出力したい場合はプロジェクトごとに設定するのがよいと思う。 

app/log/log.go

package log

import (
  "os"
  "fmt"
  "github.com/Sirupsen/logrus"
)

var AppLog *logrus.Logger = logrus.New()

func Init() {

  file, err := os.OpenFile("app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
  if err != nil {
    panic(fmt.Sprintf("[Error]: %s", err))
  }

  AppLog.Out = file
  AppLog.Formatter = &logrus.JSONFormatter{}
}

app.server.go

package main

import (
  "app/route"
  "app/config"
  "app/db"
  "app/log"

  "flag"
)

func main() {

  setConfig()

  db.Init()

  // 初期化してログ出力
  log.Init()
  log.AppLog.Info("サーバーが起動しました");

  ・・・
}

エラー処理

エラー処理については、サーバーエラーとビジネスエラーとを決まった形式のJSONでかえすようにしたい。

app/error/system.go

package error

import(
  "fmt"
)

type SystemError struct {
  Message string
  LogMessage string
}

func (err *SystemError) Error() string {
  return fmt.Sprintf("%s", err.Message)
}

app/error/business.go

package error

import(
  "fmt"
)

type BusinessError struct {
  Message string
  LogMessage string
}

func (err *BusinessError) Error() string {
  return fmt.Sprintf("%s", err.Message)
}

app/handler/handler.go

package handler

import(
  AppErr "app/error"
  "net/http"
  "github.com/labstack/echo"
)

type ApiError struct {
  Status int `json:status`
  Message string `json:message`
}
func JSONErrorHandler(err error, c echo.Context) {

  switch e := err.(type) {
  case *AppErr.BusinessError:
    // Business Error
    c.JSON(
      http.StatusOK, ApiError{
        Status: 200,
        Message: e.Message,
      })
  case *AppErr.SystemError:
    // System Error
    c.JSON(
      http.StatusOK, ApiError{
        Status: 500,
        Message: e.Message,
      })
  default:
    if he, ok := err.(*echo.HTTPError); ok {
      if he.Code == 404 {
        // 404
        c.JSON(he.Code, ApiError{
          Status: he.Code,
          Message: "Not Found",
        })
      } else {
        // その他サーバーエラー
        c.JSON(he.Code, ApiError{
          status: he.Code,
          Message: "System Error",
        })
      }
    }
  }
}

server.go

package main

import (
  ・・・
  "app/handler"
  ・・・
)

func main() {
  ・・・ 
  e := route.Init()
  e.HTTPErrorHandler = handler.JSONErrorHandler
  ・・・
}

こんな感じでエラーハンドリングできる。今回はこんな感じにしてしまったけど、ここはけっこう悩ましくてどう実装するのがよいか引き続き考えてゆきたいと思う。

go-server-starterでプロセス監視

あとでまとめる

おわりに

とりあえずこれくらい書いておけば後は要件ごとに調べながら実装まで根性でもっていけるかな。goの文法とかちゃんとやらないとダメですね。
rubyとかnode.jsとかもそうだけど1つのマシンに複数バージョンの言語を入れるのが当たり前になってる。以上です。

参考URL
http://tech.aainc.co.jp/archives/10945