ドメイン駆動設計の目的とレイヤーについて自分なりに整理する

はじめに

ドメイン駆動設計 モデリング/実装ガイドを読んで、ドメイン駆動設計(以下DDD)について改めて思ったことを書いていきたいと思います。
自分のDDDの経験としては、実務経験はありますが、DDDで実装されたリポジトリの機能追加くらいで、メインで普段DDDで実装しているわけではない、という感じです。
なお、DDDの解説記事というより、自分の理解を整理する記事となっております。
DDDの詳細な解説は紹介した本の著者のブログが大変参考になります。

DDDの目的についての理解

ドメイン駆動設計の目的であるドメイン(実務領域)をソースコードに落とし込むことで、実務に沿った振る舞いのみを達成するコードを書くことが、DDDの目的であると自分は理解しています。
例えば、配送方法と配送金額をプロパティに持つクラスを例にして考えてみます。 ドメイン(実務領域)として、配送方法はstandardとexpressのみ、金額は1円以上1000円以下であると仮定します。
まずはDDDを特に意識しない形で書いてみます。

class Delivery {
    private string $method;
    private int $price;

    public function setMethod(string $method): void {
        $this->method = $method;
    }

    public function setPrice(int $price): void {
        $this->price = $price;
    }

    // その他の処理は割愛
}

このsetterは昔からよく見る書き方ですが、型さえ合っていれば、業務であり得ない配送方法や金額でも渡すことができます。
またコンストラクタで一度のみ渡すわけではなく、オブジェクトが生成された後でもsetter経由で変えることが出来ます。

このクラスに対して、ドメイン駆動設計の考えを適用して変更を加えてみます。

class Delivery {
    private string $method;
    private int $price;

    public function __construct(string $method, int $price) {
        if (!in_array($method, ['standard', 'express'])) {
            throw new InvalidArgumentException("Invalid delivery method");
        }
        if ($price < 0 || $price > 1000) {
            throw new InvalidArgumentException("Invalid delivery price");
        }
        $this->method = $method;
        $this->price = $price;
    }

    // その他の処理は割愛
}

まずはpublicなsetterではなくコンストラクタでオブジェクト生成時に値が入るようにして、不変性を担保して後から意図しない変更が入らないようにしました。
また、ドメイン(実務領域)として、配送方法はstandardとexpressのみ、金額は1円以上1000円以下であるというルールに則った値以外は入らないようにドメイン知識をソースコードに取り入れてバリデーションしています。

ドメイン駆動設計で何を達成したいのか考える時は、いきなりレイヤーの話からするのではなく、上記のような例で考えると理解しやすい、と自分は感じました。

各レイヤーについて

ドメイン駆動設計はいくつかのレイヤーに分かれて、各レイヤーがそれぞれ責務を持っているため、主要なレイヤーについて記載します。

プレゼンテーション層

ユーザーの入力・要求を受け付けて、必要なビジネスロジックを呼び出します。 配送の例で言えば、配送方法の一覧を取得するリクエストを受け付けて、必要なアプリケーション層の処理を呼び出します。

アプリケーション層

プレゼンテーション層のリクエストを処理して、ドメイン層を利用して適切な業務のユースケースを達成させます。
命名も業務のユースケースに沿ったものとなります。
配送の例で言えば、配送方法について取り扱っているドメイン層の処理を呼び出します。
アプリケーション層自体は配送方法についての詳細な業務知識(ドメイン知識)を持っていません。

ドメイン

ドメイン(実務領域)の知識を持っている層で、ドメインに沿ったオブジェクトの情報やビジネスロジックを持っています。
先述の配送方法や配送価格の落とし込みはドメイン層で行われます。
配送の例で言えば、この層が配送方法の一覧や紐づく価格を知っているので、インフラ層を利用して必要な情報を返したり、登録処理などを行います。

インフラ層

DB操作など、ドメイン層で必要とされるインフラ領域の操作を担当します。
いわゆるRepositoryクラスの実装がメインとなります。

PHPのキャッシュ(OPcacheとpreload)についてメモ

はじめに

この記事は自分がPHPのキャッシュ戦略についての理解を整理するために書いています。 細かい解釈ミスなどありましたら教えていただけると幸いです。

まずは基本的なことですがおさらいから。
PHPインタプリタ言語です。インタプリタは事前コンパイルはされず、実行時にソースコードを読み込んで実行可能なバイトコードに変換します。
なので、事前に全て実行可能な機械語になっているコンパイル言語よりも遅いとよく言われます。
しかし、PHPにはOPCasheやpreloadの仕組みがあり、これらを活用すれば毎回毎回実行時にコンパイルされるわけではありません。

OPcasheについて

OPcasheについて公式の説明を引用します。
PHP: OPcache - Manual

OPcache はコンパイル済みのバイトコードを共有メモリに保存し、PHP がリクエストのたびにスクリプトを読み込み、パースする手間を省くことでパフォーマンスを向上させます。

OPcasheを使うとコンパイル済みのバイトコードが保存されて実行されるので、毎度コンパイルする必要がありません。
OPcasheはPHP5.5から入っているので、今世界で動いているPHPの多くはこの仕組みを使っていると思います。

OPcasheはphp.iniで設定されています。詳しい設定内容は公式に記載されています。

PHP: 実行時設定 - Manual

preloadについて

preloadはPHP7.4で実装された機能で、サーバー起動時に事前にコンパイルした結果をキャッシュしてPHPを高速化する機能です。
OPcacheは実行された結果をキャッシュしますが、preloadは実際に実行される前に事前にコンパイルした結果をキャッシュするので、最初から高速化されています。
PHP: コードの事前ロード - Manual

キャッシュの破棄タイミングについてメモ

細かいキャッシュ破棄のケースについては割愛しますが、新しいコードがデプロイされてサーバーが再起動されたタイミングでは基本的にキャッシュが破棄されます。
古いキャッシュは破棄されますが、preloadの機能で事前コンパイルした結果がキャッシュに保存されるので、最初からキャッシュありで実行することができます。

ChatGPTにSQLの集計と集計結果に対するcase文について説明してもらいました

はじめに

自分は普段SQLなどの小ネタをブログに書いたりしているのですが、説明のためにテーブルを用意したりするのはちょっと面倒です。
なのでやりたいことだけ書いて、全部解説してデータも例示してもらうことにしました。

こちらの仕事としては、集計した結果をcase文で出力するようなプロンプトを用意するだけです。

プロンプト1

一つの注文に複数の商品が紐づくデータがあります。商品には三つほどの種類があります。注文で集計しつつ商品の種類を出力するsqlを書いてください

このプロンプトの狙いとしては、注文テーブルと、注文テーブルIDに対して1対Nの商品IDが格納される注文詳細テーブル、そして商品テーブルを作ってもらい、注文テーブルIDでGroup byしつつ商品の情報をCASE文で出してもらうことでした。

ChatGPTの応答1

以下のようなテーブル構造を仮定し、商品を種類A、種類B、種類Cの3つに分類するクエリを示します。

-- ordersテーブル:注文情報
CREATE TABLE orders (
  order_id INT,
  order_date DATE,
  customer_id INT,
  PRIMARY KEY (order_id)
);

-- order_detailsテーブル:注文明細情報
CREATE TABLE order_details (
  order_id INT,
  product_id INT,
  quantity INT,
  price INT,
  PRIMARY KEY (order_id, product_id),
  FOREIGN KEY (order_id) REFERENCES orders(order_id)
);

-- productsテーブル:商品情報
CREATE TABLE products (
  product_id INT,
  product_name VARCHAR(50),
  product_type VARCHAR(10),
  PRIMARY KEY (product_id)
);
-- 商品の種類A、種類B、種類Cに分類しながら、注文単位で集計するクエリ
SELECT 
  o.order_id,
  o.order_date,
  SUM(CASE WHEN p.product_type = 'A' THEN od.quantity ELSE 0 END) AS type_a_quantity,
  SUM(CASE WHEN p.product_type = 'B' THEN od.quantity ELSE 0 END) AS type_b_quantity,
  SUM(CASE WHEN p.product_type = 'C' THEN od.quantity ELSE 0 END) AS type_c_quantity
FROM orders o
INNER JOIN order_details od ON o.order_id = od.order_id
INNER JOIN products p ON od.product_id = p.product_id
GROUP BY o.order_id, o.order_date;

このクエリでは、注文情報を表すordersテーブルと注文明細情報を表すorder_detailsテーブルをJOINし、商品情報を表すproductsテーブルともJOINしています。そして、CASE文を使用して、商品の種類A、種類B、種類Cごとに数量を合計し、それぞれの数量をカラムに出力しています。また、注文単位で集計するため、ordersテーブルのorder_idとorder_dateでグルーピングしています。

このようにして、注文データから商品の種類別の数量を集計しつつ、注文単位で集計することができます。

ChatGPTの応答1について所感

自分がイメージしているテーブル構造およびクエリを書いてもらうことができました。
自分は商品名などをCASE文で出すのかなと漫然と思っていたのですが、集計対象として意味のある注文に対する商品の数量をちゃんと集計していますね。
たしかに商品名などは元の注文詳細テーブルから出せばいいので、この集計クエリで出す必要はありません。
意味のあるデータだけ出しているようで、感心しました。

また実際のデータがあればもっと分かりやすいと思ったので、次の応答で出してもらうようにしました。

プロンプト2

具体的なデータのレコードで説明してくれますか?

ChatGPTの応答2

はい、データのレコードを以下に示します。

ordersテーブル:

order_id order_date customer_id
1 2022-01-01 100
2 2022-01-02 200
3 2022-01-03 300

order_detailsテーブル:

order_id product_id quantity price
1 1 2 100
1 2 3 200
2 1 1 100
2 3 2 300
3 2 4 200
3 3 1 300

productsテーブル:

product_id product_name product_type
1 商品A A
2 商品B B
3 商品C C

このとき、商品の種類A、種類B、種類Cごとの数量を注文単位で集計するクエリを実行すると、以下のような結果が得られます。

order_id order_date type_a_quantity type_b_quantity type_c_quantity
1 2022-01-01 2 3 0
2 2022-01-02 1 0 2
3 2022-01-03 0 4 1

注文1では、商品Aが2個、商品Bが3個注文されているため、type_a_quantityが2、type_b_quantityが3となります。商品Cは注文されていないため、type_c_quantityは0です。

同様に、注文2では、商品Aが1個、商品Cが2個注文されているため、type_a_quantityが1、type_b_quantityが0、type_c_quantityが2となります。

注文3では、商品Bが4個、商品Cが1個注文されているため、type_a_quantityは0、type_b_quantityは4、type_c_quantityは1となります。

以上のように、商品の種類別に集計することができました。

ChatGPTの応答2について所感

具体的なレコードを出して、さらになぜこうなるかの結果まで説明してもらいました。
これはSQL学習が捗りそうですね。

GROUP_CONCATでグループ化された行のカラムの値を連結して出力する

概要

グループ化された行のカラムの内容も表示したい時などに使うGROUP_CONCAT()について記載します

実行環境:MySQL5.6

SQL

CREATE TABLE classroom (
  classroom_id INT PRIMARY KEY,
  classroom_name VARCHAR(50) NOT NULL,
  capacity INT NOT NULL
);

CREATE TABLE student (
  student_id INT PRIMARY KEY,
  student_name VARCHAR(50) NOT NULL,
  classroom_id INT
);

INSERT INTO classroom (classroom_id, classroom_name, capacity) VALUES
  (1, 'Classroom A', 30),
  (2, 'Classroom B', 20),
  (3, 'Classroom C', 25);

INSERT INTO student (student_id, student_name, classroom_id) VALUES
  (1, 'Alice', 1),
  (2, 'Bob', 1),
  (3, 'Charlie', 2),
  (4, 'David', 2),
  (5, 'Eve', 3),
  (6, 'Frank', 3);


select
    classroom_name,
    group_concat(student_name)
from
    classroom
    inner join
        student
    on  classroom.classroom_id = student.classroom_id
group by
    classroom.classroom_id
;

結果

classroom_nameに紐づくstudent_nameが連結して出力されています

ちなみにCreateとInsertはChatGPTに用意してもらいました、便利ですね

classroom_name group_concat(student_name)
Classroom A Alice,Bob
Classroom B Charlie,David
Classroom C Frank,Eve

ChatGPTにSQLのCreate文を書いてもらいました

概要

SQLの記事を書こうと思ったのですが、Createを1から書くのが面倒だったのでChatGPTに書いてもらいました

お願いした内容

SQLでCreateを書いてください。教室テーブルとそれに紐づく生徒テーブルの二つです

出力結果

以下は、SQLで教室テーブルと生徒テーブルを作成するCREATE文の例です。教室テーブルと生徒テーブルをそれぞれ作成し、教室IDをキーとして生徒テーブルに外部キー制約を設定しています。

Copy code
-- 教室テーブルの作成
CREATE TABLE classroom (
  classroom_id INT PRIMARY KEY,
  classroom_name VARCHAR(50) NOT NULL,
  capacity INT NOT NULL
);

-- 生徒テーブルの作成
CREATE TABLE student (
  student_id INT PRIMARY KEY,
  student_name VARCHAR(50) NOT NULL,
  classroom_id INT,
  FOREIGN KEY (classroom_id) REFERENCES classroom (classroom_id)
);

上記の例では、教室テーブルには教室ID、教室名、収容人数の3つのカラムを定義し、生徒テーブルには生徒ID、生徒名、教室IDの3つのカラムを定義しています。生徒テーブルの教室IDには、外部キー制約を設定しています。この制約は、生徒テーブルの教室IDが教室テーブルの教室IDと一致するように制限するものです。

感想

classroom_idでリレーションを作りつつ、外部キー制約もつけてもらいました

今回は雑に指定しましたが、細かく指示したらテーブル設計もお手のものっぽいですね

SWRが検証中かisValidatingで判断可能

概要

SWRはデータ取得のための React Hooks ライブラリで、データを継続的かつ自動的に受け取ることができる
例えばAPIに渡すパラメータが変わったとき、手動でAPIを叩き直さなくてもSWRを使っていれば変わったときに自動でAPIを叩いてくれる

再検証中かどうかSWRのisValidatingで判断できる

const { data, error, isValidating, mutate } = useSWR(key, fetcher, options)

useSWRは、再検証中かどうかisValidatingというbooleanで教えてくれる

isValidating: リクエストまたは再検証の読み込みがある場合

↑公式より

isValidatingtrueのときは値の再検証が動いているので、それを元にloading処理を実装することができた

SWRのキャッシュ戦略との兼ね合いなどもあり、isValidatingをloading処理の判断として使うことが常に正しいかは限らないようだが、まだ調べきれてない

PhpStormのIndexingが毎回走って遅いので対処法メモ

概要

PhpStormにはインデックス作成の機能があります
公式によると以下のように色々な役割を担ってくれます

PhpStorm のインデックス作成は、IDE のコア機能であるコード補完、インスペクション、使用箇所の検索、ナビゲーション、構文のハイライト、リファクタリングを担当します。

インデックス作成 | PhpStorm

しかし、これがけっこう重いです
不要なファイルまでインデックス作成する必要はないので、node_modulesなどは除外したいです

対処

インデックス作成を除外する方法が公式で紹介されています

ファイルとフォルダーを除外する

ファイルを除外するには、プロジェクトツールウィンドウでファイルを右クリックし、ファイルタイプのオーバーライド | プレーンテキストを選択します。プレーンテキストファイルは Plain text アイコンでマークされています。


フォルダーを除外するには、プロジェクトツールウィンドウでフォルダーを右クリックし、ディレクトリをマーク | 除外を選択します。除外されたフォルダーは the Excluded root icon アイコンでマークされます。

自分はnode_modulesやインデックス作成されなくても困らない普段触らないディレクトリを除外することで、ある程度PhpStormが軽くなりました