FreePascalとDelphiでリフレクションとxUnitフレームワーク

DelphiとFreePascalで動作する自作ライブラリのテストコードを書くために、自作のxUnitフレームワークを使っています。

github.com

自作のテストフレームワークでは、 Test で始まるメソッド名をテストメソッドとして自動で実行するxUnitフレームワークでよくある仕様としています。

このような仕様を実装する場合に、フレームワーク側でテストコード側のメソッド名を取得するコードが必要となります。 私のテストフレームワークでは、リフレクションでこれを実装することにしました。

元々Delphiのみ対応で最初に作成したのですが、FreePascalに対応するPullRequestをもらってマージしたので、そこからはDelphi, FPC両対応としてメンテナンスしています。

DelphiとFreePascalでリフレクションの実装状況に違いがあるので、コンパイラ指令で環境ごとに処理を分けることになります。

どちらも実行時型情報(RTTI)が必要になるので、実装クラス側はpublished宣言でテストメソッドを定義して利用します。

テストコードの例(抜粋):

delphi-argparse/src/common/Nullpobug.ArgumentParserTest.pas at main · tokibito/delphi-argparse · GitHub

// 中略

  TArgumentParserAddArgumentTest = class(TTestCase)
  private
    FArgumentParser: TArgumentParser;
  public
    procedure SetUp; override;
    procedure TearDown; override;
  published
    procedure TestTArgument;

// 中略

procedure TArgumentParserAddArgumentTest.TestTArgument;
begin
  FArgumentParser.AddArgument(TArgument.Create('--foo', 'bar', saStore));
  AssertEquals(FArgumentParser.ArgumentsCount, 1);
  AssertEquals(FArgumentParser.Arguments[0].Option, '--foo');
  AssertEquals(FArgumentParser.Arguments[0].Dest, 'bar');
end;

Delphi向けのTRttiContextを使った実装

Delphi向けの実装についてはTRttiContextを使って実装しています。

TRttiContextは、実行時型情報を扱うためのクラスで、メソッドオブジェクトを取り出して実行したりできます。

10年以上前ですが記事にまとめています。

tokibito.hatenablog.com

FreePascal向けの実装

FreePascal向けの実装については、DelphiのTRttiContext相当のクラスが無いので、仮想メソッドテーブルからメソッドのエントリを取り出して、メソッド名を取得しています。

先月記事にまとめています。

tokibito.hatenablog.com

自作のテストフレームワークを作ってみて良かったこと

自作のテストフレームワークを作った元々のモチベーションとしては、Delphiの標準のテストフレームワークであった DUnit だとXML形式でテスト結果をレポートする機能がなくて、JenkinsなどのCIツールから結果を集計するのが手軽にできなかったり、APIのスタイルで気になる部分もあり、「1ファイルで動くような手軽なテストフレームワークがあるとよいな」と思ったところからでした。

ちょうどDelphiにTRttiContextが実装されたところでもあったので、思ったより簡単にできるかも、と考えて作成してました。

FreePascal向けのPullRequestをマージしてからは、FreePascalの仕様を読みつつメンテするのも勉強になりました。

クラスシステムをメモリ上にどのように展開して処理を呼び出す、みたいな部分もイメージを持てるようになってよかったです。

FreePascalでリフレクションを実装する

リフレクションは便利なのでFreePascalでも使いたい。

Delphiの場合はRttiユニットがあるのでリフレクションは簡単に実装できるのですが、FreePascalには同等のユニットがありません。

昔作成してGitHubで公開していたテストフレームワークのほうにPullRequestをもらって、FPC用のリフレクション実装をマージしたことがあったのですが、古いバージョンのFPCでしか動かないものでした。

x86_64のFreePascalCompilerで動くように試行錯誤してうまくいったので、まとめておきます。

メモリレイアウトの情報を見つけきれなくて、結局メモリダンプで推測しつつ調整することになりました。

試したFPCのバージョンは、 Free Pascal Compiler version 3.2.2+dfsg-9ubuntu1 [2022/04/11] for x86_64

コード

my_class.pp:

unit my_class;

{$M+}  // publishedを使うのでM+オプション指定
{$MODE Delphi}

interface

type
  TMyClass = class(TObject)
  published  // RTTI生成のためにpublishedを使う
    procedure SayHello;
  end;

implementation

procedure TMyClass.SayHello;
begin
  WriteLn('Hello');
end;

end.

invokes.pp:

unit invokes;

{$MODE Delphi}

interface

uses
  SysUtils;

type
  TMethodtableEntry = packed record
    Name: PShortString;  // メソッド名
    Address: Pointer;  // メソッドの関数ポインタ
  end;

  TPlainMethod = procedure of object;  // 今回は引数無しのメソッド

  procedure InvokeMethod(Obj: TObject; Name: string; AClass: TClass);

implementation

procedure InvokeMethod(Obj: TObject; Name: string; AClass: TClass);
var
  pp: ^Pointer;
  pMethodTable: Pointer;
  pMethodEntry: ^TMethodTableEntry;
  I, numEntries: Word;
  VMethod: TMethod;
  VPlainMethod: TPlainMethod absolute VMethod;
begin
  if AClass = nil then Exit;
  pp := Pointer(NativeUInt(AClass) + vmtMethodtable);  // 仮想メソッドテーブルのオフセット分アドレスずらし
  pMethodTable := pp^;
  if pMethodtable <> nil then begin
    numEntries := PDWord(pMethodTable)^;  // メソッドテーブルのエントリ数をポインタ経由で取得
    pMethodEntry := Pointer(NativeUInt(pMethodTable) + SizeOf(DWord));  // ポインタ分ずらしてエントリにアクセス
    for I := 1 to numEntries do
    begin
      if LowerCase(pMethodEntry^.Name^) = LowerCase(Name) then  // 指定されたメソッド名と同じ場合
      begin
        VMethod.Code := pMethodEntry^.address;  // メソッドの関数ポインタ
        VMethod.Data := Obj;  // SelfをAssign
        VPlainMethod;  // メソッド呼び出し
      end;
      pMethodEntry := Pointer(NativeUInt(pMethodEntry) + SizeOf(TMethodtableEntry));
    end;
  end;
  InvokeMethod(Obj, Name, AClass.ClassParent);
end;

end.

method_invoke.lpr:

program method_invoke;

{$MODE Delphi}

uses
  MyClass in './my_class.pp',
  Invokes in './invokes.pp';

var
  obj: TMyClass;

begin
  obj := TMyClass.Create;
  // TMyClassのSayHelloを呼び出し、Selfにはobjを指定
  InvokeMethod(obj, 'SayHello', TMyClass);
end.

ビルド

$ fpc method_invoke.lpr

実行結果

$ ./method_invoke
Hello

参考

FreePascalのコンソールアプリでメモリダンプ

FreePascalのコンソールアプリで、デバッグするときにメモリダンプするユーティリティがほしかったので、ChatGPTにざっくり書いてもらって少し手直しして使っている。

コード

memory_dump.pp:

program memory_dump;

uses
  SysUtils;

procedure DumpMemory(addr: Pointer; size: Integer);
var
  i, j: Integer;
  p: PByte;
  asciiLine: string;
begin
  p := PByte(addr);
  for i := 0 to (size div 16) - 1 do
  begin
    // 16バイトずつ表示
    Write(Format('%p ', [Pointer(p)])); // メモリアドレスを表示

    asciiLine := ''; // ASCII表現の行

    // 16バイト分のデータを表示
    for j := 0 to 15 do
    begin
      Write(Format('%02x ', [p^])); // 16進数で表示
      if (p^ >= 32) and (p^ <= 126) then
        asciiLine := asciiLine + Chr(p^) // 表示可能なASCII文字を保存
      else
        asciiLine := asciiLine + '.'; // 表示できない文字は '.' に置換

      Inc(p);
    end;

    // ASCII表現を表示
    WriteLn(' ', asciiLine);
  end;

  // 残りのバイトがある場合
  if (size mod 16) <> 0 then
  begin
    Write(Format('%p ', [Pointer(p)])); // メモリアドレスを表示
    asciiLine := ''; // ASCII表現の行

    // 残りのバイトを表示
    for j := 0 to (size mod 16) - 1 do
    begin
      Write(Format('%02x ', [p^]));
      if (p^ >= 32) and (p^ <= 126) then
        asciiLine := asciiLine + Chr(p^)
      else
        asciiLine := asciiLine + '.';

      Inc(p);
    end;

    // 残り部分の整列
    for j := (size mod 16) to 15 do
      Write('   ');

    // ASCII表現を表示
    WriteLn(' ', asciiLine);
  end;
end;

var
  testData: array[0..31] of Byte;
  i: Integer;
begin
  // ダミーデータの初期化
  for i := 0 to High(testData) do
    testData[i] := i + 32;

  // メモリダンプの実行
  DumpMemory(@testData, SizeOf(testData));
end.

コンパイル

$ fpc memory_dump.pp

実行結果

$ ./memory_dump
000000000047D530 20 21 22 23 24 25 26 27 28 29 2A 2B 2C 2D 2E 2F   !"#$%&'()*+,-./
000000000047D540 30 31 32 33 34 35 36 37 38 39 3A 3B 3C 3D 3E 3F  0123456789:;<=>?

メモリアドレスの表示とascii出力があるので、ポインタのアドレス探しつつ... みたいなデバッグで役に立ちました。 このprocedure自体は、そのままDelphiでも使えそう。

Djangoで作成したアプリを本番環境で動かすときにWSGIとASGIのどちらを使うのか

先日、 django-jaのDiscord で出た話題ですが、Djangoで作成したアプリを本番環境で動かす際のアプリケーションサーバーについてです。

具体的にはWSGIとASGIどちらを使うのか、そしてアプリケーションサーバーはどれがよいのか。

WSGIかASGIか

DjangoWSGIかASGIのどちらで動かすかですが、迷うぐらいなら現状はWSGIでいいです。

「ASGIは必要になったら使う」ぐらいの気持ちでいるのをおすすめします。

ASGIは、PythonのAsync(非同期)に対応するための、アプリケーションインターフェースです。

asgi.readthedocs.io

WSGIをおすすめする理由は次の通り。

ASGIをおすすめしない理由は次の通り。

  • 非同期処理になるのでデバッグしづらい
    • とにかくこれです。IO待ちの状況では並行で実行されてコンピュータリソース的には効率良いわけですが、エラーのときのスタックトレースが読みづらいです。実行順序もアプリによってはバラバラになることもあり、エラーの再現もしづらいです。
  • まだPython標準仕様ではない(が、デファクトスタンダードにはなっている)
    • 積極的に採用するほどでもない、という感じ

ASGIをおすすめする状況は、現状だと限定的かなあと思ってます。

  • Django Channelsを使っている場合
    • ほぼこれです。現在のDjango ChannelsはASGI前提にしているので、Django Channelsを使っている場合はASGIサーバーで動かすことになります。
  • アプリがASGI対応、Asyncのコードになっている場合
    • そもそもasync/awaitを使ったアプリの場合は、ASGIサーバーで動かさないと恩恵があまりないため、この場合もASGIサーバーを利用することになるでしょう。

参考: docs.djangoproject.com

Djangoでasync、awaitを使った場合、I/O待ちの部分では非同期処理によって高速化を期待できます。しかし、CPU依存の処理については特に速くなるわけではありません。

もしもWSGIアプリケーションのパフォーマンスを上げたい場合、次のような方法を試して、どうにもならない場合にようやく非同期IOやASGIサーバーを検討する、ぐらいでいいかなと思います。

  • アプリケーションサーバーの設定見直し
    • プロセス数を増やす
    • スレッド数を増やす
  • WSGIサーバーの変更を検討
    • gunicornの場合はuWSGIに変えてみる、など。
  • DBクエリの最適化
    • n+1クエリをなくす、速度の遅いクエリを改善する
  • DB自体の最適化
    • サーバー性能を上げて高速にする
    • インデックスを作ってクエリが速くなるようにする
  • 外部通信回数の削減
    • DBや外部ストレージなどとの通信レイテンシの影響を減らして高速化する
  • 外部呼び出し部分の非同期タスク化
    • ここでいう非同期タスク化は、Celeryを使ったり、バッチ処理にするという意味

ここに記載した方法以外にも、ウェブアプリ自体のパフォーマンス改善方法が色々あるので、それらを一通りやったあとに、非同期IOについて考えてみるぐらいでいいかも。

おすすめのWSGIサーバー

WSGIサーバーは好みもあるので、人によって答えは異なります。

個人的にはGunicornを長く使っていて、2024年現在でもこれをおすすめしています。

gunicorn.org

個人的に気に入ってる点は次の通りです。

  • Gunicorn自体のコードはPure Pythonで書かれている
    • 何か不具合のときに調査しやすい
    • セットアップでハマりが少ない
  • マルチプロセス+マルチスレッドでの動作に対応している
  • Workerスタイルでの動作のため、アプリケーションが不安定でプロセスが落ちても自動再起動がかかる

一方、気をつける点は以下です。

  • Linuxでしか動かない(そもそもコンテナで動かすなら最近はあまり問題にならないですが..)
  • Pure Pythonなので、限界までCPUリソースを使い切りたいアプリには向かない
  • uWSGIのように機能が豊富ではない(サイドカー的に色々動かすとかはuWSGIのほうが簡単)

おすすめのASGIサーバー

AWGIサーバーについては、おすすめできるほどの運用実績やノウハウがまだ個人的には少ないです。運用実績はあまり急激に増えることもなさそうです。

自社内としてはUvicornを使ってる部分が多いです。ただし、DaphneやHypercornを評価、比較したわけでもないです。

www.uvicorn.org

Uvicornを使っている理由としては、GunicornのWorkerとして動作させることもできるからです。 これはつまり、Gunicornのプロセス管理機能をそのまま使えるということです。

アプリケーション起因でWorkerプロセスが落ちても自動再起動できます。

Daphneで同様のことをしたい場合は、別途Supervisorなどを併用すればよいのですが、Gunicorn+Uvicornで同じPythonプロセスの中で完結するならそのほうが楽かなという印象を持ってます。

TechRAMEN 2024 Conferenceに参加しました

7/26(金)~7/27(土)に北海道旭川市で開催された TechRAMEN 2024 Conference に参加してきました。

techramenconf.net

Djangoハンズオン

私は2日目にDjangoのハンズオンセッションの講師をしました。休憩入れて4時間ほど、ボリュームのある内容になりました。

fortee.jp

ハンズオン資料は公開しています。

tokibito.github.io

家に帰ってきてから資料を少し調整しました。

ページ分割してSphinxの設定やCSSを変更したので、スマートフォンで見やすくなったかと思います。

パネルディスカッション

2日目、パネルディスカッションにも登壇しました。

fortee.jp

旭川の地元企業でがんばる みょう さんのお悩みについてワイワイ話す内容でした。

後日祭 OSS Gate(ゆるい勉強会@旭川

イベント翌日の7/28(日)は後日祭として同じ場所でOSS Gateのワークショップをやるとのことでしたので、参加してきました。

asahikawa.connpass.com

Pythonをテーマにしていた人が何人かいたので、サポートできて良かったと思います。

ラーメン

帰りに旭川空港で梅光軒のラーメンを食べました。うまいうまい

TechRAMEN懇親会のラーメンも美味しかったよ!

おわりに

仕事とかプライベートでも色々あり、この一週間とても忙しかったのですが、なんとか乗り切れてよかったです。

楽しいイベントだったので、また参加したい。旭川にもまたそのうち行きます。

django-bootstrap5を使って手軽にBootstrap5をDjangoのフォームに適用する

Bootstrapはフロントエンド向けのツールキットです。

getbootstrap.com

BootstrapにはCSSJavaScriptが用意されていて、ウェブページに組み込む場合は、HTMLタグにclass属性を指定すると、用意されているデザインが適用される、というものです。

メジャーバージョン間で互換性のない変更があり、Bootstrap4(バージョン 4.x)、Bootstrap5(バージョン 5.x)のようにメジャーバージョンの番号付きで呼ばれることがあります。

DjangoでBootstrapを使うには

BootstrapはHTMLタグにclass属性を指定できれば使えるので、DjangoでもテンプレートでCSSJavaScriptを読み込むように記述すれば使えます。

しかし、DjangoのForm機能を使う場合、inputタグなどのform内で利用するHTMLの文字列生成は、アプリ側のテンプレートではなくDjango内部で行われるので、出力されるタグにclass属性を付与するには少し工夫が必要です。

この記事では説明しませんが以下の方法などがあります。

  • widgetのテンプレートファイルを作成し、優先で使われるようにテンプレートローダーを設定する
  • Formクラスを使う際に、フィールドのwidgetにattrsでclass属性を指定する
    • 通化する場合はメタクラスで差し込む方法もある
    • テンプレートタグを作成し、Widgetのattrsを書き換える

いくつかある方法のうち、テンプレートタグを使ってDjangoのformの出力内容を変更するアプローチで実装されてるのが django-bootstrap5 です。

django-bootstrap5 24.2 documentation

django-bootstrap5を使うと手軽にBootstrapをDjangoに組み込めます。

インストール

Installation - django-bootstrap5 24.2 documentation

PyPIからインストールできます。

pip install django-bootstrap5

INSTALLED_APPSに django_bootstrap5 を追加します。

INSTALLED_APPS = [
    # ...
    'django_bootstrap5',
]

INSTALLED_APPSへの追加は、ライブラリ内に含まれるテンプレートファイルや、テンプレートタグを使用するために必要な作業です。

導入

Quickstart - django-bootstrap5 24.2 documentation

ドキュメントからリンクされていますが、exampleのアプリを参考にするとわかりやすかったです。

django-bootstrap5/example at main · zostera/django-bootstrap5 · GitHub

Bootstrapはmetaタグでviewportの指定や、CSSファイル、JavaScriptファイルの読込みが必要になります。

Bootstrap側のドキュメントの通りにlinkタグやscriptタグを直接記述もできますが、django-bootstrap5では、ベースのテンプレートファイルが用意されています。 このファイルを継承して使っておくと、Bootstrapに関する各種設定変更をDjangoのsettings.pyからできるようになるのでおすすめです。

https://github.com/zostera/django-bootstrap5/blob/main/src/django_bootstrap5/templates/django_bootstrap5/bootstrap5.html

アプリ作成

例として myapp を作ります。

python manage.py startapp myapp

FormとView作成

FormとViewは特にbootstrap5を意識せずに、Djangoのドキュメント通りに作成します。

myapp/forms.py:

from django import forms


FAVORITE_COLORS_CHOICES = {
    "blue": "Blue",
    "green": "Green",
    "black": "Black",
}


class MyForm(forms.Form):
    name = forms.CharField(label="名前")
    body = forms.CharField(label="本文", widget=forms.Textarea)
    favorite_colors = forms.MultipleChoiceField(
        required=False,
        widget=forms.CheckboxSelectMultiple,
        choices=FAVORITE_COLORS_CHOICES,
    )

myapp/views.py:

from django.views.generic import TemplateView
from . import forms


class IndexView(TemplateView):
    template_name = 'index.html'

    def get_context_data(self):
        return {'form': forms.MyForm()}

form という名前のコンテキストで index.htmlDjango Formのインスタンスを渡しています。

URL設定

サンプルとして作成したプロジェクトは myproject です。 myproject/urls.py にルートのURL設定があります。 作成したIndexViewを有効にしておきます。

myproject/urls.py:

from django.contrib import admin
from django.urls import path

from myapp import views as myapp_views

urlpatterns = [
    # ... (省略)
    path('', myapp_views.IndexView.as_view()),
]

テンプレート作成

この例では myapp/templates ディレクトリ以下にファイルを作っていますが、実際に使用する場合はextendsに指定するテンプレートパスなどとの整合性がとれていれば、ここではなくてもよいです。

myapp/templates/bootstrap.html:

{% extends 'django_bootstrap5/bootstrap5.html' %}

{% block bootstrap5_title %}{% block title %}{% endblock %}{% endblock %}

django-bootstrap5に含まれるテンプレートファイルを継承して、プロジェクト内で使用するbootstrap用のベーステンプレートを作ります。titleブロックを定義しているので、継承したファイルでは bootstrap5_title ブロックを使わずに、 title ブロックで title部分を記載できます。exampleと同様です。

myapp/templates/base.html:

{% extends 'bootstrap.html' %}

{% load django_bootstrap5 %}

{% block bootstrap5_content %}
    <div class="container">
        <h1>{% block title %}(no title){% endblock %}</h1>

        {% autoescape off %}{% bootstrap_messages %}{% endautoescape %}

        {% block content %}(no content){% endblock %}
    </div>

{% endblock %}

アプリ(プロジェクト)用のベーステンプレートです。各画面はこの base.html を継承して作成する想定です。exampleを参考に必要な部分だけを記載しています。

myapp/templates/index.html:

{% extends 'base.html' %}

{% load django_bootstrap5 %}

{% block title %}
Bootstrap5 フォーム
{% endblock %}

{% block content %}
<form method="post">
  {% csrf_token %}

  {% bootstrap_form form layout=layout size=size %}

  {% bootstrap_button button_type="submit" content="OK" %}
  {% bootstrap_button button_type="reset" content="Reset" %}
</form>
{% endblock %}

index.htmlでは django-bootstrap5 のテンプレートタグを使用しています。 bootstrap_formタグにformコンテキスト(IndexViewから渡されたMyFormのインスタンス)を渡して、class属性の付与などを行っています。

実行結果

ブラウザで http://127.0.0.1:8000/ にアクセスすると、Bootstrap5のデザインが適用されたフォームが表示されます。

サンプルコード全部

sample_nullpobug/django/django_bootstrap5 at main · tokibito/sample_nullpobug · GitHub