cockscomblog?

cockscomb on hatena blog

AWS LambdaでSlackアプリを動かす

プライベートな用事でサーバサイドで何かやりたい場合、サーバレスな構成が第一選択になる。規模が十分に小さい場合、サーバレスにした方が安い。常にインスタンスが立ち上がっているような構成は(たとえ冗長構成を取らなくても)プライベートな用事程度では大げさになる。またサーバレスな構成は放置しやすいのも魅力である。

f:id:cockscomb:20220307094357p:plain
Lambdaで動くcockscombot

最近、サーバサイドで何かしたあとの通知先としてSlackを使っている。Slackはちょっとしたユーザーインターフェースの代わりになる。その延長線上でSlackアプリを作ってみようと考えた。Slackが提供しているBoltというのを使うと、Slackアプリが簡単に作れる。

Slack | Bolt for JavaScript | Bolt 入門ガイド

BoltはAWS Lambdaにデプロイできるようになっている。ドキュメントではServerless Frameworkが使われているが、要するにAPI Gateway経由でLambdaを呼び出しているだけだ。

Slack | Bolt for JavaScript | AWS Lambda へのデプロイ

AWS CDKでSlackアプリを構築する

Serverless Frameworkでもいいけど、AWS CDKでもすぐにできる。API GatewayにNode.jsランタイムのLambdaをくっつけるのは、次のように書け、これでPOST /slack/eventsでLambdaが呼び出されるようになる。

import * as apigateway from '@aws-cdk/aws-apigatewayv2-alpha'
import * as apigateway_integrations from '@aws-cdk/aws-apigatewayv2-integrations-alpha'
import { Stack, StackProps } from 'aws-cdk-lib'
import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs'
import { Construct } from 'constructs'

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

    const botFunction = new lambda.NodejsFunction(this, 'BotFunction', {
      entry: './slack/src/bot.ts',
      handler: 'handler',
    })

    const botIntegration = new apigateway_integrations.HttpLambdaIntegration('BotIntegration', botFunction)

    const api = new apigateway.HttpApi(this, 'BotAPI')
    api.addRoutes({
      path: '/slack/events',
      methods: [apigateway.HttpMethod.POST],
      integration: botIntegration,
    })
  }
}

API GatewayをHTTP APIにしたかったので、experimentalの@aws-cdk/aws-apigatewayv2-alpha Construct Libraryを使っているが、これくらいなら大きな問題にはならないだろう。

aws-cdk-lib/aws-lambda-nodejsのおかげで、TypeScriptで書いたSlackアプリのコードがesbuildでいい具合にトランスパイルされ、手間要らずだ。

肝心のSlackアプリ(./slack/src/bot.ts)はBoltを使って次のように書ける。

import bolt from '@slack/bolt'
const { App, AwsLambdaReceiver } = bolt

const SLACK_BOT_TOKEN = ??? // TODO
const SLACK_SIGNING_SECRET = ??? // TODO

const awsLambdaReceiver = new AwsLambdaReceiver({
  signingSecret: SLACK_SIGNING_SECRET,
})
const app = new App({
  token: SLACK_BOT_TOKEN,
  receiver: awsLambdaReceiver,
})

app.message('Hello', async ({ message, say }) => {
  await say('Hi')
})

export const handler: ReturnType<typeof awsLambdaReceiver.toHandler> = async (event, context, callback) => {
  const handler = await awsLambdaReceiver.start()
  return handler(event, context, callback)
}

AwsLambdaReceiverというのが用意されているおかげで、Lambdaと繋ぎこむところはほとんど何もしなくていい。

シークレットをParameter Storeから読み取る

SLACK_BOT_TOKENSLACK_SIGNING_SECRETのふたつのシークレットを使っている。これらはハードコードしたくないので、AWS Systems Manager Parameter Storeに入れておいたものをLambdaから読み取らせることにする。

Parameter Storeにそれぞれ/slack/bot/SLACK_BOT_TOKEN/slack/bot/SLACK_SIGNING_SECRETの名前で、シークレットとして保存しておく。これらをLambdaから読み取るために、スタックを次のように書き換える。

import * as apigateway from '@aws-cdk/aws-apigatewayv2-alpha'
import * as apigateway_integrations from '@aws-cdk/aws-apigatewayv2-integrations-alpha'
import { Stack, StackProps } from 'aws-cdk-lib'
import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs'
import * as ssm from 'aws-cdk-lib/aws-ssm'
import { Construct } from 'constructs'

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

    const slackBotToken = ssm.StringParameter.fromSecureStringParameterAttributes(this, 'SlackBotToken', {
      parameterName: '/slack/bot/SLACK_BOT_TOKEN',
    })
    const slackSigningSecret = ssm.StringParameter.fromSecureStringParameterAttributes(this, 'SlackSigningSecret', {
      parameterName: '/slack/bot/SLACK_SIGNING_SECRET',
    })
    const botFunction = new lambda.NodejsFunction(this, 'BotFunction', {
      entry: './slack/src/bot.ts',
      handler: 'handler',
      environment: {
        SLACK_BOT_TOKEN_NAME: slackBotToken.parameterName,
        SLACK_SIGNING_SECRET_NAME: slackSigningSecret.parameterName,
      },
      bundling: {
        target: 'node14.14', // Top-level awaitが使えるバージョン
        tsconfig: './slack/tsconfig.json',
        format: lambda.OutputFormat.ESM,
        nodeModules: ['@aws-sdk/client-ssm', '@slack/bolt'],
      },
    })
    slackBotToken.grantRead(botFunction)
    slackSigningSecret.grantRead(botFunction)

    const botIntegration = new apigateway_integrations.HttpLambdaIntegration('BotIntegration', botFunction)

    const api = new apigateway.HttpApi(this, 'BotAPI')
    api.addRoutes({
      path: '/slack/events',
      methods: [apigateway.HttpMethod.POST],
      integration: botIntegration,
    })
  }
}

ssm.StringParameter.fromSecureStringParameterAttributes()でパラメータを参照し、slackBotToken.grantRead(botFunction)のようにしてLambdaに読み取り権限を与える。また環境変数を介してパラメータの名前を渡しておく。

さらにlambda.NodejsFunctionのオプションにbundlingを増やし、esbuildにオプションを渡す。./slack/tsconfig.jsonも少し調整してある。

{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "target": "es2019",
    "module": "es2022",
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true
  }
}

面倒な感じになっているが、こうするとTypeScriptからES modulesが生成され、top-level awaitが使えるようになる。AWS LambdaではNode.js 14ランタイムでtop-level awaitがサポートされており、特にLambdaのProvisioned Concurrencyを利用している場合にコールドスタート時のレイテンシが改善するとされている。

後は次のようにシークレットを読み取る関数を用意しておけばよい。

import { GetParametersCommand, SSMClient } from '@aws-sdk/client-ssm'
import { strict as assert } from 'assert'

export async function getSlackSecrets(): Promise<{ token: string; signingSecret: string }> {
  const [SlackBotTokenName, SlackSigningSecretName] = [
    process.env.SLACK_BOT_TOKEN_NAME,
    process.env.SLACK_SIGNING_SECRET_NAME,
  ]
  assert(SlackBotTokenName, 'SLACK_BOT_TOKEN_NAME is not set')
  assert(SlackSigningSecretName, 'SLACK_SIGNING_SECRET_NAME is not set')

  const ssmClient = new SSMClient({
    region: process.env.AWS_REGION,
  })
  const parametersResponse = await ssmClient.send(
    new GetParametersCommand({
      Names: [SlackBotTokenName, SlackSigningSecretName],
      WithDecryption: true,
    })
  )
  const parameters = parametersResponse.Parameters
  assert(parameters, 'Parameter Store values not found')
  const [{ Value: token }, { Value: signingSecret }] = parameters
  assert(token, 'Parameter Store value for SLACK_BOT_TOKEN is not set')
  assert(signingSecret, 'Parameter Store value for SLACK_SIGNING_SECRET is not set')

  return {
    token,
    signingSecret,
  }
}

シークレットの取得はtop-level awaitを使って次の1行で済む。

const { token: SLACK_BOT_TOKEN, signingSecret: SLACK_SIGNING_SECRET } = await getSlackSecrets()

ここまでやってはみたが、別にLambdaのProvisioned Concurrencyを使うわけではない。Top-level awaitを使おうとするとES modulesにする必要があって、現状のエコシステムでは少し複雑になる。これくらいならシークレットをそのまま環境変数に入れてしまった方がいいかもしれない。

費用

試しに実行してみた感じでは、128MBのメモリ割り当てで、コールドスタートなら平均的に250msくらい、ウォームスタートなら2msくらいのBilled Durationになった。メッセージに反応するくらいなら一瞬で処理できる。

app.message()は、Slackアプリが参加しているチャンネルのメッセージすべてに対して呼び出される。実際に処理する必要があるかどうかは問わない。例えばSlackアプリが参加しているチャンネルに毎日1,000件のメッセージが投稿されるとすると、これに伴って呼び出されるLambdaはこれくらいの頻度であれば毎回コールドスタートになるだろうから、東京リージョンでは月々$0.03かかる。API Gatewayも呼び出し毎に課金され、月々にして$0.04かかる。合わせて月々$0.07になった。お小遣いで払えそうでよかった。

サービス 費用(月々)
AWS Lambda $0.03
Amazon API Gateway $0.04
合計 $0.07

AWS Pricing Calculator

ここから、Lambdaのアーキテクチャx86からArmにすることでLambdaの費用を2/3に抑えられる可能性がある。そもそもLambdaの無料枠が残っていればそれに収まる。

このSlackアプリはほとんど意味のあることをしていないが、ちゃんと実装して処理時間が呼び出しあたり100ms増えたとしても月々$0.10程度に収まるだろうし、データベースにDynamoDBを使ってもたかがしれていると思う。

イベントの購読

費用の計算で、「app.message()は、Slackアプリが参加しているチャンネルのメッセージすべてに対して呼び出される」ことを前提にした。app.message()を使うにはmessage.channelsmessage.groupsmessage.immessage.mpimのようなmessage.*イベントを購読する。しかし例えば、Slackアプリに対してのメンションにだけ反応すればいいなら、app.event()app_mentionイベントだけを購読すればいい。呼び出し回数を大きく抑えられ、費用が下がる。

いかがでしたか

Slackアプリをサーバレスで構築するのは簡単で、趣味で使う範囲では十分に安い。Serverless Frameworkだと(ngrokを使えば)ローカルで開発しやすいが、そこに目を瞑ればCDKでも普通に開発できる。DynamoDBを組み合わせるときなどはCDKの方が楽だと思う。開発時にはCDK Watchdeploy --hotswapなどを使うことでデプロイを速く楽にできる。

みんなもやってみてね。

GitHub ActionsでDocker Buildするときのキャッシュテクニック

GitHub Actionsでdocker buildすることが多い。このときのキャッシュをどうするかという話題。

基本

GitHub Actionsでdocker buildしてAmazon ECRにdocker pushする、典型的な.github/workflow/docker-push-to-ecr.ymlはこういう感じ。

name: Push to Amazon ECR

on:
  push:
    branches: [ 'main' ]

jobs:

  docker:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2

      - uses: docker/setup-buildx-action@v1

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-1

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v1

      - uses: docker/metadata-action@v3
        id: meta
        with:
          images: |
            ${{ steps.login-ecr.outputs.registry }}/awesome-app 
          tags: |
            type=sha,prefix=

      - uses: docker/build-push-action@v2
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Docker周りはDocker公式のアクションを組み合わせて使って、BuildKitでやる。

キャッシュはdocker/build-push-action@v2cache-fromcache-toでやっていて、type=ghaとしてGitHub Actionsのキャッシュにのせている。

type=ghaは、BuildKitで使えるキャッシュの中でも手軽で使いやすい。GitHub Actionsのキャッシュはブランチと紐づいていて、現在のブランチとベースブランチのキャッシュが利用できるので、CIと組み合わせたときにわりと都合がいい。

Build Matrixとの組み合わせ

背景

去年、業務で利用しているMacがM1 Proのそれになって、我らがCTO id:motemenが用意しているmod_perl用Dockerイメージ(motemen/docker-mod_perl)にパッチを送り、arm64アーキテクチャのイメージをビルドしてもらうようにした。

変更は簡単で、docker/setup-qemu-actionQEMUのセットアップをして、docker/build-push-actionplatformsを指定した。ベースイメージの時点でマルチアーキテクチャに対応しているし、Dockerfileでアーキテクチャに依存したことをしていないので、これで十分。

マルチアーキテクチャになったのはよかったけど、GitHub Actionsがすごく遅くなってしまった。もともとキャッシュが切れていても5分30秒くらいで済んでいたのが、1時間以上かかるようになった。とはいえ、QEMUでCPUアーキテクチャをまたいでビルドすると遅いのは、それはそう。それはそうなんだけど、キャッシュの効きが悪いのも気になっていた。

f:id:cockscomb:20220215180147p:plain

add perl 5.34.0 · motemen/docker-mod_perl@1b6041d

こういうのをみると、Matrixでビルドしているうち「5.30.1」のみ9秒で終わっているが、残りは1時間近くかかっている。

f:id:cockscomb:20220215180222p:plain

Merge pull request #15 from motemen/modperl-2-0-12 · motemen/docker-mod_perl@ca11e85

さっきのブランチのベースになっているのはこちらで、「5.30.1」が一番最後に終わっていそうだった。

このことから、Matrixビルドでキャッシュが競合していて、後勝ちになっていることが類推された。

scopeを設定する

ということでBuildKitのghaキャッシュのドキュメントをもう一回見ると、scopeというのがある。これを使って、docker/build-push-action@v2cache-fromcache-toscopeを設定してみる。

          cache-from: type=gha,scope=perl-${{ matrix.perl_version }}
          cache-to: type=gha,mode=max,scope=perl-${{ matrix.perl_version }}

これでMatrixごとにキャッシュが分離された。

f:id:cockscomb:20220215175945p:plain

[DNM] test actions · motemen/docker-mod_perl@3b407c8

各バージョンが20秒前後になり、全体で1分弱になった。よかった。

まとめ

GitHub ActionsでBuildKitを使うときにtype=ghaのキャッシュが使える。ただしMatrixビルドなどで並列に複数のジョブが走ると、キャッシュが衝突してしまうが、scopeを設定することで回避できる。

Swift ConcurrencyのwithTaskCancellationHandlerとSendable

Swift 5.5がリリースされた。おめでとうございます。

Swift 5.5の目玉はもちろんSwift Concurrencyだ。言語機能として並行処理がサポートされた。async/awaitの構文だけでなく、Structured Concurrencyとしての整理や、actorの導入など、野心的な取り組みと言える。

Swift Concurrency

Swift Concurrencyに直接関係するSwift Evolutionの提案はこれだけある。

これだけの提案が行われ、実装されたことに圧倒される。

The Swift Programming Language」にもConcurrencyの章が追加されている。こちらではわかりやすく説明されているので、概要を掴みやすい。

そろそろ本題。

タスクのキャンセル

Structured Concurrencyでは、親のタスクがキャンセルされると子のタスクもキャンセルされる。キャンセルされたかどうかはTask.isCancelledTask.checkCancellation()とかで確認できる。

withTaskCancellationHandler

タスクがキャンセルされたときに何か実行するにはwithTaskCancellationHandlerを使う。

func withTaskCancellationHandler<T>(operation: () async throws -> T, onCancel handler: @Sendable () -> Void) async rethrows -> T

onCancel@Sendableであることは重要なので、覚えておいてください。

コールバックを引数に取る関数からasync関数を作るwithUnsafeThrowingContinuationwithTaskCancellationHandlerを組み合わせる例が、SE-0300 Continuations for interfacing async tasks with synchronous codeのAdditional examplesにある。

import Foundation

func download(url: URL) async throws -> Data? {
    var urlSessionTask: URLSessionTask?

    return try await withTaskCancellationHandler {
        try await withUnsafeThrowingContinuation { continuation in
            urlSessionTask = URLSession.shared.dataTask(with: url) { data, _, error in
                if let error = error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume(returning: data)
                }
            }
            urlSessionTask?.resume()
        }
    } onCancel: {
        urlSessionTask?.cancel()
    }
}

最新のAPIに合わせて少し書き換えているが、これは次のエラーでコンパイルできない。

Reference to captured var 'urlSessionTask' in concurrently-executing code

var urlSessionTask: URLSessionTask?varであるため、エラーが起きている。これはdiag::concurrent_access_of_local_captureというメッセージだった。

diag::concurrent_access_of_local_capture

エラーを出しているのはここ。

ちょっとコードを読むと、「flow-sensitive concurrent captures」なる機能についても言及されているが、今はフラグで無効になっている。

これはキャプチャした後に変更されないとわかっていればOKとするもので、今回のケースではいずれにせよコンパイルが通らないのが正しいように見える。

varじゃなくてletにして、参照型を間に挟めばコンパイルが通る。

import Foundation

class Wrapper<Wrapped> {
    var value: Wrapped
    init(_ value: Wrapped) { self.value = value }
}

func download(url: URL) async throws -> Data? {
    let urlSessionTask: Wrapper<URLSessionTask?> = Wrapper(nil)

    return try await withTaskCancellationHandler {
        try await withUnsafeThrowingContinuation { continuation in
            urlSessionTask.value = URLSession.shared.dataTask(with: url) { data, _, error in
                if let error = error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume(returning: data)
                }
            }
            urlSessionTask.value?.resume()
        }
    } onCancel: {
        urlSessionTask.value?.cancel()
    }
}

一見よいように思うかもしれないが、var変数のキャプチャはNGでこれはOKなのって不思議じゃないですか?ということで、Other Swift Flags-Xfrontend -warn-concurrencyを設定する。

f:id:cockscomb:20210926102445p:plain
Other Swift Flags

-warn-concurrency

-warn-concurrencyってなんなんだという話だけど、これは互換性と関係している。Swift 5系では破壊的な変更を避けつつ、並行処理でデータ競合を避けるための道具を提供する。そしてSwift 6ではチェックを厳密にすることで、デフォルトでデータの競合について安全になる。-warn-concurrencyオプションをつけると、Swift 6で問題になる箇所を予め知るためのもの、ということらしい。

さて、-warn-concurrencyオプションをつけると、該当の箇所に次の警告が現れる。

Cannot use let 'urlSessionTask' with a non-sendable type 'Wrapper<URLSessionTask?>' from concurrently-executed code

onCancel@Sendableクロージャだが、そこからSendableではない値を参照していることで、警告が出た。class Wrapper<Wrapped>@unchecked Sendableにすれば警告は消えるが、Wrapperがちゃんとスレッドセーフなわけではないので、コンパイラを誤魔化しているだけの効果しかない。

Sendable

Swift 6でチェックされる項目の一つはSendableについてである。

Sendableの提案は受理されているが、現時点では実装済みになっていない。Swift 6でデフォルトでチェックされるようになるのを待っているのだと思うが、Swiftの標準ライブラリでは既に使われているし、自分たちで使うこともできる。

Sendableというのはactor間でやり取りできる型を表すマーカープロトコルである。マーカープロトコルはコンパイラに対する目印。

Sendableにできるかどうかは、例えば次のようになっている。

  • structのような値型は、Copy on Writeなので、actor間でやり取りしても競合が起きない。変更可能なプロパティがSendableであればSendableにできる
  • classは参照型なので、変更可能なプロパティを持っていないときだけSendableにできる。ただしfinalじゃないといけない
  • actorは参照型だけど内部状態をデータ競合から保護しているのでSendable

classだけど、内部的にデータ競合を防ぐ仕組み(ロックとか)を持っていることもある。そういうときは@unchecked Sendableに指定できる。これはコンパイラにチェックされないSendableである。

そしてクロージャ@Sendableアノテーションすると、クロージャもSendableになる。SendableなクロージャがキャプチャできるのはSendableだけである。

Swift Atomics

ここでとりあえずapple/swift-atomicsManagedAtomicLazyReferenceを使ってみる。要するにデータの競合が排除されていればいいので、今回の用途には大袈裟な感じもするが、使ってしまう。

import Foundation
import Atomics

func download(url: URL) async throws -> Data? {
    let urlSessionTask = ManagedAtomicLazyReference<URLSessionTask>()

    return try await withTaskCancellationHandler {
        try await withUnsafeThrowingContinuation { continuation in
            let task = urlSessionTask.storeIfNilThenLoad(URLSession.shared.dataTask(with: url) { data, _, error in
                if let error = error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume(returning: data)
                }
            })
            task.resume()
        }
    } onCancel: {
        urlSessionTask.load()?.cancel()
    }
}

ManagedAtomicLazyReferenceは、遅延して初期化される参照型をメモリ管理しつつ保持してくれる。

Swift Atomicsの1.0.2時点ではManagedAtomicLazyReferenceはSendableではないが、次のバージョンではSendableになるはずだ。

次のようにしてこれを先取りする。

extension ManagedAtomicLazyReference: @unchecked Sendable where Instance: Sendable {}
extension URLSessionTask: @unchecked Sendable {}

勢いよくURLSessionTaskもSendableにしてしまっているが、スレッドセーフなはずなので大丈夫だと思う。

ここまでやって、ついに-warn-concurrencyでも警告が出なくなったはずだ。

実際にはresumeする前にもキャンセルのチェックをしないと、タイミングによってうまくキャンセルされないはずなので、チェックする。ここまでのコードをまとめると次のようになるはず。

import Foundation
import Atomics

extension ManagedAtomicLazyReference: @unchecked Sendable where Instance: Sendable {}
extension URLSessionTask: @unchecked Sendable {}

func download(url: URL) async throws -> Data? {
    let urlSessionTask = ManagedAtomicLazyReference<URLSessionTask>()

    return try await withTaskCancellationHandler {
        return try await withUnsafeThrowingContinuation { continuation in
            let task = urlSessionTask.storeIfNilThenLoad(URLSession.shared.dataTask(with: url) { data, _, error in
                if let error = error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume(returning: data)
                }
            })
            if Task.isCancelled {
                continuation.resume(throwing: CancellationError())
                return
            }
            task.resume()
        }
    } onCancel: {
        urlSessionTask.load()?.cancel()
    }
}

いかがでしたか?

withTaskCancellationHandlerを題材にしてSwift Concurrencyの-warn-concurrencyを試してみたつもりですが、いかがでしたか?なんとなく大袈裟に感じたように思う。現実には、URLSessionはすでにSwift Concurrencyに対応したインターフェースを持っているので、こういうコードを書くことはない。とはいえ-warn-concurrencyすると、普通のアプリでもSendableに関連した警告がいくらでも出てくるのではないか。

Swiftは安全な言語を志向しているから、デフォルトでデータ競合からも安全になっていく。(これは別にSwiftに限ったことではない。)このことはポジティブに受け止めていいはずだし、Swift 6が楽しみである。

とはいえ現状では、-warn-concurrencyでやっていくのは少し難しい。スレッドセーフなclassでもSendableがついていなかったりするので、単純にやりにくい。Swift自体もConcurrencyのサポートを始めたところで、Swift 6までにまだよくなるだろうから、焦らなくてもいいはずだ。

特にアプリの開発では、たまに-warn-concurrencyを有効にしてみて、なるほど、くらいでいいような気がする。反対にライブラリの場合は、今のうちから-warn-concurrencyを設定しておくと、将来的な破壊的変更を避けられるかもしれない。

こちらからは以上です。

Relayに学ぶGraphQLのスキーマ設計

2018年の初めくらいから、仕事でGraphQL APIを何度も作っている。サーバーサイドもクライアントサイドも実装している。

最近クライアント側にRelayを使ってみている。

GraphQLのクライアントとしてはApolloを使う場合が多いと思うが、Facebook製のRelayもかなりよくできている。以前はTypeScriptに対応していなかったが、今はTypeScriptも使える。最近のバージョンではhooksのAPIがexperimentalではなくなり、ReactのSuspense API(Suspense for Data Fetchingは使わずに)と合わせて使える。

RelayはGraphQLのスキーマに制約を設けることで、クライアント側のAPIがデータの再取得やページネーションなどを抽象化している。換言すると、Relayからデータの再取得やページネーションに必要なスキーマ上の制約を学べる、ということだ。

ということで、スキーマの設計について抽象的な理解を得たので、それを記す。

RelayのGraphQL Server Specificationに倣う

GraphQL Clientとしてrelayを利用することもそうでないこともあるが、いずれにしても、これに倣っておくとうまくいくことが多い。

RelayのGraphQL Server Specificationは、再取得とページネーションのための仕様である。

Node

id という ID! 型のフィールドを持った Node インターフェースを定義する。

interface Node {
  id: ID!
}

ID はグローバルに(型に関わらず)ユニークな識別子。内部的には base64(type + ":" + internal_identifier) のような実装にすることが多い。クライアントサイドでは透過的な値として扱い、パースを試みるべきではない。

トップレベルのクエリに node(id: ID!): Node を持つ。Node インターフェースに準拠する型は、このトップレベルクエリから取得できるようにする。

type Query {
  node(id: ID!): Node
}

このようにしておくと、Node はクライアントから便利に扱える。キャッシュのノーマライズもできるし、再取得も簡単である。

Connection

Connectionはページングに関連している。要するに、ページネーションを行うフィールドで Connection というのを返したりすると、カーソルベースのページネーションができる。

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}
type User implements Node {
  id: ID!
}
type UserEdge {
  node: User!
  cursor: String!
}
type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
}
type Query {
  users(first: Int, after: String, last: Int, before: String): UserConnection!
}

組み合わせ

例えば Userfriends(first: Int!, after: String!): UserConnection! というページネーションできるフィールドがあるとき、2ページ目を取得するには User から指定しなければならない。

type User implements Node {
  id: ID!
  friends(first: Int!, after: String!): UserConnection!
}
query {
  node(id: "xxx") {
    ... on User {
      friends(first: 10, after: "yyy") {
        ...
      }
    }
  }
}

このようにネストしたフィールドでのページネーションでは、目的のフィールドまでのパスが一意に定まる必要がある。

Viewerパターン

サービスにもよるが、GraphQL APIを認証情報付きで呼び出している利用者を、type Viewer として表し、トップレベルから viewer フィールドで取得できるようにする。

type Viewer {
  name: String!
}

type Query {
  viewer: Viewer
}

このようなフィールドがあると、例えばログインしている利用者の名前を表示する際に便利なショートカットになる。従って利用者自身に紐づくリソースは Viewer に持たせるとよい。

Viewer 以外にも Visitor のような語彙でもよさそうではあるが、統一されている方が便利なので、特別な理由がなければ合わせるとよい。また、Relayではこのようなフィールドを Viewer に決め打ちして特別扱いしている。

取得可能性

type Query のフィールド、Node、そしてViewerパターンでは、いずれも目的のオブジェクトを直接取得可能である。使い分けは、グローバルにユニークなオブジェクトなら type Query のフィールド、そうでなければNode、そして現在の利用者を指すショートカットとしてViewerパターン、となるだろう。

Relayではもうひとつ、@fetchable ディレクティブと fetch__Xxx というトップレベルのクエリを組み合わせることができる。

directive @fetchable(field_name: String!) on OBJECT

type User @fetchable(field_name: "id") {
  id: ID
}

type Query {
  fetch__User(id: ID!): User
}

見ての通り、ほとんどNodeと同じである。

合わせて4つが、直接取得可能なフィールドということになる。Relay Compiler(Rustで書かれている)でも、これらが特別扱いされている。

GraphQLで扱うオブジェクトは、なるべく直接取得ができるように設計されていると、取り回しがよい。例えばパーマリンクとして扱うには直接取得が可能でなければならないだろう。

Mutationの戻り値

Mutationでオブジェクトを作成、更新、もしくは削除したときの戻り値をどうするか。

更新

GraphQLクライアントは普通、内部にノーマライズされたキャッシュを保持している。

例えばTwitterを作るとして、ツイート一覧画面について考える。一覧でツイートを選択して、個別のツイートの画面で「いいね」する。その後ツイート一覧に戻ってきたとき、一覧の中でも該当のツイートが「いいね」状態になっていてほしい。

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

type Tweet implements Node {
  id: ID!
  text: String!
  likeCount: Int!
  viewerLiked: Boolean
}

type TweetEdge {
  node: Tweet!
  cursor: String!
}
type TweetConnection {
  edges: [TweetEdge!]!
  pageInfo: PageInfo!
}

type Query {
  node(id: ID!): Node
  timeline(first: Int!, after: String): TweetConnection!
}

このとき、TweetNode なので、GraphQLクライアントは id の値をキーとしてキャッシュをノーマライズする。Mutationとして like(tweetID: ID!): Tweet! が用意されていれば、これの戻り値を使ってキャッシュを更新できるため、「いいね」状態の一貫性が維持される。

要するに、特に Node の更新については、戻り値は Node そのものであればよい。

作成

作成されたオブジェクトが一覧にも表示される場合、取得されたConnectionのキャッシュにも要素を付け加える必要がある。

大まかには、戻り値が Node であればよい。RelayのConnectionを利用している場合、戻り値をEdgeにしてもよい。戻り値を使ってConnectionのキャッシュを書き換える必要がある。

Relayの場合、@appendEdge / @prependEdge もしくは @appendNode / @prependNode といったディレクティブが用意されており、Connectionの前後に要素を付け加えるのが少し簡単になっている。

削除

オブジェクトを削除する場合も、やはり一覧での表示から取り除く必要がある。

Connectionから取り除くには、Nodeid さえわかっていればいい。従って戻り値は ID! で十分である。

Relayでは @deleteEdge ディレクティブが用意されている。

まとめると

GraphQLのスキーマを設計するとき、とりあえず従っておくべき指針をRelayから得た。

まずはRelayのGraphQL Server Specificationに従っておく。NodeとConnectionを実装する。

そして直接的に取得可能な、type Query のフィールドやNode、Viewerパターンを意識する。

Mutationの戻り値は、作成や更新ならNode、削除はIDが基本になる。

例外はいくらでもあると思うが、最初はこれくらいから考えると概ね妥当と思う。

SwiftUIにおけるActionパターン

SwiftUIはWWDC20で大きく更新された。アプリ全体をSwiftUIで作る方法が確立されたほか、新しい抽象がいくつも導入されている。どれも非常に興味深いが、本記事ではその中からActionパターンを見出し、紹介する。

Actionの導入

WWDC20ではSwiftUIに、以下の4つの「Action」が導入された。

また対応するプロパティがEnvironmentValuesに追加されている。

ドキュメントに使い方が載っている。

struct SupportView : View {
    @Environment(\.openURL) var openURL

    var body: some View {
        Button(action: contactSupport) {
            Text("Email Support")
            Image(systemName: "envelope.open")
        }
    }

    func contactSupport() {
        openURL(mailToSupport)
    }
}

@Environment(\.openURL) var openURLとしてEnvironmentから取得しているのが、OpenURLAction構造体の値である。これをcontactSupport()関数の中で、openURL(mailToSupport)という風に呼び出している。

構造体の値を関数のように呼び出しているが、これは「SE-253 Callable values of user-defined nominal types」で議論され、Swift 5.2で導入された機能だ。このような値をcallable valueという。callAsFunctionメソッドを持っている構造体の値やクラスのインスタンスは、関数のように呼び出すことができる。関数のシグネチャcallAsFunctionメソッドのシグネチャと一致する。またcallAsFunctionメソッドはオーバーロードできる。

Actionという抽象

4つのActionが、「Action」と名付けられていることには意味がある。Actionの特徴は、以下のようになる。

  • Actionの名前はAction接尾語を持つ(XxxActionのように)
  • Actionの名前は動詞と目的語で構成される
  • Actionはcallable valueである(callAsFunctionメソッドを持っている)
  • ActionはEnvironment経由で取得できる
  • Actionを表すEnvironmentValuesのプロパティはActionの名前からAction接尾語を除いたものになる(OpenURLActionopenURLになる)
  • (おそらく)Actionは値型である

最後だけは「おそらく」と書いたが、Actionを参照型で作って状態を共有するようなケースはおおよそ考えられないし、またそうあるべきではないと思われるから、値型(つまり構造体など)で作るのが普通だろう。

これらの特徴を備えたActionは必然的に、コンテキストの親(祖先)が提供する機能を、子(子孫)から呼び出す、というユースケースになる。

独自のActionを作る

Actionとして抽象化できそうな処理があれば、独自のActionを作ることができる。

Actionとなる構造体を作るのは簡単だ。

struct PrintStringAction {
    func callAsFunction(_ string: String) {
        print(string)
    }
}

これをEnvironmentに入れるには、以下のようにKeyを作って、EnvironmentValuesを拡張する。

struct PrintStringActionKey: EnvironmentKey {
    static var defaultValue: PrintStringAction {
        return PrintStringAction()
    }
}

extension EnvironmentValues {
    var printString: PrintStringAction {
        get {
            return self[PrintStringActionKey.self]
        }
        set {
            self[PrintStringActionKey.self] = newValue
        }
    }
}

これだけで、@Environment(\.printString) var printStringとして利用できる。

(この例はActionとして抽象化するのが適切ではない……。)

SwiftUIにおけるAction

「Action」という語は、伝統的なCocoaにおいてはTarget-Actionパターンに登場し、さらにUIKitにはUIActionが登場している。あるいはFluxでも使われる。このように、文脈に依存して意味が少しずつ異なるが、SwiftUIではここまで説明したようなものが「Action」パターンである。

ということで、SwiftUIにおけるActionパターンを見出した。パターンというのは、再利用可能な設計のプラクティスである。

Actionパターンは、SwiftUIにおいて必要不可欠な抽象化になっていくだろう。

Swift AWS Lambda Runtimeを試す

2020年5月末に、 SwiftをAWS Lambdaで動作させるプロジェクトが発表された。swift-server/swift-aws-lambda-runtimeがそれである。ということで、AWS CDKでAPI GatewayとSwiftのLambda Handlerを作ってみた。

Lambda Runtime

AWS Lambdaは、AWSのFaaS。提供されているランタイムを使えば、ソースコードをアップロードするだけで、関数が実行できる。提供されているランタイムはNode.jsやPythonRubyJava、Go、.NET Coreである(徐々に拡充されていった)。そして2018年のre:Inventで、Lambda LayerとLambda Runtime APIが発表され、swift-aws-lambda-runtimeでは、このRuntime APIを利用している。

Lambda Runtime APIを乱暴に説明すると、こうだ。bootstrapという実行ファイルを用意しておくと、自動的にこれを起動してくれる。bootstrapは内部でイベントループを回す。イベントループの内部では、HTTPでイベントを取得し、それを処理して、結果をHTTPで送る、というのを繰り返す。

swift-aws-lambda-runtimeは、イベントループを回して、イベントを取得して結果を返す、というところをやってくれる。

Lambda

f:id:cockscomb:20200615001416p:plain
AWS Lambda

swift-aws-lambda-runtimeの主要な開発者であるFabian Fettさんのチュートリアルを参考に進める。

Getting started with Swift on AWS Lambda

まずはSwift Package Managerでpackageを作る。

$ swift package init --type executable

API Gatewayを使いたいので、Package.swiftAWSLambdaEventsの依存も追加。

import PackageDescription

let package = Package(
    name: "Handler",
    platforms: [
        .macOS(.v10_13),
    ],
    products: [
        .executable(name: "Handler", targets: ["Handler"]),
    ],
    dependencies: [
        .package(
            url: "https://github.com/swift-server/swift-aws-lambda-runtime.git",
            .upToNextMajor(from: "0.1.0")),
    ],
    targets: [
        .target(
            name: "Handler",
            dependencies: [
                .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"),
                .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-runtime"),
            ]
        ),
    ]
)

API Gatewayのリクエストを受けて適当なJSONを返すのは、以下のようになる。

import AWSLambdaEvents
import AWSLambdaRuntime
import Foundation

struct Response: Codable {
    let message: String
}

Lambda.run {
    (
    context,
    request: APIGateway.Request,
    callback: @escaping (Result<APIGateway.Response, Error>) -> Void
    ) in
    context.logger.debug("\(request)")
    let encoder = JSONEncoder()
    do {
        let response = Response(message: "OK")
        let json = try encoder.encode(response)
        callback(
            .success(
                APIGateway.Response(
                    statusCode: .ok,
                    headers: ["Content-Type": "application/json"],
                    body: String(bytes: json, encoding: .utf8)
                )
            )
        )
    } catch {
        callback(.failure(error))
    }
}

Lambda Runtimeの説明で書いたようなイベントループとかそういうのは、すっかり抽象化されている。

あとはこれをbootstrapという実行ファイルにして、Lambdaにアップロードすればいい。

パッケージング

実行ファイルはAmazon Linux 2で作る。Dockerの公式なSwiftイメージに、Amazon Linux 2のものが用意されているので、今回はswift:5.2-amazonlinux2を使う。

チュートリアルを真似て、簡単なシェルスクリプトを用意する。

#!/bin/bash

set -eu

executable=$1

swift build --product $executable -c release

target=.build/lambda/$executable
rm -rf "$target"

mkdir -p "$target"
cp ".build/release/$executable" "$target/"
cp -Pv \
  /usr/lib/swift/linux/libBlocksRuntime.so \
  /usr/lib/swift/linux/libFoundation.so \
  /usr/lib/swift/linux/libFoundationNetworking.so \
  /usr/lib/swift/linux/libFoundationXML.so \
  /usr/lib/swift/linux/libdispatch.so \
  /usr/lib/swift/linux/libicudataswift.so \
  /usr/lib/swift/linux/libicudataswift.so.65 \
  /usr/lib/swift/linux/libicudataswift.so.65.1 \
  /usr/lib/swift/linux/libicui18nswift.so \
  /usr/lib/swift/linux/libicui18nswift.so.65 \
  /usr/lib/swift/linux/libicui18nswift.so.65.1 \
  /usr/lib/swift/linux/libicuucswift.so \
  /usr/lib/swift/linux/libicuucswift.so.65 \
  /usr/lib/swift/linux/libicuucswift.so.65.1 \
  /usr/lib/swift/linux/libswiftCore.so \
  /usr/lib/swift/linux/libswiftDispatch.so \
  /usr/lib/swift/linux/libswiftGlibc.so \
  "$target"
cd "$target"
ln -s "$executable" "bootstrap"
zip --symlinks lambda.zip *

swift buildして、Swiftのランタイムや標準ライブラリ(shared objectファイル)を集めてきて、実行ファイルをbootstrapという名前でsymlinkして、ZIPにまとめている。

$ docker run --rm -it swift:5.2-amazonlinux2 ls /usr/lib/swift/linux
libBlocksRuntime.so       libicui18nswift.so.65.1
libFoundation.so          libicuucswift.so
libFoundationNetworking.so    libicuucswift.so.65
libFoundationXML.so       libicuucswift.so.65.1
libXCTest.so              libswiftCore.so
lib_InternalSwiftSyntaxParser.so  libswiftDispatch.so
libdispatch.so            libswiftGlibc.so
libicudataswift.so        libswiftRemoteMirror.so
libicudataswift.so.65         libswiftSwiftOnoneSupport.so
libicudataswift.so.65.1       libswift_Differentiation.so
libicui18nswift.so        x86_64
libicui18nswift.so.65

あとはこれをDockerで動かす。

FROM swift:5.2-amazonlinux2

RUN yum -y update && yum -y install \
  zip

COPY build.sh /build.sh
WORKDIR /src
ENTRYPOINT ["/build.sh"]

ソースコード/srcにマウントするつもりなので、Dockerfileはこれだけ。

$ docker build --tag swift-lambda-builder .
$ docker run --rm --volume /Users/cockscomb/swift-lambda/handler:/src swift-lambda-builder Hanlder

あとはこういう感じで実行すると、マウントされたディレクトリ下に.build/lambda/Handler/lambda.zipができる。

AWS CDKを使う

パッケージングからデプロイの作業は退屈なので、Infrastructure as Codeって感じで、AWS CDKを使ってまとめてしまう。AWS CDKというのは、AWS CloudFormationをいい感じにしてくれるやつ。

CloudFormationは、YAMLとかで書かれたテンプレートをもとに、AWS上のリソース(ここではLambdaとか)を作成したり、更新したりしてくれる。CloudFormationで作ったリソースは、手で書き換えてはいけない。CloudFormationする時の単位をスタックと言う。

AWS CDKを使うと、CloudFormationのテンプレートをTypeScriptなどのプログラミング言語で書けるようになる。今回は使わないが、スタック間のリソースの依存関係もうまく表現できる。

CDKをセットアップしたら、以下のようなスタックを定義する。

import * as cdk from "@aws-cdk/core";
import * as lambda from "@aws-cdk/aws-lambda";
import * as apigateway from "@aws-cdk/aws-apigateway";

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

    const code: lambda.AssetCode = // TODO: ここをなんとかする

    const handler = new lambda.Function(this, "Handler", {
      code,
      handler: "Handler",
      runtime: lambda.Runtime.PROVIDED,
    });

    new apigateway.LambdaRestApi(this, "Api", {
      handler,
    });
  }
}

Lambda Functionを作って、ランタイムをProvidedにして、API Gatewayにくっつけている。AWS CDKのAPI Referenceに、各パッケージの主要な使い方が載っているので、とっつきやすい。

import "source-map-support/register";
import * as cdk from "@aws-cdk/core";
import { ApiGatewaySwiftStack } from "../lib/api-gateway-swift-stack";

const app = new cdk.App();
new ApiGatewaySwiftStack(app, "ApiGatewaySwiftStack", {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
});

ここで作ったclass ApiGatewaySwiftStackを、appの側でこういう感じでnewしてやればOK。

// TODO: ここをなんとかする、と書いたところでさっきのlambda.zipを作ってやりたいのだけど、どうしたものかと思っていたら、オフィシャルの@aws-cdk/aws-lambda-nodejsパッケージで、Builder classを作ってDockerでなんかしているのを見つけたので、真似する。

import * as lambda from "@aws-cdk/aws-lambda";
import { spawnSync, SpawnSyncReturns } from "child_process";
import * as path from "path";

interface Options {
  dir: string;
  executable: string;
}

export class Builder {
  private static imageName: string = "swift-lambda-builder";

  constructor(private readonly options: Options) {}

  private docker(args: string[]): SpawnSyncReturns<string> {
    const returns = spawnSync("docker", args);
    if (returns.error) {
      throw returns.error;
    }
    if (returns.status !== 0) {
      throw new Error(
        `[Status ${
          returns.status
        }] stdout: ${returns.stdout?.toString().trim()}\n\n\nstderr: ${returns.stderr?.toString().trim()}`
      );
    }
    return returns;
  }

  public build(): lambda.AssetCode {
    this.docker(["build", "--tag", Builder.imageName, path.join(__dirname, "../builder")]);
    this.docker(["run", "--rm", "--volume", `${this.options.dir}:/src`, Builder.imageName, this.options.executable]);
    return lambda.Code.fromAsset(
      path.join(this.options.dir, "./.build/lambda/", this.options.executable, "lambda.zip")
    );
  }
}

こういうDocker CLIを呼び出すものを作って、さっきのところに埋める。

import * as cdk from "@aws-cdk/core";
import * as lambda from "@aws-cdk/aws-lambda";
import * as apigateway from "@aws-cdk/aws-apigateway";
import * as path from "path";
import { Builder } from "./builder";

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

    const code = new Builder({
      dir: path.join(__dirname, "../handler"),
      executable: "Handler",
    }).build();

    const handler = new lambda.Function(this, "Handler", {
      code,
      handler: "Handler",
      runtime: lambda.Runtime.PROVIDED,
    });

    new apigateway.LambdaRestApi(this, "Api", {
      handler,
    });
  }
}

あとはこれを使ってデプロイする。事前にAWS CLIの設定をしておくとよい。

$ npm run cdk deploy

AWSアカウントでまだCDKを使ったことがなければ、先に$ npm run cdk bootstrapが必要かもしれない。

うまくいくと、API GatewayのURLが出力されるだろう。AWS ConsoleのCloudFormationを見ると、作成されたスタックが表示される。

f:id:cockscomb:20200615001508p:plain
AWS CloudFormation

適当にcurlしてみると、実際に動いている様子がわかる。

$ curl --include https://xxx.execute-api.ap-northeast-1.amazonaws.com/prod/
HTTP/2 200 
content-type: application/json
content-length: 16
date: Sun, 14 Jun 2020 13:25:02 GMT
x-amzn-requestid: xxx
x-amz-apigw-id: xxx
x-amzn-trace-id: xxx
x-cache: Miss from cloudfront
via: 1.1 xxx.cloudfront.net (CloudFront)
x-amz-cf-pop: xxx
x-amz-cf-id: xxx

{"message":"OK"}

まとめ

上記の素朴なLambda Functionでは、コールドスタートとみられる場合で300から400 msの時間がかかった。一方でスタンバイからの実行では、2 ms程度だった。コールドスタートは、Lambda内部の初期化で150 ms、Lambda Functionの初期化が180 msというところ。それほど悪くはないと思う。メモリの消費は、メモリ容量を128 MBに設定したうちの51 MB。

コスト的には、Lambdaだけなら100万リクエストで1ドルかからないくらい、API Gatewayと合わせても5ドルくらいか。

ということで、SwiftでLambda Functionを作って、API Gateway経由で呼び出せるようにした。デプロイにはAWS CDKを使っている。完全なサンプルコードは以下。

実際にAWS LambdaをSwiftで開発するのかどうかというと、現時点では微妙なところである。しかしServer-side Swiftのエコシステムが、少しずつでも整っていくのは興味深い。

SwiftUIではSingletonの代わりにEnvironmentを使うことができる

ここで言うSingletonというのは、ある種のグローバル変数を指している。そもそもクライアントアプリケーションの開発においては、実質的なグローバル変数が出現しやすい。環境にたった一つしか存在しない、存在すべきでない、というものが見出せる。例えばタイムゾーンがそれである。アプリケーション内のタイムゾーンはシステムに合わせるのが自然であり、アプリケーション中で複数存在することはほとんど起こり得ない。

iOSmacOSでは、タイムゾーンを表すのはFoundation frameworkのTimeZoneである。システムのタイムゾーンTimeZone.currentで取得できる。これはSingletonパターンのインターフェースに近い(本当にインスタンスが一つであるかどうかをここでは問題にしない)。

このように環境中で存在する個数が限定されるような値を表すのに、Singletonパターンが用いられることがある。しかしSingletonパターンを用いると、単体テストを書くのに不便だったり、あるいは多用することでコードベースのメンテナンス性が低下するなどとして、嫌われることも多い。このような問題は、DIのようなアプローチを用いることで軽減できるだろう。

SwiftUIのEnvironment

SwiftUIでは、タイムゾーンの取得に@Environmentを使う。以下のようにしてTimeZoneインスタンスを得られる。

struct TimeZoneView: View {
    @Environment(\.timeZone) var timeZone

    var body: some View {
        Text("\(timeZone)")
    }
}

@Environment property wrapperを使い、EnvironmentValuesのpropertyをKeyPathとして(\.timeZoneのように)与えると、環境の値を読み取ることができる。

Environmentの上書き

環境の値は上書きすることもできる。以下のように、何らかのviewに対してenvironment(_:_:) modifierを呼び出すと、そのviewの子孫の環境を上書きできる。

SomeView()
    .environment(\.timeZone, TimeZone(abbreviation: "GMT")!)

全てを上書きできるわけではなく、EnvironmentValuesのpropertyにsetが設定されているものだけを上書きできる。

タイムゾーンを上書きするのはあまり有意義とは言えないが、例えばEnvironmentValues.isEnabledを上書きするのは理解しやすいユースケースである。特定のview以下を全てdisabledにするのは、リーズナブルだ。ということで、SwiftUIのviewにはdisabled(_:) modifierがあり、実質的に.environment(\.isEnabled, false)のショートハンドであろう。

新しいEnvironmentを作る

EnvironmentValuesを拡張してpropertyを追加することで、独自の値をEnvironmentに持たせることができる。extensionしてもstored propertyは追加できないが、幸いなことにEnvironmentValuesEnvironmentValues.subscript(_:)によってストレージを提供してくれる。

例えばmacOSで、viewが所属するNSWindowを環境から取得できるようにしてみる。(Singletonの例にはならないが……。)

struct WindowKey: EnvironmentKey {
    static var defaultValue: NSWindow? {
        return NSApp.mainWindow
    }
}

extension EnvironmentValues {
    var window: NSWindow? {
        get {
            return self[WindowKey.self]
        }
        set {
            self[WindowKey.self] = newValue
        }
    }
}

このようにEnvironmentKeyに準拠した型を作って、EnvironmentValuesを拡張する。そして以下のように、NSHostingViewを初期化するときにNSWindowへの参照を与える。

let window = NSWindow(...)
let contentView = ContentView()
    .environment(\.window, window)
window.contentView = NSHostingView(rootView: contentView)

あとはいつでも@Environment(\.window) var windowとして、NSWindowにアクセスできる。

EnvironmentとDIコンテナ

こうしてみると、EnvironmentはViewをターゲットとしたDIコンテナのような役割を果たしている。これはObservableObjectを対象としたEnvironmentObjectでも同じだが、Environmentの場合はObservableObject以外もその対象になる。

またEnvironmentは、ViewModifierからも参照できる。EnvironmentalModifierがそれだ。EnvironmentalModifierは、それ自体がViewModifierでありながら、resolve(in:)メソッドを実装することで、EnvironmentValuesの値を元にして別のViewModifierを解決することができる。つまりEnvironmentはViewModifierに対しても依存を提供できる。

DIコンテナとしては万能ではないが、これまでSingletonパターンを使っていたようなものの置き換えとしては十分だろう。

EnvironmentとContext

Reactに親しんでいれば、EnvironmentはReactのContextとほとんど同じということに気づくだろう。つまり、Reactでは内部的にContextを使って実現されるようなライブラリと似たようなことが、Environmentでも実現できそうだ。

まとめ

SwiftUIのEnvironmentを説明して、独自に拡張する方法を紹介した。そしてDIコンテナとの類似性から、Singletonパターンの置き換えとしての可能性を示した。

SwiftUIは昨秋にリリースされたばかりで、不完全な印象を抱きがちではあるが、Environmentのような仕組みがしっかりと整備されており、高いポテンシャルを持っているように思う。

参考

SwiftUI 徹底入門

SwiftUI 徹底入門