MinIO on Docker

Docker Compose で MinIO を動作させたい。

動作させた MinIO コンテナに対して S3 API を使って何らかの操作をしたい。

MinIO 起動

server コマンドに console-address オプションを指定してコンソール画面に 9001 番ポートを割り当てている。

ルートのユーザーとパスワードを環境変数で指定している。

services:
  minio:
    image: quay.io/minio/minio:latest
    environment:
      MINIO_ROOT_USER: root
      MINIO_ROOT_PASSWORD: password
    command: server --console-address ":9001" /data
    ports:
      - 9000:9000
      - 9001:9001

docker compose コマンドで起動させると、ブラウザからコンソールへアクセスできる。

ルートのユーザー名、パスワードを入力してログインできる。

後の API 操作で使用するバケットを作成しておく。

S3 API 操作

バケット一覧を取得する API を実行して、example バケットが取得できるか確認する。

import { S3 } from "@aws-sdk/client-s3";
import { fromEnv } from "@aws-sdk/credential-providers";

const initEnv = () => {
  process.env.AWS_ACCESS_KEY_ID = "root";
  process.env.AWS_SECRET_ACCESS_KEY = "password";
};

const client = new S3({
  credentials: fromEnv(),
  region: "us-west-2",
  endpoint: "http://localhost:9000",
});

initEnv();

client
  .listBuckets({})
  .then((data) => {
    console.log(data);
  })
  .catch((error) => {
    console.error(error);
  });
% npx ts-node index.ts
{
  '$metadata': {
    httpStatusCode: 200,
    requestId: '17C851EF64A23053',
    extendedRequestId: 'dd9025bab4ad464b049177c95eb6ebf374d3b3fd1af9251148b658df7ac2e3e8',
    cfId: undefined,
    attempts: 1,
    totalRetryDelay: 0
  },
  Buckets: [ { Name: 'example', CreationDate: 2024-04-21T14:28:52.674Z } ],
  Owner: {
    DisplayName: 'minio',
    ID: '02d6176db174dc93cb1b899f7c6078f08654445fe8cf1b6ce98d8855f66bdbf4'
  }
}

想定通り、example バケットが取得できている。

まとめ

全ての API を試していないが S3 互換なのはかなり嬉しい。Local 動作確認、テストでの S3 アクセスに重宝しそう。

Prisma の Seeding 機能を使ってみた

Prisma には DB への Seeding の機能が搭載されています。

この機能を用いて、Docker コンテナで動作する MySQL に初期データの投入作業を行ってみたので、備忘録としてブログに残します。

環境

  • node 20.11.1
  • prisma 5.10.2
  • @prisma/client 5.10.2
  • docker compose 2.22.0-desktop.2

MySQL コンテナ

Docker compose を用いて、MySQL コンテナを構築します。今回は MySQL 8.0 のイメージを使用します。

volumes:
  db:

services:
  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: example
    command: mysqld
    volumes:
      - type: volume
        source: db
        target: /var/lib/mysql
    ports:
      - 127.0.0.1:3306:3306
docker compose up

Prisma スキーマ

単純なスキーマを作成します。model が2つほどあれば十分でしょう。

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = "mysql://root:password@localhost:3306/example" // 本来ハードコーディングするべきではない⚠️
}

model User {
  id         Int      @id @default(autoincrement())
  name       String
  tasks      Task[]
  created_at DateTime @default(now())
  updated_at DateTime @default(now())
}

model Task {
  id         Int        @id @default(autoincrement())
  user       User       @relation(fields: [userId], references: [id])
  userId     Int
  status     TaskStatus
  createdAt DateTime   @default(now())
  updatedAt DateTime   @default(now())
}

enum TaskStatus {
  TODO
  IN_PROGRESS
  DONE
}

Prisma スキーマMySQL スキーマを同期します。

npx prisma db push

テーブルの作成を確認します。テーブル名、カラム名はスネークケースが好みですが、今回は一旦見逃しています。

% docker compose exec db sh
sh-4.4# mysql -uroot -p
(snip)
mysql> use example;
(snip)
mysql> show tables;
+-------------------+
| Tables_in_example |
+-------------------+
| Task              |
| User              |
+-------------------+
2 rows in set (0.00 sec)

Seed スクリプト

User と Task を1件ずつ作成するスクリプトを作成します。@prisma/client を使用するために、クライアントコードの生成コマンドも実行しておきます。

npx prisma generate
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient({
  log: ["query"],
});

async function main() {
  await prisma.user.create({
    data: {
      name: "Alice",
      tasks: {
        create: {
          status: "TODO",
        },
      },
    },
  });
}

main()
  .then(async () => {
    await prisma.$disconnect();
  })
  .catch(async (e) => {
    console.error(e);
    await prisma.$disconnect();
    process.exit(1);
  });

package.jsonprisma.seed のフィールドを追加します。

(snip)
  "prisma": {
    "seed": "ts-node prisma/seed.ts"
  },
(snip)

実際に試してはいませんが、Next.js のプロジェクトである場合はスクリプト実行コマンドにコンパイルオプションが必要であるようです。

ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts

Seeding 実行

コマンドを打って Seeding を実行します。

 % npx prisma db seed
Environment variables loaded from .env
Running seed command `ts-node prisma/seed.ts` ...
prisma:query BEGIN
prisma:query INSERT INTO `example`.`User` (`id`,`name`,`created_at`,`updated_at`) VALUES (?,?,?,?)
prisma:query INSERT INTO `example`.`Task` (`id`,`userId`,`status`,`created_at`,`updated_at`) VALUES (?,?,?,?,?)
prisma:query SELECT `example`.`User`.`id`, `example`.`User`.`name`, `example`.`User`.`created_at`, `example`.`User`.`updated_at` FROM `example`.`User` WHERE `example`.`User`.`id` = ? LIMIT ? OFFSET ?
prisma:query COMMIT

🌱  The seed command has been executed.

テーブルを確認します。

% docker compose exec db sh
sh-4.4# mysql -uroot -p
(snip)
mysql> use example;
(snip)
mysql> select * from User;
+----+-------+-------------------------+-------------------------+
| id | name  | created_at              | updated_at              |
+----+-------+-------------------------+-------------------------+
|  1 | Alice | 2024-03-09 13:19:02.744 | 2024-03-09 13:19:02.744 |
+----+-------+-------------------------+-------------------------+
1 row in set (0.00 sec)

mysql> select * from Task;
+----+--------+--------+-------------------------+-------------------------+
| id | userId | status | created_at              | updated_at              |
+----+--------+--------+-------------------------+-------------------------+
|  1 |      1 | TODO   | 2024-03-09 13:19:02.744 | 2024-03-09 13:19:02.744 |
+----+--------+--------+-------------------------+-------------------------+
1 row in set (0.00 sec)

まとめ

Prisma の Seeding 機能を使ってみました。

やってみて途中で気がついたのですが、npm スクリプトでも同じことできるような気がします。Prisma の機能を使うことで実現可能な便利オプションとかあるのでしょうか。

最近 Prisma に入門して SQL でテストデータ作るの面倒だなと思っていたところなので、ひとまずその課題が解消できそうなので良かったなと思います。

ts-jest-mocker のメモ

https://github.com/dariosn85/ts-jest-mocker

interface や class のモックを簡単にできるようにしてくれるライブラリである、ts-jest-mocker についてのメモ

嬉しいこと

interface を満たすための無意味なメソッド実装が不要になる。

初回実装、メンテナンスのコストが下がる。

// 挙動を変えたいのは c だけなのに...
const mockrepo: IRepo = {
  a: jest.fn(),
  b: jest.fn(),
  c: jest.fn().mockReturnValue("mocked value")
}
// c の挙動だけ変えられる
// やりたいことも一目瞭然
const mockrepo = mock<IRepo>().c.mockReturnValue("mocked value");

あくまで jest を使いやすくする機能だけを提供しているだけで、導入のハードルが低い。テストの実装感に影響がない。

MySQL の整数型の括弧

エンジニアになりたての頃に、同僚がMySQL のテーブルのカラムの型を TINYINT(1) にしていたところ、(1) は意味ないからヤメロとレビューを受けていた。その時に理由を調査したのか、そういうものなんだなとスルーしたのかどうかは定かではないが、記憶に定着していなかったことは確かだったので調べた。

MySQL 8.0 のドキュメントを一次情報とする。 https://dev.mysql.com/doc/refman/8.0/ja/numeric-type-syntax.html

整数データ型の場合、M は最大表示幅を示します。 最大表示幅は 255 です。 表示幅は、セクション11.1.6「数値型の属性」 で説明されているように、型が格納できる値の範囲とは無関係です。

括弧内の値は最大表示幅を示し、格納される値の範囲とは無関係である。

浮動小数点データ型および固定小数点データ型の場合、M は格納できる合計桁数です。

浮動小数点、固定小数点の場合は、格納できる桁数を示す。

MySQL 8.0.17 では、整数データ型の表示幅属性は非推奨になりました。将来のバージョンの MySQL ではサポートされなくなる予定です。

将来的にサポートされなくなる予定なので、これから新たにカラム定義する場合には避けるほうが良い。

ユースケースレイヤーの実装はユースケースを洗い出してから

何を当たり前のことを、みたいなタイトルだが最近実感していることなので。

レイヤードアーキテクチャ、ヘキサゴナルアーキテクチャ、オニオンアーキテクチャはアプリケーション固有のルールを実装するレイヤーを持つ。このレイヤーはアーキテクチャによって、アプリケーションレイヤー、ユースケースレイヤー、サービスレイヤーなどと呼び名は違うが関心事は同じである。

このレイヤーの粒度や責務はどのように設計するのかを明確にすることができていなく、モヤモヤしていたが何となく自分の中の正解が見えてきた。

まず、よくあるパターンで自分も過去そのように設計してしまっていたパターンは、ドメインモデルのCRUDを実装するレイヤーにしてしまうことだ。汎用性があって、一見良い実装に見える。が、この実装だと一つ上のレイヤーであるプレゼンテーションレイヤー(ハンドラーとかコントローラー)で複数のユースケースを呼び出す設計になってしまう。ユースケースの呼び出しの順序の制御であったりどのユースケースを呼び出すか?といった関心事がユースケースから漏れ出て、複数のレイヤーに散らばってしまう。

では、どうするか。まずはアプリケーションのユースケースを明確にするのが良い。アプリケーションのアクターと、そのアクターがアプリケーションで実現できることを洗い出して列挙する。実現できることとユースケースが1対1になる。もちろんモデルのCRUDになるユースケースも出てくるのだが、たまたまそうなっているくらいの認識を持っておく。このように実装することによってプレゼンテーションレイヤーは、最低限のバリデーションとユースケースの入力/出力と外部の入力/出力の調整だけを担うようになり、必ず一つのユースケースだけを呼び出すようになる。(一つのユースケースが複数のプレゼンテーションレイヤーから呼ばれることはある。)複数のユースケースで重複した処理が出てくるかとは思うが、呼び出し順序の制御であれば重複したままにしておく。なぜならその制御がそのユースケースの責務であり、重複しているのは偶々であるからだ。単一のドメインモデルを用いたロジックはドメインモデルのレイヤーに実装して使い回し、複数のドメインモデルを組み合わせたロジックは、ドメインサービスレイヤーに実装する。今のところ、この実装が一番しっくり来るし使い回しやすく、変更の影響が分かりやすいレイヤ分けだと思っている。

Go の errors.Join の挙動を調べた

Go 1.20 で追加された errors.Join の挙動を調べてみたので、備忘録としてポストしておく。

errors.Join の概要

Go doc と実装をベースに概要を簡単にまとめる。

errors.Join は error を返す。返された error は内部的に引数に渡された可変数の error を保持している。

Error メソッドを呼び出した際に返される string は、内部に保持している可変数の error のそれぞれの Error メソッドの返り値を \n で連結したものとなる。

返される error インタフェースの実体は Error メソッド、Unwrap メソッドを実装した joinError 構造体となっている。

この joinError 構造体はプライベートな構造体であるため、呼び出し元でアサーションはできない。

挙動確認

気になった挙動をそれぞれ確認してみる。

引数を渡さない

引数を渡さない場合は nil が返る。

package main

import (
    "errors"
    "fmt"
)

func main() {
    fmt.Println(errors.Join())
}
<nil>

https://go.dev/play/p/OGSXyKLKQIk

nil のみを渡す

nil のみを渡した場合は、nil が返る。

package main

import (
    "errors"
    "fmt"
)

func main() {
    fmt.Println(errors.Join(nil))
    fmt.Println(errors.Join(nil, nil))
}
<nil>
<nil>

https://go.dev/play/p/CverKIAv9xw

1つ以上の error と、1つ以上の nil を渡す

nil ではない error が返る。Error メソッドの出力は、保持されている error の Error メソッドの出力を改行区切りで出力したものとなっている。引数で渡した nil は無視されている。

package main

import (
    "errors"
    "fmt"
)

func main() {
    err1 := errors.New("error 1")
    err2 := errors.New("error 2")
    errs := errors.Join(err1, err2, nil, nil)
    fmt.Println(errs == nil)
    fmt.Println(errs)
}
false
error 1
error 2

https://go.dev/play/p/jkkDgYtEmAL

1つ以上の error を渡す

nil ではない error が返る。Error メソッドの出力は、保持されている error の Error メソッドの出力を改行区切りで出力したものとなっている。

package main

import (
    "errors"
    "fmt"
)

func main() {
    err1 := errors.New("error 1")
    err2 := errors.New("error 2")
    fmt.Println(errors.Join(err1, err2))
}
false
error 1
error 2

https://go.dev/play/p/2WUmfcvbCAW

errors.Is で比較する

返された error と、引数で渡した error との比較は true となる。引数で渡したいかなる error でも結果は等しく true である。

package main

import (
    "errors"
    "fmt"
)

func main() {
    err1 := errors.New("error 1")
    err2 := errors.New("error 2")
    errs := errors.Join(err1, err2)
    fmt.Println(errors.Is(errs, err1))
    fmt.Println(errors.Is(errs, err2))
}

https://go.dev/play/p/lEzt8wovFGH

errors.As で変換する

返された error は、Join の引数で渡した error に、As の引数で渡した error とマッチする error があった場合に変換を行い true を返す。

package main

import (
    "errors"
    "fmt"
)

type error1 struct {
    string
}

func (e error1) Error() string {
    return e.string
}

type error2 struct {
    string
}

func (e error2) Error() string {
    return e.string
}

func main() {
    err1 := error1{"error 1"}
    err2 := error2{"error 2"}
    errs := errors.Join(err1, err2)

    var err1forErrorsAs error1
    var err2forErrorsAs error2
    fmt.Println(errors.As(errs, &err1forErrorsAs))
    fmt.Println(errors.As(errs, &err2forErrorsAs))
    fmt.Println(err1forErrorsAs.Error())
    fmt.Println(err2forErrorsAs.Error())
}
true
true
error 1
error 2

https://go.dev/play/p/_3hljYeNHiH

マッチする error が複数ある場合は、Join で先に渡した引数の error に変換される。

こちらは、挙動確認に加えてコードも確認した。Join 実行時に引数で渡された順番で joinError の []error 型のフィールドに詰め替えており、Unwrap メソッドでは、フィールドをそのままかえしている。As の方では、Unwrap の返り値を for-range で回して要素ごとに As の第1引数に渡している。このような実装により、先に渡した引数の error に変換される挙動が実現している。

package main

import (
    "errors"
    "fmt"
)

type error1 struct {
    string
}

func (e error1) Error() string {
    return e.string
}

func main() {
    err1 := error1{"error 1"}
    err2 := error1{"error 2"}
    errs := errors.Join(err1, err2)

    var err1forErrorsAs error1
    var err2forErrorsAs error1
    fmt.Println(errors.As(errs, &err1forErrorsAs))
    fmt.Println(errors.As(errs, &err2forErrorsAs))
    fmt.Println(err1forErrorsAs.Error())
    fmt.Println(err2forErrorsAs.Error())
}
true
true
error 1
error 1

https://go.dev/play/p/C0vDnUD16Xg

まとめ

errors.Join が実装されたおかげで、複数の error をまとめるために標準以外のライブラリを使用する必要がなくなったことは嬉しい。

返り値が error インターフェースで抽象化されているので、これまで扱ってきたような error と同じように扱えて、使われ方を気にする必要がなくなったのも自分にはポジティブだ。

また、errors.Join の実装自体がシンプルで処理を追いやすかったので内部動作の把握も捗った。

参考

「Amazon DynamoDB におけるシングルテーブル vs マルチテーブル設計」を読んだメモ

DynamoDB のテーブル設計は、RDB のそれとは異なる。

テーブル数はなるべく少なくして、非正規化したモデルを設計するべきである。

その背景を深く知るべく、「Amazon DynamoDB におけるシングルテーブル vs マルチテーブル設計」を読んで学んだ内容をまとめてみた。

DynamoDB 特徴

  • 多くのDBは、低レベルなビット操作を抽象化して、柔軟なクエリでデータを操作できるようにしている。
    • この抽象化によって、DBのスケールや使用量の増加によるコスト増を理解しにくくなる。
  • DynamoDB はアプリケーションのスケールに応じたパフォーマンスを実現しようとしている。
  • DynamoDB はパーティショニングによる水平スケーリングでパフォーマンスを担保する。
  • パーティショニング、BTreeは DynamoDB 特有のものではない。
    • 他のDBに比べると、データ構造が剥き出しになっている。
  • 項目の操作には完全一致するプライマリキーを指定するCRUD APIと、パーティションキーを指定した複数件取得操作であるQuery APIが提供される。
  • あらゆる規模で堅実なパフォーマンスを出すために結合、集計は提供されていない。
    • 集計はクエリに関わるレコード数に強く依存し、レコード数は実行前にはわからない。
  • DynamoDB はデータ構造を剥き出しにすることで、パフォーマンスがわかりやすくなっている。
  • 読み込み書き込みのデータサイズ、トラフィック量が予想できれば、コストは計算可能である。
    • この透過的な課金モデルが、シングルテーブル設計を使用する理由の一つである。

シングルテーブル設計のススメ

  • シングルテーブル設計は、単一のサービスに対して適用されるべきである。
  • 筆者の経験則では、RDBMSで頻繁に結合するようなまとめてアクセスされるデータモデルは一つのテーブルに格納するのが良い。
    • そうではない場合は、必要に応じて分離しても良い。
  • シングルテーブルを採用することで、クエリ数を減らせる。
    • RDBMS のような結合されたデータを取得する際には、クエリを複数回発行したのちにアプリケーションで結合しないといけない。
    • DB へのI/Oはアプリケーションの中でも最も遅い処理であるので、非効率的である。
    • テーブルを分離するのではなく、同じパーティションキーを持つ別の性質の項目として一つのテーブルに格納することで、一つのクエリでまとめて取得できる。
  • シングルテーブルを採用することで、更新頻度の異なるデータの読み込み、書き込みを最適化できる。
    • 読み込みはまとめて取得したいが、書き込みは項目を分けて実行するような複数の項目を格納できる。
    • 更新頻度の高い属性を異なる項目として切り分けることで、一度の書き込みのコストを下げられる。
      • 書き込む項目のサイズが大きいほどコストは増加する。
  • シングルテーブルを採用することで運用コストを下げられる。
    • DynamoDB ではテーブルごとに設定、監視、バックアップが必要である。
      • とはいえ、1テーブルに対する運用自体はそこまで重くはない。
    • AWS アカウントごとにテーブル数の上限が設定されているので、大規模なサービスの場合テーブル数の上限に到達する可能性がある。
    • パーティションごとにバーストキャパシティを提供しているので、テーブルのパーティションキーのカーディナリティを高めればスロットリングは起こりづらくなる。

シングルテーブルで困ること

  • DynamoDB Stream のコンシューマの同時起動数は2なので、頻繁にコンシュームが発生するようなデータが一つのテーブルに格納されていると、スロットリングが発生しやすい。
  • オンラインの分析基盤へのエクスポートには不向きである。
    • テーブル内に、append-only なデータとそうではないデータがあった時、そのどちらも完全にエクスポートするのは遅い。
    • append-only なデータを DynamoDB Stream で分析基盤に転送するのは上手く機能する。
    • それぞれのデータを、異なる方法でエクスポートするにはテーブルを分けた方が良い。
      • 頻繁に更新が入るテーブルは完全にエクスポートし、append-only なテーブルはストリーミング処理してエクスポートする。

まとめ

結局のところ、シングルテーブルの利点が重要でなければ、複数テーブル設計を採用しても良い。

ただ、正規化されたモデルを避けることは絶対に正しいので、その点だけはブレてはいけない。