C#で簡易テンプレート処理2007年09月17日 02時01分31秒

いきなりC#ネタ

ZF入門も書かずに脈絡なく。しかも.NET 1.1。ごめん、がんばる。

ありそうで見つからないので

ここしばらく久しぶりに仕事でC#を使っている。まぁだいぶ勘も戻ってきて、「やっぱ慣れてる言語はよいなー」とか思っていたのだが、ちょっと欲しい機能が見つからなかった。

いま実装中の機能で、DataSetにキャッシュしたデータをもとにUPDATE用のクエリを生成するのだが、状況によってすこしだけ異なるクエリを生成し分ける必要にせまられ、簡単なテンプレートエンジンが欲しくなったのだ。

ひょっとするとすでに優秀なライブラリがあるかもしれない(実はあとでNVelocityなんてのがあることに気づいた)が、探して評価している時間もないので割り切った機能で自作をしてみた。

仕事で作ったやつなのでネタにするのはちょっと抵抗あるが、仕様検討も含めて1時間かかるかかからないかくらいの規模だし、どうせ誰かが似たようなやつ作ってるだろうしでソースを公開してみることにした。需要あるかはわからんけど。

仕様

ってほど機能があるわけではないが、標準のString.Formatのインデックスベースのプレースホルダの代わりにIDictionaryのキーやDataColumnのカラム名を使うように考えた。要はこちらで紹介されている「JSON - String.prototype.supplant」みたいな記法だ。

たとえば

string template = "name={Name}, value={Value}";
みたいなテンプレートがあって、これに
  • "Name", "Value"というColumnがあるDataTableのDataRow
  • "Name", "Value"というキーを持つIDictionary(HashtableとかNameValueCollectionとか)
を与えるって使い方。 その他はなんの付加価値もない。条件分岐も繰り返し処理もサブテンプレートの差込もできない。

ただし、パラメータ名の表記は後ろにコロン+フォーマット指定ができるように展開するため、String.Formatと同様にフォーマット指定できるようにしてみた。

仕組み

ソースみればだいたいわかると思うが、以下のようなプロセスで展開する。

  1. {}で囲まれたパラメータ名を抽出順にリストにし、そのインデックス番号でもってテンプレート内のパラメータ名を置換する
  2. 展開時はパラメータ名のリストを順次走査して与えられたDataRowやIDictionaryから値を抽出してobject[]を作成、先に変換したテンプレートを用いてString.Formatする
こんな感じでかなりシンプル。

ソース

このくらいの感じ。例によってNYSLってことで。

using System;
using System.Collections;
using System.Data;
using System.Text.RegularExpressions;

namespace DaraJ {
  public class TemplateProcessor {
    // テンプレートソース
    protected string _source;
    
    // コンパイル済みソース
    protected string _compiledSource;
    
    // テンプレート変数名のリスト
    protected ArrayList _varNames;
    
    // Regex.Replace用のMatchEvaluator
    protected MatchEvaluator _evaluator;
    
    // テンプレート変数抽出用正規表現
    protected Regex _variableParser;
    
    // コンパイル済みフラグ
    protected bool _compiled;
    
    // デフォルトコンストラクタ
    public TemplateProcessor() : this(null) {
    }
    
    // テンプレートソースを指定するコンストラクタ
    public TemplateProcessor(string source) {
      this._variableParser = new Regex( @"\{([a-zA-Z_]\w*)(:.+?)?\}" );
      this.Source = source;
      this._evaluator = new MatchEvaluator( this.evaluateVariable );
    }
    
    // テンプレートソースを取得・設定
    public string Source {
      get {
        return this._source;
      }
      set {
        if( value == this._source ) return;
        this._source = value;
        
        this._compiledSource = null;
        this._varNames = new ArrayList();
        this._compiled = false;
      }
    }
    
    // コンパイル済みソースを取得
    public string CompiledSource {
      get {
        return this._compiledSource == null ? String.Empty : this._compiledSource;
      }
    }
    
    // テンプレート変数名リストを取得
    public string[] VariableNames {
      get {
        return (string[])(this._varNames.ToArray( typeof(string) ));
      }
    }
    
    // コンパイル済みか
    public bool Compiled {
      get {
        return this._compiled;
      }
    }
    
    // テンプレートをコンパイル
    public void Compile() {
      this._varNames = new ArrayList();
       this._compiledSource = this._variableParser.Replace( this.Source, this._evaluator );
      this._compiled = true;
    }
    
    // テンプレートソースを指定してテンプレートをコンパイル
    public void Compile(string source) {
      this.Source = source;
      this.Compile();
    }
    
    // IDictionaryをパラメータにしてテンプレート処理を実行
    public string Exec(IDictionary parameters) {
      if( ! this.Compiled ) this.Compile();
      
      ArrayList paramList = new ArrayList();
      foreach(string varName in this._varNames ) {
        paramList.Add( parameters[ varName ] );
      }
      return String.Format( this.CompiledSource, paramList.ToArray() );
    }
    
    // DataRowをパラメータにしてテンプレート処理を実行
    public string Exec(DataRow parameters) {
      if( ! this.Compiled ) this.Compile();
      
      ArrayList paramList = new ArrayList();
      foreach(string varName in this._varNames ) {
        paramList.Add( parameters[ varName ] );
      }
      return String.Format( this.CompiledSource, paramList.ToArray() );
    }
    
    // Regex.Replaceから呼び出される置換メソッド
    // テンプレート変数名を出現順序のインデックスに置換しつつ
    // テンプレート変数名をリストに追加する
    private string evaluateVariable(Match m) {
      int index = this._varNames.Count;
      string varName = m.Groups[1].ToString();
      
      this._varNames.Add( varName );
      
      return String.Format( "{{{0}{1}}}", index, m.Groups[2] );
    }
  }
}
で、使い方はこんな感じ。
using System;
using System.Collections;
using System.Data;

namespace DaraJ {
  public class TestClass {
    public static void Main() {
      TemplateProcessor template = new TemplateProcessor();
      // テンプレートソースを設定
      template.Source = @"{ItemName} \{Price:#,##0} (\{UnitPrice:#,##0} x {ItemNum})";
      
      // IDictionaryで実行
      Console.WriteLine( template.Exec( createHashtable() ) );
      
      // DataRowで実行
      foreach(DataRow row in createTable().Rows) {
        Console.WriteLine( template.Exec( row ) );
      }
    }
    
    // テストデータのDataTableを作成
    private static DataTable createTable() {
      DataTable table = new DataTable();
      table.Columns.AddRange( new DataColumn[] {
        new DataColumn( "ItemName", typeof(string) ),
        new DataColumn( "UnitPrice", typeof(decimal) ),
        new DataColumn( "ItemNum", typeof(int) ),
        new DataColumn( "Price", typeof(decimal), "UnitPrice * ItemNum" )
      } );
      foreach(object[] data in new object[][] {
        new object[] { "HDD (40GB)", 1980, 1 },
        new object[] { "ボールマウス", 400, 3 },
        new object[] { "キーボード (白)", 980, 3 }
      } ) {
        DataRow row = table.NewRow();
        row.ItemArray = data;
        table.Rows.Add( row );
      }
      
      return table;
    }
    
    // Hashtableのテストデータを作成
    private static Hashtable createHashtable() {
      Hashtable hash = new Hashtable();
      
      hash["ItemName"] = "HDD (80GB)";
      hash["UnitPrice"] = 5000;
      hash["ItemNum"] = 2;
      hash["Price"] = 10000;
      
      return hash;
    }
  }
}

改造など

いまのとこIDictionaryとDataRowの受け取りをオーバーロードで実装しているため、メソッドシグニチャを除けばまったく同じコードでかなりみっともないが、TypeをキーにしたHashtableに、テンプレート変数名から値を取り出すメソッドをくるんだdelegateを登録するように改造したりすれば他の型(ってちょっと思いつかないが)への対応もむずかしくないかと。

でも、NVelocityを先に見つけてりゃこんなんつくらんかったな。

ZendFramework入門・その5 フォームを取り扱う・その32007年09月17日 06時02分12秒

お詫びというか訂正というか訂正しないというか

一週間もほったらかした上にいきなりお詫びからです。

前々回前回に扱った「zf2」のIndexController::dumpActionですが、前々回では「$this->getRequest()->getParams()」としていたところを、前回掲載のソースでは「$this->getRequest()->getPost()」とメソッドが変わっていました。どこかで変更した検証コードをそのまま確かめもせずに掲載してしまったようです。

フォームのメソッドがPOSTなので機能上はほとんど違いがないのですが、なんの断りもなく違うメソッドになっていたので混乱された方もいらっしゃるかもしれませんのでここでお詫びいたします。申し訳ありませんでした。

今回も引き続き「zf2」を流用しますが、あえて「getParams()」に戻す理由もないので、今回も「getPost()」のままにしますのでご了承ください。

入力内容を検査し、動作を変える

さて、今回の内容です。

前回まででフォームの内容をサーバに送信し、その内容を確認するところまで作りました。今回はそれに少しだけ機能を足して、

  • 送信する内容を「名前」「性別」に加えて「メールアドレス」を追加する
  • 「名前」と「メールアドレス」はどちらも入力を必須とする
  • 必須項目が未入力の場合は入力画面に戻り、再度入力させる
  • すべて入力されていたら、入力内容確認画面を表示する
といった感じにしてみましょう。

この仕様変更に伴い、以下の処理を追加する必要があります。

  • 入力内容の検査(空かどうかのみ)
  • 検査結果による表示内容の変更
  • 入力値をフォームへ反映させる
これらを順を追って実装していきましょう。

入力内容をフォームへ反映させる準備

まず、未入力項目があった場合への対応で、入力フォーム(=indexAction+index.phtml)へ、POSTされた内容を反映させる準備をします。

といっても特別なことではなく、前回までのdumpActionで行っていたように、getPost()で取得した連想配列をviewへassignするだけです。

dumpActionでやっていた、「$this->veiw->assign('postData', $this->getRequest()->getPost()」をそのままindexActionの先頭に記述してもいいのですが、どのみち今回の仕様では未入力の有無にかかわらずビューへ入力データを渡す必要があるので、共通の処理としましょう。そう、init()内で行ってしまうのです。

そして、実際のフォームフィールドへの反映ですが、これまた単純で、HTMLフォームの値として、ビュースクリプト内でechoするだけになります。

ここまでのコードを掲載します。まずはIndexController。

<?php
require_once 'Zend/Controller/Action.php';

class IndexController extends Zend_Controller_Action {
  // 初期化処理
  public function init() {
    // リクエストオブジェクトの取得
    $request = $this->getRequest();
    
    // BASE要素向けのベースURL
    $this->view->assign(
      'baseUrl',
      getApplicationUrl( $request )
    )->assign(
      'postData',
      array_merge(
        array(
          'name' => '',
          'mail' => '',
          'sex' => 1
        ),$request->getPost()
      )
    );
  }
  
  // indexアクション
  public function indexAction() {
  }
  
  // dumpアクション
  public function dumpAction() {
    // postDataへの割り当てをinit()に移動したため
    // ここではなにもしない
  }
  
  public function __call($name, $args) {
    $this->_forward( 'index' );
  }
}
IndexController.php
基本的にdumpActionで行っていた'postData'への割り当てをinitに移しただけですが、'baseUrl'への割り当てでもZend_Requestを使用するため、先にローカル変数「$request」にZend_Requestを割り当てるようにしています。また、assignでメソッドチェーンを適用しています。

また、postDataへ直接getPost()の値を割り当てているのではなく、array_merge()を使用しています。これは、フォーム送信ではなく、直接index/indexを呼び出した場合への対応で、「name」「mail」「sex」の3つのキーが常にpostDataに割り当てられた状態にするための下処理として行っています。

index.phtmlは以下のようになります。

<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=Shift_JIS">
    <base href="<?php echo $this->baseUrl; ?>/"></base>
    <title>フォーム テスト</title>
  </head>
  <body>
    <h3>入力フォーム</h3>
    <form action="index/index" method="post">
      <ul>
        <li>
          <label for="name">名前</label>
          <input type="text" name="name" id="name" size="20"
           value="<?php echo $this->escape( $this->postData['name'] ); ?>">
        
        <li>
          <label for="mail">メールアドレス</label>
          <input type="text" name="mail" id="mail" size="40"
           value="<?php echo $this->escape( $this->postData['mail'] ); ?>">
        
        <li>
          <label for="sex">性別</label>
          <select name="sex" id="sex"
           value="<?php echo $this->postData['sex']; ?>">
            <option value="1"<?php if( $this->postData['sex'] == 1 ) echo ' selected'; ?>>男性
            <option value="2"<?php if( $this->postData['sex'] == 2 ) echo ' selected'; ?>>女性
          </select>
      </ul>
      <input type="submit" value="送信">
    </form>
  </body>
</html>
index.phtml
ちょっと見にくい(醜い?)コードになっていますが、変更点は以下のとおりです。
  • フォーム項目「mail」を追加している
  • POST先をdumpActionからindexActionに変更
  • postDataから値を取得し、value属性に設定している
まずPOST先の変更ですが、これはここまでの修正の動作確認のための一時変更です。dumpActionに飛ばしてしまうと、せっかくのフォーム項目に値を反映させるコードの動作確認が取れないからです。

そして、実際のフォーム項目への値の反映ですが、これは基本的に連想配列の対応する値をvalue属性に出力するだけですが、$this->escape()で出力をエスケープしています。

出力のエスケープ処理

この「$this->escape()」(=Zend_Veiew::escapeメソッド)は、名前のとおり出力のエスケープ処理を行うZend_Viewのメソッドで、今のようにデフォルトのまま扱っている場合は、htmlspecialchars() 関数がその正体となります。

Zend_View::escapeメソッドはZend_View::setEscape()メソッドでのカスタマイズも可能になっていますが、ここでは扱いません。詳細はリファレンスガイドの「35.3.1. 出力のエスケープ」を参照してください。

入力値をチェックする

ここまでのコードで動作させてみてください。たぶん「送信」しても画面が一切変わり映えしない、つまらない結果になるでしょう。ですので、当初の予定どおり、入力値をチェックしてその結果による振り分け処理を実装してみましょう。

チェック処理は以下のような考え方になります。

  • indexActionからのPOST先はチェック処理を行うアクションになる
  • チェックアクション内で、必須項目に対してempty()で空かどうかの判断を行う
  • 2つの必須項目のうち、1つでも空であればもう一度indexActionへ飛ばす
  • 必須項目に問題がなければ結果表示を行う
これらを行うため、IndexControllerに入力値チェック用のアクションメソッドを追加します。名前はpostActionとでもしましょう。

postActionを追加したIndexControllerは以下のようになります。

<?php
require_once 'Zend/Controller/Action.php';

class IndexController extends Zend_Controller_Action {
  // 初期化処理
  public function init() {
    // リクエストオブジェクトの取得
    $request = $this->getRequest();
    
    // BASE要素向けのベースURL
    $this->view->assign(
      'baseUrl',
      getApplicationUrl( $request )
    )->assign(
      'postData',
      array_merge(
        array(
          'name' => '',
          'mail' => '',
          'sex' => 1
        ),$request->getPost()
      )
    );
  }
  
  // indexアクション
  public function indexAction() {
  }
  
  // postアクション
  public function postAction() {
    $postData = $this->getRequest()->getPost();
    
    // 未入力チェック
    $is_empty = empty( $postData['name'] ) || empty( $postData['mail'] );
    
    if( $is_empty ) {
      // 未入力があったのでエラーメッセージをセットしてindexActionへforward
      $this->view->assign( 'errorMessage', '未入力項目があります' );
      $this->_forward( 'index' );
      return;
    }
    // 正常だったのでdumpへforward
    $this->_forward( 'dump' );
  }
  
  // dumpアクション
  public function dumpAction() {
  }
  
  public function __call($name, $args) {
    $this->_forward( 'index' );
  }
}
postActionを追加したIndexController.php
処理の振り分けに前回登場した、Zend_Controller_Action::_forwardを使っている程度で、特に解説の必要はないと思います。

そして、postAction内で'errorMessage'にエラーメッセージを割り当てていますので、これを出力するコードをindex.phtmlに実装する必要があります。

<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=Shift_JIS">
    <base href="<?php echo $this->baseUrl; ?>/"></base>
    <title>フォーム テスト</title>
  </head>
  <body>
<?php if( isset( $this->errorMessage ) ) { ?>
    <div style="color: red"><?php echo $this->escape( $this->errorMessage ); ?></div>
<?php } ?>
    <h3>入力フォーム</h3>
    <form action="index/post" method="post">
      <ul>
        <li>
          <label for="name">名前</label>
          <input type="text" name="name" id="name" size="20"
           value="<?php echo $this->escape( $this->postData['name'] ); ?>">
        
        <li>
          <label for="mail">メールアドレス</label>
          <input type="text" name="mail" id="mail" size="40"
           value="<?php echo $this->escape( $this->postData['mail'] ); ?>">
        
        <li>
          <label for="sex">性別</label>
          <select name="sex" id="sex"
           value="<?php echo $this->postData['sex']; ?>">
            <option value="1"<?php if( $this->postData['sex'] == 1 ) echo ' selected'; ?>>男性
            <option value="2"<?php if( $this->postData['sex'] == 2 ) echo ' selected'; ?>>女性
          </select>
      </ul>
      <input type="submit" value="送信">
    </form>
  </body>
</html>
エラーメッセージ出力に対応したindex.phtml
isset()でerrorMessageが割り当てられているかを判断している程度で、これまた特に解説の必要はないかと思います。

Zend_Controller_Action::_redirect

さて、ここまでで今回の予定の機能はすべて実装できました。

前回の予告で「_redirectについても解説」といっていたのですが、うまい使い道が思いつかなかったので、簡単な解説だけにとどめさせていただきます。

Zend_Controller_Action::_redirectは、_forwardと同様にZend_Controller_Actionのプロテクトメソッドで、目的のURLへリダイレクトするためのメソッドです。メソッドシグニチャは以下のようになっています。

void _redirect(string $url, [ $options = array()])
使い方は_forwardに近いのですが、以下のような違いがあります。
  • 第一引数はアクションではなくURLを指定する
  • _forwardと違い、実際に指定URLへのリダイレクトが発生する(ブラウザの表示URLが実際に変更する)
_forwardとの違いを手っ取り早く見てみるには、今回のコードのpostActionで、indexActionに_forwardしているところを「_redirect('index/index')」に変更してみてください。せっかくinit()内でpostDataを割り当て、それをビューに反映させているのに、入力内容が実際には反映されないと思います。

これは、_forwardはリダイレクトが発生するわけではない(=同じリクエストを処理している)が、_redirectは実際にリダイレクトが発生するためです。

_forwardは同じリクエストを処理しているため、今回のようにPOSTされたデータをinit内でビューに割り当てている限り、_forward前のアクションでも_forward後のアクションでも同じようにデータを扱えますが、_redirectはリダイレクトすることによってリクエストの内容が変化する、ということです。

あとがき&次回予告

さて、1週間も間があいた割には、大して新しい内容が出現しませんでした(^^;が、これで一通りページを作成してユーザの入力を受け取り処理をするための基礎に触れられたと思います。

次回からは、いったんコントローラ関連の処理から離れ、これまたWebアプリケーションでは不可欠ともいえる、データベース関連の機能に移りたいと思います。

DBMSはPHP5で標準的に扱えるため環境にあまり左右されない、SQLiteを使う予定です。ですので、いきなりZend_Db関連をやるのではなく、1回くらいはSQLiteの使い方を解説するかもしれません(しないかもしれません^^;)。

それでは次回を、気長にお待ちください。