GraphQL.js の buildSchema でカスタムScalar型を使う
GraphQL.js の buildSchema
でカスタム Scalar 型を利用してみました。
サンプルコードは こちら
はじめに
GraphQL.js では Scalar 型を GraphQLScalarType
のインスタンスとして実装するようになっており、ID
(GraphQLID) や Int
(GraphQLInt) 等の基本的な型が用意されています。(src/type/scalars.ts 参照)
用意されていない型は自前で定義する事ができ、例えば以下のような日付型のカスタム Scalar(名前は Date
とする)は下記のように実装できます。
- GraphQL 上は文字列として表現し、内部的に JavaScript の Date 型を使用
実装例 - カスタム Scalar の Date 型
const GraphQLDate = new GraphQLScalarType<Date, string>({ name: 'Date', // 内部データを GraphQL 上の表現へ変換 serialize: (outputValue) => { if (outputValue instanceof Date) { return outputValue.toISOString() } throw new GraphQLError('non date') }, // GraphQL 上の値を内部データへ変換 parseValue: (inputValue) => { if (typeof inputValue === 'string') { const d = new Date(inputValue) if (isNaN(d.getTime())) { throw new GraphQLError('invalid date') } return d } throw new GraphQLError('non string value') }, // GraphQL 上の表現を内部データへ変換 parseLiteral: (valueNode) => { if (valueNode.kind === Kind.STRING) { const d = new Date(valueNode.value) if (isNaN(d.getTime())) { throw new GraphQLError('invalid date') } return d } throw new GraphQLError('non string value') } })
buildSchema 利用時
GraphQL 上はカスタム Scalar 型を以下のように定義できます。
scalar 型名
これを buildSchema
で処理すると、GraphQLScalarType のデフォルト実装が適用されてしまい、任意の処理へ差し替えたりする事はできないようでした。
そのため、現時点では buildSchema が構築した GraphQLScalarType を後から上書きするような対応が必要となりそうです。
サンプル作成
SampleDate
というカスタム Scalar 型を定義し、それを用いた処理を Deno 用の TypeScript で実装してみました。
buildSchema の結果から SampleDate の型情報を取得して、下記の処理を差し替えています。
serialize
(内部データを GraphQL 上の表現へ変換)parseValue
(GraphQL 上の値を内部データへ変換)parseLiteral
(GraphQL 上の表現を内部データへ変換)
なお、下記で parseLiteral へ variables 引数を付けているのは console.log して内容を確認するためです。
sample.ts
import { graphql, buildSchema, GraphQLError, ValueNode, Kind } from 'https://cdn.skypack.dev/graphql?dts' const schema = buildSchema(` scalar SampleDate type Query { now: SampleDate! nextDay(date: SampleDate!): SampleDate! } `) const toDate = (v: string) => { const d = new Date(v) if (isNaN(d.getTime())) { throw new GraphQLError('invalid date') } return d } type MaybeObjMap = { [key: string]: unknown } | null | undefined // buildSchema が構築した SampleDate の処理内容を差し替え Object.assign(schema.getTypeMap().SampleDate, { serialize: (outputValue: unknown) => { console.log(`*** called serialize: ${outputValue}`) if (outputValue instanceof Date) { return outputValue.toISOString() } throw new GraphQLError('non Date') }, parseValue: (inputValue: unknown) => { console.log(`*** called parseValue: ${inputValue}`) if (typeof inputValue === 'string') { return toDate(inputValue) } throw new GraphQLError('non string value') }, parseLiteral: (valueNode: ValueNode, variables?: MaybeObjMap) => { console.log(`*** called parseLiteral: ${JSON.stringify(valueNode)}, ${JSON.stringify(variables)}`) if (valueNode.kind === Kind.STRING) { return toDate(valueNode.value) } throw new GraphQLError('non string value') } }) type DateInput = { date: Date } const rootValue = { now: () => new Date(), nextDay: ({ date }: DateInput) => new Date(date.getTime() + 24 * 60 * 60 * 1000) } const r1 = await graphql({ schema, rootValue, source: '{ now }' }) console.log(r1) console.log('-----') const r2 = await graphql({ schema, rootValue, source: '{ nextDay(date: "2022-10-21T13:00:00Z") }' }) console.log(r2) console.log('-----') const r3 = await graphql({ schema, rootValue, source: ` query ($d: SampleDate!) { nextDay(date: $d) } `, variableValues: { d: '2022-10-22T14:30:00Z' } }) console.log(r3)
実行結果は下記のようになりました。
parseLiteral は variables 引数の値が undefined
と {}
の場合の 2回呼び出されています。
また、変数(variableValues
)を使った場合は parseValue が呼び出されています。
実行結果
% deno run sample.ts *** called serialize: Fri Oct 21 2022 20:20:27 GMT+0900 (日本標準時) { data: { now: "2022-10-21T11:20:27.093Z" } } ----- *** called parseLiteral: {"kind":"StringValue","value":"2022-10-21T13:00:00Z","block":false,"loc":{"start":16,"end":38}}, undefined *** called parseLiteral: {"kind":"StringValue","value":"2022-10-21T13:00:00Z","block":false,"loc":{"start":16,"end":38}}, {} *** called serialize: Sat Oct 22 2022 22:00:00 GMT+0900 (日本標準時) { data: { nextDay: "2022-10-22T13:00:00.000Z" } } ----- *** called parseValue: 2022-10-22T14:30:00Z *** called serialize: Sun Oct 23 2022 23:30:00 GMT+0900 (日本標準時) { data: { nextDay: "2022-10-23T14:30:00.000Z" } }
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)
HostnameImmutable
を true
にする事で 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_METHODS
と DECLARED_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 でテーブル作成とデータ操作
github で SeaORM という 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
enum や impl 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_json と ActiveModel::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.io
と universe.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.json
とsrc
ディレクトリをコンテナの/app
へコピー - (2)
npm install
を実行 - (3)
npm run build
を実行 - (4) コンテナの
/app/build
の内容をローカルの_build
へ出力
sample1 ではファイル単位でローカル側とやりとりしましたが、こちらはディレクトリ単位で実施するようにしています。
client.filesystem.".".read.contents
を dagger.#FS
とする事でローカルのカレントディレクトリを参照し、include
を使って package.json
と src
以外を除外しています。
ローカルのディレクトリとファイルをコンテナ環境へコピーするために 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-bindgen と wasm-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