月曜日, 5月 26, 2025
ホームニューステックニュース2025 業務アプリ向け WinForms 入力チェックはデータモデルに任せよう #C#

2025 業務アプリ向け WinForms 入力チェックはデータモデルに任せよう #C#



2025 業務アプリ向け WinForms 入力チェックはデータモデルに任せよう #C#

データアノテーションに連動させて、テキストボックスの入力チェックコードをもう一度書くことをやめる話

注意:本記事での用語

本記事での表現 他の呼び方
データモデル 業務モデル/モデルクラス
データアノテーション モデルに定義されたバリデーション属性(DataAnnotations属性)
Model.cs

public class 顧客Model
{
    // --- 以下の2行がデータアノテーション -----------------------------
    [Required(ErrorMessage = "氏名漢字は必須です")]    
    [MaxLength(10)]
    // --------------------------------------------------------------
    public string 氏名漢字 { get; set; } = string.Empty;

1.はじめに

WinForms で業務アプリを開発していると毎回やってくる「入力チェック」処理。
せっかくデータモデルに記述したデータアノテーションによる Required(必須)、MaxLength(最大長)、書式チェックなど。

チェック処理をModelとForm側の2カ所に書くのはもうやめませんか?

モデルの要件に連動させたカスタムコントロールでバリデーションを実現しました。


2.やりたいこと

以下のような要件を、カスタムTextBoxコントロール側に実装します:

✅ データチェック要件

1) モデルのデータアノテーションを読み取って自動バリデーション

image.png

Model.cs

[Range(0, 999999999999, ErrorMessage = "0〜999999999999 の範囲で入力してください。")]
public decimal 年収 { get; set; }
Form.cs

NVTextBox年収.BindValidationToModelProperty(typeof(顧客Model), nameof(顧客Model.年収));

2) 追加で個別のチェック関数(Validator)も設定できる

データアノテーションには書き切れないチェック処理は、個別に追加可能

Form.cs

VTextBox氏名カナ.InputValidator = s =>
{
    foreach (char c in s)
    {
        if (!((c >= '\u30A0' && c  '\u30FF') || c == '\u3000'))
            return (false, "全角カタカナまたは全角スペースのみを入力してください。");
    }
    return (true, string.Empty);
};

✅ 業務アプリで TextBox へ良く求められる要件も併せて実装

1) Enterで次の入力欄へ移動(ビープ音なし)

2) フォーカスを受けたことを強調(外枠が青く太くなる)

image.png

3) フォーカスを外すと外枠がグレーに戻る

image.png

4) エラーなら外枠が赤くなる(エラープロバイダーの❌マークも自動表示)

image.png

5) エラープロバイダー❌をマウスオーバーでエラーメッセージを表示

image.png

3.前提条件

  • Visual studio 2022 Version 17.14.1
  • .Net 9

なお、すべてのソースコードを公開しています。

4.実装ポイント

  • バリデーションは「モデルと連携」+「個別Validator」

image.png

Model.cs

public class 顧客Model
{
    [Required(ErrorMessage = "氏名漢字は必須です")]
    [MaxLength(10)]
    public string 氏名漢字 { get; set; } = string.Empty;

    [Required(ErrorMessage = "氏名カナは必須です")]
    [MaxLength(10)]
    public string 氏名カナ { get; set; } = string.Empty;

    [Required(ErrorMessage = "メールアドレスは必須です")]
    [EmailAddress(ErrorMessage = "メールアドレスの形式が正しくありません")]
    [MaxLength(100)]
    public string メールアドレス { get; set; } = string.Empty;

    [Required(ErrorMessage = "生年月日は必須です")]
    [Range(typeof(DateTime), "1900-01-01", "2025-12-31", ErrorMessage = "生年月日は1900年~2025年の間で指定してください")]
    public DateTime 生年月日 { get; set; }

    [Range(0, 999999999999, ErrorMessage = "0〜999999999999 の範囲で入力してください。")]
    public decimal 年収 { get; set; }

}

Form側のコードを見ると、ほとんどモデルのアノテーションだけでチェック処理が終わっていることがわかります

Form.cs

private void SetValidators()
{
    // 氏名(漢字)
    VTextBox氏名漢字.ErrorProvider = this.ErrorProvider;
    VTextBox氏名漢字.BindValidationToModelProperty(typeof(顧客Model), nameof(顧客Model.氏名漢字));

    // 氏名(カナ)
    VTextBox氏名カナ.ErrorProvider = this.ErrorProvider;
    VTextBox氏名カナ.BindValidationToModelProperty(typeof(顧客Model), nameof(顧客Model.氏名カナ));

    VTextBox氏名カナ.InputValidator = s =>
    {
        foreach (char c in s)
        {
            if (!((c >= '\u30A0' && c  '\u30FF') || c == '\u3000'))
                return (false, "全角カタカナまたは全角スペースのみを入力してください。");
        }
        return (true, string.Empty);
    };

    // メールアドレス
    VTextBoxメールアドレス.ErrorProvider = this.ErrorProvider;
    VTextBoxメールアドレス.BindValidationToModelProperty(typeof(顧客Model), nameof(顧客Model.メールアドレス));

    // 生年月日
    VDateTimePicker生年月日.ErrorProvider = this.ErrorProvider;
    VDateTimePicker生年月日.Format = DateTimePickerFormat.Custom;
    VDateTimePicker生年月日.CustomFormat = "yyyy/MM/dd";
    VDateTimePicker生年月日.BindValidationToModelProperty(typeof(顧客Model), nameof(顧客Model.生年月日));

    // 年収
    NVTextBox年収.ErrorProvider = this.ErrorProvider;
    NVTextBox年収.BindValidationToModelProperty(typeof(顧客Model), nameof(顧客Model.年収));
    NVTextBox年収.NumberFormat = "#,##0";
}

氏名カナの「全角カナチェック」のみ追加実装してます

1) 必須入力チェック

データアノテーションの Required が自動で反映されます。

[Required(ErrorMessage = "氏名漢字は必須です")]

image.png

2) 文字列長

データアノテーションの MaxLength が自動で反映されます。

image.png

MaxLength を自動的に反映し、指定の文字列長までしか入力できません

3) 値の範囲

[Range(typeof(DateTime), "1900-01-01", "2025-12-31", ErrorMessage = "生年月日は1900年~2025年の間で指定してください")]

image.png

5.TextBox以外のコントロール

ついでに、数値用の NumericValidatingTextBox
日付用の ValidatingDateTimePicker も作りました。

1) 数値入力に対応:NumericValidatingTextBox

  • 最小値/最大値
  • 負の数許可/ゼロ許可
  • 小数点以下の桁数制限
  • 書式指定(例:”#,##0″)

2) 日付入力に対応:ValidatingDateTimePicker

  • DateTimePicker を拡張し、RequiredやRangeをモデルから読み取る
  • 枠線制御や Enter移動も統一
    ※ 全選択は不安定なのでオミット(理由もコード内に記載)

6.最後に

業務アプリの現場で、
2回同じことを書くことで発生する 非同期、メンテナンス漏れを 防ぎたくて
このようなコントロールにしてみました。
「各画面で毎回同じようなバリデーションコードを書く」のをやめて
モデルへ任せる設計にすると、コードも綺麗で再利用性も高まりますよね✨

7.参考

GitHubへソースは全てアップしてますが参考までに。

using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Reflection;

namespace CustomTextbox.Contols
{
    /// 
    /// 基本仕様:
    /// 1.フォーカスを受けたときボーダーラインを強調し青い太枠へ変える
    /// 2.フォーカスを失った時にボーダーラインを元に戻す
    /// 3.フォーカスを受けたとき、文字が入力されていたら全選択する
    /// 4.エンターキーで次のタブインデックスへ遷移する
    /// 5.Modelのプロパティのデータアノテーションから、チェック内容を受け取れる。
    /// 6.そのテキストボックスの入力値のチェックメソッドを受け取れる。また、文字列の最大長をプロパティに持つ。
    /// 7.チェックメソッドや文字列長に違反した場合、フォーカスを失った時にボーダーラインを太い赤色にする
    /// 
    public class ValidatingTextBox : TextBox
    {
        [Category("データ")]
        [Description("必須項目はTrueにしてください")]
        [DefaultValue(false)]
        public bool Required { get; set; } = false;
        
        [Category("外観")]
        [Description("外枠の通常色です")]
        [DefaultValue(typeof(Color), "Gray")]
        public Color 通常色 { get; set; } = Color.Gray;

        [Category("外観")]
        [Description("フォーカスを受けた時の外枠の色です")]
        [DefaultValue(typeof(Color), "DeepSkyBlue")]
        public Color フォーカス色 { get; set; } = Color.DeepSkyBlue;

        [Category("外観")]
        [Description("エラー時の外枠の色です")]
        [DefaultValue(typeof(Color), "Red")]
        public Color エラー色 { get; set; } = Color.Red;

        [Browsable(false)]
        [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
        public ErrorProvider? ErrorProvider { get; set; }

        //バインドするModleのプロパティから自動生成するValidator
        private Funcstring, (bool isValid, string errorMessage)>? _modelValidator;
        // 個別に追加指定するValidator
        private Funcstring, (bool isValid, string errorMessage)>? _customValidator;
        [Browsable(false)]
        [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
        public Funcstring, (bool isValid, string errorMessage)>? InputValidator
        {
            get => _combinedValidator;
            set
            {
                _customValidator = value;
                UpdateCombinedValidator();
            }
        }

        // モデルからのValidatorと、個別追加Validator 2つを合成した最終的なValidator
        private Funcstring, (bool isValid, string errorMessage)>? _combinedValidator;
        private void UpdateCombinedValidator()
        {
            _combinedValidator = CombineValidators();
        }


        [Browsable(false)]
        [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
        public bool IsValid => _isValid;

        internal bool _hasFocus = false;
        private bool _isValid = true;
        private RequiredAttribute? _requiredAttr;


        public ValidatingTextBox()
        {
            BorderStyle = BorderStyle.FixedSingle;
        }

        protected override void OnEnter(EventArgs e)
        {
            _hasFocus = true;
            Invalidate();

            if (!string.IsNullOrEmpty(Text))
            {
                // 3.フォーカスを受けたとき、文字が入力されていたら全選択する
                SelectAll(); // 全選択
            }

            base.OnEnter(e);
        }

        protected override void OnLeave(EventArgs e)
        {
            _hasFocus = false;
            _isValid = ValidateInput();
            Invalidate();
            base.OnLeave(e);
        }

        protected override void OnKeyDown(KeyEventArgs e)
        {
            if (e.KeyCode == Keys.Enter)
            {
                // 4.エンターキーで次のタブインデックスへ遷移する
                Parent?.SelectNextControl(this, true, true, true, true);
                e.Handled = true;
                e.SuppressKeyPress = true; // ← ★これでビープ音防止!
            }

            base.OnKeyDown(e);
        }

        // フォーカスあり/なし/エラー状態で外枠の色を変える
        protected override void WndProc(ref Message m)
        {
            base.WndProc(ref m);

            const int WM_PAINT = 0x000F;
            if (m.Msg == WM_PAINT)
            {
                using Graphics g = CreateGraphics();
                // 1.フォーカスを受けたときボーダーラインを強調し青い太枠へ変える
                Color borderColor = 通常色;

                // 2.フォーカスを失った時にボーダーラインを元に戻す
                if (_hasFocus)
                    borderColor = フォーカス色;
                // 7.チェックメソッドや文字列長に違反した場合、フォーカスを失った時にボーダーラインを太い赤色にする
                else if (!_isValid)
                    borderColor = エラー色;

                using Pen pen = new Pen(borderColor, borderColor != 通常色 ? 2 : 1);
                Rectangle rect = new Rectangle(1, 1, this.Width - 3, this.Height - 3);
                g.DrawRectangle(pen, rect);
            }
        }

        // 5.Modelのプロパティのデータアノテーションから、チェック内容を受け取れる。
        public void BindValidationToModelProperty(Type modelType, string propertyName)
        {
            var prop = modelType.GetProperty(propertyName);
            if (prop == null) return;

            // Requiredの有無
            _requiredAttr = prop.GetCustomAttributeRequiredAttribute>();
            this.Required = _requiredAttr != null;


            // 最大長
            var maxLengthAttr = prop.GetCustomAttributeMaxLengthAttribute>();
            if (maxLengthAttr != null)
            {
                MaxLength = maxLengthAttr.Length;
            }

            // その他のValidation
            _modelValidator = s =>
            {
                // 空文字は Validator に流さず、Required で判定
                if (string.IsNullOrWhiteSpace(s)) return (true, string.Empty);

                // 型変換チェック
                var prop = modelType.GetProperty(propertyName);
                if (prop == null) return (true, string.Empty);

                var targetType = prop.PropertyType;

                // TryParse 相当の安全な型変換
                object? typedValue;
                try
                {
                    typedValue = TypeDescriptor.GetConverter(targetType).ConvertFromString(s);
                }
                catch (Exception ex)
                {
                    return (false, $"形式が正しくありません: {ex.Message}");
                }


                var dummyInstance = Activator.CreateInstance(modelType) ?? 
                    throw new InvalidOperationException("モデルのインスタンス生成に失敗しました");
                var validationContext = new ValidationContext(dummyInstance) 
                { 
                    MemberName = propertyName 
                };

                var results = new ListValidationResult>();
                bool isValid = System.ComponentModel.DataAnnotations.Validator.TryValidateProperty(typedValue, validationContext, results);

                if (isValid)
                    return (true, string.Empty);
                else
                    return (false, results[0].ErrorMessage ?? "無効な入力です");
            };
        }

        // モデルからの属性チェックと個別チェックを組み合わせる
        // (両方のチェックを有効にする)
        private Funcstring, (bool isValid, string errorMessage)>? CombineValidators()
        {
            if (_customValidator == null && _modelValidator == null)
                return null;

            return s =>
            {
                if (_modelValidator != null)
                {
                    var result = _modelValidator(s);
                    if (!result.isValid) return result;
                }

                if (_customValidator != null)
                {
                    var result = _customValidator(s);
                    if (!result.isValid) return result;
                }

                return (true, string.Empty);
            };
        }

        // Validation実行
        private bool ValidateInput()
        {
            // 6.そのテキストボックスの入力値のチェックメソッドを受け取れる。また、文字列の最大長をプロパティに持つ。
            var errorMessage = string.Empty;

            // 検証ロジック
            if (Required && string.IsNullOrWhiteSpace(Text))
            {
                errorMessage = _requiredAttr?.ErrorMessage ?? "必須入力です。";
                ErrorProvider?.SetError(this, errorMessage);
                return false;
            }

            if (InputValidator != null)
            {
                var result = InputValidator(Text);
                if (!result.isValid)
                {
                    errorMessage = result.errorMessage;
                    ErrorProvider?.SetError(this, errorMessage);
                    return false;
                }
            }

            ErrorProvider?.SetError(this, string.Empty);
            return true;
        }
    }
}





Source link

Views: 2

RELATED ARTICLES

返事を書く

あなたのコメントを入力してください。
ここにあなたの名前を入力してください

- Advertisment -

インモビ転職