Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

PHPからGoを呼び出す方法

サーバーサイドエンジニアの田実です!

ヤプリではPHPとGoを使っています。ということで(?)、PHPからGoを呼び出す方法について紹介します。 なおこの記事はGoからPHPを呼び出す方法の姉妹投稿(?)となっております。

※この記事は PHP Advent Calendar 2021の18日目の記事です!

IPC

PHPからGoを呼び出す手段としてspiral/goridgeというパッケージがあります。

github.com

GoのIPC用のサーバーを予め立てておきPHPでIPCでGoにアクセスする仕組みです。 本質的にはHTTPリクエストと変わらないのですが、IPCなのでフットプリントが少なく高速です。 Go製のPHPサーバーであるRoadRunnerはこちらのライブラリを使って動いています。

READMEの通り、Goのサーバーをこんな感じで定義します。

package main

import (
    "fmt"
    "net"
    "net/rpc"

    goridgeRpc "github.com/spiral/goridge/v3/pkg/rpc"
)

type App struct{}

func (s *App) Hi(name string, r *string) error {
    *r = fmt.Sprintf("Hello, %s!", name)
    return nil
}

func main() {
    ln, err := net.Listen("unix", "/tmp/rpc.sock")
    if err != nil {
        panic(err)
    }

    _ = rpc.Register(new(App))

    for {
        conn, err := ln.Accept()
        if err != nil {
            continue
        }
        _ = conn
        go rpc.ServeCodec(goridgeRpc.NewCodec(conn))
    }
}

PHP側は composer require spiral/goridge でパッケージをインストールして以下のようにして呼び出します。

<?php

use Spiral\Goridge;
require "vendor/autoload.php";

$rpc = new Goridge\RPC\RPC(Goridge\Relay::create('unix:///tmp/rpc.sock'));

echo $rpc->call("App.Hi", "Antony");

手軽な方法なのですが、Goのサーバーを管理する必要があったり、直接呼び出しているわけではないので微妙な感じです…。

Cの共有ライブラリをFFIで読み込む

Goを使ってCの共有ライブラリを作成し、それをPHPからFFI経由で読み込むことで何となくPHPからGoを呼び出している気持ちになります。

package main

import (
  "C"
  "fmt"
)

//export hello
func hello() { fmt.Println("world") }

//export concat
func concat(a, b *C.char) *C.char {
  return C.CString(C.GoString(a) + C.GoString(b))
}

func main() {}

ビルドは-buildmode c-shared を指定すればOK

$ go build -o example.so -buildmode c-shared main.go

PHP側はヘッダを定義しつつC共有ライブラリを読み込むことで呼び出せます。

<?php

$ffi = FFI::cdef(<<<EOS
const char* concat(char* a, char* b);
void hello();
EOS,  __DIR__ .'/example.so');
$ffi->hello();

$result = $ffi->concat('hoge', 'fuga');
echo $result;

ヘッダの定義やCStringとGoStringの変換などcgo系のハンドリングが若干面倒です…。

Cの共有ライブラリをarnaud-lb/php-goで読み込む

FFI経由ではなく arnaud-lb/php-go というパッケージ経由で読み込むとcgoのハンドリングの手間が省けます。 github.com

共有ライブラリを読みこむPHPのC拡張をビルドします。

$ git clone git@github.com:arnaud-lb/php-go.git
$ cd ./php-go/ext
$ phpize && ./configure && make && sudo make install

Goのコードを実装します。

package main

import (
  "strings"
  "github.com/arnaud-lb/php-go/php-go"
)

var _ = php.Export("example", map[string]interface{}{
  "toUpper": strings.ToUpper,
})

func main() {}

共有ライブラリとしてビルドします。

$ go build -o example.so -buildmode c-shared .

こんな感じでPHPスクリプトを書きます。

<?php

$module = phpgo_load("/path/to/example.so", "example");

ReflectionClass::export($module);

var_dump($module->toUpper("foo"));

phpgoの共有ライブラリをextensionとして有効化してPHPを実行します。

$ php -dextension=phpgo.so test.php # => FOO

cgoのハンドリングをする手間がなくなりましたが、手順が若干複雑です…。

Goのインタープリターを実装する

PHPでGoをパースしてASTを構築し、それを自前のインタープリターで解釈します。 残念ながらPHPで書かれたGoのパーサーが見つからなかったので、パーサーも自前で実装する必要があります。

ということで実装したパーサー・インタープリターはこちらです。 github.com

実行方法はこんな感じ

<?php

require_once 'vendor/autoload.php';

$runner = new \GoInterpreter\Runner();
$runner->run('/path/to/gofile', 'func_name');

簡単!

パーサーはANTLRで実装しており、GoのgrammarファイルPHPランタイムを利用しています。

しかしながら、実行してみるとわかるのですが、めちゃくちゃ遅いです。簡単な四則演算をreturnさせるだけでも10秒くらいかかります…w ANTLRでのパースに時間がかかってそうなのですが、ネタ枠なのでこれ以上追求するのは諦めました。

おまけ: ANTLRを使ったGoのパーサーの作り方

ANTLRのインストール

$ curl -O https://www.antlr.org/download/antlr-4.9-complete.jar
$ mv antlr-4.9-complete.jar /path/to/lib

環境変数やaliasを設定

export CLASSPATH=".:/usr/local/lib/antlr-4.9-complete.jar:$CLASSPATH"
alias antlr4='java -Xmx500M -cp "/usr/local/lib/antlr-4.9-complete.jar:$CLASSPATH" org.antlr.v4.Tool'
alias grun='java -Xmx500M -cp "/usr/local/lib/antlr-4.9-complete.jar:$CLASSPATH" org.antlr.v4.gui.TestRig'

grammarファイルからパーサーの自動生成

$ composer require antlr/antlr4-php-runtime
$ antlr4 -visitor -Dlanguage=PHP GoParser.g4

自動生成されたパーサーで生成されたASTはインタープリターなどを実装するには構造的に扱いづらいのでアプリケーションで利用しやすい構造に置き換えます。

<?php

class ASTConverter
{

// ...

    public function visitPrimaryExpr(Context\PrimaryExprContext $context)
    {
        if ($context->primaryExpr()) {
            $expr = $context->primaryExpr()->accept($this);
            if ($context->IDENTIFIER()) {
                $expr->child = new Identifier($context->IDENTIFIER()->getText(), null);
                return $expr;
            }
            if ($context->arguments()) {
                return new MethodCall($expr, new Arguments($context->arguments()->accept($this)));
            }
            return null;
        }
    }

// ...

ASTを作ったらそれに対してvisitorパターンでNodeを順に舐めていき処理を実行します。

<?php

class Interpreter
{
    public function visit(Node $node)
    {
        if ($node instanceof File) {
            foreach ($node->functions['main']->statements as $statement) {
                $statement->accept($this);
            }
        }
        if ($node instanceof MethodCall) {
            $arguments = [];
            foreach ($node->arguments as $argument) {
                $arguments[] = $argument->accept($this);
            }
            $receiver = $node->expr->accept($this);
            if ($receiver === 'fmt.Println') {
                echo $arguments[0]->value . PHP_EOL;
            }
        }

// ...

あとはこんな感じで実行すればOK。

<?php

$input = InputStream::fromPath('/path/to/gofile');
$lexer = new GoLexer($input);
$tokens = new CommonTokenStream($lexer);
$parser = new GoParser($tokens);
$parser->addErrorListener(new DiagnosticErrorListener());
$parser->setBuildParseTree(true);
$file = $parser->sourceFile();

$converter = new ASTConverter();
$tree = $converter->visit($file);

$interpreter = new Interpreter();
$interpreter->visit($tree);

まとめ

PHPからGoを呼び出す方法を紹介しました!

ネタでインタープリター作ろうと思ったら意外と共有ライブラリ系のアプローチがイケてるなと思いましたw