diff --git a/.github/workflows/build-check.yml b/.github/workflows/build-check.yml index 4094be1..1162dcd 100644 --- a/.github/workflows/build-check.yml +++ b/.github/workflows/build-check.yml @@ -34,7 +34,5 @@ jobs: run: go mod download - name: Backend Build Check working-directory: ./backend - run: go build -o main . - - - + run: go build -o main ./cmd/server/main.go + \ No newline at end of file diff --git a/README.md b/README.md index 9633171..0065201 100644 --- a/README.md +++ b/README.md @@ -41,11 +41,96 @@ MaximumメンバーがWeb研究部の活動として、Twitterのようなマイ `./scripts/reset-db.sh` でDBデータを削除する +### DBのマイグレーション + +> [!IMPORTANT] +> マイグレーションを初めて実行する場合、まずはデータベースのデータを削除する必要があります。これは、上記の「[DBデータの削除](#dbデータの削除)」にあるコマンドを使用して行えます。 +> データの削除が完了したら、 `./scripts/migrator.sh init` を実行して、マイグレーションを行うための必要な情報をDB上に作成することができます。 +> 詳しくは「[データベースのマイグレーションとモデル](#データベースのマイグレーションとモデル)」を参照してください。 + +- `./scripts/migrator.sh migrate` を実行することで、データベースのスキーマを最新の状態に更新できる + +- `./scripts/migrator.sh rollback` を実行することで、データベースのスキーマを1つ前の状態に戻すことができる + +- `./scripts/migrator.sh create_go <任意のコメント>` を実行することで、新しいマイグレーションを作成できる + +- `./scripts/migrator.sh status`を実行することで、現在のマイグレーションの状態を確認できる + +(他にもコマンドがありますが、詳しくは[この記事](https://zenn.dev/suetak/articles/5b3110358645b7)に書いてあるので確認してみてください。) + ### デプロイ `./scripts/deploy.sh` で本番環境にデプロイする (マイグレーションなど特別なオペレーションが必要な場合もある) +## バックエンド処理の構造と役割 + +backend 配下は次のようなディレクトリとファイルで構成されています。 + +- `cmd`: このディレクトリは、アプリケーションが始まる場所で、Goのmain関数が含まれています。ここには、例えばmigrateとserverという2つのサブディレクトリがあります。`cmd/server/main.go`は、サーバーを起動するためのプログラムで、アプリケーションが始まるときに最初に実行されます。一方、`cmd/migrate/main.go`は、データベースの構造(スキーマ)を更新するためのマイグレーションを実行するプログラムで、これは`scripts/migrator.sh`によって実行されます。これらのプログラムは、アプリケーションの動作とデータの管理を制御します。 +- `Dockerfile`: このファイルは、アプリケーションのDockerイメージをビルドするための指示を含んでいます。 +- `external`: このディレクトリには、外部サービスとのインターフェースが含まれています。例えば、`discord.go`はDiscordとの通信を処理します。 +- `handler`: このディレクトリには、HTTPリクエストを処理するためのハンドラが含まれています。 +- `migrations`: このディレクトリには、データベースのマイグレーションが含まれています。これらのマイグレーションは、データベースのスキーマを変更するために使用されます。 +- `model`: このディレクトリには、データベースのテーブルを表すGoの構造体が含まれています。これらの構造体は、データベースとのやり取りを容易にします。 +- `public`: このディレクトリには、公開される静的ファイルが含まれています。 + +## データベースのマイグレーションとモデル + +データベースのマイグレーションは、データベースのスキーマを管理する手段です。新しいテーブルを追加したり、既存のテーブルを変更したい場合は、マイグレーションを作成して実行します。 + +マイグレーションは`migrations`ディレクトリ内のGoファイルに記述されます。各マイグレーションファイルには、「アップ」操作と「ダウン」操作が含まれています。アップ操作はマイグレーションを適用するための操作を定義し、ダウン操作はマイグレーションをロールバックするための操作を定義します。 + +データベースのモデルは`model`ディレクトリ内のGoファイルに定義されます。各モデルはデータベースのテーブルを表すGoの構造体です。これらのモデルはデータベースとのやり取りを容易にします。 + +### 新しいカラムやテーブルを追加する + +新しいカラムを追加する場合、対応するモデルのGo構造体に新しいフィールドを追加します。次に、新しいマイグレーションを作成して、データベースのテーブルに新しいカラムを追加します。 + +新しいテーブルを追加するには、新しいモデルのGo構造体を作成します。次に、新しいマイグレーションを作成して、新しいテーブルをデータベースに追加します。 + +これらの操作は、`scripts/migrator.sh`を使用して行います。詳細な手順は以下の通りです。 + +1. **モデルの作成**: `model`ディレクトリに新しいGoファイルを作成します。このファイルには、新しいテーブルを表すGoの構造体を定義します。 + +2. **マイグレーションの作成**: `./scripts/migrator.sh create_go <任意のコメント>`を実行して、マイグレーション用のGoファイルを作成します。コメントはGitのコミットメッセージのように、「create users table」などと更新内容が分かりやすいと良いです。実行すると、`migrations`ディレクトリに日時と指定したコメントが合わさったような名前のGoファイルが作成されます。例えば、 `./scripts/migrator.sh create_go create initial tables` を実行して`migrations`ディレクトリに`20231109002750_create_initial_tables.go`を作成しました。 + +3. **マイグレーションの処理**: 作成されたGoファイルにマイグレーションの処理を書きます。例えば、`20231109002750_create_initial_tables.go`では以下のようなup migrationの処理を書いています。 + + ```go + fmt.Print(" [up migration] ") + _, err := db.NewCreateTable().Model((*model.Post)(nil)).Exec(ctx) + if err != nil { + return err + } + _, err = db.NewCreateTable().Model((*model.User)(nil)).Exec(ctx) + return err + ``` + + これは`model/post.go`と`model/user.go`に定義されているモデルをデータベースに追加する処理です。このように、Bunのmigration機能では実際にアップデートの処理を手動でup migrationに書く必要があります。 + + また、down migrationの処理はup migrationの処理を完全に打ち消せるように書きます。 + 例えば、`20231109002750_create_initial_tables.go`ではup migrationで`posts`テーブルと`users`テーブルを作成します。よって、down migrationではこれらのテーブルを削除するような処理を書きます。 + + ```go + fmt.Print(" [down migration] ") + _, err := db.NewDropTable().Model((*model.Post)(nil)).IfExists().Exec(ctx) + if err != nil { + return err + } + _, err = db.NewDropTable().Model((*model.User)(nil)).IfExists().Exec(ctx) + return err + ``` + + このようにすることでマイグレーションをロールバックすることができるようになるため、バージョン管理が容易になるというメリットがあります。 + +4. **マイグレーションの実行**: `./scripts/migrator.sh migrate`を実行して、新しいマイグレーションを適用します。 + +以上の手順により、新しいカラムやテーブルをデータベースに追加することができます。 + +より具体的な操作は[この記事](https://zenn.dev/suetak/articles/5b3110358645b7)に説明があるので、参考にしてみてください。 + + ## スプリント 毎週月曜日にあるWeb研究会の講義の間を1スプリントと定義する。 @@ -53,6 +138,6 @@ MaximumメンバーがWeb研究部の活動として、Twitterのようなマイ ## バージョニング バージョンは1スプリントでマイナーバージョン x.X.x を上げることにする。 -それよりも細かい単位の変更 (スプリント中だけど緊急で修正箇所が浮上したなど)でリリースが必要な場合、パッチバージョン x.x.X を上げることにする。 +それよりも細かい単位の変更(スプリント中だけど緊急で修正箇所が浮上したなど)でリリースが必要な場合、パッチバージョン x.x.X を上げることにする。 メジャーバージョンに関しては区切りが良くなったタイミングであげるで良い。 diff --git a/backend/Dockerfile b/backend/Dockerfile index 94de273..2953596 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -4,7 +4,7 @@ FROM golang:1.20-alpine AS builder WORKDIR /app COPY . . RUN go mod download -RUN CGO_ENABLED=0 go build -o main . +RUN CGO_ENABLED=0 go build -o main ./cmd/server/main.go FROM alpine AS prod @@ -27,4 +27,4 @@ COPY go.mod go.sum ./ RUN go mod download EXPOSE 8000 -CMD ["go", "run", "main.go"] +CMD ["go", "run", "./cmd/server/main.go"] diff --git a/backend/cmd/migrate/main.go b/backend/cmd/migrate/main.go new file mode 100644 index 0000000..707fddb --- /dev/null +++ b/backend/cmd/migrate/main.go @@ -0,0 +1,228 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "log" + "os" + "strings" + + "github.com/go-sql-driver/mysql" + "github.com/saitamau-maximum/maxitter/backend/migrations" + + "github.com/uptrace/bun" + "github.com/uptrace/bun/dialect/mysqldialect" + "github.com/uptrace/bun/extra/bundebug" + "github.com/uptrace/bun/migrate" + + "github.com/urfave/cli/v2" +) + +const MIGRATION_TABLE = "bun_migrations" + +func getEnv(key, fallback string) string { + value, ok := os.LookupEnv(key) + if !ok { + value = fallback + } + return value +} + +func connectDB() (*sql.DB, error) { + user := getEnv("MYSQL_USER", "user") + password := getEnv("MYSQL_PASSWORD", "password") + host := getEnv("MYSQL_HOST", "localhost") + port := getEnv("MYSQL_PORT", "3306") + dbname := getEnv("MYSQL_DATABASE", "db") + + c := mysql.Config{ + User: user, + Passwd: password, + Net: "tcp", + Addr: fmt.Sprintf("%s:%s", host, port), + DBName: dbname, + } + + db, err := sql.Open("mysql", c.FormatDSN()) + if err != nil { + return nil, err + } + + return db, nil +} + +func main() { + db, err := connectDB() + if err != nil { + panic(err) + } + + bunDB := bun.NewDB(db, mysqldialect.New()) + bunDB.AddQueryHook(bundebug.NewQueryHook( + bundebug.WithEnabled(false), + bundebug.FromEnv(""), + )) + + defer bunDB.Close() + + if err := checkMigrationsTable(context.Background(), bunDB); err != nil { + log.Println("Migrations table does not exist \n\n\t run `./scripts/migrator.sh init` first") + return + } + + app := &cli.App{ + Name: "bun", + + Commands: []*cli.Command{ + newDBCommand(migrate.NewMigrator(bunDB, migrations.Migrations)), + }, + } + if err := app.Run(os.Args); err != nil { + log.Fatal(err) + } +} + +func newDBCommand(migrator *migrate.Migrator) *cli.Command { + return &cli.Command{ + Name: "db", + Usage: "database migrations", + Subcommands: []*cli.Command{ + { + Name: "init", + Usage: "create migration tables", + Action: func(c *cli.Context) error { + return migrator.Init(c.Context) + }, + }, + { + Name: "migrate", + Usage: "migrate database", + Action: func(c *cli.Context) error { + if err := migrator.Lock(c.Context); err != nil { + return err + } + defer migrator.Unlock(c.Context) + + group, err := migrator.Migrate(c.Context) + if err != nil { + return err + } + if group.IsZero() { + fmt.Printf("there are no new migrations to run (database is up to date)\n") + return nil + } + fmt.Printf("migrated to %s\n", group) + return nil + }, + }, + { + Name: "rollback", + Usage: "rollback the last migration group", + Action: func(c *cli.Context) error { + if err := migrator.Lock(c.Context); err != nil { + return err + } + defer migrator.Unlock(c.Context) + + group, err := migrator.Rollback(c.Context) + if err != nil { + return err + } + if group.IsZero() { + fmt.Printf("there are no groups to roll back\n") + return nil + } + fmt.Printf("rolled back %s\n", group) + return nil + }, + }, + { + Name: "lock", + Usage: "lock migrations", + Action: func(c *cli.Context) error { + return migrator.Lock(c.Context) + }, + }, + { + Name: "unlock", + Usage: "unlock migrations", + Action: func(c *cli.Context) error { + return migrator.Unlock(c.Context) + }, + }, + { + Name: "create_go", + Usage: "create Go migration", + Action: func(c *cli.Context) error { + name := strings.Join(c.Args().Slice(), "_") + mf, err := migrator.CreateGoMigration(c.Context, name) + if err != nil { + return err + } + fmt.Printf("created migration %s (%s)\n", mf.Name, mf.Path) + return nil + }, + }, + { + Name: "create_sql", + Usage: "create up and down SQL migrations", + Action: func(c *cli.Context) error { + name := strings.Join(c.Args().Slice(), "_") + files, err := migrator.CreateSQLMigrations(c.Context, name) + if err != nil { + return err + } + + for _, mf := range files { + fmt.Printf("created migration %s (%s)\n", mf.Name, mf.Path) + } + + return nil + }, + }, + { + Name: "status", + Usage: "print migrations status", + Action: func(c *cli.Context) error { + ms, err := migrator.MigrationsWithStatus(c.Context) + if err != nil { + return err + } + fmt.Printf("migrations: %s\n", ms) + fmt.Printf("unapplied migrations: %s\n", ms.Unapplied()) + fmt.Printf("last migration group: %s\n", ms.LastGroup()) + return nil + }, + }, + { + Name: "mark_applied", + Usage: "mark migrations as applied without actually running them", + Action: func(c *cli.Context) error { + group, err := migrator.Migrate(c.Context, migrate.WithNopMigration()) + if err != nil { + return err + } + if group.IsZero() { + fmt.Printf("there are no new migrations to mark as applied\n") + return nil + } + fmt.Printf("marked as applied %s\n", group) + return nil + }, + }, + }, + } +} + +func checkMigrationsTable(ctx context.Context, db *bun.DB) error { + if os.Args[2] == "init" { + return nil + } + + if _, err := db.NewSelect().Table(MIGRATION_TABLE).Exists(ctx); err != nil { + return err + } + + return nil +} diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..71d8042 --- /dev/null +++ b/backend/cmd/server/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "database/sql" + "fmt" + "os" + + "github.com/go-sql-driver/mysql" + "github.com/labstack/echo/v4" + "github.com/saitamau-maximum/maxitter/backend/handler" + "github.com/uptrace/bun" + "github.com/uptrace/bun/dialect/mysqldialect" +) + +var ( + SQL_PATH = "./sql" + IMAGES_DIR = "./public/images" +) + +func getEnv(key, fallback string) string { + value, ok := os.LookupEnv(key) + if !ok { + value = fallback + } + return value +} + +func connectDB() (*sql.DB, error) { + user := getEnv("MYSQL_USER", "user") + password := getEnv("MYSQL_PASSWORD", "password") + host := getEnv("MYSQL_HOST", "database") + port := getEnv("MYSQL_PORT", "3306") + dbname := getEnv("MYSQL_DATABASE", "db") + + c := mysql.Config{ + User: user, + Passwd: password, + Net: "tcp", + Addr: fmt.Sprintf("%s:%s", host, port), + DBName: dbname, + } + + db, err := sql.Open("mysql", c.FormatDSN()) + if err != nil { + return nil, err + } + + return db, nil +} + +func main() { + e := echo.New() + e.Debug = true + e.Logger.SetLevel(0) + + db, err := connectDB() + if err != nil { + e.Logger.Fatal(err) + } + + bunDB := bun.NewDB(db, mysqldialect.New()) + defer bunDB.Close() + + h := &handler.Handler{DB: bunDB, Logger: e.Logger} + api := e.Group("/api") + api.GET("/posts", h.GetPosts) + api.POST("/posts", h.CreatePost) + api.GET("/health", func(c echo.Context) error { + e.Logger.Info("health check") + return c.JSON(200, "ok") + }) + api.GET("/users", h.GetUsers) + api.POST("/users/new", h.CreateUser) + e.Logger.Fatal(e.Start(":8000")) +} diff --git a/backend/go.mod b/backend/go.mod index 5ac0318..982dae0 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -5,18 +5,33 @@ go 1.20 require ( github.com/go-sql-driver/mysql v1.7.1 github.com/google/uuid v1.3.1 - github.com/jmoiron/sqlx v1.3.5 github.com/labstack/echo/v4 v4.11.1 + github.com/uptrace/bun v1.1.16 +) + +require ( + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/fatih/color v1.15.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect + github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect + golang.org/x/mod v0.12.0 // indirect ) require ( github.com/labstack/gommon v0.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect + github.com/uptrace/bun/dialect/mysqldialect v1.1.16 + github.com/uptrace/bun/extra/bundebug v1.1.16 + github.com/urfave/cli/v2 v2.25.7 github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect golang.org/x/crypto v0.11.0 // indirect golang.org/x/net v0.12.0 // indirect - golang.org/x/sys v0.10.0 // indirect + golang.org/x/sys v0.12.0 // indirect golang.org/x/text v0.11.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 09de8b6..3f1e006 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,19 +1,20 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= -github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/labstack/echo/v4 v4.11.1 h1:dEpLU2FLg4UVmvCGPuk/APjlH6GDpbEPti61srUUUs4= github.com/labstack/echo/v4 v4.11.1/go.mod h1:YuYRTSM3CHs2ybfrL8Px48bO6BAnYIN4l8wSTMP6BDQ= github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8= github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= -github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= -github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= @@ -21,20 +22,39 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= -github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= +github.com/uptrace/bun v1.1.16 h1:cn9cgEMFwcyYRsQLfxCRMUxyK1WaHwOVrR3TvzEFZ/A= +github.com/uptrace/bun v1.1.16/go.mod h1:7HnsMRRvpLFUcquJxp22JO8PsWKpFQO/gNXqqsuGWg8= +github.com/uptrace/bun/dialect/mysqldialect v1.1.16 h1:FMiuco/9BWd6XKdp8vpn2ftGtI7B0VbkbfLm9D1Tfr4= +github.com/uptrace/bun/dialect/mysqldialect v1.1.16/go.mod h1:JJ4XfC6QHs/4IbZhtyw69lTHefiMoR9m4GYLUpW23bQ= +github.com/uptrace/bun/extra/bundebug v1.1.16 h1:SgicRQGtnjhrIhlYOxdkOm1Em4s6HykmT3JblHnoTBM= +github.com/uptrace/bun/extra/bundebug v1.1.16/go.mod h1:SkiOkfUirBiO1Htc4s5bQKEq+JSeU1TkBVpMsPz2ePM= +github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= +github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= +github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -42,8 +62,8 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= -golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/backend/handler/handler.go b/backend/handler/handler.go new file mode 100644 index 0000000..dfdddf3 --- /dev/null +++ b/backend/handler/handler.go @@ -0,0 +1,118 @@ +package handler + +import ( + "net/http" + "strconv" + "time" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/uptrace/bun" + + "github.com/saitamau-maximum/maxitter/backend/model" +) + +type Handler struct { + DB *bun.DB + Logger echo.Logger +} + +func (h *Handler) GetPosts(c echo.Context) error { + pageParam := c.QueryParam("page") + if pageParam == "" { + pageParam = "0" + } + page, err := strconv.ParseUint(pageParam, 10, 0) + if err != nil { + return c.JSON(400, err) + } + + index := page * 20 + + ctx := c.Request().Context() + + modelPosts := []model.Post{} + + err = h.DB.NewSelect().Model(&modelPosts).Order("created_at DESC").Limit(20).Offset(int(index)).Scan(ctx) + if err != nil { + return c.JSON(http.StatusInternalServerError, err.Error()) + } + + return c.JSON(http.StatusOK, modelPosts) +} + +func (h *Handler) CreatePost(c echo.Context) error { + id, err := uuid.NewRandom() + if err != nil { + h.Logger.Error(err) + return c.JSON(http.StatusInternalServerError, err.Error()) + } + post := new(model.Post) + if err := c.Bind(post); err != nil { + h.Logger.Error(err) + return c.JSON(http.StatusInternalServerError, err.Error()) + } + post.ID = id.String() + post.CreatedAt = time.Now().Round(time.Millisecond) + + modelPost := &model.Post{ + ID: post.ID, + Body: post.Body, + CreatedAt: post.CreatedAt, + } + + ctx := c.Request().Context() + + _, err = h.DB.NewInsert().Model(modelPost).Exec(ctx) + if err != nil { + return c.JSON(http.StatusInternalServerError, err.Error()) + } + return c.JSON(http.StatusOK, modelPost) +} + +func (h *Handler) GetUsers(c echo.Context) error { + ctx := c.Request().Context() + + modelUsers := []model.User{} + err := h.DB.NewSelect().Model(&modelUsers).Scan(ctx) + if err != nil { + return c.JSON(http.StatusInternalServerError, err.Error()) + } + + return c.JSON(http.StatusOK, modelUsers) +} + +func (h *Handler) CreateUser(c echo.Context) error { + id, err := uuid.NewRandom() + if err != nil { + h.Logger.Error(err) + return c.JSON(http.StatusInternalServerError, err.Error()) + } + user := new(model.User) + if err := c.Bind(user); err != nil { + h.Logger.Error(err) + return c.JSON(http.StatusInternalServerError, err.Error()) + } + user.ID = id.String() + + user.CreatedAt = time.Now().Round(time.Millisecond) + user.UpdatedAt = time.Now().Round(time.Millisecond) + + modelUser := &model.User{ + ID: user.ID, + Name: user.Name, + ProfileImage: user.ProfileImage, + Bio: user.Bio, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + } + + ctx := c.Request().Context() + + _, err = h.DB.NewInsert().Model(modelUser).Exec(ctx) + if err != nil { + return c.JSON(http.StatusInternalServerError, err.Error()) + } + + return c.JSON(http.StatusOK, modelUser) +} diff --git a/backend/main.go b/backend/main.go deleted file mode 100644 index 5575cd0..0000000 --- a/backend/main.go +++ /dev/null @@ -1,245 +0,0 @@ -package main - -import ( - "fmt" - "log" - "os" - "strconv" - "time" - - _ "github.com/go-sql-driver/mysql" - "github.com/google/uuid" - "github.com/jmoiron/sqlx" - "github.com/labstack/echo/v4" - - "github.com/saitamau-maximum/maxitter/backend/external" -) - -type Handler struct { - DB *sqlx.DB - Logger echo.Logger -} - -type Post struct { - ID string `db:"id" json:"id"` - Body string `db:"body" json:"body"` - CreatedAt time.Time `db:"created_at" json:"created_at"` -} - -type User struct { - ID string `db:"id" json:"id"` - Name string `db:"username" json:"name"` - CreatedAt string `db:"created_at" json:"created_at"` - UpdatedAt string `db:"updated_at" json:"updated_at"` - ProfileImageURL string `db:"profile_image_url" json:"profile_image_url"` - Bio string `db:"bio" json:"bio"` -} - -var ( - SQL_PATH = "./sql" - IMAGES_DIR = "./public/images" -) - -func getEnv(key, fallback string) string { - value, ok := os.LookupEnv(key) - if !ok { - value = fallback - } - return value -} - -func connectDB() *sqlx.DB { - user := getEnv("MYSQL_USER", "user") - password := getEnv("MYSQL_PASSWORD", "password") - host := getEnv("MYSQL_HOST", "database") - port := getEnv("MYSQL_PORT", "3306") - dbname := getEnv("MYSQL_DATABASE", "db") - dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", user, password, host, port, dbname) - - con, err := sqlx.Connect("mysql", dsn) - if err != nil { - panic(err) - } - return con -} - -func migrate() { - log.Println("migrate start") - db := connectDB() - defer db.Close() - - files, err := os.ReadDir(SQL_PATH) - if err != nil { - panic(err) - } - log.Println("migrate files: ", files) - - for _, file := range files { - log.Println("migrate: " + file.Name()) - data, err := os.ReadFile(SQL_PATH + "/" + file.Name()) - if err != nil { - panic(err) - } - _, err = db.Exec(string(data)) - if err != nil { - panic(err) - } - } - - // indexをposts.created_atにつける - _, err = db.Exec("CREATE INDEX posts_latest_idx ON posts (created_at)") - if err != nil { - log.Println("index already exists") - } - - log.Println("migrate end") -} - -func init() { - migrate() -} - -func main() { - e := echo.New() - e.Debug = true - e.Logger.SetLevel(0) - db := connectDB() - defer db.Close() - h := &Handler{DB: db, Logger: e.Logger} - api := e.Group("/api") - api.GET("/posts", h.GetPosts) - api.POST("/posts", h.CreatePost) - api.GET("/health", func(c echo.Context) error { - e.Logger.Info("health check") - return c.JSON(200, "ok") - }) - api.GET("/users", h.GetUsers) - api.POST("/users/new", h.CreateUser) - e.Logger.Fatal(e.Start(":8000")) -} - -const ( - DISCORD_USERNAME = "Maxitter 投稿通知" - DISCORD_AVATAR_URL = "" -) - -func sendPostWebhookDiscord(post *Post) error { - discord_webhook_url := getEnv("DISCORD_WEBHOOK_URL", "") - - if discord_webhook_url == "" { - return fmt.Errorf("DISCORD_WEBHOOK_URL is empty") - } - - discord_webhook := &external.DiscordWebhook{ - UserName: DISCORD_USERNAME, - AvatarURL: DISCORD_AVATAR_URL, - Content: "", - Embeds: []external.DiscordEmbed{ - { - Title: "", - Desc: post.Body, - URL: "", - Color: 0x23d9eb, - Author: external.DiscordAuthor{ - Name: "匿名のユーザー", - Icon: DISCORD_AVATAR_URL, - }, - TimeStamp: post.CreatedAt.Format(time.RFC3339), // ISO8601形式にフォーマット - }, - }, - } - - result := external.SendWebhookDiscord( - discord_webhook_url, - DISCORD_USERNAME, - DISCORD_AVATAR_URL, - discord_webhook, - ) - - if result != nil { - return fmt.Errorf("sendWebhook error: %v", result) - } - - return nil -} - -func (h *Handler) GetPosts(c echo.Context) error { - pageParam := c.QueryParam("page") - if pageParam == "" { - pageParam = "0" - } - page, err := strconv.ParseUint(pageParam, 10, 0) - if err != nil { - return c.JSON(400, err) - } - - index := page * 20 - posts := []Post{} - err = h.DB.Select(&posts, "SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET ?", index) - if err != nil { - h.Logger.Error(err) - return c.JSON(500, err) - } - return c.JSON(200, posts) -} - -func (h *Handler) CreatePost(c echo.Context) error { - id, err := uuid.NewRandom() - if err != nil { - h.Logger.Error(err) - return c.JSON(500, err) - } - post := new(Post) - if err := c.Bind(post); err != nil { - h.Logger.Error(err) - return c.JSON(500, err) - } - post.ID = id.String() - post.CreatedAt = time.Now() - - _, err = h.DB.Exec("INSERT INTO posts (id, body, created_at) VALUES (?, ?, ?)", post.ID, post.Body, post.CreatedAt) - if err != nil { - h.Logger.Error(err) - return c.JSON(500, err) - } - - if err := sendPostWebhookDiscord(post); err != nil { - // Discord Webhook送信に失敗してもエラーにしない - fmt.Printf("sendPostWebhook error: %v", err) - } - - return c.JSON(200, post) -} - -func (h *Handler) GetUsers(c echo.Context) error { - users := []User{} - err := h.DB.Select(&users, "SELECT * FROM users") - if err != nil { - h.Logger.Error(err) - return c.JSON(500, err) - } - return c.JSON(200, users) -} - -func (h *Handler) CreateUser(c echo.Context) error { - id, err := uuid.NewRandom() - if err != nil { - h.Logger.Error(err) - return c.JSON(500, err) - } - user := new(User) - if err := c.Bind(user); err != nil { - h.Logger.Error(err) - return c.JSON(500, err) - } - user.ID = id.String() - user.CreatedAt = time.Now().Format("2006-01-02 15:04:05") - user.UpdatedAt = time.Now().Format("2006-01-02 15:04:05") - - _, err = h.DB.Exec("INSERT INTO users (id, username, created_at, updated_at, profile_image_url, bio) VALUES (?, ?, ?, ?, ?, ?)", user.ID, user.Name, user.CreatedAt, user.UpdatedAt, user.ProfileImageURL, user.Bio) - if err != nil { - h.Logger.Error(err) - return c.JSON(500, err) - } - return c.JSON(200, user) -} diff --git a/backend/migrations/20231109002750_create_initial_tables.go b/backend/migrations/20231109002750_create_initial_tables.go new file mode 100644 index 0000000..6b60544 --- /dev/null +++ b/backend/migrations/20231109002750_create_initial_tables.go @@ -0,0 +1,29 @@ +package migrations + +import ( + "context" + "fmt" + + "github.com/saitamau-maximum/maxitter/backend/model" + "github.com/uptrace/bun" +) + +func init() { + Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error { + fmt.Print(" [up migration] ") + _, err := db.NewCreateTable().Model((*model.Post)(nil)).Exec(ctx) + if err != nil { + return err + } + _, err = db.NewCreateTable().Model((*model.User)(nil)).Exec(ctx) + return err + }, func(ctx context.Context, db *bun.DB) error { + fmt.Print(" [down migration] ") + _, err := db.NewDropTable().Model((*model.Post)(nil)).IfExists().Exec(ctx) + if err != nil { + return err + } + _, err = db.NewDropTable().Model((*model.User)(nil)).IfExists().Exec(ctx) + return err + }) +} diff --git a/backend/migrations/main.go b/backend/migrations/main.go new file mode 100644 index 0000000..781c88d --- /dev/null +++ b/backend/migrations/main.go @@ -0,0 +1,11 @@ +package migrations + +import "github.com/uptrace/bun/migrate" + +var Migrations = migrate.NewMigrations() + +func init() { + if err := Migrations.DiscoverCaller(); err != nil { + panic(err) + } +} diff --git a/backend/model/post.go b/backend/model/post.go new file mode 100644 index 0000000..b5f7ad2 --- /dev/null +++ b/backend/model/post.go @@ -0,0 +1,14 @@ +package model + +import ( + "time" + + "github.com/uptrace/bun" +) + +type Post struct { + bun.BaseModel `bun:"posts,alias:posts"` + ID string `bun:"id,type:varchar(36),nullzero,notnull" json:"id"` + Body string `bun:"body,type:text,nullzero,notnull" json:"body"` + CreatedAt time.Time `bun:"created_at,nullzero,notnull" json:"created_at"` +} diff --git a/backend/model/user.go b/backend/model/user.go new file mode 100644 index 0000000..2055ea0 --- /dev/null +++ b/backend/model/user.go @@ -0,0 +1,17 @@ +package model + +import ( + "time" + + "github.com/uptrace/bun" +) + +type User struct { + bun.BaseModel `bun:"users,alias:users"` + ID string `bun:"id,type:varchar(36),nullzero,notnull" json:"id"` + Name string `bun:"name,type:varchar(255),nullzero,notnull" json:"name"` + ProfileImage string `bun:"profile_image_url,type:varchar(255),nullzero" json:"profile_image_url"` + Bio string `bun:"bio,type:varchar(1024),nullzero" json:"bio"` + CreatedAt time.Time `bun:"created_at,nullzero,notnull" json:"created_at"` + UpdatedAt time.Time `bun:"updated_at,nullzero,notnull" json:"updated_at"` +} diff --git a/backend/sql/1_create_post_table.sql b/backend/sql/1_create_post_table.sql deleted file mode 100644 index b349b38..0000000 --- a/backend/sql/1_create_post_table.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE IF NOT EXISTS `posts` ( - `id` varchar(36) NOT NULL COMMENT '投稿ID', - `body` text NOT NULL COMMENT '投稿の本文', - `created_at` datetime NOT NULL COMMENT '投稿日時', - PRIMARY KEY (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/backend/sql/2_create_user_table.sql b/backend/sql/2_create_user_table.sql deleted file mode 100644 index f548d04..0000000 --- a/backend/sql/2_create_user_table.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE IF NOT EXISTS `users` ( - `id` varchar(36) NOT NULL COMMENT 'ユーザーID', - `username` varchar(255) NOT NULL COMMENT 'ユーザー名', - `created_at` datetime NOT NULL COMMENT '登録日時', - `updated_at` datetime NOT NULL COMMENT '更新日時', - `profile_image_url` varchar(255) DEFAULT NULL COMMENT 'プロフィール画像のURL', - `bio` varchar(1024) DEFAULT NULL COMMENT '自己紹介文', - PRIMARY KEY (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/scripts/migrator.sh b/scripts/migrator.sh new file mode 100755 index 0000000..eeadef4 --- /dev/null +++ b/scripts/migrator.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +THIS_FILE_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "${THIS_FILE_DIR}/.." && pwd)" +SERVER_DIR="${PROJECT_DIR}/backend" +ENV_FILE="${PROJECT_DIR}/.env" + +which dotenv >/dev/null 2>&1 +if [ $? -ne 0 ]; then + echo "dotenv-cli がインストールされていません!" + echo "dotenvインストールしてから再度実行してください。" + echo "\`npm install -g dotenv-cli\`" + exit 1 +fi + +cd "${SERVER_DIR}" + +dotenv -e "${ENV_FILE}" go run "${SERVER_DIR}/cmd/migrate/main.go" db $1 $2