🥰

[TypeScript(Next.js) + Go(Echo) + MySQL + Docker]の個人開発

2024/04/09に公開

はじめに

本記事では 「文系学部生、個人参加、(ほぼ)初ハッカソン」が大規模ハッカソンでファイナルにいった記録【Dots to Code】でも述べている、ハッカソンで開発したプロダクトの技術的側面における軌跡を書いていきたいと思います。
ちなみにこれは初個人開発で、だいぶ至らぬことも多いのですが温かい目で見守っていただけると幸いです。
プロダクトの内容はこちら

デプロイされたものはこちら
https://genkiyoho-front-mire0726s-projects.vercel.app/login
GitHubはこちら
https://github.com/Mire0726/Genkiyoho

環境構築

Docker

まず、王道に(?)Docker構築を行います。今回のプロダクトでは、以下のコンテナを作成しました。

  • frontend
  • Swagger UI
  • phpmyadmin
  • mysql

バックエンドはDockerで構築せず、更新するたびにRunしています。
(この記事わかりやすかった)
https://zenn.dev/hiddy0329/articles/822aa3f0903f3f

compose.yml

version: "3"
services:
  frontend:
    build:
      context: ./frontend/
      dockerfile: Dockerfile
    volumes:
      - ./frontend:/app
    ports:
      - 3000:3000
  swagger-ui:
    image: swaggerapi/swagger-ui:latest
    environment:
      SWAGGER_JSON: /api/api-document.yaml
    volumes:
      - ./api-document.yaml:/api/api-document.yaml:ro
    ports:
      - "127.0.0.1:8082:8080"
  mysql:
    image: mysql:8.0.27
    platform: linux/amd64
    ports:
      - "3307:3306"
    environment:
      MYSQL_ROOT_PASSWORD: mysql
      MYSQL_DATABASE: db
      MYSQL_USER: root
      MYSQL_PASSWORD: password
      TZ: 'Asia/Tokyo'
    volumes:
      - db-data:/var/lib/mysql
      - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql
  phpmyadmin:
    image: phpmyadmin
    depends_on:
      - mysql
    environment:
      - PMA_HOSTS=mysql
    ports:
      - "3001:80"
volumes:
  db-data:

全体のディレクトリ構成

root/
├backend/
├frontend/
├.env
├.gitignore
├compose.yml
└api-document.yaml

backend

ディレクトリ構成

root/
├backend/
        ├main.go(起動だけ)
        ├go.mod
        ├go.sum
        ├Dockerfile
        └server/
            ├context/auth/
                        └context.go
            ├db/
                └db.go(MySQlとの接続)
            ├http/
                └auth.go
            ├handler/
                ├user.go(ビジネスロジック)
                └その他ビジネスロジックファイル
                
            ├model/
                ├user.go(DBとのやり取り)
                └その他DBとのデータのやり取りファイル
            ├外部APIフォルダ/
                └各外部APIの接続&データ取得ファイル
            └server.go(ルーティング)
            

本当は細かくアーキテクチャをやりたかったのですが、知識&時間が両方不足していて、modelとhandlerで分けるのが精一杯でした!
ただ、外部APIの導入においてどこの階層でやろう、と悩んでみたりcontextの意義について考えたりと、とても開発をしながら学ことがいっぱいあったなと思います。

main.go

package main

import (
	"flag"

	"github.com/Mire0726/Genkiyoho/backend/server"
    "os"
    "fmt"
    "log"
	
)

func main() {
	var defaultPort = "8080"
	var port = os.Getenv("PORT") //これはデプロイ時に使用しました。開発時はlocalhost:8080
	if port == "" {
		port = defaultPort
		flag.StringVar(&port, "addr", defaultPort, "default server port")
	}
	flag.Parse()

	// サーバーの設定と起動
	addr := fmt.Sprintf(":%s", port)
	log.Printf("Listening on %s...\n", addr)
	server.Serve(addr)
}

db.go

package db

import (
	"database/sql"
	"fmt"
	"log"
	"os"
	"net/url"

	"github.com/joho/godotenv" 
	"strings"

	_ "github.com/go-sql-driver/mysql"
)

const driverName = "mysql"

var Conn *sql.DB

func init() {

	err := godotenv.Load() 
	if err != nil {
		log.Fatal("Error loading .env file")
	}

	user := os.Getenv("MYSQL_USER")
	password := os.Getenv("MYSQL_PASSWORD")
	host := os.Getenv("MYSQL_HOST")
	port := os.Getenv("MYSQL_PORT")
	database := os.Getenv("MYSQL_DATABASE")
	charset := os.Getenv("MYSQL_CHARSET")
	parseTime := os.Getenv("MYSQL_PARSE_TIME")
	loc := os.Getenv("MYSQL_LOC")
	
	dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=%s&parseTime=%s&loc=%s",
		user, password, host, port, database, charset, parseTime, loc)
	
	Conn, err := sql.Open(driverName, dsn)
	if err != nil {
		log.Fatal(err)
	}
	if err := Conn.Ping(); err != nil {
		log.Fatal("Unable to connect to the database:", err)
	}
}

db.goは本番環境ではだいぶ違うものを使用しますが、この章ではローカルで開発する時のバージョンを載せます。

server.go

package server

import (
	"log"
	"net/http"

	"github.com/Mire0726/Genkiyoho/backend/server/handler"
	"github.com/Mire0726/Genkiyoho/backend/server/http/middleware"

	_ "github.com/go-sql-driver/mysql" // MySQLドライバーをインポート
	"github.com/labstack/echo/v4"
	echomiddleware "github.com/labstack/echo/v4/middleware"
)

// Serve はHTTPサーバを起動します。データベース接続を引数に追加。
func Serve(addr string) {
    e := echo.New()
    
// ミドルウェアの設定
    // panicが発生した場合の処理
	e.Use(echomiddleware.Recover())
	// CORSの設定
	e.Use(echomiddleware.CORSWithConfig(echomiddleware.CORSConfig{
        Skipper:      echomiddleware.DefaultCORSConfig.Skipper,
        AllowOrigins: echomiddleware.DefaultCORSConfig.AllowOrigins,
        AllowMethods: echomiddleware.DefaultCORSConfig.AllowMethods,
        AllowHeaders: []string{"Content-Type", "Accept", "Origin", "X-Token", "Authorization"},
    }))
    

    // ルーティングの設定("/"以下は省略)
     e.GET("/", func(c echo.Context) error {
        return c.String(http.StatusOK, "Welcome to Genkiyoho!")
    })
   
    /* ===== サーバの起動 ===== */

    log.Printf("Server running on %s", addr)
    if err := e.Start(addr); err != nil {
        log.Fatalf("Failed to start server: %v", err)
    }
}

上記+handlerとmodelファイルの初期設定をして、go run backend/main.goをすると見慣れた安心感のあるサーバー起動が行われます☺️(↓みたいなやつですね!)

   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/ v4.11.4
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
                                    O\
⇨ http server started on [::]:〜〜〜

もっと初期のgo getコマンドなどについては以下のような記事を参照してみてください!
https://zenn.dev/beeeegle/articles/fbb3caf914034c

frontend

ディレクトリ構成

root/
├frontend/
        ├package.json
        ├Dockerfile
        ├package-lock.json
        ├.next/
        ├node_modules/
        ├pages/
                      ├ ~~.scss
                  └ ~~.tsx(各ページごとに)
        ├public/
              └画像系
        ├tsconfig.json
        └yarn.lock

Nextパッケージをダウンロードしたらすぐ始められるのでここはそんなに時間はかかりませんでした!

頑張ったところ

1.DB設計

今回作成したプロダクトでは、周期的なことを記録するデータと、突発的なことを記録するデータを分ける必要がありました。そのため、それらのテーブルを分けないといけないことに途中で気がつきデータ設計を大幅に変更する必要がありました。後から、定期的に起こることと一時的なことをわけて考えるのはデータ保存の基本的な考え方であることを知り勉強になりました。
また、命名規則などもこの開発中に初めて学ぶ機会があり、途中でカラム名などを大幅に変更しました。

2.GoのAuthenticateMiddlewareを利用したログイン機能

ログイン機能はfirebaseなどを使えばサクッとできたと思うのですが、今回はAuthenticateMiddlewareを使う方法選んだので少し時間がかかりました。
以下cahtGPT4による説明です^^

Echoでのミドルウェアの利用
Echoにおいて、ミドルウェアはリクエストとレスポンスの処理過程に介入して、認証チェック、ログ記録、CORS設定などの機能を提供します。
AuthenticateMiddleware は、ユーザー認証を担うミドルウェアの一例で、リクエストが適切に認証されたユーザーから来ていることを保証するために使用されます。このミドルウェアは、リクエストヘッダーやクッキーに含まれる認証情報(例:トークン)を検証して、リクエストを処理する前にユーザーを認証します。
contextの利用
Goのcontextパッケージは、API境界を越えてリクエストスコープの値、キャンセルシグナル、デッドラインなどを伝達するために設計されています。Echoでは、リクエストごとに独自のcontextを持ち、このcontextを通じてリクエストに関連するデータをやり取りします。

以下実際に使っていたコードです。
認証トークンをx-tokenとして、ユーザー登録をすると同時にuuidで生成を行いました。
また、userIdもx-tokenとは別に生成し、セキュリティを上げています。

auth.go

package middleware

import (
	"errors"
	"fmt"

	"github.com/labstack/echo/v4"

	"github.com/Mire0726/Genkiyoho/backend/server/context/auth"
	"github.com/Mire0726/Genkiyoho/backend/server/model"
)

// AuthenticateMiddleware ユーザ認証を行ってContextへユーザID情報を保存する
func AuthenticateMiddleware() echo.MiddlewareFunc {
	return func(next echo.HandlerFunc) echo.HandlerFunc {
		
		return func(c echo.Context) error {
			ctx := c.Request().Context()
			// リクエストヘッダからx-token(認証トークン)を取得
			token := c.Request().Header.Get("x-token")
			if token == "" {
				return errors.New("x-token is empty")
			}
			
			// データベースから認証トークンに紐づくユーザの情報を取得
			user, err := model.SelectUserByAuthToken(token)
			if err != nil {
				return err
			}
			if user == nil {
				return fmt.Errorf("user not found. token=%s", token)
			}
			// ユーザIDをContextへ保存して以降の処理に利用する
			c.SetRequest(c.Request().WithContext(auth.SetUserID(ctx, user.ID)))
			
			// 次の処理
			return next(c)
		}
	}
}

context.go

package auth

import (
	"context"
)

type contextKey string

var userIDKey = contextKey("userID")

// SetUserID はContextにユーザIDを保存します。
func SetUserID(ctx context.Context, userID string) context.Context {
    return context.WithValue(ctx, userIDKey, userID)
}

// GetUserIDFromContext ContextからユーザIDを取得する
func GetUserIDFromContext(ctx context.Context) string {
	var userID string
	if ctx.Value(userIDKey) != nil {
		userID = ctx.Value(userIDKey).(string)
	}
	return userID
}

3.外部APIの活用

今回のプロダクトでは、外部APIを活用しました。
ハッカソン発表時点で4つのAPIを利用していて、まだまだ機能増やせるだろうな、という感じです。
基本的に天気系のAPIが多かったのですが、ユーザーによって登録された居場所によってリアルタイムでデータを取得し、処理も行うようにしました。

(ex)pollen.go(ある日の花粉の最大値を取得するAPI)

package weather

import (
	"encoding/csv"
	"fmt"
	"io"
	"io/ioutil"
	"strings"

	// "io/ioutil"
	"net/http"
	"strconv"
	"time"
)


func CheckPollen(Pre string) int{
	cityCode := GetCityCodeFromPrefecture(Pre)
	// 現在の日付をYYYYMMDD形式で取得
	currentDate := time.Now().Format("20060102")
	currentDateInt,err := strconv.Atoi(currentDate)

	// APIのエンドポイントを構築
	apiURL := fmt.Sprintf("https://wxtech.weathernews.com/opendata/v1/pollen?citycode=%d&start=%d&end=%d", cityCode, currentDateInt, currentDateInt)

	// HTTP GETリクエストを送信してみましょう
	response, err := http.Get(apiURL)
	if err != nil {
		fmt.Println("Error making request:", err)
		return 0
	}

	defer response.Body.Close()

    // レスポンスボディをバイトスライスとして読み込む
	bodyBytes, err := ioutil.ReadAll(response.Body)
	if err != nil {
		fmt.Println("Failed to read response body:", err)
		return 0
	}

// バイトスライスを文字列に変換し、CSVデータとして解析
bodyString := string(bodyBytes)
r := csv.NewReader(strings.NewReader(bodyString))

// CSVのヘッダー行を読み飛ばす
_, err = r.Read()
if err != nil {
	fmt.Println("Error reading CSV header:", err)
	return 0
}

maxPollen := 0
for {
	record, err := r.Read()
	if err == io.EOF {
		break
	}
	if err != nil {
		fmt.Println("Error reading CSV record:", err)
		return 0
	}

	// 花粉飛散量の値を整数に変換
	pollen, err := strconv.Atoi(record[2])
	if err != nil || pollen == -9999 {
		// 変換エラーまたは無効なデータの場合はスキップ
		continue
	}

	// 最大花粉飛散量を更新
	if pollen > maxPollen {
		maxPollen = pollen
	}
}
	return maxPollen
}

func GetCityCodeFromPrefecture(prefecture string) int {
	// 都道府県名をキーとして、都市コードを取得
	cityCode := PrefectureToCityCode[prefecture]
	if cityCode == 0 {
		fmt.Println("Invalid prefecture name")
		return 1
	}
	return cityCode
}

var PrefectureToCityCode = map[string]int{
    //大部分省略!
	"Hokkaido": 01100, // 北海道札幌市
	"Gunma": 10201,
	"Saitama": 11100,
	"Chiba": 12100,
	"Tokyo": 13103, // 東京都
	"Kanagawa": 14100,
}

4.フロントエンド全般

Go言語やそのフレームワークなどについては以前Cyber Agent社のWomen Go College インターンシップで学んでいたため、ある程度わかっていたのに対して、TypeScriptでの開発はそこまでしたことがなかった(昔々インターンでとっても少し触らせてもらったくらい)ため、書きながら学ぶという感じでわからないことだらけでした。chatGPTに聞きまくりながらなんとか実装を行いましたが、エラーが出てもその原因がわからないとお手上げで、開発終盤になってやっと宣言の仕方とかを思い出しました(え?)
各言語そのものの理解を深めるいい機会になったと思います。

5.デプロイ

初めてのデプロイで、本当に大変でした〜〜涙
インフラの部分に関する知識が全然インプットできていないことや、経験の浅さを実感しました。ただどうにかできたので、次の章でデプロイについて述べます!

デプロイ編

frotend

(バックエンドに比べたら)フロントエンドは比較的スムーズでした!
Next.jsでコーディングしていたので、王道にvercelでデプロイしました。
GitHubのリポジトリと接続し、プロジェクトのルートディレクトリではなく、frontendフォルダのみをpushする形で行い、デプロイできました(最初はフロントとバック分けることも知らなかった)。
環境変数の設定に一瞬戸惑ったりもしましたが(settingのEnvironment Variablesから行けるんですね!)、どうにかなりました。

backend

ここからが問題でした。
GCPのCloud RunでDockerfileを用いてデプロイしようと思っていたのですが、数々のエラーに阻まれ、エラーの原因も解明できず、泣く泣く一旦諦めまして、
いいらしいと聞きHerokuに変更し、3日ほど格闘してどうにかデプロイできたので、DBなども含めて触れたいと思います。
Herokuの始め方自体はいくつかの記事を参考にし、わりかしすんなり行きました。
https://qiita.com/ryome/items/6b12cc92700621e296dd
https://devcenter.heroku.com/ja/articles/heroku-cli

Heroku loginをターミナル上で行えば、Herokuの設定自体は問題ありません。
とりあえず何もわからない私はフロントエンドと同様に、バックエンドもフォルダでデプロイしたかったので以下のコマンドでpushを行いました。
git subtree push --prefix backend heroku main
すると、以下のようなエラーが色んなところで出てきました。

State changed from crashed to starting
2024/04/~~ default addr for network '~~~' unknown

まずこのエラーはGoのdatabase/sqlパッケージがうまくDB接続ができていないことによって出ていたみたいで、その時点でデプロイ環境のDBを設定してなかったのでそりゃそうかと思いHerokuのDBについて調べることにしました。

調べるとアドオンでいくつかMySQL用のDBが提供されていると知り、以下の二つが候補にあがりました。

そしてこちらの記事に説得され、Jaws DBを採用
https://qiita.com/haruyan_hopemucci/items/14e0dbeb0aedd85a74ee

ざっくりまとめると、JawsDBにはClearDBに比べて以下のような利点がありました。

  • デフォルトのMySQLのバージョンが新しい
  • auto-incrementの設定が基本のMySQLと一緒
  • LaravelでmigrateエラーがClearDBと違って出ない

早速heroku addons:create jawsdbでアドオンを追加し、heroku config:get JAWSDB_URLで環境変数を取得します。
heroku config:set JAWSDB_URL="~~~"を実行すると環境変数の設定の完了です。
ただ、これだけではGoの求めるデフォルトのMySQLの形式と少し違ったため、以下のようにurl.Parseを行いました。

import (
    "fmt"
    // 他のインポート文...
)

func init() {
    jawsdbURL := os.Getenv("JAWSDB_URL")
    if jawsdbURL == "" {
        log.Fatal("JAWSDB_URL environment variable is not set")
    }

    // JawsDB の接続 URL を Go の標準形式に変換
    parsedURL, err := url.Parse(jawsdbURL)
    if err != nil {
        log.Fatal(err)
    }

    // ユーザ名とパスワードを抽出
    user := parsedURL.User.String()

    // ホスト名とポート番号を抽出
    host := parsedURL.Host

    // データベース名を抽出
    dbName := strings.TrimPrefix(parsedURL.Path, "/")

    // データソース名 (DSN) を構築
    dsn := fmt.Sprintf("%s@tcp(%s)/%s?parseTime=true", user, host, dbName)

    Conn, err = sql.Open("mysql", dsn)
    if err != nil {
        log.Fatal(err)
    }

    if err := Conn.Ping(); err != nil {
        log.Fatal("Unable to connect to the database:", err)
    }
}

これでやっとデプロイ完了です!
さらっと書いていますが、GCPに苦戦していた日数も含めるとデプロイに丸一週間ほどかかっています。(一日2~15時間は向き合ってます多分)
いい勉強になりました!☺️

感想と今後の展望

文系出身で、プログラミングはネットに頼ることばかりな私は今までたくさんの技術記事を読んできました。しかしその中のエンジニアは、みんなスラスラ開発が進んでいるように見えて自分がつまづきまくりなことに落ち込むことも多かったように思います。なのでこの記事は技術記事とは言えど、開発の備忘録に近いものにしようと思い、奮闘したところや遠回りした道のりも書きました。(書けてないけど最初Postgresで構築して、全部作り直したりとかもしてます笑)
実際2ヶ月ほど一つのプロダクトを作っていた割に、まだまだ完成度が低いものだと思います。
けど、このプロダクトがきっかけで毎日初めて知る技術や知識がいっぱいありとても楽しい時間でした!
今後は、このプロダクトのリファクタリングをしながらアーキテクチャについて勉強したり、テストコード書いたりしたいなと思っています。
長めの記事を読んでくださってありがとうございました。

Discussion