【Go初心者】実践的ユーザー付きTODOアプリのAPI実装(おまけFlutter)

【Go初心者】実践的ユーザー付きTODOアプリのAPI実装(おまけFlutter)

はじめに

年末年始のまとまった休暇を活用して、これまで本格的に触れてこなかった Go言語の学習に挑戦することにしました。

ただ文法をなぞるだけではなく、実務に近い形で理解したかったため、ユーザー付きTODOアプリのAPIをGoで実装し、Flutterアプリから実際に利用する構成(単にFlutterは書きたかっただけ)を作ってみました。

バックエンドにはGoのWebフレームワークであるGinを採用し、フロントエンドは僕が普段から利用しているFlutter(riverpod)の王道構成で実装します。

この記事では、

  • Goの基本文法
  • なぜGoを学習対象に選んだのか
  • なぜGinを使うのか
  • (おまけ)Flutter側でどのようにAPIを利用していくのか

といった点を、要所ごとにまとめていきます。

「GoでAPIを作り、Flutterアプリから利用する」という一連の流れを通して、Go初学者でも実務レベルの感覚を掴める内容を目指します。

Goの基本文法(一部解説付き)

Goのコードをパッとみた時にあまり馴染みのない書き方が多々あったので以下で基本文法をざっと読むことができます。

https://go-tour-jp.appspot.com/list

僕がFlutterエンジニアなのでその視点でわかるようにメモ。

struct とは?

type User struct {
	ID   uint
	Name string
}

// Flutterで書くとこう
class User {
  int id;
  String name;
}

interface とは?

type UserRepository interface {
	Create(...)
}

// Flutterでいう「この型はこの機能を持つ」契約書みたいな
abstract class UserRepository {
  Future<void> create(...);
}

context.Context とは?

Flutterの BuildContext に近い。

リクエスト、キャンセル、タイムアウトを処理全体に伝搬させる箱。

なぜGoのフレームワークにGinを選んだのか?

今回、GoでTODOアプリのAPIを作成し、Flutterアプリから利用する構成を学習するにあたり、Webフレームワークには Gin を選びました。

理由は大きく3つあります。

1. 最も使われているGo Webフレームワークである

Ginは、GoのWebフレームワークの中でも圧倒的に利用者が多いライブラリです。

  • GitHubスター数:80k以上
  • 記事・Qiita・Zennの情報量が非常に多い
  • 実務での採用事例も豊富

2. シンプルで学習コストが低い

GinはルーティングやMiddlewareの書き方が直感的で、Go初心者でも「APIがどう作られているか」をすぐに理解できます。

このシンプルさが、学習用途には最適です。

3. net/http互換で、Goの標準を学べる

GinはGoの標準 net/http をラップしているため、

  • GoらしいContextの扱い方
  • 標準Middleware設計
  • 他ライブラリとの親和性

を自然に学ぶことができるようです。

フレームワーク独自仕様ではなく、「Goの正攻法」を身につけられるのは、長期的に見て非常に重要です。

参考:Go framework 比較

色々な比較記事を読んでみましたが、Ginが王道で幅広く使われているようでした。

他のフレームワーク導入は、そのフレームワークが生きる開発であれば導入検討したいですね。

まずは、ほぼ基本と言っても良いGinを扱っていきたいと思います。

GoとGinのインストール

Goプロジェクトを作成し、Ginをインストールします。

% go get -u github.com/gin-gonic/gin

main.goファイルを作成し、GET と POST に対する処理を書き、Postmanなどで動作確認します。

// packageの名前がmainと定義する
// 他のファイルからmainを指定するとこのファイルが実行される
package main

import "github.com/gin-gonic/gin" //GinというWebフレームワークを使用宣言

func main() {
	r := gin.Default() // r.Run()とかでサーバーを立てる記述が簡単に書ける
	r.GET("/test", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"message": "ok",
		})
	})
	r.Run()
}

DockerでDBの永続化とホットリロード導入

ここでは開発に欠かせない、ホットリロードとDBの永続化を行います。

以下を参考にしました。

https://zenn.dev/ring_belle/articles/go-docker-air-local

.air.tomlDockerfilecomposeを作成することで、コードを変更後に再度Postmanで確認すると変更が即時反映されるようになります。

クリーンアーキテクチャの構成で進めてみる

ここでは

  • クリーンアーキテクチャの最小構成
  • DI(依存性注入)
  • Gin と GORM の責務分離

Go初心者でも理解できる形で実装しました。

cmd/api/main.go        ← エントリーポイント(DIの司令塔)
internal/
 ├ domain/             ← 業務ルール(エンティティ)
 ├ infra/              ← 外部接続(DBなど)
 ├ repository/         ← DB操作の窓口
 ├ usecase/            ← ビジネスロジック
 └ handler/            ← HTTPハンドラ(Gin)

僕はFlutterメインのエンジニアなのでFlutterに置き換えるイメージつきやすく、以下になります。

Flutter

Go

Widget

handler

ViewModel / State

usecase

Repository

repository

Model

domain

Dio / API client

infra

実際にコードを書く

実際に、コードを書いていきます。

GORMとMySQLのインストール

% go get gorm.io/gorm
% go get gorm.io/driver/mysql

Todoエンティティ

internal/domain/todo.go

package domain

import "time"

type Todo struct {
	ID        uint   `gorm:"primaryKey"`
	Title     string `gorm:"type:varchar(255);not null"`
	Completed bool   `gorm:"not null;default:false"`
	CreatedAt time.Time
	UpdatedAt time.Time
}

要素

意味

gorm:"primaryKey"

IDを主キーとして扱う

not null

DBレベルで必須

time.Time

MySQLのTIMESTAMPと対応

DB接続レイヤー(infra)

internal/infra/db.go

Docker compose の environment を Go 側で読むための橋渡し。

package infra

import (
	"fmt"
	"os"
	"time"

	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

type DBConfig struct {
	Host     string
	Port     string
	User     string
	Password string
	Name     string
}

func LoadDBConfigFromEnv() DBConfig {
	return DBConfig{
		Host:     os.Getenv("DB_HOST"),
		Port:     os.Getenv("DB_PORT"),
		User:     os.Getenv("DB_USER"),
		Password: os.Getenv("DB_PASSWORD"),
		Name:     os.Getenv("DB_NAME"),
	}
}

func NewDB(cfg DBConfig) (*gorm.DB, error) {
	dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true&charset=utf8mb4&loc=Local",
		cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Name,
	)

	db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
	if err != nil {
		return nil, err
	}

	// 接続プール(最低限)
	sqlDB, err := db.DB()
	if err != nil {
		return nil, err
	}
	sqlDB.SetMaxOpenConns(10)
	sqlDB.SetMaxIdleConns(5)
	sqlDB.SetConnMaxLifetime(30 * time.Minute)

	return db, nil
}

Repository(DB操作の窓口)

internal/repository/todo_repository.go

  • interface を挟むのは テストしやすくするため
  • handler/usecase は GORM を直接知らない(依存しない)
  • Order("id desc") で新しい順に取得
package repository

import (
	"context"

	"todo_app_go/internal/domain"

	"gorm.io/gorm"
)

type TodoRepository interface {
	List(ctx context.Context) ([]domain.Todo, error)
}

type todoRepository struct {
	db *gorm.DB
}

func NewTodoRepository(db *gorm.DB) TodoRepository {
	return &todoRepository{db: db}
}

func (r *todoRepository) List(ctx context.Context) ([]domain.Todo, error) {
	var todos []domain.Todo
	if err := r.db.WithContext(ctx).Order("id desc").Find(&todos).Error; err != nil {
		return nil, err
	}
	return todos, nil
}

Usecase(業務ロジック)

internal/usecase/todo_usecase.go

  • usecase は「何をするか」を書く層
  • 現時点では単に repo を呼ぶだけ(今後、バリデーションや権限などが入る)
package usecase

import (
	"context"

	"todo_app_go/internal/domain"
	"todo_app_go/internal/repository"
)

type TodoUsecase interface {
	List(ctx context.Context) ([]domain.Todo, error)
}

type todoUsecase struct {
	repo repository.TodoRepository
}

func NewTodoUsecase(repo repository.TodoRepository) TodoUsecase {
	return &todoUsecase{repo: repo}
}

func (u *todoUsecase) List(ctx context.Context) ([]domain.Todo, error) {
	return u.repo.List(ctx)
}

Handler(HTTP/Gin)

internal/handler/todo_handler.go

  • handler は「HTTPの受け口」
  • JSONで返す / ステータスコードを決める、などが担当
  • DBのことは知らない(usecaseに委譲)
package handler

import (
	"net/http"

	"todo_app_go/internal/usecase"

	"github.com/gin-gonic/gin"
)

type TodoHandler struct {
	uc usecase.TodoUsecase
}

func NewTodoHandler(uc usecase.TodoUsecase) *TodoHandler {
	return &TodoHandler{uc: uc}
}

func (h *TodoHandler) List(c *gin.Context) {
	todos, err := h.uc.List(c.Request.Context())
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"message": "failed to list todos"})
		return
	}
	c.JSON(http.StatusOK, todos)
}

main.go(DIしてルーティング)

main.go を移動します

cmd/api/main.go

  • main.go は DI の司令塔
  • “作る順番”がそのまま依存関係を表すdb → repo → usecase → handler
package main

import (
	"log"

	"todo_app_go/internal/domain"
	"todo_app_go/internal/handler"
	"todo_app_go/internal/infra"
	"todo_app_go/internal/repository"
	"todo_app_go/internal/usecase"

	"github.com/gin-gonic/gin"
)

func main() {
	// --- DB ---
	cfg := infra.LoadDBConfigFromEnv()
	db, err := infra.NewDB(cfg)
	if err != nil {
		log.Fatal(err)
	}

	// 開発用:自動でテーブル作成(後でマイグレーションに置き換え可能)
	if err := db.AutoMigrate(&domain.Todo{}); err != nil {
		log.Fatal(err)
	}

	// --- DI(依存性注入) ---
	todoRepo := repository.NewTodoRepository(db)
	todoUC := usecase.NewTodoUsecase(todoRepo)
	todoHandler := handler.NewTodoHandler(todoUC)

	// --- Router ---
	r := gin.Default()
	r.GET("/todos", todoHandler.List)

	_ = r.Run(":8080")
}

TODOのAPIを完成させて動作確認

上記のような感じでPOST、PATCH、DELETEも作成しました。

GET

POST

バリデーション確認↓

PATCH

DELETE

ユーザー関連のAPIも作る

TODOまでできたので、次に、ユーザーのログイン、登録、ログアウト、削除、TODOとの紐付けまでやってバックエンドを完了にすることとします。

ユーザーの管理はJWTトークンを導入しました。

Flutterアプリを構築する

実際にAPIを使ってFlutterアプリを作りました。

先に完成系の画像を載せておきます。

次に、Flutterアプリのポイントをざっと解説。テスト容易性、保守性を考えられているおすすめ設計です。

API Client

  • RiverpodによるDI
  • テスト容易性:モックに差し替えるだけでテスト可能
  • アプリ全体で共有されるシングルトンなAPI Client
@Riverpod(keepAlive: true)
TodoClient todoClient(Ref ref) {
  final dio = ref.read(dioProvider);
  return TodoClient(dio);
}

const _path = '/todos';

@RestApi()
abstract class TodoClient {
  factory TodoClient(Dio dio, {String? baseUrl}) = _TodoClient;

  @GET(_path)
  Future<List<Todo>> getTodos();

  @POST(_path)
  Future<Todo> createTodo(@Body() CreateTodoRequest body);

  @PATCH('$_path/{id}')
  Future<Todo> updateTodo(
    @Path('id') int id,
    @Body() UpdateTodoRequest body,
  );

  @DELETE('$_path/{id}')
  Future<void> deleteTodo(@Path('id') int id);
}

モデル

  • Immutable
  • 型安全
@freezed
abstract class Todo with _$Todo {
  @JsonSerializable(fieldRename: FieldRename.snake)
  const factory Todo({
    required int id,
    required String title,
    required bool completed,
    required DateTime createdAt,
    required DateTime updatedAt,
  }) = _Todo;
  factory Todo.fromJson(Map<String, dynamic> json) => _$TodoFromJson(json);
}

AsyncNotifierで状態管理

  • 画面の構築とロジックが完全分離
  • 状態更新がイミュータブル
@riverpod
class TodoNotifier extends _$TodoNotifier {
  @override
  Future<List<Todo>> build() {
    return ref.read(todoRepositoryProvider).fetchTodos();
  }

  Future<void> addTodo(String title) async {
    final repo = ref.read(todoRepositoryProvider);

    state = await AsyncValue.guard(() async {
      final todo = await repo.createTodo(title);
      return [todo, ...(state.value ?? [])];
    });
  }

  Future<void> toggle(Todo todo) async {
    final repo = ref.read(todoRepositoryProvider);

    state = await AsyncValue.guard(() async {
      final updated = await repo.updateTodo(
        todo.copyWith(completed: !todo.completed),
      );

      return [
        for (final t in state.value!)
          if (t.id == updated.id) updated else t,
      ];
    });
  }

  Future<void> delete(int id) async {
    final repo = ref.read(todoRepositoryProvider);

    state = await AsyncValue.guard(() async {
      await repo.deleteTodo(id);
      return state.value!.where((t) => t.id != id).toList();
    });
  }
}

画面

  • ロジックがNotifierに丸投げできている
  • 状態表現が簡潔
class TodoListPage extends ConsumerWidget {
  const TodoListPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final asyncValue = ref.watch(todoProvider);

    return Scaffold(
      ...
      body: asyncValue.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (e, _) => Center(child: Text('Error: $e')),
        data: (todos) => todos.isEmpty
            ? const Center(child: Text('No todos yet'))
            : ListView.builder(
                itemCount: todos.length,
                itemBuilder: (_, i) {
                  final todo = todos[i];
                  return ListTile(
                    title: Text(todo.title),
                    leading: Checkbox(
                      value: todo.completed,
                      onChanged: (_) {
                        ref.read(todoProvider.notifier).toggle(todo);
                      },
                    ),
                    trailing: IconButton(
                      icon: const Icon(Icons.delete),
                      onPressed: () {
                        ref.read(todoProvider.notifier).delete(todo.id);
                      },
                    ),
                  );
                },
              ),
      ),
    );
  }
}

ルーティング

  • 認証状態の管理
  • ディープリンクの標準対応
final routerProvider = Provider<GoRouter>((ref) {
  final authAsyncValue = ref.watch(authProvider);

  return GoRouter(
    navigatorKey: _rootNavigatorKey,
    initialLocation: AppPage.home.path,
    refreshListenable: AuthListenable(ref),
    debugLogDiagnostics: true,
    redirect: (context, state) {
      final path = state.uri.path;

      if (_nonRedirectPaths.contains(path)) {
        return null;
      }

      return authAsyncValue.when(
        loading: () => null,
        error: (_, __) => null,
        data: (authState) => authState.when(
          loading: () => null,
          unauthenticated: () {
            if (path == AppPage.login.path || path == AppPage.register.path) {
              return null;
            }
            return AppPage.home.path;
          },
          authenticated: (_) {
            if (path == AppPage.login.path || path == AppPage.register.path) {
              return AppPage.home.path;
            }
            return null;
          },
        ),
      );
    },
    routes: [
      GoRoute(
        name: AppPage.login.name,
        path: AppPage.login.path,
        builder: (_, __) => const LoginPage(),
      ),
      GoRoute(
        name: AppPage.register.name,
        path: AppPage.register.path,
        builder: (_, __) => const RegisterPage(),
      ),
      GoRoute(
        name: AppPage.home.name,
        path: AppPage.home.path,
        builder: (_, __) => const TodoListPage(),
      ),
    ],
  );
});

最後に

Goを学べたお陰で、何もしない年末で後悔することはなくてよかったです笑

そして、仕事にとらわれないプライベートなコード書くって意外と良いですね。

この感覚久しぶりに良かったので、また時間あったら個人開発でもやってみようかな〜という気持ちになりました。

(あと、NotebookLMを初めて触ったけど結構よくて、色々キャッチアップできた年末年始でした)

記事を書いた人

Matsuura

エンジニア

Matsuura

Anycloudでエンジニアしてます!主にFlutter・Typescript