[SORACOM Orbit]TinyGoでWASMを試してみた

TinyGoで実装したWASMモジュールをSORACOM Orbitで試してみました
2021.04.02

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは、CX事業本部のうらわです。

以前書いた記事ではC++/EmascriptenでWASMモジュールを作成し、SORACOM Orbit(以下、orbit)を試してみました。

今回はTinyGoで試してみます。

作業環境

Macで作業します。

SORACOM CLIが認証含め利用できる状態を前提とします。

参考: Getting Started: SORACOM CLI をインストールし SIM カードの一覧を取得する | SORACOM CLI 利用ガイド | SORACOM Users

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.15.7
BuildVersion:   19H15

$ soracom version
SORACOM API client v0.7.0

準備

TinyGo

TinyGoをHomebrewでインストールします。

$ brew tap tinygo-org/tools
$ brew install tinygo
$ tinygo version
tinygo version 0.17.0 darwin/amd64 (using go version go1.15.2 and LLVM version 11.0.0)

orbit-development-environment

WASMモジュール開発環境のセットアップのページからSDKのzipファイルをダウンロードし、任意のディレクトリで解凍しておきます。以降はtinygoディレクトリ内で作業します。

$ unzip orbit-development-environment-2020-11.zip
$ cd tinygo

サンプルコードでorbitを試す

まずはサンプルコードをビルドしorbitをテストします。簡単に試すために、22-34の不要な行をコメントアウトしておきます。

src/main.go

package main

import (
	"strconv"

	sdk "github.com/soracom/orbit-sdk-tinygo"
)

// application entry point, but the orbit runtime never executes this.
func main() {
}

//export uplink
func uplink() sdk.ErrorCode {
	inputBuffer, err := sdk.GetInputBuffer()
	if err != nil {
		sdk.Log(err.Error())
		return -1
	}
	sdk.Log("Input Buffer: " + string(inputBuffer) + "\n")

	// tagValue, err := sdk.GetTagValue("name")
	// if err != nil {
	// 	sdk.Log(err.Error())
	// 	return -1
	// }
	// sdk.Log("Name: " + string(tagValue) + "\n")
    //
	// sourceValue, err := sdk.GetSourceValue("resourceType")
	// if err != nil {
	// 	sdk.Log(err.Error())
	// 	return -1
	// }
	// sdk.Log("Resource type: " + string(sourceValue) + "\n")

	timestamp := sdk.GetTimestamp()
	if err != nil {
		sdk.Log(err.Error())
		return -1
	}
	sdk.Log("Timestamp: " + strconv.FormatInt(timestamp, 10) + "\n")

	sdk.SetOutputJSON("{\"message\": \"Hello from Orbit with TinyGo\"}")

	return sdk.ErrorCode(0)
}

make releasebuild/soralet-optimized.wasmが作成されます。

$ make release

SORACOM CLIを利用してsoraletの作成、WASMモジュールのアップロード、WASMモジュールのテストを行います。

$ soracom soralets create --soralet-id my-soralet

$ soracom soralets upload \
     --soralet-id my-soralet \
     --content-type application/octet-stream \
     --body @build/soralet-optimized.wasm

$ cat << EOF > test.json
{
  "source": { "resourceType": "Subscriber", "resourceId": "295050919999999999" },
  "payload": "{\"value\":23.54,\"name\":\"sorao\"}"
}
EOF

$ soracom soralets exec --soralet-id my-soralet \
    --version 1 \
    --direction uplink \
    --content-type application/json \
    --body @test.json
{
        "body": "{\"message\": \"Hello from Orbit with TinyGo\"}",
        "contentType": "application/json",
        "encodingType": "plain",
        "resultCode": 0
}

SORACOMユーザーコンソールにアクセスし、メニューから SORACOM Orbit > Soralet管理でSoralet一覧を確認します。先ほど作成したsoraletを選択すると、LOGSにtest.json内のpayloadがログとして出力されていることがわかります。

ここまではorbitの基本的な動作を確認しました。次はSDKに付属しているサンプルコードを改修し、いろいろ試してみます。

base64デコード

SORACOM GPSマルチユニットから送信されるデータのpayloadはbase64でエンコードされています。以下はSORACOM Harvestでダウンロードしたjsonの例です。

[
  {
    "resourceType": "Subscriber",
    "resourceId": "000000000000000",
    "time": "2021/4/1  20:30:50",
    "contentType": "application/json",
    "content": {
      "payload": "eyJsYXQiOjM1Ljg5ODc1NywibG9uIjoxMzkuNzE5ODE4LCJiYXQiOjMsInJzIjo0LCJ0ZW1wIjoyMy45LCJodW1pIjo1Mi4zLCJ4IjowLjAsInkiOjY0LjAsInoiOi05NjAuMCwidHlwZSI6MH0="
    }
  }
]

今回はWASM内でこのpayloadをデコードしてみます。

※ SORACOM Harvestを使用している場合はデコードする必要はありません。SORACOM BeamやFunnelを使用する場合はデコードが必要になります。

リファレンス: 送信されるデータフォーマット | GPS マルチユニット SORACOM Edition ユーザーガイド | SORACOM Users

注意点として、TinyGoではGoと同じように全ての標準ライブラリを使用できるわけではないため、以下のサイトで使用できるか確認する必要があります。base64デコードのためのencoding/base64は利用可能です。

PACKAGES SUPPORTED BY TINYGO

それでは、サンプルコードを編集します。以下のように、inputBufferをデコードするだけです。

src/main.go

package main

import (
	"encoding/base64"

	sdk "github.com/soracom/orbit-sdk-tinygo"
)

// application entry point, but the orbit runtime never executes this.
func main() {
}

//export uplink
func uplink() sdk.ErrorCode {
	inputBuffer, err := sdk.GetInputBuffer()
	if err != nil {
		sdk.Log(err.Error())
		return -1
	}
	sdk.Log("Input Buffer: " + string(inputBuffer) + "\n")

	decodedInputBuffer, err := base64.StdEncoding.DecodeString(string(inputBuffer))
	if err != nil {
		sdk.Log(err.Error())
		return -1
	}
	sdk.Log("Base64 Decoded: " + string(decodedInputBuffer) + "\n")

	return sdk.ErrorCode(0)
}

ビルドしてアップロードします(ブラウザでユーザーコンソールにアクセスし、最初にアップロードしたWASMは削除しておきます)。

$ make release
$ soracom soralets upload \
     --soralet-id my-soralet \
     --content-type application/octet-stream \
     --body @build/soralet-optimized.wasm

テスト用jsonとして以下を使用します。SORACOM Harvestからダウンロードしたjsonをorbitテスト用に加工しています。

test.json

{
  "source": {
    "resourceType": "Subscriber",
    "resourceId": "000000000000000",
    "time": "2021/4/1  20:30:50",
    "contentType": "application/json"
  },
  "payload": "eyJsYXQiOjM1Ljg5ODc1NywibG9uIjoxMzkuNzE5ODE4LCJiYXQiOjMsInJzIjo0LCJ0ZW1wIjoyMy45LCJodW1pIjo1Mi4zLCJ4IjowLjAsInkiOjY0LjAsInoiOi05NjAuMCwidHlwZSI6MH0="
}

テストします。

$ soracom soralets exec --soralet-id my-soralet \
    --version 1 \
    --direction uplink \
    --content-type application/json \
    --body @test.json
{
        "body": "",
        "contentType": "",
        "encodingType": "",
        "resultCode": 0
}

ユーザーコンソールでLOGSを確認してみます。成功していればデコードされたjsonが表示されます。

jsonのパース・シリアライズ

デコードして得たjsonに対して何らかの加工をし、再度jsonを出力してみます。これは、orbit-sdk-tinygoリポジトリのexamplesのコードを参考にします。

Goでは標準ライブラリでencoding/jsonパッケージが存在しますが、TinyGoでは使用できません。そのため、examplesのコードでも使用されているmoznion/go-json-icefmtを利用している箇所をなくしたbuger/jsonparserを利用します。

上記のような制限事項については以下のページを参照ください。

リファレンス: 制限事項と注意事項 | SORACOM Orbit | SORACOM Users

なお、本項のコードは以下のGitHubリポジトリに格納してあります。

まずは、出力するjsonの構造体を定義します。

src/data/output.go

package data

//go:generate json-ice --type=Output
type Output struct {
	Lat			string	`json:"lat"`
	Lon			string	`json:"lon"`
	Bat			int64 	`json:"bat"`
	Rs			int64 	`json:"rs"`
	Temp		string	`json:"temp"`
	Humi		string	`json:"humi"`
	Timestamp	int64	`json:"timestamp"`
}

go-json-iceをインストールし、jsonにシリアライズするためのコードを自動生成します(上記の構造体を基に自動生成されます)。

$ go get -u github.com/moznion/go-json-ice/cmd/json-ice
$ cd src/data
$ json-ice -type=Output

以下のコードが自動生成されます。

src/data/output_gen.go

// Code generated by "json-ice -type=Output"; DO NOT EDIT.

package data

import "github.com/moznion/go-json-ice/serializer"

func MarshalOutputAsJSON(s *Output) ([]byte, error) {
	buff := make([]byte, 1, 184)
	buff[0] = '{'
	buff = append(buff, "\"lat\":"...)
	buff = serializer.AppendSerializedString(buff, s.Lat)
	buff = append(buff, ',')
	buff = append(buff, "\"lon\":"...)
	buff = serializer.AppendSerializedString(buff, s.Lon)
	buff = append(buff, ',')
	buff = append(buff, "\"bat\":"...)
	buff = serializer.AppendSerializedInt(buff, int64(s.Bat))
	buff = append(buff, ',')
	buff = append(buff, "\"rs\":"...)
	buff = serializer.AppendSerializedInt(buff, int64(s.Rs))
	buff = append(buff, ',')
	buff = append(buff, "\"temp\":"...)
	buff = serializer.AppendSerializedString(buff, s.Temp)
	buff = append(buff, ',')
	buff = append(buff, "\"humi\":"...)
	buff = serializer.AppendSerializedString(buff, s.Humi)
	buff = append(buff, ',')
	buff = append(buff, "\"timestamp\":"...)
	buff = serializer.AppendSerializedInt(buff, int64(s.Timestamp))
	buff = append(buff, ',')
	if buff[len(buff)-1] == ',' {
		buff[len(buff)-1] = '}'
	} else {
		buff = append(buff, '}')
	}
	return buff, nil
}

自動生成された関数を呼び出せるように、src/data/output.goを修正します。

src/data/output.go

package data

//go:generate json-ice --type=Output
type Output struct {
	Lat			string	`json:"lat"`
	Lon			string	`json:"lon"`
	Bat			int64 	`json:"bat"`
	Rs			int64 	`json:"rs"`
	Temp		string	`json:"temp"`
	Humi		string	`json:"humi"`
	Timestamp	int64	`json:"timestamp"`
}

func (o *Output) SerializeJSON() ([]byte, error) {
	return MarshalOutputAsJSON(o)
}

次に、デコードしたjsonから値を取得するためのパーサーを準備します。buger/jsonparserをforkし、fmtを使っている箇所をなくしたブランチを作成します。buger/jsonparserの修正点は以下のコミットを参照してください。

go getします。

$ go get github.com/urawa72/jsonparser@<上記の修正コミットのハッシュ値>

あとはsrc/main.goを修正します。

src/main.go

package main

import (
	"encoding/base64"
	"strconv"

	sdk "github.com/soracom/orbit-sdk-tinygo"
	"github.com/urawa72/jsonparser"
	"github.com/urawa72/orbit-tinygo/src/data"
)

// application entry point, but the orbit runtime never executes this.
func main() {
}

//export uplink
func uplink() sdk.ErrorCode {
	inputBuffer, err := sdk.GetInputBuffer()
	if err != nil {
		sdk.Log(err.Error())
		return -1
	}
	sdk.Log("Input Buffer: " + string(inputBuffer) + "\n")

	decodedInputBuffer, err := base64.StdEncoding.DecodeString(string(inputBuffer))
	if err != nil {
		sdk.Log(err.Error())
		return -1
	}
	sdk.Log("Base64 Decoded: " + string(decodedInputBuffer) + "\n")

	output, err := convertInputToOutput(decodedInputBuffer)
	if err != nil {
		sdk.Log(err.Error())
		return - 1
	}

	serializedOuput, err := output.SerializeJSON()
	if err != nil {
		sdk.Log(err.Error())
		return - 1
	}

	sdk.SetOutputJSON(string(serializedOuput))
	sdk.Log("Serialize JSON: " + string(serializedOuput) + "\n")

	return sdk.ErrorCode(0)
}

func convertInputToOutput(input []byte) (*data.Output, error) {
	lat, err := jsonparser.GetFloat(input, "lat")
	if err != nil {
		return nil, err
	}
	slat := strconv.FormatFloat(lat, 'f', -1, 64)

	lon, err := jsonparser.GetFloat(input, "lon")
	if err != nil {
		return nil, err
	}
	slon := strconv.FormatFloat(lon, 'f', -1, 64)

	bat, err := jsonparser.GetInt(input, "bat")
	if err != nil {
		return nil, err
	}

	rs, err := jsonparser.GetInt(input, "rs")
	if err != nil {
		return nil, err
	}

	temp, err := jsonparser.GetFloat(input, "temp")
	if err != nil {
		return nil, err
	}
	stemp := strconv.FormatFloat(temp, 'f', -1, 64)

	humi, err := jsonparser.GetFloat(input, "humi")
	if err != nil {
		return nil, err
	}
	shumi := strconv.FormatFloat(humi, 'f', -1, 64)

	timestamp := sdk.GetTimestamp()

	return &data.Output{
		Lat: 	   	slat,
		Lon: 	   	slon,
		Bat: 	   	bat,
		Rs:	 	   	rs,
		Temp:		stemp,
		Humi:		shumi,
		Timestamp:	timestamp,
	}, nil
}

32行目でデコードした[]byte型のデータをパースしてOutput構造体にセットします。38行目でjson-iceによって自動生成されたシリアライズ関数を使用してorbitのアウトプットに使用できるようにjsonにシリアライズします。

なお、今回は大した加工はしておらず、属性の取捨選択とタイムスタンプを追加しただけです。

ここまでできたらビルド・アップロード・テストします。テストに使用するjsonはbase64デコードを試したときと同じファイルを使用します。

成功すると、bodyにjsonが表示されます。

$ soracom soralets exec --soralet-id my-soralet \
    --version 1 \
    --direction uplink \
    --content-type application/json \
    --body @test.json
{
        "body": "{\"lat\":\"35.898757\",\"lon\":\"139.719818\",\"bat\":3,\"rs\":4,\"temp\":\"23.9\",\"humi\":\"52.3\",\"timestamp\":1617369594740}",
        "contentType": "application/json",
        "encodingType": "plain",
        "resultCode": 0
}

orbitのLOGSでも確認できます。

おわりに

TinyGoを使ってWASMモジュールを作成し、orbitを試してみました。基本的にはGitHubのorbit-sdk-goリポジトリのexamplesを参考にしながら動かすことでなんとかなりました。jsonのパース・シリアライズあたりはGoと同じように標準ライブラリを使用して気軽にできるわけではないため注意が必要です。

次回はRustで試してみたいと思います。