🐼

GoでREST API+WebSocketなTodoアプリを作る

2024/07/06に公開

はじめに

訳あってGoの勉強をする必要があり、右も左も分からない状況だったので(大半のコードをChatGPTに書いてもらいながら)TodoアプリのAPIを作りました。せっかくなので認証機能やWebSocketサーバの用意も行い、Webアプリのベースとして機能するところをゴールとして作りこみを進めました。

成果物

やったこと

  1. ホットリロードの導入
  2. Hello World!
  3. インメモリでTodoアプリを作った
  4. リファクタリングした
  5. PostgreSQLを導入した
  6. 認証機能を実装した
  7. WebSocketサーバーを追加した
  8. Redisを利用してスケールアウトに対応した

※動作確認用に簡単な画面も用意しましたが、今回は割愛します。

ホットリロードの導入

コードを変更する度にビルドして確認はしたくないので、まずはホットリロードから探しました。airが良さそうだったので、READMEの通りに実行してairを導入しました。
最初はコンテナの中で動かしていなかったのと、Windows/Macの2つの開発環境があったのでtomlを2個用意してどちらでも動かせるようにしていました。

Hello World!

// main.go
func main() {
  fmt.Printf("Hello World\n")
}

インメモリでTodoアプリを作った

  • GET /todos : 全てのtodoを返す
  • POST /todos : 新しいtodoを追加する
  • GET /todos/{id} : 該当IDのtodoを返す
  • PUT /todos/{id} : 該当IDのtodoを更新する
  • DELETE /todos/{id} : 該当IDのtodoを配列から削除する

という、基本的なREST APIの挙動が行えるものを実装しました。

ファーストコミット

リファクタリングした

main.goに全てを記述していたのですが、ゆくゆくはRDBを利用したかったのでこのタイミングで一旦処理を分けることにしました。

./
│  main.go
│  routes.go
│
├─handlers
│      todo.go
│
├─models
│      todo.go
│
├─repositories
│      todo.go
│      todoInterface.go
│
├─services
│      todo.go
│
...etc

ファクタリング後のmain.goは以下のようになります:

func main() {
	repo := repositories.NewTodoRepository()
	service := services.NewTodoService(repo)
	handler := handlers.NewTodoHandler(service)
	Routes(handler)

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

PostgreSQLを導入した

Gormを利用してPostgreSQLと読み書きを行うtodoRDBRepositoryを実装しました。前回のリファクタリングのおかげで、Repositoryを実装したらDIを差し替えるだけで良くなりました。

func main() { 
	// repo := repositories.NewTodoRepository()
	repo, err := repositories.NewTodoRDBRepository (dsn) 
	if err != nil {
		log.Fatalf("failed to connect database: %v", err)
	}
	service := services.NewTodoService(repo)
	handler := handlers.NewTodoHandler(service)
	Routes(handler)

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

変更差分

認証機能を実装した

JWTを用いたユーザー認証機能を追加しました。
あくまで認証がメインなので、userについてはcreateのみ実装しました。

./
│  main.go
│  routes.go
│
├─handlers
│      todo.go
│      +user.go
│      +utils.go
│
├─middleware
│      +auth.go
│
├─models
│      todo.go
│      +user.go
│
├─repositories
│      todo.go
│      todoInterface.go
│      +user.go
│      +userInterface.go
│
├─services
│      todo.go
...etc

変更差分1変更差分2

Routing内で/todos以下のリクエストについては認証を組み込むようにしました。

func ChainMiddleware(handler http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {
	for _, middleware := range middlewares {
		handler = middleware(handler)
	}
	return handler
}

func Routes(todoHandler *handlers.TodoHandler, authHandler *handlers.AuthHandler, wsHandler *handlers.WebSocketHandler, userService *services.UserService) {
	http.HandleFunc("POST /register", authHandler.Register)
	http.HandleFunc("POST /login", authHandler.Login)

	http.Handle("GET /todos", ChainMiddleware(http.HandlerFunc(todoHandler.Index), middleware.AuthMiddleware(userService)))
	http.Handle("POST /todos", ChainMiddleware(http.HandlerFunc(todoHandler.Create), middleware.AuthMiddleware(userService)))
	http.Handle("GET /todos/{id}", ChainMiddleware(http.HandlerFunc(todoHandler.Show), middleware.AuthMiddleware(userService)))
	http.Handle("PUT /todos/{id}", ChainMiddleware(http.HandlerFunc(todoHandler.Update), middleware.AuthMiddleware(userService)))
	http.Handle("DELETE /todos/{id}", ChainMiddleware(http.HandlerFunc(todoHandler.Delete), middleware.AuthMiddleware(userService)))
}

WebSocketサーバーを追加した

/wsでWebSocketに接続し、各種DB操作によるデータの変更を受け取れるようにしました。
WebSocketにイベントを送る形でCRUDを実装しても良かったのですが、今回はREST APIと並行して稼働しつつReadのみWebsocketでストリームを読み込めるような形にしました。

│  main.go
│  routes.go
│
├─handlers
│      auth.go
│      todo.go
│      utils.go
│      +websocket.go
│
├─infra
│      +websocket.go
│
├─middleware
│      auth.go
│
├─models
│      todo.go
│      user.go
│
├─repositories
│      todo.go
│      todoInterface.go
│      todo_sql.go
│      user.go
│      userInterface.go
│
├─services
│      todo.go
│      user.go
│      utils.go
│
...etc

変更差分

handler/websocket.goではJWTの認証とイベント受信時の処理を、notifier/websocket.goではクライアント管理とメッセージ送信の処理を担っています。

Redisを利用してサーバーのスケールアウトに対応した

Websocket化まで出来たところでCI/CDパイプラインを構築して無料のホスティングサービスにデプロイしたところ、Websocketの受信が出来る時と出来ない時がありました。よくよく調べてみると、デフォルトでコンテナイメージを2台のマシンでクラスタリングするようになっておりREST APIの向き先がランダムに変わるのが原因のようでした。

そもそもマシンを1台にすれば解決する問題ではありましたが、せっかくなのでRedisを利用してスケールアウトしても問題ないような構成にしてみました。

│  main.go
│  routes.go
│
├─handlers
│      auth.go
│      todo.go
│      utils.go
│      websocket.go
│
├─infra
│      +notifierInterface.go
│      +redis_notifier.go
│      websocket.go
│
├─middleware
│      auth.go
│
├─models
│      todo.go
│      user.go
│
├─repositories
│      todo.go
│      todoInterface.go
│      todo_sql.go
│      user.go
│      userInterface.go
│
├─services
│      todo.go
│      user.go
│      utils.go
│
...etc

変更差分

まずはNotifierInterfaceを作り、Websocket通知を行っていた箇所ではInterfaceに依存するような形に修正しました。その後、Redisを利用したNotiferを作り、クライアントの登録/削除・メッセージ送信をRedisを通じて行うようにしました。

おわりに

二週間くらい使ってチマチマとコードを書いていましたが、ChatGPTが無ければここまでスイスイ進むこともなく頭を捻っていたと思うので凄い時代になったなあという感じです。

Goが理解出来た感じは正直あんまりなく一人で改めてコードを書けるかというと怪しいですが、ひとまずGoへの抵抗感は薄れたので良かったです。WebSocketもSocket.IOの出始めに触ってみたきり、Redisに至っては触ったこともなかったので開発の手札が増えたのは個人的に大きな収穫でした。

Discussion