LoginSignup
5
3

More than 3 years have passed since last update.

Go言語でJavaScriptを使ってオリジナルなコマンドシェルを作る

Last updated at Posted at 2019-05-27

概要

MongoDBのCLIツールであるmongoシェルを使っていると、JavaScriptをコマンドシェル(REPL)をとして使うのも良い思います。それならNode.jsでも良いかもしれませんが、Go言語はシングルバイナリなところが気に入っています。今回使用するJavaScriptエンジンはECMAScript 5.1の仕様かつNode.jsとは違いI/O関係など実装されていないので、実用的にするためにはかなりGo言語で拡張しなければなりません。しかし、いろいろ拡張することでGo言語の勉強にもなり、実用的なツールも手にすることも可能です。

環境

  • Windows 10
  • Go 1.12
  • Go JavaScript パッケージ
    • github.com/dop251/goja (ECMAScript 5.1)
    • Javascriptエンジンはほかに otto, go-duptake go-duktapeなどがある

最低限の実装

  • コマンドの入力・実行。実行結果の表示
  • console.logもprintもないのでprintの実装
  • exitコマンドで終了
  • 継続行のサポート
    • 継続行のpromptは...>とします
    • 継続のキャンセルは...と入力します

実行例

go run main.go
> for(var i=0; i < 5; i++) {
...> print(i)
...> }
0
1
2
3
4
undefined
> doc={a:1, b:"xxxx"}
{"a":1,"b":"xxxx"}
> Object.keys(doc)
["a","b"]
> exit

実行例、特に実用的なものは実装していないので簡単なものしかできません。JavaScriptの仕様はECMAScript 5.1なので現在使わているのものより古いです。

ソースコード

main.go
package main

import (
    "bufio"
    "fmt"
    "os"
    "strings"

    "github.com/dop251/goja"
)

type jsRuntime struct {
    runtime   *goja.Runtime
    stringify goja.Callable
    program   *goja.Program
}

func scanCommand(scanner *bufio.Scanner, prompt string) bool {
    fmt.Print(prompt)
    return scanner.Scan()
}

func (js *jsRuntime) jsprint(vals ...goja.Value) {
    rt := js.runtime
    format := "%v"
    for _, val := range vals {
        str, ok := val.Export().(string)
        if ok {
            fmt.Printf(format, str)
            format = " %v"
            continue
        }
        v, err := js.stringify(goja.Undefined(), val)
        if err != nil {
            rt.Interrupt(rt.NewGoError(err))
            return
        }
        fmt.Printf(format, v)
        format = " %v"
    }
    fmt.Println()
}

func (js *jsRuntime) setStringify() {
    rt := js.runtime
    json := rt.Get("JSON")
    jsonObj, ok := json.(*goja.Object)
    if !ok {
        panic("JSON not defined")
    }
    stringifyProp := jsonObj.Get("stringify")
    stringify, ok := goja.AssertFunction(stringifyProp)
    if !ok {
        panic("JSON.stringify not defined")
    }
    js.stringify = stringify
}

func initialSetting() *jsRuntime {
    rt := goja.New()
    js := &jsRuntime{runtime: rt}
    rt.Set("print", js.jsprint)
    js.setStringify()
    return js
}

func (js *jsRuntime) execute() (goja.Value, error) {
    return js.runtime.RunProgram(js.program)
}

const (
    execInput = iota
    contInput
    errorInput
)

func (js *jsRuntime) compile(cmd string) int {
    program, err := goja.Compile("console", cmd, false)
    if err != nil {
        idx := strings.Index(err.Error(), "Unexpected end of input")
        if idx == -1 {
            fmt.Println(err)
            return errorInput
        }
        return contInput
    }
    js.program = program
    return execInput
}

func main() {
    prompt := "> "
    js := initialSetting()
    scanner := bufio.NewScanner(os.Stdin)
    var cmds []string
    for scanCommand(scanner, prompt) {
        cmd := scanner.Text()
        if cmd == "exit" {
            break
        } else if cmd == "..." {
            cmds = nil
            prompt = "> "
            continue
        }
        cmds = append(cmds, cmd)
        switch js.compile(strings.Join(cmds, "\n")) {
        case execInput:
            ret, err := js.execute()
            if err == nil {
                js.jsprint(ret)
            } else {
                fmt.Println(err)
            }
            cmds = nil
            prompt = "> "
        case contInput:
            prompt = "...> "
        case errorInput:
            cmds = nil
            prompt = "> "
        }
    }
}

解説

  • コンソールから入力されたTEXTをscannerで取得してCompileしてエラーが無ければRunProgramで実行します。
    • exitが入力されたらプログラムを終了します
    • RunProgram()の戻り値あるいはエラーを表示します
  • 行を継続のためにはCompileしてみて"Unexpected end of input"というエラーなら入力を継続する
  • 各関数で今実行中のgoja.Runtimeが必要なのでjsRuntime構造体を定義します。
  • Goの関数をJavaScriptで使えるようするには*goja.RuntimeSetを使いGlobalな関数として定義します。オブジェクトの関数として定義するには*goja.ObjectSetを使います。
    • 今回定義したのはprint関数だけです
    • printで直接fmt.Printfなどを使うとJSONが正常に表示されないので、一度JavaScriptのJSON.stringify()を実行してからfmt.Printfで表示します
    • 文字列はJSON.stringifyを使うと"引用符が付くので文字列だけ別処理にしました。
    • setStringify()JSON.stringifyをGo言語から使えるようにします。
  • InterruptはJavaScriptでthrowのように働きます。

(拡張1) load関数の実装

外部ファイルを実行するためにload(ファイル名)を実装します。

func initialSetting() *jsRuntimeに次のコードを追加します

 rt.Set("load", js.jsload)

js.jsloadの実装

jsload
func (js *jsRuntime) jsload(file string) goja.Value {
    rt := js.runtime
    f, err := os.Open(file)
    if err != nil {
        rt.Interrupt(rt.NewGoError(err))
        return goja.Null()
    }
    defer f.Close()
    text, err := ioutil.ReadAll(f)
    if err != nil {
        rt.Interrupt(rt.NewGoError(err))
        return goja.Null()
    }
    val, err := js.runtime.RunScript(file, string(text))
    if err != nil {
        rt.Interrupt(err)
        return goja.Null()
    }
    return val
}

import "io/ioutil"パッケージを使ってファイルを入力します。
入力したファイルの実行はRunScriptを使います。これは入力の継続の判定する必要がないためです。

test.jsを作って実行します

test.js
for(var i=0; i < 5; i++) {
    print(i)
}
go run main.go
> load("test.js")
0
1
2
3
4
undefined
>

(拡張2) Go言語のオブジェクト(構造体とそのメソッド)をJavaScriptから使う

オブジェクトexampleObjとします。これを構造体として定義し、そのメソッドも定義します。以下の例はMethod1,Method2として両方とも円の面積を計算する同じ処理ですが戻り値が型が異なっています。メソッドはgojaパッケージから参照されるのでメソッド名を大文字で始めます。またexampleObjを生成する関数createExampleObjをJavaScriptに設定します。

initialSetting()に追加
    rt.Set("createExample", js.createExampleObj)
type exampleObj struct {
    js *jsRuntime
}

func (js *jsRuntime) createExampleObj() *exampleObj {
    return &exampleObj{js: js}
}

func (o *exampleObj) Method1(r float64) float64 {
    return r * r * 3.14
}

func (o *exampleObj) Method2(r float64) goja.Value {
    return o.js.runtime.ToValue(r * r * 3.14)
}

実行

go run main.go
> o=createExample()
{}
> o.Method1(1)
3.14
> o.Method2(1)
3.14
> o.Method2(2)
12.56
>

問題はないのですが、メソッド名が大文字で始まるのは入力コマンドとしては使いづらいです。そこでこれらのWrapperをJavaScriptで記述します。

initialSetting()に追加
    _, err := rt.RunString(builtin)
    if err != nil {
        panic(err)
    }
const builtin = `
example = function() {
    return new ExampleObj()
}
ExampleObj = function () {
    var _obj = createExample()
    this.getObj = function() {
        return _obj
    }
}
ExampleObj.prototype.method1 = function (r) {
    return this.getObj().Method1(r)
}
ExampleObj.prototype.method2 = function (r) {
    return this.getObj().Method2(r)
}
`

_objExampleObj外から直接アクセスできないようにthis._objにはしませんでした。
外部ファイルにしてloadで読み込むのも可能です。

実行

go run main.go
> o=example()
{}
> o.method1(1)
3.14
> o.method1(2)
12.56
> o.method2(2)
12.56
> 

参照

まとめ

いろいろ工夫すれば使いやすいツールが作れるのではないでしょうか。

問題点

  • 基本的なものもGoで拡張しないと使いえない
  • GCは実装されていないようです。
  • JavaScriptの仕様が古い
    • Proxyがない
    • 可変長引数を次の可変長引数に渡すには配列にしてapplyメソッドを使う

Appnedix

試作の紹介

今、自分用に試作中のものを少し紹介します。

database/sqlパッケージを使用してSQLをJavaScriptのコマンドとして実装して、データの受け取りはなるべくJSON化しています。現時点ではsqlite3PostgreSQLのドライバーを使って試作しています。これらはmongoシェルを参考にしています。
また、コマンドの入力用のパッケージとして https://github.com/chzyer/readline を使っています

さらに考えられるのは

  • MongoDBのようにテーブルが存在しないときに自動的に作成する 
    • 最初にinsertするJSONを用いて、create tableを発行する。
    • データの型をどうするか。データベースごとにdefaultの設定を考える
  • EXCELファイルやCSVファイルを読んでJavaScript上でJSONデータとして扱えるようにする
    • これらのファイルをDBにストアできる
  • MongoDB
    • すでにmongoシェルがあるが、ここに実装すれば他のRDBと連携できる
    • mongoシェルの実装(C++, SpiderMonkey)は複雑なので改造は難しい。
  • I/O関係の実装
  • RESTfulクライアント
  • https://github.com/gizak/termui/ のパッケージをJavaScriptから使えるようにして監視モニター

sqlite3のコマンド例

$go run main.go
script> db=sqlite3("sample.db")
{"driver":"sqlite3","conn":"sample.db"}
script> db.exec("create table tbl1(a integer, b varchar)")
{}
script> db.exec("insert into tbl1 values(?,?)", 1, "string 1")
{}
script> db.exec("insert into tbl1 values(?,?)", 2, "string 2")
{}
script> db.query("select * from tbl1")
{"a":1,"b":"string 1"}
{"a":2,"b":"string 2"}
script>

テーブルオブジェクトを実装して次のように使う

script> t = db.table("tbl1")
{"driver":"sqlite3","name":"tbl1"}
script> t.insert({a:3, b:"string 3"})
{}
script> t.find()
{"a":1,"b":"string 1"}
{"a":2,"b":"string 2"}
{"a":3,"b":"string 3"}
script> t.find("a>1")
{"a":2,"b":"string 2"}
{"a":3,"b":"string 3"}
script> t.find().forEach(function(doc) {
...   if (doc.a > 1) {
...     print(JSON.stringify(doc))
...   }})
{"a":2,"b":"string 2"}
{"a":3,"b":"string 3"}
script>

modesql()でSQLモードにします。SQLモードを終わらせるにはexit;と入力します。

script> modesql(db)
sql> select * from tbl1 where a > 1;
{"a":2,"b":"string 2"}
{"a":3,"b":"string 3"}
sql> create table tbl2(c real ,d varchar);
sql> insert into tbl2 values(1.2, "string 1.2");
sql> select * from tbl2;
{"c":1.2,"d":"string 1.2"}
sql> exit;
script> t2=db.table("tbl2")
{"driver":"sqlite3","name":"tbl2"}
script> t2.find()
{"c":1.2,"d":"string 1.2"}
script>

findにはlimit,skipオプションを実装しました。このほかにはsortselectする項目名を指定するコマンドも実装可能でしょう。

script> t.find().limit(2).skip(1)
{"a":2,"b":"string 2"}
{"a":3,"b":"string 3"}

sqlite3tbl1のデータをPostgreSQLpgtbl1にコピーする

script> pg = postgres()
{"driver":"postgres","conn":"dbname=test sslmode=disable"}
script> pg.exec("create table pgtbl1(a integer, b varchar)")
{}
script> pgt = pg.table("pgtbl1")
{"driver":"postgres","name":"pgtbl1"}
script> t.find().forEach(function(doc) {
...   pgt.insert(doc)
... })
script> pgt.find()
{"a":1,"b":"string 1"}
{"a":2,"b":"string 2"}
{"a":3,"b":"string 3"}
script>

PostgreSQLのpsqlコマンドで確認します

>psql test
psql (11.0)
"help" でヘルプを表示します。

test=# select * from pgtbl1;
 a  |     b
----+-----------
  1 | string 1
  2 | string 2
  3 | string 3

(3 行)


test=#

また、mongoシェル風なテーブルの記述にしたかったのでgojaパッケージを改造しました。

script> t = db.table("tbl1")
{"driver":"sqlite3","name":"tbl1"}
script> t.insert({a:3, b:"string 3"})
{}

としているものをmongoシェル風にdb.tbl1.insertとして使えるようになりました。通常はtbl1undefinedになりますので、undefinedのプロパティが参照されたときに特定のメソッドを呼び出すように改造しました。

script> db.tbl1.insert({a:4, b:"string 4"})
{}
script> db.tbl1.find()
{"a":1,"b":"string 1"}
{"a":2,"b":"string 2"}
{"a":3,"b":"string 3"}
{"a":4,"b":"string 4"}
script>

db.tbl1.fromExcel("Book1.xlsx", "Sheet1")db.tbl1.toExcel("Book1.xlsx", "Sheet1")なども実装出来たら有用かもしれない。

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3