MSBuild で ClickOnce アプリケーションを発行する

せっかく CruiseControl.NET で自動ビルドを構築しているんだから、ClickOnce アプリケーションの発行も自動化できないか試してみたのでメモ。

とりあえず発行してみる

発行自体は MSBuild を使うと簡単にできた。

MSBuild.exe hoge.csproj /t:publish

ところがVS上で設定している場所には発行されずに bin/debug/hoge.publish 以下に発行される。
どうやらかなり hoge.csproj の設定を無視するらしい。

試行錯誤してみる

MSDNによるとコマンドラインでプロパティをオーバーライドできるらしいので実行してみた。

MSBuild.exe hoge.csproj /t:publish /p:PublishUrl=c:\hoge\publish

が、結果は同じ。
隠しプロパティなのか、PublishUrl でなくて PublishDir と指定すると意図したとおりに発行される。

MSBuild.exe hoge.csproj /t:publish /p:PublishDir=c:/hoge/publish/

しかし、 publish の後ろに「 / 」がないとデプロイメントマニフェスト名が publishhoge.application のようにおかしくなり、代わりに「 \ 」を付けるとエラーになるおまけつき・・・

publish.htm が出力されない

VS上で発行したときは publish.htm という ClickOnce アプリケーションのインストールページが生成されるが、MSBuild で発行するとこれが生成されない。
以下のように明示的に指定してもダメ。謎だ。
まあ1度VSから生成してしまえば、違いはバージョン番号くらいなのでまあいいかな。

MSBuild.exe hoge.csproj /t:publish /p:PublishDir=c:/hoge/publish/;CreateWebPageOnPublish=true;WebPage=test.htm

Web アプリケーション / サービスを発行したい

ここまできたら Web アプリケーション / サービスも発行したいんだけど、これは方法が見つからない。プロジェクトファイルがないためだ。ソリューションファイルを与えると、含まれているプロジェクトを発行するだけで肝心の Web アプリケーション / サービスは発行されない。
とりあえず事前コンパイルで我慢するしかないのかな・・・

NAnt から MSBuild にプロパティを渡す方法

.NETを使っているとほとんどのことは NAnt でできるけど、「発行」とかは MSBuild じゃないとできない。じゃあ MSBuild に乗り換えよう、と思ってもできる事が少なすぎて乗り換えられない。なので多機能な NAnt から必要な機能だけを MSBuild に提供してもらうことにした。その場合 NAnt から MSBuild にプロパティを渡せると便利なのでメモ。

NAnt のビルドファイル nant.xml

NAnt から MSBuild にプロパティを渡して呼ぶだけのビルドファイル。
MSBuild の引数には /t:TargetName で MSBuild のターゲット名を、/p:PropertyName=PropertyValue でプロパティ値を指定できる。プロパティ値を複数渡したいなら /p:PropertyName1=PropertyValue1;PropertyName2=PropertyValue2 というようにセミコロンで区切ってやればいい。PropertyValue2 の後にセミコロンを付けるとエラーになるので注意。

<?xml version="1.0" encoding="utf-8"?>
<project name="CallingMsbuild" default="build" basedir=".">
  <property name="msbuild.xml" value="msbuild.xml"/>
  <property name="hoge.dir" value="c:/hoge"/>
  <target name="build">
    <exec 
      program="C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727\MSBuild.exe"
      commandline="${msbuild.xml} /t:build /p:HogeDir=${hoge.dir}"
    />
  </target>
</project>

MSBuild のビルドファイル msbuild.xml

NAnt から渡されたプロパティ使ってディレクトリを作るだけのビルドファイル。
MSBuild の場合、プロパティは{}(中括弧)でなくて()(小括弧)で囲む。プロパティ名に.を含むとエラーになるようだ。
VS2005で編集すると に警告が出るが気にしない。

<?xml version="1.0" encoding="utf-8"?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <PropertyGroup>
    <HogeDir />
  </PropertyGroup>
  <Target Name ="build">
    <MakeDir Directories="$(HogeDir)" />
  </Target>
</Project>

CruiseControl.NET でビルドログ作成エラー回避

CruiseControl.NET 1.0 でソース管理に Subversion を使っているとビルド自体は成功するが、ビルドログ作成時に例外が発生することがある。
Build Status には Failure でなくて Exception と表示されるので見分けはつくが、あまり気分のいいものではない。なんとか回避策を見つけたのでメモ。

なぜ例外が発生するか?

発生する例外はこんな感じ。

 ---> System.Xml.XmlException: 行 '11' の 'msg' 開始タグは 'logentry' の終了タグと一致しません。 行 12、位置 3 です。
   at System.Xml.XmlTextReader.ParseTag()
   at System.Xml.XmlTextReader.ParseBeginTagExpandCharEntities()
   at System.Xml.XmlTextReader.Read()
   at System.Xml.XmlValidatingReader.ReadNoCollectTextToken()
   at System.Xml.XmlValidatingReader.Read()
   at System.Xml.XmlLoader.LoadChildren(XmlNode parent)
   at System.Xml.XmlLoader.LoadElementNode()
   at System.Xml.XmlLoader.LoadCurrentNode()
   at System.Xml.XmlLoader.LoadCurrentNode()
   at System.Xml.XmlLoader.LoadChildren(XmlNode parent)
   at System.Xml.XmlLoader.LoadElementNode()
   at System.Xml.XmlLoader.LoadCurrentNode()
   at System.Xml.XmlLoader.LoadCurrentNode()
   at System.Xml.XmlLoader.LoadChildren(XmlNode parent)
   at System.Xml.XmlLoader.LoadElementNode()
   at System.Xml.XmlLoader.LoadCurrentNode()
   at System.Xml.XmlLoader.LoadCurrentNode()
   at System.Xml.XmlLoader.LoadDocSequence(XmlDocument parentDoc)
   at System.Xml.XmlLoader.Load(XmlDocument doc, XmlReader reader, Boolean preserveWhitespace)
   at System.Xml.XmlDocument.Load(XmlReader reader)
   at System.Xml.XmlDocument.LoadXml(String xml)
   at ThoughtWorks.CruiseControl.Core.Sourcecontrol.SvnHistoryParser.ReadSvnLogIntoXmlNode(TextReader svnLog)
   --- 内部例外スタック トレースの終わり ---
   at ThoughtWorks.CruiseControl.Core.Sourcecontrol.SvnHistoryParser.ReadSvnLogIntoXmlNode(TextReader svnLog)
   at ThoughtWorks.CruiseControl.Core.Sourcecontrol.SvnHistoryParser.Parse(TextReader svnLog, DateTime from, DateTime to)
   at ThoughtWorks.CruiseControl.Core.Sourcecontrol.ProcessSourceControl.ParseModifications(TextReader reader, DateTime from, DateTime to)
   at ThoughtWorks.CruiseControl.Core.Sourcecontrol.ProcessSourceControl.ParseModifications(ProcessResult result, DateTime from, DateTime to)
   at ThoughtWorks.CruiseControl.Core.Sourcecontrol.Svn.GetModifications(IIntegrationResult from, IIntegrationResult to)
   at ThoughtWorks.CruiseControl.Core.Sourcecontrol.QuietPeriod.GetModifications(ISourceControl sourceControl, IIntegrationResult lastBuild, IIntegrationResult thisBuild)
   at ThoughtWorks.CruiseControl.Core.IntegrationRunner.RunIntegration(BuildCondition buildCondition)

スタックトレースを見ると XML が不正らしい。で、これが原因の XML

<?xml version="1.0" encoding="utf-8"?>
<log>
<logentry
   revision="14">
<author>user</author>
<date>2006-01-28T06:43:06.703125Z</date>
<paths>
<path
   action="A">/trunk/UITest</path>
</paths>
<msg>譁・ュ怜喧縺代ユ繧ケ繝医・險ュ螳・/msg>
</logentry>
</log>

タグの部分が文字化けしていてタグが閉じていない。ちなみにログメッセージは「文字化けテストの設定」。

CruiseControl.NET は Subversion のログ取得時に System.Diagnostics.Process クラスを使用していて、それが Subversion の出力するログ(UTF-8)を Shift-JIS で取得しているため文字化けが発生しているようだ。

回避策

System.Diagnostics.Process クラスの StandardOutput プロパティからデータを取り出すときに正しくエンコードしてやるしかないようだけど、ThoughtWorks.CruiseControl.Core.Util.ProcessReader クラスを修正すると正しいエンコーディングを判定するのが面倒だし、影響範囲が多そうだったのでやめておいた。その代わりに Subversion を呼び出して出力を UTF-8 に変換するプログラム SvnHook.exe を作ってそれを呼び出すようにした。( svn.exe をリネームして SvnHook.exe を svn.exe にしようかと思ったが、trac にも影響が及びそうなので没)

1. ThoughtWorks.CruiseControl.Core.Sourcecontrol.Svn クラスを修正
public const string DefaultExecutable = "svn.exe";

上記の部分を以下のように変更

public const string DefaultExecutable = "SvnHook.exe";

ソースに同梱されている b.bat でビルドし、できたアセンブリ ThoughtWorks.CruiseControl.Core.dll で既存のファイルを上書きする。
.NET Framework 1.1 SDK がないとビルドに失敗するようだ。
※ ソリューションファイルを VS2005 用に変換してもビルドに失敗する。

2. SvnHook.exe の作成

コンソールプロジェクトを作成して以下のような Program クラスを作成し、SvnHook.exe へパスを通しておく。

namespace SvnHook {
    class Program {
        static void Main(string[] args) {
            ProcessStartInfo info = new ProcessStartInfo();
            info.FileName = @"svn.exe";
            if (args != null) {
                info.Arguments = string.Join(" ", args);
            }
            info.RedirectStandardOutput = true;
            info.UseShellExecute = false;
            info.CreateNoWindow = true;
            Process p = new Process();
            p.StartInfo = info;
            p.Start();
            string output;
            using (StreamReader r = new StreamReader(p.StandardOutput.BaseStream, Encoding.UTF8)) {
                output = r.ReadToEnd();
            }
            p.WaitForExit();

            System.Console.Out.WriteLine(output);
        }
    }
}


UTF8 固定なのでちょっと心配だが、とりあえず上記の方法で例外は発生しないようになった。

ASP.NET 2.0 Web サービスでグローバル例外ハンドラ

ASP.NET Web サービス でも ASP.NET Web アプリケーションと同じように、Global.asax の Application_Error イベントで例外をハンドルできると思っていたらできなかったので回避策をメモ

何故ハンドル出来ないか?

ここによると、

Web サービスの HttpHandler は、Web サービスの実行中に発生した例外を処理し、その例外を Application_Error イベントを呼び出す前に SOAP 違反に変換します。

と、いうことらしい。

例外をハンドルするためには?

SOAP 拡張機能を作成し、グローバル例外ハンドラで Web サービスの例外を処理します。

と、いうことらしい。具体的にはここを参考に、SoapExtension のサブクラスを作成し、Web.config ファイルでSoap 拡張機能を指定すれば良い。

Soap 拡張機能

とりあえずクラスライブラリプロジェクトを作成し、例外を log4net でロギングするクラス LoggingExtension を作成してみた。
肝心なのは ProcessMessage メソッドで、SoapMessage クラスの Stage プロパティが AfterSerialize または BeforeSeialize の時、 Exception プロパティに SoapException が格納されている。その SoapException の InnerException プロパティに、実際に発生した例外が格納されているので、それを処理するようにした。
その他のメソッドは下記の実装でも特に問題ないようだ。

using System;
using System.Web.Services.Protocols;
using log4net;

namespace Extensions {
    public class LoggingExtension : SoapExtension {
        private static readonly ILog logger = LogManager.GetLogger("default");

        public override void ProcessMessage(SoapMessage message) {
            switch (message.Stage) {
                case SoapMessageStage.AfterSerialize:
                    if (message.Exception != null) {
                        logger.Error("",message.Exception.InnerException);
                    }
                    break;
                default:
                    break;
            }
        }

        public override object GetInitializer(Type serviceType) {
            return null;
        }

        public override object GetInitializer(
            LogicalMethodInfo methodInfo,
            SoapExtensionAttribute attribute) {

            return null;
        }

        public override void Initialize(object initializer) {
        }
    }
}

Web.config

Web サービスプロジェクトで上記のクラスライブラリを参照しただけでは不十分で、さらにWeb.config ファイルで以下の設定をする必要がある。
add タグは という書式で書く。

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <appSettings/>
  <connectionStrings/>
  <system.web>
    <webServices>
      <soapExtensionTypes>
        <add type="Extensions.LoggingExtension, Extensions"/>
      </soapExtensionTypes>
    </webServices>
  </system.web>
</configuration>

log4net でロギングされない場合

原因は良くわからないが、Web.config が単純なら上記の設定でロギングされる。なんだけど、Web.config が複雑になるとログが出力されないことがある。とりあえず Web サービスクラスで明示的に以下のコードを記述するときちんとロギングされるようになった。

log4net.Config.XmlConfigurator.Configure();

SqlBulkCopy のベンチマーク

.NET 2.0 で、大量のデータをインポートするクラス SqlBulkCopy が導入された。その性能を試してみたのでメモ。

テスト結果

試行したPCの性能は以下のとおり。
CPU : Pen4 2.4GHz , RAM : 512MB , OS : WinXPSP2 , DB : SQLServer 2005 Express
各データ数に対して連続で5回試行し、その平均をとってみた。

データ数      SqlCommand      SqlBulkCopy
     100 :       108.6 [ms]       15.0 [ms]
    1000 :       990.2 [ms]       80.6 [ms]
   10000 :      7999.6 [ms]      193.4 [ms]
  100000 :     75634.0 [ms]     1696.4 [ms]

1000件くらいなら1件ずつINSERTでも我慢できないレベルじゃないけど、1万件を超えると迷わず SqlBulkCopy を使えという感じかな。残念なのは、クラス名が示すように SQLServer 用にしか用意されていないこと。

テストコード

前半は SqlCommand を使って Table1 に1件ずつ INSERT している。
後半では、Table1 からデータを DataTable に抽出して SqlBulkCopy で Table2 にインポートしている。

string constr = @"Data Source=.\sqlexpress;Initial Catalog=testdb;Integrated Security=True";
using (SqlConnection con = new SqlConnection(constr)) {
    con.Open();
    SqlCommand c = new SqlCommand("INSERT INTO Table1 VALUES(@id,@name)", con);
    c.Parameters.Add("@id", SqlDbType.Int);
    c.Parameters.Add("@name", SqlDbType.NVarChar);

    DateTime start = DateTime.Now;
    for (int i = 1; i <= COUNT; i++) {
        c.Parameters["@id"].Value = i;
        c.Parameters["@name"].Value = "name";
        c.ExecuteNonQuery();
    }
    TimeSpan span = DateTime.Now - start;

    Log(span, true);
}

DataTable table = new DataTable();

using (SqlConnection con = new SqlConnection(constr)) {
    con.Open();

    SqlCommand command = new SqlCommand("SELECT * FROM Table1", con);
    SqlDataAdapter adapter = new SqlDataAdapter(command);
    adapter.Fill(table);
}

using (SqlConnection con = new SqlConnection(constr)) {
    con.Open();
    using (SqlBulkCopy copy = new SqlBulkCopy(con)) {
        copy.DestinationTableName = "Table2";

        DateTime start = DateTime.Now;
        copy.WriteToServer(table);
        TimeSpan span = DateTime.Now - start;

        Log(span, false);
    }
}

ADO.NET で IN演算子

ADO.NET 1.x でいい解決方法が見つからなかった問題が、ADO.NET 2.0 でも解決できなかったのでメモ。

テーブル Table1 のデータがこんな感じの場合、

 ID    NAME
 1     name1
 2     name2
 12    name1,name2

NAME が name1 または name2 のデータをSELECTしようとして、以下のように書くと意図しない結果になる。

string constr = @"Data Source=.\sqlexpress;Initial Catalog=testdb;Integrated Security=True";
using (SqlConnection con = new SqlConnection(constr)) {
    SqlCommand command = new SqlCommand();
    command.Connection = con;
    command.CommandText = "SELECT * FROM Table1 WHERE NAME IN (@name)";
    SqlParameter param = new SqlParameter("@name", SqlDbType.NVarChar);
    param.Value = "name1,name2";
    command.Parameters.Add(param);

    SqlDataAdapter adapter = new SqlDataAdapter(command);
    DataTable dt = new DataTable();
    adapter.Fill(dt);
}
----- DataTable の中身 -----
 ID    NAME
 12    name1,name2

SqlCommand の生成部分を以下のようにすると、意図した結果になる。

    SqlCommand command = new SqlCommand();
    command.Connection = con;
    command.CommandText = "SELECT * FROM Table1 WHERE NAME IN (@name1,@name2)";
    SqlParameter param1 = new SqlParameter("@name1", SqlDbType.NVarChar);
    param1.Value = "name1";
    command.Parameters.Add(param1);
    SqlParameter param2 = new SqlParameter("@name2", SqlDbType.NVarChar);
    param2.Value = "name2";
    command.Parameters.Add(param2);
----- DataTable の中身 -----
 ID    NAME
 1     name1
 2     name2

IN演算子の中のパラメータ数が固定ならこれでも問題ないが、そんなことはまずないと思うので以下のように動的にパラメータを生成すると良い。多分キャッシュも効くはず。
今のところこれ以上の解決方法が思いつかない。

    SqlCommand command = new SqlCommand();
    command.Connection = con;
    string[] targets = new string[] { "name1","name2" };
    string[] paramNames = new string[targets.Length];
    for (int i = 0; i < target.Length; i++) {
        paramNames[i] = "@name" + i;
        SqlParameter param = new SqlParameter(paramNames[i], SqlDbType.NVarChar);
        param.Value = targets[i];
        command.Parameters.Add(param);
    }
    string sql = "SELECT * FROM Table1 WHERE NAME IN ({0})";
    command.CommandText = string.Format(sql, string.Join(",", paramNames));
----- DataTable の中身 -----
 ID    NAME
 1     name1
 2     name2

TransactionScope (2)

これ の続き。TransactionScope の動作についてのメモ。

使用するテーブル

CREATE TABLE Table1 (
    ID INT PRIMARY KEY,
    NAME NVARCHAR(10)
)

ネストしない場合

メソッド Insert は、DBへの接続をオープンして Table1 にデータを追加後接続をクローズする。

using (TransactionScope root = new TransactionScope(TransactionScopeOption.Required)) {
    this.Insert(1, "name1");
    this.Insert(2, "name2");
}
--
RESULT
|  ID  |  NAME  |
| NULL |  NULL  |

これは予想通り。
Complete を予想外の場所で呼ぶと

using (TransactionScope root = new TransactionScope(TransactionScopeOption.Required)) {
    root.Complete();
    this.Insert(1, "name1");// A
    this.Insert(2, "name2");// B
}
--
RESULT
|  ID  |  NAME  |
| NULL |  NULL  |

A で InvalidOperationException がスローされる。
さらに Complete の場所を変えると

using (TransactionScope root = new TransactionScope(TransactionScopeOption.Required)) {
    this.Insert(1, "name1");// A
    root.Complete();
    this.Insert(2, "name2");// B
}
--
RESULT
|  ID  |  NAME  |
|  1   |  name1 |

今度は B で InvalidOperationException がスローされるが、A の Insert はコミットされている。

ネストした場合

普通の使い方はこんな感じかな。

using (TransactionScope root = new TransactionScope(TransactionScopeOption.Required)) {
    this.Insert(1, "name1");// A
    using (TransactionScope nest = new TransactionScope(TransactionScopeOption.Required)) {
        this.Insert(2, "name2");// B
        nest.Complete();
    }
    root.Complete();
}
--
RESULT
|  ID  |  NAME  |
|  1   |  name1 |
|  2   |  name2 |

ネストされた TransactionScope の Complete をコメントアウトしてみる。

using (TransactionScope root = new TransactionScope(TransactionScopeOption.Required)) {
    this.Insert(1, "name1");// A
    using (TransactionScope nest = new TransactionScope(TransactionScopeOption.Required)) {
        this.Insert(2, "name2");// B
        //nest.Complete();
    }
    root.Complete();
}// C
--
RESULT
|  ID  |  NAME  |
| NULL |  NULL  |

すると C で System.Transactions.TransactionAbortedException がスローされる。
今度はルート TransactionScope の Complete をコメントアウトしてみる。

using (TransactionScope root = new TransactionScope(TransactionScopeOption.Required)) {
    this.Insert(1, "name1");// A
    using (TransactionScope nest = new TransactionScope(TransactionScopeOption.Required)) {
        this.Insert(2, "name2");// B
        nest.Complete();
    }
    //root.Complete();
}// C
--
RESULT
|  ID  |  NAME  |
| NULL |  NULL  |

ネストされた TransactionScope の Complete は無視される。MSDN で全ての TransactionScope の Complete が呼ばれた場合のみコミットされると書いてあったので、まあ予想通り。
この状態でネストされた TransactionScopeOption を変えてみると

using (TransactionScope root = new TransactionScope(TransactionScopeOption.Required)) {
    this.Insert(1, "name1");// A
    using (TransactionScope nest = new TransactionScope(TransactionScopeOption.RequiresNew)) {
        this.Insert(2, "name2");// B
        nest.Complete();
    }
    //root.Complete();
}// C
--
RESULT
|  ID  |  NAME  |
|  2   |  name2 |

今度は Complete のコメントアウトを逆にしてみる。

using (TransactionScope root = new TransactionScope(TransactionScopeOption.Required)) {
    this.Insert(1, "name1");// A
    using (TransactionScope nest = new TransactionScope(TransactionScopeOption.RequiresNew)) {
        this.Insert(2, "name2");// B
        //nest.Complete();
    }
    root.Complete();
}// C
--
RESULT
|  ID  |  NAME  |
|  1   |  name1 |