non vorrei lavorare

昔はおもにプログラミングやガジェット系、今は?

node-ffi-napiでPythonを動かして、PyObjC経由でCocoa APIを叩いて結果を別スレッドでHTTPで受け取った

@kjunichiです。この記事はQiita Node.js Advent Calendar 2019 6日の記事です。

背景

たまーに、流行のGitHub見て転職案内してくれるサイトに登録してるので、 KeyCast.js見ました!なんてメールをいただくことがありますが。 AIからのメールなのはバレバレでもメンタル豆腐なので、この数年メンテしておらず心苦しくになることがあるので、 重い腰を上げて対応しようとしたら、NodObjCが不安定すぎて、別の方法を模索している。

ってのと、Keycast.jsが以前のアドカレがきっかけで作成したので、新ネタ 思いつかなかったというのもある。

Node.jsで直接Cocoaを扱えなくなった?!

node-ffiはここ数年更新がされていない状態が続いている。

NAPIに対応した node-ffi-napiが作られている。これはNode.jsにスレッドを入れた方で、Node学園祭でもスピーカで 来日された @addaleaxさんがメンテしている。

しかし、こちらのNAPI版のffi-napiではNodObjCをサポートしないっぽい。以下のISSUE参照。

ということで、今回は、Cocoa APIPythonのライブラリであるPyObjCから叩くことにした。 PyObjCは開発が活発なようで今年もアップデートされているのを観測済み。

HomebrewなPythonへの対応

dylibではなく、.framework形式で共有ライブラリがインストールされるので、node-ffi-napiで通常の処理ではアクセスできない。

node-ffi-napiのコードを眺めて以下のようにすれば解決した。

const handle = ffi.DynamicLibrary('/usr/local/Cellar/python/3.7.5/Frameworks/Python.framework/Versions/3.7/Python')
const libpython={}
libpython.Py_SetProgramName = ffi.ForeignFunction(
    handle.get('Py_SetProgramName'),
    'void',
    [voidPtr]
)

ffi.DynamicLibraryにてFrameworkの中身?を指定して、そのハンドルを取得して、 取得したハンドルに対してgetメソッドで個々のframework内のシンボル名を指定することでCで言うところの関数ポインタが 取得できて、これを使って、ffi.ForeiginFuncionでJSでの関数を取り出す事ができる。

Pythonからの結果を受け取るのがめんどくさい問題

Node.jsからnode-ffi-napiを経由してPythonを経由してPyObjCMacOS側からキーイベントを受け取ることはできるが、 これを受取には、Node.js側で、node-ffi-napiを経由してコールバック関数をPython側に渡す必要が出てくる。

これ、できなくはないが、めんどくさい。コールバック関数の型定義が複雑(個人の感想です)

ところで、PythonもNode.jsもhttp通信が追加モジュールなしで行える!

これだ! Python側で取得したキーイベントをJSONでNode.js側にPOSTすれば解決だ。

HTTPクライアントとHTTPサーバーを一つのプログラムで動かすには

シングルプロセスで、シングルスレッドではNode.jsからPythonを呼び出して、Python側でHTTPを呼び出しても、 Node.js側が受信待ち状態になってないので、アウト!という落とし穴が待ち受けていたw

しかし、この問題の対策として、Node.js側でスレッドを追加してマルチスレッドにすることで対応できる!

// HTTPサーバーを別スレッドで動かす
    worker = new Worker(path.join(__dirname, "../httpserver.js"), { workerData: 0 })
    worker.on('message', (msg) => {
      //console.log(`${msg}`)
      event.reply('nsevent', msg)
    })

    // Pythonを呼び出す
    const voidPtr = ref.refType(ref.types.void);
    const handle = ffi.DynamicLibrary('/usr/local/Cellar/python/3.7.5/Frameworks/Python.framework/Versions/3.7/Python')

    libpython.Py_SetProgramName = ffi.ForeignFunction(
      handle.get('Py_SetProgramName'),
      'void',
      [voidPtr]
    )
    libpython.Py_Initialize = ffi.ForeignFunction(
      handle.get('Py_Initialize'),
      'void',
      []
    )
    libpython.PyRun_SimpleString = ffi.ForeignFunction(
      handle.get('PyRun_SimpleString'),
      'int',
      ['string']
    )
    libpython.Py_Finalize = ffi.ForeignFunction(
      handle.get('Py_Finalize'),
      'void',
      []
    )

    libpython.Py_SetProgramName(null)
    libpython.Py_Initialize()
    const pyscript = fs.readFileSync(path.join(__dirname, 'api.py'))

    libpython.PyRun_SimpleString(pyscript)   

httpserver.js

// server.js
// worker thread
const { parentPort } = require('worker_threads')
const http = require('http')

const server = http.createServer()

server.on('request', (req, res) => {

    if (req.method === 'POST') {
        var data = ''

        //POSTデータを受けとる
        req.on('data', (chunk) => { data += chunk })
            .on('end', () => {
                res.writeHead(200, { 'Content-Type': 'text/plain' })
                res.write('hello world')
                res.end()
                
                parentPort.postMessage(data)
            })

    } 

    //console.dir(req)

})

server.listen(3000)

server.on('listening', () => {
    // ここで random port が取れる
    const port = server.address().port
    console.log(`port = ${port}`)
    parentPort.postMessage(`port = ${port}`)
})

parentPort.on('message', (msg) => {
    process.exit()
})

Cocoa APIの罠

前述の一連の方法を素のnode.jsで動かそうとするとCocoa APIの呼び出しで

(スクショ撮り忘れてるので、後日貼ります。。)

というようにmacOSからダメだしされる。

electronで動かすことで対処できる。おそらくmacOSのアプリとして必要なAPIの呼び出しをelectronが済ませてくれるからと 思われる。

また、コマンドラインから動かす場合、使っているターミナルソフト(標準ならTerminal.app)にコントロールパネルの セキュリティ設定でアクセス許可を与える必要がある。

electronをツールなりでパッケージ化して.appにして動かす場合は、.appを一度起動して、同様にコントロールパネルの セキュリティ設定でアクセス許可を与える必要がある。

f:id:kjw_junichi:20191206064704p:plain

electronをパッケージ化して発覚した問題

どうもworkerを作る際にasar形式の中のファイルにアクセスできないっぽい。

対応策としては、.appの中のResourceにworkerで読み込む.jsファイルを格納して物理的に独立したファイルとして 用意。

const builder = require('electron-builder')
const Platform = builder.Platform
const fs = require('fs')

builder.build({
    targets: Platform.MAC.createTarget(),
    config: {
        'appId': 'com.hatenablog.abrakatabura.keycastjs',
        'target': 'dir' 
    }
}).then(()=>{
    fs.copyFileSync('./httpserver.js','dist/mac/keycastjsdev.app/Contents/Resources/httpserver.js')
}).catch((e) =>{
    console.log(e)
})

実行結果

ごちゃごちゃ書いてもコードが動かないと説得力ないので、動画を上げました。

www.youtube.com

成果物

ごめんなさい、Keycast.jsにこれらの成果を反映させた版は担当の日までに間に合いませんでした。。 出来れば、年内に、Githubに上げたいです。

実験用リポジトリを晒しておきます。

github.com

git clone 

cd keycastjsdev
npm install
node build.js
open dist/mac/keycastjsdev.app

まとめ

  • Node.jsでマルチスレッドを使った。
  • プログラム内でHTTP通信することで、割と疎結合PythonとNode.jsをつなぐことが出来た。
  • Node.jsの12系でnpm installしたネイティブモジュールはElectron v7系でもそのまま動くことが分かった。
  • Electronをアプリ化してスレッドを使う際は.jsファイルをasarの外に置く必要がありそう。
  • PythonでのJSONをPOSTする方法が学べた。
  • Node.jsでのPOSTされたJSONを受取る方法が学べた。

関連記事

9年前の記事

5年前の記事

3年前の記事

2年前の記事

1年前の記事