2013年11月27日水曜日

DataAnnotationsModelValidatorProviderとマルチテナント

1年に1回!
そんな感じですけど、元気にやってます。

マルチテナント花盛りですが、マルチテナントといっても
  • シングルソースシングルデプロイ
    DBが共有か個別かは、別パラメータで調整するのでもいいし。でも拡張は絶望。だってAppDomainにテナント固有コードいれるとかちょっとなんか本末転倒。
  • シングルソースマルチデプロイ
    この場合デプロイのソース割合としては、カスタム2~3%で別アセンブリになってて、97~98%は共通コア。割合は適当だけど、そのくらいなら全然管理できるし落としどころとしてはいいんじゃないかと思う。
  • マルチソースマルチデプロイ
    これが一番厳しいけどとりあえず動かす、っていうのなら、この辺。っていうか運用がマルチテナントなだけでシステムはほぼ個別。コードも10%以上がカスタム。つか、もう、ソースをコピーしちゃってぜ、っていうパターン。
などなど。まぁ、良い悪いも視点しだいなんだけど、デベロップメント視点でいうと、マルチソースは避けたいところですね。

つい先日もStackoverflow.comでおなじみのJeff Atwoodさん話の流れで出てきてましたが、

「コードベースは簡単に再利用できるという発言はよく聞くけど、大きなサイトのコードが本当に再利用できるのは稀。同じコードベースでサイトを3つ運営できるようになったら、本当に再利用できるコードと言える。」

予告ホームランを打とう - ワザノバ | wazanova


最近、必須項目を落としたいっていう"冗談は顔だけにしろよ"っていうオーダーが入ってですね、Validationルールどうしようか、天を仰いだんすよ。もちろんモデルは共通だしー。カスタムモデルも使えるようにはしてるけど、ちょっとそういうのなしね、っていうモデル。DBにべったりなモデル。

で、まぁ、すったもんだですけど、テナントごとに調整できるようにしたのが、↓こんな感じのカスタムModelValidatorProvider。

    public class VoidDataAnnotationsModelValidatorProvider : DataAnnotationsModelValidatorProvider
    {
        private static readonly Dictionary> _disableAttributes = new Dictionary>();

        public static string KeyFormat = "{0}.{1}";

        private static string GenerateRuleKeyName(Expression> _expression)
        {
            return string.Format(KeyFormat, typeof(T).FullName, ExpressionHelper.GetExpressionText(_expression));
        }

        public static void AddRule(string key, Type attributeType)
        {
            if (!_disableAttributes.ContainsKey(key))
                _disableAttributes.Add(key, new List());
         
            _disableAttributes[key].Add(attributeType);
        }

        public static void AddRule(Expression> _expression, Type attributeType)
        {
            var key = GenerateRuleKeyName(_expression);

            AddRule(key, attributeType);
        }

        public static void RemoveRule(string key)
        {
            _disableAttributes.Remove(key);
        }

        public static void RemoveRule(Expression> _expression)
        {
            var key = GenerateRuleKeyName(_expression);

            RemoveRule(key);
        }

        public static void ClearRules()
        {
            _disableAttributes.Clear();
        }

        protected override IEnumerable GetValidators(ModelMetadata metadata, ControllerContext context, IEnumerable attributes)
        {
            var exceptAttrs = attributes;
            if (metadata.ContainerType != null && !string.IsNullOrWhiteSpace(metadata.PropertyName))
            {
                var disableKey = string.Format(KeyFormat, metadata.ContainerType.FullName, metadata.PropertyName);
                if (_disableAttributes.ContainsKey(disableKey))
                {
                    exceptAttrs = attributes.Where(attr => !_disableAttributes[disableKey].Contains(attr.GetType()));
                }
            }
            
            var validators = base.GetValidators(metadata, context, exceptAttrs);
            return validators;
        }
    }

太字のところ。GetValidatorsで対象の属性を除外してModelValidatorを抽出。普通にAttributeじゃなくてAdapterで丸めてるので、単純比較はできないんだけど、attributesから除外しちゃえばModelValidatorも除外されるから、あんまり気にしなくてもよろしですね。

これを使うようにGlobal.asaxに登録。登録方法はApp_Codeに配置するなり(マルチデプロイの場合は)、なんだもいいんだけど。

        protected void Application_Start()
        {
            AreaRegistration.RegisterAllAreas();
            FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
            RouteConfig.RegisterRoutes(RouteTable.Routes);
            BundleConfig.RegisterBundles(BundleTable.Bundles);

            // Nullableじゃない基本型の場合にRequiredを付与しないならfalseにしましょう。
            DataAnnotationsModelValidatorProvider.AddImplicitRequiredAttributeForValueTypes = false;

            VoidDataAnnotationsModelValidatorProvider.AddRule("WebApplication2.Models.RegisterViewModel.UserName", typeof(RequiredAttribute));
            VoidDataAnnotationsModelValidatorProvider.AddRule((o) => o.Password, typeof(StringLengthAttribute));
            VoidDataAnnotationsModelValidatorProvider.AddRule((o) => o.ConfirmPassword, typeof(System.ComponentModel.DataAnnotations.CompareAttribute));

            ModelValidatorProviders.Providers.RemoveAt(0);
            ModelValidatorProviders.Providers.Insert(0, new VoidDataAnnotationsModelValidatorProvider());
        }

こんなのを書いて、標準のプロバイダーを差し替えましょう。
差し替える上に、モデルクラスと無効にしたい属性を指定しておきましょう。
AddRuleで除外したいものを登録するように書いたけど、そこはExpression使えたほうがなんかかっこいいから、ちょっとそんな雰囲気も出してみたよ。

そうすると、この場合カスタムコードはGlobal.asaxのみ。デプロイアセンブリは共通がいいなら、App_Codeにコードファイルをコピーでデプロイ。どっちでも同じですね。

これをVS2013でMVCプロジェクトを新規にテンプレートから作成したものに張り付けて動かすと、ユーザー登録のところで、ユーザ名必須が外れて、パスワード長の制限が外れて、パスワード確認用の一致チェックも外れる。けど、登録自体には失敗するからね。それはバリデーションの位置が違うので今回はほっときます。


わかりにくいけど↑こんな動き。

マルチテナントって楽しいね!