Perl Hackers Hub

第77回モジュールの自動インポートによる開発効率向上 ~PPIによる静的解析と、モジュールロードによる動的解析の組み合わせで実現(2)

本連載では第一線のPerlハッカーが回替わりで執筆していきます。今回のハッカーはmomochiさんで、テーマは「モジュールの自動インポートによる開発効率向上(2⁠⁠」です。

<前回(1)こちら。>

動的解析による自動インポート

ここまでで、呼び出された関数のパッケージを静的解析し、必要なモジュールを自動でインポートできることがわかりました。しかし、my $v = somefunc()のようにパッケージが指定されていないsomefunc関数が呼び出されている場合に、インポートするべきモジュールを知るにはどうすればよいでしょうか。

一つの方法として、ライブラリでエクスポートされている関数を取得して、呼び出された関数から必要なモジュールをインポートする方法があります。具体的には、ライブラリのインクルードパスが格納されている@INCからライブラリのファイルを1つずつ見ていき、Exporterが参照する@EXPORT変数を解析します。

この方法は、@EXPORTにエクスポートする関数名が@EXPORT = qw(somefunc)のようにハードコードされている場合にはうまくいきます。しかし、@EXPORTは変数であり、右辺には文字列だけでなく関数も受けることができます。

Perlはコンパイルフェーズで振る舞いを変えることができる言語なので、ここに静的解析の限界があります。

本節では、エクスポートされている関数を動的に解析して必要なモジュールをインポートする方法について解説します。また、筆者が自動インポートモジュールを実装した際に気付いた注意点や課題を紹介します。

モジュールロードによる動的解析で自動インポートを実現

ライブラリ内でエクスポートされている関数をモジュールロードによる動的解析で取得し、パッケージが指定されていない関数から必要なモジュールをインポートします。

エクスポートされた関数を取得する

Exporterが参照する変数を、評価後にアクセスできればエクスポートされた関数を漏れなく取得できます。Exporterが参照している@EXPORT変数はour宣言されているので、シンボルテーブルにアクセスすることで値を取得できます。

たとえば、Exporterによるインポート/エクスポート」ExModuleで定義されている@EXPORTにアクセスするには次のようになります。ExModuleモジュールをロードするとExModuleで定義された@EXPORTが評価され、シンボルテーブルに登録されてアクセス可能になります。

use ExModule;
my @exports = @ExModule::EXPORT;

以上から、モジュールロードによって、エクスポートされた関数とモジュールのペアを取得できることがわかります。

パッケージが指定されていない関数から必要なモジュールをインポートする

静的解析によって、パッケージが指定されていない関数の候補を抽出できます。関数の候補は次の条件を満たすものとします。

どこに
変数宣言、式、sleep 1といった文
どのように
関数名は単語である

この条件を満たす関数の候補をPPIの木構造を利用して抽出します。たとえば、sleep 1の場合は文なのでPPI::Statementを満たし、sleepという関数名は単語なのでPPI::Token::Wordを満たすので、sleepが抽出できます。

my $candidates = $doc->find(
  sub {
    my $el = $_[1]; # 条件をテストするElement
    my $p = $el->parent; # 親のElement
    (
      # 例:sleep 1;にマッチ
      $p->class eq 'PPI::Statement' ||

      # 例:my $var = somefunc;にマッチ
      $p->isa('PPI::Statement::Variable') ||

      # 例:if(somefunc)にマッチ
      $p->isa('PPI::Statement::Expression')
    ) && $el->isa('PPI::Token::Word');
  }
);

次に、ライブラリのインクルードパス以下のファイルに対して、モジュールロードによってエクスポートされた関数を取得します。たとえば、lib/Module/A.pmで@EXPORT=qw(somefunc)とエクスポートされている場合には、(Module::A, somefunc)というモジュール名とエクスポートされた関数のペアを取得します。

@INCに含まれるライブラリのインクルードパス以下のファイル名からModule::Loadモジュールでモジュールロードすることで、ライブラリファイルで定義された@EXPORT変数にアクセスできます。よって、モジュール名とエクスポートされた関数のペアを取得できます。

my $module_to_funcs = {};
# @lib_filenamesは@INCのパス以下のファイル
for my $filename (@lib_filenames) {
  # Module/A.pmをModule::Aに変換
  my $module = $filename;
  $module =~ s/\//::/g;
  $module =~ s/\.pm$//;

  # Module::Loadモジュールのloadメソッド
  load $module;
  $module_to_funcs->{$module} //= [];
  push $module_to_funcs->{$module}->@*,
        @{$module . '::EXPORT'};
}

ここまでで、関数の候補とライブラリでエクスポートされた関数の情報がわかっています。関数の候補がエクスポートされた関数名と一致していれば、対応するモジュールをインポートの候補にします。以降の、対象のファイルにインポートする方法は「抽出したモジュールをインポートする」と同じなので省略します。

my $maybe_need_modules = [];
for my $func (@$candidates) {
  # $func_to_moduleは$module_to_funcsから作れる
  my $module = $func_to_module->{$func};
  push @$maybe_need_modules, $module if $module;
}

以上から、パッケージ名を指定しない関数の呼び出しに対しても自動インポートを実現できました。

モジュールロード時に注意すべきこと

モジュールロードを行う際に注意するべきことは主に2つあります。

1つ目は、モジュールロードを正常に行うには普段の開発環境上で行う必要があることです。なぜなら、依存したモジュールがすべて存在し、モジュールを動作させるための設定が完了している環境上で行わないとエラーになるからです。この条件を満たすには、CPANモジュールとしてインストールできるようにします。

2つ目は、モジュールロード時に標準出力に出力されるとノイズになるので抑制する必要があることです。なぜなら、自動インポート後の出力に期待するのは入力のファイルに必要なuse文を追加あるいは削除したものです。出力結果にノイズが含まれると、出力結果で入力したファイルを置換できなくなります。対策として、モジュールロード時にのみ標準出力を/dev/nullにエイリアスすることでノイズを抑制できます。

動的解析の課題

動的解析はプログラムを実際に実行するのでセキュリティ上の課題があります。PerlではBEGINブロックにコードを書くと、コンパイルフェーズの振る舞いを変えることができます。そのため、利用しているモジュールのBEGINブロック内で悪意のあるコードが存在する場合に動的解析を行うと、悪意のあるコードが実行されます。たとえば、次のコードがモジュールに含まれていると、モジュールロード時に悪意のあるサイトにアクセスします。

BEGIN {
    system('curl [悪意のあるサイトのURL]');
}

動的解析を行う際は、Dockerなどの仮想環境上で実行することをお勧めします。

まとめ

今回解説したように、一見実装が難しそうに見える自動インポートでも、PPIによる静的解析とエッジケース用の動的解析を用いることで、実現可能だということがわかっていただけたかと思います。本稿で紹介した考え方を実装した一例として、筆者が製作しているPauというモジュールがあります。Paugoimports相当の機能を備えていて、必要なモジュールを自動で追加したり、不要なモジュールを自動で削除したりできます。良ければ参照してください。本稿が、Perlで自動インポートする際の考え方の参考になれば幸いです。

さて、次回の執筆者は須藤将史さんで、テーマは「Perl Webアプリケーションのリプレイス」です。

おすすめ記事

記事・ニュース一覧