AWS SDK for Go v2 でカスタムエンドポイントを使用 - MinIO へ接続

AWS SDK for Go v2 を使って S3 互換の MinIO へ接続してみました。

今回のサンプルコードは こちら

はじめに

初期化(プロジェクトの作成)を実施します。

> go mod init sample

AWS SDK for Go v2 で S3 を扱うための依存モジュールを追加します。

> go get github.com/aws/aws-sdk-go-v2
・・・
> go get github.com/aws/aws-sdk-go-v2/config
・・・
> go get github.com/aws/aws-sdk-go-v2/service/s3
・・・

カスタムエンドポイント

基本的に、AWS SDK for Go v2 では以下のような実装でカスタムエンドポイントを使用できます。

カスタムエンドポイントの適用例
resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, opts ...interface{}) (aws.Endpoint, error) {
    return aws.Endpoint{
        URL:               "http://localhost:9000", // カスタムエンドポイント
        HostnameImmutable: true,
    }, nil
})

cfg, err := config.LoadDefaultConfig(
    context.TODO(),
    config.WithEndpointResolverWithOptions(resolver),
)
・・・
svc := s3.NewFromConfig(cfg)

HostnameImmutabletrue にする事で URL で指定したエンドポイントにそのまま接続しますが、デフォルトの false だとホスト名へバケット名を付与するので注意が必要です。

例えば、カスタムエンドポイントが http://localhost:9000バケット名が sample1 の場合、HostnameImmutable が false だと http://sample1.localhost:9000 へ接続する事になります。

また、カスタムエンドポイントの代わりに aws.Endpoint{}, &aws.EndpointNotFoundError{} を返すようにすると、通常のエンドポイントを使うようになります。

下記サンプルでは、S3 への接続時に S3_ENDPOINT 環境変数が設定されていればカスタムエンドポイント(MinIO)へ接続し、そうでなければ通常のエンドポイントを使うようにしてみました。

処理としては、S3(もしくは MinIO)からオブジェクトを取得してローカルファイルへ保存します。

main.go(サンプルコード)
package main

import (
    "context"
    "io"
    "log"
    "os"

    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/s3"
)

func main() {
    endpoint := os.Getenv("S3_ENDPOINT")
    bucket := os.Getenv("BUCKET_NAME")

    key := os.Args[1]
    dest := os.Args[2]

    resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, opts ...interface{}) (aws.Endpoint, error) {

        if service == s3.ServiceID && len(endpoint) > 0 {
            // カスタムエンドポイント使用
            return aws.Endpoint{
                URL:               endpoint,
                HostnameImmutable: true,
            }, nil
        }
        // 通常のエンドポイント使用
        return aws.Endpoint{}, &aws.EndpointNotFoundError{}
    })

    cfg, err := config.LoadDefaultConfig(
        context.TODO(),
        config.WithEndpointResolverWithOptions(resolver),
    )

    if err != nil {
        log.Fatal(err)
    }

    svc := s3.NewFromConfig(cfg)
    // オブジェクト取得
    res, err := svc.GetObject(context.TODO(), &s3.GetObjectInput{
        Bucket: &bucket,
        Key:    &key,
    })

    if err != nil {
        log.Fatal(err)
    }
    // ローカルファイル作成
    fw, err := os.Create(dest)

    if err != nil {
        log.Fatal(err)
    }

    defer fw.Close()
    // ローカルファイルへ書き込み
    _, err = io.Copy(fw, res.Body)

    if err != nil {
        log.Fatal(err)
    }
}

動作確認

MinIO を起動して下記を実施しておきます。

MinIO へ接続するための環境変数を設定します。

環境変数の設定(Windows 環境の場合)
set AWS_ACCESS_KEY_ID=minioadmin
set AWS_SECRET_ACCESS_KEY=minioadmin

set BUCKET_NAME=sample1
set S3_ENDPOINT=http://localhost:9000

実行します。

実行
> go run main.go a01/item-1.jpg output.jpg

MinIO の a01/item-1.jpg を output.jpg として保存できました。

ついでに、S3_ENDPOINT 環境変数の値を空にすると https://sample1.s3.ap-northeast-1.amazonaws.com/a01/item-1.jpg へアクセスするようになりました。

Spring Native でリフレクションを使ったメソッドの取得

Java の下記インスタンスに対して、それぞれ getClass().getDeclaredMethods() する処理を Spring Native でネイティブイメージ化するとどうなるのか試してみました。

  • (a) インナークラス
  • (b) レコードクラス
  • (c) ラムダ式

結果として、(a) は特に問題なし、(b) は少し工夫が必要、(c) は今のところ無理そうでした。

今回のソースは こちら

実装

今回は Gradle でビルドし、ビルド・実行には Java 17 を使う事にします。

ビルド定義

ビルド定義ファイルは下記のようになりました。

Spring Native を適用するため org.springframework.experimental.aot プラグインを導入し、bootBuildImage のデフォルト設定を変更しネイティブイメージ化を有効にしています。

build.gradle
plugins {
    id 'org.springframework.boot' version '2.7.0'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
    id 'org.springframework.experimental.aot' version '0.12.0'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

repositories {
    maven { url 'https://repo.spring.io/release' }
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter'
    implementation 'io.projectreactor:reactor-core'
}

tasks.named('bootBuildImage') {
    builder = 'paketobuildpacks/builder:tiny'
    environment = ['BP_NATIVE_IMAGE': 'true']
}
settings.gradle
pluginManagement {
    repositories {
        maven { url 'https://repo.spring.io/release' }
        mavenCentral()
        gradlePluginPortal()
    }
}

rootProject.name = 'sample'

リフレクション処理(メソッドの取得)

(a) ~ (c) のインスタンスに対してそれぞれ getClass().getDeclaredMethods() を実施し、その結果を文字列化して出力しています。

Reactor の Flux を利用している点に関しては、ついでに試してみただけで本件との直接的な関係はありません。

Task.java
・・・
@Component
public class Task implements ApplicationRunner {
    @Override
    public void run(ApplicationArguments args) throws Exception {
        for (var s : test().toIterable()) {
            System.out.println(s);
        }
    }

    private Flux<String> test() {
        return Flux.push(sink -> {
            showMethods(sink, new Sample1());
            showMethods(sink, new Sample2("sample2", 1));
            // (c) ラムダ式
            UnaryOperator<String> sample3 = s -> "sample3-" + s;
            showMethods(sink, sample3);

            sink.complete();
        });
    }

    private <T> void showMethods(FluxSink<String> sink, T obj) {
        try {
            // メソッドの取得
            var ms = obj.getClass().getDeclaredMethods();

            if (ms.length == 0) {
                sink.next("*** WARN [ " + obj.getClass() + " ]: no methods");
                return;
            }

            for (var m : ms) {
                sink.next("[ " + obj.getClass() + " ]: " + m);
            }
        } catch (Exception ex) {
            sink.error(ex);
        }
    }
    // (a) インナークラス
    private static class Sample1 {
        public String method1(int value) {
            method2();
            return "sample1:" + value;
        }

        private void method2() {}
    }
    // (b) レコードクラス
    private record Sample2(String name, int value) {}
}

ビルドと実行

1. bootRun で実行

まずは、ネイティブイメージ化を行わずに、下記の Application クラスを使って bootRun(実行)してみます。

Application.java
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

結果は以下の通り、(a) ~ (c) のメソッド取得に成功しており問題は無さそうです。

実行結果1
$ gradle bootRun
・・・
> Task :bootRun
2022-06-19 20:13:46.492  INFO 11354 --- [           main] o.s.nativex.NativeListener               : AOT mode disabled

・・・
[ class com.example.Task$Sample1 ]: private void com.example.Task$Sample1.method2()
[ class com.example.Task$Sample1 ]: public java.lang.String com.example.Task$Sample1.method1(int)
[ class com.example.Task$Sample2 ]: public java.lang.String com.example.Task$Sample2.name()
[ class com.example.Task$Sample2 ]: public int com.example.Task$Sample2.value()
[ class com.example.Task$Sample2 ]: public final boolean com.example.Task$Sample2.equals(java.lang.Object)
[ class com.example.Task$Sample2 ]: public final java.lang.String com.example.Task$Sample2.toString()
[ class com.example.Task$Sample2 ]: public final int com.example.Task$Sample2.hashCode()
[ class com.example.Task$$Lambda$354/0x0000000800ddd330 ]: public java.lang.Object com.example.Task$$Lambda$354/0x0000000800ddd330.apply(java.lang.Object)

2. ネイティブイメージの実行(@TypeHint なし)

次に、先ほどの Application クラスをそのまま使って、bootBuildImage でネイティブイメージ化した後、docker run で実行してみます。

実行結果2
$ gradle bootBuildImage
・・・

$ docker run --rm sample:0.0.1-SNAPSHOT
2022-06-19 20:34:32.294  INFO 1 --- [           main] o.s.nativex.NativeListener               : AOT mode enabled

・・・
*** WARN [ class com.example.Task$Sample1 ]: no methods
*** WARN [ class com.example.Task$Sample2 ]: no methods
*** WARN [ class com.example.Task$$Lambda$0ea7163e8bdc44f50e7627d382ac77b02e97e33f ]: no methods

bootRun の結果とは以下のような違いが出ました。

  • (x) メソッドを一切取得できていない(リフレクションが機能していない)
  • (y) ラムダ式のクラス名が bootRun 時と違う

まず、Spring Native が使用している GraalVM では、リフレクションを許可するクラスを事前に指定する必要があり、そうしないとリフレクションが機能せず (x) のような結果になるようです。

Spring Native では @TypeHint アノテーションでリフレクションの許可を指定できるようになっています。※

 ※ org.springframework.experimental.aot プラグインでは、
 リフレクションを許可するための設定はビルド時に以下のファイルへ出力されるようになっており、
 このファイルで Spring Native のデフォルト設定や @TypeHint の結果を確認できます。

 build/generated/resources/aotMain/META-INF/native-image/org.springframework.aot/spring-aot/reflect-config.json

次に、ラムダ式はネイティブイメージ化の際に GraalVM の内部で特殊な処理を施しているようで(org.graalvm.compiler.java.LambdaUtils)、これが (y) の原因になっていると思われます。

そうすると、@TypeHint アノテーションを処理する段階でそのクラスは存在せず、通常の方法でリフレクションを許可するのは無理そうな気がします。

色々と試したり調べてみましたが、ラムダ式でリフレクションを許可する方法がどうしても分からなかったので、今回は断念しました。

3. ネイティブイメージの実行(@TypeHint あり)

@TypeHint の types(型は Class<?>[])や typeNames(型は String[])で型を指定し、access でリフレクションのどの操作を許可するかを指定できます。

今回は private なクラスを用いているため、typeNames を使いました。

Application.java
@TypeHint(typeNames = "com.example.Task$Sample1", access = QUERY_DECLARED_METHODS)
@TypeHint(typeNames = "com.example.Task$Sample2", access = { QUERY_DECLARED_METHODS, DECLARED_METHODS })
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

レコードクラス(Sample2)の方だけ QUERY_DECLARED_METHODSDECLARED_METHODS の 2つを指定していますが、これは下記ビルドエラーを回避するためにこうする必要がありました。(QUERY_DECLARED_METHODS だけだとビルドエラーが発生)

@TypeHint(typeNames = "com.example.Task$Sample2", access = QUERY_DECLARED_METHODS) とした場合のエラー例
 [creator]     Fatal error: com.oracle.svm.core.util.VMError$HostedError: com.oracle.svm.core.util.VMError$HostedError: New Method or Constructor found as reachable after static analysis: public int com.example.Task$Sample2.value()

これをネイティブイメージ化して実行した結果は以下の通りです。

とりあえず、インナークラスとレコードクラスのメソッドを取得できるようになりました。

実行結果3
$ gradle bootBuildImage
・・・

$ docker run --rm sample:0.0.1-SNAPSHOT
・・・
[ class com.example.Task$Sample1 ]: public java.lang.String com.example.Task$Sample1.method1(int)
[ class com.example.Task$Sample1 ]: private void com.example.Task$Sample1.method2()
[ class com.example.Task$Sample2 ]: public java.lang.String com.example.Task$Sample2.name()
[ class com.example.Task$Sample2 ]: public int com.example.Task$Sample2.value()
[ class com.example.Task$Sample2 ]: public final boolean com.example.Task$Sample2.equals(java.lang.Object)
[ class com.example.Task$Sample2 ]: public final java.lang.String com.example.Task$Sample2.toString()
[ class com.example.Task$Sample2 ]: public final int com.example.Task$Sample2.hashCode()
*** WARN [ class com.example.Task$$Lambda$0ea7163e8bdc44f50e7627d382ac77b02e97e33f ]: no methods

SeaORM でテーブル作成とデータ操作

githubSeaORM という Rust 用の ORM を見つけたので軽く試してみました。

sea-orm-cli というツールを使うと、既存のテーブルから Entity 定義を自動生成してくれるようですが、ここでは自前で定義した Entity を基にテーブル作成とデータ操作(INSERT, SELECT)を実施してみました。

今回のソースは こちら

1. はじめに

Cargo.toml へ依存定義を設定します。

sea-orm の features で DB のドライバーと非同期ランタイムを指定する事になります。

今回は MySQL/MariaDB へ接続するため DB ドライバーは sqlx-mysql、 非同期ランタイムは async-std で TLS の Rust 実装を用いる事にしたので runtime-async-std-rustls としています。

Cargo.toml
・・・
[dependencies]
sea-orm = { version = "0.8", features = ["sqlx-mysql", "runtime-async-std-rustls", "macros" ] }
async-std = { version = "1", features = ["attributes"] }

2. Entity 定義

ここでは、以下のようなテーブル内容を想定した Entity 定義を行います。

CREATE TABLE tasks (
  id int(10) unsigned NOT NULL AUTO_INCREMENT,
  subject varchar(255) NOT NULL,
  status enum('ready','completed') NOT NULL,
  PRIMARY KEY (id)
)

Entity 定義は DeriveEntityModel を derive した Model という名の struct を定義すれば良さそうです。

そうすると、DeriveRelation を derive した Relation enumimpl ActiveModelBehavior for ActiveModel の定義が必要となりますが、今回は特に使わないので空実装としておきます。

status カラムを DB の enum 型とするため、DeriveActiveEnum を derive した Status enum を別途定義しています。

あとは、sea_orm でテーブル名やプライマリキー、status カラムの enum 値(DB 側)等の指定を行っています。

task.rs
use sea_orm::entity::prelude::*;

#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "tasks")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: u32,
    pub subject: String,
    pub status: Status,
}

#[derive(Clone, Debug, PartialEq, EnumIter, DeriveActiveEnum)]
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "status")]
pub enum Status {
    #[sea_orm(string_value = "ready")]
    Ready,
    #[sea_orm(string_value = "completed")]
    Completed,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}

3. 処理の実装

DB 接続

Database::connect へ DB の接続文字列 mysql://user:password@host/db を渡す事で DB へ接続します。

ここでは、環境変数から DB の接続文字列を取得するようにしてみました。

let db_uri = env::var("DB_URI")?;
let db = Database::connect(db_uri).await?;

テーブル作成

create_table_from_entity で Entity 定義から Create Table 文を作成できます。

get_database_backend で取得したバックエンドの build を用いる事で、 接続する DB(今回は MySQL/MariaDB)用の Create Table 文を取得できるので execute でテーブル作成を実施します。

let backend = db.get_database_backend();
let schema = Schema::new(backend);

let st = backend.build(&schema.create_table_from_entity(Task));
db.execute(st).await?;

INSERT

INSERT する Entity データの生成に ActiveModel を使い、値を ActiveValue で設定します。

id は自動採番を使うため NotSet としています。(明示的に設定しても可)

ActiveModel の insert を呼び出す事で INSERT を実施します。

let t1 = task::ActiveModel {
    id: ActiveValue::NotSet,
    subject: ActiveValue::Set("task1".to_owned()),
    status: ActiveValue::Set(task::Status::Ready),
};

let r1 = t1.insert(&db).await?;
println!("{:?}", r1);

なお、serde_jsonActiveModel::from_json を使う事で、JSON から生成する事も可能でした。

SELECT

find() で SELECT を実施します。 all を使う事で対象となる全レコードを取得できるようです。

let rows = Task::find().all(&db).await?;
println!("{:?}", rows);

上記処理を合わせた、最終的なコードは以下のようになりました。

main.rs
mod task;

use sea_orm::*;
use std::env;

use task::Entity as Task;

type Error = Box<dyn std::error::Error>;

#[async_std::main]
async fn main() -> Result<(), Error> {
    let db_uri = env::var("DB_URI")?;
    let db = Database::connect(db_uri).await?;

    let backend = db.get_database_backend();
    let schema = Schema::new(backend);

    let st = backend.build(&schema.create_table_from_entity(Task));
    db.execute(st).await?;

    let t1 = task::ActiveModel {
        id: ActiveValue::NotSet,
        subject: ActiveValue::Set("task1".to_owned()),
        status: ActiveValue::Set(task::Status::Ready),
    };

    let r1 = t1.insert(&db).await?;
    println!("{:?}", r1);

    let t2 = task::ActiveModel {
        id: ActiveValue::NotSet,
        subject: ActiveValue::Set("task2".to_owned()),
        status: ActiveValue::Set(task::Status::Completed),
    };

    let r2 = t2.insert(&db).await?;
    println!("{:?}", r2);

    let rows = Task::find().all(&db).await?;
    println!("{:?}", rows);

    Ok(())
}

4. 動作確認

MariaDB へ DB を作成しておきます。

DB 作成
MariaDB [(none)]> CREATE DATABASE sample1;

環境変数へ DB 接続文字列を設定し、実行します。

実行
> set DB_URI=mysql://root:@localhost/sample1
> cargo run
・・・
Model { id: 1, subject: "task1", status: Ready }
Model { id: 2, subject: "task2", status: Completed }
[Model { id: 1, subject: "task1", status: Ready }, Model { id: 2, subject: "task2", status: Completed }]

正常に実行できました。

ついでに、テーブルとレコード内容を確認してみると以下のようになりました。

テーブルとレコード内容
MariaDB [sample1]> SHOW CREATE TABLE tasks \G
*************************** 1. row ***************************
       Table: tasks
Create Table: CREATE TABLE `tasks` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `subject` varchar(255) NOT NULL,
  `status` enum('ready','completed') NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=latin1
1 row in set (0.000 sec)

MariaDB [sample1]> select * from tasks;
+----+---------+-----------+
| id | subject | status    |
+----+---------+-----------+
|  1 | task1   | ready     |
|  2 | task2   | completed |
+----+---------+-----------+
2 rows in set (0.001 sec)

Dagger で Node.js アプリをビルドする

CI/CD のパイプラインを定義するためのツール Dagger を使って Node.js アプリのビルドを試してみました。

  • Dagger v0.2.7

今回使用したソースは こちら

sample1. echo の実施

まずは以下の処理を Dagger で実施してみます。

  • (1) ローカルの input.txt ファイルの内容を取得
  • (2) alpine イメージでコンテナを実行し、(1) に _echo を付けた文字列を echo して /tmp/output.txt へ出力
  • (3) コンテナの /tmp/output.txt の内容を取得し、ローカルの output.txt ファイルへ出力

Dagger プロジェクト作成

下記コマンドを実行して Dagger の実行に必要なファイルを作成しておきます。

$ dagger project init
$ dagger project update

project init で cue.mod が作成され、project update で cue.mod/pkg 内へ dagger.iouniverse.dagger.io のコアモジュールがインストールされます。

パイプライン定義

Dagger では CUE で CI/CD パイプラインを定義する事になっており、dagger.#Plan & { パイプラインの内容 } という風に記述します。※

CUE は一部 Go 言語風なので紛らわしいのですが、パワーアップした JSON のようなものだと捉えておくと理解し易いかもしれません。

 ※ 個人的に、dagger.#Plan の内容(cue.mod/pkg/dagger.io/dagger/plan.cue 参照)に対して
    { ・・・ } の内容をマージしているのだと解釈しています

パイプラインの処理は dagger.#Plan の actions で定義する事になっており、アクション名(下記の sample)は任意の名称を付けられるようです。(dagger do 時にアクション名を指定)

actions の処理はコンテナ内で実行する事になるので、ローカルのファイルや環境変数をコンテナとやりとりするために client が用意されています。

前述の (1) ~ (3) を定義すると以下のようになりました。

sample.cue
package sample1

import (
    "dagger.io/dagger"
    "dagger.io/dagger/core"
)

dagger.#Plan & {
    actions: {
        sample: {
            // (2) alpine イメージの pull
            _alpine: core.#Pull & {
                source: "alpine:3"
            }
            // (1) ローカルの input.txt ファイルの内容
            msg: client.filesystem."input.txt".read.contents
            // (2) コンテナで echo を実行し /tmp/output.txt へ出力 
            echo: core.#Exec & {
                input: _alpine.output
                args: [
                    "sh", "-c",
                    "echo -n \(msg)_echo > /tmp/output.txt",
                ]
                always: true
            }
            // (3) コンテナの /tmp/output.txt の内容を取得
            result: core.#ReadFile & {
                input: echo.output
                path: "/tmp/output.txt"
            }
        }
    }
    client: {
        filesystem: {
            // (1) ローカルの input.txt ファイルの内容を取得(ファイルの内容を取得するために string を設定)
            "input.txt": read: contents: string
            // (3) ローカルの output.txt ファイルへ出力
            "output.txt": write: contents: actions.sample.result.contents
        }
    }
}

コンテナの実行に core.#Exec、コンテナで出力したファイルの内容を取得するために core.#ReadFile を使っています。

client.filesystem.<パス>.read.contents の値を string とする事で ※、パスで指定したローカルファイルの内容を文字列として参照できます。

 ※ dagger.#FS とするとファイルシステムを参照できる

なお、前処理の output を次の処理の input に繋げていく事でパイプライン(処理の順序)を実現しているようです。

実行

それでは、実行してみます。

input.txt の内容を以下のようにしました。

input.txt
sample1

dagger do で任意のアクションを実行します。

実行例
$ dagger do sample
[✔] actions.sample                        ・・・
[✔] client.filesystem."input.txt".read    ・・・
[✔] actions.sample.echo                   ・・・
[✔] actions.sample.result                 ・・・
[✔] client.filesystem."output.txt".write  ・・・

正常に終了し、ローカルファイル output.txt が作成されたので内容を確認すると以下のようになりました。

output.txt の内容確認
$ cat output.txt
sample1_echo

sample2. Node.js アプリのビルド

それでは、本題の Node.js アプリのビルドです。

Node.js アプリ

ここでは、以下の TypeScript ファイルを esbuild を使って Node.js 用にビルドする事にします。

src/app.ts
import * as E from 'fp-ts/Either'

console.log(E.right('sample2'))

今回、esbuild による処理は package.json の scripts で定義しました。

package.json
{
  "scripts": {
    "build": "npx esbuild ./src/app.ts --bundle --platform=node --outfile=build/bundle.js"
  },
  "devDependencies": {
    "esbuild": "^0.14.37"
  },
  "dependencies": {
    "fp-ts": "^2.11.10"
  }
}

パイプライン定義

ここでは、以下の処理を実施します。

  • (1) ローカルの package.jsonsrc ディレクトリをコンテナの /app へコピー
  • (2) npm install を実行
  • (3) npm run build を実行
  • (4) コンテナの /app/build の内容をローカルの _build へ出力

sample1 ではファイル単位でローカル側とやりとりしましたが、こちらはディレクトリ単位で実施するようにしています。

client.filesystem.".".read.contentsdagger.#FS とする事でローカルのカレントディレクトリを参照し、include を使って package.jsonsrc 以外を除外しています。

ローカルのディレクトリとファイルをコンテナ環境へコピーするために core.#Copy、コンテナの /app/build の内容を参照するために core.#Subdir を使っています。

node_build.cue
package sample2

import (
    "dagger.io/dagger"
    "dagger.io/dagger/core"
)

dagger.#Plan & {
    actions: {
        build: {
            _node: core.#Pull & {
                source: "node:18-alpine"
            }
            // (1) ローカルの package.json と src ディレクトリをコンテナの /app へコピー
            src: core.#Copy & {
                input: _node.output
                contents: client.filesystem.".".read.contents
                dest: "/app"
            }
            // (2) npm install を実行(/app をカレントディレクトリに指定)
            deps: core.#Exec & {
                input: src.output
                workdir: "/app"
                args: ["npm", "install"]
                always: true
            }
            // (3) npm run build を実行(/app をカレントディレクトリに指定)
            runBuild: core.#Exec & {
                input: deps.output
                workdir: "/app"
                args: ["npm", "run", "build"]
                always: true
            }
            // (4) /app/build の内容を参照
            result: core.#Subdir & {
                input: runBuild.output
                path: "/app/build"
            }
        }
    }
    client: {
        filesystem: {
            // (1) ローカルの package.json と src を参照
            ".": read: {
                contents: dagger.#FS
                include: ["package.json", "src"]
            }
            // (4) ローカルの _build へ出力
            "_build": write: contents: actions.build.result.output
        }
    }
}

実行

dagger do で実行します。

実行例
$ dagger do build
・・・
[✔] actions.build                     ・・・
[✔] client.filesystem.".".read        ・・・
[✔] actions.build.src                 ・・・
[✔] actions.build.deps                ・・・
[✔] actions.build.runBuild            ・・・
[✔] actions.build.result              ・・・
[✔] client.filesystem."_build".write  ・・・

これにより、ローカルへ下記のファイルが出力されました。

_build/bundle.js
var __create = Object.create;
・・・
// src/app.ts
var E = __toESM(require_Either());
console.log(E.right("sample2"));

問題なく実行できました。

_build/bundle.js の動作確認例
$ cd _build
$ node bundle.js
{ _tag: 'Right', right: 'sample2' }

sqlparse で SQL から更新対象のカラムを抽出

sqlparse を使って SQL の UPDATE 文から更新対象のカラムを抽出してみます。

ソースコードこちら

はじめに

更新対象のカラムを抽出するにはパース結果のトークンの中から該当部分を探して値を取得します。

例えば、以下のような UPDATE 文をパースすると

1.sql
UPDATE ITEMS SET price = 1000 WHERE id = '1'

次のようなトークン構成となり、更新するカラムの部分は Comparison となります。

1.sql のパース結果
[<DML 'UPDATE' at 0x200357A3280>, 
 <Whitespace ' ' at 0x200357A32E0>, 
 <Identifier 'ITEMS' at 0x2003579E7A0>, 
 <Whitespace ' ' at 0x200357A33A0>, 
 <Keyword 'SET' at 0x200357A3400>, 
 <Whitespace ' ' at 0x200357A3460>, 
 <Comparison 'price ...' at 0x2003579E8F0>, 
 <Whitespace ' ' at 0x200357A36A0>, 
 <Where 'WHERE ...' at 0x2003579E730>]

また、次のように複数のカラムを更新する場合は IdentifierList (複数の Comparison を持っている)となります。

2.sql
update ITEMS as i set i.price = 200, i.updated_at = NOW(), i.rev = i.rev + 1 where id = '2'
2.sql のパース結果
[<DML 'update' at 0x1EA8A403160>, 
 <Whitespace ' ' at 0x1EA8A403280>, 
 <Identifier 'ITEMS ...' at 0x1EA8A3FEA40>, 
 <Whitespace ' ' at 0x1EA8A4034C0>, 
 <Keyword 'set' at 0x1EA8A403520>, <Whitespace ' ' at 0x1EA8A403580>, 
 <IdentifierList 'i.pric...' at 0x1EA8A3FEB90>, 
 <Whitespace ' ' at 0x1EA8A44C280>, 
 <Where 'where ...' at 0x1EA8A3FE810>]

更新カラムの抽出

それでは、更新対象のカラム(ついでにテーブル名も付与)を抽出してみます。

トークンの型を判定する事になるので、ここでは Python 3.10 でサポートされたパターンマッチを使っています。

テーブル名にエイリアスを使っていると get_name() ではエイリアスが返ってくるため、get_real_name() を使うようにしました。

sample1.py
import sqlparse
from sqlparse.sql import Identifier, IdentifierList, Comparison, Token
from sqlparse.tokens import DML

import sys

sql = sys.stdin.read()

def fields_to_update(st):
    is_update = False
    table = ''

    for t in st.tokens:
        match t:
            # UPDATE 文の場合
            case Token(ttype=tt, value=v) if tt == DML and v.upper() == 'UPDATE':
                is_update = True
            # 複数カラム更新時
            case IdentifierList() if is_update:
                for c in t.tokens:
                    match c:
                        case Comparison(left=Identifier() as l) if is_update:
                            yield f"{table}.{l.get_real_name()}"
            # テーブル名の取得
            case Identifier() if is_update:
                table = t.get_real_name()
            # 単体カラム更新時
            case Comparison(left=Identifier() as l) if is_update:
                yield f"{table}.{l.get_real_name()}"

for s in sqlparse.parse(sql):
    fields = list(fields_to_update(s))
    print(fields)

上記の冗長な部分を再帰処理に変えて改良すると以下のようになりました。

sample2.py
import sqlparse
from sqlparse.sql import Identifier, IdentifierList, Comparison, Token
from sqlparse.tokens import DML

import sys

sql = sys.stdin.read()

def fields_to_update(st):
    def process(ts, table = '', is_update = False):
        for t in ts.tokens:
            match t:
                case Token(ttype=tt, value=v) if tt == DML and v.upper() == 'UPDATE':
                    is_update = True
                case IdentifierList() if is_update:
                    yield from process(t, table, is_update)
                case Identifier() if is_update:
                    table = t.get_real_name()
                case Comparison(left=Identifier() as l) if is_update:
                    yield f"{table}.{l.get_real_name()}"
    
    yield from process(st)

for s in sqlparse.parse(sql):
    fields = list(fields_to_update(s))
    print(fields)

動作確認

以下の SQL を使って動作確認してみます。

3.sql
UPDATE ITEMS SET price = 1000 WHERE id = '1';
SELECT * FROM sample.ITEMS WHERE price > 1000;

update
  sample.ITEMS as i 
set
  i.price = 200, 
  i.updated_at = NOW(), 
  i.rev = i.rev + 1
where
  id = '2';

delete from ITEMS where price <= 0;

実行結果は以下の通りです。

sample1.py 実行結果
$ python sample1.py < 3.sql
['ITEMS.price']
[]
['ITEMS.price', 'ITEMS.updated_at', 'ITEMS.rev']
[]
sample2.py 実行結果
$ python sample2.py < 3.sql
['ITEMS.price']
[]
['ITEMS.price', 'ITEMS.updated_at', 'ITEMS.rev']
[]

juniper による GraphQL の処理を WebAssembly 化する

juniper を使った GraphQL の処理を WebAssembly 化し、Deno 用の JavaScript で実行してみました。

ソースコードhttps://github.com/fits/try_samples/tree/master/blog/20220224/

はじめに

今回は wasm-bindgenwasm-pack を使いません。(wasm-bindgen 版のサンプルは こちら

そのため、これらの便利ツールが上手くやってくれている箇所を自前で実装する必要があり、以下が課題となります。

  • 所有権による値の破棄にどう対処するか

ここでは、Box::into_raw を使う事でメモリー解放(値の破棄)の実行責任を呼び出し側(今回は JavaScript)に移す事で対処します。

(1) GraphQL 処理の WebAssembly 化

Cargo.toml は以下のように設定しました。

Cargo.toml
[package]
name = "sample"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
juniper = "0.15"

GraphQL 処理

GraphQL の処理は juniper を使って普通に実装します。 HashMap を使って id 毎に Item を管理するだけの単純な作りにしました。

src/lib.rs (GraphQL 処理部分)
use juniper::{execute_sync, EmptySubscription, FieldError, FieldResult, Variables};

use std::collections::HashMap;
use std::sync::RwLock;

#[derive(Default, Debug)]
struct Store {
    store: RwLock<HashMap<String, Item>>,
}

impl juniper::Context for Store {}

#[derive(Debug, Clone, juniper::GraphQLObject)]
struct Item {
    id: String,
    value: i32,
}

#[derive(Debug, Clone, juniper::GraphQLInputObject)]
struct CreateItem {
    id: String,
    value: i32,
}

・・・

#[derive(Debug)]
struct Query;

#[juniper::graphql_object(context = Store)]
impl Query {
    fn find(ctx: &Store, id: String) -> FieldResult<Item> {
        ・・・
    }
}

#[derive(Debug)]
struct Mutation;

#[juniper::graphql_object(context = Store)]
impl Mutation {
    fn create(ctx: &Store, input: CreateItem) -> FieldResult<Item> {
        ・・・
    }
}

type Schema = juniper::RootNode<'static, Query, Mutation, EmptySubscription<Store>>;

・・・

WebAssembly 用の処理

ここからが本題です。

基本的に、WebAssembly とランタイム(今回は JavaScript のコード)間で文字列等を直接受け渡したりはできません。

共有メモリー ※ にデータを書き込み、その位置(ポインタ)やバイトサイズをやり取りする事で文字列等の受け渡しを実施する事になります。

 ※ JavaScript 側だと WebAssembly インスタンスの exports.memory.buffer

ここでは、JavaScript からメモリー領域の確保や破棄を実施するために以下のような処理を用意しました。

  • (a) データを保持する HashMap と GraphQL のスキーマを含む Context の生成と破棄
  • (b) GraphQL の入力文字列を書き込む領域の確保と破棄
  • (c) GraphQL 処理結果(文字列)の破棄とポインタやサイズの取得 ※

なお、(a) の Context や (b) と (c) の文字列が Rust(WebAssembly)側で勝手に破棄されては困るので、そうならないように Box::into_raw を使います。

Box::into_raw で取得した raw pointer は Box::from_raw で Box へ戻して処理する事になりますが、そのままだとスコープを外れた際にデストラクタ(drop メソッド)が呼び出されて破棄されてしまうので、そうされたくない場合は再度 Box::into_raw します。

query の戻り値である *mut String 型の raw pointer は、文字列を格納している位置では無いため、(c) では文字列の格納位置(ポインタ)を _result_ptr で、そのバイトサイズを _result_size でそれぞれ取得するようにしてみました。

また、(b) では slice を使う事で、文字列の位置をやり取りできるようにしています。

ちなみに、関数名の先頭に _ を付けたりしていますが、特に意味はありません。(単なる見た目上の区別のためだけ)

これでとりあえずは動作しましたが、処理的に何か問題があるかもしれませんのでご注意ください。

src/lib.rs (WebAssembly 用の処理部分)
・・・
struct Context {
    context: Store,
    schema: Schema,
}

// (a) Context の生成
#[no_mangle]
extern fn open() -> *mut Context {
    let context = Store::default();
    let schema = Schema::new(Query, Mutation, EmptySubscription::new());

    let d = Box::new(Context{ context, schema });
    // Context の raw pointer を返す
    Box::into_raw(d)
}
// (a) Context の破棄
#[no_mangle]
extern fn close(ptr: *mut Context) {
    unsafe {
        Box::from_raw(ptr);
    }
}

// (b) GraphQL 入力文字列の領域を確保
#[no_mangle]
extern fn _new_string(size: usize) -> *mut u8 {
    let v = vec![0; size];

    Box::into_raw(v.into_boxed_slice()) as *mut u8
}
// (b) GraphQL 入力文字列の破棄
#[no_mangle]
extern fn _drop_string(ptr: *mut u8) {
    unsafe {
        Box::from_raw(ptr);
    }
}

// (c) GraphQL 結果文字列の破棄
#[no_mangle]
extern fn _drop_result(ptr: *mut String) {
    unsafe {
        Box::from_raw(ptr);
    }
}
// (c) GraphQL 結果文字列のポインタ取得
#[no_mangle]
extern fn _result_ptr(ptr: *mut String) -> *const u8 {
    unsafe {
        let s = Box::from_raw(ptr);
        let r = s.as_ptr();
        // 結果文字列を破棄させないための措置
        Box::into_raw(s);

        r
    }
}
// (c) GraphQL 結果文字列のバイトサイズ取得
#[no_mangle]
extern fn _result_size(ptr: *mut String) -> usize {
    unsafe {
        let s = Box::from_raw(ptr);
        let r = s.len();
        // 結果文字列を破棄させないための措置
        Box::into_raw(s);

        r
    }
}

// GraphQL のクエリー処理
#[no_mangle]
extern fn query(ptr: *mut Context, sptr: *const u8, len: usize) -> *mut String {
    unsafe {
        // Context の取得
        let ctx = Box::from_raw(ptr);

        // GraphQL の入力文字列を取得
        let slice = std::slice::from_raw_parts(sptr, len);
        let q = std::str::from_utf8_unchecked(slice);

        // GraphQL の処理実行
        let r = execute_sync(q, None, &ctx.schema, &Variables::new(), &ctx.context);

        // 処理結果の文字列化
        let msg = match r {
            Ok((v, _)) => format!("{}", v),
            Err(e) => format!("{}", e),
        };
        // Context を破棄させないための措置
        Box::into_raw(ctx);
        // 結果文字列の raw pointer を返す
        Box::into_raw(Box::new(msg))
    }
}

ビルド

WASI を使っていないので --target を wasm32-unknown-unknown にしてビルドします。

ビルド例
> cargo build --release --target wasm32-unknown-unknown

(2) ランタイムの実装

(1) で作成した WebAssembly を呼び出す処理を Deno 用の JavaScript で実装してみました。

run_wasm_deno.js
const wasmFile = 'target/wasm32-unknown-unknown/release/sample.wasm'
// 処理結果の取得
const toResult = (wasm, retptr) => {
    const sptr = wasm.exports._result_ptr(retptr) 
    const len = wasm.exports._result_size(retptr)

    const memory = wasm.exports.memory.buffer

    const buf = new Uint8Array(memory, sptr, len)
    // JavaScript 上で文字列化
    const res = new TextDecoder('utf-8').decode(buf)

    return JSON.parse(res)
}

const query = (wasm, ptr, q) => {
    const buf = new TextEncoder('utf-8').encode(q)
    // 入力文字列用の領域確保
    const sptr = wasm.exports._new_string(buf.length)

    try {
        // 入力文字列の書き込み
        new Uint8Array(wasm.exports.memory.buffer).set(buf, sptr)
        // GraphQL の実行
        const retptr = wasm.exports.query(ptr, sptr, buf.length)

        try {
            return toResult(wasm, retptr)
        } finally {
            // 処理結果の破棄
            wasm.exports._drop_result(retptr)
        }
    } finally {
        // 入力文字列の破棄
        wasm.exports._drop_string(sptr)
    }
}

const buf = await Deno.readFile(wasmFile)
const module = await WebAssembly.compile(buf)
// WebAssembly のインスタンス化
const wasm = await WebAssembly.instantiate(module, {})

// Context の作成
const ctxptr = wasm.exports.open()
// GraphQL を処理して結果を出力
const queryAndShow = (q) => {
    console.log( query(wasm, ctxptr, q) )
}

try {
    queryAndShow(`
        mutation {
            create(input: { id: "item-1", value: 12 }) {
                id
            }
        }
    `)

    queryAndShow(`
        mutation {
            create(input: { id: "item-2", value: 34 }) {
                id
            }
        }
    `)

    queryAndShow(`
        query {
            find(id: "item-1") {
                id
                value
            }
        }
    `)

    queryAndShow(`
        {
            find(id: "item-2") {
                id
                value
            }
        }
    `)

    queryAndShow(`
        {
            find(id: "item-3") {
                id
            }
        }
    `)
} finally {
    // Context の破棄
    wasm.exports.close(ctxptr)
}

実行

実行すると以下のような結果になりました。

実行例
> deno run --allow-read run_wasm_deno.js
{ create: { id: "item-1" } }
{ create: { id: "item-2" } }
{ find: { id: "item-1", value: 12 } }
{ find: { id: "item-2", value: 34 } }
null

docker run 時に Kuromoji をインストールして Elasticsearch を実行

下記のオフィシャルな Docker イメージだとプラグインが導入されておらず Kuromoji を使えません。

そこで、docker run 時に Kuromoji をインストールして Elasticsearch を実行してみました。

はじめに

Elasticsearch 8.0 がリリースされていますが、デフォルトで https 接続や認証が必須となっており多少面倒そうだったので、ここでは 7.17.0 を使う事にします。

なお、通常は以下のような Dockerfile を docker build して Kuromoji をインストールしたイメージを作成すれば済む話なのですが、ここではこの方法を使わず docker run 時のインストールを試します。

Dockerfile の例(Kuromoji をインストールした Elasticsearch イメージ作成)
FROM elasticsearch:7.17.0

RUN bin/elasticsearch-plugin install analysis-kuromoji

docker run 時の Kuromoji インストール

Elasticsearch 起動後に Kuromoji をインストールすると Elasticsearch の再起動が必要になってしまうので、Kuromoji のインストール後に Elasticsearch を起動させる必要があります。

とりあえずは、以下のように bash -c "bin/elasticsearch-plugin install analysis-kuromoji; docker-entrypoint.sh" コマンドを実行する事で実現できました。

docker run 実行例1

$ docker run -d --name sample1 -p 9200:9200 -e "discovery.type=single-node" elasticsearch:7.17.0 bash -c "bin/elasticsearch-plugin install analysis-kuromoji; docker-entrypoint.sh"
確認例
$ curl -s http://localhost:9200/_cat/plugins
・・・ analysis-kuromoji 7.17.0

ただし、これだと docker start 時に毎回 Kuromoji のインストールを試みてエラーログを出力するのが気になります。

docker start 時のログ内容
$ docker stop sample1
$ docker start sample1
$ docker logs sample1
・・・
-> Failed installing analysis-kuromoji
-> Rolling back analysis-kuromoji
-> Rolled back analysis-kuromoji

ERROR: plugin directory [/usr/share/elasticsearch/plugins/analysis-kuromoji] already exists; if you need to update the plugin, uninstall it first using command 'remove analysis-kuromoji'
・・・

そこで、条件分岐(プラグインが空の場合にのみ Kuromoji をインストール)を加える事でこの問題を回避してみました。

docker run 実行例2(条件分岐の追加)

$ docker run -d --name sample2 -p 9200:9200 -e "discovery.type=single-node" elasticsearch:7.17.0 bash -c "if [[ -z \$(bin/elasticsearch-plugin list) ]]; then bin/elasticsearch-plugin install analysis-kuromoji; fi; docker-entrypoint.sh"

なお、Windows PowerShell で実行する場合は、以下のように $エスケープするためにバッククォートを使う必要がありました。

PowerShell で実行する場合
> docker run -d --name sample2 -p 9200:9200 -e "discovery.type=single-node" elasticsearch:7.17.0 bash -c "if [[ -z `$(bin/elasticsearch-plugin list) ]]; then bin/elasticsearch-plugin install analysis-kuromoji; fi; docker-entrypoint.sh"