LoginSignup
2
0

More than 1 year has passed since last update.

顔採点AIくんを個人開発して得たいくつかのgoの知見

Last updated at Posted at 2023-03-27

Goの勉強がてらアプリを作ってみたので、いくつかのGoのライブラリについて書いてみようと思います。

作ったもの

顔採点AIくん
https://line.me/R/ti/p/@659rlati

actionpreview.gif

net/http

Goでは、net/httpという標準ライブラリだけでサーバーを起動させることができます。
が、結論として/users/{id}のように、変数を含んだパスを設定できないという致命的な壁が存在するので、Ginとか使った方がいいです。

以下のコードは8080ポートでサーバーを起動する例です。どんなパスに対してもHelloを返却します。

func main() {
	http.ListenAndServe(":8080", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Hello")
	}))
}

パスによって処理は分けたいケースが多いはずなので、次のような書き方もできます。
この場合、/helloに対してリクエストを行うと、上と同じようにHelloが返るのに対して、
/に対してリクエストを行うと404 page not foundが返却されます。

func main() {
	http.Handle("/hello", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Hello")
	}))

	http.ListenAndServe(":8080", nil)
}

また、http.Handlehttp.HandlerFuncを渡す場合、次のように簡略化できます。

func main() {
	http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Hello")
	})

	http.ListenAndServe(":8080", nil)
}

ネストした感じで書こうとするとhttp.ServeMuxを使用してこうなります。
MUXマルチプレクサの略とのこと。字面でピンと来なかったがググったらすぐイメージできた。)

どうにか/good/ -> /good/byeのところを
/good -> /byeにしたかったが、無理なようです。

func main() {
	mux := http.NewServeMux()

	mux.Handle("/good/", func() http.Handler {
		mux := http.NewServeMux()

		mux.HandleFunc("/good/bye", func(w http.ResponseWriter, r *http.Request) {
			fmt.Fprintln(w, "Goodbye")
		})

		return mux
	}())

	http.ListenAndServe(":8080", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		mux.ServeHTTP(w, r)
	}))
}

そのかわり(?)、次のように書くことができます。

func main() {
	mux := http.NewServeMux()

	mux.Handle(goodRouter())

	http.ListenAndServe(":8080", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		mux.ServeHTTP(w, r)
	}))
}

func goodRouter() (string, http.Handler) {
	mux := http.NewServeMux()

	mux.HandleFunc("/good/bye", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Goodbye")
	})

	mux.HandleFunc("/good/evening", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Goodevening")
	})

	return "/good/", mux
}

また、http.Handlerの定義を見てみると次のようになっているので、

type Handler interface {
	ServeHTTP(ResponseWriter, *Request)
}

ServeHTTPさえ実装してしまえばどんな構造体でもHandleListenAndServeに渡してしまうことができます。

func main() {
	http.ListenAndServe(":8080", NewRouter())
}

type Router struct{}

func NewRouter() Router {
	return Router{}
}

func (Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Hello")
}

このように、(頑張れば)標準のnet/httpのAPIだけで実装の幅は結構広そうです。

が、冒頭にも書いた通り調べた範囲では/users/{id}のように、パスに含んだ変数を取得するようなAPIがありませんでしたので、RESTfulなURL設計がしたい場合はGinなどのフレームワークを使用した方が良さそうです。

fmt

fmtはめちゃくちゃお世話になります。

fmt.FprintWriterインターフェースに対して書き込みを行います。
net/httpではレスポンスボディの書き込みに使用していました。

func (Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Hello")
}

標準出力になにか表示したい場合はfmt.Print*

fmt.Println("Hello")
// => Hello

fmt.Printf("I am %s.", "Yamada Taro")
// => I am Yamada Taro.

文字列を生成したい場合はfmt.Sprint*

logger.Info(fmt.Sprintf("I am %s.", "Yamada Taro"))

fmt.Errorferrorを返却する関数です。

err := fmt.Errorf("user not found (id: %d)", id)

context

APIサーバー設計ではmainの中に処理を全て書くことはおそらく少なくて、
処理の段階や役割ごとに関数やクラス(goにはクラスはないが)を適切に分割していくことが多い(はず)です。

そのようにしようと考えたときに、APIの入り口から出口、リクエストからレスポンスまで、データを持ち回すためにcontextを使用することができます。

以下そのような例を書きます。
が、簡略化のために良くないコードになっています
contextのキーに文字列を使用することは非推奨です。IDEによってはワーニングが出るかと思います。

func main() {
	ctx := context.Background()
	ctx = context.WithValue(ctx, "uuid", uuid.NewString())

	db, _ := sql.Open("mysql", "root:root@tcp(127.0.0.1:3306)/test_db")
	ctx = context.WithValue(ctx, "db", db)

	http.ListenAndServe(":8080", NewRouter(ctx))
}

type Router struct {
	ctx context.Context
}

func NewRouter(ctx context.Context) Router {
	return Router{
		ctx: ctx,
	}
}

func (rr Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, rr.ctx.Value("uuid"))
}

contextには、go routineで実行される処理へキャンセルの意思を伝播するなど出来るようですが、go routineについてはあまり検証できていないため、ここでは書かないことにします。

golang.org/x/exp/slog

goでログを出力するためには、標準のlogパッケージがありますが、近いうちにlog/slogというパッケージが標準に追加されるようですので、こちらを使用するようにしたほうがいい気がしています。
現在はlog/slogではなく、golang.org/x/exp/slogというパッケージ名で実験的に提供されている状態です。

以下のようにシンプルな呼び出しで標準出力へログを出力することができます。

slog.Info("message", "id", 1)

ファイルへ出力するには以下のようにします。

logFile, err := os.Create("/var/log/debug.log")
if err != nil {
	panic(err)
}

logger := slog.New(slog.HandlerOptions{}.NewTextHandler(logFile))

logger.Info("message", "id", 1)

slogではJSON形式で構造化したログを出力できたりもします。

logger := slog.New(slog.HandlerOptions{}.NewJSONHandler(os.Stderr))
logger.Info("json sample", slog.Any("struct", struct {
	Name     string
	Children []struct{ Name string }
}{
	Name: "Kimura Taro",
	Children: []struct{ Name string }{
		{Name: "Kimura Hanako"},
		{Name: "Kimura Ichiro"},
	},
}))

// => {"time":"2023-03-27T12:29:30.708735237Z","level":"INFO","msg":"json sample","struct":{"Name":"Kimura Taro","Children":[{"Name":"Kimura Hanako"},{"Name":"Kimura Ichiro"}]}}

image

画像処理をPHPでやろうとすると、gdimagemagickを使用する必要がありますが、
goでは標準でimageというパッケージが入っています。

以下httpで取得したpng画像をjpg変換して保存する例です。

res, _ := http.Get("http://example.com/image.png")
img, _, _ := image.Decode(res.Body)
file, _ := os.Create("image.jpg")
jpeg.Encode(file, img, &jpeg.Options{})

画像の切り取りも標準パッケージで行うことができます。

original, _ := os.Open("image.jpg")
originalImg, _, _ := image.Decode(original)

type SubImager interface {
	SubImage(image.Rectangle) image.Image
}

croppedImg := originalImg.(SubImager).SubImage(image.Rect(0, 0, 50, 50))
cropped, _ := os.Create("cropped.jpg")
jpeg.Encode(cropped, croppedImg, &jpeg.Options{})

調べた感じだと画像合成や文字入れなども標準パッケージでできそうです。

LINE Messaging APIについて

goとは関係ないですが。
今回はgoにフォーカスして試してみたかったので、UI関連はすべてLINEにおまかせになっています。

LINE Messaging APIを使用すると、
Webhookの受け口とWebAPIへのPOST処理を実装するだけで対話型のUIを作成することができます。

具体的には、ユーザーがLINEでメッセージを送信すると、指定のWebhook URLへ次のようなリクエストが飛んできます。

{
  "destination": "xxxxxxxxxx",
  "events": [
    {
      "type": "message",
      "message": {
        "type": "text",
        "id": "14353798921116",
        "text": "Hello, world"
      },
      "timestamp": 1625665242211,
      "source": {
        "type": "user",
        "userId": "U80696558e1aa831..."
      },
      "replyToken": "757913772c4646b784d4b7ce46d12671",
      "mode": "active",
      "webhookEventId": "01FZ74A0TDDPYRVKNK77XKC3ZR",
      "deliveryContext": {
        "isRedelivery": false
      }
    }
  ]
}

また、LINE APIの指定エンドポイントへPOSTリクエストを送信すると、ユーザーへメッセージを送信することができます。

curl -v -X POST https://api.line.me/v2/bot/message/push \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer {channel access token}' \
-H 'X-Line-Retry-Key: {UUID}' \
-d '{
    "to": "U4af4980629...",
    "messages":[
        {
            "type":"text",
            "text":"Hello, world"
        }
    ]
}'

アーキテクチャとしては下の図のようになります。
アプリケーションサーバーではたいしたことしていないのが良くわかると思います。

おわり

goはtry-catchがなかったりエラー処理が冗長だったりと辛い部分もありますが、書いていて楽しかったです。

開発体験が良かったのは、IDE (Visual Studio Code) とLanguage Serverの助力が大きかったと思います。

コンパイルエラーになる部分を事前に教えてくれたり適切にフォーマットしてくれたり、補完機能やジャンプ機能には大変お世話になりました。

また、VSCode Serverによるトンネリングにより、VPS内のDockerコンテナにMacbookのVSCodeから接続して開発をすることができました。
(Webhookにグローバルなドメインを使用する都合上、開発環境がlocalhostになっていると辛い)

これらがなければ早々に心折れていた気がします。

Visual Studio Codeも、Language Serverも、今ではGitHubも、全てMicrosoftのプロダクトですね。
一昔前はナードなイケてない企業のイメージだったのですが、いつからこうなったのでしょうか。

最後に今回作成した顔採点AIくんのリンクをもう一度貼って終わります。
内容の誤り、補足等ございましたらコメントいただけると嬉しいです。
ここまで読んでいただきありがとうございました。

顔採点AIくん
https://line.me/R/ti/p/@659rlati

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0