🔖

Echo(Go)でバックエンドAPIを開発するときに知っておきたい最低限のこと

2024/10/25に公開

Goは何となく読める(書くのはちとしんどい)けど、Echoを使ったAPI開発は初めてという自分向けに、最低限知っておいたほう良さそうなことをまとめました。
概ね公式ガイドの内容となっております。バージョンはv4について記載してます。
https://echo.labstack.com/docs/category/guide

Echoとは

GoのWebフレームワークの1つで、高速・高い拡張性・シンプルをコンセプトに作られています。

https://echo.labstack.com/
https://github.com/labstack/echo

特徴

  • RESTful APIを作りやすい
    • ルーティングをまとめるGroupが便利
  • データバインディングの表現が豊富
    • リクエストは、パスパラメータ、クエリ、Form、JSON、XML、マルチパートなどをサポート
  • Middlewareで簡単に拡張できる

インストール

mkdir go-echo
cd go-echo
go mod init go-echo
go get -u github.com/labstack/echo/v4

Echoを使ったAPIサーバーの実装

全体の流れ

試しに2つのエンドポイントを持つAPIサーバーを作ってみます。

package main

import (
	"net/http"

	"github.com/labstack/echo/v4"
)

func hello(c echo.Context) error {
	return c.NoContent(http.StatusOK)
}

type createTodoRequest struct {
	Title string `json:"title"`
}

type createTodoResponse struct {
	ID int `json:"id"`
}

func createTodo(c echo.Context) error {
	req := new(createTodoRequest)
	if err := c.Bind(req); err != nil {
		return err
	}

	return c.JSON(http.StatusCreated, &createTodoResponse{ID: 1})
}

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

	e.GET("/", hello)
	e.POST("/todo", createTodo)

	e.Start(":1323")
}

エントリポイントとなるmain関数内で、echo.New()Echoのインスタンスを作成しています。このインスタンスがEchoのAPIサーバー本体になっており、ルーティングや後述するミドルウェアの設定を行い、最後にContext#Start(HTTPSの場合は、Context#StartTLS)で起動します。

GET /POST /todoのエンドポイントに対応するハンドラー(helloとcreateTodo)を登録しています。ハンドラー関数は、echo.Contextを引数に取ってerrorを返す(HandlerFunc型の)関数で、リクエストを受け取ってレスポンスを作る処理を記述します。
echo.Contextは、ハンドラが処理しているリクエスト・レスポンスの情報や、それらを操作するためのメソッドを持ちます。JSONのリクエストボディから構造体へのバインドや、パラスパラメータの取得、HTTPステータスのセットなどは、このecho.Contextを使って行うことになります。

ルーティング

全体を把握したところで、各機能の詳細について見ていきます。まずはルーティングです。

リクエストは、HTTPメソッド(GETやPOSTなど、Echoのメソッドと対応)とパスのパターンの組み合わせでルーティングされます。
パスのパターンには、固定文字列(/users/1など)、パスパラメータ(/users/:idなど)、ワイルドカード(/users/*など)を指定できます。

e.GET("/", hello) // GET / => hello
e.POST("/todo", createTodo) // POST /todo => createTodo
e.PUT("/todo/:id", updateTodo) // PUT /todo/1, PUT /todo/abc => updateTodo
e.GET("/images/*", getImage) // GET /images/1.jpg, GET /images/abc/def => getImage

ルーティング情報は内部的には基数木で表現されており、Echoインスタンスに登録した順に依らずに、最もマッチするパスに対応するハンドラが呼ばれます。
複数マッチした場合の優先順位は、固定文字列、パスパラメータ、ワイルドカードの順になります。

e.GET("/", rootHandler) // GET /
e.GET("/users", usersHandler) // GET /users
e.GET("/users/:id", usersIDHandler) // GET /users/2, GET /users/abc, ...
e.GET("/users/1", users1Handler) // GET /users/1
e.GET("/users/:id/hello", usersIDHandler) // GET /users/2/hello, GET /users/abc/hello, ...
e.GET("/users/1/hello", usersIDHandler) // GET /users/1/hello
e.GET("/users/*", usersWildcardHandler) // GET /users/2/hoge, GET /users/abc/hoge/fuga, ...

Group

共通のプレフィックス(/admin, /api/v1など)を持つルーティングをまとめるGroupという機能があります。
GroupごとにルーティングやMiddlewareを設定できます。

adminGroup := e.Group("/admin")
adminGroup.GET("/roles", getAdminRolesHandler) // GET /admin/roles

リクエスト

Echoではパス、クエリ、ヘッダー、リクエストボディを介して渡された情報を、ハンドラ内で直接取得したり、構造体にバインドしたりするための仕組みが用意されています。
構造体にバインドする場合はContext#Bind、値を直接取得する場合はContext#ParamやContext#QueryParamなどのメソッドを使います。

構造体タグでjson:"title"のように指定することで、JSONのキーと構造体のフィールドを紐付けることができます。json以外にも、パスパラメータをparam、クエリパラメータをquery、フォームデータをform、ヘッダーをheaderとしてバインドできます。

// JSONリクエストボディを構造体にバインド
type postTodosNewRequest struct {
    Title   string `json:"title"`
    Content string `json:"content"`
    Done    bool   `json:"done"`
    DueDate string `json:"dueDate"`
}

// curl -i -X POST --url 'http://localhost:1323/todos/new' --header 'Content-Type: application/json' --data '{"title":"Title","content":"Content"}'
e.POST("/todos/new", func(c echo.Context) error {
    // id := c.Param("id") でもパスパラメータを取れる(string型なので注意)
    var req postTodosNewRequest
    if err := c.Bind(&req); err != nil {
        return err
    }
    fmt.Printf("%+v\n", req) // {Title:Title Content:Content Done:false DueDate:}
    return c.NoContent(http.StatusCreated)
})

type getTodosRequest struct {
    ID    int    `param:"id"`
    Title string `query:"title"`
    Done  bool   `query:"done"`
}

// curl -i -X GET --url 'http://localhost:1323/todos?title=Title&done=true'
e.GET("/todos", func(c echo.Context) error {
    // title := c.QueryParam("title") でクエリパラメータを取れる(こちらもstring型)
    var req getTodosRequest
    if err := c.Bind(&req); err != nil {
        return err
    }
    fmt.Printf("%+v", req) // {ID:0 Title:Title Done:true}
    return c.NoContent(http.StatusOK)
})

バリデーション

Echo本体にはリクエストをバリデーションする機能は無く、go-playground/validatorを導入して、構造体のタグで実装することが多いかと思います。

go get github.com/go-playground/validator

バリデーションは、構造体のフィールドにvalidate:"required"のようにタグを付けることで実装できます。requiredemaillenminmaxなどが使えます。

import (
    // ...
    "github.com/go-playground/validator"
)

validate := validator.New()

e.POST("/users/new", func(c echo.Context) error {
    type postUsersRequest struct {
        Name     string `json:"name" validate:"required"`
        Email    string `json:"email" validate:"required,email"`
        Birthday string `json:"birthday" validate:"len=10"`
    }

    var req postUsersRequest
    if err := c.Bind(&req); err != nil {
        return err
    }

    if err := validate.Struct(&req); err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"message": err.Error()})
    }

    return c.NoContent(http.StatusCreated)
})
$ curl -i -X POST --url 'http://localhost:1323/users/new' --header 'Content-Type: application/json' --data '{"name":"Tanaka","email":"tanaka@hoge.fuga.com","birthday":"2000-01-01"}'
HTTP/1.1 201 Created
Date: Fri, 25 Oct 2024 00:32:20 GMT
Content-Length: 0


$ curl -i -X POST --url 'http://localhost:1323/users/new' --header 'Content-Type: application/json' --data '{"name":"","email":"tanaka_hoge.fuga.com","birthday":"2000"}'     
HTTP/1.1 400 Bad Request
Content-Type: application/json
Date: Fri, 25 Oct 2024 00:32:41 GMT
Content-Length: 299

{
  "message": "Key: 'postUsersRequest.Name' Error:Field validation for 'Name' failed on the 'required' tag\nKey: 'postUsersRequest.Email' Error:Field validation for 'Email' failed on the 'email' tag\nKey: 'postUsersRequest.Birthday' Error:Field validation for 'Birthday' failed on the 'len' tag"
}

Context#ValidatorにValidatorインスタンスをセットしておくと、Context#Validateでバリデーションを行うこともできます。

type Validator struct {
	validator *validator.Validate
}

func (v Validator) Validate(i interface{}) error {
	return v.validator.Struct(i)
}

e.Validator = Validator{validator: validator.New()}

e.POST("/users/new", func(c echo.Context) error {
    // ...
    if err := c.Validate(&req); err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"message": err.Error()})
    }
    // ...
})

レスポンス

レスポンスもContextのメソッドを使って生成できます。
バックエンドAPIでよく使うのはContext#JSON、Context#NoContent(HTTPステータスを返すだけ)、Context#Stringあたりかと思います。いずれも第1引数にHTTPステータスコードを渡します。

type getTodosResponseItem struct {
    ID      int       `json:"id"`
    Title   string    `json:"title"`
    Done    bool      `json:"done"`
    DueDate time.Time `json:"dueDate"`
}

type getTodosResponse struct {
    Todos []getTodosResponseItem `json:"todos"`
}

e.GET("/todos", func(c echo.Context) error {
    return c.JSON(
        http.StatusOK,
        &getTodosResponse{
            Todos: []getTodosResponseItem{
                {ID: 1, Title: "Title", Done: true, DueDate: time.Now()},
                {ID: 2, Title: "Title", Done: false, DueDate: time.Now()},
            },
        },
    )
})
// curl -X GET --url 'http://localhost:1323/todos' | jq
// {
//   "todos": [
//     {
//       "id": 1,
//       "title": "Title",
//       "done": true,
//       "dueDate": "2024-10-25T05:36:39.321187+09:00"
//     },
//     {
//       "id": 2,
//       "title": "Title",
//       "done": false,
//       "dueDate": "2024-10-25T05:36:39.321187+09:00"
//     }
//   ]
// }

e.GET("/todos", func(c echo.Context) error {
    return c.NoContent(http.StatusOK)
})
// curl -i -X GET --url 'http://localhost:1323/todos'
// HTTP/1.1 200 OK
// Date: Thu, 24 Oct 2024 20:43:11 GMT
// Content-Length: 0
//

e.GET("/todos", func(c echo.Context) error {
    return c.String(http.StatusBadRequest, "Bad Request")
})
// url -i -X GET --url 'http://localhost:1323/todos'
// HTTP/1.1 400 Bad Request
// Content-Type: text/plain; charset=UTF-8
// Date: Thu, 24 Oct 2024 20:45:20 GMT
// Content-Length: 11

// Bad Request

Middleware

Middlewareは、ハンドラーの前後に処理を挟むことができる仕組みで、Basic AuthやJWTなどの認証、Gzipによる圧縮/解凍、リクエストID付与やロギングなどの共通的な処理を、全てのエンドポイントや特定のGroupのエンドポイントを対象に適用することができます。

go get github.com/labstack/echo/v4/middleware

Logger

リクエスト/レスポンスをロギングしたいケースはしばしばあります。こういうケースは、Echo#Useでmiddleware.Logger関数をセットしておくと全てのエンドポイントに対して、リクエスト/レスポンスの情報をログに出力してくれます。

e.Use(middleware.Logger())
e.GET("/hello", func(c echo.Context) error {
    fmt.Println("hello")
    return c.NoContent(http.StatusOK)
})

curl -i -X GET --url 'http://localhost:1323/hello'でリクエストを送ると、レスポンスを返すときに以下のようなログが出力されます。
middleware.LoggerWithConfigでは、ログの出力形式も変更できます。

http server started on [::]:1323
hello
{"time":"2024-10-25T06:06:07.841034+09:00","id":"","remote_ip":"::1","host":"localhost:1323","method":"GET","uri":"/hello","user_agent":"curl/8.7.1","status":200,"error":"","latency":13667,"latency_human":"13.667µs","bytes_in":0,"bytes_out":0}

Recover

Echoはリクエストごとにgoroutineを立ち上げて処理を行っているので、ハンドラ内でpanicが発生しても、他のリクエストの処理に影響を与えないようになっています。

しかしハンドラ内でpanicが発生した場合、何も設定しなければ、リクエストを処理しているgoroutineが終了し、レスポンスが返されることはありません。
本番運用ではちとツライのでmiddleware.Recoverを使って、500エラーを返すようにできます。Goのdeferとrecoverを使ってハンドラ内で自前で実装できますが、panicは予期せぬところでおきるものなので、middleware.Recoverを使って全エントリポイントに設定してしまうのが楽かと思います。

e.Use(middleware.Recover())
e.GET("/panic", func(c echo.Context) error {
    panic("panic")
})

GET /panicにリクエストすると500エラーが返されています。

$ curl -i -X GET --url 'http://localhost:1323/panic'
HTTP/1.1 500 Internal Server Error
Content-Type: application/json
Date: Thu, 24 Oct 2024 21:22:13 GMT
Content-Length: 36

{"message":"Internal Server Error"}

Middlewareを自分で実装する

もちろんMiddlewareを自分で実装することもできます。
func(next echo.HandlerFunc) echo.HandlerFuncを返す関数です。生成した無名関数内でハンドラー関数を実行するのですが、その前後で任意の処理(ここではPrintln)ができるのがわかるかと思います。

func printMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		fmt.Println("printMiddleware: start")
		if err := next(c); err != nil {
			return err
		}
		fmt.Println("printMiddleware: end")
		return nil
	}
}

e.Use(printMiddleware)
e.GET("/hello", func(c echo.Context) error {
    fmt.Println("hello")
    return c.NoContent(http.StatusOK)
})
// $ curl -i -X GET --url 'http://localhost:1323/hello'で、以下のように出力される
// printMiddleware: start
// hello
// printMiddleware: end

サーバー設定

Debug出力

Echoのデバッグ出力を有効にするには、Echo#Debugをtrueに設定します。

e.Debug = true

エラー処理

ハンドラからerrorを返された場合に、レスポンスを集約・処理したいケースがしばしばあります。
Echo#HTTPErrorHandlerにエラーハンドラを設定することで、全てのエンドポイントで発生したエラーをまとめて、レスポンスを作ることができます。
以下では、エラー内容をログに出力し、"message"キーにエラーメッセージを持つJSONを返すエラーハンドラを設定しています。

e.GET("/bad_request_error", func(c echo.Context) error {
    return echo.ErrBadRequest
})

e.GET("/other_error", func(c echo.Context) error {
    return errors.New("other error")
})

e.HTTPErrorHandler = func(err error, c echo.Context) {
    type errorResponse struct {
        Message string `json:"message"`
    }
    c.Logger().Error(err)
    if he, ok := err.(*echo.HTTPError); ok {
        c.JSON(he.Code, errorResponse{
            Message: he.Message.(string),
        })
    } else {
        c.JSON(http.StatusInternalServerError, errorResponse{
            Message: err.Error(),
        })
    }
}

e.Start(":1323")

echo.ErrBadRequestなどの場合はそれに対応するエラーコード、echo.Err某以外のエラーでは500を返しています。

$ curl -i -X GET --url 'http://localhost:1323/bad_request_error'
HTTP/1.1 400 Bad Request
Content-Type: application/json
Date: Thu, 24 Oct 2024 22:32:00 GMT
Content-Length: 31

{
  "message": "Bad Request"
}

$ curl -i -X GET --url 'http://localhost:1323/other_error'      
HTTP/1.1 500 Internal Server Error
Content-Type: application/json
Date: Thu, 24 Oct 2024 22:32:13 GMT
Content-Length: 31

{
  "message": "other error"
}

テスト

最後にテストコードの書き方についても触れておきます。
楽するためにtestifyを使っています。

go get github.com/stretchr/testify/assert@v1.9.0
main.go
package main

import (
	"errors"
	"net/http"

	"github.com/labstack/echo/v4"
)

type createTodoRequest struct {
	Title string `json:"title"`
}

type createTodoResponse struct {
	ID int `json:"id"`
}

func createTodo(c echo.Context) error {
	req := new(createTodoRequest)
	if err := c.Bind(req); err != nil {
		return err
	}

	return c.JSON(http.StatusCreated, &createTodoResponse{ID: 1})
}

func main() {
	e := echo.New()
    postTodoRoute := e.POST("/todo", createTodo)
    e.Start(":1323")
}

テストコードはこんな感じ、入力となるリクエストを作って、それをecho.Contextにセットして、ハンドラーを実行しています。
Context作成時にhttptest.NewRecoderを渡すことでレスポンスがキャプチャされるので、それに対してアサーションする、という実装になってます。

main_test.go
package main

import (
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/labstack/echo/v4"
	"github.com/stretchr/testify/assert"
)

func TestCreateTodo(t *testing.T) {
	e := echo.New()

	req := httptest.NewRequest(echo.POST, "/todo", strings.NewReader(`{"title":"test"}`))
	req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)

	rec := httptest.NewRecorder()
	c := e.NewContext(req, rec)

	if assert.NoError(t, createTodo(c)) {
		assert.Equal(t, http.StatusCreated, rec.Code)
		assert.Equal(t, "{\"id\":1}\n", rec.Body.String())
	}
}

まとめ

Echoを使ったAPIサーバーの実装方法についてまとめました。
高い生産性(Copilotやコピペ、数が必要なバックエンドAPI開発でありがち)で書いたコードをデバッグしたり、既存のコードを読み解いたりする際のお役に立てると幸いです。

Discussion