‘.NET’ カテゴリーのアーカイブ

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 側の修正には汎用的なものも多いので、そのうち本家に還元できるといいですね。

murave

IronPythonとジェネリックと初期値わたし

C#勉強会に行って自己紹介で「MVVMパターン用のフレームワーク使ってViewとViewModelはC#、ModelはIronPythonで書います」と言ったら微妙な空気になりました、夏。

muraveです。

というわけでC#とIronPythonを組み合わせて書いてます。基本的に配列や辞書は便利なのでPythonのモノ使ってましたがパフォーマンス・チューニングやっててジェネリック使ったほうがいいところもあるかな、と。

IronPython側でのジェネリックの書き方は知っててたまに使ってたんですが(C#での<>が[]になります)、試したら普通に初期値も与えられるのにちょっと感激。

C#で書くとList<string>の場合

>>> import site
>>> import clr
>>> from System.Collections import Generic as DotNetCollectionsG

>>> dot_l = DotNetCollectionsG.List[str]()
>>> print dot_l
List[str]()

>>> dot_l = DotNetCollectionsG.List[str](['a', 'b'])
>>> print dot_l
List[str](['a', 'b'])
C#で書くとDictionary<string, int>の場合

>>> import site
>>> import clr
>>> from System.Collections import Generic as DotNetCollectionsG

>>> dot_dict = DotNetCollectionsG.Dictionary[str,int]()
>>> print dot_dict
Dictionary[str, int]()

>>> dot_dict = DotNetCollectionsG.Dictionary[str,int]({'a':1 ,'b':1})
>>> print dot_dict
Dictionary[str, int]({'b' : 1, 'a' : 1})
ね♪

helpみたら普通に書いてありました。

>>> help(dot_dict)
Help on Dictionary[str, int] object:

class Dictionary[str, int](object)
| Dictionary[str, int]()
| Dictionary[str, int](capacity: int)
| Dictionary[str, int](comparer: IEqualityComparer[str])
| Dictionary[str, int](capacity: int, comparer: IEqualityComparer[str])
| Dictionary[str, int](dictionary: IDictionary[str, int])
| Dictionary[str, int](dictionary: IDictionary[str, int], comparer: IEqualityComparer[str])
|
以下略

えらいなぁ。

murave

VB.NET + NUnit 2.5 の Assert.Throws で例外のテスト

[ブログ紹介] NUnit 2.5 で追加された Assert.Throws: TDD.NET

を参考に VB.NET の書き方にしただけですが。

Imports NUnit.Framework
Public Class ExceptionTest
Private Shared Function throwArgumentException(ByVal obj As Object) As String
Throw New ArgumentException("VarType Error: " & TypeName(obj))
End Function

Public Sub ArgumentExceptionTest(ByVal obj As Object, ByVal expected As String)
Dim ex = Assert.Throws(Of ArgumentException)(
Function() throwArgumentException(obj)
)
Assert.That(ex.Message = expected)
End Sub
End Class

サンプルコードは実際のコードを編集して書きましたが動作確認してないので参考程度に。

.NET のサンプルってC#の場合が多いので「VB.NETではどう書けばいいのかなぁ」ってことも結構あります。

miyazaki

VS2010+NUnit2.5.10でテストメソッドをデバッグ

VB.NET+NUnitで開発をしていますが、テストをしてもメソッドの戻り値しか確認できず不便だったので、テストメソッドもデバッグできるようにしました。

1.
ソリューションのプロパティからスタートアッププロジェクトをテストプロジェクトに変更。
マルチスタートアップでも可。
2.
テストプロジェクトのプロパティを開いてデバッグの開始動作を[外部プログラムの開始]に変更。
ファイル選択からnunit.exeを指定。
3.
開始オプションのコマンドライン引数にテストプロジェクトのdllを指定。

これでできるはずだったのですが、起動はしてもブレークポイントに反応してくれません。
調べてみると、
[VS2010][NAgile] VisualStudio2010 Express + NUnit でDebug実行ができない場合
こちらのページを発見しました。
どうやら.NET Framework 4.0だとデバッグ実行ができないみたいです。
なので上のページを参考にさせていただいて、nunit.exe.configを編集。

4.
nunit.exe.configのconfiguration内に下の3行を追加。


上書きして実行してみると、ちゃんとブレークポイントに反応するようになりました!