2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【C#あるある】C#にありがちなgetterの参照が渡されていない問題を考える【値クラス、AutoMapper】

Last updated at Posted at 2021-05-18

 AutoMapperとタイトルにあるので、ASP.NET MVCのことを最後に書きますが、
 途中までは、他のC#の開発言語でも対応可能です。
 意識すると、プロパティの設定漏れなどを防げると思います。

C#あるある

 外部のファイル、セッションなどを使うとき、getter, setterを次のように何か具体的な処理を入れて書くことがある。
 (割と誇張して書いてます)

public class MainClass {
    public PropertyClass SomeObject {
        get
        {
             return JsonSerializer<PropertyClass>.Deserialize(System.IO.File.ReadAllText(@"C:\Config.txt")) ;
        }

        set
        {
             System.IO.File.WriteAllText(@"C:\Config.txt", JsonSerializer<PropertyClass>.Serialize(value));
        }
    }
}

 このコードはプロパティの本来のgetter, setterの意味合いを崩しているので、実は良くないコード。
 というのは、例えばSomeObjectのクラスにAddTextというメソッドがあったとして


var obj = new MainClass();
obj.SomeObject.AddText("HogeHoge");

と書かれると、MainClassにはAddTextの結果が反映されないし、C:\Config.txtにも結果は反映されない。これを避けるには、

var obj = new MainClass();
var internal_obj = obj.SomeObject;
internal_obj.AddText("HogeHoge");

obj.SomeObject = internal_obj; 

という風に、再度代入する必要があるのだが、これは認識違いによるバグが起きやすいコードである。

・・・とまあ、ファイル処理一つくらいならば、これを回避する処理は何とでもなると思いますが、あるとき「データベースから参照したオブジェクトをgetter/setterでいじりたい」とか、「自分の持っているオブジェクトをList型で渡したい、setterも欲しい」とか、明確にgetter, setterが使えそうだけど内部参照で実装できないケースというのが出てきます。そういうコードを作ると、不具合が起きやすいんですね。

何が問題か

このコードの最大の問題点はですね

var obj = new MainClass();
PropertyClass internal_obj = obj.SomeObject;  /*   ここが問題!!!
                                        obj.SomeObjectとすることで、オブジェクトを渡しているので、
                                        そのオブジェクトが操作できるのではないか?と思ってしまう。*/

internal_obj.AddText("HogeHoge");

obj.SomeObject = internal_obj; 

一見、4行目が問題に見えるんですが、2行目が最大の問題です。

SomeObjectはプロパティとして「オブジェクト」として渡されるがために、オブジェクト内部の操作が許されるかのような表記をしてしまっているのです。だから、3行目でAddTextを入れたら、参照を通じて文字列を足してくれるはずだ、という考えになりがちなのです。

ちなみに、これはC++とかだと、関数にconstを適切に指定する、部分的にポインタを渡すなどである程度防げるのですが、C#ではプロパティとして渡すオブジェクトに規制がないので、つい楽をしてこの問題を起こしがちなコードを書きたくなります。

これを防ぐには、PropertyClassクラスの仕様を明確にすることですが、それが面倒なことも往々にあります。

この場合、一番、単純明快な方法は、PropertyClassをクラスとして定義するのではなく、構造体(値クラス)で定義するということになります。値クラスで定義すれば、

var obj = new MainClass();
PropertyStruct internal_obj = obj.SomeObject;  /* ここは構造体となるので、仕様上必ず参照は渡されない。  */

internal_obj.AddText("HogeHoge");  /* internal_objの値は変わるが、参照はstructでは渡されないので、
                                        この時点では明確にSomeObjectが変更されないことは明らか */


obj.SomeObject = internal_obj;    /* このように、入れなければ反映されない */

となるので、値クラスとすることで、そもそも代入しないといけないことを意識付けできます。値クラスであることを条件分岐などで代入を自動でやってくれるメソッドを作ることもそんなに難しくないでしょうしね。

値クラスで解決する

 例えば、次のような値クラスを考えましょう。

    public struct SampleStructDto
    {
        public ChildStruct1Dto Child1Dto { get; set; }

        public List<ChildStruct2Dto> Child2Dto { get; set; }


        public ChildStruct2Dto ChildXDto;
    }

    public struct ChildStruct1Dto
    {
        public string Text1
        {
            get;
            set;
        }

        public int Value1
        {
            get;
            set;
        }

        [JsonInclude]                        /* System.Text.Jsonではこの属性が必須 */
        public string Alert1;
    }

    public struct ChildStruct2Dto
    {
        public string Text2
        {
            get;
            set;
        }

        public int Value2
        {
            get;
            set;
        }
    }

値クラスSampleStructDtoを使おうとして、


var sample_dto = new SampleStructDto() {
    Child1Dto = new ChildStruct1Dto(), 
    Child2Dto = new List<ChildStruct2Dto>()
};

sample_dto.ChildXDto.Text2 = "AG";   /* OK */

sample_dto.Child1Dto.Text1 = "TEST";  /* error CS1612: 
                                        エラーメッセージ:Cannot modify the return value of 
                                       'SampleStructDto.Child1Dto' because it is not a variable */

sample_dto.Child1Dto.Alert1 = "Hello";  /* error CS1612 */

という書き方が出来ないのです。

したがって、値クラスの定義をすべてフィールドとして書く必要が出てきます。

ちなみに、これはこれで悪くないです。個人的には、Webを意識したプログラミングならこちらも使い勝手が良いと思います(Modelにべったりのコードを書かない限り)。

一方で、値クラスの一部のフィールドを使ってgetterやsetter依存のコードを書くことは別に禁止されていないので、もうちょっと工夫出来ないかと思うわけです。

AutoMapperを使う(ASP .NET MVC限定)

私はASP .NET MVCを使った経験は薄いのですが、それでもC#のコーディングにおいて、このAutoMapperを使った概念は今までのコーディングスタイルを思いっきり変えたような気がします。

そして、ここでは、AutoMapperを使って、値クラスと参照クラスを結合します。
Viewで値クラスにして、Controllerで変換して、Modelで参照クラスにすればよいのです。

Viewはクライアントから来た情報を加工することがないので、値クラスで管理できれば十分で、Modelは値から値の変更を使って加工する使い方を想定しているので、向いているのかな?と思います。

    public struct SampleStructDto
    {
        public ChildStruct1Dto Child1Dto { get; set; }

        public IEnumerable<ChildStruct2Dto> Child2Dto { get; set; }

        public SampleStructDto(SampleStructDto org)
        {
            Child1Dto = org.Child1Dto;
            Child2Dto = org.Child2Dto;
        }
    }

    public struct ChildStruct1Dto
    {
        public string Text1
        {
            get;
            set;
        }

        public int Value1
        {
            get;
            set;
        }

        public ChildStruct1Dto(ChildStruct1Dto source, ChildClass1Dto dto)
        {
            Text1 = dto.Text1;
            Value1 = source.Value1;
        }
    }

    public class ChildClass1Dto
    {
        public string Text1
        {
            get;
            set;
        }
    }

    public struct ChildStruct2Dto
    {
        public string Text2
        {
            get;
            set;
        }

        public int Value2
        {
            get;
            set;
        }

        public ChildStruct2Dto(ChildStruct2Dto source, ChildClass2Dto dto)
        {
            Text2 = dto.Text2;
            Value2 = dto.Value2;
        }
    }

    public class ChildClass2Dto
    {
        public string Text2
        {
            get;
            set;
        }

        public int Value2
        {
            get;
            set;
        }
    }

このコードの書き方は初めて見た人には違和感にしかなさそうですよねw ChildStruct1DtoとChildClass1Dtoとか、実質内容が変わらんものを入れているので、無駄のように感じます。しかし、注目すべき点があります。

値渡し(View)・参照渡し(Model)という役割分担

  • ChildClass1DtoのメンバがText1しかないのに、ChildStruct1Dtoのメンバは2つある。

 この場合の参照クラスって、一つのChildStruct1Dtoに対して、複数のChildClass1Dtoを定義できるんです。
 したがって、ControllerからModelに渡すときに、使うプロパティだけをピックアップして、Modelに渡すことが可能になります。
 
 AutoMapperは、独自クラスであるProfileをオーバーライドして、次のように定義します。

UserProfile.cs
    public class UserProfile : Profile
    {
        public UserProfile()
        {
            CreateMap<ChildStruct1Dto, ChildClass1Dto>();
            CreateMap<ChildClass1Dto, ChildStruct1Dto>();
            CreateMap<ChildStruct2Dto, ChildClass2Dto>();
            CreateMap<ChildClass2Dto, ChildStruct2Dto>();
        }
    }

 そして、StartUp.csの途中を次のように書きます。
 色々冗長な処理は入っていますが、デフォルトの.NET Core 5から変化しているところは、AutoMapperとSessionのみです。

Startup.cs
        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllersWithViews();

            services.AddDistributedMemoryCache();
            services.AddSession(options =>
            {
                options.Cookie.Name = ".Session";
            });

            var mapperConfig = new MapperConfiguration(mc =>
            {
                mc.AddProfile(new UserProfile());
            });

            IMapper mapper = mapperConfig.CreateMapper();     /*    AutoMapperに関する処理はここだけ         */
            services.AddSingleton(mapper);

            services.AddMvc();
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Home/Error");
                app.UseHsts();
            }
            app.UseHttpsRedirection();
            app.UseStaticFiles();

            app.UseSession();
            app.UseRouting();

            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllerRoute(
                    name: "default",
                    pattern: "{controller=Home}/{action=Index}/{id?}");
            });
        }

 つまり、UserProfileのおかげで、値クラスと参照クラス間で、同名のプロパティを勝手に変換してくれるわけです。 

参照渡し時のアクセス権

  • SampleStructDtoにコピーコンストラクタに近いものを入れることで、SampleStructDtoの微修正をクラスで定義できる。

 AutoMapperがあるので、参照クラスに関係ないフィールドのことを考える必要がなく、Viewに影響あるであろうパラメータだけの変更だけ考えたらよいです。

 使い方としては、

Controller側のコード
        var class_dto = _mapper.Map<ChildClass1Dto>(SampleObjectDto.Value.Child1Dto);   /* 参照クラスに変換する */

        class_dto.Text1 = "Hello World";       /* 参照クラスに対して変更を加える(Modelの仕事) */
                                               /*  でも、Model側ではText2を変えることが出来ない![ここ大事] */
        RecordNextMessage(class_dto);         /* こういう書き方をして、参照渡しのままmodelにも渡せる */

        SampleObjectDto = new SampleStructDto(SampleObjectDto.Value)
        {
             /* ChildStruct1Dtoの内容のまま、class_dtoの内容だけを書き換える */
             Child1Dto = new ChildStruct1Dto(SampleObjectDto.Value.Child1Dto, class_dto),
        };

 こんな感じですが、上の例では、値として変更される部分は、class_dtoだけを参照として渡せばよく、Controllerはその状況を感知しません。

LINQとの連携

 なお、ChildStruct2Dtoのように可変配列を使用する場合はこんな感じですかね・・・。
 この場合は、ChildStruct2Dtoの中身とChildClass2Dtoの中身が完全対応している必要がありますね。もう少し、改善の余地があるかと思います。

       var class_dto = SampleObjectDto.Value.Child2Dto.Select(c => _mapper.Map<ChildClass2Dto>(c)).ToList();

       class_dto.Add(new ChildClass2Dto()        /* こういう書き方をして、参照渡しのままmodelにも渡せる */
       {
             Text2 = "Text2",
             Value2 = 1
       });

       SampleObjectDto = new SampleStructDto(SampleObjectDto.Value)
       {
             Child2Dto = class_dto.Select(c => _mapper.Map<ChildStruct2Dto>(c))
       };

まとめ

 メリットをまとめるとこんな感じですかね。

値渡しの場合

  • C#を使用するすべての開発環境で対応できる

  • 値クラスとして定義した状態でデータを渡すので、値クラスの中身が書き換えられているかどうかを気にする必要がない

AutoMapperの場合

  • Modelで参照渡し、Viewで値渡しという定義が出来る。

  • 例えば値渡し側はIEnumerableにして、参照渡し側でListのようにするなど、Model側のみ柔軟に変更できるようなデータ構造に出来る

  • getter, setterを使うことが出来る(単にget; set;以外のものも可)

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?