Node.jsで画像を生成するAWS Lambdaの開発環境改善

これは、FOLIO Advent Calendar 2023 の xx 日目の記事として投稿しようと思ったものです。。。。(忙しくて気がついたら 2 月になっていました)

今回は私がメンテナンスを引き継いだグラフを png 出力する AWS lambda の改善例を共有します。

背景

昨年の弊社のアドベントカレンダーに該当のリポジトリの紹介があるので、今回の記事では次のブログの内容を前提として内容を進めていきます。

zenn.dev

この記事で紹介するコードは次になります。

github.com

このlambdaは json を s3 に put した場合それをトリガーし lambda を発火させ png 画像が生成させるものになります。

  • s3 に put する json の例
[
  { "label": "20代", "proportion": "0.15", "color": "#d53e4f" },
  { "label": "30代", "proportion": "0.23", "color": "#fc8d59" },
  { "label": "40代", "proportion": "0.21", "color": "#fee08b" },
  { "label": "50代", "proportion": "0.18", "color": "#ffffbf" },
  { "label": "60代", "proportion": "0.15", "color": "#e6f598" },
  { "label": "70代以上", "proportion": "0.08", "color": "#99d594" }
]
  • 出力されるグラフの例 graph

課題

このリポジトリの引き継ぎを受けた際に、node-canvas が jest と相性が悪く、描画関連の自動テストが一部整備できていないこと、またローカルで描画の動作確認ができない(毎回開発環境にデプロイしないと動作確認できない)という課題の共有がありました。

また、node-canvas というライブラリは内部的にネイティブライブラリを利用しているため、CI で TypeScript のトランスパイルは成功しても、lambda の実行時にエラーが発生することもあります。そのため、CI での lambda の実行テストも必要という話になりました。

つまりこのリポジトリを運用するにあたり、次の手段を確立する必要がありました。

  • ローカルでグラフの表示の動作確認を行う手段
  • ローカルおよび CI 環境で lambda を実行し、成果物の PNG ファイルを確認する手段

上記の課題を解決するために、開発環境改善のために Storybook の導入と、lambda の動作確認のために Docker で AWS Lambda と LocalStack の構築を行いました。

環境

  • nodejs v16
  • storybook v7.0.9

1. storybook(+webpack)の導入

UI カタログツール

storybook とは

storybook は web アプリケーションでページと UI コンポーネントを分離して、コンポーネントを独立した状態で開発できるようにする UI カタログツールです。

storybook for HTML とは

静的サイトを UI カタログとして管理できるものです。今回は lambda のグラフ生成部分(html element の段階)を UI コンポーネントとみなして storybook を利用します。 本来は html ファイルをカタログとして利用するプラグインですが、今回は html ファイルではなく、ts ファイル内の html element をカタログとして利用する方法を紹介します。

storybook の導入

現状のコードベースとして、esmodule と typescript が利用されています。しかし esmodule と typescript と今回追加したい webpack は相性が悪いという問題がありました。 これはwebpack の module の解決方法と esm の import の書き方が合わないのが原因です。issue にある次の対応を行い、module の解決を行えるように対応を行いました。

github.com

html element をカタログとして利用する方法

reander 関数が呼ばれた際に

  1. 2 で追加される element を削除する(再レンダリング時に 2 だけだとどんどん要素が追加されてしまう)
  2. 該当の tsxコンポーネントを document.body の子要素に追加する

という関数を渡すことで storybook でライフサイクルも含め UI カタログとして利用できるようになります

サンプルコードは以下になります

github.com

リファクタリング

lambda では jsdom という Node.js 環境に HTML DOM API を追加するライブラリを利用しています。その DOM の canvas に node-canvas という Web Canvas API 互換のあるAPIを利用できるようにするライブラリを利用して グラフを描画しています。 しかし StoryBook(Webpack) はブラウザ上で動作するものなので Node.js でしか動作しない jsdom や node-canvas を利用している ts ファイルをそのまま利用することは出来ません。 グラフ生成部分を引数を渡すと element が返ってくる箇所を関数として切り出し、Node.js 上だと node-canvas、StoryBook だとグローバルな html element の子要素としてそれぞれ利用できるように修正を行いました。

※実際には module の top level に関数を定義しているため Abstract Classでは無いですがmoduleの依存関係のイメージはこのようになっています

また syorybook の text の欄にある json を編集するとグラフを再レンダリングするようにし、値によってグラフがどのように表示されるかもわかるようにしました。

実際の動作

実際に生成した storybook は以下になります。

また GithubPages に deploy しているので是非 jsonvalue を変えて、グラフが再描画される事を確認してみて下さい。業務ではもう少し複雑なグラフを生成しているので、エッジケースの確認などに便利に利用できます。

yumuuu.github.io

しかしこの改善を進めながら検証を進めた結果、jsdomと webpack(chromeで見ているので多分Blink?)は HTML のレンダリングエンジンが異なるため、storybook で表示されるものと実際に生成される PNG ファイルには細かいですが差分が存在する事がわかりました。そのため、storybook 上での表示内容を比較してのビジュアルリグレッションテストを行うことはできないという結論に至りました。

ローカルで chart の表示を確認できるようになり、開発はスムーズに行えるようになりました。しかし、ローカルでの表示内容が本番環境での表示内容と異なる場合、storybook の表示内容の差分を見て変更内容が正しいと判断できません。なので、lambda で生成される画像を確認する別の方法を確立する必要がありました。

2. local での lambda の動作確認

lambda が docker image で作成されているので local で lambda の docker container を起動し、同じく local で起動させた localstack の s3 に接続することで確認を行うことができます。

localstackのs3に接続する方法

localstack の s3 にアクセスするには path-style でアクセスするしかななく、path-style でアクセスするには S3Client のインスタンスを生成する際に forcePathStyle: true, を設定する必要があります。

new S3Client({
  credentials: fromIni({ profile: "local" }),
  region: "ap-northeast-1",
  endpoint: "http://localstack:4566",
  forcePathStyle: true,
});

また今回は s3 への put をトリガーにして lambda を発火させるわけではなく、s3 の put 時の notification event を lambda の引数にして lambda を実行します。今回は次の json を lambda の入力とします。

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/with-s3.html

notification eventの例

{
    "Records": [
      {
        "eventVersion": "2.1",
        "eventSource": "aws:s3",
        "awsRegion": "us-east-2",
        "eventTime": "2019-09-03T19:37:27.192Z",
        "eventName": "ObjectCreated:Put",
        "userIdentity": {
          "principalId": "AWS:AIDAINPONIXQXHT3IKHL2"
        },
        "requestParameters": {
          "sourceIPAddress": "205.255.255.255"
        },
        "responseElements": {
          "x-amz-request-id": "D82B88E5F771F645",
          "x-amz-id-2": "vlR7PnpV2Ce81l0PRw6jlUpck7Jo5ZsQjryTjKlc5aLWGVHPZLj5NeC6qMa0emYBDXOo6QBU0Wo="
        },
        "s3": {
          "s3SchemaVersion": "1.0",
          "configurationId": "828aa6fc-f7b5-4305-8584-487c791949c1",
          "bucket": {
            "name": "local-bucket",
            "ownerIdentity": {
              "principalId": "A3I5XTEXAMAI3E"
            },
            "arn": "arn:aws:s3:::lambda-artifacts-deafc19498e3f2df"
          },
          "object": {
            "key": "graph/sample.json",
            "size": 1305107,
            "eTag": "b21b84d653bb07b05b1e6b33684dc11b",
            "sequencer": "0C0F6F405D6ED209E1"
          }
        }
      }
    ]
  }

実行方法

次の方法で動作を確認できます。

  1. lambda の docker を build する
  2. lambda と localstack の docker を起動する
  3. s3 の bucket を作成する
  4. json ファイルを s3 に入稿する
  5. lambda を実行する
  6. 実行結果生成された png ファイルを local にコピーする

具体的は次の script を実行します。

#!/bin/bash
set -e

LOCALSTACK_HOST="0.0.0.0"
LOCALSTACK_PORT="4566"
export AWS_ACCESS_KEY_ID=dummy 
export AWS_SECRET_ACCESS_KEY=dummy 


docker-compose build
docker-compose up -d 
pwd
sh ./setup/s3.sh


# lambda で読み込む file を予め削除する
echo "delete file"
aws --region=ap-northeast-1 --endpoint-url="https://$LOCALSTACK_HOST:$LOCALSTACK_PORT" --no-verify-ssl s3 rm s3://local-bucket/graph--recursive

# json file の入稿
echo "upload file"
aws --region=ap-northeast-1 --endpoint-url="https://$LOCALSTACK_HOST:$LOCALSTACK_PORT" --no-verify-ssl --profile localstack s3 cp ./../sample/sample.json s3://local-bucket/graph/sample.json

# lambda の実行
echo "running lambda"
curl -XPOST http://localhost:9000/2015-03-31/functions/function/invocations -d  @./test/sample_event_valid.json


# png file のダウンロード
aws --region=ap-northeast-1 --endpoint-url="https://$LOCALSTACK_HOST:$LOCALSTACK_PORT" --no-verify-ssl --profile localstack s3 cp s3://local-bucket/graph/sample.png ./sample.png
open ./sample.png

改善を行ったことでできるようになったこと

改善を行った結果、ローカル環境でグラフ生成ロジックの確認や、Lambda を実行してグラフを簡単に確認できるようになりました。また、Lambda のリポジトリや Node.js に詳しくないメンバーでも、動作確認や修正が容易に行えるようになりました。

さらに、副次的な効果として、画像を簡単に生成できるようになったため、デザインチームとのコミュニケーション(細かい修正の検討など)が容易に行えるようになりました。さらに、グラフを Storybook で確認できるため、「デザイン仕様とグラフのロジックの生成結果が 1px ずれている」などの細かい差分も確認できるようになりました。

注意点

上記の localstack を利用した local での動作確認方法は nodejs 16 の image だと実行できるのですが nodejs18 だと実行できません。(node.js16 の EOL は 2023.09 なのでこの記事は賞味期限切れになる前に出したかったという事情があります。)

具体的には node-canvas が依存しているネイティブライブラリのバージョンが lambda/nodejs:18(amazonlinux2)とは合わず実行時エラーになります。node18 で利用するには ric を利用して custom image を作成する必要があります。

その他

引き継ぎドキュメントや実際の引き継ぎの際に現状の課題などを共有があり、実際の開発/運用方法がにわかったので当時と事情が変わった際にどのように改善を行うと効果的かがすぐわかりました。前任者の丁寧な仕事にとても感謝しています。

また去年のアドベントカレンダーに引き継ぎ前の状態を公開出来るように加工した記事とサンプルコードがあったのでこの記事もとても書きやすかったです。

まとめ

  • AWS lambda で画像を生成するリポジトリに UI カタログを導入しました
  • AWS lambda を local で動作確認できるように localstack などの docker 環境を構築しました
  • 開発環境改善を行ったことで開発速度が向上しました

リファレンス

営業日カレンダーモデルの実装

これは、FOLIO Advent Calendar 2022 の11日目の記事です。

営業日カレンダーモデル

zenn.dev

この記事で提案されている営業日カレンダーモデルの parser を実装してみました。

*趣味で作っただけなので業務利用は行ってません

営業日カレンダーモデルとは

営業日が2つ以上存在するドメインで利用できるデータモデルです。

あるA市場の営業日からn日後の日付にもっとも近いB市場の営業日からn日後の日付、のような複雑な日付を表現することができます。詳しくは元記事を参照して下さい。

環境

sacla と scala-parser-combinators を利用します。

scalaVersion := "2.13.8"
libraryDependencies += "org.scala-lang.modules" %% "scala-parser-combinators" % "2.1.1"

元の規則の営業日カレンダーモデルの実装

オリジナルのEBNF

binop    ::= '+'
           | '-'
castop   ::= '_'
           | '^'
cal      ::= 'jp'
           | 'us'
           | 'c'
           | 'jp & us'
           | 'jp | us'
expression
         ::= ( ( "T" | expression ) cast_op ) cal
           | ( "(" expression ")" )
           | expression binop num

まずはこの EBNF の規則に従って lexer を実装してみます。以下が実装例です。

object BusinessCalendarParser extends JavaTokenParsers with RegexParsers {
  private def binop = "+" | "-"
  private def castop = "_" | "^"
  private def cal: Parser[String] = "jp&us" | "jp|us" | "jp" | "us" | "c"
  private def integer = wholeNumber
  private def T: Parser[String] = """[0-9]{4}/[0-9]{2}/[0-9]{2}""".r ^^ { _.toString } //
  private def expr: Parser[Any] = ((T | expr) ~ castop ~cal) | ("(" ~ expr ~ ")") | (expr ~ binop ~ integer)

  def apply(input: String): Either[String, Any] = parseAll(expr, input) match {
    case Success(c, n) => Right(c)
    case Failure(e, _) => Left("FAILURE: " + e.toString)
    case Error(e, _) => Left("ERROR: " + e.toString _)
  }
}

この実装では入力によっては簡単にスタックオーバーフローになってしまいます。

scala-parser-combinators は LL(1)の構文しか解析できず、元の定義が LL(1)文法ではないからです。

例えば (2022/12/11_jp&us+1) を入力するとスタックーオーバーフローになります。

新しい規則の営業日カレンダーモデルの実装

元の定義は LL(1)ではないので、実装するには元の定義を変更する必要があります。

以下が修正した新しい規則です。

新規則のEBNF

 binop    ::= '+'
            | '-'
 castop   ::= '_'
            | '^'
 cal      ::= 'jp'
            | 'us'
            | 'c'
            | 'jp&us'
            | 'jp|us'
 expr     ::= term cast_op cal [ binop num ]
 term     ::= "(" expr ")" | "T"

また元記事だと表記ゆれがあったので jp & us -> jp&us, jp | us -> jp|us としています。記事の最後の例題には半角スペースが無かったのでそちらに合わせました。

この規則だとバックトラックが必要になる例を回避できます。

新規則のBNFに基づいた実装

以下が新規則の BNF に基づいた lexer、parser の実装です。(parser の実装も一緒に行っています)

以下が修正した EBNF の定義通りの lexer を実装です。

object BusinessCalendarParser extends JavaTokenParsers with RegexParsers {
  private def binop = "+" | "-"
  private def castop = "_" | "^"
  private def cal: Parser[String] = "jp&us" | "jp|us" | "jp" | "us" | "c"
  private def integer = wholeNumber
  private def T: Parser[String] = """[0-9]{4}/[0-9]{2}/[0-9]{2}""".r ^^ { _.toString }
  private def expr: Parser[Any] = term ~! castop ~! cal ~! (binop ~! integer).?
  private def term: Parser[Any] = ("(" ~! expr ~! ")") | T

  def apply(input: String): Either[String, Any] = parseAll(expr, input) match {
    case Success(c, n) => Right(c)
    case Failure(e, _) => Left("FAILURE: " + e.toString)
    case Error(e, _) => Left("ERROR: " + e.toString _)
  }
}

case classへの変換

規則に従ってパースができることがわかったのでパース結果を扱いやすいように case class に変換します。

まずは Token を定義します。

object Token {
  sealed trait BinOp
  case object Plus extends BinOp
  case object Minus extends BinOp

  sealed trait CastOp
  case object Hat extends CastOp
  case object UnderBar extends CastOp

  sealed trait Cal
  case object JP extends Cal
  case object EN extends Cal
  case object Center extends Cal //cはCenterだと思っていたらCalendarのcだった。。。
  case object JPAndEN extends Cal
  case object JPOrEn extends Cal

  case class Num(int: Int)

  sealed trait Expr
  case class BusinessDayCalendar(
      expr: Expr,
      castOp: CastOp,
      cal: Cal,
      maybeBinOp: Option[BinOp],
      maybeInt: Option[Num]
  ) extends Expr
  
    case class Calendar(localDate: LocalDate) extends Expr
  object Calendar {
    def fromString(date: String): Calendar = Calendar(
      LocalDate.parse(
        date,
        DateTimeFormatter
          .ofPattern("uuuu/MM/dd")
          .withResolverStyle(ResolverStyle.STRICT)
      )
    )

以下が parser の実装です。

import businesscalendarparser.Token._
object BusinessCalendarParser extends JavaTokenParsers with RegexParsers {
  private def binop: Parser[BinOp] = ("+" | "-") ^^ {
    case "+" => Plus
    case "-" => Minus
  }

  private def castop: Parser[CastOp] = ("_" | "^") ^^ {
    case "_" => UnderBar
    case "^" => Hat
  }

  private def cal: Parser[Cal] = ("jp&us" | "jp|us" | "jp" | "us" | "c") ^^ {
    case "jp&us" => JPAndEN
    case "jp|us" => JPOrEn
    case "jp"    => JP
    case "us"    => EN
    case "c"     => Center
  }

  private def integer = wholeNumber ^^ { s => Num(s.toInt) }

  private def T: Parser[Calendar] =
    """[0-9]{4}/(0[1-9]|1[0-2])/(0[1-9]|[12][0-9]|3[01])""".r.filter(
      Calendar.canCreateCalendarInstance
    ) ^^ Calendar.fromString

  private def expr: Parser[BusinessDayCalendar] =
    term ~! castop ~! cal ~! (binop ~! integer).? ^^ {
      case (t: Expr) ~ (cas: CastOp) ~ (cl: Cal) ~ Some(
            (b: BinOp) ~ (i: Num)
          ) =>
        BusinessDayCalendar(t, cas, cl, Some(b), Some(i))
      case (t: Expr) ~ (cas: CastOp) ~ (cl: Cal) ~ None =>
        BusinessDayCalendar(t, cas, cl, None, None)
    }

  private def term: Parser[Expr] = ("(" ~>! expr <~! ")") | T

  def apply(input: String): Either[String, BusinessDayCalendar] =
    parseAll(expr, input) match {
      case Success(c, n) => Right(c)
      case Failure(e, _) => Left("FAILURE: " + e.toString)
      case Error(e, _)   => Left("ERROR: " + e.toString)
   

この実装で元記事で紹介されていた"((2020/12/26_c+1)jp+1)_us-1"のパースできることがわかります。

以下が該当のテストです。

  it should "((2020/12/26_c+1)^jp+1)_us-1" in {
      val bcp = BusinessCalendarParser("((2020/12/26_c+1)^jp+1)_us-1")
      bcp shouldBe a [Right[_, _]]
      bcp.right.get shouldBe BusinessDayCalendar(BusinessDayCalendar(BusinessDayCalendar(Calendar(LocalDate.of(2020, 12, 26)), UnderBar, Center, Some(Plus), Some(Num(1))), Hat, JP, Some(Plus), Some(Num(1))), UnderBar, EN, Some(Minus), Some(Num(1)))
  }  

((2020/12/26_c+1)^jp+1)_us-1 が((2020/12/26 の翌日)から翌日以降の JPX 営業日の翌日の営業日)から翌日以前の EN 営業日の前日の営業日) と表現できているのがわかります。

オリジナルのBNFでできて新規則のBNFでできないことはあるか?

  • あります。互換性が無い規則になっています。

具体的に言うと "(" "T" cast_op cal ")"は元の規則では評価可能ですが、変更した新しい規則では評価できません。

  // "(" "T" cast_op cal ")" は評価できない
  it should "left (2020/12/26^jp)" in {
      val bcp = BusinessCalendarParser("(2020/12/26^jp)")
      bcp shouldBe a [Left[_, _]]
      bcp.left.get shouldBe "ERROR: '^' expected but end of source found" 
  }

ただ "T" cast_op cal をわざわざ "(" "T" cast_op cal ")" と書く必要はないので実用上問題はないです。

評価機の実装

lexer と parser は作りましたが、実際に評価して日付を出力しないとなんとも面白くないです。

かなり適当な実装ですがインタプリタも作ってみました。

ダサいのでコードを載せるのは勘弁..。

デモ

では実際にT=2020/12/26としたときに、上のEBNFに従って書いた ((T_c+1)jp+1)_us-1 が何日になるかみてみましょう。

元記事の上記の演習を解いてみます。

sbt:BusinessDayModelBot> run "((2020/12/26_c+1)^jp+1)_us-1"
[info] running BusinessDayModelBot.Main ((2020/12/26_c+1)^jp+1)_us-1
input: ((2020/12/26_c+1)^jp+1)_us-1
token: Right(BusinessDayCalendar(BusinessDayCalendar(BusinessDayCalendar(Calendar(2020-12-26),UnderBar,Center,Some(Plus),Some(Num(1))),Hat,JP,Some(Plus),Some(Num(1))),UnderBar,EN,Some(Minus),Some(Num(1))))
result: Some(2020-12-24)

12/24 になっているので正しく解答できています。

まとめ

  • ”複数の営業日カレンダーを扱う業務のための日付モデル”のlexer 、 parser 、interpreter を実装しました
  • 既存の定義では parser-combinators で扱いにくいので修正した新たな規則を作りました
  • 演習課題が解けることを確認しました

reference

Scalaの定数の初期化失敗時にnullになる理由

初期化失敗時にnullになるとは??

cake patternを利用したDI時などで定数が束縛される前にその定数を呼び出すとnullになります。1

これはScalaを始めた頃によく陥る罠2 で私も昔これでよく悩みました。

最近友人とこの話題になり、なぜnullになるのか疑問に思い調査してみました。

環境

  • Scala: 2.13.4
  • Java: openjdk 11.0.10 2021-01-19

調査

再現する例を示す

class Example {
  println(s)
  val s = "hello world"
  println(s)
}

classインスタンスを生成したいのでREPLで実行する

$ scala
Welcome to Scala 2.13.4 (Eclipse OpenJ9 VM, Java 1.8.0_282).
Type in expressions for evaluation. Or try :help.

scala> class Example {
     |   println(s)
     |   val s = "hello world"
     |   println(s)
     | }
     |
         println(s)
                 ^
On line 2: warning: Reference to uninitialized value s
class Example

scala> new Example
null
hello world
val res0: Example = Example@9a44ab10

定数sを束縛する前に呼び出すと確かにnullになる

束縛する前に値を呼び出せるのがそもそもよくわからないが、インスタンスが作られていない状態で呼び出すとnullになるという挙動がよくわからない

たとえば当たり前だがjavaだとコンパイル時にcannot find symbolでエラーになる

class Example {
  Example(){
    System.out.println(s);
    final String s = "hello java";
    System.out.println(s);        
  }
}
$ javac example.java
example.java:3: エラー: シンボルを見つけられません
    System.out.println(s);
                       ^
  シンボル:   変数 s
  場所: クラス Example
エラー1個

つまり String型の挙動ではない

Scalaコンパイルフェーズでconstructorsというものがあり、フィールド定義をコンストラクタへ移動するということを行う

このコンパイラフェーズでコンストラクタに持ち上げる時にnullを入れているのだと予想した

scalaコンパイラはこのコンパイルフェーズの途中のものを出力することができる

$ scalac example.scala -Xprint:constructors
example.scala:2: warning: Reference to uninitialized value s
  println(s)
          ^
[[syntax trees at end of              constructors]] // example.scala
package  {
  class Example extends Object {
    private[this] val s: String = _;
      def s(): String = Example.this.s;
    def (): Example = {
      Example.super.();
      scala.Predef.println(Example.this.s());
      Example.this.s = "hello world";
      scala.Predef.println(Example.this.s());
      ()
    }
  }
}

1 warning

private[this] val s: String = _;

予想したとおりsに_という初期値を代入している箇所を見つけた

この_

https://scala-lang.org/files/archive/spec/2.13/04-basic-declarations-and-definitions.html#variable-declarations-and-definitions

に書いてあるとおり

var かつ 型アノテーションがついている場合のみ利用できる

_はTTの型によって値が変わり、AnyRefを継承していた場合はnull、AnyValを継承していた場合は0っぽい値が代入される

結果

AnyRefを継承している型をインスタンス化せずに呼び出そうとするとコンパイルフェーズ18のconstructorsの際に初期値として代入された_が呼び出され、その値のnullになる

感想

業務や趣味でScalaのコンパイルフェーズを一つづつ表示していたのでコンパイルフェーズ18でなにかやっているのではないか?`という仮定にたどり着けたのだと思う

またDIなどをするときはほとんどが独自型なのでnullになるが、AnyVal(Int, Long, etc..)を束縛前に利用すると0っぽい値が入り、実行時にも中々気が付けない場合があると考えると結構怖い

scala3(dotty)の話

Scala3ではuninitializedというメソッドに変えられている(コンパイルフェーズ中にしか利用できないメソッドになっている)

https://dotty.epfl.ch/api/scala/compiletime.html#uninitialized-0

追記1

nullになる場合はlazy valなどを使って回避しましょう

追記2

記事中のScalaとJavaの比較の箇所が正しく比較できていないようです

ちょっと僕には難しすぎて正しく解説できないので吉田さんのツイートをそのまま貼っておきます

https://gist.github.com/xuwei-k/8c8baf19f4067ca465023c44254b6973

ref