Movable Type CMSプラットフォーム Movable Type
ドキュメントサイト

Manualマニュアル

第5回】テストドリブンでのプラグインの開発について

最終更新日: 2011.03.28

このページは既にhttps://github.com/movabletype/Documentation/wiki/Japanese-plugin-dev-1-5に移行されています。

はじめに

シックス・アパートの上野初仁(はつひと)です。

テストドリブンでのプラグイン開発と、それに伴ったファンクションタグプラグイン(Perl, PHP)を作成していく手順を解説します。

ファンクションタグとは?

ファンクションタグは、MTタグの中でも値の出力を担っている部分です。"<MTEntryTitle>"などもファンクションタグの一つです。タグに与えられた情報、タグから得られた情報から文字列を作成し出力します。

今回は単純なファンクションタグを作成します。

テストドリブン開発とは?

従来の開発手法ではコードを書き終わってから結合テストを行うのが主でした。しかし、この手法ではエラーが出た際に問題箇所の特定がしづらく、バグの温床となってしまう場面が多々ありました。

そこで、仕様書からテストケースを先に書いてしまい、単体テストや結合テストを行ってテストが正しく成功すること(もしくは明示的に失敗させること)でコードの確実性を持たせる手法として「テストドリブン開発」または「テストファースト開発」という開発手法が確立されました。

Movable Typeの開発も同様にテストケースを書きテストを行っています。

そこで、プラグイン開発でもテストドリブン開発を行って行きましょう。

プラグインの仕様とテストケースの作成

プラグインの仕様

プラグイン(MyPlugin03)の仕様ですが、ここでは簡単に"<MTHelloWorld>"タグを書くと"Hello, world!"と表示されるファンクションタグとします。

<MTHelloWorld> => Hello, world!

テスト環境の準備

テストファイルを書く前にテスト環境の準備をします。テストする環境は仮想環境などを利用し、本番環境では絶対に行わないで下さい。理由はセキュリティ的に問題が発生する可能性があることと、テスト実行時にデータベースのリフレッシュを行うため本番データベースに負荷をかけないようにするためです。この後はMovable TypeがCentOS5で、既に動作するよう設定済みとして解説します。

  1. Six Apartのサイトからテストスクリプト群を、Movable Typeの配置場所($MT_DIRとします)配下にダウンロード
    $ cd $MT_DIR
    $ svn co http://code.sixapart.com/svn/movabletype/trunk/t/
    
  2. Perlモジュール(Test::Deep)のインストール
    # cpan Test::Deep
    
      以下のモジュールもインストールします(cpanコマンドが自動的にインストールするかどうかを聞いてきます)
    
        Test::Tester
        Test::NoWarnings
        Test::Builder
    
  3. DBの準備

    プラグインテスト用のデータベースを作成します

    mysql> CREATE DATABASE mt_test;
    mysql> GRANT ALL PRIVILEGES ON mt_test.* TO mt@localhost;
    
  4. php-mysql と php-gd をyumでインストール
    # yum install php-mysql php-gd
    

テストファイルの配置位置など

テストケースを配置するのは対象とするプラグインディレクトリの直下の"t"ディレクトリになります。

例)00-compile.t, 01-tags.t

$MT_DIR/
|__ plugins/
   |__ MyPlugin03/
      |__ t
         |_ 00-compile.t
         |_ 01-tags.t

テストケース1(必要クラスのロードテスト, 00-compile.t)の作成

プラグインに必要なクラスがロードされているか確認するためのテストケースを作成します。

use strict;
use lib qw( t/lib lib extlib );
use warnings;
use MT;
use Test::More tests => 5;
use MT::Test;

ok(MT->component ('MyPlugin03'), "MyPlugin03 plugin loaded correctry");

require_ok('MyPlugin03::L10N');
require_ok('MyPlugin03::L10N::ja');
require_ok('MyPlugin03::L10N::en_us');
require_ok('MyPlugin03::Tags');

1;

このテストケースでは以下のテストを行います。

  1. MyPlugin03がプラグインとして正しくロードされているか?
  2. MyPlugin03::L10N クラスが正しくrequireされているか?
  3. MyPlugin03::L10N::ja クラスが正しくrequireされているか?
  4. MyPlugin03::L10N::en_us クラスが正しくrequireされているか?
  5. MyPlugin03::L10N::Tags クラスが正しくrequireされているか?
テストの個数が5件なので"use Test::More tests => 5;"と指定しています。

テストケース2(タグの挙動テスト, 01-tags.t)の作成

与えられたMTタグと、それに対して出される結果をJSON形式でテストケースファイル内に表記します。("#===== Edit here"の場所)

JSONの一行が一つのテストになっており以下のテストを行います。

  1. 入力が何も無い場合、レスポンスも無い(空)
  2. 入力が"<MTHelloWorld>"だったら、レスポンスは"Hello, world!"
  3. 入力が"<MTHelloWorld lower_case="1">だったら、全て小文字になりレスポンスは"hello, world!"
注意)
  • テストをスキップする行がある場合({"r" : "0",)のように記述してください。
  • JSONではデータの最終行に","をつけませんのでご注意ください。("hello, world!"} <= コンマなし)
  • 今後のテストケースは、このJSONの部分を変更するだけで再利用できます。
use strict;
use warnings;
use IPC::Open2;

BEGIN {
    $ENV{MT_CONFIG} = 'mysql-test.cfg';
}

$| = 1;

use lib 't/lib', 'lib', 'extlib';
use MT::Test qw(:db :data);
use Test::More;
use JSON -support_by_pp;
use MT;
use MT::Util qw(ts2epoch epoch2ts);
use MT::Template::Context;
use MT::Builder;

require POSIX;

my $mt = MT->new();

#===== Edit here
my $test_json = <<'JSON';
[
{ "r" : "1", "t" : "", "e" : ""},
{ "r" : "1", "t" : "<MTHelloWorld>", "e" : "Hello, world!"},
{ "r" : "1", "t" : "<MTHelloWorld lower_case=\"1\">", "e" : "hello, world!"}
]
JSON
#=====

$test_json =~ s/^ *#.*$//mg;
$test_json =~ s/# *\d+ *(?:TBD.*)? *$//mg;

my $json = new JSON;
$json->loose(1); # allows newlines inside strings
my $test_suite = $json->decode($test_json);

# Ok. We are now ready to test!
plan tests => (scalar(@$test_suite)) + 4;

my $blog_name_tmpl = MT::Template->load({name => "blog-name", blog_id => 1});
ok($blog_name_tmpl, "'blog-name' template found");

my $ctx = MT::Template::Context->new;
my $blog = MT::Blog->load(1);
ok($blog, "Test blog loaded");
$ctx->stash('blog', $blog);
$ctx->stash('blog_id', $blog->id);
$ctx->stash('builder', MT::Builder->new);

my $entry  = MT::Entry->load( 1 );
ok($entry, "Test entry loaded");

# entry we want to capture is dated: 19780131074500
my $tsdiff = time - ts2epoch($blog, '19780131074500');
my $daysdiff = int($tsdiff / (60 * 60 * 24));
my %const = (
    CFG_FILE => MT->instance->{cfg_file},
    VERSION_ID => MT->instance->version_id,
    CURRENT_WORKING_DIRECTORY => MT->instance->server_path,
    STATIC_CONSTANT => '1',
    DYNAMIC_CONSTANT => '',
    DAYS_CONSTANT1 => $daysdiff + 1,
    DAYS_CONSTANT2 => $daysdiff - 1,
    CURRENT_YEAR => POSIX::strftime("%Y", localtime),
    CURRENT_MONTH => POSIX::strftime("%m", localtime),
    TEST_JSON => "$test_json",
);

$test_json =~ s/\Q$_\E/$const{$_}/g for keys %const;
$test_suite = $json->decode($test_json);

$ctx->{current_timestamp} = '20040816135142';

my $num = 1;
foreach my $test_item (@$test_suite) {
    unless ($test_item->{r}) {
        pass("perl test skip " . $num++);
        next;
    }
    local $ctx->{__stash}{entry} = $entry if $test_item->{t} =~ m/<MTEntry/;
    $ctx->{__stash}{entry} = undef if $test_item->{t} =~ m/MTComments|MTPings/;
    $ctx->{__stash}{entries} = undef if $test_item->{t} =~ m/MTEntries|MTPages/;
    $ctx->stash('comment', undef);
    my $result = build($ctx, $test_item->{t});
print "$result\n";
    is($result, $test_item->{e}, "perl test " . $num++);
}

php_tests($test_suite);

sub build {
    my($ctx, $markup) = @_;
    my $b = $ctx->stash('builder');
    my $tokens = $b->compile($ctx, $markup);
    print('# -- error compiling: ' . $b->errstr), return undef
        unless defined $tokens;
    my $res = $b->build($ctx, $tokens);
    print '# -- error building: ' . ($b->errstr ? $b->errstr : '') . "\n"
        unless defined $res;
    return $res;
}

sub php_tests {
    my ($test_suite) = @_;
    my $test_script = <<'PHP';
<?php
include_once("php/mt.php");
include_once("php/lib/MTUtil.php");
require "t/lib/JSON.php";

$cfg_file = '<CFG_FILE>';
$test_json = '<TEST_JSON>';

$const = array(
    'CFG_FILE' => $cfg_file,
    'VERSION_ID' => VERSION_ID,
    'CURRENT_WORKING_DIRECTORY' => '',
    'STATIC_CONSTANT' => '',
    'DYNAMIC_CONSTANT' => '1',
    'DAYS_CONSTANT1' => '<DAYS_CONSTANT1>',
    'DAYS_CONSTANT2' => '<DAYS_CONSTANT2>',
    'CURRENT_YEAR' => strftime("%Y"),
    'CURRENT_MONTH' => strftime("%m"),
);

$output_results = 0;

$mt = MT::get_instance(1, $cfg_file);
$ctx =& $mt->context();

$path = $mt->config('mtdir');
if (substr($path, strlen($path) - 1, 1) == '/')
    $path = substr($path, 1, strlen($path)-1);
if (substr($path, strlen($path) - 2, 2) == '/t')
    $path = substr($path, 0, strlen($path) - 2);
$const['CURRENT_WORKING_DIRECTORY'] = $path;

$db = $mt->db();
$ctx->stash('blog_id', 1);
$blog = $db->fetch_blog(1);
$ctx->stash('blog', $blog);
$ctx->stash('current_timestamp', '20040816135142');
$mt->init_plugins();
$entry = $db->fetch_entry(1);

$suite = load_tests();

run($ctx, $suite);

function run(&$ctx, $suite) {
    $test_num = 0;
    global $entry;
    global $mt;
    global $tmpl;
    foreach ($suite as $test_item) {
        $mt->db()->savedqueries = array();
        if ( preg_match('/MT(Entry|Link)/', $test_item->t)
          && !preg_match('/MT(Comments|Pings)/', $test_item->t) )
        {
            $ctx->stash('entry', $entry);
        }
        else {
            $ctx->__stash['entry'] = null;
        }
        if ( preg_match('/MTEntries|MTPages/', $test_item->t) ) {
            $ctx->__stash['entries'] = null;
            $ctx->__stash['author'] = null;
            $ctx->__stash['category'] = null;
        }
        if ( preg_match('/MTCategoryArchiveLink/', $test_item->t) ) {
            $ctx->stash('current_archive_type', 'Category');
        } else {
            $ctx->stash('current_archive_type', '');
        }
        $test_num++;
        if ($test_item->r == 1) {
            $tmpl = $test_item->t;
            $result = build($ctx, $test_item->t);
            ok($result, $test_item->e, $test_num);
        } else {
            echo "ok - php test $test_num \n";
        }
    }
}

function load_tests() {
    $suite = cleanup('<TEST_JSON>');
    $json = new JSON();
    global $const;
    foreach ($const as $c => $r) {
        $suite = preg_replace('/' . $c . '/', $r, $suite);
    }
    $suite = $json->decode($suite);
    return $suite;
}
function cleanup($tmpl) {
    # Translating perl array/hash structures to PHP...
    # This is not a general solution... it's custom built for our input.
    $tmpl = preg_replace('/^ *#.*$/m', '', $tmpl);
    $tmpl = preg_replace('/# *\d+ *(?:TBD.*)? *$/m', '', $tmpl);
    return $tmpl;
}

function build(&$ctx, $tmpl) {
    if ($ctx->_compile_source('evaluated template', $tmpl, $_var_compiled)) {
        ob_start();
        $ctx->_eval('?>' . $_var_compiled);
        $_contents = ob_get_contents();
        ob_end_clean();
        return $_contents;
    } else {
        return $ctx->error("Error compiling template module '$module'");
    }
}

function ok($str, $that, $test_num) {
    global $mt;
    global $tmpl;
    $str = trim($str);
    $that = trim($that);
    if ($str === $that) {
        echo "ok - php test $test_num \n";
        return true;
    } else {
        echo "not ok - php test $test_num\n".
             "#     expected: $that\n".
             "#          got: $str\n";
        return false;
    }
}

?>
PHP

    $test_script =~ s/<\Q$_\E>/$const{$_}/g for keys %const;

    # now run the test suite through PHP!
    my $pid = open2(\*IN, \*OUT, "php");
    print OUT $test_script;
    close OUT;
    select IN;
    $| = 1;
    select STDOUT;

    my @lines;
    my $num = 1;

    my $test = sub {
        while (@lines) {
            my $result = shift @lines;
            if ($result =~ m/^ok/) {
                pass($result);
            } elsif ($result =~ m/^not ok/) {
                fail($result);
            } elsif ($result =~ m/^#/) {
                print STDERR $result . "\n";
            } else {
                print $result . "\n";
            }
        }
    };

    my $output = '';
    while (<IN>) {
        $output .= $_;
        if ($output =~ m/\n/) {
            my @new_lines = split /\n/, $output;
            $output = pop @new_lines;
            push @lines, @new_lines;
        }
        $test->() if @lines;
    }
    push @lines, $output if $output ne '';
    close IN;
    $test->() if @lines;
}

テストの実行方法

まだプラグインが出来ていませんが、この状態でテストをするとどうなるのか試してみましょう。

  $ cd $MT_DIR
  $ perl plugins/MyPlugin03/t/00-compile.t 
  1..5
  not ok 1 - MyPlugin03 plugin loaded correctry
  #   Failed test 'MyPlugin03 plugin loaded correctry'
  #   in plugins/MyPlugin03/t/00-compile.t at line 11.
  not ok 2 - require MyPlugin03::L10N;
  #   Failed test 'require MyPlugin03::L10N;'
  #   in plugins/MyPlugin03/t/00-compile.t at line 13.
  #     Tried to require 'MyPlugin03::L10N'.
  #     Error:  Can't locate MyPlugin03/L10N.pm in @INC (以下略)

  $ perl plugins/MyPlugin03/t/01-tags.t 
  1..7
  ok 1 - 'blog-name' template found
  ok 2 - Test blog loaded
  ok 3 - Test entry loaded
  
  ok 4 - perl test 1
  # -- error compiling: <MTHelloWorld> at line 1 is unrecognized.
  Use of uninitialized value in concatenation (.) or string at plugins/MyPlugin03/t/01-tags.t line 87.
  (以下略)

何も実装されていないので、ブログの読み込みなど一部を除いて全てエラーになっています。

また、テストケースを直接Perlで実行する方法と、proveコマンドを使う方法の2つがあります。proveコマンドはPerlで実行した時よりも個々の情報出力は少なめですが、実行結果を数値で表示してくれます。エラーが出るうちはperlコマンドでテストを行い、ある程度エラーが出なくなったらproveコマンドに移行するのが良いでしょう。proveコマンドは複数ファイルを指定して実行が可能です。

  $ prove plugins/MyPlugin03/t/*.t
  plugins/MyPlugin03/t/00-compile....NOK 1                                     
  #   Failed test 'MyPlugin03 plugin loaded correctry'
  #   in plugins/MyPlugin03/t/00-compile.t at line 11.
  plugins/MyPlugin03/t/00-compile....NOK 2                                     
  #   Failed test 'require MyPlugin03::L10N;'
  #   in plugins/MyPlugin03/t/00-compile.t at line 13.
  #     Tried to require 'MyPlugin03::L10N'.
  (中略)
  plugins/MyPlugin03/t/01-tags.......dubious                                   
          Test returned status 2 (wstat 512, 0x200)
  DIED. FAILED tests 5-6
          Failed 2/7 tests, 71.43% okay
  Failed Test                       Stat Wstat Total Fail  Failed  List of Failed
  -------------------------------------------------------------------------------
  plugins/MyPlugin03/t/00-compile.t    5  1280     5    5 100.00%  1-5
  plugins/MyPlugin03/t/01-tags.t       2   512     7    2  28.57%  5-6
  Failed 2/2 test scripts, 0.00% okay. 7/12 subtests failed, 41.67% okay.

ファンクションタグプラグインの開発(Perl)

前章で作成したMyPlugin02を元にプラグインを作成します。

config.yaml

<MTHelloWorld>タグの追加には以下のように"tags"=> "function"=> "タグ名" => $プラグイン名::ハンドラ名 を記述します。

id: MyPlugin03
key: MyPlugin03
name: <__trans phrase="Sample Plugin Test Driven">
version: 1.0
description: <__trans phrase="_PLUGIN_DESCRIPTION">
author_name: <__trans phrase="_PLUGIN_AUTHOR">
author_link: http://www.example.com/about/
doc_link: http://www.example.com/docs/
l10n_class: MyPlugin03::L10N

tags:
    function:
        HelloWorld: $MyPlugin03::MyPlugin03::Tags::_hdlr_hello_world

L10N.pm

MyPlugin02と比べてパッケージ名だけが変更になっています。

package MyPlugin03::L10N;
use strict;
use base 'MT::Plugin::L10N';

1;

L10N/en_us.pm

パッケージ、クラス名、辞書の変更などが行われています。

package MyPlugin03::L10N::en_us;

use strict;
use base 'MyPlugin03::L10N';
use vars qw( %Lexicon );

%Lexicon = (
    '_PLUGIN_DESCRIPTION' => 'Sample Test Driven test plugin',
    '_PLUGIN_AUTHOR' => 'Plugin author',
);

1;

L10N/ja.pm

L10N/en_us.pmと同じく、パッケージ名、クラス名、辞書の変更などが行われています。

package MyPlugin03::L10N::ja;

use strict;
use base 'MyPlugin03::L10N::en_us';
use vars qw( %Lexicon );

%Lexicon = (
    'Sample Plugin Test Driven' => 'サンプルプラグイン テストドリブン',
    '_PLUGIN_DESCRIPTION' => 'テストドリブン テストプラグイン',
    '_PLUGIN_AUTHOR' => 'プラグイン作者',
);

1;

Tags.pm

新規で作成されるパッケージです。ハンドラ"_hdlr_Hello_world"が実装されています。"<MTHelloWorld>"タグにより、このハンドラが呼ばれ戻り値として"Hello, world!"が返りブログ記事などに表示されます。

package MyPlugin03::Tags;
use strict;

sub _hdlr_hello_world {
    my ($ctx, $args) = @_;

    return "Hello, world!";
}

1;

ファイルの配置

$MT_DIR/
|__ plugins/
   |__ MyPlugin03/
      |__ config.yaml
      |__ lib/
      |  |_ MyPlugin03/
      |     |__ L10N.pm
      |     |_ L10N/
      |     |  |_ en_us.pm
      |     |  |_ ja.pm
      |     |_ Tags.pm
      |__ t/
         |_00-compile.t
         |_01-tags.t

ファンクションタグプラグインの開発(PHP)

ついPerl版のプラグインの作成で終わってしまいがちですが、ダイナミックパブリッシングに対応するためにもPHPのプラグインも同時に開発しましょう。

function.mthelloworld.php

ファイル名はファンクションタグである事と<MTHelloWorld>タグである事から、function.mthelloworld.phpとなります。

<?php
    function smarty_function_mthelloworld($args, &$ctx) {
        return 'Hello, world!';
    }
?>

function名はsmarty記法にのっとってsmarty_function_mthelloworldとなります。

ファンクションタグとして<MTHelloWorld>が呼ばれると、このfunctionが"Hello, world!"を返し、ブログ記事などにダイナミックに表示されます。

ファイルの配置

$MT_DIR/
|__ plugins/
   |__ MyPlugin03/
      |__ config.yaml
      |__ lib/
      |  |_ MyPlugin03/
      |     |__ L10N.pm
      |     |_ L10N/
      |     |  |_ en_us.pm
      |     |  |_ ja.pm
      |     |_ Tags.pm
      |__ php/
      |  |_function.mthelloworld.php
      |__ t/
         |_00-compile.t
         |_01-tags.t

テストの実行

では、作成したプラグインに対してテストを行います。

00-compile.t

$ cd $MT_DIR
$ perl plugin/MyPlugin03/t/00-compile.t
perl plugins/MyPlugin03/t/00-compile.t 

1..5
ok 1 - MyPlugin03 plugin loaded correctry
ok 2 - require MyPlugin03::L10N;
ok 3 - require MyPlugin03::L10N::ja;
ok 4 - require MyPlugin03::L10N::en_us;
ok 5 - require MyPlugin03::Tags;

01-tags.t

$ perl plugins/MyPlugin03/t/01-tags.t
1..7
ok 1 - 'blog-name' template found
ok 2 - Test blog loaded
ok 3 - Test entry loaded

ok 4 - perl test 1
Hello, world!
ok 5 - perl test 2
hellow, world!
ok 6 - perl test 3
ok 7 - ok - php test 1 ok - php test 2 ok - php test 3 

proveコマンドでのテスト

$ prove plugins/MyPlugin03/t/*.t
plugins/MyPlugin03/t/00-compile....ok                                        
plugins/MyPlugin03/t/01-tags.......ok                                        
All tests successful.
Files=2, Tests=12, 25 wallclock secs (14.38 cusr +  4.03 csys = 18.41 CPU)

明示的にエラーを起こさせる

上記のテストは全て成功しました。テストプログラムを修正して明示的にエラーを起こさせる事も可能ですが、ここではJSONのテスト情報を故意に書き換えエラーを発生させてみます。

書き換え前

#===== Edit here
my $test_json = <<'JSON';
[
{ "r" : "1", "t" : "", "e" : ""},
{ "r" : "1", "t" : "<MTHelloWorld>", "e" : "Hello, world!"},
{ "r" : "1", "t" : "<MTHelloWorld lower_case=\"1\">", "e" : "hello, world!"}
]
JSON
#=====

書き換え後

#===== Edit here
my $test_json = <<'JSON';
[
{ "r" : "1", "t" : "", "e" : ""},
{ "r" : "1", "t" : "<MTHelloWorld>", "e" : "Hello, World!"},
{ "r" : "1", "t" : "<MTHelloWorld upper_case=\"1\">", "e" : "hello, world!"}
]
JSON
#=====
実行結果

$ perl plugins/MyPlugin03/t/01-tags.t 
1..7
ok 1 - 'blog-name' template found
ok 2 - Test blog loaded
ok 3 - Test entry loaded

ok 4 - perl test 1
Hello, world!
not ok 5 - perl test 2
#   Failed test 'perl test 2'
#   in plugins/MyPlugin03/t/01-tags.t at line 88.
#          got: 'Hello, world!'
#     expected: 'Hello, World!'
HELLO, WORLD!
not ok 6 - perl test 3
#   Failed test 'perl test 3'
#   in plugins/MyPlugin03/t/01-tags.t at line 88.
#          got: 'HELLO, WORLD!'
#     expected: 'hello, world!'
ok 7 - ok - php test 1 not ok - php test 2\#     expected: Hello, World!\#          got: Hello, world!not ok - php test 3\#     expected: hello, world!\#          got: HELLO, WORLD!
# Looks like you failed 2 tests of 7.
結果を見ると、以下のエラーがPerlとPHP共に発生した事がわかります。
  1. test 2 で"Hello, World!"が期待されたのに"Hello, world!"が戻ってきた
  2. test 3 で"hello, world!"が期待されたのに"HELLO, WORLD!"が戻ってきた

このように不具合箇所を修正していく事でバグの少ないコードを書いていく事ができます。

まとめ

今回はボリュームが大きくなりましたがテストドリブンのプラグイン開発をする理由を実感していただけたでしょうか?

テストドリブン開発でテストが通るようになっても、テストケースの作成の仕方しだいでは潜在的なバグが残る可能性はあります。しかし単体テストや複合テストのテストケースを先に作成する事で単純なバグの大半は修正可能になるはずです。

またテスト中にテストケースが誤っていた事が分かったとします。その場合、単純にテストケースを変更してテストを通す事だけに注力するだけでなく、なぜエラーが発生したかを再検討してください。実はテストケースは正しく、潜在バグがそこに眠っているかもしれないからです。

テストが終了しリリースの際には、この"t"ディレクトリは含まずにリリースしてください。なぜなら、この"t"ディレクトリは開発者しか利用しないものだからです。

このドキュメントでテストドリブン開発とPHPプラグイン開発に興味を持っていただければ幸いです。

プラグイン開発ステップ・バイ・ステップ インデックス

目次