Ramda で入れ子のオブジェクトをフラットにする

Ramda を使って入れ子になったオブジェクトをフラットにする処理を考えてみました。

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

サンプル1

まずは、以下のように入れ子になったオブジェクトを使って

{
    item: {
        name: 'item-1',
        details: {
            color: 'white',
            size: 'L'
        }
    },
    num: 5
}

下記のように変換してみます。

{ name: 'item-1', color: 'white', size: 'L', num: 5 }

方法はいろいろありそうですが、ここではオブジェクトの値部分を単一要素のオブジェクト {<項目名>: <値>} へ変換してから R.mergeAll でマージするようにしてみました。

sample1.js
const R = require('ramda')

const data = {
    item: {
        name: 'item-1',
        details: {
            color: 'white',
            size: 'L'
        }
    },
    num: 5
}

// Object の判定
const isObject = R.pipe(R.type, R.equals('Object'))

// 値部分を単一要素のオブジェクトへ変換
const valuesToObjOf = 
    R.pipe(
        R.mapObjIndexed(
            R.ifElse(
                isObject,
                v => valuesToObjOf(v), 
                R.flip(R.objOf)
            )
        ),
        R.values,
        R.flatten
    )

const flatObj = 
    R.pipe(
        valuesToObjOf,
        R.mergeAll
    )

console.log( valuesToObjOf(data) )

console.log('------')

console.log( flatObj(data) )

値部分がオブジェクトの場合は再帰的に処理するようにしていますが、その判定に R.is(Object) を使うと Array 等も true になってしまい都合が悪いので、R.type の結果が Object か否かで判定するようにしています。

実行結果
> node sample1.js

[ { name: 'item-1' }, { color: 'white' }, { size: 'L' }, { num: 5 } ]
------
{ name: 'item-1', color: 'white', size: 'L', num: 5 }

サンプル2

上記の処理だと項目名が重複した際に不都合が生じるため、項目名を連結するように valuesToObjOf を改良してみました。

sample2.js
・・・

const valuesToObjOf = (obj, prefix = '') =>
    R.pipe(
        R.mapObjIndexed(
            R.ifElse(
                isObject,
                (v, k) => valuesToObjOf(v, `${prefix}${k}_`), 
                (v, k) => R.objOf(`${prefix}${k}`, v)
            )
        ),
        R.values,
        R.flatten
    )(obj)

const flatObj = 
    R.pipe(
        valuesToObjOf,
        R.mergeAll
    )

console.log( flatObj(data) )
実行結果
> node sample2.js

{
  item_name: 'item-1',
  item_details_color: 'white',
  item_details_size: 'L',
  num: 5
}

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
・・・

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

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

前回の 「CNN でランドマーク検出」 の学習済みモデルを Keras.js を使って Web ブラウザ上で実行できるようにしてみます。

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

準備

npm で Keras.js をインストールします。

Keras.js インストール
> npm install --save keras-js

Keras.js に含まれている encoder.py スクリプトを使って、Python の Keras で学習したモデル(model/cnn_landmark_400.h5)を Keras.js 用に変換します。

モデルファイル(HDF5 形式)を Keras.js 用に変換
> python node_modules/keras-js/python/encoder.py model/cnn_landmark_400.h5

生成された .bin ファイル(model/cnn_landmark_400.bin)のパス(URL)を KerasJS.Model へ指定して使う事になります。

ついでに、webpack もインストールしておきます。(webpack コマンドを使うには webpack-cli も必要)

webpack インストール
> npm install --save-dev webpack webpack-cli

Web アプリケーション作成

今回、作成する Web アプリケーションのファイル構成は以下の通りです。

  • index.html
  • js/bundle_app.js
  • js/bundle_worker.js
  • model/cnn_landmark_400.bin

処理は全て Web ブラウザ上で実行するようにし、Keras.js の処理(今回のランドマーク検出)はそれなりに重いので Web Worker として実行します。

index.html の内容は以下の通りです。

index.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <style type="text/css">
        canvas {
            border: 1px solid;
        }

        canvas.dragging {
            border: 3px solid red;
        }

        table {
            text-align: center;
            border-collapse: collapse;
        }

        table th, table td {
            border: 1px solid;
            padding: 8px;
        }
    </style>
</head>
<body>
    <dialog id="load-dialog">loading model ...</dialog>
    <dialog id="detect-dialog">detecting landmarks ...</dialog>
    <dialog id="error-dialog">ERROR</dialog>

    <div>
        <canvas width="256" height="256"></canvas>
    </div>

    <br>

    <div>
        <div id="landmarks"></div>
    </div>

    <script src="./js/bundle_app.js"></script>
</body>
</html>

bundle_xxx.js を生成するため、以下のような webpack 設定ファイルを用意します。

fs: 'empty' の箇所は Keras.js を webpack で処理するために必要な設定で、これが無いと Module not found: Error: Can't resolve 'fs' のようなエラーが出る事になります。

webpack.config.js
module.exports = {
    entry: {
        bundle_app: __dirname + '/src/app.js',
        bundle_worker: __dirname + '/src/worker.js'
    },
    output: {
        path: __dirname + '/js',
        filename: '[name].js',
    },
    // Keras.js 用の設定
    node: {
        fs: 'empty'
    }
}

Web Worker では Actor モデルのようにメッセージパッシング(postMessage で送信、onmessage で受信)を使ってメインの UI 処理とのデータ連携を行います。

今回は、以下のようなメッセージを Web Worker(Keras.js を使った処理)とやり取りするようにします。

Web Worker(Keras.js の処理)とのメッセージ内容
処理 送信メッセージ 受信メッセージ(成功時) 受信メッセージ(エラー時)
初期化 {type: 'init', url: <モデルファイルのURL>} {type: 'init'} {type: 'init', error: <エラーメッセージ>}
ランドマーク検出 {type: 'predict', input: <入力データ>} {type: 'predict', output: <ランドマーク検出結果>} {type: 'predict', error: <エラーメッセージ>}

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

Web Worker として実装するため、postMessage で UI 処理へメッセージを送信し、onmessage でメッセージを受信するようにします。

Keras.js 1.0.3 における Dense 処理の問題

実は、Keras.js 1.0.3 では今回の CNN モデルを正しく処理できません。

というのも、Keras.js 1.0.3 における Dense の処理(GPU を使わない場合)は以下のようになっています。

node_modules/keras-js/lib/layers/core/Dense.js の問題個所
  _callCPU(x) {
    this.output = new _Tensor.default([], [this.units]);

    ・・・
  }

今回の CNN モデルでは Dense の結果が 3次元 (256, 256, 7) になる必要がありますが、上記 Dense 処理では (7) のように 1次元になってしまい正しい結果を得られません。 ※

 ※ ついでに、Keras.js の softmax 処理にも不都合な点があった

そこで、今回は(GPU を使わない事を前提に)Dense_callCPU を実行時に書き換える事で回避しました。

処理内容としては、元の処理を 2重ループ内で実施するようにしています。

Dense 問題の回避措置(src/worker.js)
import KerasJS from 'keras-js'
import { gemv } from 'ndarray-blas-level2'
import ops from 'ndarray-ops'

・・・

// Dense の _callCPU を実行時に変更
KerasJS.layers.Dense.prototype._callCPU = function(x) {
    const h = x.tensor.shape[0]
    const w = x.tensor.shape[1]

    this.output = new KerasJS.Tensor([], [h, w, this.units])

    for (let i = 0; i < h; i++) {
        for (let j = 0; j < w; j++) {

            const xt = x.tensor.pick(i, j)
            const ot = this.output.tensor.pick(i, j)

            if (this.use_bias) {
                ops.assign(ot, this.weights['bias'].tensor)
            }

            gemv(1, this.weights['kernel'].tensor.transpose(1, 0), xt, 1, ot)

            this.activationFunc({tensor: ot})
        }
    }
}

ランドマーク検出の実装

KerasJS.Modelpredict へ入力データを渡したり結果を取り出すにはレイヤー名を指定する必要があり、これらのレイヤー名は iuputLayerNamesoutputLayerNames でそれぞれ取得できます。

predict の結果は、各座標のランドマーク該当確率 (256, 256, 7) となるので、ここではランドマーク毎 ※ に最も確率の高かった座標のみを結果として返すようにしています。

 ※ ランドマーク 0 はランドマークに該当しなかった場合なので結果に含めていない
src/worker.js
import KerasJS from 'keras-js'
import { gemv } from 'ndarray-blas-level2'
import ops from 'ndarray-ops'

let model = null

// モデルデータの読み込み
const loadModel = file => {
    const model = new KerasJS.Model({ filepath: file })

    return model.ready().then(r => model)
}

// Keras.js の Dense 問題への対応
KerasJS.layers.Dense.prototype._callCPU = function(x) {
    ・・・
}

// predict の結果を処理(ランドマーク毎に最も確率の高い座標を抽出)
const detectLandmarks = ts => {
    const res = {}

    for (let h = 0; h < ts.tensor.shape[0]; h++) {
        for (let w = 0; w < ts.tensor.shape[1]; w++) {
            const t = ts.tensor.pick(h, w)

            const wrkProb = {landmark: 0, prob: 0, x: w, y: h}

            for (let c = 0; c < t.shape[0]; c++) {
                const prob = t.get(c)

                if (prob > wrkProb.prob) {
                    wrkProb.landmark = c
                    wrkProb.prob = prob
                }
            }
            // ランドマーク 0 (ランドマークでは無い)は除外
            if (wrkProb.landmark > 0) {
                const curProb = res[wrkProb.landmark]

                if (!curProb || curProb.prob < wrkProb.prob) {
                    res[wrkProb.landmark] = wrkProb
                }
            }
        }
    }

    return res
}

// UI 処理からのメッセージ受信
onmessage = ev => {
    switch (ev.data.type) {
        case 'init':
            loadModel(ev.data.url)
                .then(m => {
                    model = m
                    postMessage({type: ev.data.type})
                })
                .catch(err => {
                    console.log(err)
                    postMessage({type: ev.data.type, error: err.message})
                })

            break
        case 'predict':
            const outputLayerName = model.outputLayerNames[0]

            const shape = model.modelLayersMap.get(outputLayerName)
                                                .output.tensor.shape

            const data = {}
            // 入力データの設定
            data[model.inputLayerNames[0]] = ev.data.input

            Promise.resolve(model.predict(data))
                .then(r => new KerasJS.Tensor(r[outputLayerName], shape)) // predict 実行結果の取り出し
                .then(detectLandmarks)
                .then(r => 
                    // UI 処理へ結果送信
                    postMessage({type: ev.data.type, output: r})
                )
                .catch(err => {
                    console.log(err)
                    postMessage({type: ev.data.type, error: err.message})
                })

            break
    }
}

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

画像データの変換(入力データの作成)

KerasJS.Model で predict するために、今回のケースでは画像データを 256(高さ)× 256(幅)× 3(RGB) サイズの 1次元配列 Float32Array へ変換する必要があります。

今回は以下のように canvas を利用して変換を行いました。

ImageData.data は RGBA 並びの 1次元配列 Uint8ClampedArray となっているので、RGB 部分のみを取り出して(A の内容を除外する)Float32Array を生成しています。

ちなみに、今回の CNN モデル自体は画像サイズに依存しない(Fully Convolutional Networks 的な)構成になっています。

そのため、任意サイズの画像を処理する事もできるのですが、現時点の Keras.js ではそんな事を考慮してくれていないので、実現するにはそれなりの工夫が必要になります。(一応、実現は可能でした)

ここでは、単純に canvas へ描画した 256x256 範囲の内容だけ(つまりは固定サイズ)を使うようにしています。※

 ※ この方法では 256x256 以外のサイズで欠けや余白の入り込みが発生する
画像データ変換部分(src/app.js)
・・・
const imageTypes = ['image/jpeg']

const canvas = document.getElementsByTagName('canvas')[0]
const ctx = canvas.getContext('2d')

・・・

// RGBA 並びの Uint8ClampedArray を RGB 並びの Float32Array へ変換
const imgToArray = imgData => new Float32Array(
    imgData.data.reduce(
        (acc, v, i) => {
            // RGBA の A 部分を除外
            if (i % 4 != 3) {
                acc.push(v)
            }
            return acc
        },
        []
     )
)
// 画像の読み込み
const loadImage = url => new Promise(resolve => {
    const img = new Image()

    img.addEventListener('load', () => {
        ctx.clearRect(0, 0, canvas.width, canvas.height)

        // 画像サイズが canvas よりも小さい場合の考慮
        const w = Math.min(img.width, canvas.width)
        const h = Math.min(img.height, canvas.height)

        // canvas へ画像を描画
        ctx.drawImage(img, 0, 0, w, h, 0, 0, w, h)

        // ImageData の取得
        const d = ctx.getImageData(0, 0, canvas.width, canvas.height)

        resolve(imgToArray(d))
    })

    img.src = url
})

・・・

// モデルデータ読み込み完了時の処理
const ready = () => {
    canvas.addEventListener('dragover', ev => {
        ev.preventDefault()
        canvas.classList.add('dragging')
    }, false)

    canvas.addEventListener('dragleave', ev => {
        canvas.classList.remove('dragging')
    }, false)

    // ドロップ時の処理
    canvas.addEventListener('drop', ev => {
        ev.preventDefault()
        canvas.classList.remove('dragging')

        const file = ev.dataTransfer.files[0]

        if (imageTypes.includes(file.type)) {
            ・・・
            const reader = new FileReader()

            reader.onload = ev => {
                loadImage(reader.result)
                    .then(img => {
                        ・・・
                    })
            }

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

・・・

Web Worker との連携

Web Worker とメッセージをやり取りし、ランドマークの検出結果を描画する部分の実装です。

Web Worker との連携部分(src/app.js)
const colors = ['rgb(255, 255, 255)', 'rgb(255, 0, 0)', 'rgb(0, 255, 0)', 'rgb(0, 0, 255)', 'rgb(255, 255, 0)', 'rgb(0, 255, 255)', 'rgb(255, 0, 255)']

const radius = 5
const imageTypes = ['image/jpeg']

const modelFile = '../model/cnn_landmark_400.bin'

// Web Worker の作成
const worker = new Worker('./js/bundle_worker.js')

・・・

// 検出したランドマークを canvas へ描画
const drawLandmarks = lms => {
    Object.values(lms).forEach(v => {
        ctx.fillStyle = colors[v.landmark]
        ctx.beginPath()
        ctx.arc(v.x, v.y, radius, 0, Math.PI * 2, false)
        ctx.fill()
    })
}

・・・

// 検出したランドマークの内容を table(HTML)化して表示
const showLandmarksInfo = lms => {
    ・・・

    infoNode.innerHTML = `
      <table>
        <tr>
          <th>landmark</th>
          <th>coordinate</th>
          <th>prob</th>
        </tr>
        ${rowsHtml}
      </table>
    `
}

// モデルデータ読み込み完了後
const ready = () => {
    ・・・
    canvas.addEventListener('drop', ev => {
        ・・・
        if (imageTypes.includes(file.type)) {
            ・・・
            reader.onload = ev => {
                loadImage(reader.result)
                    .then(img => {
                        detectDialog.showModal()
                        // Web Worker へのランドマーク検出指示
                        worker.postMessage({type: 'predict', input: img})
                    })
            }

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

// Web Worker からのメッセージ受信
worker.onmessage = ev => {
    if (ev.data.error) {
        ・・・
    }
    else {
        switch (ev.data.type) {
            case 'init':
                ready()

                loadDialog.close()

                break
            case 'predict':
                const res = ev.data.output

                console.log(res)
                detectDialog.close()

                drawLandmarks(res)
                showLandmarksInfo(res)

                break
        }
    }
}

loadDialog.showModal()
// Web Worker へのモデルデータ読み込み指示
worker.postMessage({type: 'init', url: modelFile})

(c) ビルド

webpack コマンドを実行し、js/bundle_app.js と js/bundle_worker.js を生成します。

webpack によるビルド
> webpack

(d) 動作確認

HTTP サーバーを使って動作確認を行います。 今回は http-server を使って実行しました。

http-server 実行
> http-server

Starting up http-server, serving ./
Available on:
  ・・・
  http://127.0.0.1:8080
Hit CTRL-C to stop the server

http://localhost:8080/index.htmlChrome ※ でアクセスして画像ファイルをドラッグアンドドロップすると以下のような結果となりました。

f:id:fits:20190331192210j:plain

 ※ HTMLDialogElement.showModal() を使っている関係で
    現時点では Chrome でしか動作しませんが、
    dialog 以外の部分(Keras.js の処理等)は
    Firefox でも動作するようになっています

TypeScript で funfix を使用 - tsc, FuseBox

funfixJavaScript, TypeScript, Flow の関数型プログラミング用ライブラリで、Fantasy LandStatic Land ※ に準拠し Scala の Option, Either, Try, Future 等と同等の型が用意されているようです。

 ※ JavaScript 用に Monoid や Monad 等の代数的構造に関する仕様を定義したもの

今回は Option を使った単純な処理を TypeScript で実装し Node.js で実行してみます。

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

はじめに

Option を使った下記サンプルをコンパイルして実行します。

サンプルソース
import { Option, Some } from 'funfix'

const f = (ma, mb) => ma.flatMap(a => mb.map(b => `${a} + ${b} = ${a + b}`))

const d1 = Some(10)
const d2 = Some(2)

console.log( d1 )

console.log('-----')

console.log( f(d1, d2) )
console.log( f(d1, Option.none()) )

console.log('-----')

console.log( f(d1, d2).getOrElse('none') )
console.log( f(d1, Option.none()).getOrElse('none') )

ビルドと実行

上記ソースファイルを以下の 2通りでビルドして実行してみます。

  • (a) tsc 利用
  • (b) FuseBox 利用

(a) tsc を利用する場合

tsc コマンドを使って TypeScript のソースをコンパイルします。

まずは typescript と funfix モジュールをそれぞれインストールします。

typescript インストール
> npm install --save-dev typescript
funfix インストール
> npm install --save funfix

この状態で sample.ts ファイルをコンパイルしてみると、型関係のエラーが出るものの sample.js は正常に作られました。

コンパイル1
> tsc sample.ts

node_modules/funfix-core/dist/disjunctions.d.ts:775:14 - error TS2416: Property 'value' in type 'TNone' is not assignable to the same property in base type 'Option<never>'.
  Type 'undefined' is not assignable to type 'never'.

775     readonly value: undefined;
                 ~~~~~


node_modules/funfix-effect/dist/eval.d.ts:256:42 - error TS2304: Cannot find name 'Iterable'.

256     static sequence<A>(list: Eval<A>[] | Iterable<Eval<A>>): Eval<A[]>;
                                             ~~~~~~~~
・・・

sample.js を実行してみると特に問題無く動作します。

実行1
> node sample.js

TSome { _isEmpty: false, value: 10 }
-----
TSome { _isEmpty: false, value: '10 + 2 = 12' }
TNone { _isEmpty: true, value: undefined }
-----
10 + 2 = 12
none

これで一応は動いたわけですが、コンパイル時にエラーが出るというのも望ましい状態ではないので、エラー部分を解決してみます。

他にも方法があるかもしれませんが、ここでは以下のように対応します。

  • (1) Property 'value' in type 'TNone' ・・・ 'Option<never>' のエラーに対して tsc 実行時に --strictNullChecks オプションを指定して対応
  • (2) Cannot find name 'Iterable' 等のエラーに対して @types/node をインストールして対応

strictNullChecks は tsc の実行時オプションで指定する以外にも設定ファイル tsconfig.json で設定する事もできるので、ここでは tsconfig.json ファイルを使います。

(1) tsconfig.json
{
  "compilerOptions": {
    "strictNullChecks": true
  }
}

次に @types/node をインストールします。

@types/node には Node.js で実行するための型定義(Node.js 依存の API 等)が TypeScript 用に定義されています。

(2) @types/node インストール
> npm install --save-dev @types/node

この状態で tsc を実行すると先程のようなエラーは出なくなりました。(tsconfig.json を適用するため tsc コマンドへ引数を指定していない点に注意)

コンパイル2
> tsc

実行結果にも差異はありません。

実行2
> node sample.js

TSome { _isEmpty: false, value: 10 }
-----
TSome { _isEmpty: false, value: '10 + 2 = 12' }
TNone { _isEmpty: true, value: undefined }
-----
10 + 2 = 12
none

最終的な package.json の内容は以下の通りです。

package.json
{
  "name": "sample",
  "version": "1.0.0",
  "devDependencies": {
    "@types/node": "^10.5.4",
    "typescript": "^2.9.2"
  },
  "dependencies": {
    "funfix": "^7.0.1"
  }
}

(b) FuseBox を利用する場合

次に、モジュールバンドラーの FuseBox を使用してみます。(以降は (a) とは異なるディレクトリで実施)

なお、ここでは npm の代わりに yarn を使っていますが、npm でも特に問題はありません。

yarn のインストール例(npm 使用)
> npm install -g yarn

(b-1) 型チェック無し

まずは typescript, fuse-box, funfix をそれぞれインストールしておきます。

typescript と fuse-box インストール
> yarn add --dev typescript fuse-box
funfix インストール
> yarn add funfix

FuseBox ではビルド定義を JavaScript のコードで記載します。 とりあえずは必要最小限の設定を行いました。

bundle で指定した名称が init の $name に適用されるため、*.tsコンパイル結果と依存モジュールの内容をバンドルして bundle.js へ出力する事になります。

なお、> でロード時に実行する(コードを記載した)ファイルを指定します。

fuse.js (FuseBox ビルド定義)
const {FuseBox} = require('fuse-box')

const fuse = FuseBox.init({
    output: '$name.js'
})

fuse.bundle('bundle').instructions('> *.ts')

fuse.run()

上記スクリプトを実行してビルド(TypeScript のコンパイルとバンドル)を行います。

ビルド
> node fuse.js

--- FuseBox 3.4.0 ---
  → Generating recommended tsconfig.json:  ・・・\sample_fusebox1\tsconfig.json
  → Typescript script target: ES7

--------------------------
Bundle "bundle"

    sample.js
└──  (1 files,  700 Bytes) default
└── funfix-core 34.4 kB (1 files)
└── funfix-effect 43.1 kB (1 files)
└── funfix-exec 79.5 kB (1 files)
└── funfix 1 kB (1 files)
size: 158.7 kB in 765ms

初回実行時にデフォルト設定の tsconfig.json が作られました。(tsconfig.json が存在しない場合)

tsc の時のような型関係のエラーは出ていませんが、これは FuseBox がデフォルトで TypeScript の型チェックをしていない事が原因のようです。

型チェックを実施するには fuse-box-typechecker プラグインを使う必要がありそうです。

実行
> node bundle.js

TSome { _isEmpty: false, value: 10 }
-----
TSome { _isEmpty: false, value: '10 + 2 = 12' }
TNone { _isEmpty: true, value: undefined }
-----
10 + 2 = 12
none

package.json の内容は以下の通りです。

package.json
{
  "name": "sample_fusebox1",
  "version": "1.0.0",
  "main": "bundle.js",
  "license": "MIT",
  "devDependencies": {
    "fuse-box": "^3.4.0",
    "typescript": "^2.9.2"
  },
  "dependencies": {
    "funfix": "^7.0.1"
  }
}

(b-2) 型チェック有り

TypeScript の型チェックを行うようにしてみます。

まずは、(b-1) と同じ構成に fuse-box-typechecker プラグインを加えます。

fuse-box-typechecker を追加インストール
> yarn add --dev fuse-box-typechecker

次に、fuse.js へ fuse-box-typechecker プラグインの設定を追加します。

TypeChecker で型チェックにエラーがあった場合、例外が throw されるようにはなっていないため、ここではエラーがあった場合に Error を throw して fuse.run() を実行しないようにしてみました。

ただし、こうすると tsconfig.json を予め用意しておく必要があります。(TypeChecker に tsconfig.json が必要)

fuse.js (FuseBox ビルド定義)
const {FuseBox} = require('fuse-box')
const {TypeChecker} = require('fuse-box-typechecker')

// fuse-box-typechecker の設定
const testSync = TypeChecker({
    tsConfig: './tsconfig.json'
})

const fuse = FuseBox.init({
    output: '$name.js'
})

fuse.bundle('bundle').instructions('> *.ts')

testSync.runPromise()
    .then(n => {
        if (n != 0) {
            // 型チェックでエラーがあった場合
            throw new Error(n)
        }
        // 型チェックでエラーがなかった場合
        return fuse.run()
    })
    .catch(console.error)

これで、ビルド時に (a) と同様の型エラーが出るようになりました。

ビルド1
> node fuse.js

・・・
--- FuseBox 3.4.0 ---

Typechecker plugin(promisesync) .
Time:Sun Jul 29 2018 12:40:47 GMT+0900 (GMT+09:00)

File errors:
└── .\node_modules\funfix-core\dist\disjunctions.d.ts
   | ・・・\sample_fusebox2\node_modules\funfix-core\dist\disjunctions.d.ts (775,14) (Error:TS2416) Property 'value' in type 'TNone' is not assignable to the same property in base type 'Option<never>'.
  Type 'undefined' is not assignable to type 'never'.

Errors:1
└── Options: 0
└── Global: 0
└── Syntactic: 0
└── Semantic: 1
└── TsLint: 0

Typechecking time: 4116ms
Quitting typechecker

・・・

ここで、Iterable の型エラーが出ていないのは fuse-box-typechecker のインストール時に @types/node もインストールされているためです。

(a) と同様に strictNullChecks の設定を tsconfig.json追記して、このエラーを解決します。

tsconfig.json へ strictNullChecks の設定を追加
{
  "compilerOptions": {
    "module": "commonjs",
    "target": "ES7",
    ・・・
    "strictNullChecks": true
  }
}

これでビルドが成功するようになりました。

ビルド2
> node fuse.js

・・・
Typechecker name: undefined
Typechecker basepath: ・・・\sample_fusebox2
Typechecker tsconfig: ・・・\sample_fusebox2\tsconfig.json
--- FuseBox 3.4.0 ---

Typechecker plugin(promisesync) .
Time:Sun Jul 29 2018 12:44:57 GMT+0900 (GMT+09:00)
All good, no errors :-)
Typechecking time: 4103ms
Quitting typechecker

killing worker  → Typescript config file:  \tsconfig.json
  → Typescript script target: ES7

--------------------------
Bundle "bundle"

    sample.js
└──  (1 files,  700 Bytes) default
└── funfix-core 34.4 kB (1 files)
└── funfix-effect 43.1 kB (1 files)
└── funfix-exec 79.5 kB (1 files)
└── funfix 1 kB (1 files)
size: 158.7 kB in 664ms
実行結果
> node bundle.js

TSome { _isEmpty: false, value: 10 }
-----
TSome { _isEmpty: false, value: '10 + 2 = 12' }
TNone { _isEmpty: true, value: undefined }
-----
10 + 2 = 12
none

package.json の内容は以下の通りです。

package.json
{
  "name": "sample_fusebox2",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "devDependencies": {
    "fuse-box": "^3.4.0",
    "fuse-box-typechecker": "^2.10.0",
    "typescript": "^2.9.2"
  },
  "dependencies": {
    "funfix": "^7.0.1"
  }
}

Kubernetes の Watch API とタイムアウト

Kubernetes の Watch API を下記クライアントライブラリを使って試してみました。

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

はじめに

下記のコマンドを実行して Javascript Kubernetes Client をインストールしておきます。

Javascript Kubernetes Client のインストール
> npm install @kubernetes/client-node

Watch API による Pod の監視

Watch APIdefault Namespace の Pod に関するイベントを監視して、イベントのタイプと Pod 名を標準出力する処理を実装してみます。

watch の第一引数に Watch API のエンドポイント URL、第三引数でイベントハンドラを指定します。(第二引数はクエリパラメータ)

今回は Pod を監視していますが、default Namespace の Deployment を監視する場合は endpoint/apis/apps/v1/namespaces/default/deployments とします。

なお、$HOME/.kube/config もしくは %USERPROFILE%\.kube\config ファイルから Kubernetes への接続情報を取得するようにしています。

sample_watch_pod.js
const k8s = require('@kubernetes/client-node')
// default Namespace の Pod
const endpoint = '/api/v1/namespaces/default/pods'

// Windows 環境用の設定
if (!process.env.HOME) {
    process.env.HOME = process.env.USERPROFILE
}

const conf = new k8s.KubeConfig()
conf.loadFromFile(`${process.env.HOME}/.kube/config`)

const w = new k8s.Watch(conf)

w.watch(
    endpoint,
    {}, 
    (type, obj) => {
        console.log(`${type} : ${obj.metadata.name}`)
    },
    err => {
        if (err) {
            console.error(err)
        }
        else {
            console.log('done')
        }
    }
)

動作確認

今回、Kubernetes の環境を minikube で用意します。

minikube コマンドを使って start を実行するとローカル用の Kubernetes 環境が立ち上がります。

その際に、%USERPROFILE%\.kube\config ファイル等が作られます。

minikube 開始
> minikube start

・・・
Starting local Kubernetes v1.9.0 cluster...
Starting VM...
Getting VM IP address...
Moving files into cluster...
Setting up certs...
Connecting to cluster...
Setting up kubeconfig...
Starting cluster components...
Kubectl is now configured to use the cluster.
Loading cached images from config file.

Watchスクリプトを実行します。

sample_watch_pod.js の実行
> node sample_watch_pod.js

下記 YAML ファイルを使って、Kubernetes 環境へ nginx 実行用の Deployment と Service を作成してみます。

nginx.yaml (nginx 用の Deployment と Service 定義)
apiVersion: v1
kind: Service
metadata:
  name: nginx-service
  labels:
    app: nginx
spec:
  ports:
  - name: http
    port: 80
    nodePort: 30001
  selector:
    app: nginx
  type: NodePort

---

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deploy
spec:
  replicas: 2
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx
        ports:
        - containerPort: 80

kubectl を使って Deployment と Service を作成します。

Deployment と Service 作成
> kubectl create -f nginx.yaml

service "nginx-service" created
deployment "nginx-deploy" created

Watch の結果は以下のようになりました。

sample_watch_pod.js の結果1
> node sample_watch_pod.js

ADDED : nginx-deploy-679dc9c764-r9ds5
MODIFIED : nginx-deploy-679dc9c764-r9ds5
ADDED : nginx-deploy-679dc9c764-54d5d
MODIFIED : nginx-deploy-679dc9c764-r9ds5
MODIFIED : nginx-deploy-679dc9c764-54d5d
MODIFIED : nginx-deploy-679dc9c764-54d5d
MODIFIED : nginx-deploy-679dc9c764-r9ds5
MODIFIED : nginx-deploy-679dc9c764-54d5d

ここで、いつまでも接続が続くわけでは無く、minikube の環境では 40分程度(ただし、毎回異なる)で接続が切れ以下のようになりました。

sample_watch_pod.js の結果2 (一定時間経過後)
> node sample_watch_pod.js

・・・
done

タイムアウト時間の確認

Watch API の接続が切れる原因を探ってみます。

Kubernetes と minikube のソースから、タイムアウトに関係していると思われる箇所 timeout = time.Duration(float64(minRequestTimeout) * (rand.Float64() + 1.0)) を見つけました。※

 ※ minikube では localkube 内で Kubernetes の API Server を実行しているようです

これだと、タイムアウトは 30 ~ 60分でランダムに決まる事になりそうなので、接続の切れる時間が毎回異なるという現象に合致します。

ソース kubernetes/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/get.go
func ListResource(r rest.Lister, rw rest.Watcher, scope RequestScope, forceWatch bool, minRequestTimeout time.Duration) http.HandlerFunc {
    return func(w http.ResponseWriter, req *http.Request) {
        ・・・
        if opts.Watch || forceWatch {
            ・・・
            timeout := time.Duration(0)
            if opts.TimeoutSeconds != nil {
                timeout = time.Duration(*opts.TimeoutSeconds) * time.Second
            }
            if timeout == 0 && minRequestTimeout > 0 {
                timeout = time.Duration(float64(minRequestTimeout) * (rand.Float64() + 1.0))
            }
            glog.V(2).Infof("Starting watch for %s, rv=%s labels=%s fields=%s timeout=%s", req.URL.Path, opts.ResourceVersion, opts.LabelSelector, opts.FieldSelector, timeout)

            ・・・
            return
        }
        ・・・
    }
}
ソース minikube/pkg/localkube/apiserver.go
// defaults from apiserver command
config.GenericServerRunOptions.MinRequestTimeout = 1800

get.go の処理ではログレベル 2 でタイムアウトの値をログ出力しているので(glog.V(2).Infof(・・・) の箇所)ログから確認できそうです。

ただし、普通に minikube start で実行してもログレベル 2 のログは見れないようなので、minikube を -v <ログレベル> オプションを使って起動しなおします。

ログレベル 2 で miinkube 開始
> minikube start -v 2

Watchスクリプトを実行します。

sample_watch_pod.js の実行
> node sample_watch_pod.js

・・・

minikube logs でログ内容を確認してみると、get.go が出力しているタイムアウトの値を確認できました。

ログ確認
> minikube logs

・・・
Apr 08 01:00:30 minikube localkube[2995]: I0408 01:00:30.533448    2995 get.go:238] Starting watch for /api/v1/namespaces/default/pods, rv= labels= fields= timeout=58m38.2420124s
・・・