はじめに
どうも、Shakkuです。
都内某高専情報科の3年生です。(2024/3/24時点)
Websocketを使って何か作りたいな〜とずっと思っていつつ、「javascript全くわからん!」でずっと触れていなかったので、さらっとjsのお勉強して今回リアルタイムに会話ができるチャットサービスを作ってみました。
また、作ったプログラムをデプロイするという体験もしてみたかったので、Renderというサービス内でサーバーとPostgreSQLをお借りして、プログラムをデプロイ&公開してみました。
作成したものは、GitHubにもあげています。(https://github.com/Shakkuuu/websocket-chat-go)
どんなコードかだけ見たい方は上のGitHubから。
ざっくりとした構成や使用技術の概要も見たい方は、下の「概要」「参考画面」「使用技術」「構成」などを。
プログラムの中身の説明も見たい方は、最後まで読んでいただければと思います。
いつものごとく、プログラムの説明は自身の振り返りの意味も込めて長々と書いていますが、ご了承ください。
また、デプロイしたものは公開していますので、攻撃とかしない心優しい人は訪れてみてください。(https://shakku-websocket-chat.onrender.com)
↑現在停止中
概要
WebosocketとGo言語を主に使用した、リアルタイム性のあるチャットサービス。
ユーザー登録し、Roomを作成したり参加したりすることで、自由にさまざまなユーザーとリアルタイムに会話することができる。
ユーザーを指定することで、そのRoom内で指定したユーザーにのみ表示されるプライベートメッセージを送信することもできる。
サービスはRenderというPaaSサービスにデプロイしており、ユーザーやRoomのDB保存はRender内のPostgreSQLサービスを使用している。
参考画面
RoomTop
Room
SignUp
Login
UserMenu
使用した技術
大きくバージョンが違わなければ、基本的にどのバージョンでも動作できると思います。
- Golang v1.21
- PostgreSQL v15
- GORM(https://gorm.io/ja_JP)
- Websocket
- Session
- Cookie
- bcrypt
- Render(https://render.com)
- Javascript
- CSS
- Docker
- Docker Compose v3
使用したパッケージ
- embed
- errors
- fmt
- io
- log
- os
- os/signal
- syscall
- time
- encoding/json
- net/http
- html/template
- strings
- golang.org/x/net/websocket v0.20.0
- github.com/gorilla/sessions v1.2.2
- golang.org/x/crypto v0.19.0
- gorm.io/driver/postgres v1.5.6
- gorm.io/gorm v1.25.7
選定理由
net/http
これまではGinやEchoなどのフレームワークを使用してサーバーの起動やルーティングをしていたが、今回のアプリケーションでは、特にフレームワークを必要とする場面がなかったため、標準パッケージである"net/http"を使用した。
また、フレームワークに頼り気味だったこともあったため、あらためて標準パッケージを触ってみたかったという理由もある。
Render
無料枠を使用してプログラムをデプロイ&サービス開始できるPaaSサービス。
Herokuのようなサービスで、インターネット上に無料公開するなどができる。
Herokuの無料枠は1年ほど前になくなってしまった記憶があったので、代わりのサービスとして面白そうなこのサービスを使用してみた。
無料枠でのデプロイの場合、サーバーは一部機能の制限やサーバー起動速度の低下、約15分間アクセスがなかったら自動でスリープモードに入るなどの制限があり、RDBは約90日間経過すると削除されてしまうなどの制限がある。
今回は、スペック的には無料枠で問題なかったが、ログファイルの確認や収集など、サーバーに接続して作業したい場面があったため、Starterプランを選んだ。(RDBは無料枠を使用)
アカウント作成やデプロイ方法はこちらの記事を参考にしました。(https://zenn.dev/miumi/articles/c985d6a010d826)
PostgreSQL
普段はMySQLを使用していたが、Renderで使用できるRDBがPostgreSQLだったため、それに合わせて採用してしてみた。
GORM
GoのORMと呼ばれるものの一つで、特徴として、マイグレーション機能やクエリを直接書かなくていいため行数が少なくて済むなどがある。
他のプログラムでもお世話になっており、今回使用したPostgreSQLにも対応していたので使用した。
マイグレーションでは、Goの構造体を直接テーブルに変換でき、構造体のフィールドを追加削除しても、DBのデータは残したままテーブルの変更ができ、そこに魅力を感じた。
また、クエリ発行時の関数を作りたい時に、クエリのSQL文を長々と書かなくても、関数を使うだけで自動で変換してくれることがとても便利だった。
他は、その技術で一般的に使用されているパッケージを使用したり、参考資料が豊富だったものを使用しています。
github.com/gorilla/sessions
クライアントとサーバー間でユーザーのログイン情報を管理するためのセッションを実装できるパッケージ。
Echoではフレームワークに標準で搭載されていたため必要なかったが、"net/http"にはなかったので、今回使用した。
他にもSessionを実装できるパッケージはあるが、参考資料や記事、公式の使用例がとても参考になったので、このパッケージを選んだ。
golang.org/x/net/websocket
Websocket用のパッケージ。
gorillaのWebsocketパッケージもあったが、参考資料が多かったため、こちらを選んだ。
構成
ディレクトリ
websocket-chat-go/
├── src
│ ├── controller
│ │ ├── room_controller.go
│ │ ├── session.go
│ │ ├── template.go
│ │ └── user_controller.go
│ ├── db
│ │ └── db.go
│ ├── entity
│ │ └── entity.go
│ ├── log
│ │ ├── access.log
│ │ ├── error.log
│ │ └── chat.log
│ ├── model
│ │ ├── room_model.go
│ │ └── user_model.go
│ ├── server
│ │ └── server.go
│ ├── view
│ │ ├── icon
│ │ │ └── favicon.ico
│ │ ├── script
│ │ │ ├──room.js
│ │ │ ├──roomtop.js
│ │ │ ├──signup-login.js
│ │ │ └── usermenu.js
│ │ ├── style
│ │ │ └── main.css
│ │ ├── login.html
│ │ ├── room.html
│ │ ├── roomtop.html
│ │ ├── signup.html
│ │ └── usermenu.html
│ ├── dockerfile
│ ├── go.mod
│ ├── go.sum
│ └── main.go
├── .env(開発用)
├── .gitignore
├── docker-compose.yml
├── Production.env(デプロイ用)
└── README.md
ルーティング
ユーザー系
- SignUpページ
GET /signup
- Signup
POST /signup
- Loginページ
GET /login
- Login
POST /login
- Logout
GET /logout
- ユーザーのメニューページ
GET /usermenu
- ユーザー削除
GET /deleteuser
- パスワード変更
POST /changepassword
- 自身のユーザー名取得
GET /username
- そのユーザーの参加中のRoom一覧
GET /joinrooms
Room系
- Roomのtopページ
GET /
- Room作成
POST /
- Room内のページ
GET /room
- Room削除
GET /deleteroom
- Room一覧取得
GET /rooms
Websocket系
- コネクション確立と、メッセージ受信待機
/ws
- controller.HandleMessages()
goroutineでチャネルにクライアントから来たメッセージが入るまで待機し、クライアントにメッセージを送信する。
ポート
- サーバー:8000
- RDB:5432
Renderへのデプロイ方法
アカウント作成やデプロイ方法はこちらの記事を参考にしました。(https://zenn.dev/miumi/articles/c985d6a010d826)
DB
RenderではPostgreSQLというRDBを使用できる。
NewからPostgreSQLを選択
Nameに任意の名前を入れる。
インスタンスタイプを選ぶ(今回はFreeにした)。
(※Freeだと、約90日間経過すると中身が消えてしまうので注意。)
Create Databaseする。
接続に必要な情報が得られる。
DatabaseとUsernameのNameには先ほどNameに入れた名前が入る。
Webサーバー
NewからWeb Serviceを選択。
今回はプログラムをGitHubに上げているので、GitHubからデプロイを選択。
(※あらかじめ、RenderのアカウントとGitHubのアカウントを連携させておく必要があります。)
デプロイするプログラムのリポジトリを選択。
サーバー名、デプロイしたいプログラムのGitのブランチ、Rootにしたいディレクトリを選択。
今回はコンテナでGoのサーバーを起動しているため、RuntimeにDockerを選択。
インスタンスタイプはStarterを選択した。
(※Freeでもスペック的には問題なかったが、15分リクエストが来ないとサーバーがスリープしてしまい、次のリクエストが来たら起動する(起動に30秒ほど時間かかる。)という制限と、その他機能制限があるため、不便に感じStarterにした。)
環境変数を設定。
SERVERPORTとSESSION_KEYはGoのプログラム内で使用したもの。
DB_〇〇はGoからPostgreSQLにGormで接続する用。
PORTはRenderでサーバーを起動するに際して指定する必要があった。
(※DB_〇〇系はDB作成時に表示された情報を使用する。DB_PROTOCOLはhostname、DB_DATABASENAMEはDatabase、DB_USERNAMEはUsername、DB_USERPASSはPassword、DB_PORTはPort。)
Advancedからdockerfileのある場所を指定する。
Create Web Serviceから作成。
デプロイが自動的に開始されて、Logsメニューでデプロイされたことが表示されたらデプロイ完了。
ちなみに、指定したGitHubのリポジトリを更新すると(リモートにpushすると)、自動でデプロイし直してれる。
(※逆を言えば、開発中はちゃんとブランチを分けてあげないとデプロイされまくります。 戒め)
プログラム解説
特徴的な部分を解説しています。
本記事ではコード全体の掲載はしていませんので、GitHubのリポジトリからご覧ください。(https://github.com/Shakkuuu/websocket-chat-go)
entity
ChatRoom
クライアントが参加するチャットルーム
部屋ごとに作成され、RoomIDとクライアント(mapでユーザー名とwebsocketを管理)を管理している。
type ChatRoom struct {
ID string
Clients map[*websocket.Conn]string
}
Data
HTMLテンプレートに渡すためのデータ
基本的にはMessageフィールドを使用して問題発生時のメッセージ表示などに使用している。
type Data struct {
Name string
Message string
}
Message
クライアントサーバ間でやりとりするメッセージ
クライアントからjsonでメッセージを送信して、サーバで処理し、クライアント全体にブロードキャストするための構造体。
「どのRoomのメッセージなのか」「メッセージ本文」「送信者」「宛先(全体向けの場合は空文字列)」「Roomの参加者一覧」「Roomのオンラインユーザー一覧」といったフィールドを持っている。
type Message struct {
RoomID string `json:"roomid"`
Message string `json:"message"`
Name string `json:"name"`
ToName string `json:"toname"`
AllUsers []string `json:"allusers"`
OnlineUsers []string `json:"onlineusers"`
}
SentRoomsList
ルーム一覧送信用
現在何番のRoomが作成されているかをクライアントに渡すための構造体。
Jsonでやりとりしている。
type SentRoomsList struct {
RoomsList []string `json:"roomslist"`
}
SentUser
ユーザー名送信用
クライアント側からCookieを使用して、自身のユーザー名が何かをサーバ殻もらうための構造体。
Jsonでやりとりしている。
type SentUser struct {
Name string `json:"name"`
}
User
ユーザー管理
DBにも保存する、ユーザー情報を管理する構造体。
「ユーザーのID」「ユーザー名」「パスワード」のフィールドを持つ。
なお、パスワードがDBに保存される際は、ハッシュ化されて保存される。
gorm:"unique"
とすることで、DB保存時に被りなしにできる。
type User struct {
ID int `gorm:"unique"`
Name string `gorm:"unique"`
Password string
}
ParticipatingRoom
ユーザーの参加中Room情報
ユーザーがどのRoomに参加しているか管理する構造体。
「ID(index)」「RoomのID」「その部屋の作成者か」「ユーザー名」のフィールドを持つ。
User構造体でRoomIDの配列のフィールドを作成して管理しようとしたが、DBでは配列が対応されていなかったため、フィールドにUserNameを入れることで対応した。
gorm:"unique"
とすることで、DB保存時に被りなしにできる。
type ParticipatingRoom struct {
ID int `gorm:"unique"`
RoomID string
IsMaster bool
UserName string
}
DBRoom
DB保存用Room
ChatRoomはフィールドにmapを使用しているため、DBに保存できないので、作成されているRoomの管理用としての構造体。
Room作成時に同時にRoomsのmapとDBへの保存がされる。(削除なども同様
gorm:"unique"
とすることで、DB保存時に被りなしにできる。
type DBRoom struct {
ID int `gorm:"unique"`
RoomID string `gorm:"unique"`
}
db
PostgreSQLのデータベースに接続したりGORMを動かすためのパッケージ。
主にmodelから呼び出される。
db.go
main.goでdb.Init()
が実行されたときに、実行される。
引数に与えられたホスト名
ユーザー名
パスワード
データベース名
データベースのポート番号
をもとにデータベースへ接続する。
データベース接続には、"GORM"というORMを使用し、gorm.Open()関数の引数にデータベースの種類名とその他情報を与えることで、接続ができる。
gorm.Open()
を使用してデータベースと接続することができ、返り値である*gorm.DB
を使うことで、取得、追加、削除などのデータベースの操作ができる。
データベース接続時に失敗した場合は、countが180になるまで1秒おきに再接続を試みるようにしている。
接続出来次第、マイグレーションと初期データを追加している。(テーブルの中身が空だとうまく動かなかった時があったため。)
// データベースと接続
func Init(host, user, password, database, dbport string) {
CONNECT := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Shanghai", host, user, password, database, dbport)
fmt.Println("DB接続開始")
// 接続できるまで一定回数リトライ
count := 0
db, err = gorm.Open(postgres.Open(CONNECT), &gorm.Config{})
if err != nil {
for {
if err == nil {
fmt.Println("")
break
}
fmt.Print(".")
time.Sleep(time.Second)
count++
if count > 180 { // countが180になるまでリトライ
fmt.Println("")
log.Printf("db Init error: %v\n", err)
panic(err)
}
db, err = gorm.Open(postgres.Open(CONNECT), &gorm.Config{})
}
}
autoMigration()
var u entity.User
db.Where("name = ?", "匿名").Delete(&u)
insertTokumei()
fmt.Println("DB接続完了")
}
GetDB
modelでデータベースとやりとりする用
func GetDB() *gorm.DB {
return db
}
Close
サーバ終了時のデータベースとの接続終了用
PostgreSQLとの接続の都合上GORMのバージョンを上げたら、パッケージのパスがgithub.com/jinzhu/gorm
からgorm.io/gorm
に変わっていた。
以前のバージョンではクローズ時にdb.Close()
で終了していたが、このバージョンでは以下の記述方法で終了する必要があるようだ。
// サーバ終了時にデータベースとの接続終了
func Close() {
if sqlDB, err := db.DB(); err != nil {
log.Printf("db Close error: %v\n", err)
panic(err)
} else {
if err := sqlDB.Close(); err != nil {
log.Printf("db Close error: %v\n", err)
panic(err)
}
}
}
model
ユーザーや部屋の取得,追加,更新,削除などのDBとのやりとりをしたり、パスワードのハッシュ化、各Roomのオンラインユーザーの管理などをしている。
主にcontrollerから呼び出される。
room_model
作成されている各RoomをRoomIDとChatRoom構造体で格納している。
rooms内の各ChatRoomにオンラインのユーザーがClientとして入っている。
ChatRoom構造体のフィールドにはmapであったりwebsocketの情報であったりが入るため、DBには保存できないので、サーバーのメモリ上でオンラインのユーザーは管理している。
var rooms = make(map[string]*entity.ChatRoom) // 作成された各ルームを格納
RoomInit
roomsはサーバーのメモリ上で管理されているため、サーバーが再起動された際に作成されたRoomが削除されてしまう。
そのため、Roomっ作成時にDBにもRoomIDだけ保存しておき、サーバー起動時にDBから作成済みのRoomIDを持ってきて、roomsを復元する。
func RoomInit() error {
db := db.GetDB()
var r []entity.DBRoom
err = db.Find(&r).Error
if err != nil {
return err
}
for _, room := range r {
CreateRoom(room.RoomID)
}
return nil
}
CreateRoom
Room作成時はroomsの方とDBの方の両方に保存している。
RoomInit()
時にCreateRoom()
を呼び出すため、roomsとDBへの作成を別の関数に分けている。
// Room作成
func CreateRoom(roomid string) *entity.ChatRoom {
room := &entity.ChatRoom{
ID: roomid,
Clients: make(map[*websocket.Conn]string),
}
rooms[roomid] = room
return room
}
// Room作成(db)
func DBCreateRoom(roomid string) error {
db := db.GetDB()
var r entity.DBRoom
r.RoomID = roomid
err = db.Create(&r).Error
if err != nil {
return err
}
return nil
}
DeleteRoom
roomsとDBの両方からRoomを削除する。
// Room削除
func DeleteRoom(roomid string) error {
db := db.GetDB()
var r entity.DBRoom
err = db.Where("room_id = ?", roomid).Delete(&r).Error
if err != nil {
return err
}
delete(rooms, roomid)
return nil
}
Get AllUsers/OnlineUsers
ParticiPatingRoomを使用した、そのRoomに参加している参加しているユーザーの取得と、roomsを使用した、そのRoom内でオンラインのユーザーの取得をする関数。
Room内のページでの、参加者一覧やオンライン一覧に使用する。
リストの中が空の状態であまりクライアントに渡したくなかったため、とりあえず匿名ユーザーを追加している。
// 参加しているユーザー一覧の取得
func GetAllUsers(roomid string) ([]string, error) {
var allusers []string
// Roomに参加しているユーザーの取得
allusers = append(allusers, "匿名")
allprooms, err := GetParticipatingRoomByRoomID(roomid)
if err != nil {
err = fmt.Errorf("GetParticipatingRoomByRoomID error: %v", err)
return allusers, err
}
// ユーザーを格納
for _, prm := range allprooms {
allusers = append(allusers, prm.UserName)
}
return allusers, err
}
// オンラインのユーザー一覧の取得
func GetOnlineUsers(roomid string) ([]string, error) {
var onlineusers []string
// オンラインのユーザー取得
onlineusers = append(onlineusers, "匿名")
// Room一覧取得
rooms = GetRooms()
// roomがあるか再度確認
room, exists := rooms[roomid]
if !exists {
err = fmt.Errorf("this room was not found")
return onlineusers, err
}
// Room内のユーザーを格納
for _, user := range room.Clients {
onlineusers = append(onlineusers, user)
}
return onlineusers, nil
}
user_model
HashPass
UserとParticipatingRoomの取得,追加,更新,削除に関しては、基本的にはGORMの公式ドキュメント通りに取得,追加,更新,削除しており、特筆すべきことがないため省略。
なお、GORMのAutoMigrationで構造体のフィールドであるRoomID
がカラムになった際、room_id
と単語が_
で区切られてしまうため、Where
でカラムを指定するときは注意が必要。
パスワードをDBに保存する際、そのまま保存するのはあまりよろしくないため、bcrypt
パッケージを使用してハッシュ化して保存している。
また、ログイン時などに入力されたパスワードの一致確認をする際も、このパッケージを使用して確認している。
// パスワードのハッシュ化
func HashPass(password string) (string, error) {
hp, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
hashpass := string(hp)
return hashpass, nil
}
// ハッシュ化されたパスワードの一致確認
func HashPassCheck(hashpass, password string) error {
// ハッシュ化されたパスワードの解読と一致確認
err := bcrypt.CompareHashAndPassword([]byte(hashpass), []byte(password))
if err != nil {
log.Printf("error bcrypt.CompareHashAndPassword: %v\n", err)
return err
}
return nil
}
controller
リクエストを受けてHTMLのページを表示したり、POSTされたデータやクエリのデータを処理したり、セッションを管理したり、Websocketの通信をしたりする。
主にserverから呼び出される。
user,room共通
メソッド確認
リクエストされたURLに対してメソッドを確認して、switch分で処理を分けている。
switch r.Method {
case http.MethodGet:
// GETの記述
case http.MethodPost:
// POSTの記述
case http.MethodHead:
fmt.Fprintln(w, "Thank you monitor.")
default:
fmt.Fprintln(w, "Method not allowed")
http.Error(w, "そのメソッドは許可されていません。", http.StatusMethodNotAllowed)
return
}
テンプレート表示
template.go
でParseしたテンプレートを使用してページを表示させている。
Executeの第二引数にデータを与えることで、テンプレート内でメッセージなどのデータを表示できる。
// メッセージをテンプレートに渡す
var data entity.Data
data.Message = "ルーム " + roomid + " は既にあります"
err = troomtop.Execute(w, data)
if err != nil {
log.Printf("Excute error:%v\n", err)
http.Error(w, "ページの表示に失敗しました。", http.StatusInternalServerError)
return
}
セッションからユーザー名取得
Session.go
の関数を使用して、セッションからユーザー名を取得している。
// セッション読み取り
un, err := SessionToGetName(r)
if err != nil {
log.Printf("SessionToGetName error: %v\n", err)
// メッセージをテンプレートに渡す
var data entity.Data
data.Message = "再ログインしてください"
err = tlogin.Execute(w, data)
if err != nil {
log.Printf("Excute error:%v\n", err)
http.Error(w, "ページの表示に失敗しました。", http.StatusInternalServerError)
return
}
return
}
room_controller
sentmessage
Websocketでクライアント側からメッセージがJSONで送信され、それをGoの形式に直したものをこのチャネルに入れて、goroutineで待機させているHandleMessages()
でこのチャネルを受信して、各クライン後にメッセージを送信する。
var sentmessage = make(chan entity.Message) // 各クライアントに送信するためのメッセージのチャネル
ChatLog
Room内のチャットのログを残すための.logファイルをmainでオープンして、こちらで受け取る。
var chatlogfile *os.File
// mainでOpenしたログファイルを変数に入れる
func ChatLogInit(f *os.File) {
chatlogfile = f
}
timeToStr
チャットのログをログファイルに時間を入れて残す際、timeパッケージのtime.Now()
関数での時間では、時間の表記が見づらいため、フォーマットを設定してstringに変換している。
// "YYYY-MM-DD HH-MM-SS"に変換
func timeToStr(t time.Time) string {
return t.Format("2006-01-02 15:04:05")
}
Room作成
Formから作成したいRoomのIDを受け取り、まずは既にそのRoomがぞんざいしていないかをチェック。
セッションのユーザーを取得して、ログインせずに作成しようとしていたらログインページに飛ばす。
DBとの接続に失敗する可能性があるため、先にDBの方でRoomを作成して、それからmapの方のroomsにRoomを追加している。
そのRoomの作成者かつ参加者であるため、Ismaster
をTrueとしてParticipatingRoomの追加をして、Roomが作成された旨のメッセージを表示している。
// roomtopページの表示
func RoomTop(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
// ...
}
case http.MethodPost:
// POSTされた作成するRoomidをFormから受け取り
r.ParseForm()
roomid := r.FormValue("create_roomid")
// Room一覧取得
rooms := model.GetRooms()
_, exists := rooms[roomid]
if exists { // roomが既に存在していたら
// 作成失敗メッセージ表示
// メッセージをテンプレートに渡す
var data entity.Data
data.Message = "ルーム " + roomid + " は既にあります"
err = troomtop.Execute(w, data)
if err != nil {
log.Printf("Excute error:%v\n", err)
http.Error(w, "ページの表示に失敗しました。", http.StatusInternalServerError)
return
}
return
}
// セッション読み取り
un, err := SessionToGetName(r)
if err != nil {
log.Printf("SessionToGetName error: %v\n", err)
// メッセージをテンプレートに渡す
var data entity.Data
data.Message = "再ログインしてください"
err = tlogin.Execute(w, data)
if err != nil {
log.Printf("Excute error:%v\n", err)
http.Error(w, "ページの表示に失敗しました。", http.StatusInternalServerError)
return
}
return
}
var user entity.User
// セッションのユーザー取得
user, err = model.GetUserByName(un)
if errors.Is(err, gorm.ErrRecordNotFound) {
log.Printf("model.GetUserByName error: %v\n", err)
// メッセージをテンプレートに渡す
var data entity.Data
data.Message = "ユーザーが見つかりませんでした。"
err = troomtop.Execute(w, data)
if err != nil {
log.Printf("Excute error:%v\n", err)
http.Error(w, "ページの表示に失敗しました。", http.StatusInternalServerError)
return
}
return
}
if err != nil {
log.Printf("model.GetUserByName error: %v\n", err)
// メッセージをテンプレートに渡す
var data entity.Data
data.Message = "データベースとの接続に失敗しました。"
err = troomtop.Execute(w, data)
if err != nil {
log.Printf("Excute error:%v\n", err)
http.Error(w, "ページの表示に失敗しました。", http.StatusInternalServerError)
return
}
return
}
// Room作成
err = model.DBCreateRoom(roomid)
if err != nil {
log.Printf("model.DBCreateRoom error: %v\n", err)
// メッセージをテンプレートに渡す
var data entity.Data
data.Message = "データベースとの接続に失敗しました。"
err = troomtop.Execute(w, data)
if err != nil {
log.Printf("Excute error:%v\n", err)
http.Error(w, "ページの表示に失敗しました。", http.StatusInternalServerError)
return
}
return
}
room := model.CreateRoom(roomid)
// 参加中のルーム一覧にMasterとして追加
var proom entity.ParticipatingRoom
proom = entity.ParticipatingRoom{
RoomID: room.ID,
IsMaster: true,
UserName: user.Name,
}
err = model.AddParticipatingRoom(&proom)
if err != nil {
log.Printf("model.AddParticipatingRoom error: %v\n", err)
// メッセージをテンプレートに渡す
var data entity.Data
data.Message = "データベースとの接続に失敗しました。"
err = troomtop.Execute(w, data)
if err != nil {
log.Printf("Excute error:%v\n", err)
http.Error(w, "ページの表示に失敗しました。", http.StatusInternalServerError)
return
}
return
}
// メッセージをテンプレートに渡す
var data entity.Data
data.Message = "ルーム " + roomid + " が作成されました。"
err = troomtop.Execute(w, data)
if err != nil {
log.Printf("Excute error:%v\n", err)
http.Error(w, "ページの表示に失敗しました。", http.StatusInternalServerError)
return
}
case http.MethodHead:
// ...
default:
// ...
}
}
Room削除と離脱
HTMLからはDELETEメソッドが使用できないため、GETで代用している。
URLのクエリ(https://.../deleteroom?roomid=3 みたいな)から削除したいRoomのIDを読み取って、そのRoomが存在するか確認。
セッションからユーザー情報と、ユーザー情報からそのRoomの参加中Room情報を取得して、部屋の作成者であるかどうかを確認。
部屋の作成者でない場合は、その部屋から離脱する。(オフラインとは異なる。)
RoomIDから全ユーザーのそのRoomのParticipatingRoomレコードを削除して、mapのroomsやDBRoomテーブルのレコードからも部屋自体を削除する。
// Room削除
func DeleteRoom(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
// クエリ読み取り
r.ParseForm()
roomid := r.URL.Query().Get("roomid")
// Room一覧取得
rooms := model.GetRooms()
// roomがあるか確認
_, exists := rooms[roomid]
if !exists {
// メッセージをテンプレートに渡す
var data entity.Data
data.Message = "そのIDのルームは見つかりませんでした。"
err = troomtop.Execute(w, data)
if err != nil {
log.Printf("Excute error:%v\n", err)
http.Error(w, switch r.Method {
case http.MethodGet:
// クエリ読み取り
r.ParseForm()
roomid := r.URL.Query().Get("roomid")
// Room一覧取得
rooms := model.GetRooms()
// roomがあるか確認
_, exists := rooms[roomid]
if !exists {
// ...
}
// セッション読み取り
un, err := SessionToGetName(r)
if err != nil {
// ...
}
var user entity.User
// セッションのユーザー取得
user, err = model.GetUserByName(un)
if errors.Is(err, gorm.ErrRecordNotFound) {
// ...
}
if err != nil {
// ...
}
var proom entity.ParticipatingRoom
prooms, err := model.GetParticipatingRoomByName(user.Name)
if err != nil {
// ...
}
for _, proom = range prooms {
if proom.RoomID == roomid {
break
}
}
// 部屋の作成者ではない場合は、部屋から離脱
if !proom.IsMaster {
// 部屋離脱
err = model.DeleteParticipatingRoomByUserNameAndRoomID(user.Name, roomid)
if err != nil {
log.Printf("model.DeleteParticipatingRoomByUserNameAndRoomID error: %v\n", err)
// メッセージをテンプレートに渡す
var data entity.Data
data.Message = "データベースとの接続に失敗しました。"
err = troomtop.Execute(w, data)
if err != nil {
log.Printf("Excute error:%v\n", err)
http.Error(w, "ページの表示に失敗しました。", http.StatusInternalServerError)
return
}
return
}
// メッセージをテンプレートに渡す
var data entity.Data
data.Message = "部屋を離脱しました。"
err = troomtop.Execute(w, data)
if err != nil {
log.Printf("Excute error:%v\n", err)
http.Error(w, "ページの表示に失敗しました。", http.StatusInternalServerError)
return
}
return
}
// ユーザーの参加中ルームリストからも削除
err = model.DeleteParticipatingRoomByRoomID(roomid)
if err != nil {
// ...
}
// 部屋削除
err = model.DeleteRoom(roomid)
if err != nil {
// ...
}
// メッセージをテンプレートに渡す
var data entity.Data
data.Message = "部屋を削除しました。"
// ...
default:
// ...
}
}
クライアントとWebsocketのコネクション確立
Roomページに移動したのち、javascriptからWebsocketのコネクション確立のリクエストがされる。
リクエストの中のメッセージのJSONからユーザ名やRoomIDを取得。
ユーザー情報を取得したら、既に参加済みかどうか(ParticipatingRoomが追加されているか)を確認し、未参加であれば追加。
その後、ChatRoomのClientsにユーザーが追加される(オンラインユーザーとしてRoomに追加される。)
参加者一覧、オンライン一覧の取得(コードの詳細は後ほど解説)をしたのち、ユーザーの入室メッセージをsentmessageチャネルへ送って、そのRoomの各クライアントに送信と、参加するユーザーにWellcomeメッセージを送信する。
その後、コネクションを確立したサーバーはクライアントからのメッセージが来るまで受信待ちモードへと入る
クライアントからメッセージが来たらsentmessageチャネルに送って、メッセージを他のクライアントへ送信する。
受信待ち中にクライアント側からRoomのページを抜けたことを知らせるシグナルが来たら、ChatRoomのmapから削除(オフライン状態への移行)と、退出メッセージをRoom内の各クライアントに送信する。
// WebsocketでRoom参加後のコネクション確立
func HandleConnection(ws *websocket.Conn) {
defer ws.Close()
// クライアントから参加する部屋が指定されたメッセージ受信
var msg entity.Message
err := websocket.JSON.Receive(ws, &msg)
if err != nil {
log.Printf("Receive room ID error:%v\n", err)
return
}
// Room一覧取得
rooms := model.GetRooms()
// 部屋が存在しているかどうか(なくてもいいかも)
room, exists := rooms[msg.RoomID]
if !exists {
log.Printf("This room was not found\n")
return
}
var user entity.User
// ユーザー取得
user, err = model.GetUserByName(msg.Name)
if errors.Is(err, gorm.ErrRecordNotFound) {
log.Printf("model.GetUserByName error: %v\n", err)
log.Printf("User Not Found: %v\n", err)
return
}
if err != nil {
log.Printf("model.GetUserByName error: %v\n", err)
log.Printf("GetUserByName error: %v\n", err)
return
}
var check bool = false
// 既に参加しているかどうかを確認
var proom entity.ParticipatingRoom
prooms, err := model.GetParticipatingRoomByName(user.Name)
if err != nil {
log.Printf("GetParticipatingRoomByName error: %v\n", err)
return
}
for _, proom = range prooms {
if proom.RoomID == room.ID {
check = true
}
}
if !check {
// 参加中のルーム一覧に参加者として追加
var proom entity.ParticipatingRoom = entity.ParticipatingRoom{
RoomID: room.ID,
IsMaster: false,
UserName: user.Name,
}
err = model.AddParticipatingRoom(&proom)
if err != nil {
log.Printf("AddParticipatingRoom error: %v\n", err)
return
}
}
// Roomに参加
room.Clients[ws] = msg.Name
fmt.Println(room.Clients) // 参加者一覧 デバッグ用
// 参加しているユーザー一覧とオンラインのユーザー一覧の取得
allusersChan := make(chan interface{})
onlineusersChan := make(chan interface{})
var allusers []string
var onlineusers []string
go func() {
aus, err := model.GetAllUsers(msg.RoomID)
if err != nil {
err = fmt.Errorf("GetAllUsers error: %v", err)
allusersChan <- err
return
}
allusersChan <- aus
}()
go func() {
ous, err := model.GetOnlineUsers(msg.RoomID)
if err != nil {
err = fmt.Errorf("GetOnlineUsers error: %v", err)
allusersChan <- err
return
}
onlineusersChan <- ous
}()
v1 := <-allusersChan
v2 := <-onlineusersChan
switch t1 := v1.(type) {
case error:
log.Println(t1)
return
case []string:
allusers = t1
}
switch t2 := v2.(type) {
case error:
log.Println(t2)
return
case []string:
onlineusers = t2
}
// Roomに参加したことをそのRoomのクライアントにブロードキャスト
entermsg := entity.Message{RoomID: room.ID, Message: msg.Name + "が入室しました", Name: "Server", ToName: "", AllUsers: allusers, OnlineUsers: onlineusers}
sentmessage <- entermsg
// サーバ側からクライアントにWellcomeメッセージを送信
err = websocket.JSON.Send(ws, entity.Message{RoomID: room.ID, Message: "サーバ" + room.ID + "へようこそ", Name: "Server", ToName: msg.Name, AllUsers: nil, OnlineUsers: nil})
if err != nil {
log.Printf("server wellcome Send error:%v\n", err)
}
// クライアントからメッセージが来るまで受信待ちする
for {
// クライアントからのメッセージを受信
err = websocket.JSON.Receive(ws, &msg)
if err != nil {
if err.Error() == "EOF" { // Roomを退出したことを示すメッセージが来たら
log.Printf("EOF error:%v\n", err)
delete(room.Clients, ws) // Roomからそのクライアントを削除
// 参加しているユーザー一覧とオンラインのユーザー一覧の取得
allusersChan := make(chan interface{})
onlineusersChan := make(chan interface{})
var allusers []string
var onlineusers []string
go func() {
aus, err := model.GetAllUsers(msg.RoomID)
if err != nil {
err = fmt.Errorf("GetAllUsers error: %v", err)
allusersChan <- err
return
}
allusersChan <- aus
}()
go func() {
ous, err := model.GetOnlineUsers(msg.RoomID)
if err != nil {
err = fmt.Errorf("GetOnlineUsers error: %v", err)
allusersChan <- err
return
}
onlineusersChan <- ous
}()
v1 := <-allusersChan
v2 := <-onlineusersChan
switch t1 := v1.(type) {
case error:
log.Println(t1)
return
case []string:
allusers = t1
}
switch t2 := v2.(type) {
case error:
log.Println(t2)
return
case []string:
onlineusers = t2
}
// そのクライアントがRoomから退出したことをそのRoomにブロードキャスト
exitmsg := entity.Message{RoomID: msg.RoomID, Message: msg.Name + "が退出しました", Name: "Server", ToName: "", AllUsers: allusers, OnlineUsers: onlineusers}
sentmessage <- exitmsg
break
}
log.Printf("Receive error:%v\n", err)
}
// goroutineでチャネルを待っているとこへメッセージを渡す
sentmessage <- msg
}
}
参加/オンライン ユーザー一覧の取得処理
GetAllUsers()
やGetOnlineUsers()
はfor文が処理に入っていたり、DBとのやりとりがあって処理に時間がかかる可能性がありつつ、並行に処理した方が早く処理できそうであったため、goroutineで処理した。
それぞれの関数ようにチャネルを用意して、処理が終わったら受け取るようにしている。
interface型のチャネルにすることで、エラーであってもまとめて受け取ることができ、のちにswitch文で型を確認することでエラーハンドリングしている。
// 参加しているユーザー一覧とオンラインのユーザー一覧の取得
allusersChan := make(chan interface{})
onlineusersChan := make(chan interface{})
var allusers []string
var onlineusers []string
go func() {
aus, err := model.GetAllUsers(msg.RoomID)
if err != nil {
err = fmt.Errorf("GetAllUsers error: %v", err)
allusersChan <- err
return
}
allusersChan <- aus
}()
go func() {
ous, err := model.GetOnlineUsers(msg.RoomID)
if err != nil {
err = fmt.Errorf("GetOnlineUsers error: %v", err)
allusersChan <- err
return
}
onlineusersChan <- ous
}()
v1 := <-allusersChan
v2 := <-onlineusersChan
switch t1 := v1.(type) {
case error:
log.Println(t1)
return
case []string:
allusers = t1
}
switch t2 := v2.(type) {
case error:
log.Println(t2)
return
case []string:
onlineusers = t2
}
メッセージ受信
この関数はserver.go
でgoroutineで実行されており、常にメッセージの受け取りを待機している。
sentmessageチャネルにメッセージが送信されたら、チャットログファイルへの書き込みと、各クライアントへのメッセージ送信を行う。
メッセージが改行に対応しているため、チャットログ書き込み時には改行文字をstrings.ReplaceAll
で半角スペースに置き換えた上でログに保存している。
チャットログには、フォーマット変換した現在日時とRoomID、送信者、受信者、メッセージを保存する。
メッセージ送信の際は、ToName
にユーザー名が指定されているかを確認して、プライベートチャットかブロードキャストの全体チャット化を判断してから送信する。
rooms内のChatRoomのClients(オンラインユーザー)のwebsocketを使用して、Room内の各クライアントへメッセージを送信する。(プライベートチャットの際は、送信元のクライアントと送信先のクライアントにのみ送信する。)
// goroutineでメッセージのチャネルが来るまで待ち、Roomにメッセージを送信する
func HandleMessages() {
for {
// sentmessageチャネルからメッセージを受け取る
msg := <-sentmessage
// Room一覧取得
rooms := model.GetRooms()
// 部屋が存在しているかどうか
room, exists := rooms[msg.RoomID]
if !exists {
continue
}
// チャットログを出力と保存 日時、サーバー名、ユーザー名、宛先、メッセージ
replaceNlMsg := strings.ReplaceAll(msg.Message, "\n", " ") // 改行があるとログが改行されてしまうため、改行を削除
chatlog := fmt.Sprintf("%s: [S%s] From(%s) To (%s) Msg(%s)\n", timeToStr(time.Now()), msg.RoomID, msg.Name, msg.ToName, replaceNlMsg)
fmt.Print(chatlog)
fmt.Fprint(chatlogfile, chatlog)
if msg.ToName != "" {
// 接続中のクライアントにメッセージを送る
for client, name := range room.Clients {
if msg.ToName == name || msg.Name == name {
// メッセージを返信する
err := websocket.JSON.Send(client, entity.Message{RoomID: room.ID, Message: msg.Message, Name: msg.Name, ToName: msg.ToName, AllUsers: msg.AllUsers, OnlineUsers: msg.OnlineUsers})
if err != nil {
log.Printf("Send error:%v\n", err)
}
}
}
} else {
// 接続中のクライアントにメッセージを送る
for client := range room.Clients {
// メッセージを返信する
err := websocket.JSON.Send(client, entity.Message{RoomID: room.ID, Message: msg.Message, Name: msg.Name, ToName: "", AllUsers: msg.AllUsers, OnlineUsers: msg.OnlineUsers})
if err != nil {
log.Printf("Send error:%v\n", err)
}
}
}
}
}
user_controller
UserMenu
ユーザー情報に関数流ページを表示させる。
セッションを読み取って各種ユーザーデータをmodelから持ってきて、テンプレートに渡す。
このページからパスワードの変更やユーザー削除、ログアウトなどが行える。
// ユーザー情報のページ
func UserMenu(w http.ResponseWriter, r *http.Request) {
// ...
}
DeleteUser
ユーザー削除時は、DBからユーザーを削除するだけでなく、セッションの削除や削除するユーザーの参加中のRoomリスト、削除するユーザーが作成したRoomとそのRoomに参加していたユーザーのそのRoomのParticipatingRoomを削除する必要がある。
セッションを削除する必要があるため、セッション読み取りの処理の際にmodelの関数を使用せずに、直接セッションの削除を行っている。(ResponseWriterとRequestの両方を使用するため。)
// ユーザー削除
func DeleteUser(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
// セッション読み取り
// ...
// セッション削除
session.Options.MaxAge = -1
session.Save(r, w)
// ユーザーが作成したRoomの削除
var proom entity.ParticipatingRoom
prooms, err := model.GetParticipatingRoomByName(user.Name)
if err != nil {
// ...
}
for _, proom = range prooms {
if !proom.IsMaster {
continue
}
// ユーザーの参加中ルームリストからも削除
err = model.DeleteParticipatingRoomByRoomID(proom.RoomID)
if err != nil {
// ...
}
// 部屋削除
err = model.DeleteRoom(proom.RoomID)
if err != nil {
// ...
}
}
// ユーザーの参加中ルームリストを削除
// ...
// ユーザー削除
// ...
// メッセージをテンプレートに渡す
// ...
default:
// ...
}
}
パスワードの変更
HTMLのformから、現在のパスワードと新しいパスワード、新しいパスワードの確認用の3つを受け取り、入力漏れや再確認パスワードが間違えていないか、現在のパスワードの確認を行った上で、変更を行う。
現在のパスワードの確認の際は、ログイン処理と同様に、'model.HashPassCheck'から、DBの保存されたハッシュ化されたパスワードと比較する。
確認が終わり次第、新しいパスワードをハッシュ化して、ユーザーデータの更新をする。
パスワード変更処理終了後に一度ログアウトさせ、ログインページに遷移させている。
// パスワード変更
func ChangeUserPassword(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
r.ParseForm()
oldpassword := r.FormValue("oldpassword")
password := r.FormValue("password")
checkpass := r.FormValue("checkpassword")
// セッション読み取り
// ...
if oldpassword == "" || password == "" || checkpass == "" {
// ...
}
if password != checkpass {
// ...
}
err = model.HashPassCheck(user.Password, oldpassword)
if err != nil {
// ...
}
hashpass, err := model.HashPass(password)
if err != nil {
// ...
}
user = entity.User{
ID: user.ID,
Name: user.Name,
Password: hashpass,
}
// ユーザー更新
err = model.PutUserByName(&user, un)
if err != nil {
// ...
}
// 再ログイン用に一度セッション削除
session.Options.MaxAge = -1
session.Save(r, w)
// メッセージをテンプレートに渡す
// ...
default:
// ...
}
}
Signup
HTMLのformから新規登録するユーザーのユーザー名とパスワード確認用の再入力パスワードを受け取り、入力漏れや再確認パスワードが間違えていないかの確認を行った上で、ユーザー作成を行う。
ユーザー名の被りを禁止しているため、modelからユーザー一覧を取得し、既にユーザー名が使用されてないかを確認する。
パスワードをハッシュ化してからユーザーをmodelを使って作成する。
// Signup処理
func Signup(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
// ...
case http.MethodPost:
// POSTされたものをFormから受け取り
r.ParseForm()
username := r.FormValue("username")
password := r.FormValue("password")
checkpass := r.FormValue("checkpassword")
if username == "" || password == "" || checkpass == "" {
// ...
}
if password != checkpass {
// ...
}
// ユーザー一覧取得
users, err := model.GetUsers()
if err != nil {
// ...
}
for _, v := range users {
if v.Name == username {
// メッセージをテンプレートに渡す
var data entity.Data
data.Message = "そのユーザー名は既に使用されています。"
// ...
}
}
// パスワードのハッシュ化
hashpass, err := model.HashPass(password)
if err != nil {
// ...
}
user := entity.User{
Name: username,
Password: hashpass,
}
// ユーザー追加
err = model.AddUser(&user)
if err != nil {
// ...
}
// メッセージをテンプレートに渡す
// ...
default:
// ...
}
}
Login
HTMLのformからユーザー名とパスワードを取得して、入力漏れやユーザーが存在するかを確認してからログイン処理を行う。
ユーザー名からユーザーを取得する際に、gorm.ErrRecordNotFound
というGORMのエラーを受け取った際は、ユーザーが見つからなかったとしてエラーを表示する。
model.HashPassCheck
でパスワードを確認したのち、セッションにユーザー名を保存する。
// Login処理
func Login(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
// ...
case http.MethodPost:
// POSTされたものをFormから受け取り
r.ParseForm()
username := r.FormValue("username")
password := r.FormValue("password")
if username == "" || password == "" {
// ...
}
var user entity.User
// 登録されているユーザー取得
user, err = model.GetUserByName(username)
if errors.Is(err, gorm.ErrRecordNotFound) {
log.Printf("model.GetUserByName error: %v\n", err)
// メッセージをテンプレートに渡す
var data entity.Data
data.Message = "ユーザーが見つかりませんでした。"
err = tlogin.Execute(w, data)
if err != nil {
log.Printf("Excute error:%v\n", err)
http.Error(w, "ページの表示に失敗しました。", http.StatusInternalServerError)
return
}
return
}
if err != nil {
// ...
}
err = model.HashPassCheck(user.Password, password)
if err != nil {
// ...
}
// ログイン成功時の処理
// メッセージをテンプレートに渡す
var data entity.Data
data.Message = "ログインに成功しました。"
session, _ = store.Get(r, SESSION_NAME)
session.Values["username"] = username
session.Save(r, w)
err = troomtop.Execute(w, data)
if err != nil {
// ...
}
default:
// ...
}
}
Logout
ユーザーのログアウト処理。
session.Save
を使用するため、セッションの取得の際にmodel.SessionToGetName
を使用せずに処理している。
// Logout処理
func Logout(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
// セッション確認
session, err = store.Get(r, SESSION_NAME)
if err != nil {
// ...
}
// セッション削除
session.Options.MaxAge = -1
session.Save(r, w)
// メッセージをテンプレートに渡す
// ...
default:
// ...
}
}
セッションからユーザー名の確認
セッションからユーザー名を読み取って、クライアントにJSONで返す。
// 自身のユーザー名を返す
func GetUserName(w http.ResponseWriter, r *http.Request) {
// ...
}
ユーザーの参加中のRoomリスト送信
RoomTopにて表示するユーザーの参加中のRoom一覧をJSONで送信する。
// 参加中のRoomの一覧を返す
func JoinRoomsList(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
var joinroomslist entity.SentRoomsList
// セッション読み取り
// ...
var user entity.User
// ユーザー取得
user, err = model.GetUserByName(un)
if errors.Is(err, gorm.ErrRecordNotFound) {
// ...
}
if err != nil {
// ...
}
// joinRoomを格納
prooms, err := model.GetParticipatingRoomByName(user.Name)
if err != nil {
// ...
}
for _, proom := range prooms {
joinroomslist.RoomsList = append(joinroomslist.RoomsList, proom.RoomID)
}
fmt.Println(joinroomslist.RoomsList)
// jsonに変換
sentjson, err := json.Marshal(joinroomslist)
if err != nil {
// ...
}
// jsonで送信
w.Header().Set("Content-Type", "application/json")
w.Write(sentjson)
default:
// ...
}
}
session
Sessionの初期化やセッションからユーザー名を取得する処理が書かれている。
セッションの作成や削除の処理に関しては、その処理が行われる箇所が少ないことと、Saveにhttp.ResponseWriter
が絡んでくるため、userやroomのcontroller内で書いた。
ブラウザのCookie一覧で表示されるCookie名を定数で指定し、各Controllerで使用するセッションは変数で定義している。
const SESSION_NAME string = "Shakkuuu-websocket-chat-go"
var session *sessions.Session
var store *sessions.CookieStore
SessionInit
mainで実行され、セッションキーを元にしたCookieとSessionの初期化を行う。
// セッションの初期化
func SessionInit(sessionKey string) {
store = sessions.NewCookieStore([]byte(sessionKey))
session = sessions.NewSession(store, SESSION_NAME)
}
セッションからユーザー名の取得処理
store.Get
を使用して、セッションを読み取ってユーザー名を取得する。
セッション作成時にValues["username"]
で入れたため、そこからユーザー名を取り出す。
session.Values
はinterface型なので、username.(string)
と型を指定して取り出す必要がある。
// セッションからユーザー名を取得
func SessionToGetName(r *http.Request) (string, error) {
// セッション読み取り
session, err = store.Get(r, SESSION_NAME)
if err != nil {
log.Printf("store.Get error: %v\n", err)
return "", err
}
username := session.Values["username"]
if username == nil {
fmt.Println("セッションなし")
err = fmt.Errorf("セッションなし")
return "", err
}
un := username.(string)
return un, nil
}
template
各種controllerで使用するHTMLテンプレートをParseしておくプログラム。
template.Template
型でサーバー起動時に変数にセットしておくことで、リクエストごとにファイルをパースする必要がない。
var tlogin *template.Template
var troom *template.Template
var troomtop *template.Template
var tsignup *template.Template
var tusermenu *template.Template
func TemplateInit() error {
tlogin, err = template.ParseFiles("view/login.html")
if err != nil {
log.Printf("template.ParseFiles error:%v\n", err)
return err
}
troom, err = template.ParseFiles("view/room.html")
if err != nil {
log.Printf("template.ParseFiles error:%v\n", err)
return err
}
troomtop, err = template.ParseFiles("view/roomtop.html")
if err != nil {
log.Printf("template.ParseFiles error:%v\n", err)
return err
}
tsignup, err = template.ParseFiles("view/signup.html")
if err != nil {
log.Printf("template.ParseFiles error:%v\n", err)
return err
}
tusermenu, err = template.ParseFiles("view/usermenu.html")
if err != nil {
log.Printf("template.ParseFiles error:%v\n", err)
return err
}
return nil
}
server
リクエストを受けてどのcontrollerに渡すかを処理、管理したり、アクセスログの保存をしたりしている。
loggingMiddleware
net/http
の機能としてあるミドルウェ機能の自作をして、アクセスされて処理終了後に、サーバーとログファイルへのログ出力とそのログのフォーマット化をしている。
next.ServeHTTP(w, r)
のタイミングでリクエストの処理が実行されるため、処理実行前にtime.Now
で時間を変数にいれ、処理終了後にtime.Since
で処理にかかった時間を出している。
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
accesslog := fmt.Sprintf("%s: [%s] %s %s %s\n", timeToStr(start), r.Method, r.RemoteAddr, r.URL, time.Since(start))
fmt.Print(accesslog)
fmt.Fprint(accesslogfile, accesslog)
})
}
Init
mainから実行され、ポート番号とview関係のファイルの情報、アクセスログのログファイルを受け取って、サーバーを起動する。
http.FileServer(http.FS(view))
で、サーバー内の静的ファイルをサーバに上げ、各controllerから/view/
のアドレスで読み込めるように設定している。
通常のハンドラーでは、loggingMiddleware(http.HandlerFunc(controller.RoomTop))
といったように、ミドルウェアを通してcontrollerをHandelの引数に渡している。
func Init(port string, view embed.FS, f *os.File) {
// 出力先ファイル設定
accesslogfile = f
http.Handle("/view/", http.FileServer(http.FS(view)))
http.Handle("/", loggingMiddleware(http.HandlerFunc(controller.RoomTop)))
// ...
http.Handle("/ws", websocket.Handler(controller.HandleConnection))
go controller.HandleMessages()
// サーバ起動
err = http.ListenAndServe(port, nil)
if err != nil {
log.Printf("ListenAndServe error:%v\n", err)
os.Exit(1)
}
}
main
ログファイルの読み込みや環境変数の読み込み、各種Initの実行を行う。
環境変数読み込み
Renderにて設定または、開発時にDockerCompose.ymlにて設定した環境変数をos.Getenv
で読み込んでおく。
func loadEnv() (string, string, string, string, string, string, string) {
// Docker-compose.ymlでDocker起動時に設定した環境変数の取得
// dbms := os.Getenv("DB_DBMS") // データベースの種類
username := os.Getenv("DB_USERNAME") // データベースのユーザー名
userpass := os.Getenv("DB_USERPASS") // データベースのユーザーのパスワード
protocol := os.Getenv("DB_PROTOCOL") // データベースの使用するプロトコル
dbname := os.Getenv("DB_DATABASENAME") // データベース名
dbport := os.Getenv("DB_PORT") // データベースのポート番号
port := os.Getenv("SERVERPORT") // ポート番号
sessionKey := os.Getenv("SESSION_KEY") // セッションキー
return protocol, username, userpass, dbname, dbport, port, sessionKey
}
静的ファイルの取り込み
go 1.16
から追加された機能で、//go:embed
の記載の後に、ファイル名やファイルへのパスを書くことで、実行ファイルにバイナリファイルとして読み込むことができ、プログラム内で静的ファイルを参照することができる。
// view以下の静的ファイルを変数に格納
//
//go:embed view/*
var view embed.FS
ログの出力設定
標準パッケージのlog
を使用する際に、log.SetFlags
を使用することで、出力方法に関して各種設定を行える。
log.SetFlags(log.LstdFlags | log.Lshortfile)
と設定することで、ログの先頭に日時とログが実行されたファイル名と行数を出力するように設定でき、エラーの原因を発見しやすい。
// ログの先頭に日付時刻とファイル名、行数を表示するように設定
log.SetFlags(log.LstdFlags | log.Lshortfile)
// エラーログの出力先をファイルに指定
log.SetOutput(io.MultiWriter(os.Stderr, errorfile))
サーバー起動と終了処理
main
からgoroutineでサーバー起動を実行し、mainではSIGINT
やSIGTERM
、SIGQUIT
などの終了シグナルが来るのをチャネルで待機し、シグナルをキャッチして終了処理を実行する。
fmt.Println("server start")
go server.Init(port, view, f) // サーバ起動
// 終了シグナルをキャッチ
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
s := <-sig
fmt.Printf("Signal %s\n", s.String())
// 終了処理
fmt.Println("Shutting down server...")
db.Close()
fmt.Println("server stop")
}
view
goのtemplate機能を使用して、HTMLファイルがクライアント側に提供される。
javascriptでGoのサーバーとやりとりして、websocket通信をしたり、JSONの送受信をしたりしている。
チャットルーム入室時
チャットルームにページが遷移し次第、ログインしているか(プログラムだと、サーバーから自身のユーザ名が取得できるかを確かめている。)を確認し、joinRoom
でサーバー側に参加したことをwebsocketで伝える。
その後、メッセージがサーバーから来るのを待機する。
window.onload = function () {
fetch(protocol+"//"+domain+":"+port+"/username")
.then(response => response.json())
.then(data => {
const username = data.name;
console.log("username:", username);
Name = username;
if (Name == "") {
window.location.href = protocol + "//" + domain + ":" + port + '/login';
return
}
socket = new WebSocket(wsprotocol + "//" + domain + ":" + port + "/ws");
socket.onopen = function () {
joinRoom();
};
socket.onmessage = function (event) {
// サーバーからメッセージを受け取る
const msg = JSON.parse(event.data);
updateMessage(msg.roomid, msg.message, msg.name, msg.toname, msg.allusers, msg.onlineusers);
};
})
.catch(error => {
console.error('Error fetching user data:', error);
window.location.href = protocol + "//" + domain + ":" + port + '/login';
return
});
}
function joinRoom() {
let url_string = location.href;
let url = new URL(url_string);
room_id = url.searchParams.get("roomid");
document.getElementById("current_server").textContent = room_id
document.getElementById("username").textContent = Name
const message = { roomid: room_id, name: Name};
socket.send(JSON.stringify(message));
}
メッセージ受信時
サーバーからメッセージを受信したときに、そのメッセージが入退室時の目セージかどうかをallusers
,onlineusers
情報が入っているかで判断して、入退室の場合はオンラインユーザーや参加ユーザーの表示を更新する。
通常の場合は、メッセージの欄を更新する。
function updateMessage(roomid, message, name, toname, aus, ous) {
const allusers = aus;
const onlineusers = ous;
if (allusers != null || onlineusers != null) {
document.getElementById('allusers').textContent = '';
const allusersListElement = document.getElementById("allusers");
const ausdetails = document.createElement('details');
const aussummary = document.createElement('summary');
const ausul = document.createElement('ul');
aussummary.textContent = "参加ユーザー 一覧";
ausdetails.appendChild(aussummary);
allusers.forEach(user => {
const listItem = document.createElement('li');
listItem.textContent = user;
ausul.appendChild(listItem);
});
ausdetails.appendChild(ausul);
allusersListElement.appendChild(ausdetails);
document.getElementById('onlineusers').textContent = '';
const onlineusersListElement = document.getElementById("onlineusers");
const ousdetails = document.createElement('details');
const oussummary = document.createElement('summary');
const ousul = document.createElement('ul');
oussummary.textContent = "オンラインユーザー 一覧";
ousdetails.appendChild(oussummary);
onlineusers.forEach(user => {
const listItem = document.createElement('li');
listItem.textContent = user;
ousul.appendChild(listItem);
});
ousdetails.appendChild(ousul);
onlineusersListElement.appendChild(ousdetails);
};
let listName = document.createElement("li");
let nameText = document.createTextNode(roomid + " : " + name + "→" + toname);
listName.appendChild(nameText);
let messages = document.createElement("ul");
let listMessage = document.createElement("li");
let messageText = document.createTextNode(message);
listMessage.appendChild(messageText);
messages.appendChild(listMessage);
listName.appendChild(messages);
let ul = document.getElementById("messages");
ul.appendChild(listName);
}
パスワードのバリデーション
form送信時に、ちゃんとルールに沿ったパスワードが設定されているかを正規表現で確認する。
ルールはよくある感じの半角英数字をそれぞれ1種類以上含み8文字以上にしており、一部の記号の使用も可能にしている。
window.onload = function() {
document.getElementById("passwordform").addEventListener("submit", function(event) {
var passwordInput = document.getElementById("password").value;
var pattern = /^(?=.*[a-zA-Z])(?=.*[0-9])[a-zA-Z0-9!@#$%^&*()_+-=]{8,100}$/;
if (!pattern.test(passwordInput)) {
alert("パスワードは半角英数字をそれぞれ1種類以上含み、8文字以上100文字以下である必要があります。");
event.preventDefault(); // フォームの送信をキャンセルする
}
});
};
最後に
今回は、Websocketを使用してリアルタイムなチャットサービスを作成し、Renderというサービスにデプロイしてみました。
初めてWebsocketという技術を使用したため、初歩的なチャットサービスを作成してみましたが、ストリーミング再生の配信やゲームのリアルタイム対戦など、応用的な実装も挑戦できる機会があればなと思います。
また、PaaSサービスにデプロイして全世界に公開するということもはじめてだったので、AWSなどのクラウドを使用したデプロイにも発展できればと思いました。