回归本源,仔细聊一聊DI(依赖注入)、IoC、DIP和相关内容

前言:

已经很久没有在CSDN写过文章了,现在打算回到最开始的地方,整理这些年的工作所得,并勇敢的迈出学习成长最勇敢的一步:在分享自我中勇于暴露自己的错误,被指出然后在讨论中获得进步。所有接下来写的博客,都是一些较大的话题,浅显的、深入的方面都会涉及。

概念篇:什么是DI,什么是IoC

先来谈一谈什么是IoC:

控制反转(Inversion of Control,缩写为IoC),是面向对象编程中的一种设计原则,可以用来减低计算机代码之间的耦合度


1996年,Michael Mattson在一篇有关探讨面向对象框架的文章中,首先提出了IoC 这个概念。 这个概念看起来苍白,还有更加形象一点的、相关的概念名词:“依赖倒置原则”、“依赖抽象原则”等。注:依赖抽象是对于”依赖倒置“的实际操作方式;依赖倒置原则(Dependence Inversion Principle)

我们说,控制反转是一种设计原则,可以用来降低系统中存在的耦合,那么我们用一个很直白的反例来理解它:

注意:如无必要,后续代码均为kotlin

//手抓饼
class ShouZhuaBing

class Milk

class Hamburger

class Noodles
//各种食物类

class Tom {
    val shouZhuaBing: ShouZhuaBing = ShouZhuaBing()
    val milk: Milk = Milk()
    val hamburger: Hamburger = Hamburger()
    val noodles: Noodles = Noodles()
    //各种食物


    fun onBreakfast() {
        eatShouZhuaBing()
        //eatMilk
    }

    fun onLunch() {
        //eatHamburger
    }

    fun onSupper() {
        //eatNoodles
    }
    
    private fun eatShouZhuaBing() {
        print("eatShouZhuaBing")
        //shouZhuaBing.foo()
    }

//    "下面的各种eatXXX不写了,简直想打人"
}

这是我写出来的耦合度最高的代码了,简直想打人,Tom也想打人:他必须定义好他这辈子可能吃到的所有的食物、他这辈子每天的食谱!那么是什么使我们如此暴躁,是Tom和各种食物之间的很强的耦合,Tom一直具有着控制权。那么要改进设计就是将Tom的控制权移交出去,这就是所谓的控制反转。

那么如何去做呢?最关键的就是先实现”依赖抽象“。Tom只要知道那是食物,到点吃就完事了,至于这些食物是Tom之前想好的还是他妈妈决定的,这都不是Tom张嘴吃东西所关心的事情。

 

我们看一下改进后的例子:

注:限于篇幅,我不展示每一步重构,直接展示一个相对完善的阶段性成果


abstract class Food {
    abstract fun beEaten(eater: Eater)
}

class FoodBundle(val foods:List<Food>) : Food() {
    
    override fun beEaten(eater: Eater) {
        //while(eater还想吃&&没有吃完) {
        // 弄点东西继续给他执行吃的动作
        // 
        // }
    }
}

abstract class Eater {
    fun eat(food: Food) {
        food.beEaten(this)
    }

    //咀嚼
    fun chew(food: Food) {
        //咀嚼有更多的细节,用哪些牙,咀嚼到什么程度,这里不继续完善,每进一步完善,都
        //包含着定义相关抽象类和反转控制的部分
    }

    //吞咽
    fun gulp(food: Food) {
        //
    }
}

//手抓饼
class ShouZhuaBing : Food() {
    override fun beEaten(eater: Eater) {
        eater.chew(this)
        eater.gulp(this)
    }
}

class Milk : Food() {
    override fun beEaten(eater: Eater) {
        eater.gulp(this)
    }
}

class Hamburger : Food() {
    override fun beEaten(eater: Eater) {
        eater.chew(this)
        eater.gulp(this)
    }
}

class Noodles : Food() {
    override fun beEaten(eater: Eater) {
        eater.chew(this)
        eater.gulp(this)
    }
}

enum class Type {
    Breakfast,Lunch,Supper,SnackTime
}

class Tom :Eater(){

    fun onBreakfast() {
        getFood(Type.Breakfast)?.beEaten(this)
    }

    fun onLunch() {
        getFood(Type.Lunch)?.beEaten(this)
    }

    fun onSupper() {
        getFood(Type.Supper)?.beEaten(this)
    }
    
    private fun getFood(type: Type): Food? {
        //例如从包里拿出汉堡、去超时买牛奶、从厨房端出面。当然你也有搞不到食物的时候
        return null
    }
}

我们发现,Tom和具体食物之间没有直接依赖了(耦合降低),想要在系统中添加更多的食物,添加更多的eater就会变得更加简单(系统的扩展性和可维护性提升

到这里,我们应该可以体会,IoC在面向对象编程中,是一种怎么的思想。

什么是DI?

相比于IoC,一种编程思想或者说指导性原则,DI讲的是一个更加具体的东西:依赖注入。

这里话题扯开一下,在oop(面向对象编程,后续将直接使用缩写)中,我们为了让程序的扩展性更好,有单一职能、依赖优于继承等原则,带来的结果就是:多个相关职能的类的实例结合起来才能完成复杂任务。而这些类实例之间就存在着依赖关系(我要完成一件事情时,需要另外一个类的功能),而如何维护这些依赖(使用别的类功能)就是DI要处理的事情。

IOC或者DIP原则指导我们:通过面向抽象去编程,将控制权移交给依赖者、即抽象类的具体子实现类。这也意味着:获得依赖对象的过程被反转了!

    在上面的例子中,最开始Tom需要控制自己吃什么,具体吃什么的时候怎么吃,这样编码是很复杂的,修改过后,Tom作为Eater对外的可观测方法是咀嚼、吞咽等动作,选择食物的控制权(这部分我们Demo中没有写,比进食过程要复杂的多,限于篇幅自动略去)移交了出去、如何吃一个具体食物的控制权移交到具体食物中。

那么此时还有一个问题需要处理:将抽象类的具体子实现类的实例注入目标中,即如何获得依赖对象。

DI的具体方式

我们会了解到这样一种说法,包括我之前的一些文章中也提到过:

  • 构造器注入
  • API注入、接口注入
  • 注解注入

这是从表象观察上得出的结论,因为比较简单,不做示例代码。

而从本质上看,所有的注入形式本质上只有两种:

  • 分析了具体业务,注入的规则(选择具体的依赖对象)直接侵入,在编码中进行描述,包含在类的某一段业务逻辑的编写中。
  • 注入过程抽象给第三方,使用者维护依赖规则。

我们重点讨论下这一块儿。

从事Android的朋友们应该都接触过google出品的MVP分层架构设计的Demo,为了演示的更加清楚一些,我按照记忆中google sample的设计,移除掉jetpack的内容,现写了一个简单的例子:

基类

interface IBaseView {
    //ignore 一些常用的API,例如toast,loading等
}

interface IBasePresenter<T : IBaseView>

abstract class BasePresenter<T : IBaseView>(val view: T) {
    //生命周期相关的控制,略去
}

abstract class BaseFragment<V : IBaseView, P : IBasePresenter<V>> : Fragment(), IBaseView {
    protected lateinit var presenter: P

    open fun bindPresenter(presenter: P) {
        this.presenter = presenter
    }
    
    //生命周期相关控制略去
}

数据类和model部分

data class Author(val id: Int, val name: String)
data class Essay(val id: Int, val title: String, val content: String, val author: Author)

interface EssayRepo {
    fun getEssayListByAuthorId(id: Int, from: Int, limit: Int): List<Essay>
    fun getEssayDetail(essayId: Int): Essay
    //...
}

一个文章详情展示页面

注:我们仅用来做Demo说明问题,实际生产中绑定presenter的时机一般会更复杂一些。

interface EssayDetailContract {
    interface View : IBaseView {
        fun displayEssay(essay: Essay)
    }

    interface Presenter : IBasePresenter<View> {
        fun loadEssayDetail(essayId: Int)
    }
}

class EssayDetailFragment : BaseFragment<EssayDetailContract.View, IBasePresenter<EssayDetailContract.View>>(), EssayDetailContract.View {
    override fun displayEssay(essay: Essay) {
        //ignore
    }

}

class EssayPresenterImpl(val view: EssayDetailContract.View, val repo: EssayRepo) : EssayDetailContract.Presenter {
    override fun loadEssayDetail(essayId: Int) {
        //ignore detail
        //1.工作线程处理获取数据 repo.getEssayDetail(essayId)
        //2.主线程回调view#dislpayEssay(essay)
    }
}

class EssayRepoDao : EssayRepo {
    override fun getEssayListByAuthorId(id: Int, from: Int, limit: Int): List<Essay> {
        TODO("curd") //To change body of created functions use File | Settings | File Templates.
    }

    override fun getEssayDetail(essayId: Int): Essay {
        TODO("curd") //To change body of created functions use File | Settings | File Templates.
    }
}

class EssayRepoMemoryCache : EssayRepo {
    override fun getEssayListByAuthorId(id: Int, from: Int, limit: Int): List<Essay> {
        TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
    }

    override fun getEssayDetail(essayId: Int): Essay {
        TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
    }

}

class EssayDetailActivity : Activity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        EssayDetailFragment().apply {
            bindPresenter(EssayPresenterImpl(this, EssayRepoDao()))
        }.run {
            //display the Fragment via FragmentManager
        }
    }
}

我们可以看到,用Fragment来实现了View,定义了类EssayPresenterImpl实现了Presenter接口。这两者是如何实现依赖的注入的呢?

就是最终在Activity中的这一段业务代码:

EssayDetailFragment().apply {
    bindPresenter(EssayPresenterImpl(this, EssayRepoDao()))
}

回到前面:

分析了具体业务,注入的规则(选择具体的依赖对象)直接侵入,在编码中进行描述,包含在类的某一段业务逻辑的编写中

我们是分析了Activity呈现一个视图的流程、以及潜在的更加复杂的业务,在Activity的onCreate阶段,选择了具体的类进行实例化,并完成了相关的依赖注入。

当然,具有实际android开发经验的朋友们可能注意到实际生产中,一个Presenter使用到不同Fragment子类的场景还可能存在(小概率)例如Essay的阅读模式和编辑模式;但是这种场景确实很少。即使出现了一些需求变化,我们只需要在Activity相应的代码片段做出调整即可,改动量是有限的、可接受的。

ok,继续从刚才的例子看,本身这个设计已经足够优秀了,根据日常实际得出了一个结论:这样的方案已经满足日常开发。让我们把注意力放到EssayRepo的注入。

细心的朋友们一定注意到了,我上面定义了两个EssayRepo的实现类。假定我们的故事是这样的:请原谅我黑了一波产品经理和差劲的沟通氛围


  • 一开始产品经理拍板,这个文档编辑应用就是单机的(不要问为什么,就是没多过脑子)

于是我们哼哧哼哧的完成了一个版本,数据都是存在本地数据库的。我们为各种XXXPresenter注入了各种XXXXDao的实例,当然,有些可能是单例。

  • 项目上线后,用户对于创作交互好评如潮,但是提出反馈:没法分享和阅读他人开放的文章。产品经理一拍脑门,我怎么没想到,2.0:存储全部做服务端(不要问为什么之前做单机,就是没过脑子)

于是服务端的开始哼哧哼哧的搞。客户端开始哼哧哼哧的把之前所有注入XXXXDao的地方改成XXXXWebService,并且把之前自动存储的功能转嫁到WebService,不再存储本地DB。

  • 项目上线后,IT和服务端主程开始天天盯着服务器监测,随后开始喷产品经理:”说了不信,所有功能都依靠服务器那是作死,QPS太高了“(不要问为什么产品经理之前不听,听了他也不懂,不见棺材不掉泪)

于是客户端的兄弟们又开始哼哧哼哧的修改,将注入的XXXXWebService修改成一种RepoImpl,它在内部封装了自动存储到DB,完成后提交到服务端,按照特定算法决定差异版本中实际有效的版本,并做本地和服务端同步。

  • 终于项目又上线了,IT和后端主程可以睡安稳觉了。细心的用户又提出反馈了,我对文档编辑了,提交了之后、之前打开的详情页啊列表啊啥的信息都不同步啊,写文章的时候都挺费脑子的,一不注意就以为自己漏编辑了,又去操作一遍。产品经理又拍板了:应用内数据一致性维护

于是客户端程序员赶鸭子上架,EventBus满天飞,最终赶上了工期,但几个版本后,项目已经快无法维护了,尤其是大量直接操作DB使得体验变的越来越糟糕。

  • 于是公司高薪聘请客户端架构师,试图最后锤死挣扎。

架构师放大招了,提出了本地中央仓储层,在内存对象层面实现多页面观测数据的一致性+基于数据变化的相应式设计,取代之前难以管理的EventBus事件体系、以及从DB读取数据同步页面。然后又带着大家做了一遍依赖的注入。并在总结会上提出了一个问题:”这几波依赖注入修改的爽吗?“


ok,我们虚构的故事结束了,请原谅我将故事中众多人的智商设计的很低,毕竟,设计高了就没这些事了?。

从这个故事中,我们得出结论:我们把自己忽悠了,这些侵入的代码是没有经过抽象的,他们面临修改时,会给我们制造一些麻烦,这些麻烦和这些代码的场景数成正比,正是我们的一些经验让我们没有正视这一问题,当问题没有爆发时(几率太小)他一点问题都没有,一旦爆发,对于项目的维护和稳定迭代都是巨大的挑战。

那么如何解决这些问题呢(如果有必要的话)?

对于注入过程做抽象,用一个第三方,它抽象并实现了依赖的注入、接受使用者对依赖规则的维护,将整个系统中做依赖注入的内容剥离。

两种方式的区别

这里有必要直接给出结论了,假定这个系统是个10万行代码的项目,有100行代码是这样处理依赖注入的。

按照第一种侵入式做法:我们对100行夹杂在业务代码中的内容进行了修改,第一步先从10万行代码中找到并修改,至少需要对这100处代码做单测,甚至需要对10万行代码的项目做完整的集成测试。

按照第二中方式做,可能需要编写300-500行代码实现这100行的内容,但是很集中,只需要在这500行代码中做修改,只需要对这500行代码做单测即可判断依赖注入正确与否。

聊一聊IoC和DI的发展史

  • 1996年,Michael Mattson在一篇有关探讨面向对象框架的文章中,首先提出了IOC 的概念
  • 2004年,Martin Fowler探讨了同一个问题,提出了IoC的实现细节,Martin Fowler的经典文章:Inversion of Control Containers and the Dependency Injection pattern》
  • spring在实践中推广,2004年3月,Spring 1.0使用外部配置文件(xml)描述对象之间的依赖关系。
  • 2004年10月,JDK1.5支持注解(Annotation)语法。2007年3月, Google发布 Guice 1.0,使用annotation描述依赖关系。
  • 2007年11月,Spring 2.5支持使用annotation描述依赖关系
  • 为了规范和统一,JCP于2009年10月发布了JSR330
  • 2012年square发布了开源的dagger1,用于Android平台后来google接盘,搞了dagger2.
  • 等等

深入了解一个东西,你才能用好它

区别于网上直接讲一个DI框架(例如dagger2)如何用,我们还是先来了解了解JSR-330,这样能够帮助我们更好的使用工具,毕竟这些工具是面向JSR-330写的。

其中定义了一些注解:

  • @Inject
  • @Qualifer, @Named
  • @Scope, @Singleton

Inject :Identifies injectable constructors, methods, and fields. 需要注入的标识,可用于构造器(constructors), 方法(methods)或字段(fields)

Qualifier:Identifies qualifier annotations. 限定器,多子类情况

Named:String-based qualifier. 使用字符串的限定符,

Scope:Identifies scope annotations. 标记作用域

Singleton:Identifies a type that the injector only instantiates once.

定义了一个接口:

public interface Provider<T> {

    /**
     * Provides a fully-constructed and injected instance of {@code T}.
     *
     * @throws RuntimeException if the injector encounters an error while
     *  providing an instance. For example, if an injectable member on
     *  {@code T} throws an exception, the injector may wrap the exception
     *  and throw it to the caller of {@code get()}. Callers should not try
     *  to handle such exceptions as the behavior may vary across injector
     *  implementations and even different configurations of the same injector.
     */
    T get();
}

我们前面提到过,jsr-330是jcp为了规范各种提供给java生态中使用的ioc容器而定义的协议标准,在其他生态中,是不完全一致的,但是思想是一致的.

前面说到,获取依赖对象的方式交给第三方管理,这个第三方就是IOC容器,它可以是dagger,可以是spring,可以是kodein或者其他,他的作用就是管理依赖规则以及按照依赖规则提供依赖注入。

前面提到:@Inject向IOC容器表达了此处有依赖需要注入,而Provider接口在IOC容器中被实现(或者被注册)以提供可能需要的依赖’对象实例‘;

而限定器(@Qualifer, @Named)在存在必要时,配合@Inject用于向容器说明需要”特定“的依赖、配合Provider用于向容器说明提供”特定“的实例。

而@Scope(用于自定义注解)、@Singleton(通过Scope注解的一个特定注解)是用来标记依赖对象的”生命周期“的控制的,它用于指导ioc容器如何管理这些依赖实例,是每次都新建实例(默认的)还是按照规则复用实例。

 

后记

这篇文章写得内容很长,但精髓的内容并不是很多,主旨在于:(1)把复杂的概念通过最直白的语言讲透彻;(2)举出例子引发思考以达到和”被动接受的概念、结论“相互印证;

本来还打算解析一下dagger2以及在kotlin中挺好用的kodein(本身是支持多平台的,不是面向JSR-330),限于篇幅决定拆分出去,我们下篇文章见。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值