如何运用领域驱动设计 - 领域服务

827 阅读17分钟

概述

本文将介绍领域驱动设计(DDD)战术模式中另一个非常重要的概念 - 领域服务。在前面两篇博文中,我们已经学习到了什么是值对象和实体,并且能够比较清晰的定位它们自身的行为。但是在某些时候,你会发现某一些业务行为好像不容易落到单个实体或者值对象身上,并且会为放置这一部分业务逻辑而困惑。此时,你可能需要一个领域服务来完成操作。

那么,到底什么是领域服务呢?怎么发现领域中的领域服务呢?领域服务和传统的应用服务又有什么区别呢?本文将从不同的角度来带大家重新认识一下“领域服务”这个概念,并且给出相应的代码片段(本教程的代码片段都使用的是C#,后期的实战项目也是基于 DotNet Core 平台)。

什么是领域服务

在开始之前,还是说一点题外话吧:如果大家读过这个系列的前几篇文章,可能都会发现该系列的风格都是从原著的解析开始,然后结合了自身的一些案例和实际场景来为大家解读领域驱动中的一些概念。我也不知道这样的写作方式能不能让大家更清楚的理解,所以如果大家有什么建议的话可以在评论区留言,我一定会认真的听取大家的意见和建议。

在文章中,我会尽可能避免各类名称的简写(比如事件溯源,有些同学喜欢简写为ES),虽然简写有时候确实会很方便,但是会让人与人之间的沟通成本无形的增大,所以在我的博文中只要能不用简写的地方我都不会使用简写。

另外还有一点就是,可能前期属于概念性的东西比较多,所以就没有现成的github代码供大家参考,不多大家不用担心,在完成这几次的概念学习之后我们就开始我们的code time(●'◡'●)。

回到正题吧,什么是领域服务呢?看看原著原著《领域驱动设计:软件核心复杂性应对之道》中所提及到的领域服务的概念:

在某些情况下,最清楚、最实用的设计会包含一些特殊的操作,这些操作从概念上讲不属于任何对象。与其把它们强制地归于哪一类,不如顺其自然地在模型中引入一种新的元素,这就是Service(服务)。 当领域中的某个要的过程或转换操作不属于实体或值对象的自然职责时,应该在模型中添加一个作为独立接口的操作,并将其声明为Service.定义接口时要使用模型语言,并确保操作名称是UBIQUITOUS LANGUAGE中的术语。此外,应该将Service定义为无状态的。

李姐万岁

额。。。。“李姐万岁”。这个概念不好理解的原因是因为:首先它假设我们寻找到了领域中一些“包含特殊的操作”,也就是说我们在此时已经具备了划分领域中各种对象以及其对应行为的能力,然后我们再来考虑提取出这个传说中的“service”(也就是我们本次的主题领域服务)。而往往现实则是,作为一个初学者,我们并不能合理的抽象出各个对象,并且也没有一个好的案例来进行体验性的思考。所以在读这个概念的时候就很迷惑,我们无法找到概念中的“这些操作”是什么东西,也就更不能理解这个Service是什么了。

“在自己的私人飞机里面玩儿电子游戏是什么感觉呢?   呃.....好像前提是我得有钱买一架飞机吧?”

从实际场景下手

我思考了很多种方法来表述“领域服务”,但是想了半天好像都不太容易能让人理解。所以该篇博文采用先从案例入手的思路,希望大家能从这个案例能够理解出领域服务的用处。

来回顾一下上一篇文章 《如何运用DDD - 实体》 中我们所提炼出来的一个实体对象:

public class Itinerary
{
    public int ID { get; set; }

    public List<Person> Participants { get; set; }

    public List<Address> Places { get; set; } 

    public ItineraryNote  Note { get; set; } 

    public ItineraryTime TripTime { get; set; }

    public ItineraryStatus Status { get; set; }

    //ctor

    public void ChangeNote(string content)
    {
        Note = new ItineraryNote(content);
    }
}

该实体对象表明了一次旅行的行程。目前作为示例,我们仅仅知道了在该领域中我们允许修改行程的备注信息,所以我们在上一篇文章中为它赋予了修改备注的一个行为。

根据项目的进展,我们现在捕获到了另一个需求:如果行程没有结束,用户访问到该行程,系统会根据用户目前所在的地点为用户推荐附近好吃的美食。

这是一个非常人性化以及好用的功能,也是该产品可以和其他同类型的产品系统竞争的优势。所以我们理应将它放置于领域来考虑。从该功能需求的描述来看,我们要做的是一个推荐美食的行为。但是让我们矛盾的是,推荐美食这一个动作,我们应该将它归属于谁呢? 给旅程?让旅程实体来推荐美食? 很显然,你并不会这么做。旅程仅仅关心的是本次旅行的基本信息,地点人物时间等,我们不会将推荐美食这一个动作给它,让它成为一个万能的机器。

来回顾一下上面所说的概念:“在某些情况下,最清楚、最实用的设计会包含一些特殊的操作,这些操作从概念上讲不属于任何对象。” 仔细读几遍,纳尼?这不是说的就是这个情况吗? 在现在这个情况下,我们出现了一个推荐美食的操作,但是它却不属于任何对象。

当走到这一步时,可能我们已经有一点理解领域服务了。接下来,继续往下走。现在,我们已经明白了,可能我们需要一个Service来处理这一个操作。尝试着来建立一个 RecommendFoodsService

public class RecommendFoodsService
{
    public List<RecommendFoodInfo> RecommendFoods(Itinerary currentItinerary)
    {
        //todo
    }
}

在该领域服务中,有一个RecommedFoods的方法,它通过获取到当前的旅程,返回一个推荐美食的列表。它内部的实现方法可能是这样的:(在这里我们假设ItineraryPlaces中的最后一个地点就是我们的当前地点,而且我们已经有一个叫做餐厅 Restaurant 的实体,该实体提供了有关餐馆的一系列信息和行为。当然,你可以自己尝试建立餐厅这样一个实体,以便加深对实体章节的印象)

public class RecommendFoodsService
{
    public List<FoodInfo> RecommendFoods(Itinerary currentItinerary)
    {
        var recommendFoods = new List<FoodInfo>();

        //Get Last Address
        int lastCountIndex = currentItinerary.Places.Count -1;
        var currentAddress = currentItinerary.Places[lastCountIndex];

        var nearbyRestaurants = Restaurants.Where(s=> s.Address.isNearby(currentAddress)).ToList();

        foreach(var restaurant in nearbyRestaurants)
        {
            var food = restaurant.GetRankNoOneFood();

            recommendFoods.Add(new FoodInfo(food,restaurant.Address));
        }

        return recommendFoods;
    }
}

OK,到目前我们已经完成了一个演示版本的领域服务,在该服务中,我们通过获取到当前的旅程的位置,根据该位置,从系统中存在的餐馆集合中找到了距离该位置最近的餐厅,然后再将这些餐厅中排名评价最好的一道菜推荐给用户。

来看看上面的行为中出现了哪些东西,首先是我们的行程,然后是餐馆。通过合理的处理这两个实体之间的关系,我们完成了我们的一系列操作,并且返回了一个美食信息的集合(在这里美食信息我们定义为了一个值对象)。要注意,虽然我们里面包含了几个实体和几个值对象,以及使用了他们之间的不同行为,但是从推荐美食这一个行为来看,他们其实是一个整体,是密不可分的处理逻辑(敲重点!!!)。

更贴近现实

上面的版本我们将他作为一个演示版本来定义,是因为在实际的情况中,我们往往是通过存储库(Repository,有关该内容的介绍会在后期文章中介绍)来获取到实体集合的信息的,就如同上面代码中的Restaurants。有可能更贴近于我们现实中的代码是类似于下面这样,不过我们现在可以不用考虑这种写法,因为里面涉及到了存储库(仓储 Repository) 和 聚合根(AggregateRoot) 的概念,而现在我们只需要理解好领域服务就好了。

 public List<FoodInfo> RecommendFoods(int currentItineraryID)
    {
        var recommendFoods = new List<FoodInfo>();

        //Get Last Address
        var currentItinerary = itineraryRepository.Get(currentItineraryID);
        int lastCountIndex = currentItinerary.Places.Count -1;
        var currentAddress = currentItinerary.Places[lastCountIndex];

        var nearbyRestaurants = restaurantRepository.GetNearbyRestaurant(currentAddress);

        foreach(var restaurant in nearbyRestaurants)
        {
            var food = restaurant.GetRankNoOneFood();

            recommendFoods.Add(new FoodInfo(food,restaurant.Address));
        }

        return recommendFoods;
    }

来吧,根据我们现在所理解和发现的内容,来看一下领域服务的一些特点:

  • 领域服务处理的是领域中的对象,比如实体、值对象等
  • 领域服务是负责对领域中一系列对象的编排处理
  • 当我们发现一个操作无法赋予一个实体或者值对象,且该操作又对业务流程很重要时,我们往往需要使用领域服务
  • 领域服务中的操作,从领域的角度来看,它是一个整体

如果你在进行下面的操作时,可能证明你需要一个领域服务:

  • 通过A和B,得到一个C。
  • A需要一个繁琐的内部策略才能得到一个结果B。

(ps: A,B,C指的是领域对象中的值对象或者实体)

领域服务VS应用服务

其实在使用领域驱动中,还有一个服务叫做应用服务,应用服务是划分在应用层的服务。而往往都是因为叫做服务,所以大家很难区分它与领域服务有什么区别,最终的结果就是要么造成应用服务很庞大(所有的逻辑编排都在该层处理了),要么就是应用服务很薄弱(就一句调用领域服务的代码)。无独有偶,当应用服务开始混乱时,领域服务也会变得混乱,因为原有领域服务的逻辑你可能给了应用服务,而应用服务的逻辑又给了领域服务。

在比较两者之前,来看一看传统领域驱动设计为大家提供的四层架构示意图:

DDD四层

从图中可以看到,应用层保持了对领域层的引用关系,也就是说在应用层中,可以访问到领域对象。所以让应用层也具备了编排领域对象的能力。这一点和我们的领域对象的特征相同了,所以在很多时候,大家对应用服务和领域服务的区分难度就加大了。

关于应用服务,因为在原著中我没有找到对应的关键语句,所以选取了网上的一些结论供大家参考:

应用服务是用来表达用例和用户故事(User Story)的主要手段。 应用层通过应用服务接口来暴露系统的全部功能。在应用服务的实现中,它负责编排和转发,它将要实现的功能委托给一个或多个领域对象来实现,它本身只负责处理业务用例的执行顺序以及结果的拼装.

从上面的结论中我们大概可以知道,应用服务是为了让应用能够运用并且支撑对外的用户能够访问领域对象和执行领域逻辑的一层。就好比在dotnetoore中,用户可以通过访问我们定义的controller来访问我们的业务对象,并且还可以通过controller暴露出来的接口来执行业务逻辑。

因此,我们可以将应用服务考虑为执行业务逻辑的一个中介(可能这样定义也不太好),它没有涉及到核心领域的任何逻辑过程,它只负责了一些的验证,构件的支持等(比如日志,性能监控等)。

扩展上面的需求

在上面识别领域服务中,我们已经捕获到了这样一个需求:“如果行程没有结束,用户访问到该行程,系统会根据用户目前所在的地点为用户推荐附近好吃的美食。” 后来需求又增加了一项:“我们可以用短信的方式将美食通知给客户。”

那么考虑这样一个需求,我们该把短信通知这一个功能实现放在哪儿呢?或者说将发短信这个行为操作放在哪儿呢?我们来考虑一下将他放置在领域服务中:

public class RecommendFoodsService
{
    public List<FoodInfo> RecommendFoods(Itinerary currentItinerary)
    {
        var recommendFoods = new List<FoodInfo>();

        //Get Last Address
        int lastCountIndex = currentItinerary.Places.Count -1;
        var currentAddress = currentItinerary.Places[lastCountIndex];

        var nearbyRestaurants = Restaurants.Where(s=> s.Address.isNearby(currentAddress)).ToList();

        foreach(var restaurant in nearbyRestaurants)
        {
            var food = restaurant.GetRankNoOneFood();

            recommendFoods.Add(new FoodInfo(food,restaurant.Address));
        }

        //在这里添加短信发送?
        SmsUtil.Send(currentItinerary.Participants,recommendFoods);

        return recommendFoods;
    }
}

我们在原有代码的基础上,添加了一行代码,为其实现短信通知功能,现在这样已经符合我们的需求了。但是!!!!将短信通知放置在这里好吗?为解开这个问题,我们需要考虑:“短信发送是我领域提炼出来的行为吗?”,“如果没有这个行为,对业务逻辑有什么影响?”

来想一想,发短信是领域提炼出来的吗? 我们一直都在关心有关旅程的问题,很显然旅程中的各种才是我们主要关心的对象。那么发短信就不是我们所提炼出来的东西,它只是需要我们附带的支持功能罢了。

那么如果没有这个行为,对业务逻辑有什么影响呢? 它会不会影响我完成美食推荐这个行为? 很显然,不会! 还记得我们在上文说的一个领域服务的特点吗:领域服务中的操作,从领域的角度来看,它是一个整体。 如果整体中的一部分丧失它就不能完成业务了。那么在现在这个推荐美食的业务中,如果把餐厅的一部分拿掉会是什么样子呢?OMG,这个服务已经废了,它失去了已有的功能。那如果把短信发送拿掉呢?好像没有一点点影响。

那么这个短信发送,到底放在哪儿呢? 应用服务!!!!!

public class ItineraryApplicationService 
{
    public string RecommendFoods(int currentItineraryID)
    {
        Logger.Log("执行推荐美食业务");

        var participants = itineraryRepository.Getparticipants(currentItineraryID);
        var foods = RecommendFoodsService.RecommendFoods(currentItineraryID);

        SmsUtil.Send(foods);

        return foods.toJson();
    }
}

我们在应用层定义了一个叫做ItineraryApplicationService的应用服务,它对外提供了一个RecommendFoods的接口,客户端(App,网页等)可以透过该API来完成推荐美食这一系列的操作。推荐美食的行为我们已经封装在了领域服务中,应用服务根本不需要知道内部的逻辑就可以完成操作,这也验证了我们上面说的一点:从领域的角度看,领域服务是一个整体

最常见的认证授权是领域服务吗

就一般的应用来说,认证授权是应用服务。为什么呢?因为它往往只是给你提供了维持系统允许的基础功能,而并非你领域执行的必须。也许,这还不好理解,那么我们就来尝试一下将它定义为领域服务来看一看。考虑改成那个发短信的例子,我们实现了一个错误版本的领域服务,那么现在我们把领域服务的发短信替换为身份验证代码,然后放置在方法块最前面。来吧?继续回答上面的问题,他们是一个整体吗?如果剥离了这个代码,对行为有什么影响? 慢慢的你就会将它从领域服务中拿出来。 但是假如你正在实现一个组织权限软件,它可能会被定义在领域之中。因为你的领域就是认证的一系列操作,你需要认真的去思考它,一旦失去了认证的代码可能你的应用就无法提供正常的功能。

使用领域服务

你己经和领域专家谈论过涉及多个实体的领域概念了,但你不确定哪个实体“拥有”行为。看起来该行为并不属于任何一个实体,但当你尝试将该行为强制适配到实体中的任何一个时,处理起来就会有点棘手了。这一思维模式就是需要领域服务的强烈迹象。[嘘,这句话是我copy的。(^__^) ]

不要过多的使用领域服务

是不是只有领域服务才能调度值对象和实体等领域对象呢? 当然不是,应用服务也可以。 这也是一个大家常见的问题:将所有实体、值对象、仓储都通过领域服务来编排完成业务逻辑。从而使得应用服务层非常的薄,往往只有一行调用领域服务的代码(日志,性能等代码通过一些现有框架自动完成)。

尝试将部分调度权限分配给应用服务,它不会影响你的领域代码可读性,反而会使得阅读更加清晰。当你发现你的逻辑编排只是调用实体或值对象之间的行为,而没有构成一个完整的领域业务行为的时候(比如有一个Api表示了获取一次旅行地点距离的功能,你可以不用将该功能考虑为领域服务,在应用服务中通过传入的ID,在仓储中获取本次旅行的行程地址,然后交给系统中的距离转换功能计算出距离,然后返回给客户端),请考虑将它设置为应用服务。

不要将过多的行为都给了领域服务

为什么会这样说呢?如果你发现在你建立的领域模型中,实体和值对象的行为只是零星一点,而实体和值对象实现行为操作的动作都是通过领域服务来完成的。那么,你也许用错了领域服务,去重新认识你所识别出的实体和值对象,为它们赋予他们自身的行为,删除这些错误的领域服务。

总结

本次我们介绍了领域驱动设计战术模式中的领域服务。同时也对比了领域服务和应用服务,该部分内容可能介绍的还不是太完整,希望大家能从例子中理解两者之间的差异,后期如果有时间的话会为大家写一篇博文专门来区别领域服务和应用服务。在讲解的过程中,我们还涉及到了一切战术模式中的其他概念,比如Repository和AggregateRoot,这两个概念将在后期的文章中为大家带来介绍。