API GatewayからStep Functionsを実行する

terraformのコードが以下の通り。

terraform

#####################################################################
# API Gateway
#####################################################################

resource "aws_apigatewayv2_api" "apigw2sfn" {
  name          = "apigw2sfn"
  protocol_type = "HTTP"
}

resource "aws_apigatewayv2_stage" "apigw2sfn_main" {
  api_id      = aws_apigatewayv2_api.apigw2sfn.id
  name        = "main"
  auto_deploy = true
}

resource "aws_apigatewayv2_route" "apigw2sfn_post_root" {
  api_id    = aws_apigatewayv2_api.apigw2sfn.id
  route_key = "POST /"
  target    = "integrations/${aws_apigatewayv2_integration.apigw2sfn.id}"
}

resource "aws_apigatewayv2_integration" "apigw2sfn" {
  api_id              = aws_apigatewayv2_api.apigw2sfn.id
  integration_type    = "AWS_PROXY"
  integration_subtype = "StepFunctions-StartExecution"
  credentials_arn     = aws_iam_role.apigw2sfn_role.arn

  request_parameters = {
    Input           = "$request.body"
    StateMachineArn = aws_sfn_state_machine.my_sfn_sleep.arn
  }
}

resource "aws_iam_role" "apigw2sfn_role" {
  name = "apigw2sfn-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = "sts:AssumeRole"
        Principal = {
          Service = "apigateway.amazonaws.com"
        }
      }
    ]
  })
}

resource "aws_iam_role_policy" "apigw2sfn_start_sfn" {
  role = aws_iam_role.apigw2sfn_role.name
  name = "start-sfn"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = "states:StartExecution"
        Resource = aws_sfn_state_machine.my_sfn_sleep.arn
      }
    ]
  })
}

#####################################################################
# Step Functions
#####################################################################

resource "aws_sfn_state_machine" "my_sfn_sleep" {
  name     = "my-sfn-sleep"
  role_arn = aws_iam_role.my_sfn_role.arn

  definition = jsonencode({
    QueryLanguage = "JSONata"
    StartAt       = "Wait"
    States = {
      Wait = {
        Type    = "Wait"
        Seconds = 60
        Next    = "Exit"
      }
      Exit = {
        Type = "Succeed"
      }
    }
  })
}

resource "aws_iam_role" "my_sfn_role" {
  name = "my-sfn-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = "sts:AssumeRole"
        Principal = {
          Service = "states.amazonaws.com"
        }
      }
    ]
  })
}

curlからPOSTするとすぐにレスポンスが返ってくる。

% curl -XPOST -d '{"hello":"world"}' https://xxx.execute-api.ap-northeast-1.amazonaws.com/main/
{"executionArn":"arn:aws:states:ap-northeast-1:123456789012:execution:my-sfn-sleep:7af667d7-16f8-4435-8ce5-f3500a878a32","startDate":1.772339363814E9}

リクエストボディがStateへの入力になる。

認証を追加

LambdaのAuthorizerを追加する。

resource "aws_apigatewayv2_route" "apigw2sfn_post_root" {
  # ...

  # 追加
  authorization_type = "CUSTOM"
  authorizer_id      = aws_apigatewayv2_authorizer.apigw2sfn.id
}

#####################################################################
# Authorizer
#####################################################################

resource "aws_apigatewayv2_authorizer" "apigw2sfn" {
  api_id                            = aws_apigatewayv2_api.apigw2sfn.id
  authorizer_type                   = "REQUEST"
  identity_sources                  = ["$request.header.Authorization"]
  name                              = "authorizer"
  authorizer_payload_format_version = "2.0"
  enable_simple_responses           = true
  authorizer_uri                    = aws_lambda_function.apigw2sfn_authorizer.invoke_arn
  authorizer_result_ttl_in_seconds  = 0
}

resource "lambdazip_file" "apigw2sfn_authorizer" {
  output = "apigw2sfn-authorizer.zip"

  contents = {
    "index.mjs" = <<-EOT
      export const handler = async (event) => {
        console.log(event);
        return {
          isAuthorized: true,
          context: {}, // コメントアウトしてもよい
        };
      };
    EOT
  }
}

resource "aws_lambda_function" "apigw2sfn_authorizer" {
  filename         = lambdazip_file.apigw2sfn_authorizer.output
  function_name    = "apigw2sfn-authorizer"
  role             = aws_iam_role.lambda_apigw2sfn_authorizer_role.arn
  handler          = "index.handler"
  source_code_hash = lambdazip_file.apigw2sfn_authorizer.base64sha256
  runtime          = "nodejs22.x"
}

# NOTE: apigw2sfn_roleへの権限の付与は不要っぽい
resource "aws_lambda_permission" "authorizer" {
  statement_id  = "AllowExecutionFromAPIGateway"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.apigw2sfn_authorizer.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.apigw2sfn.execution_arn}/authorizers/${aws_apigatewayv2_authorizer.apigw2sfn.id}"
}

resource "aws_iam_role" "lambda_apigw2sfn_authorizer_role" {
  name = "lambda-apigw2sfn-authorizer-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Service = "lambda.amazonaws.com"
        }
        Action = "sts:AssumeRole"
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_apigw2sfn_authorizer_role" {
  role       = aws_iam_role.lambda_apigw2sfn_authorizer_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

curlにAuthorizationヘッダをつけてリクエストを送る

% curl -H "Authorization: secret" -XPOST -d '{"hello":"world"}' https://xxx.execute-api.ap-northeast-1.amazonaws.com/main/
2026-03-01T05:18:57.786000+00:00 2026/03/01/[$LATEST]adc7c386f6284871bec443ce118165e0 INIT_START Runtime Version: nodejs:22.v72  Runtime Version ARN: arn:aws:lambda:ap-northeast-1::runtime:d16c83c0a8a33f01d0039330bdf4f8f429ff40686e670a93d7fbf2b2d77cb783
2026-03-01T05:18:57.949000+00:00 2026/03/01/[$LATEST]adc7c386f6284871bec443ce118165e0 START RequestId: 23221b22-ee34-4a2c-ac2d-9ccde5bc0475 Version: $LATEST
2026-03-01T05:18:57.952000+00:00 2026/03/01/[$LATEST]adc7c386f6284871bec443ce118165e0 2026-03-01T05:18:57.952Z  23221b22-ee34-4a2c-ac2d-9ccde5bc0475    INFO    {
  version: '2.0',
  type: 'REQUEST',
  routeArn: 'arn:aws:execute-api:ap-northeast-1:123456789012:xxx/main/POST/',
  identitySource: [ 'secret' ],
  routeKey: 'POST /',
  rawPath: '/main/',
  rawQueryString: '',
  headers: {
    accept: '*/*',
    authorization: 'secret',
    'content-length': '17',
    'content-type': 'application/x-www-form-urlencoded',
    host: 'xxx.execute-api.ap-northeast-1.amazonaws.com',
    'user-agent': 'curl/8.7.1',
    'x-amzn-trace-id': 'Root=1-69a3cc41-6fb73814690fbe6a00877c63',
    'x-forwarded-for': 'xxx.xxx.xxx.xxx',
    'x-forwarded-port': '443',
    'x-forwarded-proto': 'https'
  },
  requestContext: {
    // ...
  }
}
2026-03-01T05:18:58.009000+00:00 2026/03/01/[$LATEST]adc7c386f6284871bec443ce118165e0 END RequestId: 23221b22-ee34-4a2c-ac2d-9ccde5bc0475
2026-03-01T05:18:58.010000+00:00 2026/03/01/[$LATEST]adc7c386f6284871bec443ce118165e0 REPORT RequestId: 23221b22-ee34-4a2c-ac2d-9ccde5bc0475    Duration: 59.51 ms  Billed Duration: 219 ms Memory Size: 128 MB Max Memory Used: 74 MB  Init Duration: 158.59 ms

isAuthorizedをfalseにするとブロックされる。

% curl -H "Authorization: secret" -XPOST -d '{"hello":"world"}' https://rthl7n1u4a.execute-api.ap-northeast-1.amazonaws.com/main/
{"message":"Forbidden"}

psqlからClaude Codeを呼び出す

psqlのpagerをteeにしておけば直前のクエリの結果がファイルに記録されるので、それをClaude Codeに食わせられるな…ということでやってみた。

$ psql -h localhost -U postgres

postgres=# \setenv PSQL_PAGER 'tee query.log';
postgres=# \pset pager always

postgres=# explain analyze WITH partition AS (
  SELECT
    i.inhrelid,
    c.relname
  FROM
    pg_catalog.pg_inherits i
    JOIN pg_catalog.pg_class c ON c.oid = i.inhparent
)
SELECT
  c.relname,
  p.relname AS partition_of
FROM
  pg_catalog.pg_class c
  JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
  LEFT JOIN LATERAL (
    SELECT relname FROM partition WHERE inhrelid = c.oid LIMIT 1
  ) AS p ON true
WHERE n.nspname = 'public'
;

postgres=# \! claude "query.logのQUERY PLANを分析して"

S3でCodeBuildの排他制御をする

CodeBuildで同時ビルド数を一つに制限したいことがあって、そういうときはConcurrent build limit1に設定して運用するようにしていた。

[Concurrent build limit] (同時ビルド制限) で、このジョブで許可される同時実行の最大数を設定します。

しかしConcurrent build limitでは制限を超えるビルドが実行されると「待ち状態」にはならずに即座にビルドが失敗する。

それが原因でCIに組み込んだCodeBuildのビルドが失敗することがしばしばあったのでS3の制御を使って「他のビルドの終了を待てる」仕組みを作ってみた。

s3lock

github.com

s3lockはS3の条件付き書き込みを使った排他制御ツールで、If-None-Match: *をつけてオブジェクトを作成することでPutObjectで上書きできないようにしている。

# URLを指定してロックを取得
$ s3lock lock s3://my-bucket/lock-object
s3://my-bucket/lock-object has been locked
create lock-object.lock

# 他のプロセスではロックを取れない
# $ s3lock lock s3://my-bucket/lock-object
# s3lock: error: lock already held

# 何か処理をやってからロックを解除
$ s3lock unlock lock-object.lock
s3://my-bucket/lock-object has been unlocked
delete lock-object.lock

lockサブコマンドには-w UINTオプションがあって、即時終了しないで指定秒数ロックの取得を待つことができる。

# すでにロックがとられていた場合、解除されるまで600秒待つ
$ s3lock lock -w 600 s3://my-bucket/lock-object

CodeBuild+s3lock

CodeBuildでs3lockを使って排他制御してみる。

buildspecはこんな感じ。

version: 0.2

phases:
  install:
    commands:
      - curl -sSfLO https://github.com/winebarrel/s3lock/releases/download/v0.1.0/s3lock_0.2.0_amd64.deb
      - dpkg -i s3lock_0.2.0_amd64.deb
  build:
    commands:
      - s3lock lock -w 600 s3://winebarrel/lock-object
      - sleep 60
    finally:
      - |
        if [ -e lock-object.lock ]; then
          s3lock unlock lock-object.lock
        fi

コマンドラインから連続してビルドを実行してみる。

$ aws codebuild start-build --project-name hello
{
    "build": {
        "buildNumber": 4,
        "startTime": "2026-02-08T10:37:12.198000+09:00",
...
$ aws codebuild start-build --project-name hello
{
    "build": {
        "buildNumber": 5,
        "startTime": "2026-02-08T10:37:13.866000+09:00",
...

buildNumber: 4では普通にロックを取得して、sleep 60してからロック解除して終了。

[Container] 2026/02/08 01:37:24.824871 Entering phase BUILD
[Container] 2026/02/08 01:37:24.826554 Running command s3lock lock -w 600 s3://winebarrel/lock-object > lock-object.lock
[Container] 2026/02/08 01:37:24.903498 Running command sleep 60
[Container] 2026/02/08 01:38:24.910839 Running command s3lock unlock lock-object.lock
[Container] 2026/02/08 01:38:25.026469 Phase complete: BUILD State: SUCCEEDED

buildNumber: 5ではs3lock lock -w 600でロックの取得を待ち、60秒たってロックを取得できてからsleepに進んでいる。

[Container] 2026/02/08 01:37:25.642655 Entering phase BUILD
[Container] 2026/02/08 01:37:25.643898 Running command s3lock lock -w 600 s3://winebarrel/lock-object > lock-object.lock
[Container] 2026/02/08 01:38:25.742380 Running command sleep 60
[Container] 2026/02/08 01:39:25.750361 Running command s3lock unlock lock-object.lock
[Container] 2026/02/08 01:39:25.881432 Phase complete: BUILD State: SUCCEEDED

最終的に同時ビルド数を1に制限しつつ、複数のビルドを正常終了することができた。

iPhone Safari用のはてブNGフィルタ機能拡張「はてブフィルター」を作った

はてなブックマークは好きだけどあまり目にしたくないエントリもあるので、iPhone/iPadのSafari用NGフィルタ機能拡張を作った。

はてブフィルター

はてブフィルター

  • Genki Sugawara
  • ユーティリティ
  • 無料
apps.apple.com

github.com

*1

ChromeFirefoxの拡張はすでにいくつか存在する。

anond.hatelabo.jp

しかし普段使っているiOSSafariは作り方がやや特殊なせいかまだなかったので自作した。 といっても基本的にはWeb Extensionsなので、Chrome拡張の作り方がそのまま流用できる。

iOSSafariの場合、ネイティブアプリにバンドルされる形で機能拡張がインストールされるのでXcodeが必要になるが、New > Project > Safari Extention Appでプロジェクトを作って各種js、manifest.jsonを修正したらほとんどの実装は完了した。

雑感

  • はてなブックマーク、ヘッダのエントリだけdivのclassが違ったり遅延して表示されるエントリがあったりと、ユーザースクリプトだけでカバーするのは結構厳しそうな感じがした
    • 遅延表示エントリについてはchrome.tabs.onUpdated.addListenerでも消せなかったので、画面を表示してから3秒間は走査しつづけるという力技で対応した
  • キーワードの保存にはchrome.storage.local.setを使っている。
    • content.jsからネイティブアプリに通信することは可能なので、SwiftData+iCloud同期で端末間の設定を共有することはできると思うがそこまでやっていない
  • iPhoneとiPadでポップアップのレイアウトが違いすぎる
    • 「divでmarginとmin-widthを設定して〜」みたいなことを久々にやった
  • デバッグがややめんどい
    • 母艦であるMac miniでWebデベロップ機能を有効にしたSafariを立ち上げてiOSSafariに接続する
    • jsファイルの編集はVSCodeでやっていたので、VSCodeXcode・母艦のSafariiOSSafariを行ったり来たりしていた
    • しかもポップアップを閉じるとポップアップ用のWeb開発コンソールは閉じてしまうので、console.logを確認するのもめんどくさかった
  • 機能拡張そのものの話ではないけれど、ネイティブアプリの画面から設定画面を開くためのURLスキームを探すのがめんどくさかった

*1:シミュレーターだとポップアップのレイアウトがおかしくなる

GolangのジェネリクスでWriterDB/ReaderDBの型を分ける

アプリケーションからDBを使うときに読み込み専用のノードを作ってトランザクションの不要なクエリや重いクエリを読み込み専用のノードに投げるようにすることがよくある。

アプリケーションの全体の情報を保持する構造体があるとして、Writerノード・Readerノードの両方へのコネクションプールを持つ場合、以下のような構造になると思う。

type App struct {
  WriterDB *sql.DB
  ReaderDB *sql.DB
}

ジェネリクスを使わないで*sql.DBの型を分ける

ReaderDBしか使わない関数に対してWriterDBを渡されたくないので、WriterDB/ReaderDBの型を分けたい。 Embeddingすると同じインターフェースを持ちながら異なる型を定義できる。

type Writer struct {
  *sql.DB
}
type Reader struct {
  *sql.DB
}
type App struct {
  WriterDB *Writer
  ReaderDB *Reader
}

// `*Writer`は受け取れない
func procForReader(db *Reader) {
    // ...
}

WriterDB/ReaderDBの両方で使うような関数がある場合は、*sql.DBと同じメソッドを持つインターフェースを定義して引数の型にすれば*Writer*Readerの両方を渡すことができる。

type DB interface {
  Begin() (*sql.Tx, error)
  BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error)
  // ...
}

// `*Writer`と`*Reader`を受け取れる
func procForDB(db DB) {
  // ...
}

ReaderDBについては、読み込み処理しか行わないのでExec()Begin()は禁止したい。

QueryXXX()しか持たないインターフェースを定義すれば一応、制限することはできる。 しかしそれだと、*Writer*Reader両方を渡すことができてしまう。

type Queryer interface {
  Query(query string, args ...any) (*sql.Rows, error)
}

// `*Writer`も受け取れてしまう
func procForReader(db Queryer) {
  // ...
}

ジェネリクスで*sql.DBの型を分ける

…という要件をここ数日悶々と考えていて、ジェネリクスを使えばできそうなのでライブラリを書いてみた。

github.com

まずマーカーにする型を定義する。 型の実態はstruct{}でもintでも何でもいい。

type WriterDB struct{}
type ReaderDB struct{}

type MyDB interface {
  WriterDB | ReaderDB
}

そのマーカーを使って*dbtyp.DB[T]を生成する。

  writer, _ := dbtyp.New2[WriterDB](sql.Open("sqlite", "file::memory:"))
  reader, _ := dbtyp.New2[ReaderDB](sql.Open("sqlite", "file::memory:"))

*dbtyp.DB[T]*sql.DBを埋め込んでいるので同じメソッドを持つ。

type DB[T any] struct {
  *sql.DB
}

*dbtyp.DB[WriterDB]*dbtyp.DB[ReaderDB]は型が違うので当然、代入はできない。

  writer = reader // COMPILE ERROR!

DBを使う関数では型パラメーターで使うDBを制限できる。

// `*dbtyp.DB[WriterDB]`は受け取れない
func procForRaeder(db *dbtyp.DB[ReaderDB]) {
  // ...
}

もし、WriterDBとReaderDBの両方を受け取りたかったら型制約MyDBを使う。

// `*dbtyp.DB[WriterDB]`と`*dbtyp.DB[ReaderDB]`を受け取れる
func procForRW[T MyDB](db *dbtyp.DB[T]) {
  // ...
}

さらに*dbtyp.DB[T]はメソッドを制限した*dbtyp.Queryer[T]を生成できる。

*dbtyp.Queryer[T]を使えば、ReaderDBに対してQueryXXX()以外のメソッドの呼び出しを禁止することができる。

func main() {
   reader, _ := dbtyp.New2[ReaderDB](sql.Open("sqlite", "file::memory:"))
   q := reader.Queryer()
   procReader(q)
}

// `*dbtyp.Queryer[WriterDB]`は受け取れない
func procReader(q *dbtyp.Queryer[ReaderDB]) {
   q.Query("select 1") // QueryXXX()しか呼び出せない
}

既存の*sql.DB*sql.Txとの相互運用も踏まえて、双方に互換性のあるインターフェースも用意してみた。

iface package - github.com/winebarrel/dbtyp/iface - Go Packages

type DB interface {
    Begin() (*sql.Tx, error)
    BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error)
    Close() error
    Conn(ctx context.Context) (*sql.Conn, error)
    Driver() driver.Driver
    Exec(query string, args ...any) (sql.Result, error)
    ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
    Ping() error
    PingContext(ctx context.Context) error
    Prepare(query string) (*sql.Stmt, error)
    PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
    Query(query string, args ...any) (*sql.Rows, error)
    QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
    QueryRow(query string, args ...any) *sql.Row
    QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
    SetConnMaxIdleTime(d time.Duration)
    SetConnMaxLifetime(d time.Duration)
    SetMaxIdleConns(n int)
    SetMaxOpenConns(n int)
    Stats() sql.DBStats
}

GolangでそこそこのJSONパーサを楽に自作する

先日、json2goというツールを作ったが、構造体のフィールドの順番がJSONと同じにならないのがいやで、なんとかならないかと調べてみた。 ObjectのanyへのUnmarshalがmap[string]anyに決め打ちされているのが原因で、ルートのObjectの型をなんとかすることはできても不定型なObjectの末端の子要素まで型を変えるのは難しそうだった。

そうなるとJSONのパーサを書くしかなさそうで「runeでデータを取り出さないと」「lexer書くのめんどくさい」「Unicodeエスケープシーケンスどうしよう」などと考えていたが、json.DecoderToken()というメソッドがあってJSONトークンを順次返してくれるので、これをlexerとしてパーサにトークンを渡せば、そこそこの品質のJSONパーサが楽にかけそうだったので 書いてみた。

github.com

パーサのライブラリには participle を使っている。

パーサ部分だけ取り出すとこんな感じ。

// lexer.go
package parser

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"

    "github.com/alecthomas/participle/v2/lexer"
)

const (
    TokenTypeDelim  lexer.TokenType = iota // '[',']','{','}'
    TokenTypeFalse                         // false
    TokenTypeNull                          // null
    TokenTypeTrue                          // true
    TokenTypeNumber                        // number
    TokenTypeString                        // string
)

var jsonSymbols = map[string]lexer.TokenType{
    "[":      TokenTypeDelim,
    "]":      TokenTypeDelim,
    "{":      TokenTypeDelim,
    "}":      TokenTypeDelim,
    "false":  TokenTypeFalse,
    "null":   TokenTypeNull,
    "true":   TokenTypeTrue,
    "number": TokenTypeNumber,
    "string": TokenTypeString,
}

type JsonDefinition struct {
}

func (l *JsonDefinition) Symbols() map[string]lexer.TokenType {
    return jsonSymbols
}

func (l *JsonDefinition) Lex(filename string, r io.Reader) (lexer.Lexer, error) {
    buf := &bytes.Buffer{}
    decoder := json.NewDecoder(io.TeeReader(r, buf))
    decoder.UseNumber()

    lex := &JsonLexer{
        decoder: decoder,
        buf:     buf,
        pos: lexer.Position{
            Filename: filename,
            Line:     1,
            Column:   1,
        },
    }

    return lex, nil
}

type JsonLexer struct {
    decoder *json.Decoder
    buf     *bytes.Buffer
    pos     lexer.Position
}

func (l *JsonLexer) Next() (lexer.Token, error) {
    startOffset := l.decoder.InputOffset()
    rawTok, err := l.decoder.Token()
    span := make([]byte, l.decoder.InputOffset()-startOffset)
    tok := lexer.Token{}

    if _, err := l.buf.Read(span); err != nil {
        return tok, err
    }

    tok.Pos = l.pos
    l.pos.Advance(string(span))

    if err == io.EOF {
        tok.Type = lexer.EOF
        return tok, nil
    } else if err != nil {
        return tok, fmt.Errorf("%d:%d: %w", tok.Pos.Line, tok.Pos.Column, err)
    }

    switch v := rawTok.(type) {
    case json.Delim:
        tok.Type = TokenTypeDelim
        tok.Value = v.String()
    case bool:
        if v {
            tok.Type = TokenTypeTrue
            tok.Value = "true"
        } else {
            tok.Type = TokenTypeFalse
            tok.Value = "false"
        }
    case nil:
        tok.Type = TokenTypeNull
        tok.Value = "null"
    case json.Number:
        tok.Type = TokenTypeNumber
        tok.Value = v.String()
    case string:
        tok.Type = TokenTypeString
        tok.Value = v
    }

    return tok, nil
}
// parser.go
package parser

import "github.com/alecthomas/participle/v2"

var (
    jsonParser = participle.MustBuild[JsonValue](
        participle.Lexer(&JsonDefinition{}),
    )
)

type JsonValue struct {
    False  *string     `parser:"@false |"`
    Null   *string     `parser:"@null |"`
    True   *string     `parser:"@true |"`
    Object *JsonObject `parser:"@@ |"`
    Array  *JsonArray  `parser:"@@ |"`
    Number *string     `parser:"@number |"`
    String *string     `parser:"@string"`
}

type JsonObject struct {
    Members []*JsonObjectMember `parser:"'{' @@* '}'"`
}

type JsonObjectMember struct {
    Key   string     `parser:"@string"`
    Value *JsonValue `parser:"@@"`
}

type JsonArray struct {
    Elements []*JsonValue `parser:"'[' @@* ']'"`
}

func ParseJSON(filename string, src []byte) (*JsonValue, error) {
    v, err := jsonParser.ParseBytes(filename, src)

    if err != nil {
        return nil, err
    }
    return v, nil
}

参考: Go言語のorderedmapパッケージを改善した - プログラムモグモグ

追記

パーサだけ別ライブラリに切り出した。

github.com

json2goの作成といくつかの学び

元旦の手隙な時間にjson2goというJSONをGoの構造体に変換するツールを書いた。

github.com

$ echo '{"foo":"bar","zoo":[1,2,3],"baz":[{"hoge":10},{"fuga":20}]}' | json2go
struct {
    Baz []struct {
        Fuga int `json:"fuga"`
        Hoge int `json:"hoge"`
    } `json:"baz"`
    Foo string `json:"foo"`
    Zoo []int  `json:"zoo"`
}

オンラインで同様のサービスを提供するサイトはすでにいくつかあるが、業務のコードをWebサイトのフォームに貼り付けたくなかったのでCLIを作成した。ただ、よくよく見たら既存のCLIもそれなりにあった。

一応、特徴としては

  • オブジェクト・配列だけでなくプリミティブ型のルート値も変換できる
  • 無名の構造体として出力される
  • 数値は.があればfloat64、なければint
  • 複数の型が混じらない配列を[]anyにしない(e.g., [1,2,3][]int
  • オブジェクトの配列を和集合の配列にする
  • 数字始まりや記号のキーも変換する
  • 変換結果をさらにコンパイルしてjson.Unmarshal()するテストをしている
  • JSONの定義通りの順番で変換する

…といったところ。

テストケースを読めば仕様がわかると思う。

いくつかの学び

最初、JSONをany型にUnmarshalしたオブジェクトを再帰的にたどっていけば簡単に作れるだろうと思っていた。 実際、その通りの実装になっているがコーナーケースや仕様決めが必要な箇所が細々とあって、小さなツールの割にそれなりに考えて実装することになった。

※仕様についてはJSON-to-Goの振る舞いを踏襲している

  • numberを一律float64にしたくない
    • json.Numberという型があって使う側でfloat64 or int64を決められる。さらに元の文字列も保持している
    • func (*Decoder) UseNumberを呼ぶと、map[string]anyへのUnmarshalでもnumberをjson.Numberに変換してくれる
  • [1, 2, 3]という配列を[]anyにしたくない
    • →すべての型をなめてから型を決定
  • [{"foo":1},{"bar":2}]を和集合に変換
    • →最初、型が完全に一致しない場合は[]anyにしていたが、キーが省略されるパターンがありそうなので []struct{ Foo int ; Bar int }になるようにした
    • 和集合を作る際にメンバーの型が異なっていてもany[]anyに丸められるようにした
  • [[1],["str"]][][]anyにしない
    • →頑張ったらできそうな気もしたが[]int[]stringは違う型なので違和感を拭えなかった
  • map[string]anyでordered mapを使う手段を見つけられなかったので、あきらめてソートするようにした
  • 数値始まりや記号のキー
    • →構造体のフィールドしてvalidな名前に変換
  • テストで変換結果をコンパイルすることでGoのコードとしてvalidなことを保証している
  • Goコードのフォーマットにはformat.Sourceを利用