スタイル・エッジ・グループ技術ブログ

士業集客支援/コンサルティングのスタイル・エッジ・グループのエンジニアによるブログです。

LocalStackを使ってLambda関数をローカルで検証してみよう


こんにちは。morizoです。
もともとwebアプリケーションエンジニアとして入社しましたが、ココ一年くらいはAWSを利用したサーバ運用やセキュリティ面の強化に取り組んでいます。

さて、チーム内で共有してる手順書をもとに日頃の運用業務を進めていると
作成日の古い手順書がUIの変更に追いついておらず、混乱することが多々あります。
近頃はメンバー内でも「AWS CLIつかっていこう」という機運が高まっておりますが、
あまり気楽に試せる環境がないことが悩みのタネになっています。
そこで、今回はLocalStackを利用してローカル環境でAWSサービスを動かしてみようと思います。
無料版だとCLIの操作のみに制限されているため、CLI操作の練習にもなりますね。
※執筆中にちょうど正式版がリリースされました

LocalStack導入とその動作検証

LocalStackのインストールと起動

githubで公開されていますのでこちらからcloneしてきます。
今回はお試しということもあり、Docker経由で起動します。
また、clone後にdocker-compose.ymlを一部編集しました。(編集箇所にコメントを追加しています)
環境変数についてはこちらにまとまっています。

version: "3.8"

services:
  localstack:
    container_name: "${LOCALSTACK_DOCKER_NAME-localstack_main}"
    image: localstack/localstack
    ports:
      - "127.0.0.1:4566:4566"            # LocalStack Gateway
      - "127.0.0.1:4510-4559:4510-4559"  # external services port range
      - "127.0.0.1:53:53"                # DNS config (only required for Pro)
      - "127.0.0.1:53:53/udp"            # DNS config (only required for Pro)
      - "127.0.0.1:443:443"              # LocalStack HTTPS Gateway (only required for Pro)
    environment:
      - DEBUG=${DEBUG-}
      - PERSISTENCE=${PERSISTENCE-}
     # Lambda関数実行のたびに同じコンテナを再利用する
      - LAMBDA_EXECUTOR=docker-reuse
      - LOCALSTACK_API_KEY=${LOCALSTACK_API_KEY-}  # only required for Pro
      - DOCKER_HOST=unix:///var/run/docker.sock
     # Lambda関数をコンテナにマウントすることで処理が早くなる場合がある(ローカル環境向け)
      - LAMBDA_REMOTE_DOCKER=0
    volumes:
      - "${LOCALSTACK_VOLUME_DIR:-./volume}:/var/lib/localstack"
      - "/var/run/docker.sock:/var/run/docker.sock"

どのようなことができる?

ここから実際にコンテナに入って作業をしてみます。
コンテナ内ではawslocalコマンドが使用できるので簡単のために使用しています。
まずはs3バケットを作成してみました。
※以後コマンドの行頭に実行環境を補足しています

(host) docker exec -it localstack_main bash

(container) awslocal s3api create-bucket --bucket morizo-s3
{
    "Location": "/morizo-s3"
}
(container) awslocal s3api list-buckets
{
    "Buckets": [
        {
            "Name": "morizo-s3",
            "CreationDate": "2022-07-ddThh:00:00.000Z"
        }
    ],
    "Owner": {
        "DisplayName": "webfile",
        "ID": "XXXXXXXXXXXXXXXXXXXXXXXXXXX"
    }
}

できていますね。
続いてLambdaを実行してみます。
以下ファイルをローカルで用意してコンテナ内にコピーしました。

def handler(event, context):
    print(event)
    return 'localstackはじめました'

作成したファイルをzip形式に圧縮して、Lambda関数を作成します。
※roleに指定する値は任意で問題ありません。

(container) zip localstack.zip localstack.py
(container) awslocal lambda create-function --function-name localstack --runtime python3.8 --role morizo --handler localstack.handler --zip-file fileb://localstack.zip
{
    "FunctionName": "localstack",
    "FunctionArn": "arn:aws:lambda:us-east-1:000000000000:localstack",
    "Runtime": "python3.8",
    "Role": "morizo",
    "Handler": "handler.handler",
    ~
    "LastUpdateStatus": "Successful",
    "PackageType": "Zip",
    "Architectures": [
        "x86_64"
    ]
}

Successfulとあるのでどうやらうまくいったようです。
それでは実行してみます。

(container) awslocal lambda invoke --function-name localstack output.log
{
    "StatusCode": 200,
    "LogResult": "",
    "ExecutedVersion": "$LATEST"
}

200で返ってきました。
中身をみてみましょう。

(container) cat output.log
"localstack\u306f\u3058\u3081\u307e\u3057\u305f"
(container) echo -e `cat output.log`
"localstackはじめました"

unicode表示になっていたのでcatだけではうまく表示できませんでしたが、
コード内でreturnした文字列がファイルに出力されていることを確認できました。
簡単な関数なら充分やっていけそうです。

ほかのサービスとも組み合わせてみたい

固定の文字列を返すだけでは少し味気ないので、
ほかのサービスを組み合わせた処理を書いてみます。
「cloudwatch logsに保管したログをs3にエクスポート」してみましょう。

まずはlogsにlog groupとlog streamを追加します。

(container) awslocal logs create-log-group --log-group-name morizo-logs
(container) awslocal logs create-log-stream --log-group-name morizo-logs --log-stream-name morizo-logs-stream

logsに追加するログはこちらです。

{
  "logEvents":[
    {
      "timestamp": 1658746080711,
      "message": "Example Event 1"
    },
    {
      "timestamp": 1658746081711,
      "message": "Example Event 2"
    },
    {
      "timestamp": 1658746082711,
      "message": "Example Event 3"
    }
  ]
}

こちらのログをlogsに作成したlogstreamに追加します。

(container) awslocal logs put-log-events --log-group-name morizo-logs --log-stream-name morizo-logs-stream --cli-input-json file://logs.json
(container) awslocal logs get-log-events --log-group-name  morizo-logs --log-stream-name morizo-logs-stream
{
    "events": [
        {
            "timestamp": 1658746080711,
            "message": "Example Event 1",
            "ingestionTime": 1658758594869
        },
        {
            "timestamp": 1658746081711,
            "message": "Example Event 2",
            "ingestionTime": 1658758594869
        },
        {
            "timestamp": 1658746082711,
            "message": "Example Event 3",
            "ingestionTime": 1658758594869
        }
    ],
    "nextForwardToken": "f/00000000000000000000000000000000000000000000000000000001",
    "nextBackwardToken": "b/00000000000000000000000000000000000000000000000000000000"
}

こちらも無事に追加できました。
ではこのログをs3にエクスポートするLambda関数を実行してみましょう。
以下pythonコードを用意しました。

import boto3

def handler(event, context):
    client = boto3.client('logs', endpoint_url='http://host.docker.internal:4566', region_name = "us-east-1", aws_access_key_id='dummy', aws_secret_access_key='dummy')
    return client.create_export_task(
        logGroupName      = 'morizo-logs',
        fromTime          = 1658746080000,
        to                = 1658750400000,
        destination       = 'morizo-s3',
        destinationPrefix = 'localstack'
    )

挙動確認用に使用したLambda関数と同様にzipファイルを作成して登録・実行します

(container) awslocal lambda create-function --function-name create_export_task --runtime python3.8 --role morizo --handler create_export_task.handler --zip-file fileb://create_export_task.zip
(container) awslocal lambda invoke --function-name create_export_task output.log
(container) cat output.log | jq
{
  "ResponseMetadata": {
    "HTTPHeaders": {
      "access-control-allow-headers": "authorization,cache-control,content-length,content-md5,content-type,etag,location,x-amz-acl,x-amz-content-sha256,x-amz-date,x-amz-request-id,x-amz-security-token,x-amz-tagging,x-amz-target,x-amz-user-agent,x-amz-version-id,x-amzn-requestid,x-localstack-target,amz-sdk-invocation-id,amz-sdk-request",
      "access-control-allow-methods": "HEAD,GET,PUT,POST,DELETE,OPTIONS,PATCH",
      "access-control-allow-origin": "*",
      "access-control-expose-headers": "etag,x-amz-version-id",
      "content-length": "50",
      "content-type": "application/x-amz-json-1.1",
      "date": "aaa, dd Jul 2022 00:00:00 GMT",
      "server": "amazon.com, hypercorn-h11"
    },
    "HTTPStatusCode": 200,
    "RetryAttempts": 0
  },
  "taskId": "XXXXXXXXXXXXXXXXXXXXXXXXX"
}

ステータスコード200とtaskidが返ってきました!
確認のためにs3バケットをみてみます。

(container) awslocal s3 ls s3://morizo-s3 --recursive # 表示なし

おや、ステータスは200にはなっているけどオブジェクトがありません。
作成していないロググループやバケット名を指定するとエラーになるため、
コマンド自体は成功していますがログファイルがS3にエクスポートされていないようです。
こちらについては次回以降改めて挑戦してみます。

まとめ

さらなる調査は必要ですが、LocalStackを使ってDockerコンテナ上でLambda関数を実行するところまでできました。
無料版でも利用できるAWSサービスが数多くありますし、
業務で実際に活用できるLambda関数の動作確認をローカル環境でも一通りできるのではないでしょうか?
正式版リリースによって今後更に勢いづいてくるかと思いますので
更新情報を追っていきつつ、よりよいローカルでの開発・検証環境構築も進めていきたいです。

おわりに

☆スタイル・エッジ・グループでは、一緒に働く仲間を募集しています☆
もし興味を持っていただけましたら、採用サイト↓も覗いてみてください!
recruit.styleedge-labo.co.jp

AWS CDK入門|準備からサブネットを分けるまで

はじめに

こんにちは!子育てエンジニアのganpeです。毎日暑いですね。
夏の太陽のせいでしょうか。インフラとは一定の距離を保ってきた私の中でもIaC熱がふつふつと沸き上がってきたので、今回はAWS CDK (Cloud Development Kit) について書いてみたいと思います。

AWS CDKとは

AWS クラウド開発キット (AWS CDK) は、最新のプログラミング言語を使用してクラウドインフラストラクチャをコードとして定義し、それを AWS CloudFormation を通じてデプロイするためのオープンソースのソフトウェア開発フレームワークです。

引用元|https://aws.amazon.com/cdk/faqs/

つまりCloudFormation (以下、CFn) のようにJSONYAMLで構成を記述するのではなく、プログラミング言語*1によってAWSリソースを定義・デプロイできるということですね。
プログラミング言語そのものの力によって、クラスの継承ができたりテストができたりするのはもちろんのこと、さらに後述のConstruct LibraryによってCFnより少ない記述量でベストプラクティスが利用できることも魅力です。

AWS CDKは2019年7月にv1が一般公開され、そして昨年12月にはv2が利用可能になりました。
2022年4月には日本でもAWS CDKのカンファレンスが開催されるなど、盛り上がりを見せています。
ワクワクしてきましたね。

まずはやってみよう

早速ですが、次のようなよくある構成を作成してみたいと思います。
1度では書ききれない予感がしますので、今回はサブネットまでをご紹介します。

構成図(未来予想図)
構成図(未来予想図)
今回のゴール
今回のゴール

環境構築

事前準備

AWS CDKの利用にはNode.js*2とnpmが必要です。

AWS CDKは内部でAWS CLIをしているので、AWS CLIのデフォルトプロファイルにデフォルトリージョン、アクセスキー、シークレットキーをそれぞれ設定しておきます。

[default]
region=MYREGION
[default]
aws_access_key=MYACCESSKEY
aws_secret_access_key=MYSECRETKEY

参考|https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html#getting_started_prerequisites

AWS CDKインストール

$ npm install -g aws-cdk
$ cdk --version
2.28.1 (build d035432)

参考|https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html#getting_started_install

プロジェクトの作成

ディレクトリを作成し、プログラミング言語を指定してCDKプロジェクトを作成します。(csharpとしたい気持ちをぐっと堪えてtypescriptと叩きました)
このとき、ディレクトリ名がそのままCDKのプロジェクト名 (CFnテンプレート名) になるので注意が必要です。

$ mkdir my-first-cdk-app && cd my-first-cdk-app
$ cdk init app --language typescript
Applying project template app for typescript
# Welcome to your CDK TypeScript project

This is a blank project for CDK development with TypeScript.

The `cdk.json` file tells the CDK ToolKit how to execute your app.

## Useful commands

* `npm run build`   compile typescript to js
* `npm run watch`   watch for changes and compile
* `npm run test`    perform the jest unit tests
* `cdk deploy`      deploy this stack to your default AWS account/region
* `cdk diff`        compare deployed stack with current state
* `cdk synth`       emits the synthesized CloudFormation template

Initializing a new git repository...
Executing npm install...
✅ All done!

参考|https://docs.aws.amazon.com/cdk/v2/guide/work-with-cdk-typescript.html#typescript-newproject


作りたてのプロジェクトはこのような構成でした。

my-first-cdk-app
├── bin
│   ├── my-first-cdk-app.ts
├───lib
│   ├── my-first-cdk-app-stack.ts
├── node_modules
│   ├── ~省略~
│   ├── aws-cdk-lib
│   ├── ~省略~
├── test
│   ├── my-first-cdk-app.test.ts
├── .gitignore
├── .npmignore
├── cdk.json
├── jest.config.js
├── package-lock.json
├── package.json
├── README.md
└── tsconfig.json    

後述のConstruct Library (aws-cdk-lib) もこのときインストールされています。
ユニットテスト用にjestも導入済みの状態です。便利ですね。

binの下にmy-first-cdk-app.tsというファイルがあり、こちらがエントリポイントになっているようです。


my-first-cdk-app.ts

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { MyFirstCdkAppStack } from '../lib/my-first-cdk-app-stack';

const app = new cdk.App();
new MyFirstCdkAppStack(app, 'MyFirstCdkAppStack', {
~省略~
});

appではstackを読み込んでいて、stackはlib/my-first-cdk-app-stack.tsに定義されているようです。

CDKプロジェクトの構造

appやstackという単語が登場したので少し補足します。
CDKプロジェクトはapp, stack, constructの3階層で構成されます。
stackというのはCFnのstackに相当し、デプロイの単位になります。
stackの中ではconstructを使ってリソースを定義します。
appはCDKのプロジェクトファイルのようなもので、stackどうしの依存関係を定義します。

CDKプロジェクト構成
CDKプロジェクトの構造

VPCの作成

今はstack1つだけなのでappは無視してstackのソースに手を加えていきます。
VPCを追加してみましょう。

my-first-cdk-app-stack.ts

import { Stack, StackProps } from 'aws-cdk-lib';
import { Vpc } from 'aws-cdk-lib/aws-ec2';
import { Construct } from 'constructs';

export class MyFirstCdkAppStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const vpc = new Vpc(this, 'MyVpc1'); // <- 第3引数のpropsは指定なし=デフォルト設定
  }
}

参考|https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2.Vpc.html

え?これだけ?と思われるかもしれませんが、まずはこのnew Vpc()によってどのようなリソースが作成されるのかを見てみることにします。

CFnテンプレートへのコンパイルとデプロイ

コンパイル(synthesize)

$ cdk synth

参考|https://docs.aws.amazon.com/cdk/v2/guide/work-with-cdk-typescript.html#typescript-running


cdk synthコマンドによってcdk.outディレクトリの中にCFnテンプレート (MyFirstCdkAppStack.template.json) が生成されます。
AWS CDKはこのCFnテンプレートをCFnにアップロードすることでデプロイを実現しているわけですね。なるほど。

ちなみにTerraformユーザーであれば、cdktf (CDK for Terraform)*3を使うことでCFnテンプレートの代わりにTerraformのHCLステートファイルを出力することもできます。
CFnやTerraformなどこれまでの資産を活かしつつ利用できるのがCDKの嬉しいところですね。


さて、先程のコードから生成されたテンプレートを見てみましょう。

$ grep "Type" cdk.out/MyFirstCdkAppStack.template.json | sort
   "Type": "AWS::CDK::Metadata",
   "Type": "AWS::EC2::EIP",
   "Type": "AWS::EC2::EIP",
   "Type": "AWS::EC2::InternetGateway",
   "Type": "AWS::EC2::NatGateway",
   "Type": "AWS::EC2::NatGateway",
   "Type": "AWS::EC2::Route",
   "Type": "AWS::EC2::Route",
   "Type": "AWS::EC2::Route",
   "Type": "AWS::EC2::Route",
   "Type": "AWS::EC2::RouteTable",
   "Type": "AWS::EC2::RouteTable",
   "Type": "AWS::EC2::RouteTable",
   "Type": "AWS::EC2::RouteTable",
   "Type": "AWS::EC2::Subnet",
   "Type": "AWS::EC2::Subnet",
   "Type": "AWS::EC2::Subnet",
   "Type": "AWS::EC2::Subnet",
   "Type": "AWS::EC2::SubnetRouteTableAssociation",
   "Type": "AWS::EC2::SubnetRouteTableAssociation",
   "Type": "AWS::EC2::SubnetRouteTableAssociation",
   "Type": "AWS::EC2::SubnetRouteTableAssociation",
   "Type": "AWS::EC2::VPC",
   "Type": "AWS::EC2::VPCGatewayAttachment",
   "Type": "AWS::SSM::Parameter::Value<String>",

VPCだけではなく、インターネットゲートウェイ、Elastic IPx2、NATゲートウェイx2、サブネットx4(上記からは分かりづらいですが、パブリックx2、プライベートx2)、ルートテーブルなどがもりもり生成されていることが分かります。すごい。。
感覚的には、AWSマネジメントコンソールからVPCを作成するときの「VPCのみ/VPCなど」の選択肢で「VPCなど」を選んだときと似ていますね。

一度このままデプロイまで進めてみたいと思います。

Bootstrap

デプロイに移る前にBootstrapを実行しておきます。
Bootstrapでは、S3バケットやIAMロールなど、デプロイの際に必要になる各種リソースをまとめて準備します。
アカウント、リージョンごとに1度だけ実行すればOKです。

$ cdk bootstrap aws://MYACCOUNTNUMBER/MYREGION
~省略~
  ✅  Environment aws://MYACCOUNTNUMBER/MYREGION bootstrapped.

参考|https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html#getting_started_bootstrap

Bootstrapによって作成されたAWSリソースはこちらです。

  • CFn > Stack > CDKToolKit
    • SSMパラメータ: CdkBootstrapVersion
    • IAMポリシー: FilePublishingRoleDefaultPolicy, ImagePublishingRoleDefaultPolicy
    • IAMロール: CloudFormationExecutionRole, DeploymentActionRole, FilePublishingRole, ImagePublishingRole, LookupRole
    • S3バケット: StagingBucket
    • S3バケットポリシー: StagingBucketPolicy
    • ECRリポジトリ: ContainerAssetsRepository

デプロイ

$ cdk deploy 

✨  Synthesis time: 7.61s

MyFirstCdkAppStack: deploying...
~省略~
  ✅  MyFirstCdkAppStack

✨  Deployment time: 169.28s

Stack ARN:
arn:aws:cloudformation:以下略

✨  Total time: 176.89s

参考|https://docs.aws.amazon.com/cdk/v2/guide/work-with-cdk-typescript.html#typescript-running

VPCとその他諸々が無事にデプロイされました!

なお、デプロイ対象のstackがapp内に複数ある場合は「cdk deploy stack1 stack2」のようにstack名を指定します。

テストを書く

先ほどのデフォルトのVPCは、私が欲しい形になっていませんでした。
サブネットを分けてみたいと思いますが、せっかくユニットテストができるのでテストを先に書いておきます。
テスト名を日本語で書いている理由は後述します。

import * as cdk from 'aws-cdk-lib';
import { Match, Template } from 'aws-cdk-lib/assertions';
import * as MyFirstCdkApp from '../lib/my-first-cdk-app-stack';

const app = new cdk.App();
const stack = new MyFirstCdkApp.MyFirstCdkAppStack(app, 'MyTestStack');
const template = Template.fromStack(stack);

const getResourceIds = (type: string, props?: any): string[] =>
  Object.keys(template.findResources(type, props ? { Properties: props } : {}));

const getResourceId = (type: string, props?: any): string => {
  const resourceIds = getResourceIds(type, props);
  if (resourceIds.length !== 1)
    throw new Error('リソースがないか1つに特定できません');
  return resourceIds[0];
};

test('VPCがcidr10.0.0.0/16で1つ作成されること', () => {
  template.resourceCountIs('AWS::EC2::VPC', 1);
  template.hasResourceProperties('AWS::EC2::VPC', {
    CidrBlock: '10.0.0.0/16',
  });
});

// 後で使うためにVPCのリソースIDを取得しておく
const vpcId = getResourceId('AWS::EC2::VPC');

test('VPCにインターネットゲートウェイが1つ作成されること', () => {
  template.resourceCountIs('AWS::EC2::InternetGateway', 1);
  template.hasResourceProperties('AWS::EC2::VPCGatewayAttachment', {
    VpcId: { Ref: vpcId },
    InternetGatewayId: { Ref: getResourceId('AWS::EC2::InternetGateway') },
  });
});

test('サブネットが6つ作成されること', () => {
  template.resourceCountIs('AWS::EC2::Subnet', 6);
});

test('ALB用のパブリックサブネットが/24で作成されること', () => {
  template.hasResourceProperties('AWS::EC2::Subnet', {
    VpcId: { Ref: vpcId },
    CidrBlock: Match.stringLikeRegexp('.*/24'),
    MapPublicIpOnLaunch: true,
  });
});

test('アプリケーション用のパブリックサブネットが/24で作成されること', () => {
  template.hasResourceProperties('AWS::EC2::Subnet', {
    VpcId: { Ref: vpcId },
    CidrBlock: Match.stringLikeRegexp('.*/24'),
    MapPublicIpOnLaunch: false,
  });
});

test('RDS用のパブリックサブネットが/28で作成されること', () => {
  template.hasResourceProperties('AWS::EC2::Subnet', {
    VpcId: { Ref: vpcId },
    CidrBlock: Match.stringLikeRegexp('.*/28'),
    MapPublicIpOnLaunch: false,
  });
});

test('NATゲートウェイが2つ作成されること', () => {
  template.resourceCountIs('AWS::EC2::NatGateway', 2);
});

test('ALB用のサブネット2つにそれぞれNATゲートウェイが設置されること', () => {
  const ingressSubnetIds = getResourceIds('AWS::EC2::Subnet', {
    VpcId: { Ref: vpcId },
    MapPublicIpOnLaunch: true,
  });
  template.hasResourceProperties('AWS::EC2::NatGateway', {
    SubnetId: { Ref: ingressSubnetIds[0] },
  });
  template.hasResourceProperties('AWS::EC2::NatGateway', {
    SubnetId: { Ref: ingressSubnetIds[1] },
  });
});

test('EIPが2つ作成されること', () => {
  template.resourceCountIs('AWS::EC2::EIP', 2);
});

test('NATゲートウェイ2つにそれぞれEIPが作成されること', () => {
  const eipIds = getResourceIds('AWS::EC2::EIP');
  template.hasResourceProperties('AWS::EC2::NatGateway', {
    AllocationId: { 'Fn::GetAtt': [ eipIds[0], 'AllocationId' ] },
  });
  template.hasResourceProperties('AWS::EC2::NatGateway', {
    AllocationId: { 'Fn::GetAtt': [ eipIds[1], 'AllocationId' ] },
  });
});

test('ルートテーブルが6つ作成されること', () => {
  template.resourceCountIs('AWS::EC2::RouteTable', 6);
  template.resourceCountIs('AWS::EC2::SubnetRouteTableAssociation', 6);
});

test('アプリケーション用のサブネット2つがそれぞれNATゲートウェイにルーティングされること', () => {
  const applicationSubnetIds = getResourceIds('AWS::EC2::Subnet', {
    VpcId: { Ref: vpcId },
    CidrBlock: Match.stringLikeRegexp('.*/24'),
    MapPublicIpOnLaunch: false,
  });

  const applicationRouteTableId1 = Object.values(template.findResources('AWS::EC2::SubnetRouteTableAssociation', {
    Properties: { SubnetId: { Ref: applicationSubnetIds[0] } },
  }))[0]?.Properties?.RouteTableId?.Ref;
  if (!applicationRouteTableId1) throw new Error('SubnetRouteTableAssociationが正しく定義されていません');

  const applicationRouteTableId2 = Object.values(template.findResources('AWS::EC2::SubnetRouteTableAssociation', {
    Properties: { SubnetId: { Ref: applicationSubnetIds[1] } },
  }))[0]?.Properties?.RouteTableId?.Ref;
  if (!applicationRouteTableId2) throw new Error('SubnetRouteTableAssociationが正しく定義されていません');

  const natGatewayIds = getResourceIds('AWS::EC2::NatGateway');

  template.hasResourceProperties('AWS::EC2::Route', {
    RouteTableId: { Ref: applicationRouteTableId1 },
    NatGatewayId: { Ref: natGatewayIds[0] },
  });
  template.hasResourceProperties('AWS::EC2::Route', {
    RouteTableId: { Ref: applicationRouteTableId2 },
    NatGatewayId: { Ref: natGatewayIds[1] },
  });
});

サブネットを分ける

サブネットを分けてみます。

  • パブリックサブネット (ALB用を想定)
  • NAT付のプライベートサブネット (アプリケーション用を想定)
  • NATなしのプライベートサブネット (RDS用を想定)

それぞれデフォルトでMulti-AZに配置されます。

import { Stack, StackProps } from 'aws-cdk-lib';
import { Vpc } from 'aws-cdk-lib/aws-ec2';
import { Construct } from 'constructs';

export class MyFirstCdkAppStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const vpc = new Vpc(this, 'MyVpc1', {
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: 'Ingress',
          subnetType: SubnetType.PUBLIC,
        },
        {
          cidrMask: 24,
          name: 'Application',
          subnetType: SubnetType.PRIVATE_WITH_NAT,
        },
        {
          cidrMask: 28,
          name: 'Rds',
          subnetType: SubnetType.PRIVATE_ISOLATED,
        },
      ]
    });
  }
}

VPCを作るときにサブネットマスクとサブネットのタイプを指定するだけで、あとはよしなに作ってくれるようです。
cdk synthコマンドの結果は割愛しますが、気になる各サブネットのCIDRブロックは次のようになっていました。
パブリックサブネット→10.0.0.0/24、10.0.1.0/24
プライベートサブネット(App)→10.0.2.0/24、10.0.3.0/24
プライベートサブネット(RDS)→10.0.4.0/28、10.0.4.16/28

デプロイ前の差分確認

cdk diffコマンドを使うと、現在デプロイされているテンプレートと、ローカルでsynthされたテンプレートの差分を見ることができます。
デプロイ前に修正内容が確認できるので非常に便利です。

また、cdk deployの際にも、センシティブな変更を伴う場合には以下のように警告を出してくれるので助かります。

This deployment will potentially sensitive changes according to your current security approval level (--require-approval broadening).
Please confirm you intend to make the following modifications:

~各種センシティブな変更内容~

Do you wish to deploy these changes (y/n)?

テストの実施

jestは設定された状態になっていますので npm run test だけで実行可能です。

$ npm run test

> my-first-cdk-app@0.1.0 test
> jest

 PASS  test/my-first-cdk-app.test.ts (11.626 s)
  ✓ VPCがcidr10.0.0.0/16で1つ作成されること (2 ms)
  ✓ VPCにインターネットゲートウェイが1つ作成されること (1 ms)
  ✓ サブネットが6つ作成されること
  ✓ ALB用のパブリックサブネットが/24で作成されること (2 ms)
  ✓ アプリケーション用のパブリックサブネットが/24で作成されること (1 ms)
  ✓ RDS用のパブリックサブネットが/28で作成されること (1 ms)
  ✓ NATゲートウェイが2つ作成されること
  ✓ ALB用のサブネット2つにそれぞれNATゲートウェイが設置されること (1 ms)
  ✓ EIPが2つ作成されること (1 ms)
  ✓ NATゲートウェイ2つにそれぞれEIPが作成されること (1 ms)
  ✓ ルートテーブルが6つ作成されること (1 ms)
  ✓ アプリケーション用のサブネット2つがそれぞれNATゲートウェイにルーティングされること (1 ms)

Test Suites: 1 passed, 1 total
Tests:       12 passed, 12 total
Snapshots:   0 total
Time:        11.891 s, estimated 12 s
Ran all test suites.

テスト名を日本語かつテストケースのような記述にしていた理由、おわかりいただけたでしょうか?
テスト結果がわかりやすく、どんな要件が満たされている/いないのか一目瞭然です。
命名に悩まれている方は是非真似してみてください。

リソースの破棄

$ cdk destroy
Are you sure you want to delete: MyFirstCdkAppStack (y/n)? y
MyFirstCdkAppStack: destroying...

  ✅  MyFirstCdkAppStack: destroyed

参考|https://docs.aws.amazon.com/cdk/v2/guide/hello_world.html#hello_world_tutorial_destroy

デプロイされたリソースはコマンド一つで破棄できます。
EIPのコストももったいないので、しばらく使うことがないようなら破棄しておきましょう。

AWS Construct Library

最後にAWS Construct Libraryについて少し触れておきます。

AWS Construct Libraryは、CDKが標準で提供しているconstructのライブラリです。
ライブラリには低レイヤのものから、L1 〜 L3の3種類があります。

L1 (CFn Resources)

AWSリソースと1対1にマッピングされたライブラリです。
CFnで定義するのと同じようなイメージで、リソースに対して1つ1つ設定を加えていきます。

今回はL2のConstructを使ってVPCを定義してみましたが、細かい設定が必要な場合はもしかするとこちらを使ったほうが良いのかも知れません。このあたりは未調査です。

disassemblerやFormer2のように、既存リソースからCDKのコードを生成してくれるツールもこのL1のConstructに変換してくれるようです。
www.npmjs.com
former2.com

L2 (Curated)

AWSのベストプラクティスをデフォルトとして定義されたライブラリです。
今回作成したVPCもこちらを使っています。
細かいパラメータの設定が省略可能となっており、コードを簡潔に記述することが可能です。もちろん指定することもできます。

さらにこのレイヤでは、リソースごとの便利なメソッド (例えばS3バケットでの grantRead(user)) が提供されています。
これによって、IAMポリシーを定義して、s3:GetObject*とs3:GetBucket*とs3:List*をアタッチして・・・のような、煩雑なIAMポリシーとAWSリソースのマッピングからも解放され、よりサクサクとコードを書くことができます。

L3 (Patterns)

特定のユースケースのために、複数のAWSリソースを組み合わせたパターンが提供されています。
以下のリンクを見ていただいた方が早いと思いますので説明は割愛します。
cdkpatterns.com

おわりに

熱い気持ちばかりが先行してとても長くなってしまいました。
ここまで読んでくださった方、ありがとうございます。

CDKを使ってみた所感としては、

  • Construct Libraryを使って書くことで、必須パラメータが何なのか?どういう仕様なのか?をWebサイトで確認する必要がなく、VS Code上ですべて完結するのはストレスフリー。
  • L2のライブラリを使うことで、細かいパラメータを設定しなくて済むのが楽。(もしかするとL1でないとできないことがあるのかも?)
  • cdk diffの安心感
  • cdk deploy/destroyの簡単さ
  • CFnを知らなくても書ける(ただしユニットテストを書くのにはCFnの知識が必要)

といった感じでしょうか。

AWSリソースに関する知識の再整理もできたように思います。
もしかするとマネジメントコンソールでGUIを恐る恐る触るよりも、こちらのほうが結果を確認しながら構成していける分学習には向いているかもしれません。

テストに関してはまだ正解がわからずにいます。
実際に使っていくとなると既存リソースの移行が多く発生しそうなので、ユニットテストというよりスナップショットテストの方が活躍しそうです。
そちらはまた別の機会に。



☆スタイル・エッジ・グループでは、一緒に働く仲間を募集しています☆
もし興味を持っていただけましたら、採用サイト↓も覗いてみてください!
recruit.styleedge-labo.co.jp

*1:2022年6月21日現在では、TypeScript、JavaScriptPythonJavaC#、Go (開発者プレビュー) が利用可能です。

*2:>=10.13.0。ただし13.0.0から13.6.0を除く

*3:2022年6月21日現在ではα版

メールサーバーの仕組みについて

はじめに

こんにちは、MKです!
以前の記事を書いてから、約1年経ちました。

1年間、主にインフラ関係の業務をしていましたので、
今回は「メールサーバーの仕組み」を記事にしたいと思います。

普段メールを送受信することが多くあると思いますが、
どういう風に送受信されているのか、を考えたことは少ないのではないでしょうか?

ですので、少しでも理解していただけると幸いです😊

メールが送信されるまでの流れ

メールが送信されるまでの流れを図1にまとめてみました。
簡単にですが、メールは以下の手順で受信者に届きます。

①送信者PCから送信者のメールサーバーへメール送信の指示が送られる
②送信者のメールサーバーは、送り先のアドレス調査のため、DNSサーバーにIPアドレスを問い合わせる
③問い合わせたIPアドレスのサーバーにメールを送る
④受信者のメールサーバーから受信者PCにメールが届く

普段、みなさんのもとにはたくさんのメールが届くと思いますが、
「なりすましメール」が届いたことはないでしょうか…?

メールの構成となりすましメール

まず、なりすましメールとは、
悪意のある誰かがメールのヘッダー部分を書き換えて偽装したメールのことです。

メールは以下の2つの領域で構成されています。

エンベロープとは、手紙を出す際の「封筒」のようなものと認識していただくのがイメージしやすいかと思います。(図2)

例えば、信頼できるA会社からメールが届いたとき・・・
届いたメールの差出人(ヘッダーFrom)は、A会社のメールアドレスです。
この時、エンベロープFromのメールサーバーがA会社のものであれば、
「信頼できるメールである」と言えますが、A会社のものでない場合は、
悪意ある第三者によってヘッダーが書き換えられた「なりすましメールである」可能性があります。

なりすましメールの対策

なりすましメールか、そうでないかを判断するために、
メールサーバーで設定できる仕組み(= 送信ドメイン認証)があります。
今回は概要のみの紹介ですが、以下の3つです。

  • SPF(Sender Policy Framework): 送信元メールサーバーのIPアドレスが信頼できるものか判断する

  • DKIMDomainKeys Identified Mail): 電子署名を利用してメール送信元が詐称されていないかどうかを確認する

  • DMARC(Domain-based Message Authentication、Reporting and Conformance): SPFDKIMの認証が失敗した場合、受信メールをどう扱うか判断する

いずれも届いたメールのヘッダーを確認すれば、
経由しているメールサーバーに設定がされているかどうかや送信元が信頼できるかどうかを確認できますので、
興味のある方はメールヘッダーを確認してみてください!  

メールヘッダーってどう確認するの???

今回は代表して、Gmailのメールヘッダーの確認方法をご紹介したいと思います!!
(わたしは業務でメールヘッダーを確認することになって初めて確認方法を知りました…🙄)

メールヘッダーの開き方

①確認したいメールを開く
三点リーダーをクリック
③「メッセージのソースを表示」をクリック

ヘッダーFrom・エンベロープFromを確認する

簡単にですが、今回紹介した内容と紐づいたメールヘッダーの項目を図にしてみました。(図4)
※実際のメールヘッダーはもっと情報量が多いですが…😓

エンベロープFromは「Return-Path」を、ヘッダーFromは「From」の部分を確認します。
「Return-Path」と「From」が同じか否かを確認することで、
信頼できるメールかどうかを判断する材料に使用することができます。

また、メールサーバーで設定できる仕組み(= 送信ドメイン認証)は
「Authentication-Results」の部分で、認証が成功か、失敗かを確認することができ、

図4の(★)で示した「Received」の部分は、メールが受信者のメールサーバーに到着するまでに
経由したサーバーの情報などが記載されます。(経由したメールサーバーの数だけ記載されます)

おわりに

今回は「メールサーバーの仕組み」について紹介しました。
なりすましメールの対策方法として、メーラーによっては
「なりすましメールの疑いのあるメールは迷惑メールBoxに割り振る」等、
なりすましメールをそもそも開かないための設定があるものも多いです。
これを機に、みなさんがお使いのメーラーの設定を見直してみてはいかがでしょうか。

弊社の風土として

実際に業務利用するメールサーバーの構築・保守・チューニングは
自分だけで学習しようとしてもなかなか難しいと思います。
スタイル・エッジ・グループにはインフラ系のプロジェクトもあるため、
本格的にサーバーの運用や保守に携わることもできます!

自身の興味に応じてプロジェクトの異動も上長に相談できるため、
「インフラにも興味あるし、アプリケーション開発にも関わりたい…」という人にピッタリだと思います😉

☆スタイル・エッジ・グループでは、一緒に働く仲間を募集しています☆
もし興味を持っていただけましたら、採用サイト↓も覗いてみてください!
recruit.styleedge-labo.co.jp

個人的に便利だと思ったWeb API 2選

はじめに

こんにちは、スタイル・エッジLABOの┣と申します。
前回の記事より早2年経ち入社3年目になります。
昨年からCSIRTとしてサイバーセキュリティ関連の業務に携わらせていただくようになりました。

そのCSIRTとは関連性の薄い話題ですが、今回は別のシステムの開発中に便利そうなWeb APIを見つけたので2つほどつづらせていただきました。
ブラウザの画面制御など、何かのお役に立てますと幸いですm(_ _)m

Web APIについてざっくりと

今回ご紹介する『Web API』は『ブラウザ API』とも呼ばれ、読んで字のごとくブラウザの機能にアクセスしたり挙動をコントロールしたりするものを指します。

有名なWeb APIとして『XMLHttpRequest』や『File』が挙げられると思いますが、今回は若干マイナー(!?)かつ便利そうなものを挙げさせていただきました。

本題

1. 異なるウィンドウやタブで連携するBroadcastChannel

このWeb APIは、一言で表すと「同一オリジン1のコンテンツを開いているウィンドウやタブ・フレームに対してメッセージを送受信する」といった制御ができるAPIです。  

なお、メッセージを送受信するためにはウィンドウやタブ・フレームごとに下記のようなコードでチャンネルを作成/閉設しなければなりません。

// チャンネル作成
const bc = new BroadcastChannel('channel1');

// チャンネル閉設(不要なチャンネルが残り続けるとメモリリークの可能性あり)
bc.close();

この特性を利用して、特定のページにのみ「チャンネルを作成/閉設する」という処理を組み込むことで「同一オリジンの中でも特定のページのみ利用する」といった制御もできます。

似たようなWeb APIに『MessageChannel』があります。
ですが、このAPIは1対1でのやり取りに用いるので「不特定多数のウィンドウやタブと連携する」……といった用途だとチャンネル管理が煩雑になりそうです。
(どちらかというと「特定の子フレームと円滑に連携したい」といった用途に向いているものと思っております。)

BroadcastChannelの用途として、「とあるタブの操作により複数の別タブの操作を行う」といったことが考えられます。
そこで、簡単にですが動作検証用に下記のサンプルを用意してみました。

<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
        <title>BroadcastChannelサンプル</title>
    </head>
    <body>
        <div>BroadcastChannelサンプルです</div>
        <div id="message"></div>
        <button id="post">テスト</button>
        <script>
            // チャンネルを作成
            const bc = new BroadcastChannel('channel1');
    
            // メッセージ受信時の処理
            bc.addEventListener('message', (e) => {
                // 受信したメッセージにより処理を振り分け
                switch (e.data) {
                    case 'alert':
                        alert('test');
                        break;
                    default:
                        break;
                };

                // 受信したメッセージを表示
                document.querySelector('#message').innerText = e.data;
            });

            window.addEventListener('unload', () => {
                // 不要になったチャンネルは閉じないとメモリーリークの可能性あり
                bc.close();
            });
    
            document.querySelector('#post').addEventListener('click', () => {
                bc.postMessage('alert');
            });
        </script>
    </body>
</html>

このHTMLファイルをブラウザで複数開いてどれかのボタンを押すと……

このようにボタンを押さなかったウィンドウ(もしくはタブ)が反応します(∩´∀`)∩

2. サイズ変更を監視して検知するResizeObserver

こちらにつきましては読んで字のごとくですが、「要素のサイズ変更を検知する」といったものです。
「だったらresizeイベントで十分じゃ……?」と思われるかもしれませんが、resizeイベントはウィンドウにしか設定できないのに対し、ResizeObserverはページ内の要素にも適用できるのが大きな利点だと思います。

また、似たようなWeb APIに『MutationObserver』があります。
このAPIは「属性やテキストの変更を検知するか」「子要素の変更も検知するか」……といった細かい設定ができるので、今後使ってみたいと思いますm(_ _)m

こちらも簡単にですがサンプルを用意したので、ご覧いただけますと幸いです。

<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
        <title>ResizeObserverサンプル</title>
    </head>
    <body>
        <div>ResizeObserverサンプルです</div>
        <textarea id="sample"></textarea>
        <div id="target" style="background-color: cadetblue;"></div>
        <script>
            // 監視対象(textarea)とサイズ変更する要素(div)を取得してResizeObserverに設定
            const textarea = document.querySelector('#sample'),
                target = document.querySelector('#target'),
                ro = new ResizeObserver(() => {
                    target.style.width = textarea.clientWidth + 'px';
                    target.style.height = textarea.clientHeight + 'px';
                });
            ro.observe(textarea);
        </script>
    </body>
</html>

このHTMLファイルをブラウザで開いて、テキストエリアを大きくすると……

緑色の部分も大きくなりました(∩´∀`)∩

本題は以上となりますm(_ _)m

おわりに

実はこの2つのWeb API、弊社システムのUI改善の一環として取り入れられないか思案しております。
とある機能において、

  • システムの他の画面とはウィンドウを分けて独立したアプリっぽく使えるようにしつつ、他の画面と連携して操作をコントロールする
  • このウィンドウのサイズを中のUIのサイズによって自動で調整する

といった挙動にできないか検討しているので、正にうってつけだと考えております。

☆スタイル・エッジLABOでは、一緒に働く仲間を募集しています☆
システムの構築・改善に限らず、フロントエンド開発やサイバーセキュリティに関する活動などにも興味をお持ちでしたら、 採用サイト↓も覗いてみてください!
recruit.styleedge-labo.co.jp


  1. プロトコル、ホスト、ポートが同じである

Flutterのパッケージ備忘録

はじめに

こんにちは!スタイル・エッジLABOのガッキーです。
私は昨年10月より未経験でエンジニアとして入社し、
現在は、スタイル・エッジLABOの福井オフィスで勤務しています!

福井オフィスは昨年12月に新設されたばかりの、とてもスタイリッシュなオフィスです🌱
素敵な環境で気持ちよく勤務させていただけることに感謝しながら、日々仕事に邁進しております。
オフィスの写真は、こちら↓から是非ご覧ください! www.talent-book.jp

さて、私は入社して3ヵ月の研修後、「Flutter」を使ったスマホアプリ開発の機会を得られました。
今回は、Flutterのパッケージについて、備忘録的に書き留めておきたいと思います。

Flutterとは?

Flutterは、Googleが提供するモバイルアプリフレームワークです。
また、Flutterの開発にはDartという言語が用いられます。

一般的なネイティブアプリ開発では、AndroidiOS等、それぞれの環境に合わせたアプリ開発が必要ですが、 Flutterを使った開発の場合は、一度の開発でさまざまなデバイスやOS(AndroidiOS/Web/WindowsmacOS)に対応できます。

Flutterのパッケージ

Flutterでは外部パッケージを導入して、アプリに様々な機能を追加できます。
パッケージ自体はDart packagesから探すことができ、下記3ステップでインストールできます。

(例)audioplayers というパッケージをインストールする場合

① Flutterプロジェクトの中にあるpubspec.yamlファイル*1に、パッケージ名・バージョンを記述
dependencies:
  audioplayers: ^0.20.1
flutter pub getコマンドの実行

Visual Studio Codeを利用している場合は、Flutter・Dart拡張機能を導入すると、pubspec.yamlファイルを保存することで上記コマンドが走ります。)

③ main.dart等、パッケージを利用したいファイルの冒頭に import 文を書き加える
import 'package:audioplayers/audioplayers.dart';

パッケージのインストール手順や使用方法については、
各パッケージ詳細ページの 「Installing」・「Readme」 に記載されています。
(例)audioplayers のインストール手順:https://pub.dev/packages/audioplayers/install
(例)audioplayers の使用方法:https://pub.dev/packages/audioplayers
使い方はパッケージによって異なるので、それぞれ確認が必要になります。

さて、このパッケージについて、Flutter開発の際に実際に使ってみて便利だった、もしくは、
使わなかったけど使ってみたいパッケージを織り交ぜてご紹介したいと思います。

パッケージ3選

1. flutter_native_splash

アプリの起動中に画像やアニメーションがうぉんっと表示される、スプラッシュ画面を実装するためのパッケージです。
pubspec.yamlファイルに スプラッシュ画面に表示する画像と、背景色を記載することで、簡単にスプラッシュ画面が実装できます。
pub.dev

2. settings_ui

アプリの設定画面を実装するためのパッケージです。
このパッケージを使うことにより、各環境に最適化されたUIを提供できます。
どのような画面が作成できるかは、リンク先を是非見てみてください…! pub.dev

3. local_auth

iOS/Android端末で設定されている情報を使って、指紋認証・顔認証を実装できるパッケージです。
生体認証の結果(認証成功・失敗)を使って、セキュアなアプリにできます。 pub.dev

最後に

パッケージを使うことで簡単に機能や画面の実装ができて、パッケージの便利さが実感できます。
他にもたくさんのパッケージが存在しているので、どんどん活用してみたいと思います。

☆スタイル・エッジLABOでは、一緒に働く仲間を募集しています☆
Flutterの開発・モバイルアプリ開発に興味を持っていただけましたら、 採用サイト↓も覗いてみてください!
recruit.styleedge-labo.co.jp

*1:プロジェクトの設定ファイルで、パッケージマネージャーとしての役割もあります。新規でFlutterプロジェクトを作成した際、プロジェクトフォルダの中に自動的に作成されます。

MySQL のソースを読んでみる ~初めまして、コードリーディング編~

ご挨拶

初めまして。スタイル・エッジLABO の しお です。昨年10月に中途で入社してきました。
前職では SQL を書いてデータ調査をしたり巨大なシステムの中で路頭に迷ったり Java を書いたり書かなかったりしていました。
現在は、スタイル・エッジの社内で使用されているとあるシステムの保守開発をさせて頂いています。

日々の業務の中で MySQL を使用してデータ調査用のクエリを組んだりバッチ処理を書いたりしているわけですが、ふと「MySQL の実装ってどうなってるんだ?そもそもこれってどんな仕組みで動いてるの??」と思ったので、今日は実際に MySQLソースコードを読んでみようと思います。

MySQLOSS (オープンソースソフトウェア) で、ソースコードが全世界に公開されています。そのため、誰でも自由にソースコードを読むことが出来ます。
個人的には飛行機の飛ぶ原理とかも気になって仕方がないのですが、飛行機は OSS ではないので今回は諦めて MySQLソースコードを読んでいくことにします。

モチベーション

  • 色んな OSS のコードリーディングしてみたい。
  • 初見のコード読めるようになりたい。
  • MySQL の仕様について自信を持ちたい。

対象

普段業務で使っている、mysql-server のソースを読んでいきます。ただ、闇雲に読んでも迷子になるだけなので、目的は設定しておきます。
今回は、最近知った 宇宙船演算子 (英: spaceship operator) というカッコイイ演算子 *1 の謎を紐解いていきます。

f:id:styleedge_tech:20220323171636j:plain

宇宙船演算子とは?

宇宙船演算子は、簡単に言うと null 値を比較できる比較演算子です。

例えば、数値同士は以下の通り何の問題もなく比較できます。

mysql> set @hoge = 1;
Query OK, 0 rows affected (0.00 sec)

mysql> select @hoge = 1 as result;
+--------+
| result |
+--------+
|      1 |
+--------+
1 row in set (0.00 sec)

文字列同士での比較も同様です。

mysql> set @hoge = 'aaa';
Query OK, 0 rows affected (0.00 sec)

mysql> select @hoge = 'aaa' as result;
+--------+
| result |
+--------+
|      1 |
+--------+
1 row in set (0.00 sec)

また、異なる型同士でも、比較自体は可能です。

mysql> set @hoge = 1;
Query OK, 0 rows affected (0.00 sec)

mysql> select @hoge = 'aaa' as result;
+--------+
| result |
+--------+
|      0 |
+--------+
1 row in set, 1 warning (0.00 sec)

では、比較対象が null 値だとどうなるのでしょうか?

mysql> set @hoge = null;
Query OK, 0 rows affected (0.00 sec)

mysql> select @hoge = 'aaa' as result;
+--------+
| result |
+--------+
|   NULL |
+--------+
1 row in set (0.00 sec)

この通り、比較結果は null となり結果は返ってきません。そのため、null と比較するときは coalesce (これなんて読むのが正解?)*2 で初期値を設定するか、is not null and 等でよくお茶を濁します。

mysql> set @hoge = null;
Query OK, 0 rows affected (0.00 sec)

mysql> select coalesce(@hoge, '') = 'aaa' as result;
+--------+
| result |
+--------+
|      0 |
+--------+
1 row in set (0.00 sec)

mysql> select (@hoge is not null) and (@hoge = 'aaa') as result;
+--------+
| result |
+--------+
|      0 |
+--------+
1 row in set (0.00 sec)

ただ、null 値の考慮のために coalesceis not null and 等の比較を入れていることで単純にソースの分量も増えて読みづらくなるし、本来のクエリの目的もぼやけて伝わりづらくなってしまいます。

この問題を解決するのが宇宙船演算子で、彼は null 値との比較を可能にしてくれます。

mysql> set @hoge = null;
Query OK, 0 rows affected (0.00 sec)

mysql> select @hoge <=> 'aaa' as result;
+--------+
| result |
+--------+
|      0 |
+--------+
1 row in set, 1 warning (0.00 sec)

ちなみに、比較対象が両方とも null ならば等しいと見なされるそうです。

mysql> set @hoge = null;
Query OK, 0 rows affected (0.00 sec)

mysql> set @fuga = null;
Query OK, 0 rows affected (0.00 sec)

mysql> select @hoge <=> @fuga as result;
+--------+
| result |
+--------+
|      1 |
+--------+
1 row in set (0.01 sec)

実際に読んでいく

ソースの展開

前置きが大変長くなってしまいました。以上が宇宙船演算子の概要です。ここからは、実際に MySQL のソースを読んで宇宙船演算子の確かな仕様を掴みに行きます。

ソースの展開は以下の手順で行いました。

# 任意の場所に移動
cd ~/projects
# ソースを落としてくる
git clone https://github.com/mysql/mysql-server.git
# ディレクトリ移動する
cd ./mysql-server

とにかく grep だ!

兎にも角にも、宇宙船演算子なるものの正体を暴くためにキーワード <=>grep してみます。

$ grep -rn "<=>" ./
./storage/innobase/include/log0types.h:222:       @name Users <=> writer
./storage/innobase/include/log0types.h:276:       @name Users <=> flusher
./storage/innobase/include/log0types.h:453:       @name Log flusher <=> flush_notifier
./storage/innobase/include/log0types.h:478:       @name Log writer <=> write_notifier
./storage/ndb/src/mgmclient/CommandInterpreter.cpp:351:"                      #&()*+-./:;<=>?@[]_{|}~.\n"
./storage/ndb/src/kernel/blocks/dbtup/DbtupExecQuery.cpp:3754:   * len=4 <=> 1 word
./storage/ndb/src/kernel/blocks/dbdih/DbdihMain.cpp:21970:       * Nothing queued or started <=> Complete on that node
./storage/ndb/nodejs/jones-ndb/impl/src/ndb/QueryOperation.cpp:128://  DEBUG_PRINT_DETAIL("compareTwoResults for level %d: %d <=> %d", level, r2, r1);
./storage/myisam/mi_key.cc:277:    unpack_blobs        true  <=> Unpack blob columns
./storage/myisam/mi_key.cc:278:                        false <=> Skip them. This is used by index condition
./include/base64.h:77:    60,        61, -1, -1, -1, -1, -1, -1, /* 0123456789:;<=>? */
./include/mysql/service_command.h:184:  @param is_unsigned   TRUE <=> value is unsigned
./unittest/gunit/decimal-t.cc:409:  sprintf(s, "'%s' <=> '%s'", s1, s2);
./unittest/gunit/libmysqlgcs/xcom/gcs_xcom_xcom_base-t.cc:641:     gt_ballot(m->proposal, p->proposer.msg->proposal) <=>
./unittest/gunit/libmysqlgcs/xcom/gcs_xcom_xcom_base-t.cc:642:     gt_ballot((0,0), (0,1)) <=>
./unittest/gunit/opt_trace-t.cc:828:      "0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^_`"
./unittest/gunit/xplugin/xpl/mysql_function_names_t.cc.in:238:    {"<=>", OPERATOR},
./plugin/group_replication/include/sql_service/sql_service_context.h:136:    @param is_unsigned   TRUE <=> value is unsigned
./plugin/group_replication/include/sql_service/sql_service_context_base.h:142:    @param is_unsigned   TRUE <=> value is unsigned
./plugin/innodb_memcached/daemon_memcached/scripts/damemtop:369:                @newrows = sort { $a->[$colnum] <=> $b->[$colnum] } @rows;
./plugin/innodb_memcached/daemon_memcached/scripts/damemtop:375:                @newrows = sort { $b->[$colnum] <=> $a->[$colnum] } @rows;
./plugin/x/src/ngs/command_delegate.h:273:    @param unsigned_flag true <=> value is unsigned
./scripts/fill_help_tables.sql:157:INSERT INTO help_topic (help_topic_id,help_category_id,name,description,example,url) VALUES (55,10,'<=>','Syntax:\n<=>\n\nNULL-safe equal. This operator performs an equality comparison like the\n= operator, but returns 1 rather than NULL if both operands are NULL,\nand 0 rather than NULL if one operand is NULL.\n\nThe <=> operator is equivalent to the standard SQL IS NOT DISTINCT FROM\noperator.\n\nURL: https://dev.mysql.com/doc/refman/8.0/en/comparison-operators.html\n\n','mysql> SELECT 1 <=> 1, NULL <=> NULL, 1 <=> NULL;\n        -> 1, 1, 0\nmysql> SELECT 1 = 1, NULL = NULL, 1 = NULL;\n        -> 1, NULL, NULL\n','https://dev.mysql.com/doc/refman/8.0/en/comparison-operators.html');
./scripts/fill_help_tables.sql:860:INSERT INTO help_keyword (help_keyword_id,name) VALUES (56,'<=>');
./scripts/mysqldumpslow.pl.in:157:my @sorted = sort { $stmt{$b}->{$opt{s}} <=> $stmt{$a}->{$opt{s}} } keys %stmt;
./sql/field.h:4590:    true <=> source item is an Item_field. Needed to workaround lack of
./sql/json_dom.h:1598:    @param[out] err    true <=> error occur during coercion
./sql/json_dom.h:1618:    @param[out] err    true <=> error occur during coercion
./sql/json_dom.h:1636:    @param[out] err    true <=> error occur during coercion
・・・
以下、お見せできないほどの量

そっか・・・

落ち着いて tree

情報量に圧倒されてしまいそうだったので、一旦呼吸を整えて tree コマンドでざっくりとディレクトリ構成を把握することにしました。

$ tree ./ -L 1
./
├── CMakeLists.txt
├── Docs
├── Doxyfile-ignored
├── Doxyfile.in
├── INSTALL
├── LICENSE
├── MYSQL_VERSION
├── README
├── client
├── cmake
├── components
├── config.h.cmake
├── configure.cmake
├── doxygen_resources
├── extra
├── include
├── libbinlogevents
├── libbinlogstandalone
├── libchangestreams
├── libmysql
├── libservices
├── man
├── mysql-test
├── mysys
├── packaging
├── plugin
├── router
├── run_doxygen.cmake
├── scripts
├── share
├── sql
├── sql-common
├── storage
├── strings
├── support-files
├── testclients
├── unittest
├── utilities
└── vio

29 directories, 10 files

なんだか sql の下が匂う・・・気がするので、再度./sql 配下で grep してみます。

$ grep -rn "<=>" ./sql
./sql/field.h:4590:    true <=> source item is an Item_field. Needed to workaround lack of
./sql/json_dom.h:1598:    @param[out] err    true <=> error occur during coercion
./sql/json_dom.h:1618:    @param[out] err    true <=> error occur during coercion
./sql/json_dom.h:1636:    @param[out] err    true <=> error occur during coercion
./sql/temp_table_param.h:174:    true <=> don't actually create table handler when creating the result
./sql/sql_class.cc:2518:  @param  all   true <=> rollback main transaction.
./sql/sql_const_folding.h:36:  Fold boolean condition {=, <>, >, >=, <, <=, <=>} involving constants and
./sql/range_optimizer/index_range_scan.h:79:  bool free_file; /* TRUE <=> this->file is "owned" by this quick select */
./sql/range_optimizer/index_range_scan_plan.h:55:      index_read_must_be_used  true <=> assume 'index only' option will be set
./sql/range_optimizer/index_range_scan_plan.h:57:      update_tbl_stats         true <=> update table->quick_* with information
./sql/range_optimizer/index_range_scan_plan.h:100:      update_tbl_stats  true <=> update table->quick_* with information
./sql/range_optimizer/partition_pruning.cc:911:          Ok, we've got "fieldN<=>constN"-type SEL_ARGs for all partitioning
./sql/range_optimizer/partition_pruning.cc:939:          Ok, we've got "fieldN<=>constN"-type SEL_ARGs for all subpartitioning
./sql/range_optimizer/range_analysis.cc:1134:  @param comp_op                    Comparison operator: >, >=, <=> etc.
./sql/range_optimizer/range_analysis.cc:1246:          Independent of data type, "out_of_range_value =/<=> field" is
./sql/range_optimizer/range_analysis.cc:1544:    Any sargable predicate except "<=>" involving NULL as a constant is always
./sql/range_optimizer/tree.cc:1072:  // (cond) OR (IMPOSSIBLE) <=> (cond).
./sql/rpl_rli_pdb.cc:1710:        lwm_estimate < last_committed  <=>  last_committed  \not <= lwm_estimate
./sql/sql_optimizer_internal.h:69:  Currently 'op' is one of {'=', '<=>', 'IS [NOT] NULL', 'arg1 IN arg2'},
./sql/lex.h:74:    {SYM("<=>", EQUAL_SYM)},
./sql/handler.h:3579:  Flag set <=> default MRR implementation is used
./sql/handler.h:3588:  Flag set <=> the caller guarantees that the bounds of the scanned ranges
./sql/handler.h:4165:  /* true <=> source MRR ranges and the output are ordered */
./sql/handler.h:4168:  /* true <=> we're currently traversing a range in mrr_cur_range. */
./sql/handler.h:4200:    true <=> the engine guarantees that returned records are within the range
./sql/handler.h:6777:  bool dsmrr_eof; /* true <=> We have reached EOF when reading index tuples */
./sql/handler.h:6779:  /* true <=> need range association, buffer holds {rowid, range_id} pairs */
./sql/handler.h:6782:  bool use_default_impl; /* true <=> shortcut all calls to default MRR impl */
./sql/sql_executor.h:392:  /** true <=> remove duplicates on this table. */
./sql/opt_explain_json.cc:903:        (x <=> (SELECT FROM DUAL) AND x = (SELECT FROM DUAL)),
./sql/table_function.h:214:    true <=> NESTED PATH associated with this element is producing records.
./sql/table_function.h:304:    @param[out]  skip  true <=> it's a NESTED PATH node and its path
./sql/item_cmpfunc.cc:1071:  @param [out] is_null        true <=> the item_arg is null
./sql/item_cmpfunc.cc:1490:  @param[out] is_null  true <=> the item_arg is null
./sql/item_cmpfunc.cc:1553:    is_null    [out]    true <=> the item_arg is null
./sql/item_cmpfunc.cc:4949:  /* true <=> arguments values will be compared as DATETIMEs. */
./sql/sql_resolver.cc:1867:  @param top         true <=> cond is the where condition
./sql/sql_resolver.cc:1868:  @param in_sj       true <=> processing semi-join nest's children
./sql/sql_resolver.cc:5479:      // If antijoin, we can decorrelate '<>', '>=', etc, too (but not '<=>'):
./sql/join_optimizer/make_join_hypergraph.cc:2235:      //   (t1 <opA> t2) <opB> t3 <=> t1 <opA> (t2 <opB> t3)
./sql/join_optimizer/interesting_orders.cc:1414:      // TODO(sgunders): When we get C++20, use operator<=> so that we
./sql/join_optimizer/join_optimizer.cc:3831:// TODO(sgunders): Include x=y OR NULL predicates, <=> and IS NULL predicates,
./sql/sql_opt_exec_shared.h:109:    true <=> disable the "cache" as doing lookup with the same key value may
./sql/sql_partition.cc:3112:      include_endpoint  true <=> the endpoint itself is included in the
./sql/sql_optimizer.h:589:  /** Exec time only: true <=> current group has been sent */
./sql/handler.cc:7151:  @param      interrupted  true <=> Assume that the disk sweep will be
./sql/sql_select.cc:2561:  index are available other_tbls_ok  true <=> Fields of other non-const tables
./sql/sql_select.cc:5036:          <=> N > refkey_rows_estimate.
./sql/opt_trace.cc:219:    0 <=> this trace should be in information_schema.
./sql/opt_explain.h:97:  bool zero_result;           ///< true <=> plan will not be executed
./sql/key_spec.h:182:  /// true <=> ascending, false <=> descending.
./sql/key_spec.h:185:  /// true <=> ASC/DESC is explicitly specified, false <=> implicit ASC
./sql/opt_sum.cc:759:  bool eq_type = false;          // =, <=> or IS NULL
./sql/opt_sum.cc:760:  bool is_null_safe_eq = false;  // The operator is NULL safe, e.g. <=>
./sql/opt_trace_context.h:370:    <>0 <=> any to-be-created statement's trace should not be in
./sql/sql_const_folding.cc:1053:  [*] for the "<=>" operator, we fold to FALSE (0) in this case.
./sql/partitioning/partition_handler.h:1022:    @param[in]  have_start_key  true <=> the left endpoint is available, i.e.
./sql/partitioning/partition_handler.h:1025:                                false <=> there is no left endpoint (we're in
./sql/partitioning/partition_handler.cc:2208:  @param idx_read_flag  true <=> m_start_key has range start endpoint which
./sql/partitioning/partition_handler.cc:2211:                        false <=> there is no start endpoint.
./sql/item.h:1607:        left_endp  false  <=> The interval is "x < const" or "x <= const"
./sql/item.h:1608:                   true   <=> The interval is "x > const" or "x >= const"
./sql/item.h:1610:        incl_endp  IN   false <=> the comparison is '<' or '>'
./sql/item.h:1611:                        true  <=> the comparison is '<=' or '>='
./sql/item.h:5944:    true <=> that the outer_ref is already present in the select list
./sql/item.h:6504:    true <=> cache holds value of the last stored item (i.e actual value).
./sql/sql_tmp_table.cc:1962:  @param force_disk_table true <=> Use InnoDB
./sql/sql_tmp_table.cc:2013:  @param force_disk_table true <=> Use InnoDB
./sql/item.cc:2041:  @param skip_registered <=> function be must skipped for registered SUM items
./sql/item.cc:2131:  /* An item of type Item_sum  is registered <=> referenced_by[0] != 0 */
./sql/item.cc:7310:  @param [out] arg If != NULL <=> Cache this item.
./sql/item.cc:9150:        We can't ignore NULL values here as this item may be used with <=>, in
./sql/sql_select.h:736:  /** true <=> AM will scan backward */
./sql/sql_select.h:811:  bool null_key{false}; /* true <=> the value of the key has a null part */
./sql/sql_select.h:992:  @param func   comparison operator (= or <=>)
./sql/sql_parse.cc:6551:  if ((cmp == &comp_eq_creator) && !all)  //  = ANY <=> IN
./sql/sql_parse.cc:6553:  if ((cmp == &comp_ne_creator) && all)  // <> ALL <=> NOT IN
./sql/table.cc:4170:  @param is_virtual true <=> it's a virtual tmp table
./sql/sql_planner.cc:965:  @param disable_jbuf      true<=> Don't use join buffering
./sql/sql_optimizer.cc:6151:      AND t11.b <=> t10.b AND (t11.a = (SELECT MAX(a) FROM t12
./sql/sql_optimizer.cc:6281:  @param  other_tbls_ok  true <=> Fields of other non-const tables are allowed
./sql/sql_optimizer.cc:6661:  -   (t2.key = t1.field OR t2.key <=> t1.field) -> null_rejecting=false
./sql/sql_optimizer.cc:6846:  @param eq_func            True if we used =, <=> or IS NULL
./sql/sql_optimizer.cc:7006:    Only the <=> operator and the IS NULL and IS NOT NULL clauses may return
./sql/sql_optimizer.cc:7062:    @param  eq_func        True if we used =, <=> or IS NULL
./sql/sql_optimizer.cc:8830:        Note that ref access implements "table1.field1 <=>
./sql/sql_optimizer.cc:10247:       3) If the <=> operator is used, result is always true because
./sql/table.h:1725:    For tmp tables. true <=> tmp table has been instantiated.
./sql/table.h:1742:      true <=> range optimizer found that there is no rows satisfying
./sql/table.h:3354:       <=>
./sql/table.h:3358:       <=>
./sql/table.h:3362:       <=>
./sql/table.h:3686:  /// true <=> VIEW CHECK OPTION condition is processed (also for prep. stmts)
./sql/table.h:3688:  /// true <=> Filter condition is processed
./sql/table.h:3826:  /// true <=> this table is a const one and was optimized away.
./sql/table.h:3830:    true <=> all possible keys for a derived table were collected and
./sql/item_cmpfunc.h:143:  bool set_null{true};  // true <=> set owner->null_value
./sql/item_cmpfunc.h:336:    True <=> this item was added by IN->EXISTS subquery transformation, and
./sql/item_cmpfunc.h:531:/// Abstract base class for the comparison operators =, <> and <=>.
./sql/item_cmpfunc.h:564:    return "<=>";
./sql/item_cmpfunc.h:681:  >, >=) as well as the special <=> equality operator.
./sql/item_cmpfunc.h:1067:  The <=> operator evaluates the same as
./sql/item_cmpfunc.h:1071:  a <=> b is equivalent to the standard operation a IS NOT DISTINCT FROM b.
./sql/item_cmpfunc.h:1089:  const char *func_name() const override { return "<=>"; }
./sql/item_cmpfunc.h:1230:  bool negated;    /* <=> the item represents NOT <func> */
./sql/item_cmpfunc.h:1231:  bool pred_level; /* <=> [NOT] <func> is used on a predicate level */
./sql/item_cmpfunc.h:1261:  /* true <=> arguments will be compared as dates. */

またまた情報量に圧倒されつつ、一つ明らかに怪しいものが・・・

./sql/lex.h:74:    {SYM("<=>", EQUAL_SYM)},

というわけで、./sql/lex.h:74 近辺を詳しく見てみます。

細かく grep しつつ読んでいく

static const SYMBOL symbols[] = {
    /*
     Insert new SQL keywords after that commentary (by alphabetical order):
    */
    {SYM("&&", AND_AND_SYM)},
    {SYM("<", LT)},
    {SYM("<=", LE)},
    {SYM("<>", NE)},
    {SYM("!=", NE)},
    {SYM("=", EQ)},
    {SYM(">", GT_SYM)},
    {SYM(">=", GE)},
    {SYM("<<", SHIFT_LEFT)},
    {SYM(">>", SHIFT_RIGHT)},
    {SYM("<=>", EQUAL_SYM)},
    {SYM("ACCESSIBLE", ACCESSIBLE_SYM)},
    {SYM("ACCOUNT", ACCOUNT_SYM)},
    {SYM("ACTION", ACTION)},
    {SYM("ACTIVE", ACTIVE_SYM)},
    {SYM("ADD", ADD)},
    {SYM("ADMIN", ADMIN_SYM)},
    {SYM("AFTER", AFTER_SYM)},
    {SYM("AGAINST", AGAINST)},
    {SYM("AGGREGATE", AGGREGATE_SYM)},
    {SYM("ALL", ALL)},
    {SYM("ALGORITHM", ALGORITHM_SYM)},
    {SYM("ALTER", ALTER)},
    // 省略

こんな感じで SQL予約語が定義されてました。本筋とは関係ない話ですが、MySQL って <>!= の両方をサポートしてたんですね(両方とも NE なので同じ働きしてくれそう)。知らなかった。

さて、肝心の宇宙船演算子を意味する比較演算子 <=> はここでは EQUAL_SYM と定義されているらしいので、再度 EQUAL_SYM./sql 配下を grep してみます。

$ grep -rn "EQUAL_SYM" ./sql
./sql/lex.h:74:    {SYM("<=>", EQUAL_SYM)},
./sql/sql_yacc.yy:716:%token  EQUAL_SYM 416                     /* OPERATOR */
./sql/sql_yacc.yy:1405:%left   EQ EQUAL_SYM GE GT_SYM LE LT NE IS LIKE REGEXP IN_SYM
./sql/sql_yacc.yy:10462:        | EQUAL_SYM { $$ = &comp_equal_creator; }

上から順に見ていくと、

./sql/lex.h:74:    {SYM("<=>", EQUAL_SYM)},

これはさっき見た定義ファイル。

./sql/sql_yacc.yy:716:%token  EQUAL_SYM 416                     /* OPERATOR */
./sql/sql_yacc.yy:1405:%left   EQ EQUAL_SYM GE GT_SYM LE LT NE IS LIKE REGEXP IN_SYM

これは前者がよく分からないけど、後者はただ比較演算子を列挙しているだけっぽいのでスルー。

./sql/sql_yacc.yy:10462:        | EQUAL_SYM { $$ = &comp_equal_creator; }

次に見るとしたら comp_equal_creator な気がするので、grep してみます。

$ grep -rn "comp_equal_creator" ./
./sql/sql_yacc.yy:10267:            if ($2 == &comp_equal_creator)
./sql/sql_yacc.yy:10462:        | EQUAL_SYM { $$ = &comp_equal_creator; }
./sql/sql_parse.h:76:Comp_creator *comp_equal_creator(bool invert);
./sql/sql_parse.cc:6512:Comp_creator *comp_equal_creator(bool invert [[maybe_unused]]) {

上2つは、雰囲気的に comp_equal_creator 関数の呼び出し元で、下2つのどちらかが定義部分っぽい?

comp_equal_creator は、sql_parse.cc では以下のように定義されていました。

Comp_creator *comp_equal_creator(bool invert [[maybe_unused]]) {
  assert(!invert);  // Function never called with true.
  return &equal_creator;
}

最初の謎 assert は、コメントを信じると「引数 invert は基本的に true を取らない」と書いてあるし、引数定義部分で maybe_unused と注釈?があるので、読み飛ばします。

次に注目するワードは equal_creatorequal_creatorgrep するとさっき検索した comp_equal_creator も引っかかってしまうので、-w オプションを付けて完全一致で検索することにします。

$ grep -rnw "equal_creator" ./
./sql/item_cmpfunc.cc:287:Item_bool_func *Equal_creator::create_scalar_predicate(Item *a, Item *b) const {
./sql/item_cmpfunc.cc:292:Item_bool_func *Equal_creator::combine(List<Item> list) const {
./sql/sql_parse.cc:6514:  return &equal_creator;
./sql/mysqld.cc:1500:Equal_creator equal_creator;
./sql/item_cmpfunc.h:559:class Equal_creator : public Linear_comp_creator {
./sql/item_cmpfunc.h:2707:extern Equal_creator equal_creator;

Equal_creator というクラスの定義があるので、./sql/item_cmpfunc.h:559 を見てみます。

class Equal_creator : public Linear_comp_creator {
 public:
  const char *symbol(bool invert [[maybe_unused]]) const override {
    // This will never be called with true.
    assert(!invert);
    return "<=>";
  }

 protected:
  Item_bool_func *create_scalar_predicate(Item *a, Item *b) const override;
  Item_bool_func *combine(List<Item> list) const override;
};

ざっと見てみると、一番上の const は比較演算子の文字列定義、下の二つがメソッド? create_scalar_predicate(Item *a, Item *b) が引数を2つ取っていてそれっぽいので、grep してみます。

$ grep -rn "create_scalar_predicate" ./
./sql/item_cmpfunc.cc:257:  create_scalar_predicate().
./sql/item_cmpfunc.cc:275:  return create_scalar_predicate(a, b);
./sql/item_cmpfunc.cc:278:Item_bool_func *Eq_creator::create_scalar_predicate(Item *a, Item *b) const {
./sql/item_cmpfunc.cc:287:Item_bool_func *Equal_creator::create_scalar_predicate(Item *a, Item *b) const {
./sql/item_cmpfunc.cc:296:Item_bool_func *Ne_creator::create_scalar_predicate(Item *a, Item *b) const {
./sql/item_cmpfunc.h:544:  virtual Item_bool_func *create_scalar_predicate(Item *a, Item *b) const = 0;
./sql/item_cmpfunc.h:555:  Item_bool_func *create_scalar_predicate(Item *a, Item *b) const override;
./sql/item_cmpfunc.h:568:  Item_bool_func *create_scalar_predicate(Item *a, Item *b) const override;
./sql/item_cmpfunc.h:577:  Item_bool_func *create_scalar_predicate(Item *a, Item *b) const override;

どうやら実装は ./sql/item_cmpfunc.cc:278 にあるようです。*Equal_creator::create_scalar_predicate を見てみます。

Item_bool_func *Equal_creator::create_scalar_predicate(Item *a, Item *b) const {
  assert(a->type() != Item::ROW_ITEM || b->type() != Item::ROW_ITEM);
  return new Item_func_equal(a, b);
}

最初に、また謎の assert。 一旦 assert は読み飛ばして、return new してる Item_func_equal で検索してみます。

$ grep -rn "Item_func_equal" ./sql
./sql/sql_help.cc:687:      Item *cond_topic_by_cat = new Item_func_equal(
./sql/sql_help.cc:689:      Item *cond_cat_by_cat = new Item_func_equal(
./sql/item_cmpfunc.cc:289:  return new Item_func_equal(a, b);
./sql/item_cmpfunc.cc:2493:bool Item_func_equal::resolve_type(THD *thd) {
./sql/item_cmpfunc.cc:2500:longlong Item_func_equal::val_int() {
./sql/item_cmpfunc.cc:2535:float Item_func_equal::get_filtering_effect(THD *, table_map filter_for_table,
./sql/item_cmpfunc.h:1075:class Item_func_equal final : public Item_func_comparison {
./sql/item_cmpfunc.h:1077:  Item_func_equal(Item *a, Item *b) : Item_func_comparison(a, b) {
./sql/item_cmpfunc.h:1080:  Item_func_equal(const POS &pos, Item *a, Item *b)

./sql/item_cmpfunc.h:1075 で、Item_func_equal クラスが定義されているので、早速見てみます。

/**
  The <=> operator evaluates the same as

    a IS NULL || b IS NULL ? a IS NULL == b IS NULL : a = b

  a <=> b is equivalent to the standard operation a IS NOT DISTINCT FROM b.

  Notice that the result is TRUE or FALSE, and never UNKNOWN.
*/
class Item_func_equal final : public Item_func_comparison {
 public:
  Item_func_equal(Item *a, Item *b) : Item_func_comparison(a, b) {
    null_on_null = false;
  }
  Item_func_equal(const POS &pos, Item *a, Item *b)
      : Item_func_comparison(pos, a, b) {
    null_on_null = false;
  }
  longlong val_int() override;
  bool resolve_type(THD *thd) override;
  enum Functype functype() const override { return EQUAL_FUNC; }
  enum Functype rev_functype() const override { return EQUAL_FUNC; }
  cond_result eq_cmp_result() const override { return COND_TRUE; }
  const char *func_name() const override { return "<=>"; }
  Item *truth_transformer(THD *, Bool_test) override { return nullptr; }

  float get_filtering_effect(THD *thd, table_map filter_for_table,
                             table_map read_tables,
                             const MY_BITMAP *fields_to_ignore,
                             double rows_in_table) override;
};

かなり核心に近づいている気がします。

Javadoc 的なやつを書いてくれてるのでざっくり雰囲気で読んでみると、

<=> 演算子は、

a IS NULL || b IS NULL ? a IS NULL == b IS NULL : a = b

と同じ動きをします。

a <=> b は、標準的な構文の a IS NOT DISTINCT FROM b と同じ働きをします。

この関数により返却されるのは TRUEFALSE のどちらかであり、UNKNOWN にはならないことに注目してください。

とのこと。(IS NOT DISTINCT FROM の構文知らなかった・・・。 *3

・・・

時間切れです。

すみません、Item_func_equal のクラスを見つけるのに精一杯で、結局宇宙船演算子の実装までは辿り着けませんでした。 敗因としては、コードリーディングの経験値の少なさと、そもそも C++ の構文を知らなさ過ぎて何となく当てずっぽうで読み進めてしまったところでしょうか。

次回はもう少し勉強して、踏み込んだ内容で記事を書けるよう研鑽します。

オライリー社出版の Understanding MySQL Internals がどうやら今の自分のニーズにぴったりの本で、実際にソースコードを読み進めていきながら MySQL の理解を深めていくという内容らしいので、会社の書籍購入支援制度で購入してもらって読んでみようと思います!!!

まとめ

残念ながら結論は出せませんでしたが、今回コードリーディングをしてみたことで、世界中で使われている超メジャーな OSS でもソースコードを落としてきてキーワードを grep していくだけである程度欲しい情報には近づけるということに気が付けた点においては大きな収穫と言えるかと思います。

普段何気なく使っているツール類でも「あれ、これってどうやって作ってるんだろう?」という視点で見てみると、より理解が深まったり新たなバグが発見出来てしまうかもしれません。

それでは、次回「MySQL のソースを読んでみる ~宇宙船演算子完全に理解した編~」でお会いいたしましょう。


スタイル・エッジLABO では、一緒に働く仲間を募集しています。
もし興味を持っていただけましたら、以下の採用サイトも一度覗いてみてください!

recruit.styleedge-labo.co.jp

*1:正式には「NULL安全等価演算子(英:NULL-safe equal)」
dev.mysql.com

*2:先輩に教えてもらいました。読みは「コ(ウ)アレス」とのこと。

*3:「IS DISTINCT FROM」は SQL:1999、「IS NOT DISTINCT FROM」は SQL:2003 にて策定されたそうです。
modern-sql.com

Laravelでファットコントローラーを防ぎたい

はじめに

こんにちは!スタイル・エッジLABOのZNです。
2022年が始まったと思えば、もう2ヶ月が経ってしまいました。
エンジニアLIFEは、時間の流れが早いように感じます。

私は約8ヶ月前に未経験でエンジニアとして「スタイル・エッジLABO」に入社しました。
未経験でのエンジニアへの挑戦だったので、面接にはポートフォリオを持参し挑みました。
ポートフォリオはLaravelというフレームワークを使用して、チャットのようなものを作成したのを覚えています。
入社から8ヶ月経った今、そのポートフォリオの中身を見ると「ザ・ファットコントローラー」だったので、Laravelでファットコントローラーを防ぐ秘訣について少し紹介できればと思います!

そもそもファットコントローラーとは何...?

Laravelなどのフレームワークで用いられているコントローラーに様々な処理を任せてしまい、1つのコントローラー内、1つのメソッド内の行数が多くなってしまって肥大化していることを指します。

f:id:styleedge_tech:20220210163851p:plain

では、何が問題なのかをいくつか挙げてみます。

はじめに、「どこに処理を記述したかが分かりにくく、コードを追いにくい」 ということが挙げられます。
実装した本人でさえ後から処理を追おうとすると、どこに記述したか・どういった経緯でDBから値を持ってきたかが分かりにくくなります。ましてや、実装者以外がコードを見たときは言うまでもなく、処理を追うのがとても大変になります。
そのため、複数人の開発では大きな問題になります。

次に、「改修漏れが生じやすい」ことが考えられます。
ファットコントローラーでは同じ処理を至るところに書いている場合が多く、仕様変更などがあった場合は同じ処理のところを全て変更しなければならなくなり、改修漏れが生じやすくなってしまいます。

ファットコントローラーにはこのような問題が挙げられ、 私のポートフォリオでもよく起こっていました。

それでは、ファットコントローラーにならないためにはどうしたら良いのか、いくつか秘訣を紹介します。

ファットコントローラーを防ぐ秘訣

その壱:バリデーションは FormRequest にまとめる

これはファットコントローラーと言われたら一番に思いつく内容ではないでしょうか。

Laravelには、独自のバリデーションおよび認可ロジックをカプセル化するカスタムリクエストクラスで、標準の機能として FormRequest が用意されています。
バリデーションをコントローラー内に記述する際、入力パラメータや条件が複数ある場合や仕様変更により入力パラメータが増えた場合は、コントローラー内の行数は確実に増え、読みにくくなってしまいます。
そのため、バリデーションは FormRequest にまとめてしまい、バリデーションによる肥大化を防ぎます。
仕様変更があった際や、入力パラメータが増えた場合にも FormRequest のみを変更するだけで済むようになります。

その弍:DBの処理はRepositoryに任せる

LaravelなどのMVCフレームワークを使用している場合、コントローラーにSQLを直接記述してしまうことで、どんどん肥大化していってしまいます。
おそらく、これが見た目の部分で見づらいなと思う大きな要因になっているのではないでしょうか。
扱うテーブルやカラムが多くなればなるほど、コントローラー内の行数は多くなってきてしまいます。
FormRequest のようにLaravel標準の機能ではない為に少しレベルは上がりますが、DBの処理はRepositoryにまとめることで、コントローラー内をスッキリさせることができます。

その参:コントローラーやメソッド間で重複している処理は共通化する

コントローラーやメソッド間で重複している処理は、共通のメソッドを作成してそのメソッドを呼び出すようにします。

私はポートフォリオを作成中に、ほとんど同じデータで結果も似たようなものだったのにもかかわらず、何度も同じ処理を書いていたので手間がかかりましたし、とても読みにくいコードになっていました。
もし仕様変更などでDBの内容に変更が生じた場合には、その処理を書いているところは全て修正しなくてはならなくなります。
通化しておけば修正箇所は1つで済みますし、プロジェクトに新規参画したメンバーにも分かりやすいコードになります。
しかしながら、共通化できそうなコードを無理に共通化しようとすると、意図しない値の置き換えが生じる場合があるので注意も必要です。

おわりに

いかがだったでしょうか。今回は実体験を元にファットコントローラーを防ぐ方法を幾つか紹介しました。

Laravelは自由度が高いことから、レベル感が異なるメンバー同士での開発ではコーディングにバラつきが出てしまったり、勉強したての頃はコードが煩雑になりやすいです。
個人開発では好きなところに好きにコードをかけましたが、共同開発ではいかに読みやすいコードにするかがポイントになってくると思います。

今回紹介した内容だけでファットコントローラーが解消される訳ではなく、より良い方法や対策案はいくつもあると思います。
また、Laravelは奥が深く、私もまだまだ理解が追いついていない部分も多くありますが、日々勉強し少しずつ前進しています!
スタイル・エッジLABOの一員としてより良いシステムを開発していけるように、これからも全力を尽くします!!!

☆スタイル・エッジLABOでは、一緒に働く仲間を募集しています☆ もし興味を持っていただけましたら、採用サイト↓も覗いてみてください!

recruit.styleedge-labo.co.jp