この章で行うこと
- データベース周り
- MySQLへの接続処理の実装
- DBマイグレーションによるテーブルの作成
- キーバリューストア周り
- Redisへの接続処理の実装
なぜこのタイミングでデータベース・キーバリューストアといったインフラストラクチャ層の実装を行なっておくかというと、理由は次章の内容に関わります。次章では、リポジトリを実装していきますが、別に「外側」であるMySQLなどは知らない(つまり依存していない)ので、この章を飛ばしてもリポジトリの実装は可能です。
ただ、リポジトリのテストは、テストDB環境を用いて行います。その際、インフラストラクチャ層にテスト環境のセットアップに関する実装を行うことになります。それならば、あらかじめここで先に開発環境のDB環境をセットアップしておいた方がおそらく理解しやすいのではないかなと思った次第です。本書の構成的にも繋げやすかったため、というのも理由の一つです。
5章までは円の「内側」の実装をしてきて、ここから「外側」のお話になり急に毛色が変わりますが、頑張って実装していきましょう。
データベース(MySQL)
データベースにはMySQLを使います。docker compose upをしてtodo-dbコンテナを起動している時点で、MySQLのDBサーバーも起動しています。make upコマンドを打った時、ログからMySQLが起動していることがわかります。
todo-db | 2025-01-08 15:22:16+00:00 [Note] [Entrypoint]: Switching to dedicated user 'mysql'
todo-db | 2025-01-08 15:22:16+00:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 8.0.40-1debian12 started.
// MySQLサーバーが起動
todo-db | 2025-01-08T15:22:17.610586Z 0 [System] [MY-010116] [Server] /usr/sbin/mysqld (mysqld 8.0.40) starting as process 1
todo-db | 2025-01-08T15:22:17.685906Z 1 [System] [MY-013576] [InnoDB] InnoDB initialization has started.
todo-db | 2025-01-08T15:22:17.901945Z 1 [System] [MY-013577] [InnoDB] InnoDB initialization has ended.
todo-db | 2025-01-08T15:22:18.276341Z 0 [Warning] [MY-010068] [Server] CA certificate ca.pem is self signed.
todo-db | 2025-01-08T15:22:18.276624Z 0 [System] [MY-013602] [Server] Channel mysql_main configured to support TLS. Encrypted connections are now supported for this channel.
todo-db | 2025-01-08T15:22:18.283774Z 0 [Warning] [MY-011810] [Server] Insecure configuration for --pid-file: Location '/var/run/mysqld' in the path is accessible to all OS users. Consider choosing a different directory.
// MySQLサーバーがポート番号3306 で接続を受け付ける準備が整った
todo-db | 2025-01-08T15:22:18.329143Z 0 [System] [MY-010931] [Server] /usr/sbin/mysqld: ready for connections. Version: '8.0.40' socket: '/var/run/mysqld/mysqld.sock' port: 3306 MySQL Community Server - GPL.
todo-db | 2025-01-08T15:22:18.329607Z 0 [System] [MY-011323] [Server] X Plugin ready for connections. Bind-address: '::' port: 33060, socket: /var/run/mysqld/mysqlx.sock
前章で実装したConfigからDB接続で環境変数を取得し、接続処理を実装していきます。
接続処理
app/infrastructure/dbディレクトリにconnect.goファイルを作成してください。
また、以下のコマンドを打ってgolangでMySQLを扱うためのパッケージをダウンロードしてください。
make get-app name="github.com/go-sql-driver/mysql"
connect.go の実装
// app/infrastructure/db/connect.go
package db
import (
"context"
"database/sql"
"fmt"
"log"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/kakkky/app/config"
)
// パッケージ変数としてDB接続を管理
var (
db *sql.DB
)
// パッケージ変数として*sql.DBをセット
func SetDB(d *sql.DB) {
db = d
}
func GetDB() *sql.DB {
return db
}
// DBをに接続する
func NewDB(ctx context.Context, cfg *config.Config) func() {
db, close, err := connect(
ctx,
cfg.MySQL.User,
cfg.MySQL.Password,
cfg.MySQL.Host,
cfg.MySQL.Port,
cfg.MySQL.Name,
)
if err != nil {
// DB接続失敗は復旧不可
panic(err)
}
// dbパッケージ変数にセット
SetDB(db)
return close
}
const (
maxRetriesCount = 5
initialDelay = 5 * time.Second
)
// DBへ接続
// 失敗したらリトライさせ、待機時間を2倍ずつ指数的に増やす(エクスポネンシャルバックオフ)
func connect(ctx context.Context, user, password, host, port, name string) (*sql.DB, func(), error) {
delay := initialDelay
for i := 0; i < maxRetriesCount; i++ {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true", user, password, host, port, name)
db, err := sql.Open("mysql", dsn)
if err != nil {
// DB接続の初期エラーが発生した場合のログ
log.Printf("failed to open db (attempt %d/%d): %v", i+1, maxRetriesCount, err)
} else {
// 接続確認
if err := db.PingContext(ctx); err == nil {
// 成功した場合、呼び出し元でdb.Close()を実行できるようにする
log.Printf("successfully connected to db (attempt %d/%d)", i+1, maxRetriesCount)
return db, func() { db.Close() }, nil
} else {
// 接続できた場合のエラーメッセージ
log.Printf("failed to ping db: %v", err)
}
}
// 接続できなかったらリトライ
log.Printf("failed to connect to db (attempt %d/%d), retrying in %v seconds...", i+1, maxRetriesCount, delay/time.Second)
time.Sleep(delay)
// 待機時間を2倍
delay *= 2
}
// 最大試行回数を超えても接続できなかった場合
return nil, nil, fmt.Errorf("failed to connect to db after %d attempts", maxRetriesCount)
}
ポイントは、
NewDB()関数の実装NewDB()関数でConfigを受け取っている*sql.DB型のインスタンス(DB接続情報) をパッケージ変数にセットしている
- エクスポネンシャルバックオフを適用した接続処理
NewDB()の実装
main関数から呼び出すことになるこの関数の実装のポイントを見ていきます。
// DBをに接続する
func NewDB(ctx context.Context, cfg *config.Config) func() {
db, close, err := connect(
ctx,
cfg.MySQL.User,
cfg.MySQL.Password,
cfg.MySQL.Host,
cfg.MySQL.Port,
cfg.MySQL.Name,
)
if err != nil {
// DB接続失敗は復旧不可
panic(err)
}
// dbパッケージ変数にセット
SetDB(db)
return close
}
Configの使用
引数にて*config.Config型のcfgを受け取っています。実際に接続を行うconnect()関数に接続情報を渡すために、埋め込み型であるMySQL構造体から各環境変数を取得し渡しています。なお、connect()にて、以下のように接続情報が使用されています。
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true", user, password, host, port, name)
db, err := sql.Open("mysql", dsn)
*sql.DBをパッケージ変数で管理
connect()はdb接続情報である*sql.DBを返します。それを、SetDB()関数によってパッケージ変数にセットしているのがわかります。
db, close, err := connect(
// 省略
)
// 省略
// dbパッケージ変数にセット
SetDB(db)
ゲッター関数も用意しています。
// パッケージ変数としてDB接続を管理
var (
db *sql.DB
)
// パッケージ変数として*sql.DBをセット
func SetDB(d *sql.DB) {
db = d
}
func GetDB() *sql.DB {
return db
}
この*sql.DBインスタンスは、次章で設定するormでも参照します。また、テスト環境でも、「テスト環境のDB接続情報」として参照します。基本どこからでも参照できるように、このような設計としています。
エクスポネンシャルバックオフを用いた接続処理
connect()関数の具体的な実装ですが、簡単に処理の流れを表すと以下のようになっています。
- DBに接続するための環境変数を受け取り、コンテナ内に起動しているDBに接続(
sql.Open())- エラーがあれば一定時間待機して(
time.Sleep(delay))、再トライ
- エラーがあれば一定時間待機して(
- 接続確認を行う(
db.PingContext(ctx))- エラーがない(接続OK)ならば、
*sql.DBインスタンスとdb接続をクローズする関数を返す - エラーがあれば一定時間待機して(
time.Sleep(delay))、再トライ
- エラーがない(接続OK)ならば、
- 最大試行回数の5回(
maxRetriesCount)を超えてしまった場合、エラー(接続失敗)を返す
一回の接続失敗でシステムが落ちてしまっては困るため、5回まで再トライさせるようにしています。そして、その再トライまでにインターバルを設け、失敗を重ねるごとにその時間を2倍して伸ばすようにしています。これを、エクスポネンシャルバックオフといいます。
エクスポネンシャルバックオフ
これは、リトライ処理の際に、再試行の間隔を指数関数的に増加させる手法のことです。何度も等間隔にリトライしないことでシステムの負荷を軽減する目的があります。今回の実装だと、
1回目: 5秒
2回目: 10秒
3回目: 20秒
4回目: 40秒
5回目: 80秒
と増えていきます。5回目の80秒はやり過ぎ感あるのですが。手法と目的だけ理解できればOKです。
DBマイグレーション
DBにおけるマイグレーションとは、DBのテーブルなどの変更をファイルに記録して、その内容を実行しDBのスキーマ(テーブル、インデックス、カラムの追加・変更・削除など)を更新することです。
単に、todo-dbコンテナに入って、alter table .....と打ったりして直接スキーマを変更していくこともできますが、それだとDBスキーマの変更を追いづらいです。そこで、「マイグレーションファイル」に変更内容や作成したテーブルの内容を記録することで、データベースのスキーマをバージョン管理します。これにより、開発環境や本番環境でのスキーマ変更を簡単に追跡・管理でき、スキーマの変更が一貫性を持って行われます。
スキーマの再現性も確保されるため、開発環境のDBの状態をテスト環境にそのまま再現することができます。
このようにマイグレーションはとても便利です。
golangにおけるマイグレーションの方法はいくつかありますが、今回はgolang-migrateというツールを使います。
これはCLIツールです。インストールはDockerfile.appにてすでに行っています。
RUN go install -tags 'mysql' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
早速、テーブル作成を行ってみましょう。
go-migrateの使い方(CLI)
以下の流れでマイグレーションは進めます。
- マイグレーションファイルを作成
- マイグレーションをup(変更を適用してスキーマを更新)する内容とdown(変更内容をロールバックする=戻す)する内容を記述
- マイグレーションをup(変更を適用)
マイグレーションファイル作成
以下のコマンドでファイルを生成します。
migrate create -ext <拡張子> -dir <出力先> -seq <マイグレーションファイル名>
例えば、xxxxxxxxx.up.sqlとxxxxxxxx.down.sqlの二つが作成されます。
Makefileには以下のタスクを定義しています。
# マイグレーションファイルを作成
# コマンド例: $ make migrate-create name=<migration-name>
migrate-create:
$(eval NAME=$(or $(name),$(error "Error: Please specify a migration name using name=<name>")))
@echo "Creating migration file..."
cd app/ && migrate create -ext sql -dir $(MIGRATE_PATH) -seq $(NAME)
いちいちマイグレーションファイルの出力先を入力するのは面倒なので定数として定義しています。
MIGRATE_PATH = infrastructure/db/migrations
マイグレーションファイルに記述
xxxxxxxxx.up.sqlとxxxxxxxx.down.sqlに記述します。
xxxxxxxxx.up.sqlには、適用させたい変更をSQLで記述します。テーブルが作りたいのであれば、create table ....などと書きます。xxxxxxxx.down.sqlには、upした内容を取り消す(ロールバック)内容を記述します。
upにcreate tableと書いたなら、drop table ...でテーブルを削除する内容を書きます。
マイグレーションを適用
-verboseフラグは、コマンドを実行後、マイグレーションを適用させる様子をロギングするためのものです。
$ migrate --path <参照するマイグレーションファイルのパス> --database <DBサーバーのURL> -verbose up
Makefileには以下のタスクが定義してあります。
# マイグレーションを適用
# コマンド例: $ make migrate-up
migrate-up:
@echo "Applying migrations..."
docker compose run --rm app migrate --path $(MIGRATE_PATH) --database "$(DB_URL)" -verbose up
# マイグレーションをロールバック
# コマンド例: $ make migrate-down
migrate-down:
@echo "Rolling back migrations..."
docker compose run --rm app migrate --path $(MIGRATE_PATH) --database "$(DB_URL)" -verbose down
DB_URLは、定数として定義しています。
DB_URL = mysql://user:pswd@tcp(db:3306)/todo-db?parseTime=true
また、docker compose run --rm appというふうに、一時的にコンテナを起動させてコンテナ内でmigrateコマンドを実行しています。こうしているのは、DBサーバーが起動していないとDBのスキーマ変更が不可能だからです。
--rmオプションを付加しているので、実行後コンテナは削除されます。
マイグレーションに失敗した時
SQLの構文エラーなどが原因で、マイグレーションをupすることに失敗した場合、SQLを修正してupしなおしたとしても以下のようなエラーが出る可能性があります。
error: Dirty database version 3. Fix and force version.
make: *** [migrate-up] Error 1
その場合は、最後のバージョンを指定してdirtyなバージョンをリセットしましょう。例えば、000004_create_tasks_table.upといったファイルのマイグレーションが失敗したとしましょう。このマイグレーションのバージョンは「4」となります。この場合、バージョン4がdirty versionとしてみなされます。そこで、migrate force 3とコマンドをうち、バージョンを3(失敗する前の状態)に強制変更してあげます。すると、問題なく再度マイグレーションをupできると思います。
Makefileには以下のようにタスクを定義してあります。
# マイグレーションのバージョンをセット
# migration-upが失敗した時は、最後のバージョンにforceしなおしてから再度upするようにする
# コマンド例: $ make migrate-force version=<version_number>
migrate-force:
@echo "Forcing database migration version to $(version)..."
docker compose run --rm app migrate --path $(MIGRATE_PATH) --database "$(DB_URL)" force $(version)
usersテーブルを作成する
usersテーブルを作成してきましょう。以下のようにコマンドを打ってマイグレーションファイルを作成しましょう。
make migrate-create name="create_users_table"
生成されたファイルに以下のように記述します。
-- 000001_create_users_table.down.sql
drop table if exists users;
-- 000001_create_users_table.up.sql
create table users
(
id char(26) not null,
email char(254) not null,
name char(255) not null,
hashed_password char(255) not null,
created_at timestamp default current_timestamp,
updated_at timestamp default current_timestamp on update current_timestamp,
primary key(id)
);
create index idx_email on users (email);
記述できれば、マイグレーションを適用させましょう。
make migrate-up
tasksテーブルを作成する
流れはusersテーブルと同じです。
make migrate-create name="create_tasks_table"
-- 000002_create_tasks_table.down.sql
drop table if exists tasks;
-- 000002_create_tasks_table.up.sql
create table tasks(
id char(26) not null,
user_id char(26) not null,
content text not null,
state int not null,
created_at timestamp default current_timestamp,
updated_at timestamp default current_timestamp on update current_timestamp,
primary key(id),
constraint fk_user_id foreign key (user_id) references users (id) on delete cascade
);
create index idx_user_id on tasks (user_id);
記述できれば、マイグレーションを適用させます。
make migrate-up
確認する
本当にできてるのかー?と心配な方は、DBサーバーに入って確認してみましょう。以下のコマンドでDBコンテナに入れます。
make exec-db
mysqlとコマンドを打って、use todo-dbと使うDBを指定して、show tables;とコマンドを打ってみてください。以下のように出力されればOKです。
mysql> show tables;
+-------------------+
| Tables_in_todo-db |
+-------------------+
| schema_migrations |
| tasks |
| users |
+-------------------+
3 rows in set (0.00 sec)
なお、schema_migrationsは、go-migrateによってマイグレーションのバージョンを管理しているテーブルです。
キーバリューストア(Redis)
キーバリューストア(Key Value Store)として、Redisを採用します。
Redisを本APIのどこで扱うかというと、10章で実装する認証・認可の部分です。詳細は割愛しますが、ログイン時にJWTトークンをRedis上に保存し、認可制御ではJWTトークンがRedis上に存在するかというのを一つの判断基準とします。また、ログアウト時にはJWTトークンをRedisから削除します。
このように、削除する前提があったり、有効期限により無効化されるトークンを扱うのに、データの永続化を目的としたリレーショナルデータベースは使用しません。
接続処理
app/infrastructure/kvsディレクトリを作成してください。また、golangでredisを扱うためのパッケージをダウンロードしておいてください。
make get-app name="github.com/redis/go-redis/v9"
redis_client.go の実装
// app/infrastructure/kvs/redis_client.go
package kvs
import (
"context"
"fmt"
"log"
"time"
"github.com/kakkky/app/config"
"github.com/redis/go-redis/v9"
)
var redisClient *redis.Client
// パッケージ変数を取得
func GetRedisClient() *redis.Client {
return redisClient
}
func NewRedisClient(ctx context.Context, cfg *config.Config) *redis.Client {
// Redisクライアントを作成
redisClient = redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%s", cfg.Redis.Host, cfg.Redis.Port),
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
// contextによるタイムアウトを許可
ContextTimeoutEnabled: true,
})
// Redisが接続できるか確認するため、pingコマンドを実行
_, err := redisClient.Ping(ctx).Result()
if err != nil {
log.Fatalf("failed to connect to redis: %v", err)
} else {
log.Println("successfully connected to redis")
}
return redisClient
}
ポイントは以下の通りです。
Configを受け取っている*redis.Client型のインスタンス(接続情報)をパッケージ変数にセットしている
基本的にはDBへの接続の時と変わりません。
Configを受け取っている
接続のために環境変数を取得しています。
// Redisクライアントを作成
redisClient = redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%s", cfg.Redis.Host, cfg.Redis.Port),
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
// contextによるタイムアウトを許可
ContextTimeoutEnabled: true,
})
接続情報をパッケージ変数で管理する
redisClientをパッケージ変数として定義しています。リポジトリでは、GetRedisClient関数で取得した*redis.Client型のインスタンスを利用して操作します。
var redisClient *redis.Client
// パッケージ変数を取得
func GetRedisClient() *redis.Client {
return redisClient
}
func NewRedisClient(ctx context.Context, cfg *config.Config) *redis.Client {
// 代入している
redisClient = redis.NewClient(&redis.Options{
// 省略
})
// 省略
return redisClient
}
また、帰り値としても*redis.Clientを返していますが、これはmain()にてRedisクライアントをクローズさせるためです。
main関数で呼び出す
MySQLへの接続、Redisへの接続を実装したので、mainパッケージのmain()関数にて呼び出しましょう。
// app/main.go
package main
import (
"context"
"log"
"github.com/kakkky/app/config"
"github.com/kakkky/app/infrastructure/db"
"github.com/kakkky/app/infrastructure/kvs"
)
func main() {
ctx := context.Background()
cfg, err := config.NewConfig()
if err != nil {
log.Fatalf("failed to read config : %v", err)
}
run(ctx, cfg)
}
func run(ctx context.Context, cfg *config.Config) {
// データベース接続を初期化し、終了時にクローズする
close := db.NewDB(ctx, cfg)
defer close()
// Redisクライアントに接続
redisClient := kvs.NewRedisClient(ctx, cfg)
defer redisClient.Close()
}
これで、goプログラムを起動するとMySQL、Redisに接続されるようになりました。
コンテナをmake upしてみてください。場合によっては1回目では成功せず、2回目(attempt 2/5)で接続されます。
todo-api | running...
// pingに失敗
todo-api | 2025/01/10 14:36:22 failed to ping db: dial tcp 172.18.0.3:3306: connect: connection refused
// 5秒後に再接続を試みる
todo-api | 2025/01/10 14:36:22 failed to connect to db (attempt 1/5), retrying in 5ns seconds...
todo-db | 2025-01-10T14:36:22.332235Z 1 [System] [MY-013577] [InnoDB] InnoDB initialization has ended.
todo-db | 2025-01-10T14:36:22.657877Z 0 [Warning] [MY-010068] [Server] CA certificate ca.pem is self signed.
todo-db | 2025-01-10T14:36:22.658212Z 0 [System] [MY-013602] [Server] Channel mysql_main configured to support TLS. Encrypted connections are now supported for this channel.
todo-db | 2025-01-10T14:36:22.666102Z 0 [Warning] [MY-011810] [Server] Insecure configuration for --pid-file: Location '/var/run/mysqld' in the path is accessible to all OS users. Consider choosing a different directory.
todo-db | 2025-01-10T14:36:22.714465Z 0 [System] [MY-010931] [Server] /usr/sbin/mysqld: ready for connections. Version: '8.0.40' socket: '/var/run/mysqld/mysqld.sock' port: 3306 MySQL Community Server - GPL.
todo-db | 2025-01-10T14:36:22.715557Z 0 [System] [MY-011323] [Server] X Plugin ready for connections. Bind-address: '::' port: 33060, socket: /var/run/mysqld/mysqlx.sock
// 2回目で成功
todo-api | 2025/01/10 14:36:27 successfully connected to db (attempt 2/5)
todo-api | 2025/01/10 14:36:27 successfully connected to redis
現段階のディレクトリ
.
├── .github
│ └── workflows
│ └── test.yml
├── .gitignore
├── Dockerfile.app
├── Makefile
├── app
│ ├── .air.toml
│ ├── application
│ │ └── usecase
│ │ ├── task
│ │ │ ├── create_task_usecase.go
│ │ │ ├── create_task_usecase_dto.go
│ │ │ ├── create_task_usecase_test.go
│ │ │ ├── delete_task_usecase.go
│ │ │ ├── delete_task_usecase_dto.go
│ │ │ ├── delete_task_usecase_test.go
│ │ │ ├── fetch_task_usecase.go
│ │ │ ├── fetch_task_usecase_dto.go
│ │ │ ├── fetch_task_usecase_test.go
│ │ │ ├── fetch_tasks_usecase.go
│ │ │ ├── fetch_tasks_usecase_test.go
│ │ │ ├── fetch_user_tasks_usecase.go
│ │ │ ├── fetch_user_tasks_usecase_dto.go
│ │ │ ├── fetch_user_tasks_usecase_test.go
│ │ │ ├── interface_task_query_service.go
│ │ │ ├── mock_task_query_service.go
│ │ │ ├── update_task_state_usecase.go
│ │ │ ├── update_task_state_usecase_dto.go
│ │ │ └── update_task_state_usecase_test.go
│ │ └── user
│ │ ├── fetch_users_usecase.go
│ │ ├── fetch_users_usecase_dto.go
│ │ ├── fetch_users_usecase_test.go
│ │ ├── register_usecase.go
│ │ ├── register_usecase_dto.go
│ │ ├── register_usecase_test.go
│ │ ├── unregister_usecase.go
│ │ ├── unregister_usecase_dto.go
│ │ ├── unregister_usecase_test.go
│ │ ├── update_profile_usecase.go
│ │ ├── update_profile_usecase_dto.go
│ │ └── update_profile_usecase_test.go
│ ├── config
│ │ ├── config.go
│ │ └── config_test.go
│ ├── domain
│ │ ├── errors
│ │ │ ├── errors.go
│ │ │ └── errors_test.go
│ │ ├── task
│ │ │ ├── interface_task_repository.go
│ │ │ ├── mock_task_repository.go
│ │ │ ├── task.go
│ │ │ ├── task_content.go
│ │ │ ├── task_state.go
│ │ │ └── task_test.go
│ │ └── user
│ │ ├── interface_user_domain_service.go
│ │ ├── interface_user_repository.go
│ │ ├── mock_user_domain_service.go
│ │ ├── mock_user_repository.go
│ │ ├── user.go
│ │ ├── user_domain_service.go
│ │ ├── user_domain_service_test.go
│ │ ├── user_email.go
│ │ ├── user_hashed_password.go
│ │ ├── user_test.go
│ │ └── users.go
│ ├── go.mod
│ ├── go.sum
│ ├── infrastructure
│ │ ├── db
│ │ │ ├── connect.go
│ │ │ ├── migrations
│ │ │ │ ├── 000001_create_users_table.down.sql
│ │ │ │ ├── 000001_create_users_table.up.sql
│ │ │ │ ├── 000002_create_tasks_table.down.sql
│ │ │ │ └── 000002_create_tasks_table.up.sql
│ │ │ └── mysql
│ │ │ └── conf.d
│ │ │ └── my.conf
│ │ └── kvs
│ │ └── redis_client.go
│ ├── main.go
│ └── tmp
│ ├── build-errors.log
│ └── main
├── compose.yml
├── go.work
├── go.work.sum
└── pkg
├── go.mod
├── go.sum
├── hash
│ ├── hash.go
│ └── hash_test.go
└── ulid
├── ulid.go
└── ulid_test.go