😊

Go言語でユーザー管理アプリケーション

2023/08/14に公開

Go言語、Gin、Dockerを活用したシンプルなユーザー管理CRUDアプリケーションを紹介します。
コードとREADMEは以下のレポジトリに掲載しています。

https://github.com/Nakamurus/go-crud-user-management

はじめに

本アプリケーションは、ユーザー管理のためのCRUD操作を中心に、JWTによる認証システムやトークンリフレッシュ機能を持つものとして、Gin Web Applicationを使って実装しました。

Docker Composeを活用して全体の構成を管理しており、各部分はDockerでコンテナ化されています。また、Github Actionsを使って、プッシュ時にCI/CDテストが自動で行われるよう設定しています。

アーキテクチャ

このアプリケーションはGo、PostgreSQL、Redisで構築されています。Go言語を使ってAPIを提供し、PostgreSQLを用いてユーザー情報の永続化を行います。JWTトークンの活用に伴い、トークンのブラックリスト管理にはRedisを採用しています。

docker-compose.yaml
version: "3.8"

services:
  db:
    env_file: .env
    image: postgres:14.0-alpine
    restart: always
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: ${DB_NAME}
      POSTGRES_PORT: ${DB_PORT}
    ports:
      - ${DB_PORT}:${DB_PORT}
    command:
      - "postgres"
      - "-c"
      - "shared_preload_libraries=pg_stat_statements"
    volumes:
      - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql


  redis:
    image: redis:6.2-alpine
    env_file: .env
    environment:
      - REDIS_HOST=${REDIS_HOST}
      - REDIS_PORT=${REDIS_PORT}
      - REDIS_PASSWORD=${REDIS_PASSWORD}
    restart: always
    ports:
      - ${REDIS_PORT}:${REDIS_PORT}
    command: redis-server /usr/local/etc/redis/redis.conf
    volumes:
      - ./redis.conf:/usr/local/etc/redis/redis.conf

  api:
    env_file: .env
    build:
      context: ./api
      dockerfile: Dockerfile
    tty: true
    volumes:
      - ./api:/app
      - ./wait-for-it.sh:/app/wait-for-it.sh
      - ./api-test.sh:/app/api-test.sh
    ports:
      - 8080:8080
    depends_on:
      - db
      - redis
    environment:
      DB_HOST: ${DB_HOST}
      DB_USER: ${DB_USER}
      DB_PASSWORD: ${DB_PASSWORD}
      DB_NAME: ${DB_NAME}
      DB_PORT: ${DB_PORT}

実装詳細

エンドポイントの構築手順

基本的な流れは一貫しており、エンドポイントの設定←ハンドラ、ミドルウェアの宣言←ビジネスロジックの実装という手順です。

Go言語の特性として、関数の返り値は通常、期待する返り値の型とエラー型の2つを持つため、エラーが発生した場合にはリターンしてエラー情報を返すことができます。

  1. Ginを初期化する。
main.go
r := gin.Default()
  1. エンドポイントで必要なデータを保持する構造体を作る。
    ここではデータベースへのアクセスとJWTトークンを使うので、それを保持する構造体をmain.goと/handlers/user.goで宣言する。
main.go
type App struct {
	DB     *gorm.DB
	JWTKey []byte
	RDB    *redis.Client
}
/handlers/user.go
type Handler struct {
	db     *gorm.DB
	JWTKey []byte
}

func UserHandler(db *gorm.DB, jwtKey []byte) *Handler {
	return &Handler{
		db:     db,
		JWTKey: jwtKey,
	}
}
  1. その構造体のメソッドとして、実際のハンドラを実装する。

構造体のメソッドとして実装することで、引数にいちいち共通するデータを渡さずにすむことでコードがシンプルになるだけではなく、必要な情報が変わっても構造体とそれに対応するハンドラを変えるだけで済むメリットがあります。

/handlers/user.go
func (h *Handler) CreateUserHandler() gin.HandlerFunc {
	return func(c *gin.Context) {

		var user models.User

		if err := c.ShouldBind(&user); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}

		if user.Name == "" || user.Email == "" || user.Password == "" {
			c.JSON(http.StatusBadRequest, gin.H{"error": "Missing required fields"})
			return
		}

		newUser, err := models.CreateUser(h.db, user)

		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
		c.JSON(http.StatusOK, gin.H{"message": "user created" + newUser.Name})
	}
}
  1. ビジネスロジックを担当する関数を作成する。
    なお、ここでは防御的プログラミングとして、ハンドラ層とモデル層双方で、入力が空文字列ではないか検証しています。
/models/user.go
func CreateUser(db *gorm.DB, user User) (*User, error) {

	if user.Name == "" || user.Email == "" || user.Password == "" {
		return nil, errors.New("name, email and password are required")
	}

	if isEmailUnique(db, user.Email) {
		return nil, errors.New("email is already used")
	}

	hashedPassword, err := util.HashPassword(user.Password)
	if err != nil {
		return nil, err
	}
	user.Password = hashedPassword

	result := db.Create(&user)
	if result.Error != nil {
		return nil, result.Error
	}
	return &user, nil
}

ミドルウェアの構築手順

ミドルウェアはリクエストの処理の中で動作する部分で、以下の手順で実装します。

ミドルウェアの実装のためには以下のように、ルート全体に適用する方法と特定のルート下に適用する方法があります。

  1. ミドルウェアの適用

レートリミッティングとJWTトークンによる認証のミドルウェアを適用していますが、ここではJWTのケースを説明していきます。

main.go
rl := util.NewRateLimiter(5)
r.Use(func(c *gin.Context) {
    rl.MiddleWare()
    c.Next()
})

authorized := r.Group("/me")
authorized.Use(m.AuthenticateMiddleware())
{
    authorized.PUT("/:id", uh.UpdateUserHandler())
    authorized.PUT("/:id/password", ah.ChangePasswordHandler())
    authorized.DELETE("/:id", uh.DeleteUserHandler())
    authorized.POST("/refresh-token", ah.RefreshTokenHandler())
    authorized.GET("/:id", uh.GetUserHandler())
    authorized.POST("/logout", ah.LogOutHandler())
}
  1. ミドルウェアの実装

ここでは、「Authorization Bearer: JWT_TOKEN」を用いて、JWTトークンによる認証を行うためのミドルウェアを実装しています。

middleware/auth.go
type MiddleWare struct {
	jwtkey []byte
}

func NewMiddleware(jwtkey []byte) *MiddleWare {
	return &MiddleWare{jwtkey}
}

func (m *MiddleWare) AuthenticateMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		authHader := c.Request.Header.Get("Authorization")
		if authHader == "" {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
			c.Abort()
			return
		}
		tokenString, err := util.ExtractBearerToken(authHader)
		if err != nil {
			c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
			c.Abort()
			return
		}

		token, err := jwt.ParseWithClaims(tokenString, &jwt.StandardClaims{}, func(token *jwt.Token) (interface{}, error) {
			return m.jwtkey, nil
		})

		if err != nil {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "Error parsing token"})
			c.Abort()
			return
		}

		if !token.Valid {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
			c.Abort()
			return
		}

		c.Next()
	}
}
  1. ビジネスロジックの実装
    上のミドルウェアではJWTトークンを解析して、有効かを確認しているため、関係する関数を下に挙げます。

ここではJWTトークンをパースして有効なトークンかどうかを判断しています。

util/jwt.go
func ParseToken(jwtKey []byte, tokenString string) (*jwt.StandardClaims, error) {
	token, err := jwt.ParseWithClaims(tokenString, &jwt.StandardClaims{}, func(token *jwt.Token) (interface{}, error) {
		return jwtKey, nil
	})

	if err != nil {
		return nil, err
	}

	claims, ok := token.Claims.(*jwt.StandardClaims)
	if !ok || !token.Valid {
		return nil, errors.New("Invalid token")
	}
	return claims, nil
}

ここではAuthorizationヘッダーからJWTトークンを抽出しています。

/util.token.go

func ExtractBearerToken(authHeader string) (string, error) {
	if !strings.HasPrefix(authHeader, "Bearer ") {
		return "", errors.New("Authorization header must start with Bearer")
	}
	return strings.TrimPrefix(authHeader, "Bearer "), nil
}

エンドポイント一覧

以下はエンドポイントと、それぞれのサンプルのリクエストとレスポンスの一覧です。

注意: uuidは実際のuuidで置き換えてください。

Hello World

  • Route: GET /

  • Responses:

    • 200 OK:
    • Hello World

Authentication Routes

1. Login

  • Route: POST /login
  • Request Body:
{
    "email": "example@email.com",
    "password": "password123"
}
  • Responses:

    • 200 OK:
    {
        "token": "your_jwt_token_here"
    }
    
    • 400 Bad Request: { "error": "error_message_here" }
    • 401 Unauthorized: { "error": "Invalid email or password" }
    • 500 Internal Server Error: { "error": "Error retrieving user" }

2. Change Password

  • Route: POST /me/uuid/password
  • Request Body:
{
    "old_password": "old_password_here",
    "new_password": "new_password_here"
}
  • Request Header
Authorization: Bearer your_jwt_token_here
  • Responses:

    • 200 OK:
    {
        "message": "Password updated successfully"
    }
    
    • Other Status Codes: { "error": "error_message_here" }

3. Refresh Token

  • Route: GET /me/refresh-token
  • Request Header
Authorization: Bearer your_jwt_token_here
  • Responses:

    • 200 OK:
    {
        "token": "new_jwt_token_here"
    }
    
    • Other Status Codes: { "error": "error_message_here" }

4. Logout

  • Route: GET /me/logout
  • Request Header
Authorization: Bearer your_jwt_token_here
  • Responses:

    • 200 OK: { "message": "Successfully logged out" }
    • Other Status Codes: { "error": "error_message_here" }

User Routes

1. List Users

  • Route: GET /users

  • Responses:

    • 200 OK: Array of user objects.
    • 500 Internal Server Error: { "error": "error_message_here" }

2. Get a User

  • Route: GET /user/:uuid

  • Responses:

    • 200 OK: User object.
    • 404 Not Found: { "error": "User not found" }
    • 500 Internal Server Error: { "error": "error_message_here" }

3. Create a User

  • Route: POST /user
  • Request Body:
{
    "name": "John Doe",
    "email": "john@example.com",
    "password": "password123"
}
  • Responses:

    • 200 OK: { "message": "user created John Doe" }
    • 400 Bad Request: { "error": "Missing required fields" }
    • 500 Internal Server Error: { "error": "error_message_here" }

4. Update a User

  • Route: PUT /me/:uuid
  • Request Body (Partial updates allowed):
{
    "name": "New Name",
    "email": "newemail@example.com"
}
  • Request Header
Authorization: Bearer your_jwt_token_here
  • Responses:

    • 200 OK:
    {
        "token": "new_jwt_token_here",
        "user": updated_user_object,
        "message": "User updated successfully"
    }
    
    • Other Status Codes: { "error": "error_message_here" }

5. Delete a User

  • Route: DELETE /me/:uuid
  • Request Header
Authorization: Bearer your_jwt_token_here
  • Responses:

    • 200 OK: { "message": "user deleted" }
    • 500 Internal Server Error: { "error": "error_message_here" }

まとめ

Go言語とGinフレームワークは、開発のしやすさや迅速なRESTful APIの構築に非常に適していると感じました。Go言語の関数がエラー型を第二返り値として持つことや、そのエラー型変数が一般的に「err」と命名される慣習は、心地よさや読みやすさにも寄与しています。

Discussion