🐥

【Go+Firebase+TypeScript+レイヤードアーキテクチャ】WebSocketのチャットアプリ開発

2024/09/08に公開

こんにちは、Suuです
久しぶりに個人開発をしたので、備忘録として残したいと思います!
GitHubはこちら

作るものと経緯

新しい技術に挑戦したい、長期インターンで学んだことをアウトプットしたいと思い、表題の通りチャットののwebアプリを作成しました。実装した機能は以下のとおりです。

  • 認証つき
  • ログイン後work spaceIDとパスワードを入れて、特定のチャット画面に遷移する
    • 別々のユーザーが、特定のWork spaceで会話ができる
  • WebSocketの利用によって、リアルタイムでのチャットを行う

技術選定

バックエンド

  • Go 1.21.5
    • 主要ライブラリ
      • firebase.google.com/go v3.13.0
      • github.com/go-sql-driver/mysql v1.8.1
      • github.com/joho/godotenv v1.5.1
      • github.com/kelseyhightower/envconfig v1.4.0
      • github.com/labstack/echo/v4 v4.12.0
      • go.opentelemetry.io/otel/trace v1.24.0
      • go.uber.org/zap v1.27.0

フロントエンド

  • Next.js
    • next v14.2.8
  • React関連
    • react v18.3.1
    • react-dom v18.3.1
  • TypeScript
    • typescript v5.4.5
  • Chakra UI関連
    • @chakra-ui/icons v2.1.1
    • @chakra-ui/react v2.8.2
  • Emotion関連
    • @emotion/react v11.11.4
    • @emotion/styled v11.11.5
  • 型定義パッケージ
    • @types/node v20.12.11
    • @types/react v18.3.2
    • @types/react-dom v18.3.0
  • スタイル関連
    • autoprefixer v10.4.19
    • postcss v8.4.39
    • sass v1.77.4
    • tailwindcss v3.4.4
    • prettier-plugin-tailwindcss v0.6.5
  • Lint/フォーマッタ
    • eslint v8.57.0
    • eslint-config-next v14.1.0
    • eslint-plugin-tailwindcss v3.17.4
    • prettier v3.3.2
  • HTTPクライアント
    • axios v1.6.8
  • Firebase
    • firebase v10.11.1
  • アニメーション
    • framer-motion v11.2.13

アーキテクチャ

DB

  • MySQL
  • phpmyadmin
    DB構造
+-----------------+
| Tables_in_db    |
+-----------------+
| channel_members |
| channels        |
| messages        |
| workspaces      |
+-----------------+

テーブル構造

CREATE TABLE `channels` (
  `organization_id` varchar(225) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
  `channel_id` char(36) NOT NULL,
  `name` varchar(255) NOT NULL,
  `description` text,
  `is_private` tinyint(1) NOT NULL DEFAULT '0'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

CREATE TABLE `channel_members` (
  `channel_id` char(36) NOT NULL,
  `user_id` varchar(128) NOT NULL,
  `joined_at` datetime NOT NULL,
  `role` varchar(50) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

CREATE TABLE `messages` (
  `message_id` char(36) NOT NULL,
  `channel_id` char(36) NOT NULL,
  `user_id` varchar(400) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
  `content` text NOT NULL,
  `workspace_id` varchar(225) NOT NULL,
  `timestamp` datetime NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

CREATE TABLE `workspaces` (
  `id` varchar(225) NOT NULL,
  `name` varchar(225) NOT NULL,
  `password` varchar(225) NOT NULL,
  `createdBy` varchar(225) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

チャレンジした技術

WebSocket

開発前にWebSocketについて調べてはいたものの、あまり理解していなかったために開発中に結構詰まりました。
WebScketとは、双方向通信のプロトコルのことです。双方向通信のプロトコルとは??って感じの方もいると思います。(私がそうでした)
図にするとこんな感じです(こちらをかなり参考にさせていただきました!)

  • HTTP通信(RestAPI)の場合

    この場合、仮にAさんがBさんにメッセージを送り、Bさんの画面にAさんのメッセージが表示されるチャット画面を作る時には以下の手順が必要になります。
    ①AさんのメッセージがサーバーにPOSTされる
    (DBにインサートされる)
    ②Bさんが、サーバーに対してGET処理を送る
    (DBからSELECTする)
    Aさんがメッセージを送ってからBさんが受け取るまで、クライアント側から処理を呼び出す必要があります。そのため、GETのメソッドが呼び出されない限りメッセージは表示することができません。
    これではタイムラグが出やすいですし、仮に1秒間に一回GETをし続けるという設定であれば、メッセージが来ていない時にもGET処理を呼び出していることになりとてももったいないです。
    これを解決するのがWebSocketになります

  • WebSocketの場合

    ちょっとわかりにくいですが、WebSocketで一度接続をするとサーバーから処理を行うことができます。これが双方向通信ということです。
    そのため、
    ①Aさんのメッセージがサーバー側に送られる
    →サーバー側がBさんにメッセージを表示する処理を行う
    のような形になります。

レイヤードアーキテクチャ

以前、「クリーンアーキテクチャをパン工場で説明する【Go】」の記事を書いたように、アーキテクチャについて勉強は(少し)しましたが、個人開発で実装したことはありませんでした。実際、ユーザーが多くいたり、大規模なプロダクトでない、かつ開発者も多くなければアーキテクチャを分ける必要自体は減っていくのですが今回は自分で試したかったので採用しました。
レイヤードアーキテクチャはクリーンアーキテクチャやオニオンアーキテクチャの手前(と言っていいのでしょうか)にあるアーキテクチャです。

ディレクトリ構成

├── app
│   ├── auth
│   │   ├── context.go
│   │   └── middleware.go
│   ├── handlers
│   │   ├── auth.go
│   │   ├── channels.go
│   │   ├── message.go
│   │   └── workspace.go
│   └── usecase
│       ├── auth.go
│       ├── channels.go
│       ├── message.go
│       └── workspase.go
├── cmd
│   ├── server
│   │   └── server.go
│   └── main.go
├── config
│   └── env.go
├── domain
│   ├── model
│   │   ├── channels.go
│   │   ├── client.go
│   │   ├── hub.go
│   │   ├── message.go
│   │   └── workspace.go
│   └── repository
│       ├── channels.go
│       ├── message.go
│       └── workspace.go
├── infrastructure
│   ├── firebase
│   │   ├── auth.go
│   │   └── firebase.go
│   ├── mysql
│   │   ├── channels.go
│   │   ├── db.go
│   │   ├── massage.go
│   │   └── workspace.go
│   └── websocket
│       ├── draft_message.go
│       └── message.go
├── internal
│   └── cerror
│       ├── code.go
│       ├── error.go
│       ├── option.go
│       ├── reason_code.go
│       └── stacktrace.go
├── pkg
│   ├── log
│   │   ├── encoder.go
│   │   └── log.go
│   └── middleware
├── go.mod
├── go.sum
└── ServiceAccount.json

正直、WebSocketに関しては最初わからなかったことが多かったため責務を分離しきれてないと感じています。
例えば、domain/repository/hub.goでは以下のようにアプリケーション層に含まれるような実装が入っています。この記事を書きながら気づいたことも多いので、リファクタリングをしていきたいです。(個人開発に関しては完璧にしてから全てを公開しようと思うと一生お蔵入りになりそうなので一旦この記事公開させてください!)

func (h *Hub) Run() {
	for {
		select {
		case client := <-h.Register:
			h.Clients[client] = true
		case client := <-h.Unregister:
			if _, ok := h.Clients[client]; ok {
				delete(h.Clients, client)
				close(client.Send)
			}
		case message := <-h.Broadcast:
			for client := range h.Clients {
				select {
				case client.Send <- message:
				default:
					close(client.Send)
					delete(h.Clients, client)
				}
			}
		}
	}
}

Firebase Authentication

実は、個人開発で使ったことはほぼなかったFirebase。
フロントエンドとバックエンドでどう認証情報を渡していくのかが最初わからず試行錯誤しました。
最終的に、バックエンドはインフラストラクチャ層にfirebase/firebase.gofirebase/auth.goを作成し、フロントエンドではfirebase/firebase.tsxを作成しました。
idTokenを読み取り、バックエンドに渡すようにしています。

import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/router";
import {
  onAuthStateChanged,
  getIdToken,
  signInWithPopup,
  GoogleAuthProvider,
} from "firebase/auth";
import { auth } from "./firebaseConfig";

const useAuth = () => {
  const router = useRouter();
  const [currentUser, setCurrentUser] = useState(null);

  useEffect(() => {
    const unsubscribe = onAuthStateChanged(auth, async (user) => {
      if (!user) {
        router.push("/");
      } else {
        const token = await getIdToken(user);
        localStorage.setItem("token", token);
      }
    });

    return () => unsubscribe();
  }, [router]);

  const signInWithGoogle = useCallback(async () => {
    try {
      const provider = new GoogleAuthProvider();
      const result = await signInWithPopup(auth, provider);
      const user = result.user;
      const token = await getIdToken(user);
      localStorage.setItem("token", token);
      setCurrentUser(user);
      router.push("/workspace");
    } catch (error) {
      console.error("Google sign in error:", error);
    }
  }, [router]);

  return { currentUser, signInWithGoogle };
};

export default useAuth;
export { auth };

完成したもの

①ユーザーのログイン画面

②WorkSpaceのログイン画面

③チャット画面

デザイン面はインターン先で使っていたChakuraUIを使ってみました。
今までちまちまscssで書いていたので、感動体験でした。
チャット自体はリアルタイムでできていて、初めて動いた時は本当に嬉しかったです。

課題

とりあえず動くものは作ったものの、課題はたくさんあります。

  • アーキテクチャの責務が分かれきっていない
  • フロントに関して、同期について理解が浅くラグが多い
  • SQLにおいて、外部キー制約などが不十分である
    • DB設計や、生のSQL文を作る際にセキュリティやパフォーマンスを考えられていない部分がある
  • Goに関して、パッケージの精査ができてない
    • 慣れているものを優先的に採用してしまっているので、技術選定をがっつり行うことができていないです

感想

上記のような課題はありつつも、最初に開発の目的にしていた「新しい技術に挑戦したい、長期インターンで学んだことをアウトプットしたい」という部分はクリアできたのでよしとしたいと思います。
次回はは、今関心のあるパフォーマンスやセキュリティについて何か挑戦をしてみたいです。

Discussion