kazuk は null に触れてしまった

C# / .NET 系技術ネタ縛りでお送りしております

月別アーカイブ: 8月 2011

MVC と Code Contracts と DataAnnotations


MVC でリポジトリパターン?サービスインターフェース?を Code Contracts で堅くいこうとすると、こんな恰好になりますよね?

[ContractClass( typeof( RepositoryContracts ) )]
public interface IRepository
{
    Data GetByName( string name );
}

[ContractClassFor( typeof( IRepository ) )]
abstract class RepositoryContracts : IRepository
{
    public Data GetByName( string name )
    {
        Contract.Require( name!=null );
    }
}

これをやればリポジトリを無茶なパラメータで呼ぶような事は契約によって守られるわけで素晴らしいんだけど。

namespace Application.Models
{
    public class QueryViewModel
    {
        [Require]
        public string Name { get; set; }
    }
}

なViewModel を元にコントローラで検索するこんなコード

[HttpPost]
public ActionResult Query( QueryViewModel model )
{
    if( ModelState.IsValid )
    {
        var data = _repository.GetByName( model.Name );
        return View( data );
    }
}

IsValid 確認したって、code contracts にとってはそんなこと知ったことなくで unproven 祭り。

この unproven を解消する為に Assume をここに書き込むのは避けたい、Model 側の属性で制御してる事柄を Controller に書き込んでいるも同然で、Model を仕様変更した途端に Assume は嘘に変わる可能性が無いわけではないし、そもそも DataAnnotations でやっている検証で保障してる内容を再確認するコードだし二重記述な気がして嫌だというわけ。

Model にこんなメソッドを書く

public void EnsuresAssumeAsValid( )
{
    Contract.Ensures( Name!=null );
    Contract.Assume( Name!=null );
}

そうすると、さっきのコントローラーでは model.EnsuresAssumeAsValid() と ModelState.IsValid の確認後に呼んであげれば Name が null じゃないという認識になる。

んで DataAnnotations での検証結果が反映されるようにEnsures/Assume するメソッドを書く、DataAnnotations での設定に従ってという仕事が残る。

形を変えての繰り返し作業ってメンドイよね、飽きやすいし、けどミスは許されなくて、属性書き換えたらちゃんと直さないと Code Contracts が絵に描いた餅になる。

でだ、そこで T4 だ

リフレクションで舐めるから Models は別アセンブリになる必要がある、まぁ、MVC でアプリケーション作ったら Models は別アセンブリにするのはレイヤ分離上も結構良くやる事なんで別に構わんだろう。

image

という感じで ASP.NET MVC3 のデフォルトの Models にちゃんと Contract を意識したコードを吐いてるのが以下のT4コード、これで ModelState.IsValid だったら model.EnsureAssumeAsValid() ってやれば DataContracts に従った Ensures/Assumeがされる。

<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ assembly name="System.ComponentModel.DataAnnotations" #>
<#@ assembly name="$(SolutionDir)\MvcApplication2.Models\bin\Debug\MvcApplication2.Models.dll" #>
<#@ import namespace="System.Reflection" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ output extension=".cs" #>
<#
    // ここで属性に対する Ensures / Assume を定義します
    AttributeTypeMap.Add(
        typeof( System.ComponentModel.DataAnnotations.RequiredAttribute ),
        new EnsuresAssumeActions { 
            Ensures= (attrData, propInfo) => {#>
            Contract.Ensures( item.<#=propInfo.Name#> != null );
<#},
            Assume= (attrData, propInfo) => {#>
            Contract.Assume( item.<#=propInfo.Name#> != null );
<#}
        }
    );
#>
using System.Diagnostics.Contracts;

namespace MvcApplication2
{
    public static class ContractHelper
    {
<# 
// モデル型をちゃんと抽出する様に直してね

var typeQuery = from asm in AppDomain.CurrentDomain.GetAssemblies() where asm.GetName().Name=="MvcApplication2.Models" from t in asm.GetTypes() select t; foreach( Type t in typeQuery ) {#> // <#=t.Name#> public static void EnsureAssumeAsValid( this <#=ClassName(t)#> item ) { <# foreach( var p in t.GetProperties() ) { #> <# foreach( var attr in p.GetCustomAttributesData() ) { EnsuresAssumeActions actions; if( AttributeTypeMap.TryGetValue( AttributeType(attr), out actions ) ) { actions.Ensures( attr, p ); } } #> <# } #> <# foreach( var p in t.GetProperties() ) { #> <# foreach( var attr in p.GetCustomAttributesData() ) { EnsuresAssumeActions actions; if( AttributeTypeMap.TryGetValue( AttributeType(attr), out actions ) ) { actions.Assume( attr, p ); } } #> <# } #> } <# } #> } } <#+ class EnsuresAssumeActions { public Action< CustomAttributeData, PropertyInfo > Ensures; public Action< CustomAttributeData, PropertyInfo > Assume; } Dictionary<Type, EnsuresAssumeActions> AttributeTypeMap = new Dictionary<Type, EnsuresAssumeActions>(); public string ClassName( Type t ) { return t.FullName; } public Type AttributeType( CustomAttributeData attrData ) { return attrData.Constructor.DeclaringType; } public string AttributeTypeName( Type t ) { string s =t.Name; s = s.Substring( 0, s.Length- "Attribute".Length); return s; } #>

Requires にしか変換パターン書いてないけど、必要に応じて足してくれればいいよね。

これで Unproven 祭りに Assume 祭りの応戦をする必要はなくなったね! code-contracs 活用して堅いサービス書いてね!、それでは see you!

無駄な処理をしない事と、重い処理をバックグラウンドにする事


UniversalProviders の高速化合戦に参戦してみます。

無聊を 託つ: HighPerformanceSessionStateProvider

ASP.NET Univarsal Providers のセッションプロバイダを使ってみる (3) « ブチザッキ

パフォーマンスの改善と言ったら、無駄処理の削減と重い処理のバックグラウウド化でしょーよと。

まず、無駄処理の削減、毎リクエストごとに Expire 処理するなんて無駄ジャン、精々毎秒、もっとルーズに行くなら 15秒に一回とかでもセッションの Expire 処理の精度なんて十分でしょーと。

private DateTime _lastSessionExpireInvoked = DateTime.MinValue;
private object _lockSessionExpire = new object();

で最後にセッションの Expire 処理が起動された日時を保存、これが1秒を超えてた時だけ Expire 処理を実行する様にします。

RemoveExpiredSessions(connectionStringSettings);

の一行を以下の様に書き換え

using System.Threading; (ソースの冒頭で)

if( _lastSessionExpireInvoked < DateTime.Now – TimeSpan.FromSeconds(1) )
{
    if( Monitor.TryEnter( _lockSessionExpire ) ) {
        // ロックに入れたスレッドでだけ処理する、入れなかったスレッドでは処理しない
        DateTime old = _lastSessionExpireInvoked;
        _lastSessionExpireInvoked = DateTime.Now;
        try
        {
            try
            {
                RemoveExpiredSessions(connectionStringSettings);
            }
            catch
            {
                // 失敗した時はタイマ値を戻すことで別のスレッドにやってもらう
                _lastSessionExpireInvoked = old;
                throw;
            }
        }
        finally
        {
            // 絶対にMonitorを出る
            Monitor.Exit( _lockSessionExpire );
        }
   }
}

これでリクエストが来ていれば1秒以上の間隔をもってRemoveExpiredSessions が呼ばれます。

このリクエストにとってはExpire処理をしなければならないなんてペナルティでしかありません。実行をバックグラウウドスレッドに投げるまでを責務として切り分けましょう。

まぁ、 ThreadPool.QueueUserWorkItem するだけで良いですよね。結果気にする必要あんまりないし。

bool _working = false;
private void RemoveExpiredSessions(
    ConnectionStringSettings connectionStringSettings)
{
    if( _working ) return; // やってる人が居るならやらんでいいでしょ
    _working = true;
    try {
        ThreadPool.QueueUserWorkItem( (object state)=>
        { 
            try
            {
                ExecuteSql(connectionStringSettings, (factory, command) =>
                { 
                    command.CommandText = "delete Sessions where Expires < @0";
                    var parameter = factory.CreateParameter();
                    parameter.ParameterName = "@0";
                    parameter.Value = DateTime.UtcNow;
                    command.Parameters.Add(parameter);
                });
            }
            finally { _working = false; }
        });
    } catch { _working = false; throw; }
}

_working 変数によって、すでに Queue されている処理が走っていれば実行しないって事が簡単にできますね。これで1秒以上の Expire 処理が走ってる時にかぶせて実行して RDBMS 側でのロック待ちに好き好んで飛び込む愚を避ける事ができます。

一番最後の catch は QueueUserWorkItem がキュー上限で OutOfMemoryException を投げる事に対する対策、_working を戻しとかないと二度と Expire 処理が走れなくなるという事に対する例外復旧ですね。

これで無駄に全リクエストで Expire 行くこともないし、無駄に RDBMS 上で Expire 総なめdeleteの為の tablock を奪い合い参加しないし、リクエスト処理にはぶっちゃけ WorkItemをQueueする為の負荷しかかからないはずで、もろもろ含めて使い物になる様になるんじゃないかな?

ToList / ToArray の性能 – GC 特性とCLRメモリヒューリスティックス


neuecc さんの ToArray vs ToList を受けてちょっと書いてみます。

というのも、 .NET Framework にはパフォーマンス上ある一点に断崖絶壁があり、この断崖絶壁を何の気なしに超えてしまうと一気にパフォーマンスが落ちます。この断崖絶壁すなわちオブジェクトのサイズによる CLR のヒューリスティックが発動される時です。

例えばこのヒューリスティックはオブジェクトのサイズが 24KB を超える点で発動します。

CLR2 ではこのヒューリスティックは LOH へのオブジェクトの配置、CLR4では Gen1 に確保する様になっているようです。

このヒューリスティックの発動は性能面ではっきりした影響を表す事に注意してください、最も単純な例でも 20% ~ 30% の性能影響がでます。 (参考: .NET CLR2 と CLR4 の StringBuilder のパフォーマンス )

 

確認してみましょう。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Diagnostics;

namespace ToArrayPerformance
{
    class Program
    {
        static void Main(string[] args)
        {
            Stopwatch stopWatch = new Stopwatch();
            for (int elmCount = 3500; elmCount < 8000; elmCount += 100)
            {
                stopWatch.Restart();
                for (int i = 0; i < 100000000/elmCount; i++)
                {
                    int[] result = Enumerable.Range(1,elmCount).ToArray();
                }
                stopWatch.Stop();
                Console.WriteLine( DateTime.Now.TimeOfDay +"\t"+
                    elmCount.ToString() + "\t" + 
                    stopWatch.Elapsed );
            }
            Console.ReadLine();
        }
    }
}

単純に Enumerable.Range を (1,n) する事の n を 3500 –> 8000 まで100 づつ伸ばしています。ループ回数を十分に大きく、しかし nに逆比例させる事で計測期間内で処理する要素数は平均的には一緒です。

09:33:24.0662034        3500    00:00:03.6627269

09:33:27.4823988        3600    00:00:03.4151080

09:33:31.2646151        3700    00:00:03.7812066

09:33:33.9517688        3800    00:00:02.6867959

09:33:37.1399512        3900    00:00:03.1877383

09:33:40.7921601        4000    00:00:03.6520175

09:33:44.7573869        4100    00:00:03.9653936

09:33:48.3675933        4200    00:00:03.6096440

09:33:52.6018355        4300    00:00:04.2337512

09:33:56.5520615        4400    00:00:03.9497644

09:33:59.6342378        4500    00:00:03.0817188

09:34:03.1714401        4600    00:00:03.5373395

09:34:07.0466617        4700    00:00:03.8745989

09:34:10.2968476        4800    00:00:03.2493948

09:34:13.9820584        4900    00:00:03.6850011

09:34:17.8952822        5000    00:00:03.9133208

09:34:21.6224954        5100    00:00:03.7266563

09:34:24.9926882        5200    00:00:03.3696358

09:34:28.5378910        5300    00:00:03.5445853

09:34:31.8280791        5400    00:00:03.2904550

09:34:35.7383028        5500    00:00:03.9096186

09:34:38.5334627        5600    00:00:02.7946198

09:34:41.1226108        5700    00:00:02.5892790

09:34:44.6508126        5800    00:00:03.5274057

09:34:47.9480011        5900    00:00:03.2966803

09:34:52.0562361        6000    00:00:04.1073346

09:34:56.2544763        6100    00:00:04.1981550

09:35:00.3177087        6200    00:00:04.0629688

09:35:04.5789524        6300    00:00:04.2609572

09:35:08.7101887        6400    00:00:04.1306676

09:35:12.1133833        6500    00:00:03.4019436

09:35:16.5896394        6600    00:00:04.4765241

09:35:20.6798733        6700    00:00:04.0892036

09:35:25.0381226        6800    00:00:04.3580106

09:35:29.1703589        6900    00:00:04.1319132

09:35:33.1835885        7000    00:00:04.0123602

09:35:37.2978238        7100    00:00:04.1144444

09:35:41.1280429        7200    00:00:03.8297086

09:35:44.0262086        7300    00:00:02.8976320

09:35:46.4393467        7400    00:00:02.4127087

09:35:50.6465873        7500    00:00:04.2066884

09:35:54.7918244        7600    00:00:04.1448968

09:35:58.6760466        7700    00:00:03.8837421

09:36:03.0412962        7800    00:00:04.3644022

09:36:06.3874876        7900    00:00:03.3454986

3要素目の処理時間を見ると2つのトレンドがある事に気づいてもらえますでしょうか。

6000要素を超えたとたんに 4秒以上処理時間を要する様に変わっています。

コレのパフォーマンスグラフを見ると以下の様になります。

image

端的には %Time in GC ががくっと上がっているのが目に見えて解ります。それを起点として立ち上がってるカウンターは # Gen1 Collections です。

image

int で 各4バイトの6000要素ですので、24KB がこの性能の境界線である事が解ります。

要するにアプリケーションが確保する領域のサイズが頻繁にこの境界を超えると3割ぐらい性能が落ちるという事になりますね。

 

さて、性能狂になる準備は整ったも同然ですね、練習問題です。

  1. 24KBを超えない様に内部バッファを管理してコピーを実行する GcNiYasasiiToArray を実装しましょう。
  2. この GcNiYasasiiToArray が管理する 24KB 以下に制約された中間バッファは当然に相当数のインスタンスが生成される事になります。(大きなデータを扱った場合、同じ24KB程度のサイズのバッファがいくつも作られる)これを再利用する事の有用性と馬鹿馬鹿しさを論じた上で実際に試して有用さ無駄さを評価してください。
    1. 再利用される事によりオブジェクトが長寿命になるという事は24KB以下のオブジェクトは Gen0 に作られるという点に配慮した意味を失うかもしれません。これがパフォーマンスグラフにどう表れるか、性能にどう影響を与えたかを評価しなければなりません。
    2. 配列は確保時にゼロクリアされます、このゼロクリアは要素を上書きするのであれば無駄なことで、これが避けられる様になる事は性能に影響する可能性があります、この性能変化を評価してください。
  3. あなたのアプリケーションに GCへの優しさを意識させると性能が変わりますか?それをパフォーマンスグラフより判断し、改善する事が期待できる場合、改善するべきポイントを CLR Profiler によって発見してください。

LOH は 85KB かららしいですね、この境界でのパフォーマンスグラフの違いを見るというのも練習問題としていいかもしれません。

 

なーんて、練習問題でお茶を濁す教科書風な終わり方をしてみます。Gen1 GCだけで3割性能が変わるわけで、Gen2 のFull GCとか気絶しちゃうぐらい怖い話なんでGCには優しく、女の子にも優しくしましょうね。