cockscomblog?

cockscomb on hatena blog

SwiftUIでSFSafariViewControllerを使う手法の探求

SwiftUIからSFSafariViewControllerを使いたい場面は多い。

SafariView

SFSafariViewControllerはビューコントローラーだから、UIViewControllerRepresentableを使ってSwiftUIのビューにしてしまうのが簡単か。

import SwiftUI
import SafariServices

struct SafariView: UIViewControllerRepresentable {
    typealias UIViewControllerType = SFSafariViewController

    typealias Configuration = SFSafariViewController.Configuration

    private let url: URL
    private let configuration: Configuration?

    init(url: URL, configuration: Configuration? = nil) {
        self.url = url
        self.configuration = configuration
    }

    func makeUIViewController(context: Context) -> SFSafariViewController {
        let safariViewController: SFSafariViewController
        if let configuration {
            safariViewController = SFSafariViewController(url: url, configuration: configuration)
        } else {
            safariViewController = SFSafariViewController(url: url)
        }
        return safariViewController
    }

    func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {
    }
}

誰でもこういうコードを書いたことがあるんじゃないか。

あとは何らかのきっかけ‌fullScreenCover(isPresented:onDismiss:content:))なんかを使って、このSafariViewを表示してやればいい。 UIViewControllerpresent(_:animated:completion:)とは少し違うけど、他にいい方法を知らない。

じゃあ「何らかのきっかけ」ってなんだろう、というところが本題。

OpenURLAction

SwiftUIでは、TextのイニシャライザにAttributedStringを渡せば、文字列中にリンクを埋め込める。 あるいはそのものずばりLinkビューというのがあって、URLへのリンクを表現できる。 こういうのをクリックしたときもSFSafariViewControllerを表示したい。

実はLinkドキュメンテーションにいい例があった。

Link("Visit Our Site", destination: URL(string: "https://www.example.com")!)
    .environment(\.openURL, OpenURLAction { url in
        print("Open \(url)")
        return .handled
    })

LinkText内のリンクをクリックすると、EnvironmentからOpenURLActionが呼び出される。 そしてこれは上書き可能になっている。 OpenURLActionを上書きして、SFSafariViewControllerを開くようにフックしてやればよいらしい。

渡ってくるURLを状態として持って、URLが存在するときSafariViewを開く、という風にしたい。 何とか再利用性のあるコードにしたいので、ちょっと考える。

ViewModifier

ここで、ViewModifierを使えばモディファイア内に状態を持てることを思い出す。 ちょっと書いてみるとこういう感じになる。

import SwiftUI

struct OpenURLInSafariViewModifier: ViewModifier {
    @State private var url: URL? = nil
    private var isPresented: Binding<Bool> {
        Binding {
            url != nil
        } set: { newValue in
            if newValue == false {
                url = nil
            }
        }
    }

    private let configuration: SafariView.Configuration?

    init(configuration: SafariView.Configuration?) {
        self.configuration = configuration
    }

    func body(content: Content) -> some View {
        content
            .environment(\.openURL, OpenURLAction { url in
                switch url.scheme {
                case "https"?, "http"?:
                    self.url = url
                    return .handled
                default:
                    return .systemAction(url)
                }
            })
            .fullScreenCover(isPresented: isPresented) {
                if let url {
                    SafariView(url: url, configuration: configuration)
                        .edgesIgnoringSafeArea(.all)
                }
            }

    }
}

extension View {
   func openURLInSafariView(configuration: SafariView.Configuration? = nil) -> some View {
        return modifier(OpenURLInSafariViewModifier(configuration: configuration))
    }
}

いっけんよさそう。

SafariServicesUI

こういう感じで使う。

import SwiftUI

struct ContentView: View {
    var body: some View {
        Link("Open in SFSafariViewController", destination: URL(string: "https://developer.apple.com")!)
            .openURLInSafariView()
    }
}
import SwiftUI

struct ContentView: View {
    var body: some View {
        Text("Open in SFSafariViewController with [Attributed String](https://developer.apple.com)")
            .openURLInSafariView()
    }
}

状態が隠蔽されていて、シンプルだし、SwiftUIっぽいインターフェースだと思う。

ということでライブラリっぽくSwift Packageにしてみた。

どうでしょうか。

東京に引っ越しました

はてなインターンシップに参加した2012年の夏から、10年弱、京都で暮らした。その間に結婚もしたし、二人の子供にも恵まれた。京都では2Kの賃貸住宅に住んでいて、ひどく手狭だった。上の子は来年度から小学生になる。その前に引っ越しておきたい。

勤務先の株式会社はてなは、フレキシブルワークスタイル制度というので、だいたい全国どこに住んでもよくなった。オフィスに出社することも稀なので、通勤時間を気にする必要もほとんどない。ということで、京都にこだわらず、どこにでも引っ越せる。

いろいろ合理的に考えると、東京近郊がいいということになった。そもそも僕は北海道出身、妻は静岡出身であるから、帰省などに都合がいいのは関東だ。家賃が高くなるのを心配したけど、地域によっては京都の街中より安いくらいだった。

ということで、先月末に引っ越してきた。

部屋が広くなったおかげで、以前は部屋の狭さから我慢していたいろいろなことが解決した。小さすぎる冷蔵庫もいくらか大きなものにできた。特に大きめのダイニングテーブルを買ったことで、家族で落ち着いてご飯を食べたり、子供の勉強をみたりしやすくなって、嬉しい。

東京はなんでもあるようだけど、京都のコンパクトな街が恋しいとも思う。思っていたより、住む街が自分のアイデンティティの一部になっていたことに気づいた。しばらく住んでいたら気にならなくなるのだろうけど、こういう気持ちになったことは覚えていたい。

ということで、住む場所は変わりましたが、引き続きよろしくお願いします。 フレキシブルワークスタイル制度の株式会社はてなもよろしくお願いします。

WWDC22への期待が高まってきたのでSwift Evolutionをナナメ読みする

WWDC22で何が発表されるのか、期待で眠りが浅い日々を過ごしている。何かヒントはないかとSwiftの次期バージョンについて調べていた。

普段から、定期的に同僚のid:ikesyoid:yutailang0119id:nakiwoid:kouki_danらとSwift Evolutionの様子をチェックしていて、おもしろいプロポーザルにはだいたい目を通している(教えてもらっている)のだけど、あらためてみてみると見えてくるものもある。

Swift 5.7になる

現在のSwiftのバージョンは5.6で、次のバージョンはSwift 5.7となる。

Swift 6は破壊的変更のためにとってあるバージョンで、そういう意味ではPython 3のようなフィールもあって不気味だが、期待も高い。

Swiftは(Appleの都合もあってか)半年ごとにマイルストーンを刻んでいる。直近のバージョンで言えば、Swift 5.5でasync/awaitやActorが入って、非同期処理に対する言語的なサポートが充実した。Swift 5.6はそれと較べると小規模な変更で、「チックタック」の「タック」という趣があった。

ではSwift 5.7はどうなるのかというと、広範なアップデートがありそうに見える。

apple/swift-evolution

Swiftの言語に対する変更は、SwiftがOSSであり、Swift Evolutionによる民主的な意思決定プロセスを採用していることから、予め窺い知れることが多い。ということでSwift Evolutionの主なプロポーザルを見ていく。

ここで取り上げるプロポーザルは、すでにSwift 5.7で実装されることが明らかなものもあれば、まだ議論の途中のものもある。また受理されたプロポーザルであっても、実装がSwift 5.7に間に合わないこともある。それでもSwiftの方向性を知るのに参考になるだろうから、混ぜこぜにしている。

構文

SE-0345 if let shorthand for shadowing an existing optional variable

if letでoptional unwrappingするときにif let a = a {}if let a {}と書けるようにして、冗長さをなくす。

SE-0359 Build-Time Constant Values

コンパイル時に決まる(動的ではない)値の概念を導入する。そのような値は、文字列や数値、真偽値、associated valueのないenum、それらのArray、Dictionary、タプルに限られる。@const属性を導入し、プロパティや関数の引数に付ける。そのようなプロパティはコンパイル時に決まる値で初期化されていなければならないし、関数の引数もコンパイル時に決まる値でないといけない。これらの制約(コンパイラから見れば保証)を導入することで、コンパイル時にできる処理が増える。

標準ライブラリ

SE-0329 Clock, Instant, and Duration

(Foundationではなく)標準ライブラリに時刻に関する型を加える。非同期処理のようなタスクのスケジューリングに使う。

SE-0358 Primary Associated Types in the Standard Library

「SE-0346 Lightweight same-type requirements for primary associated types」で導入されたprimary associated typesを標準ライブラリでも設定する。

正規表現

SE-0348 buildPartialBlock for result builders

(「SE-0351 Regex builder DSL」を見越して)result buildersにフックとなるメソッドを追加して、挙動をカスタマイズしやすくする。このフックによってオーバーロードを減らせる。

SE-0350 Regex Type and Overview

正規表現型の導入。キャプチャされた値の型を型パラメータで表現するところがSwiftらしい。

SE-0351 Regex builder DSL

Result buildersでDSLを作り、正規表現を組み立てられるようにする。おもしろい。単におもしろいだけでなく、キャプチャした値を何らかの型に変換する処理も組み込める。

SE-0354 Regex Literals

正規表現リテラルを言語に組み込む。リテラルコンパイル時に評価され、正規表現として妥当であることが保証され、キャプチャの型も決まる。DSLにも組み込める。Swift 6以降は/のみで使えるが、Swift 5.7の時点では演算子の互換性の都合から#/を使う。

SE-0355 Regex Syntax and Run-time Construction

実行時に文字列から正規表現を作る。

SE-0357 Regex-powered string processing algorithms

StringCollection正規表現を活用するメソッドを追加。パターンマッチの追加。

非同期処理関連

SE-0302 Sendable and @Sendable closures

Sendableの概念自体はSwift 5.5からあるが、Sendableのチェックに関する警告は有効になっていなかった。Swift 5.6で有効にされそうだったが、あまりにも警告が多くなりすぎることから、結局無効にされた( https://github.com/apple/swift/pull/41368 )。Swift 5.7でついに有効になった。ついでに-warn-concurrencyオプションが-strict-concurrency=(minimal|targeted|complete)になっている( https://github.com/apple/swift/pull/42523 )。

SE-0338 Clarify the Execution of Non-Actor-Isolated Async Functions

Actorのasync関数から、他のactorに紐付かないasync関数を呼び出すとき、これまではactor上で実行されてしまっていた。これをやめて、actorに紐付かないasync関数は常にグローバルに共有されたプールで実行されることになる。これに伴ってSendableのチェックも行われる。

SE-0340 Unavailable From Async Attribute

Async関数はawaitの前後で実行されるスレッドが同一とは限らない。このような環境で呼び出されることを意図してない(単一のスレッドから呼び出されることを意図している)関数について、async関数からの呼び出しを防ぐように宣言できる。

SE-0343 Concurrency in Top-level Code

トップレベルでawaitできる。このときグローバル変数は暗黙的に@MainActorが関連付けられる。

分散actor

Actor間のやりとりを拡張していくと、(やりとりするメッセージがシリアライズできるなら)個々のactorが同一のプロセス、あるいは物理マシンに存在しなくてもいいんじゃないか、となっていく。しかしこれらが実際にどういうものになるのか、門外漢すぎてわかっていない。

SE-0336 Distributed Actor Isolation

SE-0344 Distributed Actor Runtime

型システム関連

SE-0309 Unlock existentials for all protocols

Associated typeやSelfを持つprotocolでもany Pとしてexistential typeのように扱える。これまではジェネリクスの制約として使うしかなかった。anyという表現はSwift 5.6の「SE-0335 Introduce existential any」ですでに実装されている。

SE-0326 Enable multi-statement closure parameter/result type inference

式が2つ以上のクロージャで、引数や返り値の型推論を有効にする。これまでも式が1つなら型推論されていたが、型チェッカーのパフォーマンスの問題で式が2つ以上のときは型を明示する必要があった。実装の改善でパフォーマンスの問題を解消できたため、2つ以上でも型推論を有効にさせられる。

SE-0328 Structural opaque result types

Opaque result typeは、protocolを返り値の型としてsome Pのように書けるもので、Swift 5.1から導入されている。これを拡張して、(some P)?(some A, some B)() -> some Pのようなパターンも書けるようにする。

SE-0341 Opaque Parameter Declarations

Opaque typeのsome Pを引数の宣言でも使えるようにする。これまでは型パラメータを制約する形で表現していた。

SE-0346 Lightweight same-type requirements for primary associated types

Protocolが1つ以上のassociated typeを持つとき、その一部をprimary associated typeであると宣言できる。Primary associated typeはProtocol<AssociatedType>というジェネリクスと同様の構文で実際の型を指定できる。これによって表記が簡単になる。

SE-0347 Type inference from default expressions

関数の引数の型にジェネリクスで型パラメータを使っていても、型推論によってデフォルト引数を持てるようにする。

SE-0352 Implicitly Opened Existentials

あるprotocol Pについて、any Psome Pとして渡せるようにする。

SE-0353 Constrained Existential Types

「SE-0346 Lightweight same-type requirements for primary associated types」でprimary associated typesが設定されたprotocolがexistential typeとして使える。

SE-0360 Opaque result types with limited availability

Opaque result typeを使っていて関数の返り値がsome Pのとき、その関数が実際に返す具体的な型Tは常に一致している必要がある。例えば関数の中で条件分岐して、ある場合はA、ある場合はBを返したりはできない。これを緩和して、#available()による条件分岐に限って型が違ってもいい、とする。

Swift Package Manager

SE-0339 Module Aliasing For Disambiguation

利用するモジュールに別名を与えて紛らわしくなくできる。

SE-0356 Swift Snippets

Swift Packageにスニペットを追加できる。

Swift 5.7の傾向

こうしてプロポーザルを概観すると、大まかな傾向が掴めるはずだ。

非同期処理関連の改善がいくつか見られる。データ競合を引き起こさない安全な平行処理のために、そしてSwift 6で完全に近い保護を達成するために準備が進められている。

わかりやすいのは正規表現に関するプロポーザルが多いことで、一連のプロポーザルによって、正規表現はSwiftの一級市民になる。Swiftでは何年も前にString Manifestoというのが議論されていて、このときに(正規表現ではないが)文字列のパターンマッチングが俎上に載せられていた。今回の正規表現について、これの一環とも思える。

もうひとつ、型システムについて大幅な改善がある。Opaque result typeのsomeやexistential typeのany、あるいはprimary associated typeによって、protocolの使い勝手が(あるいは書き味が)かなりよくなる。これらの一部はGenerics Manifestoで議論されていたが、ついに大きく手が入った格好である。

Swiftの進化による期待

ここで手が入った機能の多くは、例えばSwiftUIに大きな影響を与えそうだ。SwiftUIはSwiftの言語機能を活かしているし、特に型システム周りでは恩恵が大きそうに思う。

勝手な期待では、SwiftUIのナビゲーションをもっと抽象的に、あるいは宣言的に扱いたい。そういう機能が追加されるとして、アプリ内の任意の画面にディープリンクを設定するなら、正規表現の言語的サポートも大いに役立ちそうだ。

加えて、Swiftの言語的な進化による、Swiftのためのライブラリやフレームワークの進歩も期待している。Apple製のフレームワークのうち新しいものには、Swiftからのみ利用できるものが出てきている。それらはSwiftの言語機能を活かしていて、堅牢で使いやすい。今後もそのような、例えばasync/awaitやactorをうまく活用したフレームワークが増えていくだろうことに非常に期待している。例えばデータの永続化について、Swiftyなものが出てきてほしい。

ということで、今年もWWDCを楽しみにしています。

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などを使うことでデプロイを速く楽にできる。

みんなもやってみてね。

ScrapboxでMermaidを使う

筆者の勤務先ではScrapboxというWiki的なツールが導入されていて、何でもそこに書いている。

そして筆者は以前からPlantUMLなどで作図するのが気に入っているが、最近は同様の目的を持ったMermaidがよく使われている様子がある。2021年12月にはNotionが、そして2022年2月にGitHubがそれぞれMermaidの機能を発表している。

いずれもドキュメント中にコードブロックを埋め込み、シンタックスとして「Mermaid」を設定すると、表示時には図に置き換わる。

特にソフトウェア開発に関連したツールでは、こういった機能は便利だ。ちょっとした図を載せたいときに、別なツールで作図して画像として貼り付けるのは手間だから、ひとつのツール内で閉じていると嬉しい。PlantUMLにしろMermaidにしろ、元がテキストなのもいい。

ある日仕事をしていて、どうしてもScrapbox上のドキュメントに図を入れたくなった。ScrapboxにMermaidが統合されていれば……。試しに別なツールで作図してSVGで出力して貼り付けて、とやったら1度で我慢の限界を迎えた。

ScrapboxのUserScriptを書く

ということでなんとかしていく。ScrapboxはUserScriptの機能を持っているので、危険を承知でこれを使う。

ということで書いてみたものがこちら。

UserScriptを有効にした上で、プロジェクト内の自分のページにscript.jsコードブロックを置いて読み込む。

code:script.js
    import '/api/code/cockscomb/Mermaid/script.js'
code:example.mmd
    graph LR
        Mermaid--makes y'all-->Happy

ブラウザをリロードしてから、適当なページに拡張子「mmd」のコードブロックを書くと、そのコードブロックの上に図が表示される。

f:id:cockscomb:20220221095309p:plain
Mermaidによって描画された図がコードブロック上に表示される

拡張子が「mmd」なのはmermaid-cliの例に倣った。

コードブロックを編集すれば図も更新される。複数人で同時に編集すると、図がリアルタイムにどんどん変化していって、おもしろい。

ScrapboxのUserScriptについて

現代のWebでUserScriptを書くことはなくなっていたので、Greasemonkeyを思い出したりして懐かしい。機能としてUserScript機能を持たせるのは胆力がある。利用者側としては便利だ。

ScrapboxのUserScriptを書くにあたって以下のようなテクニックを使った。

ライブラリの読み込み

Mermaidを読み込むのには、script要素を動的に追加した。

async function loadScript(src) {
   const script = document.createElement('script');
   script.src = src;
   const promise = new Promise(resolve => {
     script.addEventListener('load', resolve);
   });
   document.body.appendChild(script);
   return promise;
}

ScrapboxはContent-Security-Policyが設定されているので、許可されているcdnjs (cdnjs.cloudflare.com) を使った。

https://cdnjs.cloudflare.com/ajax/libs/mermaid/8.14.0/mermaid.min.js

ScrapboxAPI

UserScriptからグローバル変数scrapboxを介してページの情報を得られる。scrapbox.Page.linesで行の情報が得られるのでこれを使っている。

行が変更されたり、ページを移動したとき、イベントによってそれを検知できる。これでMermaidの図を再描画させたり、ページが変わったら後片付けしたりしている。

コードブロックの内容もAPIから取得できる。

DOM操作

図を表示させるのにDOM操作するしかなく躊躇した。DOM操作するのは乱暴だと思う。しかしScrapboxの開発者もUserScriptを提供している時点でDOM操作されるだろうと想像しているはずなので、諦める。

どうですか

ScrapboxでUserScriptを書いたことがなかったが、同僚がなんかやっているのを覗き見たりしてキャッチアップできた。ありがたい。そして社内で宣伝したら乗っかってくれる人が多くて、みんなで適当な図を共同編集できておもしろかった。PlantUMLもMermaidも行志向だから、Scrapboxと相性がいいと思う。

気をよくして、Scrapboxリニューアルされたはてなスターを設置しようとしたが、Content-Security-Policyで制約されており、iframeが読み込まれず挫折した。

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を設定することで回避できる。

いいマイクアームを買った

f:id:cockscomb:20211221102110p:plain

春にShure MV7というマイクを買って、快適に過ごしていた。

快適に過ごしていたけど、このとき適当なマイクアームを買っていたことで、気になるところがあった。

口から近い位置に設置できた方がいいので、適当な安いアームも買った。

マイクアームとしての用は足りているが、最高ではなかった。安いので、見た目が少し野暮ったい。特にバネが露出していることで、2歳の娘の格好の餌食となり、ビヨンビヨンやられる。ビヨンビヨンされるとアームを介してマイクが音を拾ってしまう。

もう少しマシなマイクアームを買ってもいいかと思ったが、普通に売っているマイクアームでは解決しない本質的な課題に気づきつつあった。

人体の構造上、口は目より下側にある。マイクは口の近くに配置したい。そして通常、マイクアームは上側からマイクをぶら下げる形になっている。

f:id:cockscomb:20211220233751j:plain

模式的に表すとこうです。わかりますか

おわかりだろうか。マイクアームは人間の視界を塞ぐのである。目とディスプレイの間にマイクアームが置かれることになってしまう。これはあまりスマートではないと思う。思いませんか?

Wave Mic Arm LP

ということで、ゲーム配信者向けのデバイスのブランド「Elgato」から、Wave Mic Arm LPが出ている。これだとマイクを上からぶら下げるのではなく、横から保持できる。

海外では以前から販売されていて目をつけていたのだが、ついに日本でも12月3日から発売になった。

Elgatoの機能性とデザイン性に優れた2種類のマイクアームとPS5などに対応のオーディオアダプタ「Chat Link Pro」の販売を開始

発売に数日気づかず、12月7日にAmazonで「一時的に在庫切れ; 入荷時期は未定です。」の表示になっていたが、注文できた。しばらく届かないかと思ったが、12月15日には届いた。

ということで設置しました。剛性も高く、ビヨンビヨンしないし、質感も高級感がある。ケーブルも収納できて、何もいうことがない。

Shure MV7にElgato Wave Mic Arm LP、これが新たなスタンダードだと思う。