LoginSignup
1
0

GoとWebsocketでリアルタイムチャットサービスを作成しRenderへデプロイしてみた

Last updated at Posted at 2024-03-24

はじめに

 どうも、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

roomtop.png

Room

room.png

SignUp

signup.png

Login

login.png

UserMenu

usermenu.png

使用した技術

大きくバージョンが違わなければ、基本的にどのバージョンでも動作できると思います。

使用したパッケージ

  • 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を選択

sql1.png

Nameに任意の名前を入れる。

sql2.png

インスタンスタイプを選ぶ(今回はFreeにした)。
(※Freeだと、約90日間経過すると中身が消えてしまうので注意。)
Create Databaseする。

sql3.png

接続に必要な情報が得られる。
DatabaseとUsernameのNameには先ほどNameに入れた名前が入る。

sql4.png

Webサーバー

NewからWeb Serviceを選択。

server1.png

今回はプログラムをGitHubに上げているので、GitHubからデプロイを選択。
(※あらかじめ、RenderのアカウントとGitHubのアカウントを連携させておく必要があります。)

server2.png

デプロイするプログラムのリポジトリを選択。

server3.png

サーバー名、デプロイしたいプログラムのGitのブランチ、Rootにしたいディレクトリを選択。

server4.png

今回はコンテナでGoのサーバーを起動しているため、RuntimeにDockerを選択。
インスタンスタイプはStarterを選択した。
(※Freeでもスペック的には問題なかったが、15分リクエストが来ないとサーバーがスリープしてしまい、次のリクエストが来たら起動する(起動に30秒ほど時間かかる。)という制限と、その他機能制限があるため、不便に感じStarterにした。)

server5.png

環境変数を設定。
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。)

server6.png

Advancedからdockerfileのある場所を指定する。
Create Web Serviceから作成。

server7.png

 デプロイが自動的に開始されて、Logsメニューでデプロイされたことが表示されたらデプロイ完了。

下の方の青い文字のURLが、公開されたサービスのURL。
server8.png

 ちなみに、指定したGitHubのリポジトリを更新すると(リモートにpushすると)、自動でデプロイし直してれる。
(※逆を言えば、開発中はちゃんとブランチを分けてあげないとデプロイされまくります。 戒め)

プログラム解説

 特徴的な部分を解説しています。
 本記事ではコード全体の掲載はしていませんので、GitHubのリポジトリからご覧ください。(https://github.com/Shakkuuu/websocket-chat-go)

entity

ChatRoom

クライアントが参加するチャットルーム
部屋ごとに作成され、RoomIDとクライアント(mapでユーザー名とwebsocketを管理)を管理している。

entity.go
type ChatRoom struct {
	ID      string
	Clients map[*websocket.Conn]string
}

Data

HTMLテンプレートに渡すためのデータ
基本的にはMessageフィールドを使用して問題発生時のメッセージ表示などに使用している。

entity.go
type Data struct {
	Name    string
	Message string
}

Message

クライアントサーバ間でやりとりするメッセージ
クライアントからjsonでメッセージを送信して、サーバで処理し、クライアント全体にブロードキャストするための構造体。
「どのRoomのメッセージなのか」「メッセージ本文」「送信者」「宛先(全体向けの場合は空文字列)」「Roomの参加者一覧」「Roomのオンラインユーザー一覧」といったフィールドを持っている。

entity.go
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でやりとりしている。

entity.go
type SentRoomsList struct {
	RoomsList []string `json:"roomslist"`
}

SentUser

ユーザー名送信用
クライアント側からCookieを使用して、自身のユーザー名が何かをサーバ殻もらうための構造体。
Jsonでやりとりしている。

entity.go
type SentUser struct {
	Name string `json:"name"`
}

User

ユーザー管理
DBにも保存する、ユーザー情報を管理する構造体。
「ユーザーのID」「ユーザー名」「パスワード」のフィールドを持つ。
なお、パスワードがDBに保存される際は、ハッシュ化されて保存される。

 gorm:"unique"とすることで、DB保存時に被りなしにできる。

entity.go
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保存時に被りなしにできる。

entity.go
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保存時に被りなしにできる。

entity.go
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秒おきに再接続を試みるようにしている。

 接続出来次第、マイグレーションと初期データを追加している。(テーブルの中身が空だとうまく動かなかった時があったため。)

db.go
// データベースと接続
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でデータベースとやりとりする用

db.go
func GetDB() *gorm.DB {
	return db
}

Close

サーバ終了時のデータベースとの接続終了用

 PostgreSQLとの接続の都合上GORMのバージョンを上げたら、パッケージのパスがgithub.com/jinzhu/gormからgorm.io/gormに変わっていた。
 以前のバージョンではクローズ時にdb.Close()で終了していたが、このバージョンでは以下の記述方法で終了する必要があるようだ。

db.go
// サーバ終了時にデータベースとの接続終了
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には保存できないので、サーバーのメモリ上でオンラインのユーザーは管理している。

room_model.go
var rooms = make(map[string]*entity.ChatRoom) // 作成された各ルームを格納

RoomInit

 roomsはサーバーのメモリ上で管理されているため、サーバーが再起動された際に作成されたRoomが削除されてしまう。
 そのため、Roomっ作成時にDBにもRoomIDだけ保存しておき、サーバー起動時にDBから作成済みのRoomIDを持ってきて、roomsを復元する。

room_model.go
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_model.go
// 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_model.go
// 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内のページでの、参加者一覧やオンライン一覧に使用する。

 リストの中が空の状態であまりクライアントに渡したくなかったため、とりあえず匿名ユーザーを追加している。

room_model.go
// 参加しているユーザー一覧の取得
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パッケージを使用してハッシュ化して保存している。
 また、ログイン時などに入力されたパスワードの一致確認をする際も、このパッケージを使用して確認している。

user_model.go
// パスワードのハッシュ化
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分で処理を分けている。

room_controller.go
	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の第二引数にデータを与えることで、テンプレート内でメッセージなどのデータを表示できる。

room_controller.go
// メッセージをテンプレートに渡す
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の関数を使用して、セッションからユーザー名を取得している。

user_controller.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()でこのチャネルを受信して、各クライン後にメッセージを送信する。

room_controller.go
var sentmessage = make(chan entity.Message) // 各クライアントに送信するためのメッセージのチャネル

ChatLog

 Room内のチャットのログを残すための.logファイルをmainでオープンして、こちらで受け取る。

room_controller.go
var chatlogfile *os.File

// mainでOpenしたログファイルを変数に入れる
func ChatLogInit(f *os.File) {
	chatlogfile = f
}

timeToStr

 チャットのログをログファイルに時間を入れて残す際、timeパッケージのtime.Now()関数での時間では、時間の表記が見づらいため、フォーマットを設定してstringに変換している。

room_controller.go
// "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が作成された旨のメッセージを表示している。

room_controller.go
// 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_controller.go
// 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内の各クライアントに送信する。

room_controller.go
// 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文で型を確認することでエラーハンドリングしている。

room_controller.go
// 参加しているユーザー一覧とオンラインのユーザー一覧の取得
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内の各クライアントへメッセージを送信する。(プライベートチャットの際は、送信元のクライアントと送信先のクライアントにのみ送信する。)

room_controller.go
// 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から持ってきて、テンプレートに渡す。
 このページからパスワードの変更やユーザー削除、ログアウトなどが行える。

user_controller.go
// ユーザー情報のページ
func UserMenu(w http.ResponseWriter, r *http.Request) {
 // ...
}

DeleteUser

 ユーザー削除時は、DBからユーザーを削除するだけでなく、セッションの削除や削除するユーザーの参加中のRoomリスト、削除するユーザーが作成したRoomとそのRoomに参加していたユーザーのそのRoomのParticipatingRoomを削除する必要がある。
 セッションを削除する必要があるため、セッション読み取りの処理の際にmodelの関数を使用せずに、直接セッションの削除を行っている。(ResponseWriterとRequestの両方を使用するため。)

user_controller.go
// ユーザー削除
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の保存されたハッシュ化されたパスワードと比較する。
 確認が終わり次第、新しいパスワードをハッシュ化して、ユーザーデータの更新をする。
 パスワード変更処理終了後に一度ログアウトさせ、ログインページに遷移させている。

user_controller.go
// パスワード変更
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を使って作成する。

user_controller.go
// 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でパスワードを確認したのち、セッションにユーザー名を保存する。

user_controller.go
// 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を使用せずに処理している。

user_controller.go
// 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で返す。

user_controller.go
// 自身のユーザー名を返す
func GetUserName(w http.ResponseWriter, r *http.Request) {
	// ...
}

ユーザーの参加中のRoomリスト送信

 RoomTopにて表示するユーザーの参加中のRoom一覧をJSONで送信する。

user_controller.go
// 参加中の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で使用するセッションは変数で定義している。

session.go
const SESSION_NAME string = "Shakkuuu-websocket-chat-go"

var session *sessions.Session
var store *sessions.CookieStore

SessionInit

mainで実行され、セッションキーを元にしたCookieとSessionの初期化を行う。

session.go
// セッションの初期化
func SessionInit(sessionKey string) {
	store = sessions.NewCookieStore([]byte(sessionKey))
	session = sessions.NewSession(store, SESSION_NAME)
}

セッションからユーザー名の取得処理

 store.Getを使用して、セッションを読み取ってユーザー名を取得する。
 セッション作成時にValues["username"]で入れたため、そこからユーザー名を取り出す。
 session.Valuesはinterface型なので、username.(string)と型を指定して取り出す必要がある。

session.go
// セッションからユーザー名を取得
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型でサーバー起動時に変数にセットしておくことで、リクエストごとにファイルをパースする必要がない。

template.go
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で処理にかかった時間を出している。

server.go
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の引数に渡している。

server.go
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で読み込んでおく。

main.go
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の記載の後に、ファイル名やファイルへのパスを書くことで、実行ファイルにバイナリファイルとして読み込むことができ、プログラム内で静的ファイルを参照することができる。

main.go
// view以下の静的ファイルを変数に格納
//
//go:embed view/*
var view embed.FS

ログの出力設定

 標準パッケージのlogを使用する際に、log.SetFlagsを使用することで、出力方法に関して各種設定を行える。
 log.SetFlags(log.LstdFlags | log.Lshortfile)と設定することで、ログの先頭に日時とログが実行されたファイル名と行数を出力するように設定でき、エラーの原因を発見しやすい。

main.go
	// ログの先頭に日付時刻とファイル名、行数を表示するように設定
	log.SetFlags(log.LstdFlags | log.Lshortfile)
	// エラーログの出力先をファイルに指定
	log.SetOutput(io.MultiWriter(os.Stderr, errorfile))

サーバー起動と終了処理

 mainからgoroutineでサーバー起動を実行し、mainではSIGINTSIGTERMSIGQUITなどの終了シグナルが来るのをチャネルで待機し、シグナルをキャッチして終了処理を実行する。

main.go
	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で伝える。
 その後、メッセージがサーバーから来るのを待機する。

room.js
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情報が入っているかで判断して、入退室の場合はオンラインユーザーや参加ユーザーの表示を更新する。
 通常の場合は、メッセージの欄を更新する。

room.js
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文字以上にしており、一部の記号の使用も可能にしている。

signup-login.js,usermenu.js
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などのクラウドを使用したデプロイにも発展できればと思いました。

1
0
1

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