MetabaseAPIでGoogle認証をするまで

この記事は個人ブログ記事の転載です。

Metabaseに登録されているネイティブクエリを全件取得したかった事案がありました。 MetabaseAPIなるものがあるらしいので、それを使ってみました。
APIを使用するためにはログインが必要です。ログインのAPIは以下のようになっているのですが・・・

POST /api/session/google_auth

Login with Google Auth.

PARAMS:
  • token value must be a non-blank string.
  • request

が、この token が一体何のトークンなのかさっぱりわかりません。( 調べた結果、JWTのid-tokenでした。)
ググってもあまり情報が出て来なかったので、手順をまとめてみます。

GoogleアカウントのOAuthキーを作成する

GoogleAPIsから、OAuthのクライアントIDを作成します。

設定としては、以下のとおり。

また、OAuthのクライアントIDを作成する場合は事前にOAuthの同意画面を作成しておく必要があります。
テスト用に使うので、てきとーに設定しておきます。

作成するとクライアントIDとクライアントシークレットが作成されるので、メモっておきます。

認可画面に飛ばす

MetabaseがOAuthのトークンを必要とするということは、OAuthのフローで認可画面を踏まないといけないということです。
今回はleague/oauth2-googleを使用してみました。

<?php
// index.php

require_once __DIR__ . '/vendor/autoload.php';

$provider = new \League\OAuth2\Client\Provider\Google([
    'clientId' => 'YourClientID',
    'clientSecret' => 'YourClientSecret',
    'redirectUri' => 'http://localhost/callback.php',
]);

// 認可用URIへリダイレクト
header("Location: {$provider->getAuthorizationUrl()}");

sudo php -S localhost で簡易サーバを立ち上げアクセスすると、Googleログインの画面が表示されるはずです。

トークンの取得

Googleでのログインが終わると、登録したコールバックURIに遷移します。(ここでは、 http://localhost/callback.php )
callback後に、JWTのid-tokenを取得します。

<?php
// callback.php

require_once __DIR__ . '/vendor/autoload.php';

$provider = new \League\OAuth2\Client\Provider\Google([
    'clientId' => 'YourClientID',
    'clientSecret' => 'YourClientSecret',
    'redirectUri' => 'http://localhost/callback.php',
]);

// 正常系の場合はcodeというクエリパラメータが付いてくる
assert(isset($_GET['code']));

$token = $provider->getAccessToken('authorization_code', ['code' => $_GET['code']]);
// getValue()でJWT形式の各値を取得できる
$idToken = $token->getValues()['id_token'];

認証とMetabaseAPIの利用

id-tokenを取得できたら、それを利用してMetabaseAPIを使うことができます。
/api/session/google_authのレスポンスにidというキーがあるので、これをヘッダーのX-Metabase-Sessionにセットすることで、各種APIにアクセスできます。

<?php
$guzzle = new \GuzzleHttp\Client();

// metabaseでの認証認可
$response = $guzzle->request('POST', 'https://your.metabase.url/api/session/google_auth', [
    'json' => [
        'token' => $idToken,
    ],
]);
// JSON形式でレスポンスが来るのでデコードする
$sessionData = json_decode($response->getBody()->getContents());
// 成功の場合は必ずidがある
assert(isset($sessionData->id))

// X-Metabase-Sessionに取得したIDを設定することで、各APIを使用できる
$response = $guzzle->request('GET', 'https://your.metabase.url/api/card', ['headers' => [
    'Content-Type' => '‘application/json',
    'X-Metabase-Session' => $sessionData->id,
]]);
$cards = json_decode($response->getBody()->getContents());

Pythonでスクレイピングしてアニメの放送日時を取得してJsonにする

はじめに

昨年末に実家が空き巣に入られたり、年始早々身体に異変があってメンタル的に疲れているところに自分史上間違えなく上位に入る節々の痛さと突発的高熱、その翌週には、首を盛大に寝違えてキョンシーみたいな姿勢での生活を余儀なくされました。どうもagent bank 開発のhironekoです。

ちなみに巷で流行っているコロナではなく、ただの風邪で2日半くらいで回復して味覚等々を失うことなく今は、元気に業務と麻雀に励んでいます。

前回のブログ

  • GitHub Actionsのcronがあるらしいってことでそれを使ってアニメの放送日にSlackへ通知しようぜ!ってのをやりました。

あれから一ヶ月

  • おかげさまでアニメを見逃す日がなくなりました!(素晴らしい)

改善点と改善案

GitHub Actionsのcronラグあり過ぎじゃね?問題

  • こちらは、無料枠で無料でGiHub様の善意の元リソースを使わせていただいているのでちょっと文句言いたいけどスルーすることにしました
    • ただ何かしらのプロダクトには、活用場面なさそうって思いました。

Jsonデータで管理するのだるい問題

  • 前回の目的は、見たいアニメの放送を見逃さない。っていう点にしか焦点は当たっておらずデータの収集や管理に関しては、全くの無関心でした
  • なので改善点としては、以下になると思います。
    • JsonデータのGitHub以外での管理
    • Jsonデータを自動で作成する

改善点挙げておいてなんですが何かと初物や久々(何かを)に触るのがいいと思っていますので、遠い昔に触ったFirebaseでJsonを管理しようかなと考えたんですがクレデンシャルをどう管理すんねんS3か?って考えたんですがこのプロジェクトは、すべてを無料で!を心がけているので却下です。(微々たる金額で済むのも想定できるんですがね)

今回の実装

スクレイピングJsonデータ作るぞの巻きに決定しました。

やらないこと

前提

import requests
from bs4 import BeautifulSoup
from os import getenv
import json
import datetime as dt
import calendar

today = dt.date.today()
dayName = calendar.day_name[today.weekday()]

if today.month < 10:
    month = f'0{today.month}'
else:
    month = today.month

weekList = {
    '月': 'Monday',
    '火': 'Tuesday',
    '水': 'Wednesday',
    '木': 'Thursday',
    '金': 'Friday',
    '土': 'Saturday',
    '日': 'Sunday'
}

load_url = getenv('SCRAPING_TARGET_URL')
html = requests.get(load_url)
soup = BeautifulSoup(html.content, "html.parser")

animeList = soup.find(class_='week_area').find_all('li')

jsonList = []
for e in soup.find(class_='week_area').find_all('li'):
    week = weekList[e.find(class_="oatime").find(class_='youbi').text]
    e.find(class_="oatime").find(class_='youbi').decompose()
    e.find('h4').find('strong').decompose()

    item = {
        'title': e.find('h4').text,
        'publish_at': e.find(class_="oatime").text,
        'channel': 'TOKYO MX',
        'day_of_week': week
    }
    jsonList.append(item)


with open(f'./data/anime/{today.year}{month}.json', 'w') as f:
    json.dump(jsonList, f, ensure_ascii=False, indent=4)

前回同様解説していきます

コードを読めばわかるって内容なので解説すべきことはないんですけどね!   スクレイピングしたあとのDOM操作に関しては、ほんと愚直な方法しか思いつかなかったので誰かいい方法があったら教えて欲しいです

  - あーどうしよっておもったDOM操作が下記のようなHTMLでした

<div class="oatime"><p class="youbi"></p>25:05~ </div>
  • こんな形のHTMLだとclass="oatime"直下のテキストを抜くと25:05〜が改行付きで取得できてしまう。なので先に曜日だけ取得して曜日のクラスのDOMを削除して時刻の取得を行うってことをしました。
    • 下記のような処理
week = weekList[e.find(class_="oatime").find(class_='youbi').text]
e.find(class_="oatime").find(class_='youbi').decompose()

さきに最終的なJsonファイルをどうぞ

[
    {
        "title": "\n7SEEDS\n",
        "publish_at": "22:30~ ",
        "channel": "TOKYO MX",
        "day_of_week": "Monday"
    },
    {
        "title": "\nたとえばラストダンジョン前の村の少年が序盤の街で暮らすような物語\n",
        "publish_at": "23:00~ ",
        "channel": "TOKYO MX",
        "day_of_week": "Monday"
    },
   ......
]
  • 最終的に前回手動で作成したJsonファイルと同じような形式をとることにしました。(処理をわざわざ変えるのもなーっておもったので)
    • ファイル名に関しては、前回ファイル名を取得する際の方法で生成したyyyymmの形式にしました。

新たな問題点

  • TOKYO MXしか見ねー的なノリで始めたんですが、推しキャラの二乃がいる五等分の花嫁約束のネバーランドがT◯Sやフ◯TVじゃないですか。
  • これに関しては、それ以外の取得に関して考えて、結果マージしたデータでSlackに飛ばすようにするんだろうなってうっすら考えてます。まぁどちらもアマプラでいつでも見れるんですけどね!

  • いつcronで動かすよ問題!

    • 1クール単位がいいんですがー言うてそんな設定できるんか?基準日を考慮できないと思うのでn日毎には実行難しそう。
    • ってことでこちらは、月次で実行させておそらくPython側で判定する形にするでしょう!

反省

Pythonのお作法わからずだぁーって書いてしまっているので、リファクタリングとmodule化等を行っていきたいと思います。年末年始、特に年末が何してたかわからんくらいな状態だったので何もできなかったのでこれから平日含め学習をする癖がつくことただただ願います。

最後に独り言

アニメの通知ってamebaTVでできません?やアルっていう漫画サービスで発売日通知できますよって会社の同僚に言われました。「そそそそそうだったの」って内心思ったのは、内緒です。

AWS CloudWatch Logs のログデータを S3 に配信する(Kinesis Data Firehose)

この記事は個人ブログと同じ内容です

www.ritolab.com


CloudWatch Logs では、AWS のコンソールからログデータを S3 にエクスポートできます。

f:id:ro9rito:20210118223828p:plain

全ログデータを CloudWatch 側で保持して、ログデータを抽出する必要があった場合は都度 S3 にエクスポートして使うっていう運用ならこれで問題ないと思います。

別のケースとして、以下のような場合は、CloudWatch Logs から S3 に定期的にログを自動で配信する仕組みが欲しくなります。

  • CloudWatch Logs のログデータを活用するために全てのデータを S3 にも溜めたい(データレイク的なあれ)
  • CloudWatch Logs 側のログデータ保持は限定的(数カ月間だけとか)にするけど、過去全てのログデータは S3 に保管しておきたい(Glacier で氷漬けにする的なあれ)

今回は、CloudWatch Logs のログデータを自動で S3 に配信してみます。

Kinesis Data Firehose

CloudWatch Logs のログデータを S3 に配信するためには、Kinesis Data Firehose を使うとサクッといけます。

Amazon Kinesis Data Firehose

https://aws.amazon.com/jp/kinesis/data-firehose/

Amazon Kinesis Data Firehose は、ストリーミングデータをデータレイクやデータストア、分析サービスに確実にロードする最も簡単な方法を提供するサービスです。
ストリーミングデータを取り込んで変換し、Amazon S3Amazon Redshift、Amazon Elasticsearch Service、汎用 HTTP エンドポイント、さらに Datadog、New Relic、MongoDB、Splunk のようなサービスプロバイダーに配信できます。

流れとしては以下になります。

  1. CloudWatch Logs から Kinesis Data Firehose にログデータを送信
  2. Kinesis Data Firehose から S3 にログデータを配信

サブスクリプションフィルタ

CloudWatch Logs にはサブスクリプションフィルタという機能があり、これを設定する事で Kinesis Data Firehose にログデータを送信できるようになります。

f:id:ro9rito:20210118224036p:plain

しかしながらキャプチャを見ての通り、AWS コンソールからは Elasticsearch もしくは Lambda のサブスクリプションフィルタしか作成できません。(2021年1月16日現在)

なので、Kinesis へのサブスクリプションフィルタを作成するためには AWS CLI などを使用する必要があります。

そういう理由もあり今回は AWS コンソール画面ではなく Terraform(AWS に対応したインフラオーケストレーションツール)を使って一連の仕組みを構築していこうと思います。

Terraform

https://www.terraform.io/

S3 サブスクリプションフィルタ以外は AWS コンソール画面からも作成できるので、この記事の前半はすっ飛ばせます。

ロググループと S3 バケットの作成

main.yml

# CloudWatch Logs - LogGroup
resource "aws_cloudwatch_log_group" "my_test" {
  name = "my-test"
}

# S3 - Bucket
resource "aws_s3_bucket" "cloud_watch_logs" {
  bucket = "my-test-logs"
  acl    = "private"
}

Kinesis Data Firehose 配信ストリームの作成

まずは S3 への配信が行えるように Kinesis 用の IAM Role を作成します。

main.yml

# CloudWatch Logs - LogGroup
# IAM Role - for Kinesis
resource "aws_iam_role" "kinesis_data_firehose_send_log_to_s3" {
  name               = "tf-KinesisFirehoseServiceRole-my-test"
  assume_role_policy = file("aws_iam_role_kinesis_data_firehose.json")
}

# IAM Policy - for Kinesis
resource "aws_iam_policy" "kinesis_data_firehose_send_log_to_s3" {
  name        = "tf-KinesisFirehoseServicePolicy-my-test"
  policy      = templatefile("aws_iam_policy_kinesis_data_firehose", {
    aws_id              = var.aws_id
    region              = var.region
    bucket_arn          = aws_s3_bucket.my_test.arn
    cloudwatch_logs_arn = aws_cloudwatch_log_group.my_test.arn
  })
}

# Attach Policy to Role
resource "aws_iam_role_policy_attachment" "kinesis_data_firehose_send_log_to_s3" {
  role       = aws_iam_role.kinesis_data_firehose_send_log_to_s3.name
  policy_arn = aws_iam_policy.kinesis_data_firehose_send_log_to_s3.arn
}

読み込んでいるポリシーのファイルはそれぞれ以下

aws_iam_role_kinesis_data_firehose.json

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "firehose.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "",
            "Effect": "Allow",
            "Action": [
                "s3:AbortMultipartUpload",
                "s3:GetBucketLocation",
                "s3:GetObject",
                "s3:ListBucket",
                "s3:ListBucketMultipartUploads",
                "s3:PutObject"
            ],
            "Resource": [
                "${bucket_arn}",
                "${bucket_arn}/*"
            ]
        },
        {
            "Sid": "",
            "Effect": "Allow",
            "Action": [
                "logs:PutLogEvents"
            ],
            "Resource": [
                "${cloudwatch_logs_arn}"
            ]
        },
        {
            "Sid": "",
            "Effect": "Allow",
            "Action": [
                "kinesis:DescribeStream",
                "kinesis:GetShardIterator",
                "kinesis:GetRecords",
                "kinesis:ListShards"
            ],
            "Resource": "arn:aws:kinesis:${region}:${aws_id}:stream/%FIREHOSE_POLICY_TEMPLATE_PLACEHOLDER%"
        },
        {
            "Effect": "Allow",
            "Action": [
                "kms:Decrypt"
            ],
            "Resource": [
                "arn:aws:kms:${region}:${aws_id}:key/%FIREHOSE_POLICY_TEMPLATE_PLACEHOLDER%"
            ],
            "Condition": {
                "StringEquals": {
                    "kms:ViaService": "kinesis.${region}.amazonaws.com"
                },
                "StringLike": {
                    "kms:EncryptionContext:aws:kinesis:arn": "arn:aws:kinesis:${region}:${aws_id}:stream/%FIREHOSE_POLICY_TEMPLATE_PLACEHOLDER%"
                }
            }
        }
    ]
}

Kinesis Data Firehose 側の配信ストリームを作成します。

main.yml

# Kinesis Data Firehose - delivery stream
resource "aws_kinesis_firehose_delivery_stream" "s3_stream" {
  name        = "tf-my-test-send-log-from-cwl-to-s3-stream"
  destination = "extended_s3"

  extended_s3_configuration {
    bucket_arn         = aws_s3_bucket.my_test.arn
    role_arn           = aws_iam_role.kinesis_data_firehose_send_log_to_s3.arn
    buffer_size        = 10  # MB
    buffer_interval    = 300 # second
    compression_format = "UNCOMPRESSED"
    s3_backup_mode     = "Disabled"
  }
}
  • destination には S3 を宛て先とするので extended_s3 を指定します
  • buffer_size は MB 単位です。今回の設定の場合は、受信バッファーが 10MB に到達したら buffer_interval に関係無く S3 に配信します。
  • buffer_interval は 秒単位です。今回の設定の場合は、5 分経過したら(=5分おきに)buffer_size に関係無く S3 に配信します。
  • compression_format は圧縮についての設定ですが、CloudWatch Logs のサブスクリプションフィルタを介して Kinesis に送信されるログは Base64 エンコードされて gzip に圧縮されるので、ここでの圧縮は不要です。

サブスクリプションフィルタのための IAM Role 作成

CloudWatch Logs のサブスクリプションフィルタを作成するにあたり、必要なロールを作成しておきます。

main.yml

# IAM Role - for Cloud Watch Logs
resource "aws_iam_role" "cloud_watch_logs_send_log_to_kinesis" {
  name               = "tf-CloudWatchLogsServiceRole-my-test-send-log-to-kinesis"
  assume_role_policy = templatefile("aws_iam_role_cloud_watch_logs_send_log_to_kinesis.json", {
    region = var.region
  })
}

# IAM Role Policy - Cloud Watch Logs
resource "aws_iam_policy" "cloud_watch_logs_send_log_to_kinesis" {
  name        = "tf-CloudWatchLogsServicePolicy-my-test-send-log-to-kinesis"
  policy      = templatefile("aws_iam_policy_cloudwatch_logs_send_log_to_kinesis.json", {
    kinesis_data_firehouse_arn = aws_kinesis_firehose_delivery_stream.s3_stream.arn
  })
}

# Attach Policy to Role
resource "aws_iam_role_policy_attachment" "cloud_watch_logs_send_log_to_kinesis" {
  role       = aws_iam_role.cloud_watch_logs_send_log_to_kinesis.name
  policy_arn = aws_iam_policy.cloud_watch_logs_send_log_to_kinesis.arn
}

読み込んでいるポリシーのファイルはそれぞれ以下

aws_iam_role_cloud_watch_logs_send_log_to_kinesis.json

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "logs.${region}.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}

aws_iam_policy_cloudwatch_logs_send_log_to_kinesis.json

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "firehose:PutRecord",
                "firehose:PutRecordBatch"
            ],
            "Resource": [
                "${kinesis_data_firehouse_arn}"
            ]
        }
    ]
}

ここまでサブスクリプションフィルタ設定の前の準備として Terraform での設定を書きましたが、これらは AWS コンソール画面からでも作成できます。

次の S3 サブスクリプションフィルタ作成が、前述の通り AWS CLI かその他 Terraform 等のツールで行う必要があります。

CloudWatch Logs S3 サブスクリプションフィルタの作成

S3 サブスクリプションフィルタを作成します。

main.yml

## Subscription Filter
resource "aws_cloudwatch_log_subscription_filter" "s3" {
  name            = "tf-my-test-s3-subscription-filter"
  role_arn        = aws_iam_role.cloud_watch_logs_send_log_to_kinesis.arn
  log_group_name  = aws_cloudwatch_log_group.my_test.name
  destination_arn = aws_kinesis_firehose_delivery_stream.s3_stream.arn
  filter_pattern  = ""
}

filter_pattern(フィルターパターン)について、ここには何も記述していないですが、この場合は全てのログイベントに一致(=全てのログが対象)します。

これを例えば、「ELB のヘルスチェックは除外したい」の場合は以下のように設定する事で kinesis への送信を行わないようにできます。

filter_pattern  = "-\"ELB-HealthChecker/2.0\""

フィルターとパターンの構文

docs.aws.amazon.com

動作確認

S3 サブスクリプションフィルタを CloudWatch Logs に設定できたので、S3 を確認してみます。

f:id:ro9rito:20210118224616p:plain

S3 にログが配信された事が確認できました。

ちなみにこれを S3 Select で見てみると

{
    "messageType": "DATA_MESSAGE",
    "owner": "xxxxxxxx",
    "logGroup": "/my-test",
    "logStream": "php/sample/4478f375fl19400daf513cb5b0cd77a9",
    "subscriptionFilters": [
        "tf-my-test-s3-subscription-filter"
    ],
    "logEvents": [
        {
            "id": "123456789...",
            "timestamp": 1610777406264,
            "message": "[2021-01-16 15:10:06] local.INFO: message test. {\"type\":\"info\"} \n"
        },
        {
            "id": "123456789...",
            "timestamp": 1610777406264,
            "message": "[2021-01-16 15:10:06] local.WARNING: message test. {\"type\":\"warning\"} \n"
        }
    ]
},
{
    "messageType": "DATA_MESSAGE",
    "owner": "xxxxxxxx",
    "logGroup": "/my-test",
    "logStream": "nginx/sample/44k86375f03b410daf513p2xb0cd77a9",
    "subscriptionFilters": [
        "tf-my-test-s3-subscription-filter"
    ],
    "logEvents": [
        {
            "id": "123456789...",
            "timestamp": 1610777410150,
            "message": "12.3.45.57 - - [16/Jan/2021:15:10:10 +0900] \"GET /sample/1 HTTP/1.1\" 200 17486 \"-\" \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36\" \"123.45.67.89\""
        }
    ]
}

問題なく配信されてきているようですね。

コストどんな感じだろう

とはいえ、kinesis もお高いんでしょう?という気持ちになったので、CloudWatch ログデータ保存と S3 のデータ保存、そして Kinesis Data Firehose の取り込みデータ料金を見てみました。(2021/01/16時点・東京リージョン)

  • CloudWatch Logs(ログデータ保存)
    • 0.033 USD/GB
  • S3 標準(ストレージ 〜 50TB)
    • 0.025 USD/GB
  • S3 Glacier(〜 50TB)
    • 0.005 USD/GB
  • Kinesis Data Firehose(取込データ量 最初の 500 TB/月)
    • 0.036USD/GB

aws.amazon.com

aws.amazon.com

aws.amazon.com

「ログデータはサービスが終了するまで永久に持ち続ける」とした場合に、まあふつうに考えたら Kinesis 稼働分のコストもあるので CloudWatch Logs のみを使い続けた方がコストは安いようにも思えるけど、例えば、以下のような使い方をした場合

  • CloudWatch Logs
    • 3 ヶ月間だけログデータを保持
  • Kinesis Data Firehose
    • 常時 S3 へログデータを配信
  • S3
    • kinesis から配信されたログデータは 標準 に格納後、3 ヶ月後に Glacier へ移動

この構成だと、7 ヶ月後にはコストがトントンになって、8 ヶ月後には CloudWatch Logs のみで運用するよりも月額のコストが安くなった。(参考程度なのできちんとした試算は各自でお願いします)

f:id:ro9rito:20210118224837p:plain

永久に持ち続けないとしても年単位では持つと思うので、そう考えてもお得かな。ログデータはサービスが動いている以上永延に溜まるし、Glacier 様々という結論に至りました。(あんまり本筋とは関係ない結論)

まとめ

AWS にログデータを溜める時には Glacier を上手く活用しましょう。

ともあれ CloudWatch Logs から S3 へログデータをそっと配信できたのでこれにて終了です。

Twilioを使ってLaravelのSMS通知をする

※ 本記事はZennに投稿したものを転載したものになります。よかったらZennの方の評価もよろしくおねがいします。

zenn.dev

こんにちはみなさん

うん、まあ、ちょっとしたアクシデントがありまして、Laravelが公式提供しているVonage Communication Apiを使ったSMS通知が使えなくなってしまったのですよね。 というわけで、別のSMSサービスとしてTwilioを使うようにします。

ではやっていきましょう。

問題設定

今回はユーザへのSMS通知を、Twilioを使って実現してみましょう。 方針は以下の通りです。

  • SMS通知チャンネルを作る
  • 通知チャンネルで使用するクライアントをTwilioにする。
  • 実際に送信する

結局どうしたかというと、パッケージを作成して、簡単にtwilioを入れられるようにしたって感じです。

Quick Start

どうやっているのかを知りたい人はほとんどおらんと思いますので、どうやれば動かせるかを先に解説しちゃいます。

パッケージインストール

まず、パッケージとsdkを導入します。

composer require niisan/laravel-sms-notification twilio/sdk

個人的な趣向により、sdkを直接はパッケージに依存させていないのです。

ついで、設定ファイルを作ります。

php artisan vendor:publish --provider "Niisan\Notification\SmsNotificationServiceProvider"

設定ファイルの中身は、基本的には環境変数で外から入れることができます。

SMS_DRIVER=twilio
SMS_DEFAULT_FROM='+1111222233'
TWILIO_SID=***********************
TWILIO_TOKEN=***********************

SID と TOKEN はTwilioに登録したときにもらえます。 また、FROMはTwilioにログインしたときのダッシュボードから、無料の電話番号を発行して、それを使います。

通知の方法

SMS通知をたくさんうつ場合は通知対象のモデルに以下のメソッドをはやしておきます。

<?php
// 省略
class User extends Authenticatable
// 省略
    public function routeNotificationForSms()
    {
        return $this->phone_number;
    }

細かいことですが、Twilioでは国際電話番号の規格を使用していて、+から必要になるようです。(ex. +1122223333 )

次にNotificationを作ります。

<?php

namespace App\Notifications;

use Illuminate\Notifications\Notification;
use Niisan\Notification\SmsMessage;

class SmsNotification extends Notification
{
    /**
     * Get the notification's delivery channels.
     *
     * @param  mixed  $notifiable
     * @return array
     */
    public function via($notifiable)
    {
        return ['sms'];
    }

    public function toSms($notifiable): SmsMessage
    {
        return (new SmsMessage)
                ->unicode()
                ->body('hello world! こんにちは!' . $notifiable->name);
    }
}

viaメソッドでsmsを追加し、toSmsメソッドで、メッセージクラスを返します。 SmsMessageオブジェクトで、メッセージに日本語を含める場合はunicodeメソッドで、マルチバイトをオンにしておきます。

あとは、普通にnotifyするだけです。

$user->notify(new SmsNotification);

おまけ

送信先や送信元をNotificationで変更することもできます。

        return (new SmsMessage)
                ->unicode()
                ->body('hello world! こんにちは!' . $notifiable->name)
        ->to('+1122223333')
        ->from('+2233334444')

こうすると、notifiableやdefault_fromに設定してある電話番号を無視して、ここで設定した電話番号を使うようにします。

SMS通知チャンネルを作る

ここからはパッケージの実装を説明します。多くの人にはいらんですが、興味のある方だけ見て行ってくださいませ。

公式のパッケージではないので、Laravelの通知チャンネルを拡張する必要があるわけですが、公式のマニュアルがちょっと不親切というか、interface用意しておいてほしいのよね。 https://laravel.com/docs/8.x/notifications#custom-channels

チャンネルの拡張

SMSの通知チャンネルをこんな風に作っておきます。

<?php
namespace Niisan\Notification;

use Illuminate\Notifications\Notification;
use Niisan\Notification\Contracts\SmsClient;
use RuntimeException;

class SmsChannel
{

    /** @var SmsClient $client */
    private $client;

    public function __construct(SmsClient $client)
    {
        $this->client = $client;
    }

    public function send($notifiable, Notification $notification)
    {
        $message = $notification->toSms($notifiable);
        if (!$message->to) {
            if (!method_exists($notifiable, 'routeNotificationForSms')) {
                throw new RuntimeException('A notifiable object must have "routeNotificationForSms" method.');
            }
            $message->to($notifiable->routeNotificationForSms());
        }

        $message->from(config('sms-notification.default_from'));
        
        $this->client->send($message);
    }
}

NotificationオブジェとのtoSmsメソッドからメッセージオブジェクトを取得し、必要であればtoやfromを入れて、クライアントに投げる形式になっています。 基本、クライアントはTwilio以外のも考えられると思ったので、他にも入れられるようにしてあります。

SmsClientはinterfaceになっており、public function send(SmsMessage $message): void;の未定義されていますので、これをクライアントごとに実装すればオッケーとなっています。

チャンネルの拡張を適用する

サービスプロバイダーを使って、チャンネルの拡張を適用します。

<?php
namespace Niisan\Notification;

use Illuminate\Notifications\ChannelManager;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\ServiceProvider;
use Niisan\Notification\Clients\Twilio;
use Niisan\Notification\Contracts\SmsClient;

class SmsNotificationServiceProvider extends ServiceProvider
{

    public function boot()
    {
        $this->publishes([
            __DIR__.'/config/sms-notification.php' => config_path('sms-notification.php'),
        ]);
    }

    public function register()
    {
        $this->app->bind(SmsClient::class, function ($app) {
            if (config('sms-notification.driver') === 'twilio') {
                $client = new \Twilio\Rest\Client(
                    config('sms-notification.twilio.sid'),
                    config('sms-notification.twilio.token')
                );
                return new Twilio($client);
            }
        });

        Notification::resolved(function (ChannelManager $service) {
            $service->extend('sms', function ($app) {
                return $app[SmsChannel::class];
            });
        });
    }
}

まあ、Twilioしかないんですがね。 一応、SmsClientについては入れ替えできるようになっているので、別のSMSサービス使いたい場合は新しいクライアントクラスを実装して、サービスプロバイダで入れ替えちゃえばオッケーです。 多分。

クライアントクラスの実装

クライアントクラスはそのまま、Twilioのクライアントをラップする感じです。

<?php
namespace Niisan\Notification\Clients;

use Niisan\Notification\Contracts\SmsClient;
use Niisan\Notification\SmsMessage;
use Twilio\Rest\Client;

class Twilio implements SmsClient
{

    private $client;

    public function __construct(Client $client)
    {
        $this->client = $client;
    }
    
    /**
     * Send message through Twilio.
     *
     * @param SmsMessage $message
     *
     * @return void
     */
    public function send(SmsMessage $message): void
    {
        $this->client->messages->create(
            $message->to,
            [
                'from' => $message->from,
                'body' => $message->body,
                'smartEncoded' => $message->unicode
            ]
        );
    }
}

これはもう、単純にTwilio SDKのクライアントをラップしているだけです。 別のサービスのクライアントを実装する場合は、Niisan\Notification\Contracts\SmsClientを実装しておいて、サービスプロバイダで入れ替えればオッケーです。

SmsMessageクラスは対して言及するところもないので、割愛します。

まとめ

ということで、Laravel公式のSMS通知が使えなくなってしまったので、自作したという話でした。 いや、Laravelはパッケージ自作して突っ込むの簡単ななんですが、微妙にinterfaceがないとか、足りんところもあるなぁといった感想。 まあ、パッケージづくりは楽しいというか、プロダクト開発のようにカネに直結しない分、気楽にできますね。 いや、カネ儲けも好きなんですがね。

今回はこんなところです。

おまけ

弊社ROXXでは、エンジニアさんをとてもとても採用しております。 興味のある方は私のtwitterにDMするかリプライつけてくれると、反応すると思います。 よろしくどうぞ!

twitter.com

docker で nginx & php-fpm の PHP 実行環境を構築する(TCP/UNIX domain socket)

この記事は個人ブログと同じ内容です

www.ritolab.com


nginx と php-fpm の構成で docker コンテナの PHP アプリケーション実行環境を構築してみます。

(開発環境構築の話ではないので docker-compose は使いません)

nginx と php-fpm の通信(How)が今回の話のメインです。

ディレクトリ構成など

docker イメージとコンテナの作成に入る前に、今回は php-fpm と nginx のコンテナを作成するため、それぞれ php-fpm 用に php というディレクトリを作成し、nginx 用に web というディレクトリを切って進めます。

そして、アプリケーション(の代わり)として index.phpphp-fpm 側に配置しておきます。

docker/
├─ php/
│   └ src/
│       └─ index.php
└─ web/

なお、index.php は phpinfo を表示させるだけの簡単なものになっています。

php/src/index.php

<?php phpinfo(); ?>

php-fpm の Dockerfile を作成

Dockerfile を作成して、まずは最もベースとなる部分の定義を行います。

php/ 配下に Dockerfile を作成し以下を記述します。

php/Dockerfile

FROM php:8.0-fpm-alpine

# install configure file
RUN cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini

# settings
COPY settings/php.ini /usr/local/etc/php/conf.d/php.ini

# app sources
COPY src /usr/share/nginx/html
  1. ベースイメージには PHP の公式イメージを指定しています。
  2. PHP の設定ファイルである php.ini を作成します。
  3. 追加で設定するための PHP 設定ファイルを設置しています。
  4. アプリケーションをドキュメントルートにコピーしています。

PHP の設定ファイル php.ini について

工程 2 では、PHP に予め用意されているテンプレートからそのまま php.ini を作成し設置しています。

設定値をデフォルトから変更したいものについては、予め用意した php.ini にその設定を記述し、工程 3 で conf.d/ 配下に設置する事で設定値を上書きしています。

php/settings/php.ini

[PHP]
date.timezone = "Asia/Tokyo"

なので、変更が不要なら工程 3 の記述は不要です。

nginx の Dockerfile を作成

次に、nginx 側の Dockerfile を作成します。

web/ 配下に Dockerfile を作成し以下を記述します。

web/Dockerfile

FROM nginx:1.19-alpine

# settings
COPY settings/default.conf /etc/nginx/conf.d/default.conf
  1. ベースイメージには nginx の公式イメージを指定しています。
  2. nginx の設定ファイルを設置します。

nginx - Docker Official Images

hub.docker.com

nginx と php-fpm の通信を定義する

nginx と php-fpm の通信方式は 2 通りあって、それぞれで設定ファイルや Dockerfile への記述が異なります。

どちらを採用すべきか?については検証と結論に至れていないので、今回は両方で構築してみます。

TCP で通信を行う

TCP で通信する場合は、nginx の設定ファイルを以下のように定義します。

web/settings/default.conf

server {
    listen 80;
    root   /usr/share/nginx/html;

    location / {
        index          index.php index.html index.htm;
        fastcgi_pass   php-sample:9000;
        fastcgi_index  index.php;
        fastcgi_param  SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include        fastcgi_params;
    }
}

設定のもろもろは条件によって書き方が異なるためここの記述自体はかなり簡略化してますが、ポイントは fastcgi_pass の値です。

fastcgi_pass php-sample:9000;

  • php-sample というのは、今回作成する php-fpm のコンテナ名です。この後、php-fpm のコンテナを立ち上げる際にこの名前で起動させるので、それをここに指定します。

  • :9000 はポート番号です。php-fpm のデフォルトの待受ポートは 9000 なのでこれを指定しています。

前項で php-fpm と nginx の、ベースの定義を行いましたが、TCP で通信する場合は nginx の設定ファイルの定義のみで実現できます。

動作確認

ではイメージの作成とコンテナの起動を行いアプリケーションを動作させてみます。

以下のコマンドを実行して環境を構築します。

# 1. PHP のイメージを作成
docker build --no-cache -t php/sample:20210109 .

# 2. nginx のイメージを作成
docker build --no-cache -t nginx/sample:20210109 .

# 3. ネットワークを作成
docker network create --driver bridge sample_nw

# 4. PHP のコンテナを起動
docker run --net=sample_nw --name php-sample php/sample:20210109

# 5. nginx のコンテナを起動
docker run -p 80:80 --net=sample_nw --name nginx-sample nginx/sample:20210109

ブラウザからアクセスします。

f:id:ro9rito:20210112202912p:plain

nginx と php-fpm を TCP で通信させてアプリケーションが実行できました。

最終的なファイル構成

docker/
├── php/
│   ├ Dockerfile
│   ├ settings/
│   │   └─ php.ini
│   └ src/
│       └─ index.php
└── web/
    ├ Dockerfile
    └ settings/
        └─ default.conf

Docker コンテナ・ネットワークについて

先程の環境立ち上げの際に、イメージとコンテナ以外に「ネットワーク」を作成しました。

1つのネットワークを作成しそのネットワークに各コンテナを接続する事で、コンテナ間が互いを識別できるようにしています。

作成したネットワークの詳細を確認すると、コンテナが2つ接続されている事がわかります。

# ネットワークを作成
docker network create --driver bridge sample_nw

# ネットワーク一覧
% docker network ls
NETWORK ID     NAME             DRIVER    SCOPE
9326dc6f3546   sample_nw        bridge    local

# 作成したネットワークを確認
% docker network inspect sample_nw
[
  {
      "Name": "sample_nw",
      "Id": "9326dc6f35463041ec9511fba0426bd81311a704f757da755a81a42fd5da51ef",
      "Created": "2021-01-09T04:31:03.73817688Z",
      "Scope": "local",
      "Driver": "bridge",
      "EnableIPv6": false,
      "IPAM": {
          "Driver": "default",
          "Options": {},
          "Config": [
              {
                  "Subnet": "172.18.0.0/16",
                  "Gateway": "172.18.0.1"
              }
          ]
      },
      "Internal": false,
      "Attachable": false,
      "Ingress": false,
      "ConfigFrom": {
          "Network": ""
      },
      "ConfigOnly": false,
      "Containers": {
          "61d2d36499511f76ea2e64cd3124da3fc0fc05aa5b8af115bd70a3b937258e31": {
              "Name": "nginx-sample",
              "EndpointID": "27a848cb6d4e39121a2a8fcd0ec69c0929411e6bf353b868c7dc70354a351ef9",
              "MacAddress": "08:19:ac:14:00:06",
              "IPv4Address": "172.18.0.3/16",
              "IPv6Address": ""
          },
          "acbc9a2bf88caeb1fad3a8d0d0e5a409642ea2651487abede28157ad883b027d": {
              "Name": "php-sample",
              "EndpointID": "b1fe75cad1c2e6d9541cfca82577a1e02e93107b6e026ecb476e9309456ae1ce",
              "MacAddress": "08:19:ac:14:00:05",
              "IPv4Address": "172.18.0.2/16",
              "IPv6Address": ""
          }
      },
      "Options": {},
      "Labels": {}
  }
]

Docker コンテナ・ネットワークの理解

docs.docker.jp

ちなみに、ネットワークを作成せずに、 nginx 側のコンテナ起動時に --link オプションを使って php-fpm 側のコンテナを識別させる方法もありますが、現在では非推奨となっています。

コンテナ・リンク機能(古い機能)

docs.docker.jp

UNIX ドメインソケット で通信を行う

UNIX ドメインソケットで通信する場合に TCP の時と違うのは、通信の為にソケットを指定する事と、nginx 側からソケットを参照できるようにする事です。

php-fpm

php/Dockerfile

FROM php:8.0-fpm-alpine

# add user
RUN addgroup -S nginx && adduser -S nginx -G nginx

# install setting
RUN cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini

# settings
COPY settings/zzz-docker.conf /usr/local/etc/php-fpm.d/zzz-docker.conf
COPY settings/php.ini /usr/local/etc/php/conf.d/app-php.ini

# unix socket
RUN mkdir /var/run/php-fpm
VOLUME ["/var/run/php-fpm"]

# app sources
COPY src /usr/share/nginx/html
  1. ベースイメージには PHP の公式イメージを指定しています。
  2. nginx ユーザを作成します。
  3. PHP の設定ファイルである php.ini を作成します。
  4. php-fpm の設定ファイル(更新分)を設置します。
  5. PHP の設定ファイル(更新分)を設置します。
  6. ソケットを設置するディレクトリを作成し、ボリュームマウントします。
  7. アプリケーションをドキュメントルートにコピーします。

php-fpm の設定ファイルはデフォルトで設置されていますが、UNIX ドメインソケットを利用するために値を変更したい(工程 5 のところ)ので、設定値を上書きするための設定ファイルを作成します。

php/settings/zzz-docker.conf

[www]
listen = /var/run/php-fpm/php-fpm.sock
listen.owner = nginx
listen.group = nginx
listen.mode = 0660

unix ドメインソケットを使うように指定したのと、ソケットを使用するユーザ(パーミッション)を設定しています。

owner/group は、nginx 側からソケットを参照するユーザを指定しています。

nginx

web/Dockerfile

FROM nginx:1.19-alpine

# settings
COPY settings/default.conf /etc/nginx/conf.d/default.conf
  1. ベースイメージには nginx の公式イメージを指定しています。
  2. nginx の設定ファイルを設置します。

Dockerfile は TCP の時と変わりません。

nginx の設定ファイルを以下に定義します。

web/settings/default.conf

server {
    listen       80;
    server_name  localhost;
    root         /usr/share/nginx/html;

    location / {
        index          index.php index.html index.htm;
        fastcgi_pass   unix:/var/run/php-fpm/php-fpm.sock;
        fastcgi_index  index.php;
        fastcgi_param  SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include        fastcgi_params;
    }
}

TCP の時との違いは、fastcgi_pass に unix ドメインソケットを指定している点です。

動作確認

ではイメージの作成とコンテナの起動を行いアプリケーションを動作させてみます。

以下のコマンドを実行して環境を構築します。

# 1. PHP のイメージを作成
docker build --no-cache -t php/sample:20210109 .

# 2. nginx のイメージを作成
docker build --no-cache -t nginx/sample:20210109 .

# 3. PHP のコンテナを起動
docker run --name php-sample php/sample:20210109

# 4. nginx のコンテナを起動
docker run -p 80:80 --volumes-from php-sample --name nginx-sample nginx/sample:20210109

ブラウザからアクセスします。

f:id:ro9rito:20210112203405p:plain

nginx と php-fpm を UNIX ドメインソケット で通信させてアプリケーションが実行できました。

最終的なファイル構成

docker/
├ php/
│   ├ Dockerfile
│   ├ settings/
│   │   ├─ php.ini
│   │   └─ zzz-docker.conf
│   └ src/
│       └─ index.php
└ web/
    ├ Dockerfile
    └ settings/
        └── default.conf

まとめ

Laravel や CakePHP 等の PHP フレームワークを動かす際も基本的には同じ要領で、nginx の設定ファイル側を調整したり、必要なパッケージを PHP 側に入れたりなどすれば動作させる事ができます。

実行環境なのでアプリケーションごとイメージに固めていますが、これらの dockerfile と docker-compose を併せて役割を分ければ開発環境の構築も可能です。

TypeScriptで深いJSON構造から要素を取り出すときに型をちゃんと取るTIPS

このブログは個人ブログと同じ内容です

kotamat.com

下記のような多段のマスターデータが存在しているときに、ちゃんと型安全に値を取り出したいとなったときの型定義を考える

large.json

{
  "1": "foo",
  "2": "baz"
}

detail.json

{
  "1": {
    "1001": "foo1",
    "1002": "foo2"
  },
  "2": {
    "2001": "baz1",
    "2002": "baz2"
  }
}

これに対して、下記のような参照をしたら、ちゃんと値を取りたい。

import large from "~/large.json";
import detail from "~/detail.json";

// anyもちゃんとした型に変更する
function getValue(largeKey: any, detailKey: any) {
  // hogeを型がちゃんとついてる状態で取得する
  const hoge = detail[largeKey][detailKey];
}

※ちなみに上記で import している large, detail は as const したような厳密な型定義になっていることを前提とする。

declare module "~/detail.json" {
  type Detail = {
    "1": {
      "1001": "foo1";
      "1002": "foo2";
    };
    "2": {
      "2001": "baz1";
      "2002": "baz2";
    };
  };
  const data: Detail;
  export default data;
}

Step1: 深さ指定して key の型を取れるようにする

https://qiita.com/KuwaK/items/587205867d333b705a41 を参考にさせていただき、下記のような型を作成する(Property2 を NthDepthProperty に変えているが、必要であればもとに戻します)

type NumMap = {
  3: 2;
  2: 1;
  1: 1;
};

type ValueOf<T> = T[keyof T];

export type NthDepthProperty<T, P extends keyof NumMap> = P extends 1
  ? keyof T
  : ValueOf<
      {
        [K in keyof T]: T[K] extends object
          ? NthDepthProperty<T[K], NumMap[P]>
          : never;
      }
    >;

簡単に説明すると、最大 3 段ネストするオブジェクトに対して、型引数 2 つ目に指定した階層に存在する key を Union Type にして返却するというもの。NumMap を増やせば当然いくらでもネストは可能だが、今回は 3 段のままで大丈夫なのでそのままにする

その上で元々のファイルを下記のように変えると、引数として望まれる型が適切に指定できる

import large from "~/large.json";
import detail from "~/detail.json";
import { NthDepthProperty } from "./types";

function getValue(
  largeKey: NthDepthProperty<typeof large, 1>, // largeKey: "1" | "2"
  detailKey: NthDepthProperty<typeof detail, 2> // detailKey: ValueOf<{"1": "1001" | "1002"; "2": "2001" | "2002"}> = "1001" | "1002" | "2001" | "2002"
) {
  const hoge = detail[largeKey][detailKey];
}

largeKey は keyof でもいいんだけど、見た目的に揃えた

Step2: Union Type を Intersection に変える型を用意する

上記のままでも行けそうな感じはするが、下記のようなエラーが出てしまう

(parameter) detailKey: ValueOf<{
    1: "1001" | "1002";
    2: "2001" | "2002";
}>
型 'ValueOf<{ 1: "1001" | "1002"; 2: "2001" | "2002"; }>' の式を使用して型 '{ "1001": "foo1"; "1002": "foo2"; } | { "2001": "baz1"; "2002": "baz2"; }' にインデックスを付けることはできないため、要素は暗黙的に 'any' 型になります。
  プロパティ '1001' は型 '{ "1001": "foo1"; "1002": "foo2"; } | { "2001": "baz1"; "2002": "baz2"; }' に存在しません。

これはdetail[largeKey] で取得した値の型が

{
    "1001": "foo1";
    "1002": "foo2";
} | {
    "2001": "baz1";
    "2002": "baz2";
}

となっており、例えばこのオブジェクトに対して"1001"という key を指定した場合、largeKey=2 で参照すると出てくる後方の型にマッチしない可能性があるためエラーになるかもしれないから。

回避策としては、下記のように丁寧に TypeGuard をして行くことも考えられるが、key が増えるたびに処理が増えてしまい、なんのための型定義をしているのかよくわからなくなってしまう。

if (largeKey === "1" && detailKey === "1001") {
  const l = detail[largeKey];
  const hoge = l[detailKey];
} else if (largeKey === "2" && detailKey === "2001") {
  const l = detail[largeKey];
  const hoge = l[detailKey];
}

ここで開発者は事前に largeKey と detailKey の組み合わせがほぼ確実に正しい形で渡って生きていることを知っている(そうでない場合は一旦想定しないで OK)とした場合、Union 型を Intersection 型に変える事によって解決する。 つまり

{
    "1001": "foo1";
    "1002": "foo2";
} | {
    "2001": "baz1";
    "2002": "baz2";
}

{
    "1001": "foo1";
    "1002": "foo2";
} & {
    "2001": "baz1";
    "2002": "baz2";
}

に変えてしまえば、"1001"でアクセスしたとしても確実に値を得られるということになる。

ここで、Intersection に変換する方法はこちらを参考に下記のような型で解決できる

export type UnionToIntersection<U> = (
  U extends any ? (k: U) => void : never
) extends (k: infer I) => void
  ? I
  : never;

あとはこんな感じでコードを変更すれば hoge がいい感じの型で取得できるようになる

import large from "~/large.json";
import detail from "~/detail.json";
import { UnionToIntersection, NthDepthProperty } from "./types";

function getValue(
  largeKey: NthDepthProperty<typeof large, 1>,
  detailKey: NthDepthProperty<typeof detail, 2>
) {
  const l = detail[largeKey];
  const hoge = (l as UnionToIntersection<typeof l>)[detailKey]; // hoge: "foo1" | "foo2" | "baz1" | "baz2"
}

おしまい

「プロジェクトマネージャーいらなくね?」と思って辞めた話

back check 開発チームの秋葉です

概要

※ PjM: プロジェクトマネージャー

三行でまとめると

  • PjMの役割を開発チームへと移譲していった
  • チームとしてPjMいならいと思い辞めてみた
  • 辞めた結果支障なかった

PjMの仕事

自分は2020年の4月頃から、開発チームのPjMというポジションにつきました。
PjMをやっていましたが、1メンバーとして普通に開発もしていました。(開発をするうえで、負荷は減らしてもらっていました)

PjMというと、会社やチームによってやることが千差万別だとおもいますが、自分は以下のようなことをやってました。

  • スケジュールの管理(短期)
  • 差し込みタスクのハンドリング
  • スプリントプランニング
  • POと開発チームの橋渡し的な役割
  • POがいないときの開発の意思決定
  • etc....

色々書きましたが個人的には、やる人が決まっていな雑多なこと を やっていました。

PjM辞めたわけ

back check 開発チーム は アジャイル開発の手法として、スクラム開発 を導入しています。
チームとしてはより良いアジャイル開発、スクラム開発を目指しています。

ここで、アジャイル開発名著のである アジャイルサムライ から言葉をいくつか引用させていただきます。

自己組織化

最良のアーキテクチャ・要求・設計は、
自己組織的なチームから生み出されます。

成果責任と権限委譲

意欲に満ちた人々を集めてプロジェクトを構成します。
環境と支援を与え仕事が無事終わるまで彼らを信頼します。

といことで、開発チームを自己組織化していくために、
スプリント内での開発の成果責任 と PjMの権限 を開発チーム移譲してみました、
徐々にやっていくなかで、「あれ、もうプロジェクトマネージャーいらなくね?」となり、 自分がこの役割を辞めることを決めました。
(開発チームの内訳には自分も含まれています)

実際には以下のようなことをやった。

  • スプリントの成果を個人ではなくチームの成果であることを意識した

    • デイリーで進捗を共有し相互にフォローをしあう
    • 達成した成果も達成できなかった成果も個人ではなくチームのもの
  • 開発への依頼は、個人へ宛ではなく開発チーム全体への依頼としてもらう

    • Slack で開発チーム全体にメンションしてもらうようにした
    • 差し込みのタスクもチーム全体のスケジュール影響することとして認識する
  • スプリントプランニングをチーム全員で考えるようにした

    • 実施するタスクを、指名性ではなく挙手制にし、やりたいことを優先する
  • 開発の進捗は全員で管理し、プロダクトオーナを含めた全員が見えるようにした

上記については自分が提案した内容もありますが、基本的にはスクラム開発のチームビルディングの一貫とて行ったことが多いです。
スクラムマスターとしてチームビルディングを実施してくれた、同僚に感謝です。

PjM辞めた結果

何も変わらなかった

辞めて1ヶ月ほど現状としては、自分がPjM辞めた前と後では特に変化はなく、チームも問題なく回っている状況です。
新しい問題が発生しても、基本的にはチームとして対処していく形です。

無くして良かった というよりは、 無くしても問題なかった という結果でした。

まとめ

PjMは必要ない という話ではなく、今の開発チームはPjM必要ない という状態にチームがなったというお話です。

状況やチームの編成が変わればまた必要になるかもしれないし、新しい役割が必要になるかもしれません。
特定の役割がなくなっても、自然とチームが回るのはチームとして成長し、より良チームに近づけたのかなと思います。