gRPC Server Reflection のクライアント処理

gRPC Server Reflection を呼び出す処理を Node.js で実装してみました。

ソースは http://github.com/fits/try_samples/tree/master/blog/20191008/

事前準備(サーバー実装)

まずは、gRPC Server Reflection を有効にしたサーバー処理を用意します。 Node.js での実装は無理そうだったので(未実装らしい) Go 言語で実装します。

protoc コマンドをインストールした後、Go 言語用の protoc プラグインである protoc-gen-go をインストールします。

protoc-gen-go インストール
> go get -u github.com/golang/protobuf/protoc-gen-go

google.golang.org/grpc 等のライブラリをビルド時に自動ダウンロードするように、Go のモジュールファイル go.mod を用意しておきます。

go.mod の作成
> go mod init sample

proto ファイルを作成してインターフェースを定義します。 今回は、go_package を使って Go 用のパッケージを別途定義してみました。

proto/item/item.proto
syntax = "proto3";

import "google/protobuf/empty.proto";

option go_package = "sample/proto/item";

package item;

message ItemRequest {
    string item_id = 1;
}

message AddItemRequest {
    string item_id = 1;
    uint64 price = 2;
}

message Item {
    string item_id = 1;
    uint64 price = 2;
}

service ItemService {
    rpc GetItem(ItemRequest) returns (Item);
    rpc AddItem(AddItemRequest) returns (google.protobuf.Empty);
}

protoc コマンドで Go 用のコードを生成します。

protoc によるコード生成
> protoc -I=proto --go_out=plugins=grpc,paths=source_relative:./proto proto/item/item.proto

サーバー処理を実装します。

Server Reflection を有効化するには google.golang.org/grpc/reflection を import して reflection.Register を適用するだけなので簡単です。

server/main.go
package main

import (
    "context"
    "fmt"
    "net"
    "log"
    "google.golang.org/grpc"
    "google.golang.org/grpc/reflection"
    empty "github.com/golang/protobuf/ptypes/empty"
    pb "sample/proto/item"
)

type Server struct {
    Items map[string]pb.Item
}

func (s *Server) GetItem(ctx context.Context, req *pb.ItemRequest) (*pb.Item, error) {
    log.Println("call GetItem: ", req)

    item, ok := s.Items[req.GetItemId()]

    if !ok {
        return nil, fmt.Errorf("item not found: %s", req.GetItemId())
    }

    return &item, nil
}

func (s *Server) AddItem(ctx context.Context, req *pb.AddItemRequest) (*empty.Empty, error) {
    log.Println("call AddItem: ", req)

    s.Items[req.GetItemId()] = pb.Item{ItemId: req.GetItemId(), Price: req.GetPrice()}

    return &empty.Empty{}, nil
}

func main() {
    address := ":50051"

    listen, err := net.Listen("tcp", address)

    if err != nil {
        log.Fatalf("error: %v", err)
    }

    s := grpc.NewServer()

    pb.RegisterItemServiceServer(s, &Server{Items: make(map[string]pb.Item)})
    // gRPC Server Reflection 有効化
    reflection.Register(s)

    log.Println("server start:", address)

    if err := s.Serve(listen); err != nil {
        log.Fatalf("failed serve: %v", err)
    }
}

上記を実行しておきます。(go run でビルドも実施されます)

初回実行時は google.golang.org/grpc 等の依存ライブラリが自動的にダウンロードされます。

実行
> go run server/main.go

・・・
2019/10/06 22:00:00 server start: :50051

サーバー側のファイル構成は以下のようになっています。

ファイル構成
  • go.mod
  • go.sum
  • proto
    • item
      • item.proto
      • item.pb.go
  • server
    • main.go

gRPC Server Reflection クライアント処理

それでは、本題の gRPC Server Reflection を呼び出す処理を実装していきます。

準備

どのように実装すべきか分からなかったので、とりあえず今回は https://github.com/grpc/grpc/blob/master/src/proto/grpc/reflection/v1alpha/reflection.proto を使う事にしました。

まずは、この proto ファイルをローカルにダウンロードしておきます。

proto ファイルのダウンロード
> curl -O https://raw.githubusercontent.com/grpc/grpc/master/src/proto/grpc/reflection/v1alpha/reflection.proto

Node.js で gRPC 処理を実装するため grpcproto-loader をインストールしておきます。

grpc と proto-loader のインストール
> npm install grpc @grpc/proto-loader

(a) サービスのリスト取得

はじめに、サービス名をリストアップする処理を実装してみます。

reflection.proto を見てみると、以下のように ServerReflectionInfo メソッドの引数である ServerReflectionRequestmessage_request にどのフィールドを指定するかで取得内容が変わるようになっています。

サービスのリストを取得するには list_services フィールドを使えば良さそうです。

reflection.proto の該当箇所
・・・
package grpc.reflection.v1alpha;

service ServerReflection {
  rpc ServerReflectionInfo(stream ServerReflectionRequest)
      returns (stream ServerReflectionResponse);
}

message ServerReflectionRequest {
  string host = 1;

  oneof message_request {
    string file_by_filename = 3;
    string file_containing_symbol = 4;
    ExtensionRequest file_containing_extension = 5;
    string all_extension_numbers_of_type = 6;
    string list_services = 7;
  }
}
・・・

ServerReflectionInfo メソッドは引数と戻り値の両方に stream が指定されている双方向ストリーミング RPC となっているため、以下のように write でリクエストメッセージを送信して on('data', ・・・) でレスポンスメッセージを取得する事になります。

また、end でストリーミング処理を終了します。

list_services.js
const grpc = require('grpc')
const protoLoader = require('@grpc/proto-loader')

const pd = protoLoader.loadSync('reflection.proto', {
    keepCase: true,
    defaults: true
})

const proto = grpc.loadPackageDefinition(pd).grpc.reflection.v1alpha

const client = new proto.ServerReflection(
    '127.0.0.1:50051', 
    grpc.credentials.createInsecure()
)

const call = client.ServerReflectionInfo()

call.on('error', err => {
    // ストリーミングの終了
    call.end()
    console.error(err)
})
// レスポンスメッセージの受信
call.on('data', res => {
    // ストリーミングの終了
    call.end()
    
    // サービス名の出力
    res.list_services_response.service.forEach(s => {
        console.log(s.name)
    })
})
// リクエストメッセージの送信
call.write({host: 'localhost', list_services: ''})

実行結果は以下の通り、サービス名を出力できました。

実行結果
> node list_services.js

grpc.reflection.v1alpha.ServerReflection
item.ItemService

(b) サービスのインターフェース定義取得

次に、サービスの内容(インターフェース定義)を取得してみます。

こちらは、リクエストメッセージの file_containing_symbol フィールドにサービス名を指定する事で取得できます。

ただ、レスポンスメッセージの該当フィールドの内容が reflection.proto のコメントにあるように FileDescriptorProtoシリアライズした結果 ※ (の配列)となっている点に注意が必要です。

 ※ bytes 型は Node.js では Buffer として扱われる
reflection.proto の該当箇所
・・・
message FileDescriptorResponse {
  // Serialized FileDescriptorProto messages. We avoid taking a dependency on
  // descriptor.proto, which uses proto2 only features, by making them opaque
  // bytes instead.
  repeated bytes file_descriptor_proto = 1;
}
・・・

FileDescriptorProto へのデシリアライズに関しては試行錯誤しましたが、最も手軽そうな protobufjs/ext/descriptorFileDescriptorProto.decode を使う事にしました。

load_symbol.js
const grpc = require('grpc')
const protoLoader = require('@grpc/proto-loader')
const descriptor = require('protobufjs/ext/descriptor')

const serviceName = process.argv[2]

・・・

const call = client.ServerReflectionInfo()

call.on('error', err => {
    call.end()
    console.error(err)
})

call.on('data', res => {
    call.end()
    
    res.file_descriptor_response.file_descriptor_proto
        // FileDescriptorProto デシリアライズ
        .map(buf => descriptor.FileDescriptorProto.decode(buf))
        .forEach(d => {
            // JSON 化して出力
            console.log(JSON.stringify(d, null, 2))
        })
})

call.write({host: 'localhost', file_containing_symbol: serviceName})

item.ItemService のサービス内容を取得してみた結果です。 go_package の内容も含め、問題なく取得できているようです。

実行結果
> node load_symbol.js item.ItemService

{
  "name": "item/item.proto",
  "package": "item",
  "dependency": [
    "google/protobuf/empty.proto"
  ],
  "messageType": [
    {
      "name": "ItemRequest",
      "field": [
        {
          "name": "item_id",
          "number": 1,
          "label": "LABEL_OPTIONAL",
          "type": "TYPE_STRING",
          "jsonName": "itemId"
        }
      ]
    },
    {
      "name": "AddItemRequest",
      "field": [
        {
          "name": "item_id",
          "number": 1,
          "label": "LABEL_OPTIONAL",
          "type": "TYPE_STRING",
          "jsonName": "itemId"
        },
        {
          "name": "price",
          "number": 2,
          "label": "LABEL_OPTIONAL",
          "type": "TYPE_UINT64",
          "jsonName": "price"
        }
      ]
    },
    {
      "name": "Item",
      "field": [
        {
          "name": "item_id",
          "number": 1,
          "label": "LABEL_OPTIONAL",
          "type": "TYPE_STRING",
          "jsonName": "itemId"
        },
        {
          "name": "price",
          "number": 2,
          "label": "LABEL_OPTIONAL",
          "type": "TYPE_UINT64",
          "jsonName": "price"
        }
      ]
    }
  ],
  "service": [
    {
      "name": "ItemService",
      "method": [
        {
          "name": "GetItem",
          "inputType": ".item.ItemRequest",
          "outputType": ".item.Item"
        },
        {
          "name": "AddItem",
          "inputType": ".item.AddItemRequest",
          "outputType": ".google.protobuf.Empty"
        }
      ]
    }
  ],
  "options": {
    "goPackage": "sample/proto/item"
  },
  "syntax": "proto3"
}

(c) 全サービスのインターフェース定義取得

最後に、全サービスのインターフェース定義を取得する処理を実装してみます。

双方向ストリーミング RPC を活用して、サービスのリスト取得とサービス内容の取得を同じストリーミング上で行ってみました。

load_symbols.js
const grpc = require('grpc')
const protoLoader = require('@grpc/proto-loader')
const descriptor = require('protobufjs/ext/descriptor')

・・・

const call = client.ServerReflectionInfo()

call.on('error', err => {
    call.end()
    console.error(err)
})

let count = 0

call.on('data', res => {
    // サービスのリスト取得時の処理
    if (res.list_services_response) {
        const names = res.list_services_response.service.map(s => s.name)

        count = names.length

        names.forEach(name => 
            call.write({host: 'localhost', file_containing_symbol: name})
        )
    }
    // サービスのインターフェース定義取得時の処理
    else if (res.file_descriptor_response) {
        if (--count == 0) {
            // インターフェース定義を全て取得したら終了
            call.end()
        }

        res.file_descriptor_response.file_descriptor_proto
            .map(buf => descriptor.FileDescriptorProto.decode(buf))
            .forEach(d => {
                console.log(JSON.stringify(d, null, 2))
            })
    }
    else {
        console.log(res)
        call.end()
    }
})

call.write({host: 'localhost', list_services: ''})

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

実行結果
> node load_symbols.js

{
  "name": "grpc_reflection_v1alpha/reflection.proto",
  "package": "grpc.reflection.v1alpha",
  ・・・
  "service": [
    {
      "name": "ServerReflection",
      "method": [
        {
          "name": "ServerReflectionInfo",
          "inputType": ".grpc.reflection.v1alpha.ServerReflectionRequest",
          "outputType": ".grpc.reflection.v1alpha.ServerReflectionResponse",
          "clientStreaming": true,
          "serverStreaming": true
        }
      ]
    }
  ],
  "syntax": "proto3"
}
{
  "name": "item/item.proto",
  "package": "item",
  "dependency": [
    "google/protobuf/empty.proto"
  ],
  ・・・
  "service": [
    {
      "name": "ItemService",
      "method": [
        {
          "name": "GetItem",
          "inputType": ".item.ItemRequest",
          "outputType": ".item.Item"
        },
        {
          "name": "AddItem",
          "inputType": ".item.AddItemRequest",
          "outputType": ".google.protobuf.Empty"
        }
      ]
    }
  ],
  "options": {
    "goPackage": "sample/proto/item"
  },
  "syntax": "proto3"
}

備考

別の実装例として、処理毎に個別のストリーミングで処理するようにして Promise 化してみました。

load_symbols2.js
const grpc = require('grpc')
const protoLoader = require('@grpc/proto-loader')
const descriptor = require('protobufjs/ext/descriptor')

・・・

const merge = a => b => Object.fromEntries([a, b].map(Object.entries).flat())

const serverReflectionInfo = (f, g) => new Promise((resolve, revoke) => {
    const call = client.ServerReflectionInfo()

    call.on('error', err => {
        call.end()
        revoke(err)
    })

    call.on('data', res => {
        call.end()
        resolve( g(res) )
    })

    call.write( f({host: 'localhost'}) )
})
// サービスのリスト取得
const listServices = () => serverReflectionInfo(
    merge({list_services: ''}),
    res => res.list_services_response.service.map(s => s.name)
)
// サービスのインターフェース定義取得
const loadSymbol = name => serverReflectionInfo(
    merge({file_containing_symbol: name}),
    res => res.file_descriptor_response.file_descriptor_proto
                .map(buf => descriptor.FileDescriptorProto.decode(buf))
)

listServices()
    .then(names => 
        Promise.all(names.map(loadSymbol))
    )
    .then(ds => ds.flat())
    .then(ds => ds.forEach(d => console.log(JSON.stringify(d, null, 2))))
    .catch(err => console.error(err))
実行結果
> node load_symbols2.js

{
  "name": "grpc_reflection_v1alpha/reflection.proto",
  "package": "grpc.reflection.v1alpha",
  ・・・
  "service": [
    {
      "name": "ServerReflection",
      "method": [
        {
          "name": "ServerReflectionInfo",
          "inputType": ".grpc.reflection.v1alpha.ServerReflectionRequest",
          "outputType": ".grpc.reflection.v1alpha.ServerReflectionResponse",
          "clientStreaming": true,
          "serverStreaming": true
        }
      ]
    }
  ],
  "syntax": "proto3"
}
{
  "name": "item/item.proto",
  "package": "item",
  "dependency": [
    "google/protobuf/empty.proto"
  ],
  ・・・
  "service": [
    {
      "name": "ItemService",
      "method": [
        {
          "name": "GetItem",
          "inputType": ".item.ItemRequest",
          "outputType": ".item.Item"
        },
        {
          "name": "AddItem",
          "inputType": ".item.AddItemRequest",
          "outputType": ".google.protobuf.Empty"
        }
      ]
    }
  ],
  "options": {
    "goPackage": "sample/proto/item"
  },
  "syntax": "proto3"
}

Pulumi を使って Kubernetes へ CRD を登録

PulumiJavaScriptPython・Go のようなプログラミング言語で Infrastructure as Code するためのツールです。

今回は、この Pulumi を使って Kubernetes(k3s を使用)へカスタムリソースを登録してみます。

ソースは http://github.com/fits/try_samples/tree/master/blog/20190825/

はじめに

今回は、k3s(Lightweight Kubernetes) がインストール済みの Ubuntu 環境を使います。※

 ※ ただし、k3s の前に microk8s をインストールしたりしているので
    クリーンな環境とは言えないかもしれない
    (Istio と Knative のために Helm 等をインストールしていたりもする)

Pulumi を以下のようにインストールします。

Pulumi インストール例
$ curl -fsSL https://get.pulumi.com | sh

$HOME/.pulumi ディレクトリへ各種ファイルが展開され、.bashrc ファイルへ PATH の設定が追加されました。

なお、このままだと pulumi コマンド実行時に kubeconfig を参照できないようだったので、とりあえず /etc/rancher/k3s/k3s.yaml$HOME/.kube/config ファイルとしてコピーし chown しています。

動作確認
$ pulumi version

v0.17.28

Kubernetes へ CRD を登録(Node.js 使用)

準備

プロジェクトを作成する前に、まずは Pulumi でログインを実施しておきます。

今回はローカル環境の Kubernetes を使うので --local を指定して login を実施しました。

ログイン
$ pulumi login --local

プロジェクト作成

適当なディレクトリを用意し、テンプレートを指定してプロジェクトのひな型を作成します。

今回は Kubernetes を対象とした Node.js 用のプロジェクトを作成するため kubernetes-javascript を指定しました。

プロジェクト作成
$ mkdir sample
$ cd sample
$ pulumi new kubernetes-javascript

・・・
project name: (sample)
project description: (A minimal Kubernetes JavaScript Pulumi program)
Created project 'sample'

stack name: (dev)
Enter your passphrase to protect config/secrets:
Re-enter your passphrase to confirm:
Created stack 'dev'

Enter your passphrase to unlock config/secrets
    (set PULUMI_CONFIG_PASSPHRASE to remember):
Installing dependencies...
・・・

実装

上記で生成された index.js ファイルに Kubernetes へ登録する内容を実装していきます。

今回は下記のようなカスタムリソースの登録を実装します。

例. カスタムリソース登録内容
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  name: items.example.com
spec:
  group: example.com
  version: v1alpha1
  scope: Namespaced
  names:
    kind: Item
    plural: items
    singular: item
  preserveUnknownFields: false
  validation:
    openAPIV3Schema:
      type: object
      properties:
        spec:
          type: object
          properties:
            value:
              type: integer
            note:
              type: string

---

apiVersion: "example.com/v1alpha1"
kind: Item
metadata:
  name: item1
spec:
  value: 100
  note: sample item 1

---

apiVersion: "example.com/v1alpha1"
kind: Item
metadata:
  name: item2
spec:
  value: 20
  note: sample item 2

上記のカスタムリソースを実装したコードが以下です。

Pulumi でカスタムリソースを定義する場合、@pulumi/kubernetesCustomResourceDefinitionCustomResource を使えば良さそうです。

YAML と同等の内容を JavaScript の Object で表現して、CustomResourceDefinition 等のコンストラクタの第 2引数として渡すだけです。

index.js (カスタムリソース登録内容の実装)
'use strict'
const k8s = require('@pulumi/kubernetes')

const capitalize = s => `${s[0].toUpperCase()}${s.slice(1)}`

const crdName = 'item'
const crdGroup = 'example.com'
const crdVersion = 'v1alpha1'

const props = {
    value: 'integer',
    note: 'string'
}

const items = [
    { name: 'item1', value: 100, note: 'sample item 1' },
    { name: 'item2', value:  20, note: 'sample item 2' }
]

const crdKind = capitalize(crdName)
const crdPlural = `${crdName}s`

new k8s.apiextensions.v1beta1.CustomResourceDefinition(crdName, {
    metadata: { name: `${crdPlural}.${crdGroup}` },
    spec: {
        group: crdGroup,
        version: crdVersion,
        scope: 'Namespaced',
        names: {
            kind: crdKind,
            plural: crdPlural,
            singular: crdName
        },
        preserveUnknownFields: false,
        validation: {
            openAPIV3Schema: {
                type: 'object',
                properties: {
                    spec: {
                        type: 'object',
                        properties: Object.fromEntries(
                            Object.entries(props).map(([k, v]) => 
                                [k, { type: v }]
                            )
                        )
                    }
                }
            }
        }
    }
})

items.forEach(it => 
    new k8s.apiextensions.CustomResource(it.name, {
        apiVersion: `${crdGroup}/${crdVersion}`,
        kind: crdKind,
        metadata: {
            name: it.name
        },
        spec: Object.fromEntries(
            Object.keys(props).map(k => [k, it[k]])
        )
    })
)

デプロイ

pulumi up でデプロイします。

デプロイ
$ pulumi up

・・・
Previewing update (dev):

     Type                                                         Name        P
 +   pulumi:pulumi:Stack                                          sample-dev  c
 +   tq kubernetes:example.com:Item                               item2       c
 +   tq kubernetes:example.com:Item                               item1       c
 +   mq kubernetes:apiextensions.k8s.io:CustomResourceDefinition  item        c

Resources:
    + 4 to create

Do you want to perform this update?
  yes
> no
  details

Do you want to perform this update?yes を選択すると実際にデプロイが実施されます。

Do you want to perform this update? yes
Updating (dev):

     Type                                                         Name        S
 +   pulumi:pulumi:Stack                                          sample-dev  c
 +   tq kubernetes:apiextensions.k8s.io:CustomResourceDefinition  item        c
 +   tq kubernetes:example.com:Item                               item1       c
 +   mq kubernetes:example.com:Item                               item2       c

Resources:
    + 4 created
・・・

ちなみに、details を選ぶと登録内容(YAML)を確認できます。

正常に登録されたか、kubectl コマンドで確認してみます。(k3s の一般的な環境では k3s kubectl とする必要があるかもしれません)

CRD の確認
$ kubectl get crd | grep items

items.example.com                                    2019-08-14T08:57:22Z
カスタムリソースの確認
$ kubectl get item

NAME    AGE
item1   13m
item2   13m
カスタムリソース詳細1
$ kubectl describe item item1

Name:         item1
Namespace:    default
Labels:       app.kubernetes.io/managed-by=pulumi
Annotations:  kubectl.kubernetes.io/last-applied-configuration:
                {"apiVersion":"example.com/v1alpha1","kind":"Item","metadata":{"labels":{"app.kubernetes.io/managed-by":"pulumi"},"name":"item1"},"spec":{...
API Version:  example.com/v1alpha1
Kind:         Item
Metadata:
  Creation Timestamp:  2019-08-14T08:57:22Z
  Generation:          1
  Resource Version:    19763
  Self Link:           /apis/example.com/v1alpha1/namespaces/default/items/item1
  UID:                 87503274-be71-11e9-aeea-025c19d6acb9
Spec:
  Note:   sample item 1
  Value:  100
Events:   <none>
カスタムリソース詳細2
$ kubectl describe item item2

Name:         item2
Namespace:    default
Labels:       app.kubernetes.io/managed-by=pulumi
Annotations:  kubectl.kubernetes.io/last-applied-configuration:
                {"apiVersion":"example.com/v1alpha1","kind":"Item","metadata":{"labels":{"app.kubernetes.io/managed-by":"pulumi"},"name":"item2"},"spec":{...
API Version:  example.com/v1alpha1
Kind:         Item
Metadata:
  Creation Timestamp:  2019-08-14T08:57:22Z
  Generation:          1
  Resource Version:    19764
  Self Link:           /apis/example.com/v1alpha1/namespaces/default/items/item2
  UID:                 875af773-be71-11e9-aeea-025c19d6acb9
Spec:
  Note:   sample item 2
  Value:  20
Events:   <none>

問題なく登録できているようです。

アンデプロイ

デプロイ内容を削除(アンデプロイ)する場合は destroy を実行します。

アンデプロイ
$ pulumi destroy

・・・
Do you want to perform this destroy? yes
Destroying (dev):

     Type                                                         Name        S
 -   pulumi:pulumi:Stack                                          sample-dev  d
 -   tq kubernetes:apiextensions.k8s.io:CustomResourceDefinition  item        d
 -   tq kubernetes:example.com:Item                               item2       d
 -   mq kubernetes:example.com:Item                               item1       d

Resources:
    - 4 deleted
・・・

Jsonnet で YAML を生成

JsonnetJSON を拡張したような DSL で、変数や関数を定義できたり内包表記が使えたりと、それなりに便利になっています。

JSON 以外にも YAML や INI ファイルなども生成できるようになっているようなので、YAML の生成を試してみました。

なお、Jsonnet の事は Kubernetesksonnet で知ったのですが、KubernetesYAML に関しては JavaScriptPython 等のプログラミング言語を使う Pulumi の方が(現時点では)良さそうな気がしています。

はじめに

今回は Jsonnet ファイルの処理に Go 言語で実装された下記ツールを使います。

インストールは go get するだけです。

インストール
> go get github.com/google/go-jsonnet/cmd/jsonnet

これで jsonnet コマンドが使えるようになります。

> jsonnet --version

Jsonnet commandline interpreter v0.13.0

JSON 生成

まずは JSON を生成してみます。 以下のような機能を使った jsonnet ファイルを用意してみました。

  • local で変数・関数を定義
  • self でカレントのオブジェクトを参照
  • $ でルートのオブジェクトを参照
  • std.関数名 で標準ライブラリの関数を呼び出す
  • []{} の内包表記
sample.jsonnet
local items = [ { id: i, name: 'item-' + i } for i in std.range(1, 3) ];
local last(xs) = xs[std.length(xs) - 1];

{
    base_items: items,
    products: [ { name: it.name, qty: 1 } for it in items ],
    first: self.products[0],
    last: last(self.products),
    ref: {
        local ps = $['products'],
        products_md5: { [p.name]: std.md5(p.name) for p in ps }
    },
}

jsonnet コマンドで JSON 変換すると以下のようになります。 要素の並びは名前順にソートされるようです。

JSON 生成結果
> jsonnet sample.jsonnet

{
   "base_items": [
      {
         "id": 1,
         "name": "item-1"
      },
      {
         "id": 2,
         "name": "item-2"
      },
      {
         "id": 3,
         "name": "item-3"
      }
   ],
   "first": {
      "name": "item-1",
      "qty": 1
   },
   "last": {
      "name": "item-3",
      "qty": 1
   },
   "products": [
      {
         "name": "item-1",
         "qty": 1
      },
      {
         "name": "item-2",
         "qty": 1
      },
      {
         "name": "item-3",
         "qty": 1
      }
   ],
   "ref": {
      "products_md5": {
         "item-1": "761ff52b8e6dd373fdf291a1a70df20c",
         "item-2": "38d3f385ea8d8bb2dcfc759ac85af6ef",
         "item-3": "65910a0fd76f69a08e6faa142384f327"
      }
   }
}

YAML 生成

YAML で出力したい場合は std.manifestYamlDoc() を使うだけです。

sample_yaml.jsonnet
local items = [ { id: i, name: 'item-' + i } for i in std.range(1, 3) ];
local last(xs) = xs[std.length(xs) - 1];

std.manifestYamlDoc({
    base_items: items,
    products: [ { name: it.name, qty: 1 } for it in items ],
    first: self.products[0],
    last: last(self.products),
    ref: {
        local ps = $['products'],
        products_md5: { [p.name]: std.md5(p.name) for p in ps }
    },
})

YAML を出力する場合は、-S オプションを指定して jsonnet コマンドを実行します。

YAML 生成結果
> jsonnet -S sample_yaml.jsonnet

"base_items":
- "id": 1
  "name": "item-1"
- "id": 2
  "name": "item-2"
- "id": 3
  "name": "item-3"
"first":
  "name": "item-1"
  "qty": 1
"last":
  "name": "item-3"
  "qty": 1
"products":
- "name": "item-1"
  "qty": 1
- "name": "item-2"
  "qty": 1
- "name": "item-3"
  "qty": 1
"ref":
  "products_md5":
    "item-1": "761ff52b8e6dd373fdf291a1a70df20c"
    "item-2": "38d3f385ea8d8bb2dcfc759ac85af6ef"
    "item-3": "65910a0fd76f69a08e6faa142384f327"

-S オプションを指定しないとエスケープされた文字列として出力されてしまいます。

YAML 生成結果(-S を指定しなかった場合)
> jsonnet sample_yaml.jsonnet

"\"base_items\":\n- \"id\": 1\n  \"name\": \"item-1\"\n- \"id\": 2\n  \"name\": \"item-2\"\n- \"id\": 3\n  \"name\": \"item-3\"\n\"first\":\n  \"name\": \"item-1\"\n  \"qty\": 1\n\"last\":\n  \"name\": \"item-3\"\n  \"qty\": 1\n\"products\":\n- \"name\": \"item-1\"\n  \"qty\": 1\n- \"name\": \"item-2\"\n  \"qty\": 1\n- \"name\": \"item-3\"\n  \"qty\": 1\n\"ref\":\n  \"products_md5\":\n    \"item-1\": \"761ff52b8e6dd373fdf291a1a70df20c\"\n    \"item-2\": \"38d3f385ea8d8bb2dcfc759ac85af6ef\"\n    \"item-3\": \"65910a0fd76f69a08e6faa142384f327\""

多腕バンディット問題とトンプソンサンプリング

多腕バンディット問題に対してベイズ的な手法をとるトンプソンサンプリングに興味を惹かれたので、「テストの実行 - C# を使用した Thompson Sampling」 を参考に Python で実装してみました。

ソースは http://github.com/fits/try_samples/tree/master/blog/20190603/

実装

ここで実装するのは、当たった場合の報酬を 1、外れた場合の報酬を 0 とするベルヌーイ試行によるバンディット問題です。

この場合、ベータ分布を 当たり数 + 1外れ数 + 1 のパラメータで ※ サンプリングした結果からアクションを選ぶだけのようです。

 ※ +1 するのは 0 にしないための措置だと思う

引数 arms でアクションの選択肢とその当たる(報酬が得られる)確率を渡すようにしています。

import numpy as np

def thompson_sampling(arms, n = 1000):
    states = [(0, 0) for _ in arms]

    # "当たり数 + 1" と "外れ数 + 1" でベータ分布からサンプリングし
    # アクションを決定する処理
    action = lambda: np.argmax([np.random.beta(s[0] + 1, s[1] + 1) for s in states])

    for _ in range(n):
        # アクションの決定
        a = action()
        # 当たり・外れの判定(報酬の算出)
        r = 1 if np.random.rand() < arms[a] else 0

        # 当たり・外れ数の更新
        states[a] = (states[a][0] + r, states[a][1] + 1 - r)

    return states

上記の結果を出力する処理は以下です。

def summary(states):
    for s in states:
        print(f'win={s[0]}, lose={s[1]}, p={s[0] / sum(s)}')

実行

アクションと報酬の得られる確率が以下のような構成に対して、試行回数 1,000 で実行してみます。

  • (a) 0.2
  • (b) 0.5
  • (c) 0.7
実行例1
summary( thompson_sampling([0.2, 0.5, 0.7]) )
win=2, lose=7, p=0.2222222222222222
win=7, lose=12, p=0.3684210526315789
win=673, lose=299, p=0.6923868312757202
実行例2
summary( thompson_sampling([0.2, 0.5, 0.7]) )
win=1, lose=7, p=0.125
win=11, lose=13, p=0.4583333333333333
win=690, lose=278, p=0.7128099173553719

実行の度に結果は異なりますが、アクションが選択された回数(win と lose の合計)に注目してみると、報酬の得られる確率が最も高い 3番目 (c) に集中している事が分かります。

つまり、最も報酬の得られる(確率の高い)アクションを選んでいる事になります。

実装2

報酬の得られる確率を thompson_sampling 関数の引数として与える上記の実装は微妙な気がするので、少し改良してみます。

確率を直接与える代わりに、アクションの選択肢と報酬を算出する関数を引数として与えるようにしてみました。

import numpy as np

def thompson_sampling(acts, reward_func, n = 1000):
    states = {a: (0, 0) for a in acts}
    
    def action():
        bs = {a: np.random.beta(s[0] + 1, s[1] + 1) for a, s in states.items()}
        return max(bs, key = bs.get)
    
    for _ in range(n):
        a = action()
        r = reward_func(a)
        
        states[a] = (states[a][0] + r, states[a][1] + 1 - r)
    
    return states

def probability_reward_func(probs):
    return lambda a: 1 if np.random.rand() < probs[a] else 0

def summary(states):
    for a, s in states.items():
        print(f'{a}: win={s[0]}, lose={s[1]}, p={s[0] / sum(s)}')
実行例1
probs1 = { 'a': 0.2, 'b': 0.5, 'c': 0.7 }

summary( thompson_sampling(probs1.keys(), probability_reward_func(probs1)) )
a: win=1, lose=7, p=0.125
b: win=17, lose=19, p=0.4722222222222222
c: win=674, lose=282, p=0.7050209205020921
実行例2
probs2 = { 'a': 0.2, 'b': 0.5, 'c': 0.7, 'd': 0.1, 'e': 0.8 }

summary( thompson_sampling(probs2.keys(), probability_reward_func(probs2)) )
a: win=4, lose=7, p=0.36363636363636365
b: win=10, lose=10, p=0.5
c: win=60, lose=25, p=0.7058823529411765
d: win=0, lose=4, p=0.0
e: win=701, lose=179, p=0.7965909090909091

Metabase における週初めは日曜

Metabase を試していたところ、以下の点が気になりました。

  • 週単位で集計すると週初めが日曜になる(日曜から土曜までの集計)
(画面例)

f:id:fits:20190525210517p:plain

DB 等、一般的なシステムにおける週初めは月曜になる(ISO 8601)はずなので、Metabase が日曜へ変えているのは確実。

そこで、「SQLを見る」をクリックして SQL の内容を確認してみると、やはり日曜へ変える(週初めの月曜 - 1日)ようになっていました。(接続先 DB は PostgreSQL

クエリビルダーで生成された SQL
SELECT (date_trunc('week', CAST((CAST("public"."stock_move"."date" AS timestamp) + INTERVAL '1 day') AS timestamp)) - INTERVAL '1 day') AS "date", sum("public"."stock_move"."product_qty") AS "sum"
FROM "public"."stock_move"
GROUP BY (date_trunc('week', CAST((CAST("public"."stock_move"."date" AS timestamp) + INTERVAL '1 day') AS timestamp)) - INTERVAL '1 day')
ORDER BY (date_trunc('week', CAST((CAST("public"."stock_move"."date" AS timestamp) + INTERVAL '1 day') AS timestamp)) - INTERVAL '1 day') ASC

特に設定も見当たらないので(タイムゾーンや言語を設定しても無駄だった)、該当箇所の ソース を見てみると、日曜へ変える事しか考慮していない事が判明。

src/matabase/driver/postgres.clj
・・・

(defmethod sql.qp/date [:postgres :week]            [_ _ expr] (hx/- (date-trunc :week (hx/+ (hx/->timestamp expr)
                                                                                             one-day))
                                                                     one-day))

・・・

(現時点では)週初めを月曜へ変えるには Metabase のソースを書き換える事になりそうですが、日曜を前提に作られている点が懸念されます。

また、Allow organizations to determine the start of their week #1779 などを見る限り、Metabase 側の対応に期待するのも厳しそうです。

PostgreSQL 検索時の週初めを月曜へ変更

試しに、Java の Instrumentation 機能を利用し、PostgreSQL 検索時に週初めが月曜となるようにしてみます。

Clojure で実装された該当処理(上記 postgres.clj の処理内容)が Java 上でどのように処理されるのか調べたところ、以下のようになっていました。

  • metabase.driver.postgres__init クラスの static initializer 実行時に metabase.driver.sql.query-processor 名前空間に属する date 変数の rawRoot に [:postgres :week] をキーにして addMethod している

つまり、この処理が終わった後で [:postgres :week] の処理を差し替えれば何とかなりそうです。

実装

ソースは http://github.com/fits/try_samples/tree/master/blog/20190525/

postgres__init クラスの初期化後に処理を実施したいので、ClassFileTransformer を使って任意のクラスのロード時に処理を差し込めるようにしました。(クラスのロード時に transform メソッドが呼ばれる)

ここでは org/postgresql/Driver クラスのロード時に処理するようにしましたが、postgres__init の初期化が済んでいればどのタイミングでも問題ないと思います。

差し替え後の処理 PgWeekFuncClojure のコードで (date-trunc :week expr) を実施するように実装しています。

sample/SampleAgent.java
package sample;

import java.lang.instrument.*;
import java.security.*;
import clojure.lang.*;

public class SampleAgent {
    public static void premain(String agentArgs, Instrumentation inst) {
        inst.addTransformer(new PgWeekTransformer());
    }

    static class PgWeekTransformer implements ClassFileTransformer {
        public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
            // org/postgresql/Driver クラスのロード時に実施
            if (className.equals("org/postgresql/Driver")) {
                replacePgWeek();
            }

            return null;
        }

        // [:postgres :week] の処理を置き換えるための処理
        private void replacePgWeek() {
            // metabase.driver.sql.query-processor 名前空間の取得
            Namespace n = Namespace.find(
                Symbol.intern("metabase.driver.sql.query-processor")
            );

            // date 変数の取得
            Var v = n.findInternedVar(Symbol.intern("date"));

            MultiFn root = (MultiFn)v.getRawRoot();

            // キー [:postgres :week] の作成
            IPersistentVector k = Tuple.create(
                RT.keyword(null, "postgres"), 
                RT.keyword(null, "week")
            );

            // [:postgres :week] へ紐づいた処理を差し替え
            root.removeMethod(k);
            root.addMethod(k, new PgWeekFunc());
        }
    }

    // 週の処理関数を定義
    static class PgWeekFunc extends AFunction {
        public static final Var const__1 = RT.var(
            "metabase.driver.postgres", 
            "date-trunc"
        );

        public static final Keyword const__2 = RT.keyword(null, "week");

        public static Object invokeStatic(Object obj1, Object obj2, Object expr) {
            // (date-trunc :week expr) の実施
            return ((IFn)const__1.getRawRoot()).invoke(const__2, expr);
        }

        public Object invoke(Object obj1, Object obj2, Object obj3) {
            return invokeStatic(obj1, obj2, obj3);
        }
    }
}
META-INF/MANIFEST.MF
Manifest-Version: 1.0
Premain-Class: sample.SampleAgent

上記ソースをビルドして JAR ファイル化(例. sample-agent.jar)しておきます。

実行

Metabase の実行時に(上で作成した)sample-agent.jar を -javaagent オプションで適用します。

Metabase 実行(Instrumentation 適用)
> java -javaagent:sample-agent.jar -jar metabase.jar

SQL を確認してみると、INTERVAL '1 day' の減算等が無くなり、処理の差し替えが効いている事を確認できました。

クエリビルダーで生成された SQL(差し替え後)
SELECT date_trunc('week', CAST("public"."stock_move"."date" AS timestamp)) AS "date", sum("public"."stock_move"."product_qty") AS "sum"
FROM "public"."stock_move"
GROUP BY date_trunc('week', CAST("public"."stock_move"."date" AS timestamp))
ORDER BY date_trunc('week', CAST("public"."stock_move"."date" AS timestamp)) ASC

ついでに、サーバーからのレスポンス内容を確認してみると、日付が月曜になり集計結果が変わっている事を確認できました。

レスポンス内容(一部)
"data":{
    "rows":[
        ["2019-04-22T00:00:00.000+09:00",2139.0],
        ["2019-05-06T00:00:00.000+09:00",30.0],
        ["2019-05-13T00:00:00.000+09:00",13.0]
    ],
    "columns":["date","sum"],
    ・・・
}

ただし、Web 画面上は JavaScript が週の範囲を生成している事から、(date: Week の)表示上は日曜から土曜となってしまいます。

(画面例)

f:id:fits:20190525210556p:plain

このように、週初めを月曜へ変えるには以下のような JavaScript の処理に関しても考慮が必要になりそうです。

frontend/src/metabase/lib/formatting.js
・・・

function formatWeek(m: Moment, options: FormattingOptions = {}) {
  // force 'en' locale for now since our weeks currently always start on Sundays
  m = m.locale("en");
  return formatMajorMinor(m.format("wo"), m.format("gggg"), options);
}

・・・

Keras.js によるランドマーク検出の Web アプリケーション化2

前回 はランドマーク検出対象の画像サイズを固定(256x256)しましたが、今回は任意の画像サイズに対応できるように改造してみます。

ソースは http://github.com/fits/try_samples/tree/master/blog/20190506/

可変サイズ対応

ドラッグアンドドロップした画像のサイズに合わせてランドマーク検出を実施するようにしてみます。(ファイル構成などは 前回 と同じ)

ただ、Keras.js を通常とは異なる使い方をするため、何らかの不都合が生じるかもしれませんし、別バージョンでは動作しないかもしれません。

(a) UI 処理(src/app.js)

canvas のサイズを画像サイズに合わせて変更し、ランドマーク検出処理へ画像サイズ(幅、高さ)の情報を渡すようにします。

src/app.js
・・・
const loadImage = url => new Promise(resolve => {
    const img = new Image()

    img.addEventListener('load', () => {
        // canvas のサイズを画像サイズに合わせて変更
        canvas.width = img.width
        canvas.height = img.height

        ctx.clearRect(0, 0, canvas.width, canvas.height)

        ctx.drawImage(img, 0, 0)

        const d = ctx.getImageData(0, 0, canvas.width, canvas.height)
        resolve({width: img.width, height: img.height, data: imgToArray(d)})
    })

    img.src = url
})

・・・

const ready = () => {
    ・・・

    canvas.addEventListener('drop', ev => {
        ev.preventDefault()
        canvas.classList.remove('dragging')

        const file = ev.dataTransfer.files[0]

        if (imageTypes.includes(file.type)) {
            clearLandmarksInfo()

            const reader = new FileReader()

            reader.onload = ev => {
                loadImage(reader.result)
                    .then(d => {
                        detectDialog.showModal()
                        // 画像のサイズ情報を追加
                        worker.postMessage({type: 'predict', input: d.data, width: d.width, height: d.height})
                    })
            }

            reader.readAsDataURL(file)
        }
    }, false)
}

・・・

(b) ランドマーク検出処理(src/worker.js)

通常は(Keras.js の)モデル内でレイヤー毎の入出力の形状が固定化されているので、このままでは任意の画像サイズには対応できません。

そこで、検出処理の度に入出力の形状を強制的にリセットする処理(以下)を加える事で可変サイズに対応します。

  • (1) 入力データの形状(画像サイズ)を変更
  • (2) 各レイヤーの出力形状をクリア
  • (3) inputTensorsMap のリセット
src/worker.js
・・・

onmessage = ev => {
    switch (ev.data.type) {
        ・・・
        case 'predict':
            const inputLayerName = model.inputLayerNames[0]
            const outputLayerName = model.outputLayerNames[0]

            const w = ev.data.width
            const h = ev.data.height

            // (1) 入力データの形状(画像サイズ)を変更
            const inputLayer = model.modelLayersMap.get(inputLayerName)
            inputLayer.shape[0] = h
            inputLayer.shape[1] = w

            // (2) 各レイヤーの出力形状をクリア
            model.modelLayersMap.forEach(n => {
                if (n.outputShape) {
                    n.outputShape = null
                    n.imColsMat = null
                }
            })

            // (3) inputTensorsMap のリセット
            model.resetInputTensors()

            const data = {}
            data[inputLayerName] = ev.data.input

            Promise.resolve(model.predict(data))
                .then(r => {
                    const shape = model.modelLayersMap.get(outputLayerName)
                                                .output.tensor.shape

                    return new KerasJS.Tensor(r[outputLayerName], shape)
                })
                .then(detectLandmarks)
                .then(r => 
                    postMessage({type: ev.data.type, output: r})
                )
                .catch(err => {
                    console.log(err)
                    postMessage({type: ev.data.type, error: err.message})
                })

            break
    }
}

動作確認

(1) 画像サイズ 128x128

f:id:fits:20190506114638p:plain

(2) 画像サイズ 307x307

f:id:fits:20190506114655p:plain

(3) 画像サイズ 100x128

f:id:fits:20190506114713p:plain

(4) 画像サイズ 200x256

f:id:fits:20190506114736p:plain

SonarAnalyzer.CSharp でサイクロマティック複雑度を算出

C# ソースファイルのサイクロマティック複雑度(循環的複雑度)を算出するサンプルを SonarC# (SonarAnalyzer.CSharp)API を利用して作ってみました。

今回、使用した環境は以下の通りです。

ソースは http://github.com/fits/try_samples/tree/master/blog/20190422/

準備

dotnet コマンドを使ってプロジェクトを作成します。

プロジェクトの作成
> dotnet new console

C# のソースを構文解析する必要があるので Microsoft.CodeAnalysis.CSharp パッケージを追加します。

Microsoft.CodeAnalysis.CSharp の追加
> dotnet add package Microsoft.CodeAnalysis.CSharp

次に、SonarAnalyzer.CSharp パッケージを追加しますが、これは IDE(VisualStudio)用パッケージのようなので、単に add package してもプロジェクトで参照できるようにはなりません。(analyzers ディレクトリへ .dll が配置されているため)

そこで、以下のように指定のディレクトリへパッケージを配置し ※、.csproj を編集する事で対応してみました。

 ※ 普通に add package して .nuget/packages ディレクトリへ
    配置された dll のパスを設定する方法も考えられる
SonarAnalyzer.CSharp の追加(pkg ディレクトリへ配置)
> dotnet add package SonarAnalyzer.CSharp --package-directory pkg

上記コマンドで追加された PackageReference 要素をコメントアウトし、代わりに Reference 要素を追加します。(HintPath で SonarAnalyzer.CSharp.dll のパスを指定)

sonar_sample.csproj の編集
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp3.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.0.0" />
    <!-- 以下をコメントアウト
    <PackageReference Include="SonarAnalyzer.CSharp" Version="7.13.0.8313">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    -->
    <!-- 以下を追加 -->
    <Reference Include="SonarAnalyzer.CSharp">
      <HintPath>./pkg/sonaranalyzer.csharp/7.13.0.8313/analyzers/SonarAnalyzer.CSharp.dll</HintPath>
    </Reference>
  </ItemGroup>

</Project>

実装

C#ソースコードをパースして MethodDeclarationSyntax を取り出し、CSharpCyclomaticComplexityMetric.GetComplexity メソッドへ渡す事でサイクロマティック複雑度を算出します。

Program.cs
using System;
using System.Linq;
using System.IO;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using SonarAnalyzer.Metrics.CSharp;

namespace CyclomaticComplexity
{
    class Program
    {
        static void Main(string[] args)
        {
            using (var reader = new StreamReader(args[0]))
            {
                // ソースコードのパース
                var tree = CSharpSyntaxTree.ParseText(reader.ReadToEnd());
                var root = tree.GetCompilationUnitRoot();

                // MethodDeclarationSyntax の取得
                var methods = root.DescendantNodes()
                                    .OfType<MethodDeclarationSyntax>();

                foreach(var m in methods)
                {
                    var c = CSharpCyclomaticComplexityMetric.GetComplexity(m);

                    Console.WriteLine("{0},{1}", m.Identifier, c.Complexity);
                }
            }
        }
    }
}

実行

ビルドして実行してみます。

ビルド
> dotnet build

・・・
ビルドに成功しました。
    0 個の警告
    0 エラー

Program.cs の複雑度を算出してみます。

実行1
> dotnet run Program.cs

Main,2

SonarC# のソースで試してみます。

実行2
> cd ..
> git clone https://github.com/SonarSource/sonar-dotnet.git
・・・

> cd sonar_sample
> dotnet run ../sonar-dotnet/sonaranalyzer-dotnet/src/SonarAnalyzer.CSharp/Metrics/CSharpMetrics.cs

GetCognitiveComplexity,1
GetCyclomaticComplexity,1
IsClass,4
IsCommentTrivia,1
IsDocumentationCommentTrivia,4
IsEndOfFile,1
IsFunction,16
IsNoneToken,1
IsStatement,28