1. はじめに
ひととおりechoの機能を確かめるために、簡単なWebAPIのアプリを試作して主要な機能は把握できました。
その過程でまとまったコードが書けたので、せっかくならとハンズオン形式で公開しようと思い、記事にしました。お役に立てれば幸いです。
問題点等ありましたら教えてください。
目次
2. フォルダ構成
WebAPIなので、本来であればsqlなどをつかってデータベースに情報を書き込んり読みだしたりして編集することになりますが、sqlの準備は少し面倒だったので今回は変数をデータベース代わりにし、そのためのパッケージをローカルに作りました。
フォルダ構成は以下の通りです。
go-handson
│ compose.yaml
│ Dockerfile
│ README.md
│
└─work
│ go.mod
│ go.sum
│ main.go
│
├─handlers
│ handlers.go
│
└─warehouse
warehouse.go
Goのファイルは三つだけです。
3. HTTPメソッド
HTTPに定義されてある、リソースに対して実行してほしい操作を指定する一連のメソッドのことをHTTPメソッドと言います。
ここでは、get, post, patch, deleteの四つを実装しました。
patchではなくputを使うコードもあります。どちらも「更新」を示していますが、patchは部分的な編集に対してputはデータ全体の置き換えを意味しています。データが大きくなればなるほど全体の更新は処理が重くなることが予想されますので、特にこだわりがなければpatchを使う方がよいと思います。
3-1. 基本設定
私はDockerで環境構築していますが、プロダクトをつくるためのGoコマンドやコーディングは同じなので特にDockerで環境をつくらなくても大丈夫です。
コンテナー内のgoのバージョンは1.22ですので、バージョンが気になる方はここだけでも揃えておいても良いかもしれません。
まず、Goのパッケージを初期化するためのコマンドを打ちます。
この際、パッケージ名を定めることになるのですが、一般的にはgithubに登録したリモートリポジトリと同じにすることが推奨されます。
go mod init github.com/<username>/<product name>/(<file directory>)
このプロダクトの場合、Goのファイルはすべてworkディレクトリーに入れておりgo.modファイルが作られるのもこのディレクトリーです。つまり、ここがこのGoパッケージのルートディレクトリーと同じになるので、プロダクトの名前の後にファイルディレクトリーまで繋げました。
Dockerfileなどをサブディレクトリーにおいてルートディレクトリーで初期化する場合などは、file directoryを書く必要はありません。
続いて、echoを取得します。
go get github.com/labstack/echo/v4
と go get github.com/labstack/echo/v4/middleware
をすれば、開発に必要な外部パッケージのインストールは完了します。
最後に、メソッドを定義する前の下準備を終わらせます。
まず、サーバー構築の部分です。
package main
import (
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
func main() {
e := echo.New()
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.Logger.Fatal(e.Start(":8080"))
}
インスタンス化したechoに対していくつかの処理を加えます。
middleware.Loggerでアクセスログを出力し、middleware.Recoverでアプリ内の処理の中で生じたpanicから復帰し、アプリを継続するようにします。
e.Startでサーバーとして起動します。
次に、データベース代わりの変数定義の部分です。
package warehouse
type User struct{
Id int `json:"id" xml:"id" form:"id" query:"id"`
Name string `json:"name" xml:"name" form:"name" query:"name"`
Age int `json:"age" xml:"age" form:"age" query:"age"`
}
type UserWareHouse struct{
LastId int
UserList []User
}
var UserWH = UserWareHouse{
LastId: 3,
UserList: []User{{
Id: 1,
Name: "Joe Biden",
Age: 82,
},
{
Id: 2,
Name: "Donald Trump",
Age: 79,
},
{
Id: 3,
Name: "Robert Kennedy Junior",
Age: 70,
},
},
}
User構造体でjson:"name" xml:"name"...
としているのは、構造体へのタグバインドの為です。
データを取得するタグと対応するキーを指定すれば、postメソッドなどのリクエストに含まれるデータと自動的に対応を確認して構造体のプロパティに値を結び付けてくれます。
これで基本的な下準備は終わりました。
3-2. GetUser
ここでは、idを指定してユーザーを取得するメソッドを定義します。
分かりやすさのために、まずmain関数から見ます。
package main
import (
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/yupon-pro/go-handson/work/handlers"
)
func main() {
e := echo.New()
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.GET("/users/:id", handlers.GetUser)
e.Logger.Fatal(e.Start(":8080"))
}
handlersパッケージからGetUserハンドラ―関数を取得して、e.Get関数の第二引数に渡しています。第一引数はこのハンドラーを作動させたいURLを指定します。
"/users/:id"
の:id
となっている部分が、パスパラメータに当たります。つまり、この関数は/users/1や/users/2に対応して発火するということです。
続いて、handlersパッケージの中を見ていきます。
package handlers
import (
"net/http"
"strconv"
"github.com/labstack/echo/v4"
)
func GetUser(c echo.Context) error {
id, err := strconv.Atoi(c.Param("id"))
if err != nil{
return c.String(http.StatusBadRequest, "The id is wrong.")
}
}
パスパラメータは、c.Param("id")
の返り値として取得することができます。
ここでは、パスパラメータは数値を想定しているので、数値に変換できないのであればURLのアクセスが誤っていることになります。きちんとレスポンスを返してあげましょう。
c? echo?
ハンドラ―関数の引数として指定しているcはecho.Context型の値です。この記事の説明では実際のコードに則って全てcとしますが、echoと読み替えても同じです。
このままでは正常にパスパラメータが取得された場合のレスポンスがありません。
先程のコードに続けて記述します。
package handlers
import (
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"github.com/yupon-pro/go-handson/work/warehouse"
)
func GetUser(c echo.Context) error{
id, err := strconv.Atoi(c.Param("id"))
if err != nil{
return c.String(http.StatusBadRequest, "The id is wrong.")
+ for _,v := range warehouse.UserWH.UserList{
+ if v.Id == id{
+ return c.JSON(http.StatusOK, v)
+ }
+ }
+ return c.String(http.StatusBadRequest, "There was no user information you specified.")
}
for range 文でUserListの中をループで取り出しつつ、各Userデータの内idで一致するものが見つかればそれをレスポンスとして返却しています。
JSON形式でレスポンスを返したかったらreturn c.JSON()
を、エラーを通知するなど単に文字列だけを返すのでよければreturn c.String()
を記述することがお勧めです。
3-3. PostUser
package main
import (
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/yupon-pro/go-handson/work/handlers"
)
func main() {
e := echo.New()
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.GET("/users/:id", handlers.GetUser)
e.POST("/users", handlers.PostUser)
e.Logger.Fatal(e.Start(":8080"))
}
idはuserデータが登録された後に付与されるので、idを指定してpostすることはありません。このためパスパラメータは指定せずにURLを与えています。
次にハンドラ―関数を設定します。
package handlers
import (
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"github.com/yupon-pro/go-handson/work/warehouse"
)
func PostUser(c echo.Context) error{
u := new(warehouse.User)
if err := c.Bind(u); err != nil{
return c.String(http.StatusBadRequest, "Bad Request!")
}
}
c.Bindとは、リクエストで受け取ったリソースを構造体のプロパティに結びつける関数です。
errが出てしまった時はエラーを通知するレスポンスを返してあげましょう。
また、c.Bindには、newによってポインタ型の構造体を渡しています。ポインタではなく値を渡した場合は、c.Bind関数に渡した後もuに変更が反映されないので注意してください。
package handlers
import (
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"github.com/yupon-pro/go-handson/work/warehouse"
)
func PostUser(c echo.Context) error{
u := new(warehouse.User)
if err := c.Bind(u); err != nil{
return c.String(http.StatusBadRequest, "Bad Request!")
}
+ warehouse.UserWH.LastId++
+ u.Id = warehouse.UserWH.LastId
+ warehouse.UserWH.UserList = append(warehouse.UserWH.UserList, *u)
+ return c.JSON(http.StatusCreated, u)
}
Userデータを登録するたびに、Idは更新しておかないといけません
LastIdは最後に登録されたユーザーデータのidの値と等しくしているので、u.IdにLastIdを代入する前に値を増加させています。
UserListに新しく今回登録されたユーザーデータを追加しますが、uが構造体のポインタ型であったので、デリファレンスすることを忘れないでください。
3-4. PatchUser
main関数でのハンドラ―関数の登録方法はだいたい察しが付くと思うので、ハンドラー関数だけ紹介します。
package handlers
import (
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"github.com/yupon-pro/go-handson/work/warehouse"
)
func PatchUser(c echo.Context) error{
id, err := strconv.Atoi(c.Param("id"))
if err != nil{
return c.String(http.StatusBadRequest, "The id is wrong.")
}
age := c.FormValue("age")
name := c.FormValue("name")
if age == "" && name == "" {
return c.String(http.StatusBadRequest, "Please provide either name or age.")
}
}
リクエストボディから値を取得するためには、c.FormValue
を使います。ここでは使いませんでしたが、一気に値を取得したいのであればFormValuesを使うことになります。
データがなければ空の文字列が渡されます。ユーザーが変更可能なデータはageとnameの二つですが、両方ともなければ変更が起きないのでエラーとして返します。
(ここではBadRequestとしていますが、NotModifiedも使えるかもしれません)
このままでは、nameかageどちらか一方の値を取得できた場合に処理がないので、ただしいハンドラ―関数とは言えません。
続いて更新処理を記述します。
package handlers
import (
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"github.com/yupon-pro/go-handson/work/warehouse"
)
func PatchUser(c echo.Context) error{
id, err := strconv.Atoi(c.Param("id"))
if err != nil{
return c.String(http.StatusBadRequest, "The id is wrong.")
}
age := c.FormValue("age")
name := c.FormValue("name")
if age == "" && name == "" {
return c.String(http.StatusBadRequest, "Please provide either name or age.")
}
+ for i, v := range warehouse.UserWH.UserList {
+ if v.Id == id {
+ if age != "" {
+ intAge, err := strconv.Atoi(age)
+ if err != nil {
+ return c.String(http.StatusBadRequest, "The type of age is wrong.")
+ }
+ warehouse.UserWH.UserList[i].Age = intAge
+ }
+ if name != "" {
+ warehouse.UserWH.UserList[i].Name = name
+ }
+ return c.JSON(http.StatusOK, warehouse.UserWH.UserList[i])
+ }
+ }
+ return c.String(http.StatusBadRequest, "There was no user information you specified.")
}
ageも数値でなければいけないので、idの時と同じようにこれが数値に変換可能かどうかを検証しています。
そのあとは、age, name共にUserList内の当該Userインスタンスの値を変更できれば大丈夫です。
for i,v := range {}のとき、vにはコピーされた変数が代入されます。このため、v.Nameを変更しても元のデータは何ら変化しないことに注意してください。
3-5. DeleteUser
ここまでくれば、だいたいDeleteの時はどうするか分かると思います。
package handlers
import (
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"github.com/yupon-pro/go-handson/work/warehouse"
)
func DeleteUser(c echo.Context) error{
id, err := strconv.Atoi(c.Param("id"))
if err != nil{
return c.String(http.StatusBadRequest, "The id is wrong.")
}
for i,v := range warehouse.UserWH.UserList{
if v.Id == id{
warehouse.UserWH.UserList = append(warehouse.UserWH.UserList[:i],warehouse.UserWH.UserList[i+1:]...)
return c.NoContent(http.StatusNoContent)
}
}
return c.String(http.StatusBadRequest, "There was no user information you specified.")
}
一つ注意したいのは、Goにおいて、スライスの値を削除するような関数が組み込みで提供されていないということです。
Pythonでいうpopのようなものがありません。このため、スライスすることで要素の削除処理を行っています。
3-6. GetUers
ちょっとした応用です。
先程のGetUserは、idで指定したuserのみをとってきました。
しかし、userデータをすべて取得したり、あるいはある条件に沿うもののみを取得したいと考えることがあります。
年齢の指定も可能なGetUsersハンドラ―関数を実装しましょう。
年齢の指定について80歳以上のUserデータと70歳以下のUserデータを取得視するとき、例えばURLはこのように指定することを考えます。
http://localhost:8080/users?age_over=80&age_under=70
これによってバイデン氏とケネディ氏が取得できるようなイメージです。
package handlers
import (
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"github.com/yupon-pro/go-handson/work/warehouse"
)
func GetUsers(c echo.Context) error {
aboveAgeStr := c.QueryParam("age_over")
belowAgeStr := c.QueryParam("age_under")
if aboveAgeStr == "" && belowAgeStr == "" {
return c.JSON(http.StatusOK, warehouse.UserWH.UserList)
}
}
クエリーパラメータで指定されたデータを取得するためには、c.QueryParam
を使います。
ここでは使いませんでしたが、一気にクエリーパラメータを取得したい場合はQueryParamsも使えます。
二つのクエリーパラメータが共に指定されていないときはデフォルトの挙動が採用されます。つまり、単にユーザーリストを返却するだけです。
年齢によるフィルタリングをかけるコードをつくるのはすこし手間がかかりました。
まず、フィルタリングコードの準備として、クエリーパラメータとして指定された年齢の値が数値に変換可能かどうかを検証しておきます。
package handlers
import (
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"github.com/yupon-pro/go-handson/work/warehouse"
)
func GetUsers(c echo.Context) error {
aboveAgeStr := c.QueryParam("age_over")
belowAgeStr := c.QueryParam("age_under")
if aboveAgeStr == "" && belowAgeStr == "" {
return c.JSON(http.StatusOK, warehouse.UserWH.UserList)
}
+ var aboveAge, belowAge int
+ var err error
+ if aboveAgeStr != "" {
+ aboveAge, err = strconv.Atoi(aboveAgeStr)
+ if err != nil {
+ return c.String(http.StatusBadRequest, "The type of age_over is wrong.")
+ }
+ }
+ if belowAgeStr != "" {
+ belowAge, err = strconv.Atoi(belowAgeStr)
+ if err != nil {
+ return c.String(http.StatusBadRequest, "The type of age_under is wrong.")
+ }
+ }
}
そして、以下が年齢によるフィルタリングです。
package handlers
import (
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"github.com/yupon-pro/go-handson/work/warehouse"
)
func GetUsers(c echo.Context) error {
aboveAgeStr := c.QueryParam("age_over")
belowAgeStr := c.QueryParam("age_under")
if aboveAgeStr == "" && belowAgeStr == "" {
return c.JSON(http.StatusOK, warehouse.UserWH.UserList)
}
var aboveAge, belowAge int
var err error
if aboveAgeStr != "" {
aboveAge, err = strconv.Atoi(aboveAgeStr)
if err != nil {
return c.String(http.StatusBadRequest, "The type of age_over is wrong.")
}
}
if belowAgeStr != "" {
belowAge, err = strconv.Atoi(belowAgeStr)
if err != nil {
return c.String(http.StatusBadRequest, "The type of age_under is wrong.")
}
}
+ var uLis []warehouse.User
+ for _, v := range warehouse.UserWH.UserList {
+ if aboveAgeStr != "" && belowAgeStr != "" && belowAge >= aboveAge{
+ if aboveAge <= v.Age && belowAge >= v.Age{
+ uLis = append(uLis, v)
+ }
+ }else{
+ if aboveAgeStr != "" && v.Age >= aboveAge {
+ uLis = append(uLis, v)
+ }
+ if belowAgeStr != "" && v.Age <= belowAge {
+ uLis = append(uLis, v)
+ }
+ }
+ }
+ return c.JSON(http.StatusOK, uLis)
}
ある年以上の指定をA、ある年以下の指定をBとします。
B>=Aのとき、「年齢がA以上且つB以下の範囲を満たすデータ」が指定されてあると判断します。
もしelse文内のコードのように、単にA以上の年齢のユーザー全てと、B以下の年齢のユーザー全てを選ぼうとすると、範囲外のユーザーのデータも出してしまいます。つまり、「A以上のデータまたはB以下のデータ」という指定と同じことになり、B>=Aである以上これはすべてのデータを含んでしまうことになりますので、望ましくありません。
このため、B>=Aの場合だけ別にユーザーリストを作っています。
aboveAgeStr != "" && belowAgeStr != ""
としているのは、「年齢がA以上且つB以下の範囲を満たすデータ」のためにはどちらのデータも指定されてある必要があるためです。
もし、sqlで表現するなら以下のようになると思います。
SLECT *
FROM User
WHERE age >= A AND age <= B
4. プロダクトの全体像
最後に、プロダクトの全体像をお見せしてこの記事を終えたいと思います。
なお、コードはgithubにもあげているので、cloneしたかったらこちらもご参照ください。
package warehouse
type User struct{
Id int `json:"id" xml:"id" form:"id" query:"id"`
Name string `json:"name" xml:"name" form:"name" query:"name"`
Age int `json:"age" xml:"age" form:"age" query:"age"`
}
type UserWareHouse struct{
LastId int
UserList []User
}
var UserWH = UserWareHouse{
LastId: 3,
UserList: []User{{
Id: 1,
Name: "Joe Biden",
Age: 82,
},
{
Id: 2,
Name: "Donald Trump",
Age: 79,
},
{
Id: 3,
Name: "Robert Kennedy Junior",
Age: 70,
},
},
}
package main
import (
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/yupon-pro/go-handson/work/handlers"
)
func main() {
e := echo.New()
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.GET("/users", handlers.GetUsers)
e.GET("/users/:id", handlers.GetUser)
e.POST("/users", handlers.PostUser)
e.PATCH("/users/:id", handlers.PatchUser)
e.DELETE("/users/:id", handlers.DeleteUser)
e.Logger.Fatal(e.Start(":8080"))
}
package handlers
import (
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"github.com/yupon-pro/go-handson/work/warehouse"
)
func GetUsers(c echo.Context) error {
aboveAgeStr := c.QueryParam("age_over")
belowAgeStr := c.QueryParam("age_under")
if aboveAgeStr == "" && belowAgeStr == "" {
return c.JSON(http.StatusOK, warehouse.UserWH.UserList)
}
var aboveAge, belowAge int
var err error
if aboveAgeStr != "" {
aboveAge, err = strconv.Atoi(aboveAgeStr)
if err != nil {
return c.String(http.StatusBadRequest, "The type of age_over is wrong.")
}
}
if belowAgeStr != "" {
belowAge, err = strconv.Atoi(belowAgeStr)
if err != nil {
return c.String(http.StatusBadRequest, "The type of age_under is wrong.")
}
}
var uLis []warehouse.User
for _, v := range warehouse.UserWH.UserList {
if aboveAgeStr != "" && belowAgeStr != "" && belowAge >= aboveAge{
if aboveAge <= v.Age && belowAge >= v.Age{
uLis = append(uLis, v)
}
}else{
if aboveAgeStr != "" && v.Age >= aboveAge {
uLis = append(uLis, v)
}
if belowAgeStr != "" && v.Age <= belowAge {
uLis = append(uLis, v)
}
}
}
return c.JSON(http.StatusOK, uLis)
}
func GetUser(c echo.Context) error{
id, err := strconv.Atoi(c.Param("id"))
if err != nil{
return c.String(http.StatusBadRequest, "The id is wrong.")
}
uLis := warehouse.UserWH.UserList
for _,v := range uLis{
if v.Id == id{
return c.JSON(http.StatusOK, v)
}
}
return c.String(http.StatusBadRequest, "There was no user information you specified.")
}
func PostUser(c echo.Context) error{
u := new(warehouse.User)
if err := c.Bind(u); err != nil{
return c.String(http.StatusBadRequest, "Bad Request!")
}
warehouse.UserWH.LastId++
u.Id = warehouse.UserWH.LastId
warehouse.UserWH.UserList = append(warehouse.UserWH.UserList, *u)
return c.JSON(http.StatusCreated, u)
}
func PatchUser(c echo.Context) error{
id, err := strconv.Atoi(c.Param("id"))
if err != nil{
return c.String(http.StatusBadRequest, "The id is wrong.")
}
age := c.FormValue("age")
name := c.FormValue("name")
if age == "" && name == "" {
return c.String(http.StatusBadRequest, "Please provide either name or age.")
}
for i, v := range warehouse.UserWH.UserList {
if v.Id == id {
if age != "" {
intAge, err := strconv.Atoi(age)
if err != nil {
return c.String(http.StatusBadRequest, "The type of age is wrong.")
}
warehouse.UserWH.UserList[i].Age = intAge
}
if name != "" {
warehouse.UserWH.UserList[i].Name = name
}
return c.JSON(http.StatusOK, warehouse.UserWH.UserList[i])
}
}
return c.String(http.StatusBadRequest, "There was no user information you specified.")
}
func DeleteUser(c echo.Context) error{
id, err := strconv.Atoi(c.Param("id"))
if err != nil{
return c.String(http.StatusBadRequest, "The id is wrong.")
}
for i,v := range warehouse.UserWH.UserList{
if v.Id == id{
warehouse.UserWH.UserList = append(warehouse.UserWH.UserList[:i],warehouse.UserWH.UserList[i+1:]...)
return c.NoContent(http.StatusNoContent)
}
}
return c.String(http.StatusBadRequest, "There was no user information you specified.")
}
5. 参考
公式ドキュメントです。
よくまとまっているので、ぜひ参考にしてみてください。