.NET ビルド小技集 (4)

前回の .NET ビルド小技集 (3) では、PowerShell スクリプトでプロジェクトをビルドして ZIP ファイルを作成しました。
今回はさらに、バージョン番号をインクリメントする方法を追加します。

 

PowerShell でバージョンをインクリメントしてビルドする

前回までの方法では、Release ビルドの前に手動で AssemblyInfo.cs のバージョンを編集しなければなりませんでした。
今回は、バージョンのインクリメントも PowerShell で自動化します。

$msbuild = "C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe"
.\IncrementVersion-cs.ps1 ..\EmptyConsole
$slnFilePath = "..\BuildSample.sln"
Invoke-Expression ([string]::Format("{0} {1} /p:Configuration=Release /t:Clean", $msbuild, $slnFilePath))
Invoke-Expression ([string]::Format("{0} {1} /p:Configuration=Release /t:Rebuild", $msbuild, $slnFilePath))
.\CreateZipForAssembly.ps1 ..\EmptyConsole\bin\Release\EmptyConsole.exe ..\Downloads
explorer ..\Downloads
$source = @"
using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
public static class Program
{
public static int Main(string[] args)
{
if (args.Length < 1) return 100;
IncrementVersion(args[0]);
return 0;
}
public static void IncrementVersion(string projDirPath)
{
var assemblyInfoPath = Directory.EnumerateFiles(projDirPath, "AssemblyInfo.cs", SearchOption.AllDirectories).First();
var contents = File.ReadLines(assemblyInfoPath, Encoding.UTF8)
.Select(IncrementLine)
.ToArray();
File.WriteAllLines(assemblyInfoPath, contents, Encoding.UTF8);
}
static string IncrementLine(string line)
{
if (line.StartsWith("//")) return line;
var match = Regex.Match(line, @"Assembly(File)?Version\(""([0-9\.]+)""\)");
if (!match.Success) return line;
var oldVersion = match.Groups[2].Value;
var newVersion = IncrementBuildNumber(oldVersion);
return line.Replace(oldVersion, newVersion);
}
static string IncrementBuildNumber(string version)
{
return Regex.Replace(version, @"^(\d+\.\d+\.)(\d+)((\.\d+)?)$", m => m.Groups[1].Value + IncrementNumber(m.Groups[2].Value) + m.Groups[3].Value);
}
static string IncrementNumber(string i)
{
return (int.Parse(i) + 1).ToString();
}
}
"@
Add-Type -TypeDefinition $source -Language CSharp
# $Args[0]: project directory path
[Program]::Main($Args)

前回のファイルに IncrementVersion-cs.ps1 を追加しました。
このスクリプトで AssemblyInfo.cs 内の
AssemblyVersion 属性および AssemblyFileVersion 属性の値のビルド番号を 1 だけ増加させています。
例えば、1.0.2.0 が 1.0.3.0 に、1.0.2 が 1.0.3 に変わります。

Add-Type Cmdlet を利用しており実質的には C# のコードで、
文字列の検索・置換には正規表現を利用しています。

 

これで、リリース時にはスクリプトを PowerShell で実行するだけです。

image

実行結果:

image

 

2018.02.06 追記: バージョンをインクリメントしてビルドする PowerShell スクリプトを NuGet でインストールできるようにしました。

前回: .NET ビルド小技集 (3)

作成したサンプル
BuildSample (GitHub)

参照
Add-Type
.NET Framework の正規表現
Build Version Increment Add-In Visual Studio
第4回 ミッション:ビルドを自動化セヨ!

たまに利用する .NET Tips 集
NuGet パッケージを作成して公開する

カテゴリー: ALM, ツール. タグ: , . 4 Comments »

.NET ビルド小技集 (3)

前回の .NET ビルド小技集 (2) では、プロジェクトのビルド イベントで ZIP ファイルを作成しました。
今回は、ビルド自体を PowerShell で実行する方法についてです。

 

PowerShell でビルドする

前回までの方法では、ビルドのたびにコンテンツ ファイルがコピーされ、ZIP ファイルが作成されますが、
現実の運用では ZIP ファイルを作成するのは毎回である必要はなく、
アプリをリリースするときの Release ビルドだけでよいでしょう。

そこで今回は、Release ビルドと ZIP ファイルの作成を PowerShell で実行することにします。
ただし、xcopy によるファイルのコピーはデバッグ時にも必要だと考えられるため、プロジェクトのビルド イベントに残します。

image

PowerShell スクリプトで、MSBuild.exe および前回作成した CreateZipForAssembly.ps1 を呼び出します。

$msbuild = "C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe"
$slnFilePath = "..\BuildSample.sln"
Invoke-Expression ([string]::Format("{0} {1} /p:Configuration=Release /t:Clean", $msbuild, $slnFilePath))
Invoke-Expression ([string]::Format("{0} {1} /p:Configuration=Release /t:Rebuild", $msbuild, $slnFilePath))
.\CreateZipForAssembly.ps1 ..\EmptyConsole\bin\Release\EmptyConsole.exe ..\Downloads
explorer ..\Downloads

 

以上で準備は完了です。
リリース時には AssemblyInfo.cs でバージョンを設定して、この ps1 ファイルを PowerShell で実行します。

image

image

実行結果:

image

 

前回: .NET ビルド小技集 (2)
次回: .NET ビルド小技集 (4)

作成したサンプル
BuildSample (GitHub)

バージョン情報
Visual Studio 2013

参照
Build Version Increment Add-In Visual Studio
第4回 ミッション:ビルドを自動化セヨ!

たまに利用する .NET Tips 集
NuGet パッケージを作成して公開する

カテゴリー: ALM, ツール. タグ: . 2 Comments »

.NET ビルド小技集 (2)

前回の .NET ビルド小技集 (1) では、プロジェクトのビルド イベントを利用しました。
今回はビルド イベントで ZIP ファイルを作成する方法についてです。

 

ZIP ファイルを作成する

前回説明したファイルのコピーに加えて、ビルドにより出力された実行ファイル一式を圧縮して ZIP ファイルにします。
PowerShell では .NET のライブラリを利用することができるため、
PowerShell スクリプトで ZIP ファイルを作成することにします。
(PowerShell には圧縮のための Compress-Archive Cmdlet があったりもしますが。)

まず、ZipFile.CreateFromDirectory メソッドをラップしたスクリプトを用意します。
引数は、アセンブリのディレクトリと ZIP ファイルのパスです。

# source directory path
if (-not ($Args[0])) { return 100 }
# target zip file path
if (-not ($Args[1])) { return 101 }
Add-Type -AssemblyName System.IO.Compression.FileSystem
$parent = [System.IO.Path]::GetDirectoryName($Args[1])
[System.IO.Directory]::CreateDirectory($parent)
[System.IO.File]::Delete($Args[1])
[System.IO.Compression.ZipFile]::CreateFromDirectory($Args[0], $Args[1])
view raw CreateZip.ps1 hosted with ❤ by GitHub

そして、前回のプロジェクトのビルド イベントでこのスクリプトを呼び出します。

cd $(ProjectDir)
xcopy Data $(OutDir)Data /D/E/C/I/H/Y
powershell -ExecutionPolicy Unrestricted ..\Tools\CreateZip.ps1 $(OutDir) ..\Downloads\$(TargetName).zip

これで、ビルドのたびに ZIP ファイルが作成されます。

image

 

ちなみに、PowerShell では C# のコードを埋め込むことができます。
次のようにしても同様の結果が得られます。

# source directory path
if (-not ($Args[0])) { return 100 }
# target zip file path
if (-not ($Args[1])) { return 101 }
$references = @("System.IO.Compression.FileSystem")
$source = @"
using System;
using System.IO;
using System.IO.Compression;
public static class ZipHelper
{
public static void CreateZip(string sourceDirPath, string targetZipFilePath)
{
var targetDirPath = Path.GetDirectoryName(targetZipFilePath);
Directory.CreateDirectory(targetDirPath);
File.Delete(targetZipFilePath);
ZipFile.CreateFromDirectory(sourceDirPath, targetZipFilePath);
}
}
"@
Add-Type -TypeDefinition $source -Language CSharp -ReferencedAssemblies $references
[ZipHelper]::CreateZip($Args[0], $Args[1])

 

ZIP ファイルの名前にバージョンを含める

さらに、ZIP ファイルの名前を、App1-1.0.0.zip のようにバージョン番号が含まれるようにしてみましょう。
バージョン番号として、AssemblyInfo.cs の AssemblyFileVersion 属性に指定された値を取得します。

先ほどの CreateZip.ps1 を拡張して、
対象のアセンブリから AssemblyFileVersion 属性を取得して、
ZIP ファイルの名前が App1-1.0.0.zip の形式になるように組み立てます。
このスクリプトの引数は、アセンブリのパスと出力先のディレクトリとしています。

# source assembly file path
if (-not ($Args[0])) { return 100 }
# target dir path
if (-not ($Args[1])) { return 101 }
Add-Type -AssemblyName System.IO.Compression.FileSystem
$assemblyName = [System.IO.Path]::GetFileNameWithoutExtension($Args[0])
$assembly = [System.Reflection.Assembly]::LoadFrom($Args[0])
$assemblyFileVersion = [System.Reflection.CustomAttributeExtensions]::GetCustomAttribute($assembly, [System.Reflection.AssemblyFileVersionAttribute])
if (-not ($assemblyFileVersion)) { return 200 }
$sourceDirPath = [System.IO.Path]::GetDirectoryName($Args[0])
$targetZipFileName = [string]::Format("{0}-{1}.zip", $assemblyName, $assemblyFileVersion.Version)
$targetZipFilePath = [System.IO.Path]::Combine($Args[1], $targetZipFileName)
[System.IO.Directory]::CreateDirectory($Args[1])
[System.IO.File]::Delete($targetZipFilePath)
[System.IO.Compression.ZipFile]::CreateFromDirectory($sourceDirPath, $targetZipFilePath)

あとは、プロジェクトのビルド イベントでこのスクリプトを呼び出します。

cd $(ProjectDir)
xcopy Data $(OutDir)Data /D/E/C/I/H/Y
powershell -ExecutionPolicy Unrestricted ..\Tools\CreateZipForAssembly.ps1 $(TargetPath) ..\Downloads

AssemblyFileVersion 属性には、「1.0.1-Alpha」のように任意の文字列を指定できるようです。
(ただし、AssemblyVersion 属性は数値または * でなければビルド エラーになります。)

image

実行結果:

image

 

次回は、ビルド自体を PowerShell で実行する方法についてです。

前回: .NET ビルド小技集 (1)
次回: .NET ビルド小技集 (3)

作成したサンプル
BuildSample (GitHub)

バージョン情報
Visual Studio 2013

参照
ZipFile.CreateFromDirectory メソッド

Add-Type
PowerShell の Add-Type と [Reflection.Assembly]
PowerShellスクリプト内でC#コードを書いて使う

Compress-Archive
PowerShell v5 の新機能紹介 – Zipファイルの操作が可能に

カテゴリー: ALM, ツール. タグ: , . 2 Comments »

.NET ビルド小技集 (1)

.NET アプリケーション開発のビルドに関する小技を集めました。
以下の内容の実現方法について取り扱います。

  • ビルド時にファイルをコピーする
  • ZIP ファイルを作成する
  • ZIP ファイルの名前にバージョンを含める
  • PowerShell でビルドする
  • PowerShell でバージョンをインクリメントしてビルドする

追記: ビルド用のスクリプトを Visual Studio の「外部ツール」に登録すると便利です。
また、.NET Core プロジェクト形式向けのビルド スクリプトも追加しました。

 

ビルド時にファイルをコピーする

.NET アプリケーションをビルドすると、
プロジェクト フォルダーの下の bin\Debug や bin\Release に実行ファイルが出力されます。
このとき同時に、画像、動画、データなどのコンテンツ ファイルをコピーする方法を紹介します。
(サイズの小さい画像ファイルであればアセンブリ内のリソースとして埋め込んで使いますが。)

よくある方法としては、コンテンツ ファイルをプロジェクトに含めて、プロパティで [出力ディレクトリにコピー] の項目を設定します。
ただしこれだと、ファイルごとに設定しなければならないという欠点があります。

imageimage

 

この欠点を回避するには、プロジェクトのビルド イベントを利用するとよいでしょう。
プロジェクトのプロパティを開き、[ビルド イベント] を選択します。
この画面で、ビルド時に実行するコマンド ラインを指定できます。

ファイルをコピーするには copy、フォルダーをコピーするには xcopy を利用します。

xcopy $(ProjectDir)Data $(TargetDir)Data /D/E/C/I/H/Y

image

$(ProjectDir) などの形式の文字列はマクロと呼ばれるもので、
ソリューションやプロジェクトにとっての特別な文字列を扱うことができます。
[ビルド後の編集] をクリックするとエディターが開きます。
ここではマクロの実際の値を確認しながら自動で挿入できます。

image

 

上記で使用した xcopy の各オプションの説明も挙げておきます。

  • /D コピー元の日付がコピー先の日付より新しいファイルだけをコピーします。
  • /E ディレクトリまたはサブディレクトリが空であってもコピーします。"/S /E" と同じ意味です。
  • /C エラーが発生してもコピーを続けます。
  • /I 指定されたコピー先が存在しない場合、コピー先をディレクトリとしてコピーします。
  • /H 隠しファイルやシステム ファイルもコピーします。
  • /Y 既存のファイルを上書きする前に確認のメッセージを表示しません。

 

このように設定してビルドを実行すれば、出力ディレクトリにコンテンツ ファイルがコピーされます。

image

 

なお、ビルド イベントでコマンド ラインが実行されるときの作業ディレクトリは、
$(TargetDir)、つまり bin\Debug や bin\Release などの出力ディレクトリです。
複数のコマンドを実行する場合など、相対パスにしたほうがわかりやすくなるときは、

cd $(ProjectDir)
xcopy Data $(OutDir)Data /D/E/C/I/H/Y

のように作業ディレクトリを設定するとよいでしょう。

次回は、ビルド時に ZIP ファイルを作成する方法についてです。

次回: .NET ビルド小技集 (2)

作成したサンプル
BuildSample (GitHub)

バージョン情報
Visual Studio 2013

参照
Copy
Xcopy

カテゴリー: ALM, ツール. タグ: . 1 Comment »

Azure Table の検索条件を LINQ で指定する

Azure Storage の SDK (Windows Azure Storage) を利用して .NET のクライアントから Table のデータを取得する際に、
検索条件を指定しようとすると、通常の実装では次のようなコードになり少し複雑です。
この例では、フィルター条件を 2 つ指定しています。


var query = new TableQuery<Person>()
    .Where(TableQuery.CombineFilters(
        TableQuery.GenerateFilterCondition("PartitionKey", QueryComparisons.Equal, "2015"),
        TableOperators.And,
        TableQuery.GenerateFilterConditionForInt("Age", QueryComparisons.LessThan, 20)));

var result = PeopleTable.ExecuteQuery(query).ToArray();


ここで、Person  は TableEntity を継承したクラスで、PeopleTable は CloudTable 型のオブジェクトです。

結局、上記の Where メソッドに渡されるのは、

(PartitionKey eq ‘2015’) and (Age lt 20)

という文字列になります。
これなら string.Format メソッドでもよいのではないのかという気もしますが、
プログラミングのミスを防ぐためには、フィルターや射影などの検索条件は LINQ で指定したいところです。

検索条件を LINQ で指定する方法として TableServiceContext クラスを使う方法もあるようですが、
現在は Obsolete 属性が指定されており、非推奨となっています。

とはいえ、自力で IQueryable<T> を実装するのも骨が折れるので、
ここでは簡易的に、TableQuery<T> の拡張メソッドとして Select および Where メソッドを実装していきます。
ラムダ式で指定された検索条件を式ツリーとして受け取って解析し、動的にクエリを生成します。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using Microsoft.WindowsAzure.Storage.Table;
namespace ExpressionsConsole
{
public static class TableHelper
{
static readonly MethodInfo String_CompareTo = typeof(string).GetMethod("CompareTo", new[] { typeof(string) });
public static TableQuery<TElement> Select<TElement>(this TableQuery<TElement> query, Expression<Func<TElement, object>> selector)
{
if (query == null) throw new ArgumentNullException("query");
if (selector == null) throw new ArgumentNullException("selector");
var @new = selector.Body as NewExpression;
if (@new == null) throw new InvalidOperationException();
query.SelectColumns = @new.Constructor.GetParameters().Select(p => p.Name).ToArray();
return query;
}
public static TableQuery<TElement> Where<TElement>(this TableQuery<TElement> query, Expression<Func<TElement, bool>> predicate)
{
if (query == null) throw new ArgumentNullException("query");
if (predicate == null) throw new ArgumentNullException("predicate");
var binary = predicate.Body as BinaryExpression;
if (binary == null) throw new InvalidOperationException();
var filter = GenerateFilter(binary);
query.FilterString = string.IsNullOrWhiteSpace(query.FilterString) ? filter : TableQuery.CombineFilters(query.FilterString, TableOperators.And, filter);
return query;
}
static string GenerateFilter(BinaryExpression binary)
{
switch (binary.NodeType)
{
case ExpressionType.AndAlso:
case ExpressionType.OrElse:
return CombineFilters(binary);
case ExpressionType.Equal:
case ExpressionType.NotEqual:
case ExpressionType.GreaterThan:
case ExpressionType.GreaterThanOrEqual:
case ExpressionType.LessThan:
case ExpressionType.LessThanOrEqual:
return (binary.Left is MethodCallExpression) ? GenerateFilterConditionForMethodCall(binary) : GenerateFilterCondition(binary);
default:
throw new InvalidOperationException();
}
}
static string CombineFilters(BinaryExpression binary)
{
var left = binary.Left as BinaryExpression;
if (left == null) throw new InvalidOperationException();
var right = binary.Right as BinaryExpression;
if (right == null) throw new InvalidOperationException();
var op = ToCombinationOperator(binary.NodeType);
return TableQuery.CombineFilters(GenerateFilter(left), op, GenerateFilter(right));
}
static string GenerateFilterCondition(BinaryExpression binary)
{
var left = binary.Left as MemberExpression;
if (left == null) throw new InvalidOperationException();
var op = ToComparisonOperator(binary.NodeType);
var rightValue = binary.Right.Invoke();
return
left.Type == typeof(byte[]) ? TableQuery.GenerateFilterConditionForBinary(left.Member.Name, op, (byte[])rightValue) :
left.Type == typeof(bool) ? TableQuery.GenerateFilterConditionForBool(left.Member.Name, op, (bool)rightValue) :
left.Type == typeof(DateTime) ? TableQuery.GenerateFilterConditionForDate(left.Member.Name, op, (DateTime)rightValue) :
left.Type == typeof(DateTimeOffset) ? TableQuery.GenerateFilterConditionForDate(left.Member.Name, op, (DateTimeOffset)rightValue) :
left.Type == typeof(double) ? TableQuery.GenerateFilterConditionForDouble(left.Member.Name, op, (double)rightValue) :
left.Type == typeof(Guid) ? TableQuery.GenerateFilterConditionForGuid(left.Member.Name, op, (Guid)rightValue) :
left.Type == typeof(int) ? TableQuery.GenerateFilterConditionForInt(left.Member.Name, op, (int)rightValue) :
left.Type == typeof(long) ? TableQuery.GenerateFilterConditionForLong(left.Member.Name, op, (long)rightValue) :
TableQuery.GenerateFilterCondition(left.Member.Name, op, rightValue.To<string>());
}
static string GenerateFilterConditionForMethodCall(BinaryExpression binary)
{
var methodCall = binary.Left as MethodCallExpression;
if (methodCall == null) throw new InvalidOperationException();
if (methodCall.Method != String_CompareTo) throw new InvalidOperationException();
var left = methodCall.Object as MemberExpression;
if (left == null) throw new InvalidOperationException();
var op = ToComparisonOperator(binary.NodeType);
var rightValue = methodCall.Arguments[0].Invoke();
return TableQuery.GenerateFilterCondition(left.Member.Name, op, rightValue.To<string>());
}
static string ToCombinationOperator(ExpressionType nodeType)
{
switch (nodeType)
{
case ExpressionType.AndAlso:
return TableOperators.And;
case ExpressionType.OrElse:
return TableOperators.Or;
default:
throw new InvalidOperationException();
}
}
static string ToComparisonOperator(ExpressionType nodeType)
{
switch (nodeType)
{
case ExpressionType.Equal:
case ExpressionType.NotEqual:
case ExpressionType.GreaterThan:
case ExpressionType.GreaterThanOrEqual:
case ExpressionType.LessThan:
case ExpressionType.LessThanOrEqual:
return (string)typeof(QueryComparisons).GetField(nodeType.ToString()).GetValue(null);
default:
throw new InvalidOperationException();
}
}
}
}
view raw TableHelper.cs hosted with ❤ by GitHub
using System;
using System.Collections.Generic;
using System.Configuration;
using System.Diagnostics;
using System.Linq;
using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Table;
namespace ExpressionsConsole
{
public static class TableTest
{
static readonly CloudTable PeopleTable;
static TableTest()
{
var accountString = ConfigurationManager.ConnectionStrings["StorageAccount"].ConnectionString;
var account = CloudStorageAccount.Parse(accountString);
var tableClient = account.CreateCloudTableClient();
PeopleTable = tableClient.GetTableReference("people");
}
public static void SelectTest()
{
var query = new TableQuery<Person>()
.Select(p => new { p.FirstName, p.Age });
var result = PeopleTable.ExecuteQuery(query).ToArray();
}
public static void WhereTest1()
{
// (PartitionKey eq '2015') and ((LastName ge 'W') or (Age lt 20))
var query = new TableQuery<Person>()
.Where(p => p.PartitionKey == "2015" && (p.LastName.CompareTo("W") >= 0 || p.Age < 20));
var result = PeopleTable.ExecuteQuery(query).ToArray();
}
public static void WhereTest2()
{
// (PartitionKey eq '2015') and ((LastName ge 'W') or (Age lt 20))
var query = new TableQuery<Person>()
.Where(p => p.PartitionKey == "2015")
.Where(p => p.LastName.CompareTo("W") >= 0 || p.Age < 20);
var result = PeopleTable.ExecuteQuery(query).ToArray();
}
public static void WhereSelectTest()
{
var query = new TableQuery<Person>()
.Where(p => p.PartitionKey == "2015")
.Where(p => p.LastName.CompareTo("W") >= 0 || p.Age < 20)
.Select(p => new { p.FirstName, p.Age });
var result = PeopleTable.ExecuteQuery(query).ToArray();
}
}
[DebuggerDisplay(@"\{{PartitionKey}/{RowKey}\}")]
public class Person : TableEntity
{
public string FirstName { get; set; }
public string LastName { get; set; }
public int Age { get; set; }
}
}
view raw TableTest.cs hosted with ❤ by GitHub

このような TableHelper クラスを実装することで、Azure Table の検索条件を LINQ で指定できるようになります。
ただし、文字列の不等式については、String クラスの演算子として不等号が定義されていないため、

p.LastName >= "W"

と書くことができず、

p.LastName.CompareTo("W") >= 0

のようにせざるを得ませんでした。

 

作成したサンプル
ExpressionsConsole (GitHub)

バージョン情報
Windows Azure Storage 6.0.0

参照
Windows Azure Storage
Expression<TDelegate> クラス
TableQuery<TElement> Class

Windows Azure Storage Extensions
テーブル サービスに対する LINQ クエリの作成 (古い形式)
TableServiceContext Class (古い形式)