user icon

RoundcubeをPeachPieで.NETの上に載せてみた話

TL;DR

  • Roundcube 1.7.5 を PeachPie で .NET にコンパイルして、Kestrel 上で動かしました。
  • 普通に使える状態です。ログイン、閲覧、送受信、添付、スペルチェック、LDAPアドレス帳まで確認しました。
  • Roundcube 本家のユニットテストは、PHP 8.4 で通る 1,262 件が全部通るところまで追い込みました。そのために PeachPie 側に30本ちょっとのパッチを当てています。

きっかけ

弊社では Roundcube を長いこと使っていて、カスタマイズもいろいろやってきました。
ある日ふと「PHP を .NET で動かす PeachPie ってありましたよね。あれで Roundcube ってそのまま動くんでしょうか?」と思いまして。
動けば PHP-FPM なしで dotnet 一発で起動できます。.NET のライブラリと混ぜられる可能性もありますし、とりあえずやってみることにしました。

PeachPie って何

PHP のソースを .NET のアセンブリ(DLL)にコンパイルしてくれるコンパイラです。
ASP.NET Core のミドルウェアとして PHP のサイトを載せられるので、WordPress を動かすデモなどが公開されています。

ただ、NuGet で配布されている版は少し前のもので、PHP 8.1 までの構文で止まっています。
Roundcube 1.7 は新しい構文を使っているので、今回は GitHub の master をソースからビルドして使いました。

とりあえず動かす

構成はシンプルです。

  • アプリ: Roundcube を PeachPie でコンパイルした DLL + Kestrel(コンテナ1個)
  • DB: MySQL(コンテナ1個)
  • 前段: 既存のリバースプロキシから振り分け

コンテナは rootless の Podman で、systemd のユーザーユニットで起動しています。
設定は、既存の Roundcube 環境の config.inc.php をほぼそのまま持ってきました。
PeachPie では設定ファイルもコンパイル対象なので、設定を変えるたびにビルドし直しになります。

<!-- Website.msbuildproj(抜粋イメージ) -->
<Project Sdk="Peachpie.NET.Sdk/1.2.0-xxx">
  <ItemGroup>
    <Compile Include="**/*.php" Exclude="tests/**;vendor/**/tests/**" />
  </ItemGroup>
</Project>

ビルドが通ってログイン画面が出たときは、ちょっと感動しました。
……が、ログインしたら日本語のメールが化けました。ここからが本番です。

ハマりどころ: 文字列は「文字」か「バイト」か

PHP の文字列は、ただのバイト列です。strlen() はバイト数を返しますし、$s[0] は1バイトです。
一方の PeachPie は、なるべく .NET の文字列(UTF-16)のまま扱おうとします。その結果、こうなります。

  • strlen('あ') が 3 ではなく 1 になる
  • ord() がバイト値ではなく文字コードを返す
  • 正規表現の [\x80-\xFF] がマッチしない

メールまわりはまさにバイト列の世界なので、これがあちこちに効いてきます。
例えば IMAP の「リテラル」は {123} のようにバイト数を前置きしてデータを送ります。ここが文字数で数えられていると、日本語の件名を含むメールでサーバーとの会話がずれてしまいます。

対策としては、PeachPie 側で「このデータはバイト列として扱う」区別をきちんと付けるようにしました。
strlen() や strpos()、preg 系がバイト列のときはバイト単位で動くようにする、といった修正です。
Roundcube 側も、バイト数が重要な箇所を数か所だけ明示的にバイト列として扱うよう直しています。

足りない拡張は作る

PeachPie は主要な拡張を一通り持っていますが、Roundcube が使うものの中には未実装や不完全なものがありました。

  • mbstring: PHP 8.4 が対応する文字コードを全部揃えました。ISO-2022-JP はもちろん、EUC-TW や ArmSCII-8 まで入れています。
  • iconv: .NET に無い文字コードは mbstring 側の実装を使います。
  • ldap: libldap を P/Invoke で呼び出して実装しました。アドレス帳の検索やページングも動きます。
  • pspell: libaspell を P/Invoke で呼び出して実装しました。スペルチェックが使えます。
  • そのほか、bcrypt と PBKDF2、AES-GCM、IntlDateFormatter、Spoofchecker、XMLWriter、SplFixedArray なども追加しました。

コンパイラ本体のバグにもいくつか当たりました。
引数展開(...$args)の型の扱いや、__call に渡される引数が参照のまま渡ってしまう件などです。これらもパッチで直しています。

最終的に、PeachPie へのパッチが32本、Roundcube へのパッチが11本になりました。

本家のユニットテストで答え合わせ

画面で触って動作を確認するのには限界があります。そこで、Roundcube 本家のリポジトリにあるユニットテストを使うことにしました。
PHP 8.4 と PeachPie の両方で同じテストを流し、結果を突き合わせます。

PHPUnit そのものは PeachPie では動かしにくいので、PHPUnit 互換の小さなテストランナーを自作しました。
テストに必要な LDAP サーバーなども、テスト用にコンテナで立てています。

              pass   skip  fail/error
PHP 8.4       1,262  88    2 ※
PeachPie(最初)  —      —     80
PeachPie(最終)  1,264  88    0

※ PHP 側の2件は、テスト環境のロケール設定などによるものです。

最初は80件失敗していました。それを1件ずつ潰して0件にしています。
差分が出たらそのたびに PHP と PeachPie で同じコードを動かして比べる、という地道な作業です。
ユニットテストで見つかった不具合は、画面を触っているだけではまず気づかない種類のものが多かったです。やってよかったと思います。

まだ残っている差

完全に同じというわけではありません。分かっている差は次のとおりです。

  • バイト列由来でも、全部が正しい UTF-8 になってしまった文字列は、strlen() が文字数を返す場合がある
  • ライブラリ内部で出た警告が set_error_handler() に届かない
  • LDAP の SASL バインドは未対応

いずれも、普段使う範囲では影響が出ないことを確認しています。

蛇足

今回の作業は、ほぼ全部 Claude Code と一緒にやりました。
パッチ作成、テストランナーの自作、テストの差分を1件ずつ追う作業は、相当な量の試行錯誤です。人間だけでやっていたら、途中で心が折れていたと思います。笑
こちらは「0件になるまで追い込もう」「テスト用に LDAP コンテナを立ててもいいよ」と方針を出す係でした。

PeachPie 側の修正には汎用的なものも多いので、そのうち本家に還元できるといいですね。