久しぶりにLaravelを0から導入しようとしたら環境構築の方法が変わってた

はじめに

Laravel関連の記事を書くときにサクッとLaravelの環境を構築したいなと思い、環境構築の方法をブログにしようとしたところ、ドキュメントの記載が私が知ってる方法とは完全に別物になっていたので、初見で構築していきます。

いつから変わったのか

7.xのinstallationは私が知っている方法のようです。

https://laravel.com/docs/7.x#installing-laravel

8.xから構築方法が変わっていますね。

https://laravel.com/docs/8.x/installation#getting-started-on-macos

私が以前Laravelを導入したときはまだ7系だった気がするので、確かに最新の環境構築方法は使ったことなさそうです。

具体的には、7.xまではOSに直接インストールする方法が書かれていますが8.xからDocker(Laravel Sail)での開発を前提とする書き方になっていますね。

実際にやってみる

ここからは実際にLaravel 9の環境を構築していきます。

laravel.com

実行環境

  • MacBook Pro(14インチ、2021)
  • Docker Desktop 4.8.2 (79419)
    • Engine: 20.10.14
    • Compose: v2.5.1

Step1

curl -s "https://laravel.build/example-app" | bash

example-app のところがそのままディレクトリの名前になります。

途中でパスワードの入力を求められます。

> curl -s "https://laravel.build/example-app" | bash
Unable to find image 'laravelsail/php81-composer:latest' locally
latest: Pulling from laravelsail/php81-composer
eb9a2845ed12: Pull complete
1847f78773be: Pull complete
6ff48a7e6ce3: Pull complete
8d3c1623fb1a: Pull complete
ed88b3f807f2: Pull complete
53674ff3d8e3: Pull complete
c0d6d82777d8: Pull complete
4a5c216bb23d: Pull complete
f4a309a79847: Pull complete
0c21c0241293: Pull complete
a40c40f5805e: Pull complete
9001901c200e: Pull complete
cb38a3bbfb68: Pull complete
5393bb85a813: Pull complete
Digest: sha256:b27920b769ad8dc036a9ede3ae36f51a280d370ec7d125e77ca1924c9fa21dbb
Status: Downloaded newer image for laravelsail/php81-composer:latest

 _                               _
| |                             | |
| |     __ _ _ __ __ ___   _____| |
| |    / _` | '__/ _` \ \ / / _ \ |
| |___| (_| | | | (_| |\ V /  __/ |
|______\__,_|_|  \__,_| \_/ \___|_|

Warning: TTY mode requires /dev/tty to be read/writable.
    Creating a "laravel/laravel" project at "./example-app"
    Info from https://repo.packagist.org: #StandWithUkraine
    Installing laravel/laravel (v9.1.8)
      - Downloading laravel/laravel (v9.1.8)
      - Installing laravel/laravel (v9.1.8): Extracting archive
    Created project in /opt/example-app
    > @php -r "file_exists('.env') || copy('.env.example', '.env');"
    Loading composer repositories with package information
    Info from https://repo.packagist.org: #StandWithUkraine
    Updating dependencies
    Lock file operations: 108 installs, 0 updates, 0 removals
      - Locking brick/math (0.9.3)
(中略)
  - Locking webmozart/assert (1.10.0)
    Writing lock file
    Installing dependencies from lock file (including require-dev)
    Package operations: 108 installs, 0 updates, 0 removals
      - Downloading doctrine/inflector (2.0.4)
(中略)
      - Downloading spatie/laravel-ignition (1.2.3)
      - Installing doctrine/inflector (2.0.4): Extracting archive
(中略)
      - Installing spatie/ignition (1.3.1): Extracting archive
      - Installing spatie/laravel-ignition (1.2.3): Extracting archive
    66 package suggestions were added by new dependencies, use `composer suggest` to see details.
    Generating optimized autoload files
    > Illuminate\Foundation\ComposerScripts::postAutoloadDump
    > @php artisan package:discover --ansi
    Discovered Package: laravel/sail
Discovered Package: laravel/sanctum
Discovered Package: laravel/tinker
Discovered Package: nesbot/carbon
Discovered Package: nunomaduro/collision
Discovered Package: spatie/laravel-ignition
Package manifest generated successfully.
    78 packages you are using are looking for funding.
Use the `composer fund` command to find out more!
    > @php artisan vendor:publish --tag=laravel-assets --ansi --force
    No publishable resources for tag [laravel-assets].
Publishing complete.
    > @php artisan key:generate --ansi
    Application key set successfully.

Application ready! Build something amazing.
Sail scaffolding installed successfully.

Please provide your password so we can make some final adjustments to your application's permissions.

Password:

Thank you! We hope you build something incredible. Dive in with: cd example-app && ./vendor/bin/sail up

Step2

cd example-app && ./vendor/bin/sail up -d

これでdockerが立ち上がって環境構築完了です。

所感

簡単すぎて拍子抜けしました。頑張ってDockerfileやdocker-compose.ymlを書いていたのが懐かしいですね。

もちろん、実用レベルではこの方法で導入してもさらにDockerfileやdocker-compose.ymlなどを調整する必要がありそうですが、取り敢えず手元で何か試してみる程度であればこれで十分そうです。

注意点

現時点(2022-05-27)では https://laravel.build ではPHP8.1が入るようです。Laravel Sailも現状PHP 8.1までしか対応していないようです。

docker-composeでのMySQL環境構築(M1 Mac対応版)

はじめに

以前、docker-composeでMySQLのサーバーを建てる記事を書きました。

p5750-tech.hateblo.jp

しかし、これはM1 Macでは動作しません。今回はM1 Mac/Intel Macの両方で動作するようなdocker-compose.ymlに修正していきます。

まずはエラーの確認

M1 Macで以前の記事のコードを実行してみます(MySQLのバージョンだけ最新に変えてます)。

FROM mysql:8.0.28
RUN apt-get update && \
    apt-get install -y tzdata locales && \
    cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime && \
    echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && \
    locale-gen
ENV LANG en_US.UTF-8

CMD ["mysqld", "--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci"]
version: '3.8'
services:
  db:
    build: ./docker/mysql
    env_file:
      - .env
    ports:
      - "${MYSQL_PORT}:3306"
    volumes:
      - ./docker/mysql/data:/var/lib/mysql
$ docker compose build
[+] Building 0.9s (3/3) FINISHED
 => [internal] load build definition from Dockerfile                    0.0s
 => => transferring dockerfile: 32B                                     0.0s
 => [internal] load .dockerignore                                       0.0s
 => => transferring context: 2B                                         0.0s
 => ERROR [internal] load metadata for docker.io/library/mysql:8.0.28   0.8s
------
 > [internal] load metadata for docker.io/library/mysql:8.0.28:
------
failed to solve: failed to solve with frontend dockerfile.v0: failed to create LLB definition: no match for platform in manifest sha256:fc77d54cacef90ad3d75964837fad0f2a9a368b69e7d799665a3f4e90e600c2d: not found

ぱっと見て何がいけないのかわかりにくいので、docker runでミニマムに実行してみます。

$ docker run -e MYSQL_ROOT_PASSWORD=root -d mysql:8.0.28
Unable to find image 'mysql:8.0.28' locally
8.0.28: Pulling from library/mysql
docker: no matching manifest for linux/arm64/v8 in the manifest list entries.
See 'docker run --help'.

no matching manifest for linux/arm64/v8 in the manifest list entries.

とあるので、arm64/v8用のイメージが存在しないことによるエラーであるとわかります。

公式イメージをM1 Macで動かす

FROMのオプションに --platform=linux/x86_64 をつけることで、x86_64環境用のイメージを使います。

--- a/docker/mysql/Dockerfile
+++ b/docker/mysql/Dockerfile
@@ -1,4 +1,4 @@
-FROM mysql:8.0.28
+FROM --platform=linux/x86_64 mysql:8.0.28
 RUN apt-get update && \
     apt-get install -y tzdata locales && \

これで起動できました。

参考にした記事には5.7の場合はLinux Native AIO interfaceのエラーが発生するのでもうひと手間加える必要があると書いてありましたが、今(2022年4月26日) mysql:5.7.37 で試してみたところ普通に動作しました。不思議。

別のイメージを使う

ここまで使ってきた mysql は Docker 公式のイメージですが、 mysql/mysql-server というMySQL公式のイメージもあり、こちらは --platform オプションなしでも動作しました。

こちらはタイムゾーンロケール環境変数で変更できるので、 mysql イメージのときのように Dockerfile でtzdataやlocalesをインストールする必要はありません。これらを変更するだけであればDockerfileを書く必要がないのもメリットですね。

version: '3.8'
services:
  db:
    image: mysql/mysql-server:5.7.37-1.2.7-server
    environment:
      MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD}"
      MYSQL_DATABASE: "${MYSQL_DATABASE}"
      MYSQL_USER: "${MYSQL_USER}"
      MYSQL_PASSWORD: "${MYSQL_PASSWORD}"
      MYSQL_ROOT_HOST: "%"
      TZ: "Asia/Tokyo"
      LANG: "en_US.UTF-8"
    command: [ mysqld, --character-set-server=utf8mb4, --collation-server=utf8mb4_unicode_ci ]
    ports:
      - "${MYSQL_PORT}:3306"
    volumes:
      - ./docker/mysql/data:/var/lib/mysql

TZタイムゾーンLANG で言語を設定しています。 MYSQL_ROOT_HOST はrootに別のコンテナから接続するのに必要な設定です。

特にplatformを指定しなくても、これだけで正常に起動できます(5.7はx86_64しかないようですが、8.0にはarm64用のイメージもありました)。

おわりに

M1 MacMySQLのコンテナを立てる方法として

  • 公式イメージで --platform オプションを使う方法
  • mysql/mysql-server を使う方法

の2つを試しました。

MySQL5.7の場合はmysqlmysql/mysql-serverx86_64用イメージになるのでどちらでもいいかもしれませんが、MySQL8の場合は mysql/mysql-server を使うとハードウェアに合ったイメージを使うことができてよさそうです。

個人的には mysql/mysql-server だと Dockerfile 不要なのが棚ぼた的に嬉しかったのもあり、こちらに乗り換えています。

余談

私がM1 Macを購入した当時はM1でMySQLを使う記事があまり見つからなくて苦労したのですが、気づけば結構増えていますね。

参考文献

zenn.dev

gihyo.jp

MySQLのGenerated Columnsを活用する

はじめに

MySQLにはGenerated Columnsという便利な機能があります。今回はこちらについて、概要と基本的な使い方を解説します。

アプリケーションから使う場合の例はRailsとLaravelで示しています。

Generated Columnsとは

dev.mysql.com

ドキュメントの日本語訳が微妙だったので英語版を貼りますが、MySQL公式ドキュメントに

Values of a generated column are computed from an expression included in the column definition.

とあるように、Generated Columnsはカラム定義に他のカラムの値を含む式を使える、というものです。

余談ですが公式の使用例は私にはあまりいい例に思えませんね……。

使える式の制約

主なものを公式ドキュメントから抜粋します。例によって機械翻訳が微妙なので拙訳です。

  • リテラル決定論的組み込み関数、演算子を使用できます。
  • ストアドファンクションやユーザー定義関数は使用できません。
  • ストアドプロシージャ、ストアドファンクションのパラメータは使用できません。
  • 変数は使用できません。
  • サブクエリは使用できません。
  • 他のGenerated Columnsの値は、テーブル定義上で自身より先に登場するもののみ使用できます。Base Columns(=Generatedでないカラム)は定義順に関わらず参照できます。
  • AUTO_INCREMENTは使用できません。
  • AUTO_INCREMENTが設定されているカラムは参照できません。

決定論的組み込み関数は、同じデータに対してユーザーやコネクションに依らず同じ結果を返すものを指します。冪等性が必要ということですね。つまり NOW() などは使えません。

VIRTUAL と STORED

Generated ColumnsにはVIRTUALとSTOREDの2つのモードがあります(デフォルトはVIRTUAL)。

VIRTUAL

カラムの値は保存されず、レコードが読み込まれるときに値が計算されます。保存されないので当然このカラムはストレージを消費しません。

secondary index(つまりprimary key以外のindex)を張ることもできます。VIRTUALなGenerated Columnにsecondary indexが張られると、indexには計算結果が保存されます(insertやupdateで再計算されます)。indexが張られている場合、再計算のオーバーヘッドを考慮しても、VIRTUALの方が後述のSTOREDよりもreadのパフォーマンスがよくなることがあるようです。

STORED

カラムの値はinsertやupdateのときに計算され、保存されます。当然ストレージを消費します。

indexを張ることができます(primary keyも使える)。

ユースケース

Generated Columnを使えば

  • 複雑な条件をGenerated Columnに落とし込むことで、クエリを簡略化できる
    • indexを張れるので、当然パフォーマンスもよくなる
  • JSON型のカラムの中身の一部にindexを張れる

といったことが可能です。

たとえば私が実際にGenerated Columnsを採用したのは「日付のカラムがあるテーブルで、月と日だけで検索したい」というケースです。

Generated Columnsを使わない場合、クエリは以下のようになります。

select * from events where month(starts_at) = 4 and dayofmonth(starts_at) = 20;

これは当然フルテーブルスキャンになりますね。

一方、 month(starts_at)dayofmonth(starts_at) をGenerated Columnsとして定義しておけば、クエリは

select * from events where start_month = 4 and start_day = 20;

のようにシンプルになり、indexも張れるのでパフォーマンスも向上します。

定義の方法

書き方の比較のため2つGenerated Columnを作って一方をVIRTUAL、もう一方をSTOREDにします。

SQL

デフォルトはVIRTUALになるので、VIRTUALのときは省略できます。

CREATE TABLE `events` (
  `id` bigint(200) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL,
  `starts_at` datetime NOT NULL,
  `start_month` int(10) unsigned AS (MONTH(`starts_at`)),
  `start_day` int(10) unsigned AS (DAYOFMONTH(`starts_at`)) STORED,
  `created_at` datetime,
  `updated_at` datetime,
  PRIMARY KEY (`id`),
  KEY `start_month_day_index` (`start_month`, `start_day`)
);

Ruby on Rails

こちらもデフォルトは stored: false なので省略可能です。

class CreateEvents < ActiveRecord::Migration[6.1]
  def change
    create_table :events do |t|
      t.string :name, null: false
      t.datetime :starts_at, null: false
      t.virtual :start_month, type: :integer, as: "MONTH(starts_at)"
      t.virtual :start_day, type: :integer, as: "DAYOFMONTH(starts_at)", stored: true

      t.index [:start_month, :start_day]

      t.timestamps
    end
  end
end

Laravel

こちらは VIRTUAL と STORED でメソッドが違います

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateEventsTable extends Migration
{
    public function up()
    {
        Schema::create('events', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->dateTime('starts_at');
            $table->Integer('start_month')->virtualAs('MONTH(starts_at)');
            $table->integer('start_day')->storedAs('DAYOFMONTH(starts_at)');
            $table->dateTime('created_at')->nullable();
            $table->dateTime('updated_at')->nullable();
            $table->index(['start_month', 'start_day']);
        });
    }

    public function down()
    {
        Schema::dropIfExists('events');
    }
}

実験

上に例示したスキーマでテーブルを作成し、実際に使ってみます。

まずはinsert。

mysql> insert into events (name, starts_at, created_at, updated_at) values ('foo', '2022-04-21 12:00:00', now(), now());
Query OK, 1 row affected (0.03 sec)

mysql> select * from events;
+----+------+---------------------+-------------+-----------+---------------------+---------------------+
| id | name | starts_at           | start_month | start_day | created_at          | updated_at          |
+----+------+---------------------+-------------+-----------+---------------------+---------------------+
|  1 | foo  | 2022-04-21 12:00:00 |           4 |        21 | 2022-04-20 18:42:20 | 2022-04-20 18:42:20 |
+----+------+---------------------+-------------+-----------+---------------------+---------------------+
1 row in set (0.01 sec)

starts_atからstart_monthとstart_dayが計算されていることがわかります。

次はこれをupdateしてみます。

mysql> update events set starts_at = '2022-05-22 12:00:00', updated_at = now() where id = 1;
Query OK, 1 row affected (0.01 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> select * from events;
+----+------+---------------------+-------------+-----------+---------------------+---------------------+
| id | name | starts_at           | start_month | start_day | created_at          | updated_at          |
+----+------+---------------------+-------------+-----------+---------------------+---------------------+
|  1 | foo  | 2022-05-22 12:00:00 |           5 |        22 | 2022-04-20 18:42:20 | 2022-04-20 18:50:17 |
+----+------+---------------------+-------------+-----------+---------------------+---------------------+
1 row in set (0.01 sec)

ちゃんと更新されていますね。

次にGenerated Columnに値をセットするクエリを実行してみます。

mysql> insert into events (name, starts_at, start_month, created_at, updated_at) values ('foo', '2022-04-21 12:00:00', 6, now(), now());
ERROR 3105 (HY000): The value specified for generated column 'start_month' in table 'events' is not allowed.
mysql> update events set start_month = 6 where id = 1;
ERROR 3105 (HY000): The value specified for generated column 'start_month' in table 'events' is not allowed.

insert、updateともに無事エラーになりました。

最後にGenerated Columnで検索するときのパフォーマンスを確認してみます。

mysql> explain select * from events where start_month = 5 and start_day = 22;
+----+-------------+--------+------------+------+-----------------------+-----------------------+---------+-------------+------+----------+-------+
| id | select_type | table  | partitions | type | possible_keys         | key                   | key_len | ref         | rows | filtered | Extra |
+----+-------------+--------+------------+------+-----------------------+-----------------------+---------+-------------+------+----------+-------+
|  1 | SIMPLE      | events | NULL       | ref  | start_month_day_index | start_month_day_index | 10      | const,const |    1 |   100.00 | NULL  |
+----+-------------+--------+------------+------+-----------------------+-----------------------+---------+-------------+------+----------+-------+
1 row in set, 1 warning (0.01 sec)

indexが使われていることがわかります。

ちなみに、Generated Columnsが定義されている状態であれば、元の式で検索した場合でもMySQLがよしなにGenerated Columnsのindexで検索してくれます。

mysql> explain select * from events where month(starts_at) = 5 and dayofmonth(starts_at) = 22;
+----+-------------+--------+------------+------+-----------------------+-----------------------+---------+-------------+------+----------+-------+
| id | select_type | table  | partitions | type | possible_keys         | key                   | key_len | ref         | rows | filtered | Extra |
+----+-------------+--------+------------+------+-----------------------+-----------------------+---------+-------------+------+----------+-------+
|  1 | SIMPLE      | events | NULL       | ref  | start_month_day_index | start_month_day_index | 10      | const,const |    1 |   100.00 | NULL  |
+----+-------------+--------+------------+------+-----------------------+-----------------------+---------+-------------+------+----------+-------+
1 row in set, 1 warning (0.01 sec)

Generated Columnを削除してから同じクエリを確認してみると、もちろん下のようにALLで検索されます。

mysql> explain select * from events where month(starts_at) = 5 and dayofmonth(starts_at) = 22;
+----+-------------+--------+------------+------+---------------+------+---------+------+------+----------+-------------+
| id | select_type | table  | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra       |
+----+-------------+--------+------------+------+---------------+------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | events | NULL       | ALL  | NULL          | NULL | NULL    | NULL |    1 |   100.00 | Using where |
+----+-------------+--------+------------+------+---------------+------+---------+------+------+----------+-------------+
1 row in set, 1 warning (0.01 sec)

使用上の注意

上にmigrationのコードを示したとおり、RailsやLaravelなどのフレームワークでもGenerated Columnsを定義することはできるのですが、ActiveRecordやEloquent Modelのオブジェクトでは特に読み取り専用になっていたりはしませんし、当然値をセットしただけで再計算されたりもしません。

そのため、気をつけて使わないと以下のような問題が起こります。

不正なクエリの発行をフレームワークのレイヤーで抑止できない

Generated Columnに対して直接insertやupdateのクエリを発行するのをフレームワークレベルで抑止することはできません(DBがエラーを吐きます)

Rails

Rails内部でGenerated Columnと通常のカラムを区別していないので、 event.start_month = 5 の時点ではエラーが出ず、save時にMySQLがエラーを吐きます。

# rails c
Running via Spring preloader in process 92
Loading development environment (Rails 6.1.4.6)
irb(main):001:0>
irb(main):002:0> event = Event.new
=> #<Event id: nil, name: nil, starts_at: nil, start_month: nil, start_day: nil, created_at: nil, updated_at: nil>
irb(main):003:0> event.name = "foo"
=> "foo"
irb(main):004:0> event.starts_at = Time.parse("2022-05-22 12:00:00")
=> 2022-05-22 12:00:00 +0000
irb(main):005:0> event.start_month = 5
=> 5
irb(main):006:0> event.save
  TRANSACTION (0.5ms)  BEGIN
  Event Create (1.3ms)  INSERT INTO `events` (`name`, `starts_at`, `start_month`, `created_at`, `updated_at`) VALUES ('foo', '2022-05-22 12:00:00', 5, '2022-04-20 10:42:13.035857', '2022-04-20 10:42:13.035857')
  TRANSACTION (0.5ms)  ROLLBACK
Traceback (most recent call last):
        1: from (irb):6
ActiveRecord::StatementInvalid (Mysql2::Error: The value specified for generated column 'start_month' in table 'events' is not allowed.)

Laravel

※ Laravelの場合、この問題はGenerated Column以外でも普通に発生します。

# php artisan tinker
Psy Shell v0.11.2 (PHP 8.1.4 — cli) by Justin Hileman
>>> $event = new App\Models\Event();
=> App\Models\Event {#4796}
>>> $event->name = 'foo';
=> "foo"
>>> $event->starts_at = '2022-05-22 12:00:00';
=> "2022-05-22 12:00:00"
>>> $event->start_month = 5;
=> 5
>>> $event->save();
Illuminate\Database\QueryException with message 'SQLSTATE[HY000]: General error: 3105 The value specified for generated column 'start_month' in table 'events' is not allowed. (SQL: insert into `events` (`name`, `starts_at`, `start_month`, `updated_at`, `created_at`) values (foo, 2022-05-22 12:00:00, 5, 2022-04-20 19:10:13, 2022-04-20 19:10:13))'

アプリケーションでは自動計算されない

レコードを更新した後、再度selectしない限りGenerated Columnの更新はインスタンスには反映されないので、アプリケーションロジックでGenerated Columnを参照すると更新前の値を参照してしまう可能性があります。

Rails

# rails c
Running via Spring preloader in process 92
Loading development environment (Rails 6.1.4.6)
irb(main):001:0> event = Event.find(1)
=> #<Event id: 1, name: "foo", starts_at: "2022-05-22 21:00:00.000000000 +0900", start_month: 5, start_day: 22, created_at: "2022-04-20 19:44:58.337180000 +0900", updated_at: "2022-04-20 19:44:58.337180000 +0900">
irb(main):002:0> event.starts_at
=> Sun, 22 May 2022 21:00:00.000000000 JST +09:00
irb(main):003:0> event.starts_at = Time.parse("2022-04-23 13:00:00")
=> 2022-04-23 13:00:00 +0000
irb(main):004:0> event.save               # この時点でDBのstart_monthは4に更新されている
=> true
irb(main):005:0> event.start_month        # ActiveRecordインスタンスではstart_monthは5のまま
=> 5
irb(main):006:0> event.starts_at          # もちろん、starts_atは更新されている
=> Sat, 23 Apr 2022 22:00:00.000000000 JST +09:00
irb(main):007:0> event.reload
=> #<Event id: 1, name: "foo", starts_at: "2022-04-23 22:00:00.000000000 +0900", start_month: 4, start_day: 23, created_at: "2022-04-20 19:44:58.337180000 +0900", updated_at: "2022-04-20 19:45:49.199031000 +0900">
irb(main):008:0> event.start_month        # reloadするとModelインスタンスも更新される
=> 4
irb(main):009:0> event.starts_at
=> Sat, 23 Apr 2022 22:00:00.000000000 JST +09:00

Laravel

# php artisan tinker
Psy Shell v0.11.2 (PHP 8.1.4 — cli) by Justin Hileman
>>> $event = App\Models\Event::find(1);
=> App\Models\Event {#4810
     id: 1,
     name: "foo",
     starts_at: "2022-05-22 12:00:00",
     start_month: 5,
     start_day: 22,
     created_at: "2022-04-20 19:18:16",
     updated_at: "2022-04-20 19:18:16",
   }
>>> $event->starts_at = "2022-04-23 13:00:00"
=> "2022-04-23 13:00:00"
>>> $event->save();             # この時点でDBのstart_monthは4に更新されている
=> true
>>> $event->start_month;        # Modelインスタンスではstart_monthは5のまま
=> 5
>>> $event->starts_at;          # もちろん、starts_atは更新されている
=> "2022-04-23 13:00:00"
>>> $event->refresh();
=> App\Models\Event {#4810
     id: 1,
     name: "foo",
     starts_at: "2022-04-23 13:00:00",
     start_month: 4,
     start_day: 23,
     created_at: "2022-04-20 19:18:16",
     updated_at: "2022-04-20 19:19:00",
   }
>>> $event->starts_at;
=> "2022-04-23 13:00:00"
>>> $event->start_month;        # refresh()するとModelインスタンスも更新される
=> 4

おわりに

今回はMySQLのGenerated Columnsについて、基本的な使い方と注意点をまとめました。

使い所は結構限られるとは思いますが、有効に使えればかなり便利なので機会があれば是非使ってみてください。

参考文献

MySQL公式ドキュメント(日本語)

MySQL公式ドキュメント(英語)

Goで暗号化する備忘録(AES-128-CBC)

※ この記事は、2021年3月にQiitaに投稿した記事を移植したものです。

はじめに

Goで暗号化・復号の処理を書いたときに(主にパディングで)手こずったのでメモ。

暗号化

流れ

AES-128-CBCによる暗号化は以下のような流れで行う。

  • 秘密鍵 (128 bit) の用意
  • IV (128 bit) の生成
  • パディング
  • 暗号化

秘密鍵 (128 bit) の用意

省略。

IV (128 bit) の生成

IVは長さ16のランダムな byte 配列になる。この長さはAESにおけるブロックサイズなので、定数 aes.BlockSize が使える。

import (
    "crypto/aes"
    "crypto/rand"
)

func GenerateIV() ([]byte, error) {
    iv := make([]byte, aes.BlockSize)
    if _, err := rand.Read(iv); err != nil {
        return nil, err
    }
    return iv, nil
}

パディング

平文 []byte の長さが16の倍数ではない可能性がある場合、16の倍数にするためにパディングする必要がある(パディングする場合は16の倍数でも16 byte足す必要がある)。 PKCS#7は l bytes のパディングを行うときに l 個の l を追加する方式。

import (
    "bytes"
    "crypto/aes"
)

func Pkcs7Pad(data []byte) []byte {
    length := aes.BlockSize - (len(data) % aes.BlockSize)
    trailing := bytes.Repeat([]byte{byte(length)}, length)
    return append(data, trailing...)
}

暗号化

ここまでで用意した GenerateIV()Pkcs7Pad() を使って実際に暗号化処理を行う。

import (
    "crypto/aes"
    "crypto/cipher"
)

func Encrypt(data []byte, key []byte) (iv []byte, encrypted []byte, err error) {
    iv, err = GenerateIV()
    if err != nil {
        return nil, nil, err
    }
    block, err := aes.NewCipher(key)
    if err != nil {
        return nil, nil, err
    }
    padded := Pkcs7Pad(data)
    encrypted = make([]byte, len(padded))
    cbcEncrypter := cipher.NewCBCEncrypter(block, iv)
    cbcEncrypter.CryptBlocks(encrypted, padded)
    return iv, encrypted, nil
}

復号

復号するときも BlockMode 構造体を作って CryptBlocks() を呼ぶのは暗号化時と同じ。 パディングの削除は、最後の要素をintキャストした個数分だけ削ればよい。

import (
    "crypto/aes"
    "crypto/cipher"
)

func Pkcs7Unpad(data []byte) []byte {
    dataLength := len(data)
    padLength := int(data[dataLength-1])
    return data[:dataLength-padLength]
}

func Decrypt(data []byte, key []byte, iv []byte) ([]byte, error) {
    block, err := aes.NewCipher(key)
    if err != nil {
        return nil, err
    }
    decrypted := make([]byte, len(data))
    cbcDecrypter := cipher.NewCBCDecrypter(block, iv)
    cbcDecrypter.CryptBlocks(decrypted, data)
    return Pkcs7Unpad(decrypted), nil
}

動作確認

opensslコマンドと比較する。

import (
    "encoding/base64"
    "encoding/hex"
    "fmt"
)

func main() {
    text := "this is test message"
    fmt.Println("Input:", text)
    plain := []byte(text)
    keyString := "645E739A7F9F162725C1533DC2C5E827"
    key, _ := hex.DecodeString(keyString)
    fmt.Println("Key:", keyString)
    iv, encrypted, _ := Encrypt(plain, key)
    fmt.Println("IV:", hex.EncodeToString(iv))
    fmt.Println("Encrypted:", base64.StdEncoding.EncodeToString(encrypted))
    decrypted, _ := Decrypt(encrypted, key, iv)
    fmt.Printf("Decrypted: %s", decrypted)
}
$ go run main.go
Input: this is test message
Key: 645E739A7F9F162725C1533DC2C5E827
IV: 71fbf00383b6e214dc08b8b94183cf30
Encrypted: z41UoV93PIS0OYElzUd7nwA9TO6XxSDlf9N+P4nFuJw=
Decrypted: this is test message

opensslコマンド。echo-n をつけないと改行で結果がずれる。

$ echo -n 'this is test message' | openssl aes-128-cbc -e -base64 -nosalt -K 645E739A7F9F162725C1533DC2C5E827 -iv 71fbf00383b6e214dc08b8b94183cf30
z41UoV93PIS0OYElzUd7nwA9TO6XxSDlf9N+P4nFuJw=

同じ結果が得られた。

参考文献

docker-composeでのMySQL環境構築(タイムゾーン、UTF-8対応)

※ この記事は、2021年1月にQiitaに投稿した記事を移植したものです。

2022年4月26日追記

M1 Mac対応版の記事を書きました

p5750-tech.hateblo.jp

はじめに

ローカル開発環境を立てるためにMySQLのコンテナを用意することがよくあるが、(主にタイムゾーンUTF-8の対応で)毎回同じようなところで詰まって調べていたので、備忘録も兼ねて自分的な「いつものやつ」を残しておく。

結論

github.com

ディレクトリ構成

./docker/myqsl/data をvolumeとすることでDBのデータを永続化する。 なお、初回起動時にこのディレクトリに余計なファイルが入っているとコケることがあるので注意。 .gitkeep は置いておいて大丈夫だった。

.
├── docker
│   └── mysql
│       ├── data
│       │   └── .gitkeep
│       └── Dockerfile
├── .env
├── .gitignore
└── docker-compose.yml

各ファイルの内容

Dockerfile

MySQLのバージョンはお好みで。 タイムゾーンAsia/Tokyo に、ロケールen_US.UTF-8 にしている。 日本語にしたければ ja_JP.UTF-8 にする。

FROM mysql:8.0.23
RUN apt-get update && \
    apt-get install -y tzdata locales && \
    cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime && \
    echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && \
    locale-gen
ENV LANG en_US.UTF-8

CMD ["mysqld", "--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci"]

.env

適宜書き換える。

MYSQL_ROOT_PASSWORD=root_password
MYSQL_DATABASE=sample
MYSQL_USER=sample_user
MYSQL_PASSWORD=sample_password

MYSQL_PORT=3306
ADMINER_PORT=8080

docker-compose.yml

version は特に理由がなければ最新でよさそう。使えるものは公式のドキュメントでわかる。 Adminerはお好みで入れる。個人的にはローカル用なら入れといて損はしないかなと思っている。

version: '3.8'
services:
  db:
    build: ./docker/mysql
    env_file:
      - .env
    ports:
      - "${MYSQL_PORT}:3306"
    volumes:
      - ./docker/mysql/data:/var/lib/mysql
  adminer:
    image: adminer:4.7.8-standalone
    ports:
      - "${ADMINER_PORT}:8080"

.gitignore

データ永続化用の docker/mysql/data と、認証情報が含まれる .env は必ずignoreする。 .idea はJetBrainsのIDE用。VSCodeなら .vscode になる。

docker/mysql/data
.env
.idea

コマンド

Dockerの基本操作は本稿の趣旨ではないので割愛する。

DBを抹消して作り直すとき

ゴミが残るので、必ず先に down する。

# コンテナを止めて削除する
docker-compose down

# volumeでマウントしているデータをディレクトリごと消す
rm -rf ./docker/mysql/data

# ディレクトリを作り直す
mkdir ./docker/mysql/data

# gitに.gitkeepをコミットしているなら作り直す。
# ※差分出てるはずなのでgit resetとかで復旧してもよい。
touch ./docker/mysql/data/.gitkeep

# 再度立ち上げる。Dockerfileに変更が入っていたら --build も必要
docker-compose up -d

リンク集