注意
この記事は「C#がこうだったらいいのに」というC#ポエムです
C#のクラスの継承にはDIとのミスマッチがある
public class BaseController: Controller {
protected ILogger _logger {get;}
protected ILoginUser _loginUser {get;}
public BaseController( ILogger logger, ILoginUser loginUser )
{
_logger = logger;
_loginUser = loginUser;
}
}
public class MyController : BaseController {
private IMyRepository _myrepo {get;}
// ここにBaseControllerの引数をもう一度書かねばならない
public MyController( IMyRepository myrepo, ILogger logger, ILoginUser loginUser )
:base(logger, loginUser)
{
_myrepo = myrepo;
}
}
上記のようなケースにおいて、BaseControllerに新しい依存性を注入しようとすると、BaseControllerを継承しているMyControllerのコンストラクタも併せて修正しなくてはならなくなる。
「あるクラスのコンストラクタに他のクラスが影響されないようにする」がDIの目的だったはずなのに、継承を利用していると、それを回避することができない。
依存性集約による解決策
解決策の一つは、依存性集約を定義することだ。
// BaseControllerの依存性集約を定義したクラス
public class BaseControllerDependencies {
public ILogger logger {get;}
public ILoginUser loginUser {get;}
public BaseControllerDependencies(
ILogger logger,
ILoginUser loginUser
)
{
this.logger = logger;
this.loginUser = loginUser;
}
}
public class BaseController: Controller {
protected ILogger _logger {get;}
protected ILoginUser _loginUser {get;}
public BaseController( BaseControllerDependencies dependencies )
{
_logger = dependencies.logger;
_loginUser = dependencies.loginUser;
}
}
public class MyController : BaseController {
private IMyRepository _myrepo {get;}
public MyController( IMyRepository myrepo, BaseControllerDependencies base_ependencies )
:base(base_ependencies)
{
_myrepo = myrepo;
}
}
こうしておけば、BaseControllerへの新しい依存性の追加は、MyControllerへ影響を与えない。
とはいえ、継承元のクラス設計時に意識してこれをやっておかないと意味がない。
「継承ではなくコンポジションにしておけば問題ない」という話もあるだろうが、全ての継承関係をコンポジションに変更するわけにもいかないだろう。
ここからがポエム
恐らくこの問題は、C#の言語仕様自体を拡張するしか解決の道がないように思う。
public class BaseController: Controller {
protected ILogger _logger {get;}
protected ILoginUser _loginUser {get;}
public BaseController( ILogger logger, ILoginUser loginUser )
{
_logger = logger;
_loginUser = loginUser;
}
}
public class MyController : BaseController {
private IMyRepository _myrepo {get;}
public MyController( IMyRepository myrepo ) : base // base() ではなく、()を付けない base
{
_myrepo = myrepo;
}
}
子クラスのコンストラクタに括弧無しで :baseとつける。わかりにくければ :inherits でもよいかもしれない。
そうすると、baseクラスのコンストラクタ引数が自動的にMyControllerの引数の末尾に追加される(という妄想)。
よって、上記のMyControllerの定義は、シンタックスシュガーによって以下の定義と同等になっている(という妄想)。
public class MyController : BaseController {
private IMyRepository _myrepo {get;}
public MyController( IMyRepository myrepo, ILogger __base_param1, ILoginUser __base_param2 )
: base ( __base_param1, __base_param2 )
{
_myrepo = myrepo;
}
}
もちろん、__base_param1 や __base_param2 には MyControllerからその名前ではアクセスできないが、代わりに、次のbase.parametersが使える(という妄想)。
public class MyController : BaseController {
private IMyRepository _myrepo {get;}
public MyController( IMyRepository myrepo ) : base
{
_myrepo = myrepo;
var loginUser = base.parameters.logger; // baseコンストラクタの引数名でアクセス可能
}
}
base.parameters.loggerはbaseコンストラクタの仕様に依存するコードになるわけだが、そもそも「baseコンストラクタの引数にアクセスしたい」という要望自体がそこに依存しようとしているわけなので、その前提ならば問題はないはずだ。
こんな風な構文が次のC#のでサポートされれば、よりDIが使いやすいものになる気がする。
ただ、そうなるよりも、ASP.NET Coreのベストプラクティスとして「ベースコントローラのようなものを定義しないこと。コントローラ共通の機能を作りたい場合は、Filterを活用するなどコンポジションによる共有を検討すること」みたいな指針が定義されて終わり、のような気もする・・・けど、それだと、もう継承ってライブラリ内部でしか使っちゃいけないみたいな機能になってしまいそうな(実際すでにそうなっているのかも)。
以上、本日のC#ポエムでした。
よろしければ皆さんのご意見もコメント欄に書き散らかしてください。
海外で紹介されていた解決方法
海外で、次のような方法が紹介されていた。純粋なDIパターンからは外れる為、美しさはないが、問題を解決するベターな手法だと思う。
考え方としては、ベースコントローラーについてはDIオブジェクトをコンストラクタ・インジェクションにしないようにする。
例えば次のようにプロパティ+サービスロケーターパターンを使う。
public class BaseController<T>: ControllerBase {
private ILogger _logger;
private ILoginUser _loginUser;
protected ILogger<T> Logger => _logger ??= HttpContext.RequestServices.GetService<ILogger<T>>();
protected ILoginUser LoginUser => _loginUser ??= HttpContext.RequestServices.GetService<ILoginUser>();
}
public class MyController : BaseController<MyController> {
private IMyRepository _myrepo {get;}
public MyController( IMyRepository myrepo )
{
_myrepo = myrepo;
}
}
こうすることで、派生先でベースコントローラの注入オブジェクトを持ちまわる必要はなくなる。
Loggerプロパティ、LoginUserプロパティは、初回参照時にHttpContext.RequestServicesから取得してprivateフィールドに保持される為、使われない場合は余計な負荷がかからないのも良いところだろう。
但し、これによって派生先の全てのクラスは「DIコンテナ」という仕組みに依存することになる。
とはいえ、ASP.NET Coreのコントローラクラスのように、そもそもフレームワークにべったりの場合にはこれで良いのだろうと思う。
注意点として、コンストラクタ内ではまだHttpContextが存在しない為、LoggerプロパティなどにアクセスしようとするとNullReferenceExceptionが発生する。
どうしても必要な場合には直接DIしたりなどが必要だろうが、そもそもコンストラクタ内で、失敗する可能性があるような込み入った処理をするべきではない。
コントローラアクション内で事前に行っておきたい処理があるのであれば、コンストラクタではなくOnActionExecutingメソッドをオーバーライドして対処した方が良い。