Bolt + lambda を使って Slack に通知メッセージを送る API を作る

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

zenn.dev

 

 

Boltを利用してWebアプリと連携し、Slackワークスペースに所属するユーザーに応じて通知を出し分けるAPIを作ってみたので知見として書きます。

当記事で書くこと

  • Slack Appの設定
  • Bolt + serverless によるSlackBotのAPI実装
    • emailからユーザーとのDMチャンネルを検索
    • 取得したDMチャンネルIDへのメッセージ送信
  • lambdaへのデプロイ

当記事で書かないこと

  • Boltを利用したOAuth周りの認証設定

手順

【SlackApp側の設定】

Slack App 作成 ←のリンクから Slack App を作成する

App Home タブにてアプリの DM に表示するタブを設定

  • "App Display Name" の Edit ボタンから、好きな display name を設定して保存する
  • Messages Tab
    • ここのチェックをtrueにすると Messages Tab でユーザーがメッセージを送信できるようになる Allow users to send Slash commands and messages from the messages tab

OAuth & Permissions タブにて

  • Slack API の利用に必要な以下の権限を設定する
"channels:read",
"chat:write:bot",
"groups:read",
"im:read",
"mpim:read",
"users:read",
"users:read.email"

以下のAPIWorks withに必要なscopeが書いてあります

  • Install to WorkSpace する

【Slack App開発】

サンプルコードは以下のリポジトリで公開しています

まずはサーバーレスアプリケーションを開発、デプロイするためのツールをインストールします

こちらの記事で紹介されている事前準備を行ってください

Lambdaの作成

下記コマンドを実行してNode.js用の作業ディレクトリとLambdaの定義ファイル作成します。

$ serverless create --template aws-nodejs --path myService

以下を実行して初期設定を行います

$ yarn init
myService
├── .npmignore
├── handler.js
├── package.json
└── serverless.yml

以下のコマンドで必要なパッケージをインストールします

$ yarn add @slack/bolt @vendia/serverless-express multiparty
$ yarn add -D serverless serverless-offline

package.jsonに以下のscriptを追記します これでyarn devすることでlocalでデバックできるようになりました

package.json

{
    ...
    "scripts": {
        "dev": "sls offline",
        "deploy": "npx serverless deploy"
    },
}

Serverlessの設定ファイルを以下の内容に変更します

serverless.yml

service: serverless-bolt-js
frameworkVersion: "2"
provider:
  name: aws
  runtime: nodejs12.x
  region: ap-northeast-1
  environment:
    SLACK_SIGNING_SECRET: ${env:SLACK_SIGNING_SECRET}
    SLACK_BOT_TOKEN: ${env:SLACK_BOT_TOKEN}
functions:
  slack:
    handler: app.handler
    events:
      - http:
          method: ANY
          path: /{any+}
useDotenv: true
plugins:
  - serverless-offline
package:
  patterns:
    - '!.git/**'
    - '!README.md'

あわせて環境変数を追加します

.env

SLACK_SIGNING_SECRET="xxx"
SLACK_BOT_TOKEN="xoxb-xxx"

準備ができたら処理を書いていきます

handler.js

const { App, ExpressReceiver } = require("@slack/bolt");
const serverlessExpress = require("@vendia/serverless-express");
const multiparty = require("multiparty");

const accessToken = process.env["SLACK_BOT_TOKEN"];

const expressReceiver = new ExpressReceiver({
  signingSecret: process.env["SLACK_SIGNING_SECRET"],
  processBeforeResponse: true,
});

const app = new App({
  token: accessToken,
  receiver: expressReceiver,
});

// /slack/events/massegesへのpostリクエストのエンドポイント作成
app.receiver.router.post("/slack/events/masseges", async (req, res) => {
  // req から fields を抽出する
  const data = await new Promise((resolve, reject) => {
    const form = new multiparty.Form();
    form.parse(req, (err, fields, files) => {
      resolve(fields);
    });
  });

  // validation
  if (!data.email) {
    res.status(400).send("error: no_email");
    return;
  }
  if (!data.text) {
    res.status(400).send("error: no_text");
    return;
  }

  const userEmailList = data.email.find((_, i) => i === 0).split(",");
  const massege = data.text.find((_, i) => i === 0);

  let userIds = [];
  for (const email of userEmailList) {
    try {
      // reqパラメーターのemilがワークスペースに存在するか確認
      const user = await app.client.users.lookupByEmail({
        token: accessToken,
        email: email,
      });
      if (user) {
        userIds = [...userIds, user.user.id];
      }
    } catch (error) {
      res.status(400).send(`error: user is Not Found. ${email}`);
      return;
    }
  }

  // DMチャンネル一覧を取得
  const conversationsList = await app.client.conversations.list({
    token: accessToken,
    types: "im",
  });
  const channels = conversationsList.channels;

  if (!!userIds.length) {
    // メールアドレスから取得したユーザーの DM チャンネルのみにフィルター
    channels
      .filter((x) => {
        return userIds.some((y) => {
          return y === x.user;
        });
      })
      .forEach((x) => {
        // メッセージ送信
        app.client.chat.postMessage({
          token: accessToken,
          channel: x.id,
          blocks: massege,
        });
      });
  }
  res.status(200).send("success!!");
});

// Handle the Lambda function event
module.exports.handler = serverlessExpress({
  app: expressReceiver.app,
});

デプロイ

以下コマンドでlambdaへデプロイします

yarn deploy

これで完成です!

試してみる

APIエンドポイントに対してcurlでリクエストを送ってみる (email が一致したユーザーは Slack の DM にメッセージが送信される)

aws

通知メッセージを作成する

paramater

  • email : カンマ( , )区切りで通知を送信したいユーザーのメールアドレスを渡す
  • text : メッセージの block を作成しパラメーターに渡す
$ curl --location --request POST 'https://xxxxxxxxxx.amazonaws.com/dev/slack/events/masseges' \
--form 'email="taro@hoge.com,jiro@hoge.com"' \
--form 'text="[
    {
        \"type\": \"section\",
        \"text\": {
            \"type\": \"mrkdwn\",
            \"text\": \"*twitterフォローしてね!*\"
        }
    },
    {
        \"type\": \"section\",
        \"fields\": [
            {
                \"type\": \"mrkdwn\",
                \"text\": \"https://twitter.com/Area029S\"
            }
        ]
    }
]"'

送信できました!

screeen_shot

あとがき

以上でSlackワークスペースに所属するユーザーに応じて通知を出し分けるAPIを作成することができました。 今後はBoltの認証機能を利用したマルチワークスペース対応の実装例を紹介できればと思います。 ちなみにシンプルに通知のみを実行したいのであればBoltを使わないでサービス側で直接SlackAPIを叩いてしまう方が低コストに実現できます。

参考にした記事

Date系ライブラリとIE11とタイムゾーン

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

http://ratatatat30.hatenablog.jp/entry/2021/05/14/130211


sekitats です。backcheck で主に送りバントをしています。

さて、これまで backcheck では Date系ライブラリとして moment-timezone.js を使っていました。2020年9月 moment.js はすでにメンテナンスモードに入っており、backcheck ではこの度重い腰を上げて代替ライブラリへの移行をすることになりました。

以下の候補から最適なライブラリを選択するため調査を行いました。

  • date-fns (date-fns-tz)
  • Day.js
  • Luxon
  • js-joda(js-jodaについてはほぼ調査していませんが、念の為候補としてあげています。)

IE11 サポートとタイムゾーンの壁

前提として、Backcheck では IE11のブラウザ使用率が数%あり、IE11のサポートを完全に切ることができません。 選定の課題として IE11上でタイムゾーンの機能が正常に動作するかという点が必須項目となりました。

以下メリデメを表にしました。

ライブラリ名 メリット デメリット
Day.js ・他のライブラリと比べて最もファイルサイズが軽い
・moment に寄せて作られているため移行コストが最も安い
・開発継続性高い
・学習コストがほぼいらない
・IE11や timezone 関連のサポートの信頼性が低い(ライブラリの中では最もモダンブラウザに振り切ってる印象)
・IE11で意図した出力がされなかったため致命的と判断
date-fns(date-fns-tz) ・IE11での動作を確認
・採用しているプロジェクトが多い
・開発継続性高い。
・使用経験者がいる
・関数型設計のため、NuxtプロジェクトやPHPユーザーとは相性が悪い
・学習コストが要る
Luxon ・IE11での動作を確認
・moment.js のコントリビューターをしている人が作っているため後継として信頼性が高い
・Moment の後継であり開発継続性は高い
・IE11サポートに関するドキュメントが充実
・あまり人気がない
・使用経験者がいない
・学習コストが要る
js-joda - 独自実装のためIE9までサポートしている ・人気がない
・使用経験者がいない
java.time, Joda-Tome or Noda Time に慣れた人にはメリットだが、おそらくチーム的に不向き
・学習コストがいる

Luxon に決定 🎉

f:id:sekilberg:20210514111452j:plain
IE11で各種日付ライブラリを検証する

本命の Dayjs は、IE11ではタイムゾーン('Asia/Tokyo’)を指定した日時が意図した結果のになりませんでした。

date-fns (date-fns-tz)とLuxon はdate-fnsが標準時の表記(JST)、Luxon が IANA Time Zone 表記('Asia/Tokyo’)といった違いが出たものの日時は意図した時間が出力されました。 あとは好みや使い勝手といったところになってきますが Nuxt プロジェクトやPHPer との相性(Carbonの使用感との差)といった点で date-fns は脱落。最後に残ったのは Luxon でした。もともと Luxon は Moment.js のコントリビューターが作っているということもあり、IE11サポートのドキュメントも充実していてやはり安心感が違います。

polyfill

さて、IE11は UTC以外の特定のタイムゾーンをサポートしていません。IE11でタイムゾーンをサポートするためには polyfill が必要になります。

Option value ‘Asia/Tokyo’ for 'timeZone' is outside of valid range. Expected: ['UTC’]

https://stackoverflow.com/questions/54364061/ie-11-throwing-timezone-is-outside-of-valid-range-when-setting-timezone-to

IE11でタイムゾーンを扱う際の polyifll として date-time-format-timezone があります。しかし minified でも 2.64MBあり、そのままバンドルに含めると moment-timezone.jsよりもファイルサイズが増えてしまうため注意が必要です。さらにリポジトリアーカイブされており今後更新はされないため最新の polyfill を使うようにします。

最新のはこちら formatjs.io

すべて CDNから取得する場合は以下になります。polyfill.io は userAgent を見て polyfill が必要であるかを判断してファイルを返してくれるため、モダンブラウザで不要なファイルのロードをせずに済みます。

// nuxt.config.js
head: {
  script: [
    {
      src: ‘https://polyfill.io/v3/polyfill.min.js?features=Intl.~locale.ja,Intl.Locale,Intl.getCanonicalLocales,Intl.PluralRules.~locale.ja,Intl.NumberFormat,Intl.NumberFormat.~locale.ja,Intl.DateTimeFormat,Intl.DateTimeFormat.~locale.ja,Intl.NumberFormat.~locale.ja’,
      defer: true
    },
  ],
}

しかし、試してみたところ Intl.DateTimeFormat.~locale.ja のところで正しくロードできませんでした。 import する場合は下記のようになるかと思います。(そのほか必要な polyfill は適宜追加してください。 全てCDNにするか、全てimport にするかにしないと運用がたいへん。。)

// plugins/polyfill-datetimeformat.js
import { shouldPolyfill } from "@formatjs/intl-datetimeformat/should-polyfill";
(async function polyfill() {
  if (shouldPolyfill()) {
    try {
      const dataPolyfills = [
        import("@formatjs/intl-getcanonicallocales/polyfill"),
        import("@formatjs/intl-locale/polyfill"),
        import("@formatjs/intl-numberformat/polyfill"),
        import("@formatjs/intl-numberformat/locale-data/ja"),
        import("@formatjs/intl-pluralrules/polyfill"),
        import("@formatjs/intl-pluralrules/locale-data/ja"),
        import("@formatjs/intl-datetimeformat/polyfill"),
        import("@formatjs/intl-datetimeformat/locale-data/ja"),
        import("@formatjs/intl-datetimeformat/add-all-tz"),
      ];
      await Promise.all(dataPolyfills);
    } catch (e) {
      console.error(e);
    }
  }
})();

まとめ

IE11とタイムゾーンのサポートまでしているところはあまりないのではないでしょうか。タイムゾーンは日本で住んでいるとあまり意識することがありませんが、アプリケーションの世界では必ず必要になる概念であり、非常に学びの多いミッションでした。

代替ライブラリの選定にあたり、date-fns (date-fns-tz), Dayjs, Luxon, (js-joda) について多くの記事を調べましたが、IE11とタイムゾーンに言及した情報というのは見た限りはなかったと思います。この記事が有益な情報となれば幸いです。


AWS CloudFront で S3 にホスティングした静的ウェブサイトを CDN 配信(&独自ドメインのHTTPS化)

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

www.ritolab.com


AWS の S3 にホスティングした静的 WEB サイトを CloudFront を用いて CDN での配信を行うと共に、SSL 証明書等の整備も行って独自ドメインHTTPS でアクセスできるようにしていきます。

なお、S3 にホスティングした WEB サイトを独自ドメインHTTPS でアクセスできるようにするにも同じで、今回のように CloudFront を用いて構築する必要があり、手段手順としては同じになります。

S3 にホスティングした静的 WEB サイト

今回は CloudFront や SSL/TLS 証明書回りがメインです。

S3 に静的 WEB サイトをホスティングする部分については前回の記事にまとめています。

今回は前回の記事の続きになるので、環境変数周りや Route53 や S3 の設定等もこちらを確認してください。

www.ritolab.com

実行環境

今回の実行環境は以下になります。

  • Terraform v0.15.1

コードはモジュール化を行わずに一つの tf ファイルに書いていきます。

AWS プロバイダの追加

まずは aws プロバイダを追加します。

既に前回で東京リージョンの aws プロバイダを作成済みですが、ACM で作成した SSL/TLS 証明書を CloudFront にアタッチすためには証明書をバージニア北部リージョンで作成する必要があるため(2021/05/04現在)、別途こちらの aws プロバイダを作成しておきます。

main.tf

provider "aws" {
  access_key = var.aws_access_key
  secret_key = var.aws_secret_key
  region     = "us-east-1"
  alias      = "virginia"
}

リージョンをバージニア北部にし、エイリアスを付けてデフォルトとは別にこちらを指定して使用できるようにしています。

ログ用の S3 バケット作成

CloudFront のアクセスログを格納するバケットを S3 に作成します。

main.tf

## S3 for cloudfront logs
resource "aws_s3_bucket" "cloudfront_logs" {
  bucket = "${local.fqdn.name}-cloudfront-logs"
  acl    = "private"

  tags = {
    Name        = var.project_tag_name
    Environment = var.project_environment_name
  }
}

CloudFront ディストリビューション作成

CloudFront のディストリビューションを作成します。

main.tf

## キャッシュポリシー
data "aws_cloudfront_cache_policy" "managed_caching_optimized" {
  name = "Managed-CachingOptimized"
}

## ディストリビューション
resource "aws_cloudfront_distribution" "main" {
  origin {
    domain_name = "${local.bucket.name}.s3-${var.aws_region}.amazonaws.com"
    origin_id   = "S3-${local.fqdn.name}"
  }

  enabled             = true
  is_ipv6_enabled     = true
  default_root_object = "index.html"

  # Alternate Domain Names (CNAMEs)
  aliases = [local.fqdn.name]

  # 証明書の設定
  viewer_certificate {
    cloudfront_default_certificate = false
    acm_certificate_arn            = aws_acm_certificate.main.arn
    minimum_protocol_version       = "TLSv1.2_2019"
    ssl_support_method             = "sni-only"
  }

  retain_on_delete = false

  logging_config {
    include_cookies = true
    bucket          = "${aws_s3_bucket.cloudfront_logs.id}.s3.amazonaws.com"
    prefix          = "log/"
  }

  default_cache_behavior {
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    target_origin_id       = "S3-${local.fqdn.name}"
    viewer_protocol_policy = "allow-all"
    compress               = true
    cache_policy_id        = data.aws_cloudfront_cache_policy.managed_caching_optimized.id
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }
}
  • キャッシュポリシー
    • 今回は特に細かく設定する必要がないためマネージドのものを使用しています。
    • 管理キャッシュポリシーの使用
  • ディストリビューション
    • 証明書の設定
      • acm_certificate_arn は、この次で設定する ACM の値を設定します。
      • 前項でバージニア北部リージョンのプロバイダを作成しましたが、記事執筆時点では us-east-1 region のものしか指定できません。

SSL/TLS 証明書 作成

ACM(AWS Certificate Manager) にて SSL/TLS 証明書を作成し、独自ドメインに紐づけていきます。

aws.amazon.com

証明書のリクエス

SSL/TLS 証明書の発行を ACM にリクエストします。

main.tf

resource "aws_acm_certificate" "main" {
  provider                  = aws.virginia
  domain_name               = local.fqdn.name
  validation_method         = "DNS"
}
  • プロバイダはバージニア北部リージョンの方を指定しています。
  • 今回使用する独自ドメインFQDN を指定しています。
  • 検証は DNS で行なうように指定しています。

CNAME レコード 作成

証明書リクエストに対して DNS で検証を行なうため、CNAME レコードを追加します。

main.tf

## CNAME レコード
resource "aws_route53_record" "main_acm_c" {
  for_each = {
    for d in aws_acm_certificate.main.domain_validation_options : d.domain_name => {
      name   = d.resource_record_name
      record = d.resource_record_value
      type   = d.resource_record_type
    }
  }
  zone_id = data.aws_route53_zone.naked.id
  name    = each.value.name
  type    = each.value.type
  ttl     = 172800
  records = [each.value.record]
  allow_overwrite = true
}

証明書の検証

証明書のリクエストに CNAME レコードを連携させ、DNS 検証の完了を確認します。

main.tf

## ACM 証明書 / CNAME レコード 連携
resource "aws_acm_certificate_validation" "main" {
  provider        = aws.virginia
  certificate_arn = aws_acm_certificate.main.arn
  validation_record_fqdns = [for record in aws_route53_record.main_acm_c : record.fqdn]
}

独自ドメインの向き先を変更する

最後に、前回までは S3 に向いていた独自ドメインの向き先を CloudFront に変更します。

main.tf

## A レコード(to CloudFront)
resource "aws_route53_record" "main_cdn_a" {
  zone_id = data.aws_route53_zone.naked.zone_id
  name    = local.fqdn.name
  type    = "A"
  alias {
    evaluate_target_health = true
    name                   = aws_cloudfront_distribution.main.domain_name
    zone_id                = aws_cloudfront_distribution.main.hosted_zone_id
  }
}

CloudFront へ A レコードを設定しました。

前回の記事で S3 に向けた A レコードは削除します。

## 削除
## A レコード(to S3 Bucket)
//resource "aws_route53_record" "main_a" {
//  zone_id = data.aws_route53_zone.naked.zone_id
//  name    = local.fqdn.name
//  type    = "A"
//  alias {
//    evaluate_target_health = true
//    name = "s3-website-${var.aws_region}.amazonaws.com"
//    zone_id = aws_s3_bucket.app.hosted_zone_id
//  }
//}

動作確認

ここまでで terraform から環境を構築すると、CloudFront 及び HTTPS でのアクセスが可能となっている事が確認できます。

f:id:ro9rito:20210514061244p:plain

HTTPS でのみのアクセスにする

現時点では http と https どちらでもアクセス可能な状態なので、http でアクセスがきたものは https にリダイレクトするようにします。

main.tf

# allow-all を redirect-to-https に変更する
# viewer_protocol_policy = "allow-all"
viewer_protocol_policy = "redirect-to-https"

aws_cloudfront_distribution リソースの default_cache_behavior にある viewer_protocol_policy の値を allow-all から redirect-to-https へ変更します。

これで http でアクセスがきたものは https にリダイレクトされるようになります。

S3 へのアクセスを制限する

訪問者が S3 のバケットへ直接アクセスしないように制限を掛け、CloudFront ディストリビューションを介してのみアクセスできるようにします。

docs.aws.amazon.com

Origin Access Identity(OAI)作成

Origin Access Identity(以下、OAI)を作成します。

main.tf

## CloudFront OAI 作成
resource "aws_cloudfront_origin_access_identity" "main" {
  comment = "Origin Access Identity for s3 ${local.bucket.name} bucket"
}

OAI をディストーションに関連付ける

OAI で作成した CloudFront ユーザーをディストリビューションに関連付けます。

main.tf

resource "aws_cloudfront_distribution" "main" {
  origin {
    # 追加
    s3_origin_config {
      origin_access_identity = aws_cloudfront_origin_access_identity.main.cloudfront_access_identity_path
    }
  }
  .
  .
  .

aws_cloudfront_distribution リソースの origin に s3_origin_config として origin_access_identity を設定します。

IAMポリシードキュメント作成

アクセスを制御するポリシードキュメントを作成します。(ここで作成したポリシーを json としてアタッチする)

main.tf

# IAMポリシードキュメント作成
data "aws_iam_policy_document" "s3_policy" {
  statement {
    actions   = ["s3:GetObject"]
    resources = ["${aws_s3_bucket.app.arn}/*"]

    principals {
      identifiers = [aws_cloudfront_origin_access_identity.main.iam_arn]
      type        = "AWS"
    }
  }
}

ポリシーをバケットにアタッチ

作成したポリシーをアプリケーションのバケットに紐付けます。

main.tf

# ポリシーをバケットに紐付け
resource "aws_s3_bucket_policy" "main" {
  bucket = aws_s3_bucket.app.id
  policy = data.aws_iam_policy_document.s3_policy.json
}

アプリケーションのバケットへのアクセス制御変更

前回の記事で作成したアプリケーションを配置するバケットでは、PublicRead が ON になっている状態です。これをプライベートに変更します。

main.tf

## S3 for Static Website Hosting
resource "aws_s3_bucket" "app" {
  bucket = local.bucket.name
  acl    = "private"

  website {
    index_document = "index.html"
    error_document = "error.html"
  }

  tags = {
    Name        = var.project_tag_name
    Environment = var.project_environment_name
  }
}

これで、作成した OAI での CloudFront からのアクセスのみ S3 バケットへのアクセスが可能になりました。

S3 アプリケーションログ用のバケット削除

この時点で S3 へのアクセスは全て CloudFront 経由になり、S3 への訪問者のアクセスログバケットは不要になるので、前回の記事で作成したバケットは削除します。

main.tf

# 削除
//data "aws_canonical_user_id" "current_user" {}

# 削除
## S3 for app logs
## https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket
//resource "aws_s3_bucket" "app_logs" {
//  bucket = "${local.bucket.name}-logs"
//
//  grant {
//    id          = data.aws_canonical_user_id.current_user.id
//    permissions = ["FULL_CONTROL"]
//    type        = "CanonicalUser"
//  }
//  grant {
//    permissions = ["READ_ACP", "WRITE"]
//    type        = "Group"
//    uri         = "http://acs.amazonaws.com/groups/s3/LogDelivery"
//  }
//
//  tags = {
//    Name        = var.project_tag_name
//    Environment = var.project_environment_name
//  }
//}

動作確認

ここまでの設定を適用させると、ブラウザから S3 の URL でコンテンツへアクセスできなくなった事が確認できました。

f:id:ro9rito:20210514061805p:plain

f:id:ro9rito:20210514061813p:plain

まとめ

静的な WEB を公開するのであればコスト面で優れている S3 へのホスティングはおすすめです。

また、CloudFront も使うと高速にコンテンツを配信する、独自ドメインHTTPS 化するなど、できる幅も広がるのでおすすめです。

AWS S3 に静的ウェブサイトをホスティングして独自ドメインで公開するまで

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

www.ritolab.com


静的ウェブサイトを Amazon S3ホスティングし、独自ドメインで公開するまでの環境構築を terraform を使って行っていきます。

実行環境

今回の実行環境は以下になります

  • Terraform v0.15.1

コードはモジュール化を行わずに一つの tf ファイルに書いていきます。

今回使う AWS のサービスは、S3 と Route53 です。

環境変数について

terraform で構成を定義していくにあたり、環境変数を tfvars に記述しておきますが、以下のようになっています。

terraform.tfvars

aws_id           = 123456789
aws_access_key   = "XXXXXXXXXXXXXXXXXXXX"
aws_secret_key   = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
aws_region       = "ap-northeast-1"
domain_name      = "example.com"
domain_host_name = "www"
  • AWS のアカウント情報と、デフォルトのリージョンとして東京リージョンを指定しています。
  • ネイキッドドメインとホスト名をそれぞれ分けて定義しています。

変数の設定

これから使用する値について変数とプロバイダを設定しておきます。

main.tf

# variables
variable "aws_id" {}
variable "aws_access_key" {}
variable "aws_secret_key" {}
variable "aws_region" {}
variable "domain_name" {}
variable "domain_host_name" {}

provider "aws" {
  access_key = var.aws_access_key
  secret_key = var.aws_secret_key
  region     = var.aws_region
}

locals {
  fqdn = {
    name = "${var.domain_host_name}.${var.domain_name}"
  }
  bucket = {
    name = local.fqdn.name
  }
}
  • AWS プロバイダを定義しています。
  • Route53 や S3 で使用するための FQDNBucket 名の変数を作成しています。

アクセスログ用のバケット作成

S3 にホスティングする場合でも、アクセスログを取る事ができます。

docs.aws.amazon.com

そのためのバケットを S3 に作成します。(これが無くてもサイト公開はできるので、必要なら作成してください)

main.tf

## AWS アカウントのユーザー
data "aws_canonical_user_id" "current_user" {}

## S3 for app logs
resource "aws_s3_bucket" "app_logs" {
  bucket = "${local.bucket.name}-logs"

  grant {
    id          = data.aws_canonical_user_id.current_user.id
    permissions = ["FULL_CONTROL"]
    type        = "CanonicalUser"
  }
  grant {
    permissions = ["READ_ACP", "WRITE"]
    type        = "Group"
    uri         = "http://acs.amazonaws.com/groups/s3/LogDelivery"
  }
}
  • 権限は ACL ポリシーで設定しています。
    • 前者(1 つめの grant)は、コンソール画面から操作できるように、AWS アカウントの正規ユーザーの権限を設定しています。
    • 後者(2 つめの grant)は、ログを配信するための権限です。

アプリケーション用のバケット作成

静的なウェブサイトのソースコードを配置する S3 バケットを作成します。

main.tf

## S3 for Static Website Hosting
resource "aws_s3_bucket" "app" {
  bucket = local.bucket.name
  acl    = "public-read"
  policy = templatefile("bucket-policy.json", {
    "bucket_name" = local.bucket.name
  })

  website {
    index_document = "index.html"
    error_document = "error.html"
  }

  logging {
    target_bucket = aws_s3_bucket.app_logs.id
    target_prefix = "log/"
  }
}

バケット名は FQDN と同じにします。

templatefile として挿入している json は以下のようになっています。

bucket-policy.json

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicReadGetObject",
            "Effect": "Allow",
            "Principal": "*",
            "Action": [
                "s3:GetObject"
            ],
            "Resource": [
                "arn:aws:s3:::${bucket_name}/*"
            ]
        }
    ]
}

logging の部分は、前項でアクセスログ用のバケットを作成した場合に指定するので、作っていなければ指定不要です。

バケットを作成したら index.html を配置してアクセスしてみると表示されます。

f:id:ro9rito:20210507113705p:plain

独自ドメインの設定

WEB サイトの公開はできましたが、ここまでだと URL が amazonaws.com なので、独自ドメインを設定します。

ドメインとホストゾーンの作成について

ドメインですが、前提として、Route53 に登録済みのドメインを使用します。

また、ネイキッドドメインのホストゾーン登録は済ませてある前提で進めるので、例えば www.example.com で S3 にホスティングしたサイトを公開するなら、そのネイキッドドメインである example.com のホストゾーンは作成しておいてくだださい。

docs.aws.amazon.com

これだけです。

ちなみに、ドメインのネームサーバーと、作成したホストゾーンの NS レコードに設定されているネームサーバーが同じ事を確認して、もし違うようならどちらかに揃える必要があります。

レコードの設定

terraform で Route53 にレコードを作成します。

main.tf

# Route 53
## ネイキッドドメインのホストゾーン情報
data aws_route53_zone "naked" {
  name = var.domain_name
}

## A レコード作成(to S3 Bucket)
resource "aws_route53_record" "main_a" {
  zone_id = data.aws_route53_zone.naked.zone_id
  name    = local.fqdn.name
  type    = "A"
  alias {
    evaluate_target_health = true
    name = "s3-website-${var.aws_region}.amazonaws.com"
    zone_id = aws_s3_bucket.app.hosted_zone_id
  }
}
  • A レコードを作成しています。
  • ホストゾーンは、作成済みのネイキッドドメインのホストゾーン情報を取得して zone_id に指定しています。
  • エイリアスの設定として、S3 のウェブサイトエンドポイントを設定します。

docs.aws.amazon.com

動作確認

レコードを作成したら、あとは DNS の浸透を待ってアクセスすると表示されます。

f:id:ro9rito:20210507113920p:plain

S3 にホスティングしたウェブサイトを独自ドメインで公開できました。

OASとTypescript ベネフィット編

4月からRoxxに合流したkotamatsuiです(※kotamatさんとは別人です)。よろしくおねがいします。 こちらの記事はZennからの転載になります zenn.dev


はじめに

スキーマ駆動開発という手法が数年前からたびたび話題にあがっています。 スキーマ駆動開発の紹介は既に多数の先人が記事を書いておられるのでそちらをご覧いただくこととして、この記事ではOAS、特にOpenAPI Genertorを利用したスキーマ駆動開発を導入することによる、フロントエンド目線で見たときに実作業者が得られるベネフィットをまとめてみたいと思います。

実際筆者は最近、APIの仕様書を渡されて2つのエンドポイントからなるSPA(サーバー側はもう動いている状態)を0ベースで開発する機会がありましたが、渡されたAPI仕様書をOAS定義に翻訳し、各種ジェネレーターを駆使してAJAX部分を実装することで、Nuxt typescriptのプロジェクト作成からアプリケーション完成まで4~5時間程度で終わらせることができました。

とにかく使いこなせれば、病みつきになるレベルの爆速開発が可能になります

スタブサーバーが用意できる

OpenAPI generatorには各種スタブサーバー用のジェネレーターが用意されています。 これによりサーバー側の実装が進んでいない状態でも、OASの定義にexampleを書いておけばそのレスポンスを使ってフロント側の実装を進めたり、デモ画面を作成したりすることが可能になります。 フロントエンドエンジニアはexampleだけでもいじれるようになっておけば、様々なデザインパターンを想定したデータを作って開発を進めることができます。

スタブサーバーの実装方法にこだわりが無ければAPI Sproutを利用するのが良いでしょう。 dockerですぐ動く状態のイメージが用意されていますので、docker-composeに追加してOASの定義ファイルをマウントするだけでお手軽にスタブサーバーが用意できます。更新検知もしてくれて、OASの定義を変更すればすぐさまレスポンスも追従してくれます。

API Sproutをローカルの開発サーバーと別のポートで建てておくと、フロント側で開発サーバー見に行くかスタブ見に行くか切り替えるなど出来ていろいろと使い勝手が良いです。

なお、exampleを書かなかった場合でも、例えば文字列なら"String"のような何かしらスキーマに合わせた値を返してくれます。

IDEの強力な支援が受けられる

pathsからAPIクライアントを生成

上記該当箇所のコード

OpenAPI generatorには各種APIクライアント用のジェネレーターも用意されています。

上の図はtypescript-axios用のジェネレーターの例です。ジェネレーターはOASのpathsの内容に応じて上記のようなメンバメソッドを持ったクラスを生成してくれますので、BaseURLや認証情報などのConfigをクラスのコンストラクタに渡してしまえば、あとはメソッドを叩くだけでリクエストが可能です。

youtu.be

上記の例ではリクエストにパラメーターが不要だったので引数がありませんが、パラメーターが必要な場合は引数に何が必要かIDEが表示してくれます。また、Promiseが解決された場合の戻り値の型をAxiosReasponseのGenericsに埋め込んでくれるため、IDEはレスポンスの型を静的に推論することができます。

components/schemas(など)からIntefaceを生成

ジェネレーターは上記APIクライアントと同時にcomponents/schemasの内容やpathsのレスポンスボディなどのスキーマから、TSのInterfaceを生成してくれます。

これによりAPIのレスポンスが返ってくる前の初期値を作ったり、

import { DefaultApi, SomeModel } from "path/to/api";
class Hoge {
  private response: null | SomeModel = null

  async request (): Promise<void> {
    this.response = await new DefaultApi().someRequestGet().data
  }
}

のような形でレスポンスを格納する変数を先にnullで初期化しておくなどの実装も簡単に行うことができます。

この生成されたInterfaceはOASのdescriptionがTSDocの書式で埋め込まれていますので、型をきちんと保ち続けていればマウスホバーするだけでIDEがdescriptionの内容を表示してくれるようになります。このため実装作業者はTSのコードとAPI仕様書やAPIの実際のレスポンスの画面とを行ったり来たりする頻度を大幅に減らすことができます。

また、typescrit-axios用のジェネレーターはenumにも対応しており、x-enum-varnamesというプロパティを使うことでstring enumを生成することも可能です。

~~
components:
  schemas:
    Genre:
      type: string
      enum:
        - rock
        - jazz
        - progress
      x-enum-varnames:
        - rock
        - jazz
        - progress
~~

↓生成物

~~
/**
 * 
 * @export
 * @enum {string}
 */
export enum Genre {
    rock = 'rock',
    jazz = 'jazz',
    progress = 'progress'
}
~~

スキーマ変更したときに影響範囲を一網打尽にできる(実装側の型の健全性が保たれていれば)

これは前項の内容とほぼ被りますが、TSの型検査とスキーマ定義が結合できるということは、スキーマを変更した場合にその影響を受ける部分が型チェックであぶりだせるということになります。

上記の例は、IDはintegerで来ると思って実装していたが実はstringが来ることが分かったのでスキーマを変更したケースです。定義ファイルの該当箇所を変更してジェネレーターでAPIクライアントを生成しなおしてから再度型検査を行ったことで、型が不一致になった個所が画面下のターミナルに羅列されています。

(補)Vueで型健全性をちょっと良くする小技

Vueのtemplate内はただの文字列なので、TSの型検査時には型検査が行われません1

型検査を抜けてしまうということは実際にコンポーネントをマウントして表示させてみるまで壊れていることに気づけないということになり、デバッグの負担が非常に増えます。これを避けるために、computedの戻り値の型指定を使う方法があります。

<template>
  <div>
    {{ hoge.some.property }}
  </div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from 'nuxt-property-decorator';

interface Hoge {
  some: {
    property: string;
  };
}
@Component
export default class HogeComponent extends Vue {
  @Prop({ type: Object, required: true }) readonly hoge!: Hoge;
}
</script>

例えば上記のような場合、Hogeインターフェースの構造が変わった際にhoge.some.propertyに対する型検査は行われないため実行時エラーになります。 これを下記のように変更すると、トランスパイル時にエラーに気づくことができ、修正の負担を軽減することができます。

<template>
  <div>
    {{ property }}
  </div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from 'nuxt-property-decorator';

interface Hoge {
  some: {
    property: string;
  };
}
@Component
export default class HogeComponent extends Vue {
  @Prop({ type: Object, required: true }) readonly hoge!: Hoge;
  get property(): string {
    return this.hoge.some.property
  }
}
</script>

template内が簡素になり可読性も上がるため、個人的にはかなり好んで多用しています。 もちろんこの方法はあくまで修正の負担を軽減してくれるだけで、完全に安全であることを保障してくれるわけではありません。テストは別途しっかり用意する必要があります。

実行時に実装とスキーマ定義の不整合を検査できる

スキーマ定義をしっかり決めて開発を進めても、実際に統合テストしてみたらスキーマとずれが生じることは(特に型が怪しい言語を使っている場合)よく起こります。

その際レスポンスやリクエストを目視してどこが不整合を起こしているか確認するというのはあまり賢い選択肢ではありません。OASを利用することでこの不整合カ所の調査を半自動化することができます。

OASopenapi2schemaというツールを使うことでJSON Schemaに変換することが可能です。JSON Schemaはオブジェクトの構造を検証することができるスキーマで、言語に依存しない中立的な仕様としてそれなりに知名度があり、様々な言語に対応しているバリデーターが存在します。

例えばJavascriptであればajvなどがあります。 これらを用いることでリクエストボディを送信する前にOAS定義との整合性を検査したり、レスポンスを受け取った後にレスポンスボディを検査したりすることができます。

ajvによるバリデーションの例

筆者が試した時点のopenapi2schemaはデフォルトでJSON Schema Draft v.4で出力してくれたので、まずはajvに使用するスキーマのバージョンを伝えます。

import Ajv from 'ajv';
import draft4 from 'ajv/lib/refs/json-schema-draft-04.json';
const ajv = new Ajv({schemaId: 'auto', allErrors: true}).addMetaSchema(draft4);

次に、このajvと生成したスキーマをセットにしてexportしておきます。

import Ajv from 'ajv';
import draft4 from 'ajv/lib/refs/json-schema-draft-04.json';
import schema from 'path/to/scheme.json'; // openapi2schemaで生成したjsonファイル

const ajv = new Ajv({schemaId: 'auto', allErrors: true}).addMetaSchema(draft4);
const validator: {schema: typeof schema; ajv: Ajv.Ajv} = {
  schema,
  ajv
};

export default validator;

この時一つ注意しておくべきポイントとして、tsconfigの"resolveJsonModule"をtrueにしておく必要があります。"resolveJsonModule"を有効にしておくと、jsonをimportした際にtypescriptが中身を見て自動的に型推論してくれるようになります。 もしプロジェクトの事情により"resolveJsonModule"を有効にできない場合、あまりきれいな方法ではありませんがjson-to-ts-cliを使用してshcema.jsonからinterfaceの定義ファイルを生成することが可能です。

あとは実際にリクエストを飛ばす際に

import validator from "path/to/validator";
import { DefaultAPI } from "path/to/api";

export async function fetch() {
  const api = new DefaultAPI();                  // typescript-axiosクラスを初期化
  const res = await api.someRequestGet();        // リクエストを実行
  const path = "/some/request";
  const validate = validator.ajv.compile(
    validator.schema[path].get.responses[200]
  );                                             // テスト対象のスキーマをajvに流し込む
  const valid = validate(res.data);              // バリデーションを実行
  if (!valid) {
    console.warn(`validation error: ${path}`);
    console.warn(validate.errors);               // バリデーションエラーが発生した箇所をコンソールに表示
  }
}

のようにすることでバリデーションが可能です。

ただ、このスキーマ定義用のjsonはそれなりのファイルサイズになります。本番ビルドにこのjsonファイルを含めるのはあまり賢明ではないと思いますので、適宜ビルド環境に応じてバリデーション関連のファイルとロジックを切り離せるような仕組みにしておくとよいでしょう。

実装と連動した「信頼できる」API仕様書が出来上がる

これまで見てきたようにどっぷりOASに依存してクライアントの開発を進めた場合、必然的にOASスキーマ定義は実装と乖離することができない(乖離してしまうとそもそもまともに動かない)状態に持っていくことができます。

そのため、「OpenAPI generatorが動いている」かつ、「ジェネレーターで生成されたAPIクライアントが実装に組み込まれている」という二点さえ確認できれば、「OASの定義ファイルに書かれている内容は概ね最新の状態に保守されている」ということを保障することができます。

全てのAJAXOASベースに置き換えることができてしまえば、もう二度と「微妙に実装とずれてる仕様書やInterface定義」に苦しめられることはありません。

なお、VScodeにはopenapi-previewというプラグインがありますので、APIドキュメントとして利用するにはこのプラグインyamlファイルだけあれば事足ります。専用のプレビュー用サーバーなどを立てるのは、ほとんどの場合お金と時間の無駄になるだけでしょう。

※ただし、descriptionの文言だけはただの文字列なので、そこに嘘が書かれていてもTypescriptは何も指摘してくれません。信頼できるのはあくまでスキーマです。

まとめ

OASとTSの連携は、一度はまると病みつきになるレベルに爆速でコーディングが可能になるポテンシャルを持っていますが、使ったことがないとどうしても定義ファイルを管理するコストが気になって二の足を踏んでしまうケースが多いかと思います。

OASの定義ファイルと生成物自体は既存の実装を全く破壊しないので、部分的にじわじわとOASに置き換えておくことも可能です。 まずはエンドポイント一つでもOAS化してみて実際にどんなものか体験してもらうのが、ベネフィットを感じるには手っ取り早いかと思います。


  1. VScodeであればVeturのvetur.experimental.templateInterpolationServiceを使うことでtemplate内の型検査も行ってくれますが、今のところエディタ上で警告を出してくれるだけなのでトランスパイルは通ってしまうのと、ものすごくVScodeの負荷が大きくなるので動作が重くなるといった問題があります。

Vuexの型強化で学んだ、TypeScript型強化のTIPS

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

kotamat.com

Vuexは型がゆるいゆるいという話を散々されているのを見てきて、工夫すれば型の強化はできるんじゃないかと挑戦してみたところ、そこそこいい感じのものができたのですが、その過程でさまざまな工夫が必要だったためその知見を共有します。

実際に作ったPR

こちらのPRにてOpen中。

const store = new Vuex.Store({
  state: { value: 0 },
  getters: {
    rootValue: (state) => state.value,
  },
  actions: {
    foo() {},
  },
  mutations: {
    foo() {},
  },
  modules: {
    a: {
      namespaced: true,
      state: { value: 1 },
      actions: {
        test: {
          root: true,
          handler({ dispatch }) {
            dispatch("foo");
          },
        },
        test2: {
          handler({ dispatch }) {
            dispatch("foo");
          },
        },
      },
      modules: {
        b: {
          state: { value: 2 },
        },
        c: {
          namespaced: true,
          state: { value: 3 },
          getters: {
            constant: () => 10,
            count(state, getters, rootState, rootGetters) {
              getters.constant;
              rootGetters.rootValue;
            },
          },
          actions: {
            test({ dispatch, commit, getters, rootGetters }) {
              getters.constant;
              rootGetters.rootValue;

              dispatch("foo");
              dispatch("foo", null, { root: true });

              commit("foo");
              commit("foo", null, { root: true });
            },
            foo() {},
          },
          mutations: {
            foo(state, payload: string) {},
          },
        },
      },
    },
  },
});

というのがあったときに、

store.dispatch("a/c/foo")
store.dispatch("a/c/test")
store.dispatch("a/test")
store.dispatch("a/test2")
store.dispatch("foo")

store.commit("a/c/foo","gg")
store.commit("foo")

store.state.a.b.value
store.state.a.c.value
store.state.a.value
store.state.value

store.getters["a/c/constant"]
store.getters["a/c/count"]
store.getters.rootValue

この辺がすべて型安全に書ける感じになっています。 また、dispatchやcommitの引数は補完が効くようになっており、第2引数の型は第1引数で指定した文字列によって決まるようになっています。

TypeScriptの柔軟性高い型表現

単なるジェネリクスだけではなく、 Mapped TypesConditional TypesTemplate Literal Types などにより、型を非常に柔軟に書けるのがTypeScriptの強みだと思っています。 Vuexは同一オブジェクトのなかで循環する形で参照する挙動(actions→commit()でmutationを呼ぶなど)があるので、こればっかりはTSで表現できないですが、少なくともVuex外とのインターフェースにおいては型安全に書けるのではないかと思いチャレンジしてみました。

今回使ったTIPSを難易度低い順に紹介しようと思います。(公式ドキュメントに記載されている内容は前提知識として割愛)

1. Index Types + Conditional Typesによる型表現の抽出

Vuexはmoduleという機能によりネストしたステート管理を実現しています。これがVuexの型表現を難しくしているものであり、いままで型を厳密に表現できてこれなかったものなのかなと思っています。

moduleは他のstateやactionsと同階層のとこに記述されるため、ネスト表現を取得するにはindexがmodulesのものを抽出し、かつmodulesの下にあるオブジェクトそれぞれに対して再帰的に型表現を呼び出していく必要があります。

store.stateの型表現を例に説明します。

type YieldState<S, T extends ModuleTree<S> | undefined> = (S extends () => any
  ? ReturnType<S>
  : S) &
  (T extends ModuleTree<S>
    ? {
        [K in keyof T]: T extends ModuleTree<S> // -1
          ? YieldState<
              T[K]["state"],
              T[K] extends { modules: object } ? T[K]["modules"] : {} // -2
            >
          : never;
      }
    : {});
...
export declare class Store<...> {
  readonly state: YieldState<S, SO["modules"]>;
}

ModuleTreeというのはmodules下のオブジェクトツリーを表している型なのですが、 1 で各moduleのオブジェクトを抽出した上で、 2 で更に下層にmodulesがあればそれを、なければ空を指定して再帰的に呼び出すことによってstateのツリー向上を実現しています。

2. 不活性仮型引数

今までのVuexの型は型引数にstateの型だけを渡してました。当然それだけではstate以外の型を解釈する事はできないため、型表現に限界があったわけです。 今回はStoreOptions(Vuex.extends()の引数で渡すオブジェクト全体)を型として指定できるようにすることによって、呼び出し時点での型で全体の方を表現できるようにしました。 一方後方互換性を考えたときに、第一型引数をstateからStoreOptionsに変えてしまうことは避けるべきです。ここで発生するのは

  1. stateの型だけを指定したVuexでも正常に動く
  2. StoreOptionsの型を指定した場合は従来のVuexも動くし、今回の型強化も使える

ということを実現する必要が出てきました。

今回こちらを解決する方法として第一型引数を不活性にし、第三型引数にて、stateの型をStoreOptionsの型を考慮した上で決定するという形にしました。

export declare class Store<
  _,
  SO extends StoreOptions<any> = StoreOptions<_>,
  S = SO["state"] extends _ ? SO["state"] : _
> {...

こうすることでStoreOptionsがあればそのstateを、なければ今までのstateの型で表現されるようになります。

3. Vuex特有のネスト構造の文字列結合によるフラット化

dispatch(), commit()は第一引数にネストしたモジュールを / つなぎで指定し、第2引数にその対象関数のpayloadを指定するという仕様になっています。 getterも関数ではないものの、同じようなネスト構造の表現をしています。

つまり

const s = {
  modules: {
    foo: {
      modules: {
        hoge: {
          actions: {
            bar: (n: number) => "aaa",
          },
        },
      },
    },
    bar: {
      actions: {
        bal: (x: string) => "aaa",
      },
    },
  },
};

{
  "bar/bal": (x: string) => string,
  "foo/hoge/bar": (n: number) => string
}

のような形に変換する必要があるわけです。今回はこれを下記のような形で実現しました

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

type ExtractObject<
  T,
  S extends string = "actions",
  P extends string = ""
> = (T extends {
  [_ in S]: infer O;
}
  ? { [K in keyof O as `${P}${string & K}`]: O[K] }
  : {}) &
  (T extends {
    modules: infer O;
  }
    ? UnionToIntersection<
        {
          [K in keyof O]: ExtractObject<O[K], S, `${P}${string & K}/`>;
        }[keyof O]
      >
    : {});

内包されているTIPSは3つです。順番に説明していきます

UnionからIntersectionに変換する

まずひとつ目のUnionToIntersectionですが、こちらを参考にさせていただきました。 記載にある通りUnionからIntersectionに変換する型です。これは何が嬉しいかというと、Mapped Types を使うと、返却される型がUnion型になるのですが、今回導出したい型はあくまで一つのオブジェクトの型になるためIntersectionの型にする必要があり、ここで使用しています。

KeyRemappingを使った、コードジャンプの実現と/区切りのkey

KeyRemappingはTS4.1から導入された機能で、Mapped Types内で as を使うと新しいkeyを使うことができるものになります

type MappedTypeWithNewProperties<Type> = {
    [Properties in keyof Type as NewKeyType]: Type[Properties]
}

ExtractObject型の第3引数は今までの再起表現で渡されてきたprefixの文字列(moduleの / つなぎされた文字)になるのですが、そのprefixとkeyを結合することによってVuexの引数で使われている文字列にできます。 また、もともとのKeyをRemapしているだけなので、そのKeyをベースにコードジャンプができるようになり、

このような挙動を可能にしています。

Mapped Types と オブジェクトのあとの[keyof K]

今回型を実装するに当たり、他のTSプロジェクトの型実装なども参考にしていたのですが、よく出てくる表現で下記のようなものがありました

{
  [K in keyof T]: Hoge<T[K]>
}[keyof T]

これは何をしているかというと T 型の各keyに対する要素をHogeの型引数に指定し、それをすべてUnion型で結合したものとして返却します。 この表現によりオブジェクトをフラットに展開することができ、上記で説明したUnionToIntersectにより一つのオブジェクト型に戻すことができます。 Hogeに当たるところを今回は再起表現にしたため、ネストしている全ての抽出対象の関数をフラットに展開することができました。

まとめ

今回Vuexの型表現を強化するためにさまざまなTIPSを駆使して実現してきました。型パズルの欲求を満たすには丁度いいお題だったかなと思います。 まだこのPRではmapXxx()系やnamespacedがfalseのとき、root指定のmoduleのときなどが考慮されていないため、型表現としては不十分なところはあるのですが、ここは時間あるときに必要あればやっていこうかなと思います。

デイリースクラムの発表順をランダムで決める bot を作る [discord.js + TypeScript]

新型コロナの影響で、ご多分に漏れず弊社の開発メンバーもフルリモートでお仕事をしています。

コアタイム開始の11:00になると Discord に集まりデイリースクラムを行っているのですが、毎回発表の順番を決めるのが面倒なので、参加メンバーをシャッフルしてリストで返す bot を作ってみました。

パッケージ類

Discord コミュニティ謹製のパッケージ discord.js を使いました。

外部パッケージ使う際は型情報が分かっておくと便利ですね。discord.js はそれ自体で型情報を持っているので @types/◯◯ みたいなパッケージを入れる必要はないです。

{
  "name": "discord_sort_gacha",
  "version": "1.0.0",
  "description": "Sort random and list up the online member.",
  "main": "index.js",
  "scripts": {
    "start": "node ./build/index.js",
    "debug": "ts-node ./src/index.ts",
    "compile": "tsc -p .",
    "compile:test": "tsc -p . --noEmit"
  },
  "author": "Shohei-Japan",
  "license": "MIT",
  "dependencies": {
    "discord.js": "^12.5.3",
    "dotenv": "^8.2.0"
  },
  "devDependencies": {
    "@types/node": "^14.14.41",
    "ts-node": "^9.1.1",
    "typescript": "^4.2.4"
  }
}

Bot のアカウント作成

こちらのページに全部書いてあります。

Botアカウント作成- discord.py

このページの通りに行って、Bot アカウント作成、トークン取得、Discord サーバーに招待、までできたら OK です。

テスト送信

さっそくテスト送信してみます。

Discord テキストチャンネルで「おはよう」と送信すると、「やあやあ」と返してくれる処理を書きます。

import { Client, Message } from "discord.js"
const client = new Client()

client.on('message', (message: Message) => {
  if (message.content === 'おはよう') {
    message.channel.send('やあやあ')
  }
})

const token = xxxxxx // 取得した bot のトークン
client.login(token)

結果

f:id:show-hei:20210423045835p:plain うまく通ったので、続きをやっていきましょう。

Discord 用語のおさらい

Discord ではメンバーが集まるためのサーバーを作りますが、discord.js ではこれをギルド (guild) と呼んでます。

ギルドにはチャンネルがあり、テキストチャンネル、ボイスチャンネル、DM チャンネルなどが存在します。

今回の目的としては、デイリースクラムのために「ボイスチャンネルに集まったメンバー」を、「bot にコマンドを送信したテキストチャンネル」にリスト化して返すことですので、テキストチャンネルとボイスチャンネルを活用します。

チャンネルに参加しているメンバー情報は各チャンネル内に含まれているのでそれも使っていきましょう。

実装

これからの処理はこの client.on() の中に記述していきます。 Message 型を参照しているので、message.xxx のデータは補完が効くので楽です。

client.on('message', (message: Message) => {
// bot コメントを受け取ったあとの処理
})

bot コメントしたメンバーのいるボイスチャンネルとメンバー一覧を取得

変数 message 内には、送信されたギルド情報が入っており、ギルド情報にはそのギルド内のチャンネル一覧情報が含まれています。 名前からは連想しづらいのですが、チャンネル情報はチャンネルの cache 内に含まれているようです。ここから bot コメントを送信したメンバーの ID が含まれているボイスチャンネルと、取得したボイスチャンネルに存在するメンバー一覧を取得していきます。

bot コメントは !gacha というテキストとしました。

// コメントしたメンバーを取得
const author = message.author

// コメントを取得
if (message.content === '!gacha') {
  // ギルドに含まれるチャンネルを取得
  const channels = message.guild.channels
  // channels 内の各チャンネルは type を持ち、'text', 'voice' などの値を持つ
  const voiceCH = channels.cache.find((ch: GuildChannel) => {
    if (ch.type !== 'voice') {
      return false
    }
    // ボイスチャンネルのうち、コメントしたメンバーがいるチャンネルを取得
    return !!ch.members.filter((member: GuildMember) => member.user.id === author.id)
  }) as VoiceChannel
    
  if (!voiceCH) {
    console.error('チャンネルがみっかんないよ')
    return
  }
    
  // voice チャンネルに参加しているメンバー一覧を取得
  const voiceCHMemberNames: string[] = voiceCH.members.map((member: GuildMember) => member.user.username)

メンバーをシャッフルする関数

メンバーをシャッフルするための関数を別途用意しました。これを通してあげるといいですね。

const shuffleArray = (array: string[]) => {
  for(let i = (array.length - 1); 0 < i; i--){
    var r = Math.floor(Math.random() * (i + 1))

    var tmp = array[i]
    array[i] = array[r]
    array[r] = tmp
  }
  return array
}

シャッフルしたメンバーをリストにしてチャンネルに返す

あとはシャッフル関数を通して、体裁を整えてチャンネルに返しましょう。

const shuffledMembers = shuffleArray(voiceCHMemberNames)

// リストにしてテキストチャンネルに送信する
const joinedMembers = shuffledMembers
  .map((member: string, index: number) => `${index + 1}. ${member}`)
  .join('\n')

message.channel.send(joinedMembers)

完成

完成したので試してみましょう。

f:id:show-hei:20210423063842p:plain

f:id:show-hei:20210423063904p:plain

ランダムでリスト化することができました!

f:id:show-hei:20210423063944p:plain

channels.cache を参照しているので、その名称から古いキャッシュを参照しちゃう?と不安でしたが、ちゃんとリアルタイムでのメンバーを取ってきてくれました。

index.ts全コード

クリックすると展開されます

// デプロイを想定してるので dotenv 使ってます。
require('dotenv').config()
import {
  Client,
  GuildChannel,
  GuildMember,
  Message,
  User,
  VoiceChannel
} from "discord.js"
const client = new Client()

// アプリケーション名を確認のために表示
client.on('ready', () => {
  if (!client.user) {
    console.error('認証できないよ')
    return
  }
  console.log(`Logged in as ${client.user.tag}.`)
})


// チャット欄のコメントを受け取る処理
client.on('message', (message: Message) => {
  if (!message.guild) {
    console.error('ギルドがないよ')
    return
  }

  // コメントしたメンバーを取得
  const author = message.author

  if (message.content === '!gacha') {
    const channels = message.guild.channels
    const voiceCH = channels.cache.find((ch: GuildChannel) => {
      if (ch.type !== 'voice') {
        return false
      }
      // ボイスチャンネルのうち、コメントしたメンバーがいるチャンネルを取得
      return !!ch.members.filter((member: GuildMember) => member.user.id === author.id)
    }) as VoiceChannel
    
    if (!voiceCH) {
      console.error('チャンネルがみっかんないよ')
      return
    }

    // voice チャンネルに参加しているメンバー一覧を取得
    const voiceCHMemberNames: string[] = voiceCH.members
      .map(member => member.user.username)
    const shuffledMembers = shuffleArray(voiceCHMemberNames)

    // リストにしてテキストチャンネルに送信する
    const joinedMembers = shuffledMembers
      .map((member: string, index: number) => `${index + 1}. ${member}`)
      .join('\n')

    const sendMessage = joinedMembers || 'メンバー取得できなかったよ'

    message.channel.send(sendMessage)
  }
})

/**
 * 与えられた配列をシャッフルして返す
 * @param array 
 * @returns 
 */
const shuffleArray = (array: string[]) => {
  for(let i = (array.length - 1); 0 < i; i--){
    var r = Math.floor(Math.random() * (i + 1))

    var tmp = array[i]
    array[i] = array[r]
    array[r] = tmp
  }
  return array
}

const token = process.env.DISCORD_TOKEN
client.login(token)