CloudFront FunctionsをテストするOSS、cfftを公開しました

SREチームの藤原です。

今回は Amazon CloudFront Functions をテストするためのOSSとして、cfft というものを書いたので紹介します。

github.com

3行でまとめ

  • CloudFront Functionsのテストは手元ではできなくて面倒です
  • CloudFront Functionsをテストする cfft というOSSを書きました
  • KeyValueStoreの操作を含め、便利な使い方がいろいろありますのでどうぞご利用下さい

CloudFront Functionsをテストするのが面倒という問題

CloudFront Functions (以下CFF) は、AWSが提供するCDNであるAmazon CloudFrontのエッジノード上でリクエストやレスポンスの操作が行える、JavaScriptの実行環境です。典型的なユースケースとしては、キャッシュキーの正規化、ヘッダの操作やリダイレクトなどが挙げられます。

自分で任意のコードが書ける、ということは、当然テストを書きたくなります(よね?)

しかしCFFのランタイムはNode.JSではなく、ECMAScript(ES) 5.1に準拠し、ES 6以降の一部の機能が取り込まれた独自の軽量JavaScriptとなっていて、ランタイムそのものは公開されていません。そのため手元や一般的なCI環境ではCFFのコードを実行できません。当然テストもできません。1

CFFを実際に実行してテストするためには、CloudFrontのマネージメントコンソール上で実行するか、CloudFrontのAPIを呼び出す(aws cliを実行する)必要があります。CLIでもやれないことはないのですが、これを手書きするのはなかなか面倒なのでした。

  1. aws cloudfront update-function でコードを更新して、レスポンスのETag(関数のバージョン)を覚えておく
  2. aws cloudfront test-function に関数名、ETag、イベントオブジェクト(JSON)を与えて実行する
  3. 結果(関数がreturnしたobjectのJSON)を検証する
    • test-function ではコードの実行がエラーなく成功したことしか確認できないため、関数の出力が期待した内容かどうかは自分で検証する必要があります

KeyValueStoreの取り扱いが面倒という問題

2023年11月には、CloudFront用のKeyValueStore(以下KVS)が使えるようになりました。

これはCloudFront上に任意のKey-Valueを登録しておいて、CFFからkeyを指定してvalueを読み取れるというものです。KVSとはいっても関数内からは読み取り専用なので、どちらかというと環境変数を読むような機能に近いのですが……ともあれ、これがあると設定値や秘匿値をコード内にハードコードしなくて済むので、魅力的な機能です。

しかしこれも使うにはちょっと面倒なことがあります。

以下のコードはCFFからKVSを扱うものですが、kvsId = '<KEY_VALUE_STORE_ID>' の部分は、実際には kvsId = 'f0adde97-ab07-41f7-948c-aa9d39fc10aa' のように、KVSのIDをハードコードする必要があります。

import cf from 'cloudfront';
const kvsId = '<KEY_VALUE_STORE_ID>';
const kvsHandle = cf.kvs(kvsId);

async function handler(event) {
    const key = event.request.uri.split('/')[1]
    let value = "Not found" // Default value
    try {
        value = await kvsHandle.get(key);
    } catch (err) {
        console.log(`Kvs key lookup failed for ${key}: ${err}`);
    }
    var response = {
        statusCode: 200,
        statusDescription: 'OK',
        body: {
            encoding: 'text',
            data: `Key: ${key} Value: ${value}\n`
        }
    };
    return response;
}

せっかく設定値をハードコードしなくて済むのに、KVS IDだけはハードコードが必要なのですね。

また、KVSの値の操作をaws cliで行う場合にも、都度KVSのARNを指定する必要があります。この--kvs-arnはIDの値だけでは受け付けてもらえないため、長いARNを指定する必要があります。面倒ですね。

$ aws cloudfront-keyvaluestore get-key \
  --key foo \
  --kvs-arn arn:aws:cloudfront::123456789012:key-value-store/f0adde97-ab07-41f7-948c-aa9d39fc10aa

実際使うにあたりこのように面倒なことがあったので、いつものようにOSSでツールを書いて解決しよう、と作成したのが cfft というツールです。

cfft

cfft はGoで実装されたCLIコマンドで、シングルバイナリで動作します。現在、以下の機能を持っています。

  • cfft init: 既存のCFFを指定して、cfft 用の設定ファイルとコード、テストのための入力イベントを生成する
    • 関数が存在しない場合は新規にファイルを一式生成します
  • cfft test: CFFをファイルの内容で更新し、設定ファイルにしたがって入力イベントを元にテストを実行し、出力が期待したものかどうか検証する
  • cfft diff: ローカルのコードとCFF上のコードの差分を表示する
  • cfft render: ローカルのコードをレンダリングした結果を表示する
    • 設定ファイルとローカルのファイルはテンプレートとして処理され、環境変数の展開などが可能です
  • cfft publish: DEVELOPMENTステージでテストを実行した後、CFFをLIVEステージに公開する
  • cfft kvs: CloudFront KeyValueStoreの操作。list, get, put, delete, info コマンドがあります
  • cfft tf: Terraformとの連携機能

典型的な使い方を紹介します。

cfftによるCFFのテスト

新規にCFFのコードとcfftの設定ファイルを一式作成してみましょう。

$ cfft init --name example
2024-02-21T14:26:57+09:00 [info] function example not found. using default code for viewer-request
2024-02-21T14:26:57+09:00 [info] creating function file: function.js
2024-02-21T14:26:57+09:00 [info] creating config file: cfft.yaml
2024-02-21T14:26:57+09:00 [info] creating event file event.json
2024-02-21T14:26:57+09:00 [info] done

関数のコードとして funciton.js、設定ファイルとして cfft.yaml、テスト用のイベントとして event.json が生成されました。

新規に生成した関数は、単に console.log() を出力するだけのものになっています。 既にCloudFront上に存在するCFFの名前を指定した場合は、そのコードの内容がfunction.jsに保存されています。

// function.js
async function handler(event) {
  const request = event.request;
  console.log('on the edge');
  return request;
}
# cfft.yaml
name: example
comment: ""
function: function.js
runtime: cloudfront-js-2.0
testCases:
- name: default
  event: event.json
  expect: ""
  ignore: ""
  env: {}

作成されたテストイベントは実行フェーズが viewer-request (クライアントからのリクエストを受信した時点で実行される関数、主にヘッダの書き換えや認証に使う) で、リクエストはIPアドレス 1.2.3.4 から GET /index.html が送信された、というものになっています。詳しい仕様は CloudFront Functions のイベント構造 を参照して下さい。

{
    "version": "1.0",
    "context": {
        "eventType": "viewer-request"
    },
    "viewer": {
        "ip": "1.2.3.4"
    },
    "request": {
        "method": "GET",
        "uri": "/index.html",
        "headers": {},
        "cookies": {},
        "querystring": {}
    }
}

この関数を次のように、クライアントのIPアドレスを x-client-ip というヘッダにセットする(ログにも出す)ように書き換えてみましょう。

 async function handler(event) {
   const request = event.request;
-  console.log('on the edge');
+  const client_ip = event.viewer.ip ;
+  request.headers['x-client-ip'] = { value: client_ip };
+  console.log(`client ip is ${client_ip}`);
   return request;
 }

cfft test --create-if-missing で実行します。(--create-if-missing は、関数の新規作成時のみ必要です)

2024-02-21T14:39:04+09:00 [info] function example not found
2024-02-21T14:39:04+09:00 [info] creating function example...
2024-02-21T14:39:05+09:00 [info] function example created
2024-02-21T14:39:07+09:00 [info] [testcase:default] testing function
2024-02-21T14:39:07+09:00 [info] [testcase:default] ComputeUtilization: 27 optimal
2024-02-21T14:39:07+09:00 [info] [testcase:default] [from:example] client ip is 1.2.3.4
2024-02-21T14:39:07+09:00 [info] 1 testcases passed, 0 testcases failed

CFFにexampleという関数が作成され、入力されたeventでテストが実行されました。

  • ComputeUtilization は、CFFの実行時に消費したCPUコストです。ドキュメント
    • これが100を超えた状態が継続すると、CFFの実行がスロットリングされてしまいます
  • [from:example] client ip is 1.2.3.4 の部分は、CFFがconsole.logで出力したログです

この状態ではCFFの実行自体がエラーなく終了すればテスト成功になります。関数の出力結果の検証は行われていません。x-client-ip ヘッダが期待通り設定されているかを検証してみましょう。

expect.jsonというファイルを次の内容で作成します。この内容と、CFFで実行されるhandler関数が出力した値が比較されます。今回はリクエストヘッダに x-client-ip: 1.2.3.4 が追加されているのが期待される状態なので、そのように記述してあります。

{
    "request": {
        "method": "GET",
        "uri": "/index.html",
        "headers": {
            "x-client-ip": {
                "value": "1.2.3.4"
            }
        },
        "cookies": {},
        "querystring": {}
    }
}

設定ファイルに expect: expect.json を指定して、cfft testを実行します。

 testCases:
   - name: default
     event: event.json
-    expect: ""
+    expect: expect.json
     ignore: ""
     env: {}
$ cfft test
2024-02-21T14:43:58+09:00 [info] function example found
2024-02-21T14:43:59+09:00 [info] function is not changed
2024-02-21T14:43:59+09:00 [info] [testcase:default] testing function
2024-02-21T14:43:59+09:00 [info] [testcase:default] ComputeUtilization: 27 optimal
2024-02-21T14:43:59+09:00 [info] [testcase:default] [from:example] client ip is 1.2.3.4
2024-02-21T14:43:59+09:00 [info] [testcase:default] OK
2024-02-21T14:43:59+09:00 [info] 1 testcases passed, 0 testcases failed

1個のテストケースが問題なく成功したことが分かります。

更にリクエストヘッダ x-now に、現在時刻のUNIX timeを設定するように関数を書き換えてみましょう。こうするとexpect.jsonとは結果が異なる状態になるため、テストは失敗することが期待される状態です。

 async function handler(event) {
   const request = event.request;
   const client_ip = event.viewer.ip;
   request.headers['x-client-ip'] = { value: client_ip };
+  request.headers['x-now'] = { value: Date.now()+"" };
   console.log(`client ip is ${client_ip}`);
   return request;
 }
$ cfft test
2024-02-21T15:04:18+09:00 [info] [testcase:default] testing function
2024-02-21T15:04:19+09:00 [info] [testcase:default] ComputeUtilization: 28 optimal
2024-02-21T15:04:19+09:00 [info] [testcase:default] [from:example] client ip is 1.2.3.4
--- expect
+++ actual
@@ -3,6 +3,9 @@
     "headers": {
       "x-client-ip": {
         "value": "1.2.3.4"
+      },
+      "x-now": {
+        "value": "1708495458809"
       }
     },
     "method": "GET",

2024-02-21T15:04:19+09:00 [error] failed to run test case default, expect and actual are not equal
2024-02-21T15:04:19+09:00 [info] 0 testcases passed, 1 testcases failed
2024-02-21T15:04:19+09:00 [error] failed to run test case default, expect and actual are not equal

関数の出力と expect.json の内容が異なるため、(期待通りに)テストが失敗しました。

この例のように実行時に値が決まるような関数の場合、結果が完全に一致していることを期待するのは難しいことがあります。設定ファイルのテストケースで ignore を指定すると、結果から特定の値を除外して比較できます。ignoreに指定するのは jq のクエリです。

-    ignore: ""
+    ignore: '.request.headers["x-now"]'

これを指定してcfft testを実行すると、テストが成功します。(出力は割愛します)

CF KVSとの統合

cfftは、CloudFront KVSを使った関数を簡単に扱えます。

設定ファイルに kvs セクションを以下のように指定して cfft test --create-if-missing (KVSが存在しない場合、初回のみオプションが必要) を実行してみます。

kvs:
  name: hello
$ cfft test --create-if-missing
2024-02-21T15:23:28+09:00 [info] kvs hello not found, creating...
2024-02-21T15:23:37+09:00 [info] kvs hello is not ready yet. status: PROVISIONING
2024-02-21T15:23:46+09:00 [info] kvs hello created
2024-02-21T15:23:46+09:00 [info] function example found
2024-02-21T15:23:46+09:00 [info] kvsArn: arn:aws:cloudfront::123456789012:key-value-store/b2a91b52-468b-48cf-a1ad-0823d517a8b0
2024-02-21T15:23:46+09:00 [info] associating kvs hello to function example...
2024-02-21T15:23:47+09:00 [info] function is not changed
2024-02-21T15:23:47+09:00 [info] [testcase:default] testing function
(略)

hello という名前のCF KVSが作成され、CFFと関連付けられました。

cfft kvs info を実行すると、KVSの情報が出力されます。

{
  "Created": "2024-02-21T06:23:29.776Z",
  "ETag": "KVTVPDKIKX0DER",
  "ItemCount": 0,
  "KvsARN": "arn:aws:cloudfront::123456789012:key-value-store/b2a91b52-468b-48cf-a1ad-0823d517a8b0",
  "TotalSizeInBytes": 0,
  "LastModified": "2024-02-21T06:23:29.776Z",
  "ResultMetadata": {}
}

それでは function.js に、KVSを扱うコードを追加してみましょう。ここで cfft が便利なのは、{{ must_env "KVS_ID" }} という記法で関連付いたKVSのIDを参照できることです。

+import cf from 'cloudfront';
+const kvsId = '{{ must_env "KVS_ID" }}';
+const kvsHandle = cf.kvs(kvsId);
+
 async function handler(event) {
   const request = event.request;
   const client_ip = event.viewer.ip;
   request.headers['x-client-ip'] = { value: client_ip };
   request.headers['x-now'] = { value: Date.now() + "" };
+  try {
+    const world = await kvsHandle.get('world')
+    request.headers['x-world'] = { value: world };
+  } catch (e) {
+    console.log(e);
+  }
   console.log(`client ip is ${client_ip}`);
   return request;
 }

ここではx-worldというヘッダに、KVSからworldというkeyで引いた値を設定するコードを書いてみました。

cfft kvs putでkey-valueを設定します。設定した値はgetで確認できます。

$ cfft kvs put world 'こんにちは'
$ cfft kvs get world
{"key":"world","value":"こんにちは"}

KVSに値を設定した後に cfft test を実行すると… (設定後、数秒は新しい値が参照できない/古い値が見えることがあります)

$ cfft test
2024-02-21T15:36:05+09:00 [info] function example found
2024-02-21T15:36:05+09:00 [info] kvsArn: arn:aws:cloudfront::123456789012:key-value-store/b2a91b52-468b-48cf-a1ad-0823d517a8b0
2024-02-21T15:36:05+09:00 [info] associated kvs: arn:aws:cloudfront::123456789012:key-value-store/b2a91b52-468b-48cf-a1ad-0823d517a8b0
2024-02-21T15:36:05+09:00 [info] function is changed, updating...
2024-02-21T15:36:07+09:00 [info] [testcase:default] testing function
2024-02-21T15:36:08+09:00 [info] [testcase:default] ComputeUtilization: 0 optimal
2024-02-21T15:36:08+09:00 [info] [testcase:default] [from:example] client ip is 1.2.3.4
--- expect
+++ actual
@@ -4,7 +4,10 @@
       "x-client-ip": {
         "value": "1.2.3.4"
       },
-      "x-now": null
+      "x-now": null,
+      "x-world": {
+        "value": "こんにちは"
+      }
     },
     "method": "GET",
     "uri": "/index.html"

2024-02-21T15:36:08+09:00 [error] failed to run test case default, expect and actual are not equal
2024-02-21T15:36:08+09:00 [info] 0 testcases passed, 1 testcases failed
2024-02-21T15:36:08+09:00 [error] failed to run test case default, expect and actual are not equal

x-world ヘッダにKVSから引いた値が設定された結果 expect.json と異なる出力になったため、テストが失敗していることが分かります。

Terraformとの連携とLIVEステージへのデプロイ

CFFには DEVELOPMENT / LIVE というステージの概念があり、一旦DEVELOPMENTステージで関数を更新した上でテストを行い、問題がなければLIVEステージに公開(publish)するという運用が想定されています。実際にCloudFront distributionで実行されるのは、LIVEステージにpublishされたコードです。

カヤックでは基本的にTerraformでAWSのリソースを管理しています。TerraformでCloudFront distributionを管理している場合、CFFをどうやってTerraformでデプロイするか(publishするか)という問題が出てきます。

cfft publishコマンドによってcfft単独でLIVEステージへのpublishも可能です。しかしその場合、デプロイ操作が2段階(cfft publishterraform apply)になるのが煩わしく、手動で実行する場合には更新漏れが起きる可能性があります。

できればLIVEステージへの公開はTerraformに任せてしまいたい、ということで、cfftは Terraform との連携方法を2種類用意しました。詳しくは Cooperate with Terraform を参照して下さい。

1. tf.json を生成する方法

cfft tf --publish を実行すると、Terraformの JSON Configuration Syntax として読み込み可能なJSONを標準出力に出力します。

$ cfft tf --publish > cff.tf.json

このJSONには aws_cloudfront_function リソースを構築できる内容が含まれているので、この.tf.jsonを配置した状態で terraform apply を行えば、LIVEステージへのpublishはTerraformに任せることができます。

Terraformの CloudFront distribution の定義でも、素直にリソースへの参照として記述できます。

  function_association {
    event_type   = "viewer-request"
    function_arn = aws_cloudfront_function.some-function.arn
  }

全てを一度に構築しない場合は cfft test によって事前にリソースが作成済みになっているので、import blockを使用してTerraformに既存リソースを取り込む記述をしておくとよいでしょう。

import {
  to = aws_cloudfront_function.some-function
  id = "some-function"
}

2. external data sourcesを使う方法

もう一つの方法は、external data source を利用する方法です。

cfft tf --external を実行すると、external data sourceで参照可能なJSONを出力します。これを利用すると、以下のように .tf にリソースを定義できます。

resource "aws_cloudfront_function" "some-function" {
  name    = data.external.some-function.result["name"]
  runtime = data.external.some-function.result["runtime"]
  code    = data.external.some-function.result["code"]
  comment = data.external.some-function.result["comment"]
  publish = true
}

data "external" "some-function" {
  program = ["cfft", "tf", "--external"]
}

external data sourceは文字列以外の値を取り込めないため、publish属性については.tf側でtrueを指定する必要があります。

この方法でも .tf.json 方式と同様、cfft test によって事前にリソースが作成されている場合は、import block を定義しておくとよいでしょう。

その他の便利機能

長くなるので詳細は割愛しますが、ecspressolambroll をお使いのかたにはおなじみの機能、テストケースを可読性高く書くための便利な機能があります。

  • 設定ファイル/定義ファイル類は YAML, JSON, Jsonnet で記述可能
  • 設定ファイル/定義ファイル類は {{ must_env }}, {{ env }} での環境変数展開ができる
  • renderコマンドでファイルのレンダリング結果を確認できる
  • diffコマンドでリソースの差分が確認できる
  • イベントの requestresponse にはHTTPのメッセージをdumpした形式で記述できる 例:
version: "1.0"
viewer:
  ip: 1.2.3.4
context:
  eventType: viewer-response
request: |
  GET /index.html HTTP/1.1
  Host: example.com
  User-Agent: Mozilla/5.0
  Cookie: foo=bar, bar=baz
  Cookie: bar=xxx
response: |
  HTTP/1.1 200 OK
  Content-Type: text/html
  Content-Length: 13
  Set-Cookie: foo=bar; Secure; Path=/; Domain=example.com

  Hello World!

まとめ

CloudFront Functionsのテストは手元ではできないのでちょっと面倒です。

CloudFront Functionsをテストする cfft というOSSを書きました。CF KVSとの連携を含めて、便利な使い方がいろいろありますのでどうぞご利用下さい!

カヤックではOSSが好きなエンジニアを募集しています!


  1. 他のJavaScriptランタイムでコードを実行することは可能ですが、一部のCFFで使用できない機能を使用した場合でもエラーにならない可能性があり、テストとはいいがたいものになります

YAPC::Hirosima 2024でコードゴルフコンテストを開催しました 〜解説編〜 #yapcjapan

こんにちは、技術部の谷脇です。

去る2月10日に行われたYAPC::Hiroshima2024ですが、みなさまはいかがだったでしょうか。私は参加して大満足であり、運営の方々に大感謝させていただきます。

さて、カヤックではゴールドスポンサーと椅子スポンサーをしていたというのは開催直前に出した記事で述べさせていただきました。

techblog.kayac.com

この記事では伏せられていた、椅子スポンサーのPerlbatrossですが、この記事ではPerlbatrossの内容紹介と問題解説をしようと思います。

Perlbatrossとは

YAPC::Hiroshima2024の開催期間中にコードゴルフの問題を出していました。以下は椅子スポンサーで貼らせていただいた問題です。

Q1. Split of Graphemeの問題

さて、これだけではなく、QRコードにアクセスすると実際に問題を解けるサイトがあります。こちらがPerlbatrossです。問題は4つありました。またランキング形式になっており、各4問を最もコードを短くできた方が上の方に乗っています。また開催中は見れませんでしたが、他の方のコードも今は見れるようにしていますので、ぜひアクセスしてみてください。

それぞれの問題は標準入力で与えたデータを処理し、規定されている出力に加工する形式になっています。検証を行うための仕組みもサイトに組み込まれていました。

問題の解説と講評

ここではPerlbatrossで出題した問題を解説していきます。

Hole1: Split of Grapheme

問題

https://perlbatross.kayac.com/challenge/1

標準入力に文字列が与えられます。

1行ごとに奇数文字目を結合したものと、同じ行数に偶数文字目を結合したものを半角スペース区切りで分けて出力してください。入力は1行当たり偶数文字数のものしかないとします。

入力例: パタトクカシーー

出力例: パトカー タクシー

元ネタは言語処理100本ノックの第1章1問目です。ですが、タイトルにあるGraphemeの通り、ひねりを加えています。以下はテストケース2の入力例と出力例です。

入力例

ABCD
😊🐱🐶🐭
あAいBうC
🌟星🌙月
🍎🚗🍌📚
#️⃣1️⃣2️⃣3️⃣
🧑‍🤝‍🧑👫
👨‍👩‍👦‍👦👨‍👩‍👧
👨🏻👩🏼👦🏽👧🏾
★☆♡♢

出力例

AC BD
😊🐶 🐱🐭
あいう ABC
🌟🌙 星月
🍎🍌 🚗📚
#️⃣2️⃣ 1️⃣3️⃣
🧑‍🤝‍🧑 👫
👨‍👩‍👦‍👦 👨‍👩‍👧
👨🏻👦🏽 👩🏼👧🏾
★♡ ☆♢

途中までは良いのですが、「1️⃣」あたりから怪しくなります。以下のワンライナーでこの文字のUnicodeでの名前を見てみましょう。

$ perl -Mcharnames\ qw/:full/ -E 'use utf8; say charnames::viacode(ord($_)) for split //, "1️⃣"';
DIGIT ONE
VARIATION SELECTOR-16
COMBINING ENCLOSING KEYCAP

このように、この記号は複数の文字で構成されています。でも人の目で見ると1文字ですよね。この人の目から見た文字の単位をUnicodeでは書記素(Grapheme)クラスターと呼びます。

もう一つ、「🧑‍🤝‍🧑」を見てみましょう。

$ perl -Mcharnames\ qw/:full/ -E 'use utf8; say charnames::viacode(ord($_)) for split //, "🧑<200d>🤝<200d>🧑"';
ADULT
ZERO WIDTH JOINER
HANDSHAKE
ZERO WIDTH JOINER
ADULT

文字単位でも仲良く手を繋いでいますね。このように、絵文字がたくさん使われる現代では、人の感覚と合致する文字数のカウントであったり、分割というのは難しいよという問題なのでした。

ちなみに初期実装のコードでは正規表現の\Xを使って書記素クラスター単位の抜き出しをしています。

ベスト回答

1位だったKarakasaDcFdさんの回答を見てみましょう。

(utf8'decode$_)^print/(\X)\X/g,$",/\X(\X)/g,$/for<>

これで51バイトという記録でした。おめでとうございます。 まず、問題にある「1行ごとにsplitしていく」という部分ですが、それは末尾のfor<>で示されています。これはfor my $_ (<STDIN>) { ... }の略です。Perlは普通のループの場合と違って後置ループはカッコが省略できます。また、foreachやwhileも同様の目的に使えますが、この場合はforが最も文字数が少なくなります。また、このコード全体は1つのstatementですが、Perlはブロックやコード全体の末尾のセミコロンを省略できます。

さらに初期実装コードにあった入力値のUnicode decodeですが、Encodeモジュールから最初から使えるutf8プラグマに切り替わっています。utf8プラグマでもdecodeできるというのは普段ではあまり使わないですが、これで3文字の省略です。そして、'がパッケージの区切り文字に使われています。Perl5(現行バージョン)のパッケージの区切り文字は::ですが、Perl4の時代では'でした。後方互換性の観点から今でも'を使えます。しかし、非推奨かつ5.42で廃止予定です。実際にテスト出力で以下の警告が出ています。YAPC::Hiroshimaでも「2024年冬のPerl」というトークでもこの話が出ましたね。

Old package separator "'" deprecated at /tmp/tdKmQucCMy/YAZ0lfwj line 1.

また、EncodeモジュールのEncode::decode_utf8と違いutf8::decodeは結果を返すのではなく、与えた変数の中身を書き換えるので、その点でもコードゴルフ向きと言えます。また、Perlは関数呼び出し時のカッコを省略できるため、utf8'decode$_utf8::decode($_)と同じ意味になるのでした。

さて次ですが、このdecodeのあとに^演算子で接続されています。これは排他的論理和です。全体を一つのstatementにし、forループの中にいれるために^を使っていると思われます。utf8::decodeは1を返すので、他にも&&||も使えると思いきや、演算子の結合の強度として、これらは$_にくっついてしまいます。他にも+なども使えるようでした。

そして/(\X)\X/のように正規表現マッチで奇数個目と偶数個目を取り出していきます。ここで面白いのは、$"$/です。これらは、Perlの特殊変数ですが、それぞれリスト区切り文字と入力レコードセパレーターです。前者はprint "@array"のように配列を文字列内で展開する際に使われる区切り文字でデフォルトではスペースが入っています。後者はfor <STDIN>のように入力値を区切るときに使うもので、デフォルトでは改行が入っています。ここでは" ""\n"を省略して書くために特殊変数が使われています。

Hole2: The bombe

問題

標準入力に暗号文が入力されます。暗号は換字式暗号であり、アルファベット順で以下の例のようにシフトされています。

例1: シフト量1の場合

  • a -> b
  • b -> c
  • z -> a

例2: シフト量-1の場合

  • b -> a
  • a -> z
  • z -> y

平文は小文字のアルファベットのみ使用されています。

シフト数は不明です。またシフト数は行ごとに変わります。

入力された暗号文の先頭には必ず平文でyapcという単語が入っています。

一行ごとに解読した平文を出してください。

よくあるシーザー暗号ですが、ひねりとしてはシフト量が事前にわからないという問題になっています。「The bombe」というタイトルですが、チューリングマシンやチューリング完全などに名を残す計算機科学者のアラン・チューリングが第二次大戦中に開発した暗号解読機「Bombe」に由来しています。この暗号解読機はドイツ軍のエニグマ暗号を解くために開発されたものです。Bombe、暗号文でよく使われていたフレーズを手がかりにエニグマ暗号機の設定を推測するというものでした。エニグマ暗号はシーザー暗号よりかなり複雑ですが、今回の問題はBombeと同様にyapcを手がかりにシフト量を推測するというものでした。

ベスト回答

こちらもKarakusaDcFdさんの回答が1位でした。おめでとうございます。

eval'y/a-z/za-y/;'x(9+ord),print for<>

記録は38バイトでした。パーが105だったので、スコアは-67でした。驚異的ですね。

では解説をしていきましょう。for<>で1行毎に処理を回しているのはHole1と同様です。そして文字列evalをしていますね。ここでy/a-z/za-y/tr/a-z/za-y/と同じ意味です。yを使うことで1文字節約になっています。さてこれで、azに、baにと-1ずつシフトしていきます。これだけだとすべての行がシフト量-1のシーザー暗号として処理されます。

そこで出てくるのがx(9+ord)です。ここでのxは左辺値の文字列を右辺値分繰り返す演算子です。

ordを見てきましょう。こちらは与えられた文字をascii上のコードポイントに変換する組み込み関数です。省略された場合は$_になり、また複数の文字からなる文字列が与えられた場合は、最初の文字を評価するとされています。最初の文字はyがn個分シフトされた文字ですね。yはコードポイントでいうと121です。121+9は130ですね。

もし1文字目がyだった場合、y/a-z/za-y/を130回分繰り返すコードが生成されます。130回ローテートするとどうなるでしょうか? アルファベット26文字で一周するので26の剰余分が実際のシフト量(マイナス)であると言えます。130mod26は0になるので、yに戻ってきます。

同様に、一文字目がzの場合は131になるので-1文字分シフトします。というように、力技でどんどんシフトするコードをこの短い文字数で生成しているのでした。最後に引数無しでprintしていますが、y/.../.../$_に作用し、printは引数がない場合は$_を出力します。またy/a-z/za-y/は入力の末尾にある改行コードには反応しないため、改行も一緒に出力されるのでOK、というものでした。

Hole3: Apache's Call

問題

標準入力にApache combined logにリクエスト処理時間(マイクロ秒)を付与した形式のログが与えられます。

参考としてLogFormatの書式を示します。

LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-agent}i\" %D"

また、入力例は以下です。

127.0.0.1 - - [10/Feb/2024:15:16:17 +0900] "GET /challenge/9 HTTP/1.1" 200 2326 "https://perlbatross.kayac.com/" "Mozilla/4.08 [en] (Win98; I ;Nav)" 123456

ログを1分ごとのウィンドウに区切って処理した上で、1行あたり1ウィンドウの出力として、以下の指標を順番にスペース区切りで標準出力に出してください。

2xxリクエストの数 3xxリクエストの数 4xxリクエストの数 5xxリクエストの数 また、5xxリクエストの数が10を超えるウィンドウが現れた場合は、そのウィンドウの結果を出力後に処理を打ち切り即座にプログラムを終了してください。

なお、ログは時系列で並んでいるとします。

これまでとは異なり実用的なネタです。私はこのようなアクセスログをなどをワンライナーで解析することがあります。ただ、ApacheのCombined Logはパースが難しく、実務的にはltsvやJSONで吐いていることが多いですね。

ベスト回答

Hole3のベストはsugyanさんの回答でした。おめでとうございます。

for(<>){/(\d+).{3} .*?" (.)/;($h[$1]||=[(0)x4])->[$2-2]++}for(@h){print"@$_$/"if$_;last if$_->[3]>10}

長さは101バイトで、このHoleはパーが190だったので、スコアは-89でした。素晴らしいですね。では解説をしていきましょう。

B::Deparseを使って、省略されている部分も表示してみます。

$ perl -MO=Deparse -e 'for(<>){/(\d+).{3} .*?" (.)/;($h[$1]||=[(0)x4])->[$2-2]++}for(@h){print"@$_$/"if$_;last if$_->[3]>10}'
foreach $_ (readline ARGV) {
    /(\d+).{3} .*?" (.)/;
    ++($h[$1] ||= [(0) x 4])->[$2 - 2];
}
foreach $_ (@h) {
    print "@$_$/" if $_;
    last if $_->[3] > 10;
}

実は<>@ARGVに書かれたファイル名から読むという演算子であり、@ARGVが空であれば標準入力から読みます。

さて最初の/(\d+).{3} .*?" (.)/;ですが、(\d+).{3}は数字が1文字以上あり、その後何らかの文字が3つ続いたあとに空白がある箇所にマッチします。例をもとに先頭から読んでいった場合、それに初めて合致するのは 16:17の部分になります。もしIPアドレスが127.0.0.1以外ではなく4オクテット目に2桁の数字が入っていた場合はそちらにマッチしていたでしょう。しかし、テストケースがすべて127.0.0.1であったため、こちらで分をキャプチャすることができていました。

そのあとの.*?"でダブルクオートとスペースが連続する最初の箇所まで読み飛ばしています。*?は非貪欲マッチであり、もしこれが*だけであった場合は一番最後のUser-Agentの部分まで読み飛ばしてしまいます。そして、その後に出てきた1文字、つまりステータスコードの先頭の1文字をキャプチャしています。キャプチャした結果はそれぞれ順番に$1$2に入ります。

$h[$1] ||= [(0) x 4]を見ていきます。$h[$1]は配列アクセスです。つまり、$116であれば、@hの16個目の要素にアクセスします。ところでこの場合、@hに何も入っていない状態でいきなり添字16の要素にアクセスした場合、0から16までの要素がすべてundefに初期化されます。そこで ||=が効いてくるわけですが、これは左辺値がfalsyである場合に右辺値を代入します。右辺値は[(0) x 4]ですね。先ほども出たx演算子ですが、先ほどとと違い左辺値がリストなので、この場合は(0, 0, 0, 0)というリストを生成します。そして、[...]は配列リファレンスなので、[0, 0, 0, 0]という配列リファレンスがめでたく右辺に代入されるわけです。

さて、次は++($h[$1] ||= [(0) x 4])->[$2 - 2];ですね。@hの各要素には配列リファレンスが入っているので、2次元配列の状態になっています。そこで->[$2-2]で分ごと要素のさらに現れたステータスコードの数を表す要素にアクセスしています。$2 - 2なので2xxの場合は0番目、5xxの場合は3番目ですね。そして最後に++でインクリメントします。というわけですね。

そして、後半のforですが、このプログラムは実は1行毎に逐次回していくのではなく、入力をすべて読みこんで集計したうえで表示を最後に行っています。つまり集計部のループと表示部のループが分かれています。

print "@$_$/" if $_; の解説をします。if $_@hの各要素がfalsyであれば表示はしません。先程0から15番目まではundefが入ってしまっているという話がありましたが、それはここで省かれています。そして@$_ですが、各要素には配列リファレンスが入っているのでこれをデリファレンスしてあげます。文字列内で配列を展開するとデフォルトではスペース区切りで表示されますから、問題で求められている形式に合います。そして先程もでた$/ですね。改行コードが入っているので、1つのループごとに改行されていきます。

そして問題にある「5xxリクエストの数が10を超えるウィンドウが現れた場合は、そのウィンドウの結果を出力後に処理を打ち切り即座にプログラムを終了してください。」ですが、こちらはシンプルにlast if $_->[3] > 10;で実現していました。

ぎゅっと短く書かれているとドキッとしますが、B::Deparseを使うとシンプルなアルゴリズムを工夫して短く書いていることがよくわかりますね。

Hole4: Pytecode

問題

独自のプログラミング言語の命令と値が改行区切りで渡されます。

このプログラミング言語には変数はなく、ただ1つのスタックが存在します。以下の文章ではスタックの一番上の値をスタックトップと言います。

命令表

  • 二項演算子 + - * / %: スタックトップとスタックの次の値を取り出して演算子を適用してスタックトップに積みます
  • 比較演算子 = != < > <= >=: スタックトップとスタックの次の値を取り出して比較し、条件に一致すれば1をスタックトップに積み、そうでなければ0を積みます
  • dup: スタックトップの値を複製してスタックの上に更に積みます
  • drop: スタックトップの値を取り出して破棄します
  • swap: スタックトップとスタックの次の値を入れ替えます
  • .: スタックトップを取り出して標準出力へ出力します
  • cr: 改行を標準出力へ出力します
  • ifelsethen: スタックトップの値が1である場合は ifelse の間の命令を実行します。スタックトップの値が0である場合は elsethen の間の命令を実行します。else は省略できません
  • beginuntil: まず、begin から until の間の命令を実行し、untilに到達したらスタックトップから値を取り出し、この値が0であれば、begin の次の命令まで戻ります。1以外であれば、untilの次の命令に移ります

これ以外の値が入力された場合はスタックトップに積みます

元ネタはForthというプログラミング言語です。本物は命令の区切り文字が改行ではなくスペースですが、それ以外はほぼ同じ仕様です。ただ、命令が足りなくこれだけでは普通にプログラミングを行うはかなり困難です。私もテスト用の入力コードを書く際は大変苦労しました。

ベスト回答

1位はKarakasaDcFdさんのスコア-194です。おめでとうございます。こちらの解説は後日出す予定の「チート・グリッチ編」で解説するとして、2位のnsfisisさんの回答を見ていきます。こちらはスコア-107でした。

$_='@q=<>;while($_=$q[$c++]){if(/^i/){$n=!#;($_=$q[$c++])=~/^i`$n++"^e/&&$n--while$n;}elsif(/^e/){$n=1;($_=$q[$c++])=~/^e`$n++"th/&&$n--while$n;}else{/^=`%,#==#"\.`print #"du`%,$s[-1]"dr`#"^s`%,#,#"c`print$/"th`0"b`push@u,$c-1"^u`(#?$c:$d)=pop@u"[^!-\/<>\n]+`%,$&:($i=#,$j=#,%,eval$j.$_.$i);}}';s'%'push@s'g;s'#'pop@s'g;s'`'/?'g;s'"':/'g;eval;

スタック指向のインタプリタを実装する場合の戦略が初期実装と比べてもそこまでは変わっていません。初期実装は命令列を配列で持ち、プログラムカウンタも変数で持ちます。全体のスタックは配列で持っていましたが、ifやループはPerl自身の関数のコールスタックを利用していました。本当はこれもプログラムカウンタの戻り位置を覚えることで素朴に実装しようとしたのですが、バグが取り切れず間に合わなかったというのがあります。

さてこのコードはどうでしょうか。まず、全体のコードを圧縮するために定型的に出てくるようなpush @sのようなものを%#に置き換えて定義しておき、正規表現で置き換えてから実行しているというのがあります。s'%'push@s'g; s'#'pop@s'g;s''/?'g;` の部分がそうですね。他の方の回答でも、push/popのような頻出コードは関数にまとめたりなどの工夫が見られます。

コードの全体を把握するには、一旦置換を行ったあとのコード文字列をB::Deparseして見るのが良さそうです。そうしてみたのが以下です。

@q = readline ARGV;
while ($_ = $q[$c++]) {
    if (/^i/) {
        $n = !pop(@s);
        ($_ = $q[$c++]) =~ /^i/ ? ++$n : /^e/ && --$n while $n;
    }
    elsif (/^e/) {
        $n = 1;
        ($_ = $q[$c++]) =~ /^e/ ? ++$n : /th/ && --$n while $n;
    }
    else {
        /^=/ ? push(@s, pop @s == pop @s) : (/\./ ? print(pop @s) : (/du/ ? push(@s, $s[-1]) : (/dr/ ? pop @s : (/^s/ ? push(@s, pop @s, pop @s) : (/c/ ? print($/) : (/th/ ? '???' : (/b/ ? push(@u, $c - 1) : (/^u/ ? pop @s ? $c : $d = pop @u : (m[[^!-/<>\n]+] ? push(@s, $&) : ($i = pop @s, $j = pop @s, push(@s, eval $j . $_ . $i)))))))))));
    }
}

では見ていきましょう。@qに命令全体を読み込み、$cをプログラムカウンタとするのは初期実装と代わりありません。一方で違うのは命令列の解析方法です。初期実装では文字列の完全一致で行っていたのを、この回答では正規表現で短く書いています。例えばifであれば/^i/で済ましています。もしスタックに積む文字列としてiから始まるものがテストケースに含まれていたらこの回答は通りませんでしたが、テストコードにはそういった意地悪なものが含まれていなかったので、こちらで問題ありませんでした。またelseが現れた場合も/^e/で判定していますね。ifのスタックの深さに関しては$nで判定しています。そして$nが0になるまでスキップしています。これでifがtrueの場合にelseの中をスキップするコードが実現されています。

その他の部分は巨大な三項演算子で実現されていますが、一旦見やすくするとこんな感じです。

if (/^=/) { # =
    push(@s, pop @s == pop @s);
}
elsif (/\./) { # .
    print(pop @s);
}
elsif (/du/) { # dup
    push(@s, $s[-1]);
}
elsif (/dr/) { # drop
    pop @s;
}
elsif (/^s/) { # swap
    push(@s, pop @s, pop @s);
}
elsif (/c/) { # cr
    print($/);
}
elsif (/th/) { # then
    '???';
}
elsif (/b/) { # begin
    push(@u, $c - 1);
}
elsif (/^u/) { # until
    if (pop @s) {
        $c;
    }
    else {
        $d = pop @u;
    }
}
elsif (m[[^!-/<>\n]+]) { # 命令以外
    push(@s, $&);
}
else { # 演算子
    $i = pop @s;
    $j = pop @s;
    push(@s, eval $j . $_ . $i);
}

今回のテストケースが通るような最小限の正規表現で実現しているのがよくわかります。ちなみに$&は最後に正規表現でマッチした文字列が入る特殊変数です。

他の方の回答では三項演算子の他にも廃止予定のgiven/whenを使っている方もいました。ちなみに上位の回答にはありませんでしたが、私が自ら解いたときには、命令全体をPerlのコードにコンパイルするアプローチを取りました。

@t=<>;$p="(pop\@s)";sub o{push@s,@_};for$i(0..$#t){$_=$t[$i];chomp;if(s/^if$/if($p==1){/) {}elsif(s/^else$/}else{/) {}elsif(s/^then$/}/){}elsif(s/^begin$/do{/){}elsif(s/^until$/}while($p!=0);/){}elsif(s!^([+\-*/%])!(\$c,\$d)=($p,$p);o(\$d$1\$c);!){}elsif(s/^cr/print"\n";/g){}elsif(s!^\.!print$p;!g){}elsif(s/^dup/o(\$s[$#s]);/){}elsif(s/^drop/$p;/){}elsif(s/^swap/(\$c,\$d)=($p,$p);o(\$c,\$d);/){}elsif(s/^(!=|<|>|<=|>=)/if($p$1$p){o(1);}else{o(0);}/){}elsif(s/^=$/if($p==$p){o(1);}else{o(0);}/){}else{s/^(.*)$/o(\'$1\');/g;}$t[$i]=$_;}eval"@t";

まだまだ削れる気はしますが、スコアは+96です。

まとめ

というわけで総合1位はKarakasaDcFdさんのスコア-401です。おめでとうございます。

私自身も今回のコードゴルフの作問で大変勉強させていただきました。またまだまだPerlの知らない機能がたくさんあることを知れました。短く書くというのは、現代のプログラミング環境においてはあまり歓迎されませんが、こういったホビーとしてのプログラミングでは大変おもしろいものだと感じます。また、テキスト処理を行うためのPerlの価値というのもまだまだ捨てたもんじゃないと私は思います。

私は今でも仕事でアドホックに使うPerlのワンライナーを書く機会がありますが、皆様はいかがでしょうか。たまにはPerlのことも思い出してくださいね。

また、このコードゴルフの企画が面白かったなと思う方はぜひXのカヤック技術部アカウントをフォローしてください。反響が多ければまた次も同じようなことをやるかもしれません。

では次はチート・グリッチ編の解説でお会いしましょう。他にも今回のジャッジサーバーの仕組みなども解説してコードを公開して締める、といったことを予定しています。上記Xアカウントをフォローしておけばすぐに見れますので是非フォローしてください。


カヤックではコードを短く書いたり長く書いたりできるエンジニアも募集しています!

hubspot.kayac.com