0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Goで作るSNSバックエンド ― レイヤードアーキテクチャ・DI・CI/CD・ARM VMデプロイ

0
Last updated at Posted at 2026-02-26

はじめに

本記事では、SNSアプリケーション「ame:ato」のバックエンド開発を通じて実践した、以下の内容をまとめています。

  • レイヤードアーキテクチャの設計
  • DI(依存性注入)+ インターフェースによるテストしやすい構造
  • Wireを使った自動DI
  • Table-Driven Test × GomockによるGoのテスト設計
  • GitHub Actions × GHCR × Cloudflare Tunnelを使ったARM VMへのCI/CDパイプライン

リポジトリはこちらです:KanadeSisido/ameato-backend-pub

アプリ概要

ame:atoは、雨の日の窓をコンセプトにした掲示板アプリです。画面の任意の場所をクリックするとメッセージを投稿でき、曇った窓のような画面に文字が浮かびます。投稿した文字は時間経過とともに薄くなり、やがて消えていきます。

技術スタックは以下の通りです。

カテゴリ 採用技術
言語 Go
HTTPフレームワーク Gin
ORM Gorm
DI Wire
DB MariaDB
フロントエンド Next.js / Tailwind CSS / Motion

1. アーキテクチャ設計:なぜ層を分けるのか

レイヤードアーキテクチャの基本的な考え方

コードが増えてくると「この処理はどこに書くべきか」が曖昧になりがちです。責務を明確にするために、処理を複数の層(レイヤー)に分割するレイヤードアーキテクチャを採用しました。

基本方針は2つです。

  • 各層は1つ下の層だけを呼ぶ:依存の方向を一方向に保ち、変更の影響範囲を限定する
  • フレームワーク依存を特定の層に閉じ込める:GinやGormを差し替えても、ビジネスロジックに手を加えなくて済むようにする

本プロジェクトのデータフローは以下の通りです。

Router → Handler → Controller → Repository → DB

各層の役割は次のようになっています。

役割
Router Ginのルーティング定義。
Handler GinのContextを受け取り、リクエストのバインドやレスポンス生成(JSON)を担当する+Controllerを呼び出す
Controller アプリ固有のビジネスロジックとバリデーションを担当する。Repositoryを呼び出す
Repository GormによるDB操作(SQL実行・データ取得・保存)を担当する

HandlerとControllerを分けているのは、GinというフレームワークへのロックインをHandlerに閉じ込めるためです。Controllerはgin.Contextではなくcontext.Contextを受け取ります。

ファイル構成:1ロジック = 1ファイル

今回の設計の特徴は、ロジックごとにStructを分割し、1ファイルで完結させる点にあります。

controller/
  ├── createMessageController.go
  └── getMessageController.go

repository/
  ├── createMessageRepository.go
  └── getMessageRepository.go

handler/
  ├── postHandler.go
  └── getHandler.go

設計の一例として、「層ごとに1つのStructを作り、全メソッドをそこに集める」方法があります。

// 層ごとに単一Structを持つ設計:機能が増えると肥大化していく
// こうなりがち
type UserRepository interface {
	CreateUser(ctx context.Context, username string, email string, hashedPassword string) error
	GetUserByUsername(ctx context.Context, username string) (*model.User, error)
	GetUserById(ctx context.Context, userId string) (*model.User, error)
    ...
    ...
    ...
    ...
}
// ↑ 全部同じファイルに集まってしまう
// 1ファイルに集中してマージコンフリクトが起きやすくなる

ame:atoでは、1ファイルの中にインターフェース・Struct・メソッドをすべて収めます。

// ame:atoの設計:createMessageController.go

// ① インターフェース
type CreateMessageControllerInterface interface {
	CreateMessage(ctx context.Context, message model.CreateMessage) (*model.MessageResponse, error)
}

// ② Struct(依存を外から注入される)
type CreateMessageController struct {
	createMessageRepository repository.CreateMessageRepositoryInterface
}

// ③ コンストラクタ(Wireがここを使ってDIコードを生成する)
func NewCreateMessageController(createMessageRepository repository.CreateMessageRepositoryInterface) *CreateMessageController {
	return &CreateMessageController{
		createMessageRepository: createMessageRepository,
	}
}


// ④ メソッド実装
func (c *CreateMessageController) CreateMessage(ctx context.Context, message model.CreateMessage) (*model.MessageResponse, error) {
  /* ロジック */
}

この設計のメリットは次の通りです。

  • 機能の追加・変更・削除がそのファイルを見るだけで完結する
  • 複数人での開発でファイルが衝突しにくい
  • 各Structの責務が明確で、テスト単位も小さくなる

2. DI(依存性注入):疎結合とテスト可能性を両立する

DIとは何か

DI(Dependency Injection)とは、あるオブジェクトが依存する別のオブジェクトを、内部で生成するのではなく「外から注入してもらう」設計パターンです。

詳しくは拙著をご参照ください。

DIなしの場合、依存を内部で生成するため、テスト時にDBなしで動かすことができません。

// DIなし:Controllerが依存を自分で生成してしまう(イメージ)
type CreateMessageController struct{}

func (c *CreateMessageController) CreateMessage(...) {
    repo := &repository.CreateMessageRepository{db: connectDB()} // 依存を内部で生成
    repo.CreateMessage(...)
}

DIありの場合、依存を外から受け取るため、テスト時にモックを渡せます。

// DIあり:依存を外から受け取る(イメージ)
type CreateMessageController struct {
    createMessageRepository repository.CreateMessageRepositoryInterface // インターフェース型で持つ
}

func NewCreateMessageController(repo repository.CreateMessageRepositoryInterface) *CreateMessageController {
    return &CreateMessageController{createMessageRepository: repo}
}

インターフェースとテスト

Goのインターフェースは実装側が明示的に宣言しなくても自動的にインターフェースを満たせる(暗黙的実装)という特徴があります。

// リポジトリのインターフェース
type CreateMessageRepositoryInterface interface {
    CreateMessage(ctx context.Context, message model.CreateMessage) (*model.Message, error)
}

// 本番用の実装(明示的な宣言なしにインターフェースを満たす)
type CreateMessageRepository struct{ db *gorm.DB }
func (r *CreateMessageRepository) CreateMessage(...) (*model.Message, error) { /* DB処理 */ }

// テスト用のモックも同様にインターフェースを満たせる
type MockCreateMessageRepository struct{}
func (m *MockCreateMessageRepository) CreateMessage(...) (*model.Message, error) {
    return &model.Message{Content: "mock"}, nil
}

Controllerはインターフェース型にしか依存していないため、テスト時はモックを、本番時は本物のRepositoryを注入するだけで切り替えられます。

WireでDIを自動化する

各層のコンストラクタが揃うと、最終的にMain関数でこれらを組み立てる必要があります。

// 手動DIは層が増えると煩雑になる(イメージ)
func main() {
    db := connectDB()
    createRepo := repository.NewCreateMessageRepository(db)
    getRepo := repository.NewGetMessageRepository(db)
    createCtrl := controller.NewCreateMessageController(createRepo)
    getCtrl := controller.NewGetMessageController(getRepo)
    createHandler := handler.NewPostHandler(createCtrl)
    getHandler := handler.NewGetHandler(getCtrl)
    // ...
}

Wireを使うと、コンストラクタの一覧を渡すだけで、依存関係を解析して組み立てコードを自動生成してくれます。(アーカイブされてしまいましたが)

// wire.go
//go:build wireinject
// +build wireinject

package main

import (
	"ameato/controller"
	"ameato/db"
	"ameato/handler"
	"ameato/repository"
	"ameato/router"

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


func InitializeRouter() *gin.Engine {

    wire.Build(
        db.InitDB,
        repository.NewCreateMessageRepository,
        repository.NewGetMessageRepository,
        controller.NewCreateMessageController,
        controller.NewGetMessageController,
        handler.NewGetHandler,
        handler.NewPostHandler,
        router.NewRouter,

		wire.Bind(new(handler.GetHandlerInterface), new(*handler.GetHandler)),
        wire.Bind(new(handler.PostHandlerInterface), new(*handler.PostHandler)),
		wire.Bind(new(controller.CreateMessageControllerInterface), new(*controller.CreateMessageController)),
		wire.Bind(new(controller.GetMessageControllerInterface), new(*controller.GetMessageController)),
		wire.Bind(new(repository.CreateMessageRepositoryInterface), new(*repository.CreateMessageRepository)),
		wire.Bind(new(repository.GetMessageRepositoryInterface), new(*repository.GetMessageRepository)),
		
    )
	
    return nil
}
wire  # wire_gen.goが自動生成される

wire_gen.goには先ほど手動で書いていたようなコードが生成されます。依存関係のグラフをWireが解決してくれるので、コンストラクタを追加してもwire.goに追記することでいい感じにしてくれます。また、wire.goを見れば依存関係の全体像が一覧できるというおまけの利点もあります。

3. テスト設計:Table-Driven Test × Gomock

テストの全体方針

本プロジェクトでは、全ロジックに対して約80%のカバレッジを目標にしています。

CIには以下の2種類のテストを用意しています。

  • lite-test:DBをSQLiteに置き換えた軽量なテスト。PRごとに実行する
  • full-test:本番環境と同様のMariaDBを使ったテスト。mainブランチへのマージ時に実行する

Table-Driven Testとは

Goのテストでよく使われるパターンで、テストケースを構造体のスライスで定義してループで回す方法です。

// イメージ
cases := []struct {
    name    string
    in      model.CreateMessage  // 入力
    want    *model.Message       // 期待する戻り値
    wantErr error                // 期待するエラー
}{
    {name: "正常投稿", in: ..., want: ..., wantErr: nil},
    {name: "空文字はエラー", in: ..., want: nil, wantErr: errors.New("invalid Message Length")},
    // ケースを追加するだけで網羅できる
}

for _, tc := range cases {
    t.Run(tc.name, func(t *testing.T) {
        // テスト処理
    })
}

ケースの追加・削除がcasesスライスへの操作で済むため、メンテナンスが楽です。

Gomockでモックを自動生成する

手動でモックを書く場合、インターフェースにメソッドが増えるたびにモックの実装も追加しなければなりません。Gomock(go.uber.org/mock)を使うと、インターフェース定義からモックコードを自動生成できます。

go install go.uber.org/mock/mockgen@latest
mockgen -source=repository/createMessageRepository.go \
        -destination=mocks/mock_createMessageRepository.go

テストの全体像

実際のテストコード(一部加筆修正)は下記のとおりです。CreateMessageControllerのテストです。

func TestCreateMessageController(t *testing.T) {

    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    // 自動生成されたモックを使う
    repo := mocks.NewMockCreateMessageRepositoryInterface(ctrl)

    cases := []struct {
        name   string
        in     model.CreateMessage
        ret    *model.Message
        retErr error
        expErr bool
    }{
        // ── 正常系 ──────────────────────────────────────────────────
        {
            name: "正常投稿(英語)",
            in:   model.CreateMessage{Content: "Hello", Position: model.Position{X: 0.5, Y: 0.5}},
            ret:  &model.Message{Content: "Hello", CreatedAt: time.Now().Unix(), Position: model.Position{X: 0.5, Y: 0.5}},
            expErr: false,
        },
        {
            name: "正常投稿(日本語)",
            in:   model.CreateMessage{Content: "こんにちはこんにちはこんにちはこんにちはこんにちは", Position: model.Position{X: 0.5, Y: 0.5}},
            ret:  &model.Message{Content: "こんにちはこんにちはこんにちはこんにちはこんにちは", CreatedAt: time.Now().Unix(), Position: model.Position{X: 0.5, Y: 0.5}},
            expErr: false,
        },
        // ── バリデーション:メッセージ長 ─────────────────────────────
        {
            name:   "空文字はエラー",
            in:     model.CreateMessage{Content: "", Position: model.Position{X: 0.5, Y: 0.5}},
            retErr: errors.New("invalid Message Length"),
            expErr: true,
        },
        {
            name:   "30文字超はエラー",
            in:     model.CreateMessage{Content: "123456789_123456789_123456789_1", Position: model.Position{X: 0.5, Y: 0.5}},
            retErr: errors.New("invalid Message Length"),
            expErr: true,
        },
        // ── バリデーション:座標範囲 ──────────────────────────────────
        {
            name:   "X座標が負はエラー",
            in:     model.CreateMessage{Content: "Hello", Position: model.Position{X: -0.5, Y: 0.5}},
            retErr: errors.New("invalid Position Range"),
            expErr: true,
        },
        {
            name:   "X座標が1超はエラー",
            in:     model.CreateMessage{Content: "Hello", Position: model.Position{X: 1.5, Y: 0.5}},
            retErr: errors.New("invalid Position Range"),
            expErr: true,
        },
        {
            name:   "Y座標が負はエラー",
            in:     model.CreateMessage{Content: "Hello", Position: model.Position{X: 0.5, Y: -0.5}},
            retErr: errors.New("invalid Position Range"),
            expErr: true,
        },
        {
            name:   "Y座標が1超はエラー",
            in:     model.CreateMessage{Content: "Hello", Position: model.Position{X: 0.5, Y: 1.5}},
            retErr: errors.New("invalid Position Range"),
            expErr: true,
        },
    }

    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {

            // ポイント:正常系のみRepositoryが呼ばれることを期待する
            // エラー系はControllerのバリデーションで弾かれるため、Repositoryには到達しない
            if !tc.expErr {
                repo.EXPECT().CreateMessage(gomock.Any(), tc.in).Return(tc.ret, nil)
            }

            createController := controller.NewCreateMessageController(repo)
            msg, err := createController.CreateMessage(context.Background(), tc.in)

            if tc.expErr {
                if err == nil {
                    t.Errorf("エラーを期待しましたが、nilでした(期待: %v)", tc.retErr)
                } else if err.Error() != tc.retErr.Error() {
                    t.Errorf("エラーの内容が異なります(期待: %v, 実際: %v)", tc.retErr, err)
                }
            } else {
                if err != nil {
                    t.Errorf("エラーは期待していませんが、発生しました: %v", err)
                }
                if msg.Content != tc.ret.Content {
                    t.Errorf("Contentが異なります(期待: %s, 実際: %s)", tc.ret.Content, msg.Content)
                }
            }
        })
    }
}

このテストで検証していることは、大きく2点です。

  1. バリデーションが正しく機能するか
    空文字・文字数オーバー・範囲外座標などの不正入力に対して、正しいエラーが返るかを確認しています。

  2. Repositoryが期待通りに呼ばれるか
    if !tc.expErr { repo.EXPECT()... } を記載し、エラーが期待されるケースではRepositoryに到達しないことを宣言しています。もしバリデーションをすり抜けてRepositoryが呼ばれてしまった場合、Gomockがテスト失敗として検出します。


4. 開発環境(Docker Compose × air)

Dockerfile(開発環境)

開発環境では、ファイル変更を検知して自動リビルドするAir(air-verse/air)を使っています。

FROM golang:1.25.5-alpine3.22
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download && go install github.com/air-verse/air@latest
COPY . .
EXPOSE 8080
CMD ["air", "-c", "air.toml"]

compose.yml(開発環境)

services:
  backend:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8000:8000"
    volumes:
      - .:/app
    env_file:
      - .env
    depends_on:
      db:
        condition: service_healthy
  db:
    build:
      context: .
      dockerfile: Dockerfile.db
    restart: always
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      timeout: 5s
      retries: 3
    ports:
      - "3306:3306"
    env_file:
      - .env

air.tomlのポーリング設定

自身の環境ではファイルシステムのイベント(inotify)が機能せず、ホットリロードが動きませんでした。poll = trueに設定することで10秒ごとにポーリングして解決しています。

[build]
  poll = true
  poll_interval = 10000  # ミリ秒。10秒ごとにポーリングする

inotifyが使える環境ではpoll = falseにするとCPU負荷を抑えられます。


5. OpenAPI(swagger)の自動生成(swaggo)

フロントエンドでの型宣言の手間を省くため、HandlerにSwaggerアノテーションコメントを記述し、swaggoでOpenAPIのドキュメントを自動生成しています。

フロントエンドで使用するopenapi-typescriptがswaggoのデフォルト出力であるOpenAPI 2.0に対応していなかったため、swaggo側でOpenAPI 3.1で出力するオプションを指定しています。

swag init --v3.1

生成されたドキュメントをもとにopenapi-typescriptでTypeScriptの型定義を生成することで、楽にAPIの型を宣言できます。

6. CI/CDパイプライン:GitHub Actions × GHCR × ARM VM

デプロイ構成の全体像

GitHub Actions(ubuntu-latest / AMD64)
  ├─ [lite-test] PR時:SQLiteで軽量テスト
  ├─ [full-test] mainマージ時:MariaDBで本番(と同等の)テスト
  └─ [deploy] 手動トリガー
       ├─ go build(GOARCH=arm64)
       ├─ docker build → ghcr.io にpush
       └─ SSH(Cloudflare Tunnel経由)でVMにdeploy

Azure VM(ARM64)
  └─ docker compose pull & up
       ├─ backend(distrolessイメージ)
       ├─ MariaDB
       ├─ Nginx
       └─ Cloudflare Tunnel → インターネット公開

途中までQEMUだったけどやめてクロスビルドに切り替えた

VMがARM64アーキテクチャのため、最初はQEMUを使ってARM向けにDockerイメージをビルドしていました。しかしビルドに10分近くかかるという問題がありました。

参考資料:https://qiita.com/kakinaguru_zo/items/0bc18be7966e5b7e3afe

Goはクロスコンパイルを標準でサポートしているため、ARM64向けバイナリを生成できます。

# AMD64のGitHub Actions上でARM64向けにクロスビルドする
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o main .

本番用Dockerfile(distrolessイメージ)

FROM gcr.io/distroless/static-debian12
WORKDIR /
COPY main /main   # GitHub Actionsでクロスビルド済みのバイナリをコピーする
EXPOSE 8000
USER nonroot:nonroot
CMD ["/main"]

distrolessはシェルとかパッケージマネージャーなどを排除したバイナリ実行に特化したイメージです。サイズが極めて小さくalpineよりも小さいです。また、シェルもないので、セキュリティ面でも効果があります。

GitHub Actionsワークフロー(ビルド&デプロイ)

(加筆修正あり)

name: Build and Deploy

on:
  workflow_dispatch:  # 手動トリガー

env:
  IMAGE_NAME: ameato-backend

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: "1.25.5"

      # ① ARM64向けにクロスビルドする
      - name: Build Bin
        run: |
          CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o main .

      # ② GHCRへログインしてイメージをpushする
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # ③ gitのSHAをタグとして付与する(latestも併せて付与)
      - name: Docker meta
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,format=short
            type=raw,value=latest

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./Dockerfile.prod
          push: true
          platforms: linux/arm64
          tags: ${{ steps.meta.outputs.tags }}

  deploy:
    runs-on: ubuntu-latest
    needs: build-and-push

    steps:
      # ④ Cloudflare Tunnelを使ってVMにSSH接続する
      - name: Install cloudflared
        run: |
          curl -L --output cloudflared.deb \
            https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
          sudo dpkg -i cloudflared.deb

      - name: Set up SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SERVER_SSH_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          cat >> ~/.ssh/config <<EOF
          Host ameato-server
            HostName ${{ secrets.SERVER_HOST }}
            User ${{ secrets.SERVER_USER }}
            IdentityFile ~/.ssh/id_ed25519
            ProxyCommand cloudflared access ssh --hostname %h
            StrictHostKeyChecking no
          EOF

      # ⑤ VM上でdocker pullとdocker compose upを実行する
      - name: Deploy to Server
        run: |
          ssh ameato-server << 'EOF'
            cd /app/ameato
            echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
            FULL_IMAGE_NAME=$(echo "${{ needs.build-and-push.outputs.image-tag }}" | head -n 1)
            export TAG=${FULL_IMAGE_NAME##*:}
            export IMAGE_OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
            docker compose -f compose.prod.yml pull backend
            docker compose -f compose.prod.yml up -d
            docker image prune -f
          EOF

compose.prod.yml(本番環境)

VMはポートを一切開放しない構成です。外部からのHTTPアクセスはCloudflare Tunnel経由、SSHもCloudflare Tunnel経由で接続します。

services:
  backend:
    image: ghcr.io/${IMAGE_OWNER}/ameato-backend:${TAG:-latest}
    restart: always
    env_file: .env
    depends_on:
      db:
        condition: service_healthy

  db:
    build:
      context: .
      dockerfile: Dockerfile.db
    restart: always
    env_file: .env
    volumes:
      - db_data_prod:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      timeout: 5s
      retries: 3

  nginx:
    image: nginx:alpine
    restart: always
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - backend

  tunnel:
    image: cloudflare/cloudflared:latest
    restart: unless-stopped
    command: tunnel --no-autoupdate run
    env_file:
      - .env
    extra_hosts:
      - "host.docker.internal:host-gateway"
    depends_on:
      - nginx

volumes:
  db_data_prod:

最初だけセットアップのためにVMの一部ポートを開けて固定IPを介して作業し、DockerComposeが立ち上がったらポートを塞いでIPを解放すればOKです。

CIのテストワークフロー

(加筆修正あり)

name: full test

on:
  workflow_dispatch:
  workflow_call:

jobs:
  full_test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install envsubst
        run: sudo apt-get update && sudo apt-get install -y gettext

      # テスト用の.envを生成する
      - name: Setup EnvFile
        run: |
          cat <<EOF > .env
          DB_USERNAME=ameato_ci_user
          DB_PASSWORD=ameato_ci_password
          DB_NAME=ameato_db
          DB_TEST_NAME=ameato_test_db
          MYSQL_ROOT_PASSWORD=ameato_root_password
          EOF

      # envsubstで変数を埋め込んだSQLを生成する
      - name: generate init_db.sql
        run: |
          export $(cat .env | xargs) && envsubst < ./scripts/init_db.sql.template > ./scripts/init_db.sql

      # DBコンテナだけ起動する(バックエンドは不要)
      - name: Setup Docker Compose
        run: docker compose -f compose.yml up -d db

      - uses: actions/setup-go@v6.1.0
        with:
          go-version: "1.25.5"

      - name: Run Tests
        run: go test -v ./...

7. フロントエンドの実装(Next.js)

Optimistic UI(楽観的UI)

ユーザーの体感速度を上げるため、Optimistic UIを採用しています。メッセージ送信時にサーバーのレスポンスを待たず、ローカルのStateを即座に更新して画面に表示します。これは自作することもできますが、ちょうどuseSWRでデータフェッチを行っていたので、一緒にuseSWRのmutateで実現します。

送信はバックグラウンドで実行され、成功すればそのまま表示継続、失敗した場合(オフライン時など)は再送キュー(unSyncedMessages)に積まれ、オンライン復帰時に自動再送します。

他ユーザーの投稿は定期ポーリング(useMessages hook)で取得し、送信直後のGETリクエストを省くことで不要な通信を減らしています。

また、ロゴマークを押すとmutateで再ロードが行われます。ただし、連打されるとリクエストが増えて困るので、押されて再ロードが開始してから数秒間は再ロードが無効化されます。

UXを高める工夫

入力モードに応じた送信ショートカットの切り替え

日本語IMEの変換確定(Enter)を誤送信として扱わないよう、入力モードに応じてショートカットを動的に変えています。IMEの情報は取れなかったため、最後に打たれた文字が半角か全角かで判定しています。

  • 半角入力時:Enterで送信
  • 全角入力時:Ctrl + Enterで送信

image.png

スクロール位置の自動調整

入力フォームが画面外にはみ出す場合に自動スクロールします。onClickのコールバックでscrollToを呼ぶと、ReactのDOM更新と競合してスクロールが止まる問題がありました。setTimeout(fn, 0)でタスクキューに入れ、DOM更新後に実行することで解決しています。

// ReactのDOM更新と競合してスクロールが止まることがある
onClick={() => {
    setShowForm(true);
    window.scrollTo({ left: targetX, behavior: "smooth" });
}}

// タスクキューに入れてDOM更新後に確実に実行する
onClick={() => {
    setShowForm(true);
    setTimeout(() => {
        window.scrollTo({ left: targetX, behavior: "smooth" });
    }, 0);
}}

画面録画 2026-02-27 003122.gif

初見ユーザ向けのTips表示

細かい箇所ですが、サイトが開かれてからタップされずに6秒間経過した場合は「メッセージを描くには画面をタップしてください」の表示が出るようにしてあります。

image.png

かなり特殊なUIなので、初見ユーザの興味に応えるためにも重要なTipsだと考えています。

まとめ

ame:atoの開発で実践した内容を整理します。

テーマ 内容
設計 1ロジック = 1Struct・1ファイルに分割し、層内の結合度を下げる
DI インターフェース型で依存を持ち、本番・テストで実装を差し替えられる構造にする
Wire コンストラクタを列挙するだけでDIを自動生成し、依存関係をwire.goで一覧できる
テスト Table-Driven Test × Gomockで、楽に高カバレッジのテストをする
CI lite-test(SQLite)とfull-test(MariaDB)の2段階でコストとカバレッジを両立する
デプロイ QEMUをやめてGoのクロスコンパイルでビルド時間を大幅短縮する
セキュリティ distrolessイメージ+Cloudflare TunnelでVMのポートを完全に閉じる
フロントエンド Optimistic UI+オフライン対応でUXを改善する

リポジトリはこちらです:KanadeSisido/ameato-backend-pub

0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?