【Go+Firebase+TypeScript+レイヤードアーキテクチャ】WebSocketのチャットアプリ開発
こんにちは、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.go
とfirebase/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