はじめに

これは、ドリコムAdvent Calendar 2021 の8日目です。


こんにちは、雑用系エンジニアの Smith です。
この記事では、 Go で書いたモジュールを PHP で動作させる、というエッセイをお届けしたいと思おいます。
オチがあるので業務に役立つ情報ではないと思います。年末だし、許して。


やれやれだわ・・・

「Go でモジュールのコード書きたいなー」
「でも顧客は PHP 使ってるんだよなー」
「やれやれだわ・・・」

そんなシチュエーション、誰もが一度や二度の経験があるはず。
そんなときは PHP Extension を Go で書きましょう。

そう思ったのでやってみました。


私自身は現在、AROW というプロダクトに関わっています。

https://arow.world/ja/

AROW は、「誰でも秒で位置ゲーが作れる」をコンセプトに ドラクエウ◯ークやポケモンG◯が秒で作れるプロダクトを目指しています。
AROW は従量課金やサブスクの SaaS ではなく、デベロッパー自身で AROW をホストする方式を採択することによって、誰でも自由にカスタマイズでき、安価に導入できるようにしています

AROW が提供するプロダクトは幅広く、現在では下記の 4つを提供しています。

  • 地図情報の入った AROW DB
  • AROW DB の GUI 管理ツール
  • Unity SDK
  • サンプル用サーバ API

見てお気づきの方もいらっしゃるかもしれませんが、位置ゲーを秒で作るために足りないピースがあります。
そう、プロダクト用のサーバ API です。

実際に「サーバー側をよしなにしてくれるのはないんですか?」っていう要望も頂いたこともあり、じゃあ作ればいいじゃんって思うんですが、AROW は不特定多数のデベロッパー様に使って頂くミドルウェアです。
デベロッパー様の開発環境がどのようなものかは AROW ではわからず、指定することもできません。
これがスマートフォンクライアントを対象としたプロダクトであれば、環境は iOS/Android と種類が限られているので、 C/C++ や Unity などのゲームエンジン向けの実装がデファクトでしょう。

一般的に、サーバ開発向けにモジュールを作る時、 Ruby なら gem、 JavaScript (Node.js) なら npm など、大抵はアプリケーション実装言語のエコシステムに乗っかるかと思います。
しかしながら、 Ruby で書いたものは Ruby インタプリタで評価しますし、 JavaScript で書いたものは JavaScript エンジンでしか動作しません。

「環境ごとにメンテすべきコードベースを少なくして保守コストを低めたい!」
「おんなじ内容を複数言語で実装するの、不毛の極み!」
「じゃあオレはストックオプションと有給5年欲しい!」

当然そう思いますよね、私は毎日思ってます。


アプリケーション開発言語を超越した可搬性の実現。
思い当たる手法は、コンテナ技術でモジュールが動作するサービスを提供するか、ネイティブライブラリ+言語バインディングの提供をするか、のどちらかになるかと思います。

当然、前者のコンテナ案も検討しましたが、下記の理由から見送っています。

  • コンテナで提供するサービスが顧客でカスタマイズしづらい
  • ミドルウェアの特性としてパフォーマンス(=UX)面での必要経費を最小に留めたい
  • 顧客がコンテナでサービス運用するかはわからない

さて、ネイティブライブラリ+言語バインディングの方向性で行こうかと決めたものの。
「今どき C/C++ で書きたくないよなぁ」という思いが現代人だったらよぎると思います。(私自身は C/C++ が大好きです)
また、 PHP であれば PHP Extension、 Java であれば JNI と、 なにかと C言語とのつなぎ込みが要されるので、 C から利用可能なインターフェースが空けられなければ話が始まりません。
これらの条件を満たす言語や技術はいくつかありましたが、 Go が最も手早く始められそうだったので Go を採択しました。

Go + CGO を採用した際、全体のアーキテクチャはざっくり下記のように、 Go で実装されたコアロジックに対してニーズに応じた個別のインターフェースを CGO で提供する形になります。

この構成にすると、言語バインディングは CGO 層に封じ込められ、純粋にコアロジックとのブリッジングをするだけに留めることができます。
また、言語バインディングのみでなく Web インターフェースとしての HTTP サーバなどの個別のユースケースも柔軟に追加できる、という副産物がありました。

ぼくの名前は CGO です。

Go 言語をご存じの方は多いかと思いますが、 CGO についてはもしかしたら耳慣れない方もいるかと思います。
以下は 公式の CGO のドキュメント からの引用ですが、一言で表すと C コードを呼べる Go パッケージを作れる技術です。

Cgo enables the creation of Go packages that call C code.

C コードを呼べる他、インターフェースを export すれば C から Go の関数を呼ぶこともできます。
ここからは、せっかくのテックブログなので CGO 実装の簡単な例を挙げていきます。

サンプル実装環境

  • ホストマシン: macOS 10.15.7
  • go 1.16.6

Go 層 から C 関数を呼び出す

例示用に、ただ単に引数の char ポインタに "hello world !" という文字列を割り当てるだけの C の関数を用意しました。

#ifndef C_H
#define C_H

int hello(char* ptr);

#endif
#include <stdio.h>
#include "c.h"

int hello(char* ptr) {
    return sprintf(ptr, "hello world !");
}

これを static library にします。

% clang -c -I. hello.c -o hello.o
% ar rc ./libhello.a hello.o

出来上がった static library が提供する関数を Go 層からコールします。
CGO ではコメントでコンパイルフラグを指定できます。
ここで先程作成した static library を参照するようにします。

package main

// #cgo CFLAGS: -I.
// #cgo LDFLAGS: -L. -lhello
// #include <stdlib.h>
// #include "hello.h"
import "C"
import (
    "fmt"
    "unsafe"
)

func CallC() {
    ptr := C.malloc(C.sizeof_char * 64)
    defer C.free(unsafe.Pointer(ptr))
    size := C.hello((*C.char)(ptr))
    b := C.GoBytes(ptr, size)
    fmt.Println(string(b))
}

func main() {
    CallC()
}

この .go ファイルを通常通りビルドします。

% go build -o cgosample ./main.go

実行時に main 関数から CallC 関数が実行され、 CallC 内部で C 関数の hello がコールされ、 C層で割り当てられた文字列がプリントされます。

% ./cgosample
hello world !

とっても簡便ですね。

C 層 から Go 関数を呼び出す

Go 関数を C 層に露出する際はコメントで export 名を指定します。

package main

import "C"

//export Hello
func Hello(outLen *C.int) *C.char {
    msg := internalHello()
    *outLen = C.int(len(msg))
    return C.CString(msg)
}

func internalHello() string {
    return "Hello Cgo !"
}

func main() {
    panic("don't execute me")
}

Hello 関数を持つ Go バイナリを -buildmode=c-archive でビルドします。
この際、 C 用の関数宣言が含まれる C ヘッダファイルが出力ファイルと同名で生成されます。

% go build -o libcgo.a -buildmode=c-archive ./main.go
# libcgo.h も生成される

C では出力されたヘッダファイルをインクルードし、関数を利用します。

#include "libcgo.h"
#include <stdio.h>

int main(int argc, char** argv) {
    int len;
    char* ret = Hello(&len);
    printf("%s %d\n", ret, len);
    return 0;
}

コンパイルフラグにはヘッダと Go からビルドしたライブラリを含めます。

% clang callcgo.c -lcgo -L. -I. -o callcgo

実行すると、Go 層で生成された C 文字列が出力されます。

% ./callcgo
Hello Cgo ! 11

モジュール開発においてはこの CGO の仕組みを利用し、コアロジックは Go 層で完結させ、ネイティブ向けのインターフェースは CGO で提供しよう、という作戦です。

『PHP Extension』!!わたしの事を呼ぶならそう呼べ!

さて、 PHP には PHP Extension という、ネイティブライブラリで PHP を拡張できる仕組みが提供されています。

ドリコムは PHP に強い会社ではないですし、私自身も PHP を触るのは 15年ぶりくらいなので、 PHP Extension の詳細な実装方法はここでは紹介しません。
好奇心が湧いてしまって挑戦したいと思った方は Zend 公式ドキュメント を読んで進めましょう。
また、それだけだと記載内容が古いので、どちらかと言うと PHP 公式リポジトリ の対象 PHP バージョンタグの Zend_API.h のマクロ定義の方がバイブルです。
これらを読んで乗り越えましょう。


サンプル実装環境

  • ホストマシン: macOS 10.15.7
  • Docker コンテナ: php:8.0.2-buster

先程の Go の Hello 関数を PHP から呼べるようにしたい場合は下記のようすれば呼べます。

まず、 PHP Extension 用の Go バイナリは Shared Object にしておきます。

% go build -o build/libcgo.so -buildmode=c-shared ./main.go

PHP Extention 本体のヘッダファイルでは、通常の C ヘッダファイルのように関数を宣言します。
ただし Zend エンジンで利用する関数として宣言するため、 ZEND_FUNCTION マクロを用います。

#ifndef _HELLO_PHP_H
#define _HELLO_PHP_H

ZEND_FUNCTION(hello);

#endif

PHP Extention 本体の実装ファイルは、 Zend が提供するマクロを駆使して Zend 関数を定義します。
ああ、言語バインディングってこんな感じだよなぁ・・・という気持ちになります。

#include <php.h>

#include "hello.h"
#include "libcgo.h"

ZEND_BEGIN_ARG_INFO_EX(arginfo_hello, 0, 0, 0)
ZEND_END_ARG_INFO()

zend_function_entry cgo_functions[] = {
    ZEND_NS_FE("Cgo", hello, arginfo_hello)
    PHP_FE_END
};

ZEND_FUNCTION(hello) {
    int len;
    char* msg = Hello(&len);
    zend_string *phpstr = zend_string_init(msg, len, 0);
    free(msg);

    RETURN_STR(phpstr);
}

zend_module_entry cgo_module_entry = {
    STANDARD_MODULE_HEADER,
    "cgo",
    cgo_functions,
    NULL,
    NULL,
    NULL,
    NULL,
    NULL,
    "1.0.0",
    STANDARD_MODULE_PROPERTIES
};

ZEND_GET_MODULE(cgo)

これを (あなたの想像の20倍の労力を用いて) ビルドして php.ini で使えるようにすると、 PHP コード上で先程定義した関数が使えるようになります。

<?php
$msg = Cgo\hello();
var_dump($msg);
% php test.php  
string(11) "Hello Cgo !"

本稿では本当にシンプルな実装の例示でしたが、もうちょっとサンプルのバリエーションが豊かなリポジトリを作ってあるので、よろしければご参照ください。
こちらでは PHP クラスや Go 構造体の取り扱いサンプルもあります。
https://github.com/dolow/go-cgo-c-php-example


さて、例示を並べたものの、やっていることは当初の目論見通りの純粋なインターフェースのブリッジングです。

Cgo 層も、 Hello の処理の実態を内部ロジックである internalHello 関数に移譲しているように、 インターフェースとコアロジックを分離したことで、モジュール開発上で生じるメンテ箇所は薄いインターフェース層のみで事足りるでしょう。

設計面と Go 関数を PHP からコールする、という PoC は完了しました。

(Apache PHP mod もいるけど)

ここまでやってきたのは良いものの、この話にはオチがあります。

このモジュール、 CLI から実行する分には問題ないのですが、Apache モジュールとして PHP を動作させている環境において、 Go の goroutine が正しく動作しない、あるいはメッセージングされているはずの channel 入力を永遠に待ち受ける、という問題に直面しました。
この問題は、 php-fpm を使おうが Apache PHP を CGI モードで動作させようが、 PHP を ZTS (スレッドセーフ) としてビルドしたものだろうが結果は変わりません。

(ZTS でビルドし直した PHP では C 層の pthread を用いた処理が正常に処理されることを確認しているので、おそらく channel の方の問題かと思われます)

Apache からの PHP の実行方法を CLI の PHP を叩くように変更することで正常動作することは確認しましたが、一介のモジュールがサーバーアプリケーションの実行環境に制約を入れること自体が微妙オブ微妙です。

何にせよ、CLI でのテストによる開発イテレーション上では何も問題にならなかったため、開発後期にならないと気付かない問題でした。
そもそもドリコムは PHP に強くないし、私自身も PHP は 15年ぶりくらいなので、こんなの気付かなくてもしょうがないです。

問題の要因となった goroutine や channel は、Go の基本ライブラリ内でも用いているため、これを解決するのは至難の業であることもあり、現在は Go + CGO を用いる手法は寝かせています。
ぴえん。

うるせェェー WASM を呼べェェーッ

だからと言って言語別の実装を愚直に増やしたくないので、まだまだ諦めていません。
それに、Apache + PHP がダメでも、他の言語なら全然活きる可能性があります。
っていうか、何かと理由を付けて Rust でやりたいです。

Go には go2cpp という、 あの ebiten でも用いられている OSS が公開されています。
このモジュールを利用した場合、 Go コードを C++ 化することができますが、WASM を前提とした C++ コードであればシングルスレッドで動作するのではないか・・・

そういう淡い期待を胸に、目的を見失わない程度にいつかもう一回くらい挑戦してみたいと思います。

「環境ごとにメンテすべきコードベースを少なくして保守コストを低めたい!」
「おんなじ内容を複数言語で実装するの、不毛の極み!」
「じゃあオレはストックオプションと有給5年欲しい!」

今も彼らの声が私の頭でリフレインし、明日を活きる活力となっています。

まとめ

  • Go で書いたモジュールが PHP (CLI限定) で動作した
  • Apache + PHP で地獄を見た
  • ebiten すげぇ
  • ストックオプションと有給5年欲しい
  • ドリコムは PHP に強い会社ではありません

それでは良いお年を、アリーヴェデルチ!


明日は・・・

広井淳貴さんによる「GitHub Actions+セルフホストランナーでUnityビルドをしてみた 」です、お楽しみに!
ドリコムでは一緒に働くメンバーを募集しています!
募集一覧はコチラを御覧ください!