0%

Docker-创建和分享应用(3) - 头痛不头痛 - 博客园

Excerpt

Dockerfile定义容器内环境中发生的事情。对网络接口和磁盘驱动器等资源的访问在此环境中进行虚拟化,该环境与系统的其他部分隔离,因此您需要将端口映射到外部世界,并具体说明要“复制”哪些文件到该环境。但是,在执行此操作之后,您可以预期Dockerfile在此处定义的应用程序的构建 在其运


  Dockerfile定义容器内环境中发生的事情。对网络接口和磁盘驱动器等资源的访问在此环境中进行虚拟化,该环境与系统的其他部分隔离,因此您需要将端口映射到外部世界,并具体说明要“复制”哪些文件到该环境。但是,在执行此操作之后,您可以预期Dockerfile在此处定义的应用程序的构建 在其运行的任何位置都完全相同。

1. 快速测试Docker环境是否可用#

2. 创建Dockerfile文件#

   在一个空目录中创建一个名为Dockerfile的文件,内容如下:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

FROM python

WORKDIR /app

COPY . /app

RUN pip install --trusted-host pypi.python.org -r requirements.txt

EXPOSE 80

ENV NAME World

CMD ["python", "app.py"]

3. 在该目录下继续创建一个Flask应用程序和安装文件清单#

app.py#

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

from flask import Flask

from redis import Redis, RedisError

import os

import socket

redis = Redis(host="redis", db=0, socket_connect_timeout=2, socket_timeout=2)

app = Flask(__name__)

@app.route("/")

def hello():

    try:

        visits = redis.incr("counter")

    except RedisError:

        visits = "<i>cannot connect to Redis, counter disabled</i>"

    html = "<h3>Hello {name}!</h3>" \

           "<b>Hostname:</b> {hostname}<br/>" \

           "<b>Visits:</b> {visits}"

    return html.format(name=os.getenv("NAME", "world"), hostname=socket.gethostname(), visits=visits)

if __name__ == "__main__":

    app.run(host='0.0.0.0', port=80) 

requirements.txt#

4. 构建应用程序#

我们准备构建应用程序。确保您仍处于新目录的顶层。这是ls应该显示的内容:

1

2

$ ls

Dockerfile      app.py          requirements.txt

创建一个Docker镜像,我们将使用该--tag或者-t选项命名镜像

1

docker build --tag=friendlyhello .

查看创建的镜像

1

2

3

root@node1 docker]

REPOSITORY                           TAG                 IMAGE ID            CREATED             SIZE

friendlyhello                        latest              67bd580f29b8        21 seconds ago      937MB

Linux用户的故障排除
  • 代理服务器设置

  代理服务器可以在Web应用程序启动并运行后阻止其连接。如果您位于代理服务器后面,请使用以下ENV命令将以下行添加到Dockerfile中,以指定代理服务器的主机和端口:

1

2

3

ENV http_proxy host:port

ENV https_proxy host:port

  • DNS设置

  DNS配置错误可能会产生问题pip。您需要设置自己的DNS服务器地址才能pip正常工作。您可能想要更改Docker守护程序的DNS设置。您可以/etc/docker/daemon.json使用dns密钥编辑(或创建)配置文件,如下所示:

1

2

3

{

  "dns": ["your_dns_address", "8.8.8.8"]

}

  在上面的示例中,列表的第一个元素是DNS服务器的地址。第二项是Google的DNS,可在第一项无法使用时使用。

  在继续之前,请保存daemon.json并重新启动docker服务。

sudo service docker restart

修复后,重试运行该build命令。

5. 运行应用程序#

运行应用程序,使用以下方法将计算机的端口4000映射到容器的已发布端口80,-p是将容器的端口Publish到外部的意思。

1

docker run -p 4000:80 friendlyhello

 在浏览器访问‘http://localhost:4000’即可访问到内部的应用程序,按Ctrl+c即可结束应用程序。

后台以分离模式运行应用程序,-d就是在后台运行镜像,并打印出容器ID

1

docker run -d -p 4000:80 friendlyhello

6. 停止应用程序所在容器的运行#

7. 分享镜像#

如果没有Docker帐户,请在hub.docker.com上注册一个帐户 ,并记下的用户名。

1. 在本地的计算机上登录

2. 将镜像打上标签,该命令的语法是:

1

docker tag image username/repository:tag

  比如我的登录名为scottcho,将版本库命名为flask,tag为v1

1

2

3

4

5

[root@node1 docker]

[root@node1 docker]

REPOSITORY   TAG IMAGE ID     CREATED    SIZE

scottcho/flask v1 67bd580f29b8 30 minutes ago 937MB

3. 发布镜像

1

docker push scottcho/flask:v1

 在docker hup上可以看见所创建的镜像

4. 在任何计算机上可以运行分享的镜像

如果映像在本地不可用,则Docker会从存储库中提取映像。

1

docker run -p 4000:80 scottcho/flask:v1

查看容器内的改变信息

创建一个容器,会在容器的对应的镜像上增加一个可写层,镜像部分是只读的。通过 diff命令可以看出改变的信息。如:

复制代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[root@wls12c ~]$ docker run -i -t centos /bin/<span>bash
[root@224de7986c5f </span>/]# <span>touch</span><span> demo.ext
[root@224de7986c5f </span>/]# <span>echo</span> hello docker &gt;<span>demo.ext
[root@224de7986c5f </span>/]# <span>rm</span> -rf anaconda-<span>post.log
[root@224de7986c5f </span>/<span>]# exit
exit
[root@wls12c </span>~]$ docker <span>ps</span> -<span>l
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
224de7986c5f centos </span><span>"</span><span>/bin/bash</span><span>"</span> <span>40</span> seconds ago Exited (<span>0</span>) <span>3</span><span> seconds ago desperate_curie
<span>[root@wls12c </span></span><span>~]$ docker diff</span><span><span> 224d</span>
D </span>/anaconda-<span>post.log
A </span>/<span>demo.ext
C </span>/<span>root
A </span>/root/.bash_history

复制代码

说明:每行代表一个变动的文件或目录。其中 A 表示新增、C表示被修改、D表示被删除

主机和容器之间的文件拷贝

容器—>主机

复制代码

1
2
3
4
5
6
7
8
[root@wls12c ~]$ docker exec  -t -i 9f bin/<span>bash
[root@9f49397623ad </span>/]# <span>cat</span><span> demo.txt
hello
[root@9f49397623ad </span>/<span>]# exit
exit<br>
<span>[root@wls12c </span></span><span>~]$ docker cp 9f:/demo.txt /</span><span><span>test</span>
<span>[root@wls12c </span></span><span>~]$ cat /test/demo.txt
hello</span>

复制代码

主机–>容器

复制代码

1
2
3
4
5
<span>[root@wls12c ~]$  cp ~/1.txt  /var/lib/docker/aufs/mnt/9f49397623ade7dfd2beb4d84454cbdb9878a4b22a2bab2e8b5db72bcffe60a0/</span><span><span>test
</span>
[root@wls12c </span>~]$ docker exec -t -i 9f /bin/<span>bash
[root@9f49397623ad </span>/]# <span>ls</span> /<span>test
</span><span>1</span>.txt

复制代码

重命名容器

1
[root@wls12c ~]$ docker rename stoic_meitner demo

正文

前言:最近,讨论到数据库安全的问题,于是就引出了WebApi服务没有加任何验证的问题。也就是说,任何人只要知道了接口的url,都能够模拟http请求去访问我们的服务接口,从而去增删改查数据库,这后果想想都恐怖。经过一番折腾,总算是加上了接口的身份认证,在此记录下,也给需要做身份认证的园友们提供参考。

WebApi系列文章

在前言里面,我们说了,如果没有启用身份认证,那么任何匿名用户只要知道了我们服务的url,就能随意访问我们的服务接口,从而访问或修改数据库。

1、我们不加身份认证,匿名用户可以直接通过url随意访问接口:

可以看到,匿名用户直接通过url就能访问我们的数据接口,最终会发生什么事,大家可以随意畅想。

2、增加了身份认证之后,只有带了我们访问票据的请求才能访问我们的接口。

例如我们直接通过url访问,会返回401

 

 如果是正常流程的请求,带了票据,就OK了。

可以看到,正常流程的请求,会在请求报文的头里面增加Authorization这一项,它的值就是我们的Ticket票据信息。

1、常见的认证方式

我们知道,asp.net的认证机制有很多种。对于WebApi也不例外,常见的认证方式有

  • FORM身份验证
  • 集成WINDOWS验证
  • Basic基础认证
  • Digest摘要认证

园子里很多关于WebApi认证的文章,各种认证方式都会涉及到,但感觉都不够细。这里也并不想去研究哪种验证方式适用哪种使用场景,因为博主还是觉得“贪多嚼不烂”,也可能是博主能力所限。对于认证机制,弄懂其中一种,其他的都能融会贯通。此篇就使用Basic基础认证来详细讲解下整个的过程。

2、Basic基础认证原理

 我们知道,认证的目的在于安全,那么如何能保证安全呢?常用的手段自然是加密。Basic认证也不例外,主要原理就是加密用户信息,生成票据,每次请求的时候将票据带过来验证。这样说可能有点抽象,我们详细分解每个步骤:

  1. 首先登陆的时候验证用户名、密码,如果登陆成功,则将用户名、密码按照一定的规则生成加密的票据信息Ticket,将票据信息返回到前端。
  2. 如果登陆成功,前端会收到票据信息,然后跳转到主界面,并且将票据信息也带到主界面的ActionResult里面(例如跳转的url可以这样写:/Home/Index?Ticket=Ticket)
  3. 在主界面的ActionResult里面通过参数得到票据信息Ticket,然后将Ticket信息保存到ViewBag里面传到前端。
  4. 在主界面的前端,发送Ajax请求的时候将票据信息加入到请求的Head里面,将票据信息随着请求一起发送到服务端去。
  5. 在WebApi服务里面定义一个类,继承AuthorizeAttribute类,然后重写父类的OnAuthorization方法,在OnAuthorization方法里面取到当前http请求的Head,从Head里面取到我们前端传过来的票据信息。解密票据信息,从解密的信息里面得到用户名和密码,然后验证用户名和密码是否正确。如果正确,表示验证通过,否则返回未验证的请求401。

 这个基本的原理。下面就按照这个原理来看看每一步的代码如何实现。

首先说下我们的示例场景,上次介绍 CORS 的时候我们在一个解决方案里面放了两个项目Web和WebApiCORS,我们这次还是以这个为例来说明。

1、登录过程

1.1、Web前端

复制代码

<body>
<div style=“text-align:center;”>
<div>用户名:<input type=“text” id=“txt_username” /></div>
<div>密 码:<input type=“password” id=“txt_password” /></div>
<div><input type=“button” value=“登录” id=“btn_login” class=“btn-default” /></div>
</div>
</body>

复制代码

复制代码

$(function () {
$(“#btn_login”).click(function () {
$.ajax({
type: “get”,
url: “http://localhost:27221/api/User/Login“,
data: { strUser: $(“#txt_username”).val(), strPwd: $(“#txt_password”).val() },
success: function (data, status) { if (status == “success”) { if (!data.bRes){
alert(“登录失败”); return;
}
alert(“登录成功”);
            //登录成功之后将用户名和用户票据带到主界面
window.location = “/Home/Index?UserName=” + data.UserName + “&Ticket=” + data.Ticket; }
},
error: function (e) {
},
complete: function () {

        }

    });
});

});

复制代码

1.2、登录的API接口

复制代码

  public class UserController : ApiController
{ ///


/// 用户登录 ///

///
///
///
[HttpGet] public object Login(string strUser, string strPwd)
{ if (!ValidateUser(strUser, strPwd))
{ return new { bRes = false };
}
FormsAuthenticationTicket ticket = new FormsAuthenticationTicket(0, strUser, DateTime.Now,
DateTime.Now.AddHours(1), true, string.Format(“{0}&{1}”, strUser, strPwd),
FormsAuthentication.FormsCookiePath); //返回登录结果、用户信息、用户验证票据信息
var oUser = new UserInfo { bRes = true, UserName = strUser, Password = strPwd, Ticket = FormsAuthentication.Encrypt(ticket) }; //将身份信息保存在session中,验证当前请求是否是有效请求
HttpContext.Current.Session[strUser] = oUser; return oUser;
} //校验用户名密码(正式环境中应该是数据库校验)
private bool ValidateUser(string strUser, string strPwd)
{ if (strUser == “admin” && strPwd == “123456”)
{ return true;
} else { return false;
}
}
} public class UserInfo
{ public bool bRes { get; set; } public string UserName { get; set; } public string Password { get; set; } public string Ticket { get; set; }
}

复制代码

这里有一点需要注意的是,因为WebApi默认是没有开启Session的,所以需要我们作一下配置,手动去启用session。如何开启WebApi里面的Session,请参考:http://www.cnblogs.com/tinya/p/4563641.html

正如上面的原理部分说的,登录如果失败,则直接返回;如果成功,则将生成的票据Ticket带到前端,传到主界面/Home/Index,下面,我们就来看看主界面Home/Index。

2、/Home/Index主界面

复制代码

  public class HomeController : Controller
{ // GET: Home
public ActionResult Index(string UserName, string Ticket)
{
ViewBag.UserName = UserName;
ViewBag.Ticket = Ticket; return View();
}
}

复制代码

复制代码

<html>
<head>
<meta name=“viewport” content=“width=device-width” />
<title>Index</title>
<script src=“/Content/jquery-1.9.1.js”></script>
<link href=“
/Content/bootstrap/css/bootstrap.css” rel=“stylesheet” />
<script src=“/Content/bootstrap/js/bootstrap.js”></script>
<script src=“
/Scripts/Home/Index.js”></script>
<script type=“text/javascript”>
//打开页面的时候保存票据信息
var UserName = ‘@ViewBag.UserName’; var Ticket = ‘@ViewBag.Ticket’; </script>
</head>
<body>
<div>当前登录用户:‘@ViewBag.UserName’</div>

<div id\="div\_test"\>

</div\>

</body>
</html>

复制代码

复制代码

$(function () {
$.ajax({
type: “get”,
url: “http://localhost:27221/api/Charging/GetAllChargingData“,
data: {},
beforeSend: function (XHR) { //发送ajax请求之前向http的head里面加入验证信息
XHR.setRequestHeader(‘Authorization’, ‘BasicAuth ‘ + Ticket);
},
success: function (data, status) { if (status == “success”) {
$(“#div_test”).html(data);
}
},
error: function (e) {
$(“#div_test”).html(“Error”);
},
complete: function () {

    }

});

});

复制代码

这里需要说明的是,我们在发送ajax请求之前,通过 XHR.setRequestHeader(‘Authorization’, ‘BasicAuth ‘ + Ticket); 这一句向请求的报文头里面增加票据信息。就是因为这里加了这一句,所以才有我们下图中的红线部分:

3、WebApiCORS验证部分(重点)

我们看到,上面的/Home/Index页面里面发送了ajax请求去访问服务的 http://localhost:27221/api/Charging/GetAllChargingData 这个接口,那么我们在WebApi里面怎么去验证这个请求和合法的请求呢?接下来我们重点看看验证的这个过程。

3.1、在WebApiCORS项目里面自定义一个类RequestAuthorizeAttribute,去继承我们的AuthorizeAttribute这个类。然后重写OnAuthorization方法,在这个方法里面取到请求头的Ticket信息,然后校验用户名密码是否合理。

复制代码

///


/// 自定义此特性用于接口的身份验证 ///

public class RequestAuthorizeAttribute : AuthorizeAttribute
{ //重写基类的验证方式,加入我们自定义的Ticket验证
public override void OnAuthorization(System.Web.Http.Controllers.HttpActionContext actionContext)
{ //从http请求的头里面获取身份验证信息,验证是否是请求发起方的ticket
var authorization = actionContext.Request.Headers.Authorization; if ((authorization != null) && (authorization.Parameter != null))
{ //解密用户ticket,并校验用户名密码是否匹配
var encryptTicket = authorization.Parameter; if (ValidateTicket(encryptTicket))
{ base.IsAuthorized(actionContext);
} else {
HandleUnauthorizedRequest(actionContext);
}
} //如果取不到身份验证信息,并且不允许匿名访问,则返回未验证401
else { var attributes = actionContext.ActionDescriptor.GetCustomAttributes().OfType(); bool isAnonymous = attributes.Any(a => a is AllowAnonymousAttribute); if (isAnonymous) base.OnAuthorization(actionContext); else HandleUnauthorizedRequest(actionContext);
}
} //校验用户名密码(正式环境中应该是数据库校验)
private bool ValidateTicket(string encryptTicket)
{ //解密Ticket
var strTicket = FormsAuthentication.Decrypt(encryptTicket).UserData; //从Ticket里面获取用户名和密码
var index = strTicket.IndexOf(“&”); string strUser = strTicket.Substring(0, index); string strPwd = strTicket.Substring(index + 1); if (strUser == “admin” && strPwd == “123456”)
{ return true;
} else { return false;
}
}
}

复制代码

3.2、在具体的Api接口增加我们上面自定义类的特性

复制代码

[RequestAuthorize] public class ChargingController : ApiController
{ ///


/// 得到所有数据 ///

/// 返回数据
[HttpGet] public string GetAllChargingData()
{ return “Success”;
} ///
/// 得到当前Id的所有数据 ///

/// 参数Id
/// 返回数据
[HttpGet] public string GetAllChargingData(string id)
{ return “ChargingData” + id;
}

}

复制代码

增加了特性标注之后,每次请求这个API里面的接口之前,程序会先进入到我们override过的 OnAuthorization() 方法里面,验证通过之后,才会进到相应的方法里面去执行,否则返回401。

 通过上面的几步,基本就能达到我们想要的身份认证的效果,但是总是感觉不太方便,主要不太方便的点有以下几个。

  1. 每次新建一个API,对应的接口上面都要标注 [RequestAuthorize] 这个一个东西,感觉好麻烦。
  2. 每次发送ajax请求,都要在beforeSend事件里面加 XHR.setRequestHeader(‘Authorization’, ‘BasicAuth ‘ + Ticket); 这个,感觉也麻烦。
  3. 如果有些WebApi服务的某些方法,我们不想使用这个验证,让它可以匿名用户验证(比如我们的登录方法Login)。该怎么处理呢。

关于以上两点,我们优化下

1、解决API的问题

在API里面加一个公共的父类,在父类上面标注 [RequestAuthorize] 即可。

复制代码

namespace WebApiCORS.Controllers
{
[RequestAuthorize]
[EnableCors(origins: “*“, headers: “*“, methods: “*“)] public class BaseApiController : ApiController
{
}
}

复制代码

复制代码

namespace WebApiCORS.Controllers
{ public class ChargingController : BaseApiController
{ ///


/// 得到所有数据 ///

/// 返回数据
[HttpGet] public string GetAllChargingData()
{ return “Success”;
} ///
/// 得到当前Id的所有数据 ///

/// 参数Id
/// 返回数据
[HttpGet] public string GetAllChargingData(string id)
{ return “ChargingData” + id;
}
  }
}

复制代码

 注意:我们登录的请求是不需要验证的,因为登录的时候还没有产生票据,所以登录的API不能够继承 BaseApiController 

2、解决ajax的问题

还记得我们在 JS组件系列——封装自己的JS组件,你也可以 这篇里面介绍的增加ajax的error事件的公共处理方法吗?我们是否也可以通过同样的机制去增加这个呢。新建一个文件Jquery_ajax_extention.js

复制代码

(function ($) { //1.得到$.ajax的对象
var _ajax = $.ajax;
$.ajax = function (options) { //2.每次调用发送ajax请求的时候定义默认的error处理方法
var fn = {
error: function (XMLHttpRequest, textStatus, errorThrown) {
toastr.error(XMLHttpRequest.responseText, ‘错误消息’, { closeButton: true, timeOut: 0, positionClass: ‘toast-top-full-width’ });
},
success: function (data, textStatus) { },
beforeSend: function (XHR) { },
complete: function (XHR, TS) { }
} //3.扩展原生的$.ajax方法,返回最新的参数
var _options = $.extend({}, {
error: function (XMLHttpRequest, textStatus, errorThrown) {
fn.error(XMLHttpRequest, textStatus, errorThrown);
},
success: function (data, textStatus) {
fn.success(data, textStatus);
},
beforeSend: function (XHR) {
XHR.setRequestHeader(‘Authorization’, ‘BasicAuth ‘ + Ticket);
fn.beforeSend(XHR);
},
complete: function (XHR, TS) {
fn.complete(XHR, TS);
}
}, options); //4.将最新的参数传回ajax对象
_ajax(_options);
};
})(jQuery);

复制代码

引用这个js后再发送ajax不必在每个请求的beforeSend里面写了。

3、解决特殊不想使用验证的方法

如果我们某些方法不想使用验证,使得它可以让匿名用户访问,我们可以在方法的上面加特性标注 [AllowAnonymous] ,申明该方法运行匿名访问。比如:

复制代码

public class ChargingController : BaseApiController
{ ///


/// 得到所有数据 ///

/// 返回数据
[HttpGet] public string GetAllChargingData()
{ return “Success”;
} ///
/// 得到当前Id的所有数据 ///

/// 参数Id
/// 返回数据
[HttpGet]
[AllowAnonymous] public string GetAllChargingData(string id)
{ return “ChargingData” + id;
}
  }

复制代码

以上结合一个实例讲解了下Basic认证的实现原理以及简单使用,本文观点都是来自博主自己的理解,如果有不全面的地方,还望园友们斧正。如果本文能够或多或少帮到你,不妨帮忙推荐,博主一定继续努力~~


一、前言

  这个星期参加了一个面试,面试中问到深浅拷贝的区别,然后我就简单了讲述了它们的之间的区别,然后面试官又继续问,如何实现一个深拷贝呢?当时只回答回答了一种方式,就是使用反射,然后面试官提示还可以通过反序列化和表达树的方式。然后又继续问,如果用反射来实现深拷贝的话,如何解决互相引用对象的问题呢? 当时我给出的答案是说那就不用反射去实现呗,用反序列化实现呗,或者直接避免使两个对象互相引用呗。然后面试官说,如果一定用反射来写,你是怎么去解决这个问题呢?这时候我就愣住了。

  这样也就有了这篇文章。今天就来深入解析下深浅拷贝的问题。

二、深拷贝 Vs 浅拷贝

  首先,讲到深浅拷贝,自然就有一个问题来了?什么是深拷贝,什么又是浅拷贝呢?下面就具体介绍下它们的定义。

  深拷贝:指的是拷贝一个对象时,不仅仅把对象的引用进行复制,还把该对象引用的值也一起拷贝。这样进行深拷贝后的拷贝对象就和源对象互相独立,其中任何一个对象的改动都不会对另外一个对象造成影响。举个例子,一个人叫张三,然后使用克隆技术以张三来克隆另外一个人叫李四,这样张三和李四就是相互独立的,不管张三缺胳膊还是李四少腿了都不会影响另外一个人。在.NET领域,值对象就是典型的例子,如int, Double以及结构体和枚举等。具体例子如下所示:

复制代码

int source = 123; // 值类型赋值内部执行深拷贝
int copy = source; // 对拷贝对象进行赋值不会改变源对象的值
copy = 234; // 同样对源对象赋值也不会改变拷贝对象的值
source = 345;

复制代码

  浅拷贝:指的是拷贝一个对象时,仅仅拷贝对象的引用进行拷贝,但是拷贝对象和源对象还是引用同一份实体。此时,其中一个对象的改变都会影响到另一个对象。例如,一个人一开始叫张三,后来改名字为张老三了,可是他们还是同一个人,不管张三缺胳膊还是张老三少腿,都反应在同一个人身上。在.NET中引用类型就是一个例子。如类类型。具体例子如下所示:

复制代码

public class Person
{ public string Name { get; set; }
} class Program
{ static void Main(string[] args)
{
Person sourceP = new Person() { Name = “张三” };
Person copyP = sourceP; // 浅拷贝
copyP.Name = “张老三”; // 拷贝对象改变Name值 // 结果都是”张老三”,因为实现的是浅拷贝,一个对象的改变都会影响到另一个对象
Console.WriteLine(“Person.Name: [SourceP: {0}] [CopyP:{1}]“, sourceP.Name, copyP.Name);
Console.Read();
}
}

复制代码

三、深浅拷贝的几种实现方式

   上面已经明白了深浅拷贝的定义,至于他们之间的区别也在定义中也有所体现。介绍完了它们的定义和区别之后,自然也就有了如何去实现它们呢?

  对于,浅拷贝的实现方式很简单,.NET自身也提供了实现。我们知道,所有对象的父对象都是System.Object对象,这个父对象中有一个MemberwiseClone方法,该方法就可以用来实现浅拷贝,下面具体看看浅拷贝的实现方式,具体演示代码如下所示:

复制代码

// 继承ICloneable接口,重新其Clone方法
class ShallowCopyDemoClass : ICloneable
{ public int intValue = 1; public string strValue = “1”; public PersonEnum pEnum = PersonEnum.EnumA; public PersonStruct pStruct = new PersonStruct() { StructValue = 1}; public Person pClass = new Person(“1”); public int[] pIntArray = new int[] { 1 }; public string[] pStringArray = new string[] { “1” }; #region ICloneable成员
public object Clone()
{ return this.MemberwiseClone();
} #endregion } class Person
{ public string Name; public Person(string name)
{
Name = name;
}
} public enum PersonEnum
{
EnumA = 0,
EnumB = 1 } public struct PersonStruct
{ public int StructValue;
}

复制代码

  上面类中重写了IConeable接口的Clone方法,其实现直接调用了Object的MemberwiseClone方法来完成浅拷贝,如果想实现深拷贝,也可以在Clone方法中实现深拷贝的逻辑。接下来就是对上面定义的类进行浅拷贝测试了,看看是否是实现的浅拷贝,具体演示代码如下所示:

复制代码

class Program
{ static void Main(string[] args)
{
ShallowCopyDemo(); // List浅拷贝的演示
ListShallowCopyDemo();
} public static void ListShallowCopyDemo()
{
List personList = new List()
{ new PersonA() { Name=”PersonA”, Age= 10, ClassA= new A() { TestProperty = “AProperty”} }, new PersonA() { Name=”PersonA2”, Age= 20, ClassA= new A() { TestProperty = “AProperty2”} }
};
// 下面2种方式实现的都是浅拷贝
List personsCopy = new List(personList);
PersonA[] personCopy2 = new PersonA[2];
personList.CopyTo(personCopy2);

       // 由于实现的是浅拷贝,所以改变一个对象的值,其他2个对象的值都会发生改变,因为它们都是使用的同一份实体,即它们指向内存中同一个地址
personsCopy.First().ClassA.TestProperty = “AProperty3”;
WriteLog(string.Format(“personCopy2.First().ClassA.TestProperty is {0}”, personCopy2.First().ClassA.TestProperty));
WriteLog(string.Format(“personList.First().ClassA.TestProperty is {0}”, personList.First().ClassA.TestProperty));
WriteLog(string.Format(“personsCopy.First().ClassA.TestProperty is {0}”, personsCopy.First().ClassA.TestProperty));
       Console.Read(); 
} public static void ShallowCopyDemo()
{
ShallowCopyDemoClass DemoA = new ShallowCopyDemoClass();
ShallowCopyDemoClass DemoB = DemoA.Clone() as ShallowCopyDemoClass ;
DemoB.intValue = 2;
WriteLog(string.Format(“ int->[A:{0}] [B:{1}]“, DemoA.intValue, DemoB.intValue));
DemoB.strValue = “2”;
WriteLog(string.Format(“ string->[A:{0}] [B:{1}]“, DemoA.strValue, DemoB.strValue));
DemoB.pEnum = PersonEnum.EnumB;
WriteLog(string.Format(“ Enum->[A: {0}] [B:{1}]“, DemoA.pEnum, DemoB.pEnum));
DemoB.pStruct.StructValue = 2;
WriteLog(string.Format(“ struct->[A: {0}] [B: {1}]“, DemoA.pStruct.StructValue, DemoB.pStruct.StructValue));
DemoB.pIntArray[0] = 2;
WriteLog(string.Format(“ intArray->[A:{0}] [B:{1}]“, DemoA.pIntArray[0], DemoB.pIntArray[0]));
DemoB.pStringArray[0] = “2”;
WriteLog(string.Format(“stringArray->[A:{0}] [B:{1}]“, DemoA.pStringArray[0], DemoB.pStringArray[0]));
DemoB.pClass.Name = “2”;
WriteLog(string.Format(“ Class->[A:{0}] [B:{1}]“, DemoA.pClass.Name, DemoB.pClass.Name));
       Console.WriteLine();
}

private static void WriteLog(string msg) { Console.WriteLine(msg); }   } }

复制代码

  上面代码的运行结果如下图所示:

  从上面运行结果可以看出,.NET中值类型默认是深拷贝的,而对于引用类型,默认实现的是浅拷贝。所以对于类中引用类型的属性改变时,其另一个对象也会发生改变。

  上面已经介绍了浅拷贝的实现方式,那深拷贝要如何实现呢?在前言部分已经介绍了,实现深拷贝的方式有:反射、反序列化和表达式树。在这里,我只介绍反射和反序列化的方式,对于表达式树的方式在网上也没有找到,当时面试官说是可以的,如果大家找到了表达式树的实现方式,麻烦还请留言告知下。下面我们首先来看看反射的实现方式吧:

复制代码

// 利用反射实现深拷贝
public static T DeepCopyWithReflection(T obj)
{
Type type = obj.GetType(); // 如果是字符串或值类型则直接返回
if (obj is string || type.IsValueType) return obj; if (type.IsArray)
{
Type elementType = Type.GetType(type.FullName.Replace(“[]“, string.Empty)); var array = obj as Array;
Array copied = Array.CreateInstance(elementType, array.Length); for (int i = 0; i < array.Length; i++)
{
copied.SetValue(DeepCopyWithReflection(array.GetValue(i)), i);
} return (T)Convert.ChangeType(copied, obj.GetType());
} object retval = Activator.CreateInstance(obj.GetType());

        PropertyInfo\[\] properties \= obj.GetType().GetProperties(
            BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static); foreach (var property in properties)
        { var propertyValue = property.GetValue(obj, null); if (propertyValue == null) continue;
            property.SetValue(retval, DeepCopyWithReflection(propertyValue), null);
        } return (T)retval;
    }

复制代码

  反序列化的实现方式,反序列化的方式也可以细分为3种,具体的实现如下所示:

复制代码

// 利用XML序列化和反序列化实现
public static T DeepCopyWithXmlSerializer(T obj)
{ object retval; using (MemoryStream ms = new MemoryStream())
{
XmlSerializer xml = new XmlSerializer(typeof(T));
xml.Serialize(ms, obj);
ms.Seek(0, SeekOrigin.Begin);
retval = xml.Deserialize(ms);
ms.Close();
} return (T)retval;
} // 利用二进制序列化和反序列实现
public static T DeepCopyWithBinarySerialize(T obj)
{ object retval; using (MemoryStream ms = new MemoryStream())
{
BinaryFormatter bf = new BinaryFormatter(); // 序列化成流
bf.Serialize(ms, obj);
ms.Seek(0, SeekOrigin.Begin); // 反序列化成对象
retval = bf.Deserialize(ms);
ms.Close();
} return (T)retval;
} // 利用DataContractSerializer序列化和反序列化实现
public static T DeepCopy(T obj)
{ object retval; using (MemoryStream ms = new MemoryStream())
{
DataContractSerializer ser = new DataContractSerializer(typeof(T));
ser.WriteObject(ms, obj);
ms.Seek(0, SeekOrigin.Begin);
retval = ser.ReadObject(ms);
ms.Close();
} return (T)retval;
} // 表达式树实现 // ….

复制代码

四、使用反射进行深拷贝如何解决相互引用的问题

  上面反射的实现方式,对于相互引用的对象会出现StackOverflower的错误,由于对象的相互引用,会导致方法循环调用。下面就是一个相互引用对象的例子:

复制代码

[Serializable] public class DeepCopyDemoClass
{ public string Name {get;set;} public int[] pIntArray { get; set; } public Address Address { get; set; } public DemoEnum DemoEnum { get; set; } // DeepCopyDemoClass中引用了TestB对象,TestB类又引用了DeepCopyDemoClass对象,从而造成了相互引用
public TestB TestB {get;set;} public override string ToString()
{ return “DeepCopyDemoClass”;
}
}

\[Serializable\] public class TestB
{ public string Property1 { get; set; } public DeepCopyDemoClass DeepCopyClass { get; set; } public override string ToString()
    { return "TestB Class";
    }
}

\[Serializable\] public struct Address
{ public string City { get; set; }
} public enum DemoEnum
{
    EnumA \= 0,
    EnumB \= 1 }

复制代码

  在面试过程中,针对这个问题的解决方式我回答的是不知道,回来之后思考了之后,也就有了点思路。首先想到的是:能不能用一个字典来记录每个对象被反射的次数,仔细想想可行,于是开始实现,初步修复后的反射实现如下所示:

复制代码

public class DeepCopyHelper
{ // 用一个字典来存放每个对象的反射次数来避免反射代码的循环递归
static Dictionary<Type, int> typereflectionCountDic = new Dictionary<Type, int>(); public static T DeepCopyWithReflection_Second(T obj)
{
Type type = obj.GetType(); // 如果是字符串或值类型则直接返回
if (obj is string || type.IsValueType) return obj; if (type.IsArray)
{
Type elementType = Type.GetType(type.FullName.Replace(“[]“, string.Empty)); var array = obj as Array;
Array copied = Array.CreateInstance(elementType, array.Length); for (int i = 0; i < array.Length; i++)
{
copied.SetValue(DeepCopyWithReflection_Second(array.GetValue(i)), i);
} return (T)Convert.ChangeType(copied, obj.GetType());
} // 对于类类型开始记录对象反射的次数
int reflectionCount = Add(typereflectionCountDic, obj.GetType()); if (reflectionCount > 1) return obj; // 这里有错误 object retval = Activator.CreateInstance(obj.GetType());

        PropertyInfo\[\] properties \= obj.GetType().GetProperties(
            BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static); foreach (var property in properties)
        { var propertyValue = property.GetValue(obj, null); if (propertyValue == null) continue;
            property.SetValue(retval, DeepCopyWithReflection\_Second(propertyValue), null);
        } return (T)retval;
    } private static int Add(Dictionary<Type, int\> dict, Type key)
    { if (key.Equals(typeof(String)) || key.IsValueType) return 0; if (!dict.ContainsKey(key))
        {
            dict.Add(key, 1); return dict\[key\];
        }

        dict\[key\] += 1; return dict\[key\];
    }

}

复制代码

  下面用代码来测试下上面的代码是否已经解决了循环递归的问题,具体的测试代码如下所示:

复制代码

class Program
{ static void Main(string[] args)
{ //ShallowCopyDemo(); //ListShallowCopyDemo();
DeepCopyDemo();
DeepCopyDemo2();
} private static void WriteLog(string msg)
{
Console.WriteLine(msg);
} public static void DeepCopyDemo()
{
DeepCopyDemoClass deepCopyClassA = new DeepCopyDemoClass();
deepCopyClassA.Name = “DeepCopyClassDemo”;
deepCopyClassA.pIntArray = new int[] { 1 };
deepCopyClassA.DemoEnum = DemoEnum.EnumA;
deepCopyClassA.Address = new Address() { City = “Shanghai” };

        deepCopyClassA.TestB \= new TestB() { Property1 = "TestProperty", DeepCopyClass = deepCopyClassA }; // 使用反序列化来实现深拷贝
        DeepCopyDemoClass deepCopyClassB = DeepCopyHelper.DeepCopyWithBinarySerialize<DeepCopyDemoClass>(deepCopyClassA);
        deepCopyClassB.Name \= "DeepCopyClassDemoB";
        WriteLog(string.Format(" Name->\[A:{0}\] \[B:{1}\]", deepCopyClassA.Name, deepCopyClassB.Name));
        deepCopyClassB.pIntArray\[0\] = 2;
        WriteLog(string.Format(" intArray->\[A:{0}\] \[B:{1}\]", deepCopyClassA.pIntArray\[0\], deepCopyClassB.pIntArray\[0\]));
        deepCopyClassB.Address \= new Address() { City = "Beijing" };
        WriteLog(string.Format(" Addressstruct->\[A: {0}\] \[B: {1}\]", deepCopyClassA.Address.City, deepCopyClassB.Address.City));
        deepCopyClassB.DemoEnum \= DemoEnum.EnumB;
        WriteLog(string.Format(" DemoEnum->\[A: {0}\] \[B: {1}\]", deepCopyClassA.DemoEnum, deepCopyClassB.DemoEnum));
        deepCopyClassB.TestB.Property1 \= "TestPropertyB";
        WriteLog(string.Format(" Property1->\[A:{0}\] \[B:{1}\]", deepCopyClassA.TestB.Property1, deepCopyClassB.TestB.Property1));
        WriteLog(string.Format(" TestB.DeepCopyClass.Name->\[A:{0}\] \[B:{1}\]", deepCopyClassA.TestB.DeepCopyClass.Name, deepCopyClassB.TestB.DeepCopyClass.Name));
        Console.WriteLine();
    } public static void DeepCopyDemo2()
    {
        DeepCopyDemoClass deepCopyClassA \= new DeepCopyDemoClass();
        deepCopyClassA.Name \= "DeepCopyClassDemo";
        deepCopyClassA.pIntArray \= new int\[\] { 1, 2 };
        deepCopyClassA.DemoEnum \= DemoEnum.EnumA;
        deepCopyClassA.Address \= new Address() { City = "Shanghai" };

        deepCopyClassA.TestB \= new TestB() { Property1 = "TestProperty",  DeepCopyClass = deepCopyClassA }; // 使用反射来完成深拷贝
        DeepCopyDemoClass deepCopyClassB = DeepCopyHelper.DeepCopyWithReflection\_Second<DeepCopyDemoClass>(deepCopyClassA); //DeepCopyDemoClass deepCopyClassB = DeepCopyHelper.DeepCopyWithReflection<DeepCopyDemoClass>(deepCopyClassA);
        deepCopyClassB.Name = "DeepCopyClassDemoB";
        WriteLog(string.Format(" Name->\[A:{0}\] \[B:{1}\]", deepCopyClassA.Name, deepCopyClassB.Name));
        deepCopyClassB.pIntArray\[0\] = 2;
        WriteLog(string.Format(" intArray->\[A:{0}\] \[B:{1}\]", deepCopyClassA.pIntArray\[0\], deepCopyClassB.pIntArray\[0\]));
        deepCopyClassB.Address \= new Address() { City = "Beijing" };
        WriteLog(string.Format(" Addressstruct->\[A: {0}\] \[B: {1}\]", deepCopyClassA.Address.City, deepCopyClassB.Address.City));
        deepCopyClassB.DemoEnum \= DemoEnum.EnumB;
        WriteLog(string.Format(" DemoEnum->\[A: {0}\] \[B: {1}\]", deepCopyClassA.DemoEnum, deepCopyClassB.DemoEnum));
        deepCopyClassB.TestB.Property1 \= "TestPropertyB";
        WriteLog(string.Format(" Property1->\[A:{0}\] \[B:{1}\]", deepCopyClassA.TestB.Property1, deepCopyClassB.TestB.Property1));
        WriteLog(string.Format(" TestB.DeepCopyClass.Name->\[A:{0}\] \[B:{1}\]", deepCopyClassA.TestB.DeepCopyClass.Name, deepCopyClassB.TestB.DeepCopyClass.Name));
        Console.ReadKey();
    }
} 

复制代码

  此时的运行结果如下图所示:

  刚开始看到这样的运行结果,开心地以为已经解决了循环递归的问题了,因为此时结果成功运行出来了,没有了StackOverflower的错误了。但是仔细一看,反序列化和反射完成的深拷贝的运行结果不一样,如上图中红色圈出来的部分。显然,反序列化的结果是没有错误的,显然目前实现的反射代码还是有问题的。接下来就是思考了。为什么上面反射的代码不正确呢?

  仔细分析DeepCopyWithReflection_Second中的代码,发现下面代码红色部分是错误的:

int reflectionCount = Add(typereflectionCountDic, obj.GetType()); if (reflectionCount > 1) return obj; // 是错误的

  对DeepCopyWithReflection_Second方法仔细分析,在对TestB进行反射时,当反射到DeepCopyClass属性时,此时会递归调用DeepCopyWithReflection_Second方法,此时在typereflectionCountDic发现DeepCopyDemoClass已经被反射了,则直接返回,这样分析好像没什么错误,但是此时返回的是deepCopyClassA对象,但是我们需要返回的是deepCopyClassB对象,即此时deepCopyClassB对象的内存结构如下图所示:

  而我们其实需要deepCopyClassB对象的内存结构如下图所示:

  既然找到了DeepCopyWithReflection_Second的错误原因,那我们就要解决了。上面说我们返回的应该是deepCopyClassB对象,而我们怎么得到创建的deepCopyClassB对象呢?这里我就想能不能用一个变量来保存一开始通过CreateInstance方法创建的deepCopyClassB对象呢?验证想法最好的办法就是代码了,这样我就按照这个思路对DeepCopyWithReflection_Second又进行一次改进,最终的代码如下所示:

复制代码

public static T DeepCopyWithReflection_Third(T obj)
{
Type type = obj.GetType(); // 如果是字符串或值类型则直接返回
if (obj is string || type.IsValueType) return obj; if (type.IsArray)
{
Type elementType = Type.GetType(type.FullName.Replace(“[]“, string.Empty)); var array = obj as Array;
Array copied = Array.CreateInstance(elementType, array.Length); for (int i = 0; i < array.Length; i++)
{
copied.SetValue(DeepCopyWithReflection_Second(array.GetValue(i)), i);
} return (T)Convert.ChangeType(copied, obj.GetType());
} int reflectionCount = Add(typereflectionCountDic, obj.GetType()); if (reflectionCount > 1 && obj.GetType() == typeof(DeepCopyDemoClass)) return (T)DeepCopyDemoClasstypeRef; // 返回deepCopyClassB对象

        object retval = Activator.CreateInstance(obj.GetType()); if(retval.GetType() == typeof(DeepCopyDemoClass))
            DeepCopyDemoClasstypeRef \= retval; // 保存一开始创建的DeepCopyDemoClass对象

PropertyInfo[] properties = obj.GetType().GetProperties(
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static); foreach (var property in properties)
{ var propertyValue = property.GetValue(obj, null); if (propertyValue == null) continue;
property.SetValue(retval, DeepCopyWithReflection_Third(propertyValue), null);
} return (T)retval;
}

复制代码

  下面我用DeepCopyWithReflection_Third方法来测试下,具体的测试代码如下所示:

复制代码

class Program
{ static void Main(string[] args)
{ //ShallowCopyDemo(); //ListShallowCopyDemo();
DeepCopyDemo();
DeepCopyDemo2();
} private static void WriteLog(string msg)
{
Console.WriteLine(msg);
} public static void DeepCopyDemo()
{
DeepCopyDemoClass deepCopyClassA = new DeepCopyDemoClass();
deepCopyClassA.Name = “DeepCopyClassDemo”;
deepCopyClassA.pIntArray = new int[] { 1 };
deepCopyClassA.DemoEnum = DemoEnum.EnumA;
deepCopyClassA.Address = new Address() { City = “Shanghai” };

        deepCopyClassA.TestB \= new TestB() { Property1 = "TestProperty", DeepCopyClass = deepCopyClassA }; // 使用反序列化来实现深拷贝
        DeepCopyDemoClass deepCopyClassB = DeepCopyHelper.DeepCopyWithBinarySerialize<DeepCopyDemoClass>(deepCopyClassA);
        deepCopyClassB.Name \= "DeepCopyClassDemoB";
        WriteLog(string.Format(" Name->\[A:{0}\] \[B:{1}\]", deepCopyClassA.Name, deepCopyClassB.Name));
        deepCopyClassB.pIntArray\[0\] = 2;
        WriteLog(string.Format(" intArray->\[A:{0}\] \[B:{1}\]", deepCopyClassA.pIntArray\[0\], deepCopyClassB.pIntArray\[0\]));
        deepCopyClassB.Address \= new Address() { City = "Beijing" };
        WriteLog(string.Format(" Addressstruct->\[A: {0}\] \[B: {1}\]", deepCopyClassA.Address.City, deepCopyClassB.Address.City));
        deepCopyClassB.DemoEnum \= DemoEnum.EnumB;
        WriteLog(string.Format(" DemoEnum->\[A: {0}\] \[B: {1}\]", deepCopyClassA.DemoEnum, deepCopyClassB.DemoEnum));
        deepCopyClassB.TestB.Property1 \= "TestPropertyB";
        WriteLog(string.Format(" Property1->\[A:{0}\] \[B:{1}\]", deepCopyClassA.TestB.Property1, deepCopyClassB.TestB.Property1));
        WriteLog(string.Format(" TestB.DeepCopyClass.Name->\[A:{0}\] \[B:{1}\]", deepCopyClassA.TestB.DeepCopyClass.Name, deepCopyClassB.TestB.DeepCopyClass.Name));
        Console.WriteLine();
    } public static void DeepCopyDemo2()
    {
        DeepCopyDemoClass deepCopyClassA \= new DeepCopyDemoClass();
        deepCopyClassA.Name \= "DeepCopyClassDemo";
        deepCopyClassA.pIntArray \= new int\[\] { 1, 2 };
        deepCopyClassA.DemoEnum \= DemoEnum.EnumA;
        deepCopyClassA.Address \= new Address() { City = "Shanghai" };

        deepCopyClassA.TestB \= new TestB() { Property1 = "TestProperty",  DeepCopyClass = deepCopyClassA }; **// 使用反射来完成深拷贝
        DeepCopyDemoClass deepCopyClassB = DeepCopyHelper.DeepCopyWithReflection\_Third<DeepCopyDemoClass>(deepCopyClassA);** //DeepCopyDemoClass deepCopyClassB = DeepCopyHelper.DeepCopyWithReflection<DeepCopyDemoClass>(deepCopyClassA);
        deepCopyClassB.Name = "DeepCopyClassDemoB";
        WriteLog(string.Format(" Name->\[A:{0}\] \[B:{1}\]", deepCopyClassA.Name, deepCopyClassB.Name));
        deepCopyClassB.pIntArray\[0\] = 2;
        WriteLog(string.Format(" intArray->\[A:{0}\] \[B:{1}\]", deepCopyClassA.pIntArray\[0\], deepCopyClassB.pIntArray\[0\]));
        deepCopyClassB.Address \= new Address() { City = "Beijing" };
        WriteLog(string.Format(" Addressstruct->\[A: {0}\] \[B: {1}\]", deepCopyClassA.Address.City, deepCopyClassB.Address.City));
        deepCopyClassB.DemoEnum \= DemoEnum.EnumB;
        WriteLog(string.Format(" DemoEnum->\[A: {0}\] \[B: {1}\]", deepCopyClassA.DemoEnum, deepCopyClassB.DemoEnum));
        deepCopyClassB.TestB.Property1 \= "TestPropertyB";
        WriteLog(string.Format(" Property1->\[A:{0}\] \[B:{1}\]", deepCopyClassA.TestB.Property1, deepCopyClassB.TestB.Property1));
        WriteLog(string.Format(" TestB.DeepCopyClass.Name->\[A:{0}\] \[B:{1}\]", deepCopyClassA.TestB.DeepCopyClass.Name, deepCopyClassB.TestB.DeepCopyClass.Name));
        Console.ReadKey();
    }

}

复制代码

  此时的运行结果如下图示所示:

  

  从上面的测试结果可以看出,此时深拷贝的反射实现方法基本上没什么问题了。这个方法也同时解决了相互引用对象的循环递归问题。

五、总结

  到这里,该文章的内容就结束。这里主要记录下自己在一次面试过程中遇到问题的一次总结,从中可以看出,反射进行深拷贝会有很多其他的问题,所以平时还是建议大家使用序列化的形式来进行深拷贝。

  最后附上本文所有源码下载:DeepCopy.zip

C#邮件发送类 简单实用 可自定义发件人名称 - 晓晨Master - 博客园

Excerpt

上图看效果MailHelper:public class MailHelper { public bool SendMail(MailSender sender,out string errorMsg) { //声明一个Mail对象 …


public class MailHelper

    {

        public bool SendMail(MailSender sender,``out string errorMsg)

        {

            MailMessage mymail = new MailMessage();

            mymail.From = new MailAddress(sender.SendAddress,sender.SendNickName,Encoding.UTF8);

            mymail.To.Add(``new MailAddress(sender.ReceiveAddress));

            mymail.Subject = sender.Title;

            mymail.SubjectEncoding = Encoding.UTF8;

            mymail.Body = sender.Content;

            mymail.BodyEncoding = Encoding.UTF8;

            foreach (``var attachment in sender.Attachments)

            {

                mymail.Attachments.Add(attachment);

            }

            foreach (``var str in sender.Cc)

            {

                mymail.CC.Add(``new MailAddress(str));

            }

            mymail.IsBodyHtml = sender.IsBodyHtml;

            mymail.Priority = sender.MailPriority;

            SmtpClient myclient = new SmtpClient();

            myclient.Host = sender.Host;

            myclient.Port = sender.Port;

            myclient.Credentials = new NetworkCredential(sender.Username, sender.Password);

            try

            {

                myclient.Send(mymail);

                errorMsg = ""``;

                return true``;

            }

            catch (Exception ex)

            {

                errorMsg = ex.Message;

                return false``;

            }

        }

上一篇:《DDD 领域驱动设计-领域模型中的用户设计?

开源地址:https://github.com/yuezhongxin/CNBlogs.Apply.Sample(代码已更新)

在之前的项目开发中,只有一个 JsPermissionApply 实体(JS 权限申请),所以,CNBlogs.Apply.Domain 设计的有些不全面,或者称之为不完善,因为在一些简单的项目开发中,一般只会存在一个实体,单个实体的设计,我们可能会忽略很多的东西,从而以后会导致一些问题的产生,那如果再增加一个实体,CNBlogs.Apply.Domain 该如何设计呢?

按照实际项目开发需要,CNBlogs.Apply.Domain 需要增加一个 BlogChangeApply 实体(博客地址更改申请)。

在 BlogChangeApply 实体设计之前,我们按照之前 JsPermissionApply 实体设计过程,先大致画一下流程图:

流程图很简单,并且和之前的 JS 权限申请和审核很相似,我们再看一下之前的 JsPermissionApply 实体设计代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
namespace CNBlogs.Apply.Domain
{
public class JsPermissionApply : IAggregateRoot
{
private IEventBus eventBus;

public JsPermissionApply()
{ }

public JsPermissionApply(string reason, User user, string ip)
{
if (string.IsNullOrEmpty(reason))
{
throw new ArgumentException("申请内容不能为空");
}
if (reason.Length > 3000)
{
throw new ArgumentException("申请内容超出最大长度");
}
if (user == null)
{
throw new ArgumentException("用户为null");
}
if (user.Id == 0)
{
throw new ArgumentException("用户Id为0");
}
this.Reason = HttpUtility.HtmlEncode(reason);
this.User = user;
this.Ip = ip;
this.Status = Status.Wait;
}

public int Id { get; private set; }

public string Reason { get; private set; }

public virtual User User { get; private set; }

public Status Status { get; private set; } = Status.Wait;

public string Ip { get; private set; }

public DateTime ApplyTime { get; private set; } = DateTime.Now;

public string ReplyContent { get; private set; }

public DateTime? ApprovedTime { get; private set; }

public bool IsActive { get; private set; } = true;

public async Task<Status> GetStatus(string userAlias)
{
if (await BlogService.HaveJsPermission(userAlias))
{
return Status.Pass;
}
else
{
if (this.Status == Status.Deny && DateTime.Now > this.ApplyTime.AddDays(3))
{
return Status.None;
}
if (this.Status == Status.Pass)
{
return Status.None;
}
return this.Status;
}
}

public async Task<bool> Pass()
{
if (this.Status != Status.Wait)
{
return false;
}
this.Status = Status.Pass;
this.ApprovedTime = DateTime.Now;
this.ReplyContent = "恭喜您!您的JS权限申请已通过审批。";
eventBus = IocContainer.Default.Resolve<IEventBus>();
await eventBus.Publish(new JsPermissionOpenedEvent() { UserAlias = this.User.Alias });
return true;
}

public bool Deny(string replyContent)
{
if (this.Status != Status.Wait)
{
return false;
}
this.Status = Status.Deny;
this.ApprovedTime = DateTime.Now;
this.ReplyContent = replyContent;
return true;
}

public bool Lock()
{
if (this.Status != Status.Wait)
{
return false;
}
this.Status = Status.Lock;
this.ApprovedTime = DateTime.Now;
this.ReplyContent = "抱歉!您的JS权限申请没有被批准,并且申请已被锁定,具体请联系contact@cnblogs.com。";
return true;
}

public async Task Passed()
{
if (this.Status != Status.Pass)
{
return;
}
eventBus = IocContainer.Default.Resolve<IEventBus>();
await eventBus.Publish(new MessageSentEvent() { Title = "您的JS权限申请已批准", Content = this.ReplyContent, RecipientId = this.User.Id });
}

public async Task Denied()
{
if (this.Status != Status.Deny)
{
return;
}
eventBus = IocContainer.Default.Resolve<IEventBus>();
await eventBus.Publish(new MessageSentEvent() { Title = "您的JS权限申请未通过审批", Content = this.ReplyContent, RecipientId = this.User.Id });
}

public async Task Locked()
{
if (this.Status != Status.Lock)
{
return;
}
eventBus = IocContainer.Default.Resolve<IEventBus>();
await eventBus.Publish(new MessageSentEvent() { Title = "您的JS权限申请未通过审批", Content = this.ReplyContent, RecipientId = this.User.Id });
}
}
}

根据博客地址更改申请和审核的流程图,然后再结合上面 JsPermissionApply 实体代码,我们就可以幻想出 BlogChangeApply 的实体代码,具体是怎样的了,如果你实现一下,会发现和上面的代码简直一摸一样,区别就在于多了一个 TargetBlogApp(目标博客地址),然后后面的 Repository 和 Application.Services 复制粘贴就行了,没有任何的难度,这样设计实现也没什么问题,但是项目中的重复代码简直太多了,领域驱动设计慢慢就变成了一个脚手架,没有任何的一点用处。

该如何解决上面的问题呢?我们需要思考下 CNBlogs.Apply.Domain 所包含的含义,CNBlogs.Apply.Domain 顾名思议是申请领域,并不是 CNBlogs.JsPermissionApply.Domain,也不是 CNBlogs.BlogChangeApply.Domain,实体的产生是根据聚合根的设计,那 CNBlogs.Apply.Domain 的聚合根是什么呢?在之前的设计中只有 IAggregateRoot 和 IEntity,具体代码:

1
2
3
4
5
6
7
8
9
10
11
12
namespace CNBlogs.Apply.Domain
{
public interface IAggregateRoot : IEntity { }
}

namespace CNBlogs.Apply.Domain
{
public interface IEntity
{
int Id { get; }
}
}

现在再来看上面这种设计,完全是错误的,聚合根接口怎么能继承实体接口呢,还有一个问题,就是如果有多个实体设计,是继承 IAggregateRoot?还是 IEntity?IEntity 在这样的设计中,没有任何的作用,并且闲的很多余,IAggregateRoot 到最后也只是一个抽象的接口,CNBlogs.Apply.Domain 中并没有具体的实现。

解决上面混乱的问题,就是抽离出 ApplyAggregateRoot(申请聚合根),然后 JsPermissionApply 和 BlogChangeApply 实体都是由它进行产生,在这之前,我们先定义一下 IAggregateRoot:

1
2
3
4
5
6
7
namespace CNBlogs.Apply.Domain
{
public interface IAggregateRoot
{
int Id { get; }
}
}

然后根据 JS 权限申请/审核和博客地址更改申请/审核的流程图,抽离出 ApplyAggregateRoot,并且继承自 IAggregateRoot,具体实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
namespace CNBlogs.Apply.Domain
{
public class ApplyAggregateRoot : IAggregateRoot
{
private IEventBus eventBus;

public ApplyAggregateRoot()
{ }

public ApplyAggregateRoot(string reason, User user, string ip)
{
if (string.IsNullOrEmpty(reason))
{
throw new ArgumentException("申请内容不能为空");
}
if (reason.Length > 3000)
{
throw new ArgumentException("申请内容超出最大长度");
}
if (user == null)
{
throw new ArgumentException("用户为null");
}
if (user.Id == 0)
{
throw new ArgumentException("用户Id为0");
}
this.Reason = HttpUtility.HtmlEncode(reason);
this.User = user;
this.Ip = ip;
this.Status = Status.Wait;
}

public int Id { get; protected set; }

public string Reason { get; protected set; }

public virtual User User { get; protected set; }

public Status Status { get; protected set; } = Status.Wait;

public string Ip { get; protected set; }

public DateTime ApplyTime { get; protected set; } = DateTime.Now;

public string ReplyContent { get; protected set; }

public DateTime? ApprovedTime { get; protected set; }

public bool IsActive { get; protected set; } = true;

protected async Task<bool> Pass<TEvent>(string replyContent, TEvent @event)
where TEvent : IEvent
{
if (this.Status != Status.Wait)
{
return false;
}
this.Status = Status.Pass;
this.ApprovedTime = DateTime.Now;
this.ReplyContent = replyContent;
eventBus = IocContainer.Default.Resolve<IEventBus>();
await eventBus.Publish(@event);
return true;
}

public bool Deny(string replyContent)
{
if (this.Status != Status.Wait)
{
return false;
}
this.Status = Status.Deny;
this.ApprovedTime = DateTime.Now;
this.ReplyContent = replyContent;
return true;
}

protected bool Lock(string replyContent)
{
if (this.Status != Status.Wait)
{
return false;
}
this.Status = Status.Lock;
this.ApprovedTime = DateTime.Now;
this.ReplyContent = replyContent;
return true;
}

protected async Task Passed(string title)
{
if (this.Status != Status.Pass)
{
return;
}
await SendMessage(title);
}

protected async Task Denied(string title)
{
if (this.Status != Status.Deny)
{
return;
}
await SendMessage(title);
}

protected async Task Locked(string title)
{
if (this.Status != Status.Lock)
{
return;
}
await SendMessage(title);
}

private async Task SendMessage(string title)
{
eventBus = IocContainer.Default.Resolve<IEventBus>();
await eventBus.Publish(new MessageSentEvent() { Title = title, Content = this.ReplyContent, RecipientId = this.User.Id });
}
}
}

ApplyAggregateRoot 的实现,基本上是抽离出 JsPermissionApply 和 BlogChangeApply 实体产生的重复代码,比如不管什么类型的申请,都包含申请理由、申请人信息、通过或拒绝等操作,这些也就是 ApplyAggregateRoot 所体现的领域含义,我们再来看下 BlogChangeApply 实体的实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
namespace CNBlogs.Apply.Domain
{
public class BlogChangeApply : ApplyAggregateRoot
{
public BlogChangeApply()
{ }

public BlogChangeApply(string targetBlogApp, string reason, User user, string ip)
: base(reason, user, ip)
{
if (string.IsNullOrEmpty(targetBlogApp))
{
throw new ArgumentException("博客地址不能为空");
}
targetBlogApp = targetBlogApp.Trim();
if (targetBlogApp.Length < 4)
{
throw new ArgumentException("博客地址至少4个字符!");
}
if (!Regex.IsMatch(targetBlogApp, @"^([0-9a-zA-Z_-])+$"))
{
throw new ArgumentException("博客地址只能使用英文、数字、-连字符、_下划线!");
}
this.TargetBlogApp = targetBlogApp;
}

public string TargetBlogApp { get; private set; }

public Status GetStatus()
{
if (this.Status == Status.Deny && DateTime.Now > this.ApplyTime.AddDays(3))
{
return Status.None;
}
return this.Status;
}

public async Task<bool> Pass()
{
var replyContent = $"恭喜您!您的博客地址更改申请已通过,新的博客地址:<a href='{this.TargetBlogApp}' target='_blank'>{this.TargetBlogApp}</a>";
return await base.Pass(replyContent, new BlogChangedEvent() { UserAlias = this.User.Alias, TargetUserAlias = this.TargetBlogApp });
}

public bool Lock()
{
var replyContent = "抱歉!您的博客地址更改申请没有被批准,并且申请已被锁定,具体请联系contact@cnblogs.com。";
return base.Lock(replyContent);
}

public async Task Passed()
{
await base.Passed("您的博客地址更改申请已批准");
}

public async Task Denied()
{
await base.Passed("您的博客地址更改申请未通过审批");
}

public async Task Locked()
{
await Denied();
}
}
}

BlogChangeApply 继承自 ApplyAggregateRoot,并且单独的 TargetBlogApp 操作,其他一些实现都是基本的参数传递操作,没有具体实现,JsPermissionApply 的实体代码就不贴了,和 BlogChangeApply 比较类似,只不过有一些不同的业务实现。

CNBlogs.Apply.Domain 改造之后,还要对应改造下 Repository,之前的代码大家可以看下 Github,这边我简单说下改造的过程,首先 IRepository 的设计不变:

1
2
3
4
5
6
7
8
9
10
namespace CNBlogs.Apply.Repository.Interfaces
{
public interface IRepository<TAggregateRoot>
where TAggregateRoot : class, IAggregateRoot
{
IQueryable<TAggregateRoot> Get(int id);

IQueryable<TAggregateRoot> GetAll();
}
}

IRepository 对应 BaseRepository 实现,它的作用就是抽离出所有聚合根的 Repository 操作,并不单独包含 ApplyAggregateRoot,所以,我们还需要一个对 ApplyAggregateRoot 操作的 Repository 实现,定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
namespace CNBlogs.Apply.Repository.Interfaces
{
public interface IApplyRepository<TApplyAggregateRoot> : IRepository<TApplyAggregateRoot>
where TApplyAggregateRoot : ApplyAggregateRoot
{
IQueryable<TApplyAggregateRoot> GetByUserId(int userId);

IQueryable<TApplyAggregateRoot> GetWaiting(int userId);

IQueryable<TApplyAggregateRoot> GetWaiting();
}
}

大家如果熟悉之前代码的话,会发现 IApplyRepository 的定义和 IJsPermissionApplyRepository 的定义是一摸一样的,设计 IApplyRepository 的好处就是,对于申请实体的相同操作,我们就不需要再写重复代码了,比如 IJsPermissionApplyRepository 和 IBlogChangeApplyRepository 的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
namespace CNBlogs.Apply.Repository.Interfaces
{
public interface IJsPermissionApplyRepository : IApplyRepository<JsPermissionApply>
{ }
}

namespace CNBlogs.Apply.Repository.Interfaces
{
public interface IBlogChangeApplyRepository : IApplyRepository<BlogChangeApply>
{
IQueryable<BlogChangeApply> GetByTargetAliasWithWait(string targetBlogApp);
}
}

当然,除了上面的代码改造,还有一些其他功能的添加,比如 ApplyAuthenticationService 领域服务增加了 VerfiyForBlogChange 等等,具体的一些改变,大家可以查看提交

CNBlogs.Apply.Sample 开发进行到这,对于现阶段的我来说,应用领域驱动设计我是比较满意的,虽然还有一些不完善的地方,但至少除了 CNBlogs.Apply.Domain,在其他项目中是看不到业务实现代码的,如果业务需求发生变化,首先更改的是 CNBlogs.Apply.Domain,而不是不是其它项目,这是一个基本点。

先设计 CNBlogs.Apply.Domain 和 CNBlogs.Apply.Domain.Tests,就能完成整个的业务系统设计,其它都是一些技术实现或工作流程实现,这个路子我觉得是正确的,以后边做边完善并学习。

注:科比今天要退役了,我是 60 亿分之一,满腹怀念~😭😭😭

前几天看了园友的一篇文章《我眼中的领域驱动设计》,文中有段话直击痛点:有人误认为项目架构中加入 Repository,Domain,ValueObject 就变成了 DDD 架构。没错,我就是这样,不过准确的来说,并不能称为 DDD 架构,而是我之前经常说的“伪 DDD”设计,后来我还抽离出了一个伪 DDD 设计框架:DDD.Sample,大家有兴趣的可以瞧瞧,在实际项目的开发中,我用它做过了几个不太大的项目,我个人觉得用起来很顺手,当然并没有真正的 DDD 设计,只不过用了一个空的架构而已,然后冠以”DDD 开发“的名号。

因为之前有过 DDD 设计的痛苦经历(短消息系统),具体表现就是,如果真正 DDD 设计,需要花费大量的时间和精力,可能几天都在思考一个问题或者考虑几行代码的编写,但最后也可能没什么结论或结果,并且这个过程是很艰难和痛苦的,所以我后来就变懒了,懒的去思考项目所表现的业务,也不再考虑如何去设计领域模型,只是在考虑如何让框架用起来更爽,DDD.Sample 前两个应用的实际项目,我都是在完善这个框架,比如 Repository 和 UnitOfWork 的设计等等,所以,关于领域模型的设计,就是一堆贫血模型。不过,后来应用的第三个项目,也就是上一个实际项目,我觉得不能再这样下去了,因为没啥意义,框架一遍一遍的套用,而 DDD 却和它没半毛钱关系,所以,我就花了点时间去思考(只是简单的思考):我做这个项目的核心业务是什么?我该如何提炼出核心业务?提炼出核心业务之后,其他的任何实现都是为核心业务服务的,所以,你可以把这个核心业务看成领域模型。

关于第三个应用项目,实际上就是我们园子的“提到我”系统,现在已经应用在新闻评论了,大家可以去瞧瞧,类似为微博的“提到我”,相对比较简单的一个系统,你可以在评论中 @一个人,然后另一个人会接受通知,那这个系统的核心业务是什么?其实就是上面那句话,只不过你需要抽离出一些内容,如果领域专家和开发人员进行交流这个系统的设计,那领域专家的表述就是:你可以在评论中 @一个人,然后另一个人会接受通知,领域专家可能不懂代码设计,他的这个表述就是最直接和最精准的业务,所以,我们需要针对这段话,再和领域专家深入探讨下所蕴含的业务:

  • 你可以在评论中 @一个人 -> @一个人 -> 怎么能得到并确认这个“一个人” -> @匹配规则
  • 另一个人会接受通知 -> 通知 -> 通知所 @的人

所以,**@匹配规则通知所 @的人**是“提到我”系统的核心业务,确定好核心业务了,那就该具体的实现了,关于这个我没有深入的去考虑,就直接放在了 Mention(提到)中,大致代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
namespace CNBlogs.Mention.Domain
{
public class Mention : IAggregateRoot
{
private static readonly string SplitRegex = "::  @,";
private static readonly Regex MentionsRegex = new Regex($"@([^{SplitRegex}]+)", RegexOptions.Compiled);

public int Id { get; set; }

public string Content { get; set; }

public int ContentId { get; set; }

public AppType AppType { get; set; }

...


public async Task<List<int>> Extract()
{
...
}


public async Task Notify()
{
...
}
}
}

看起来很简单,就是把两个方法放在了 Mention 中,但这简单的操作却好像给 Mention 领域模型生命一样,不再那么贫血,对于复杂系统的业务变化,往往是核心业务的变化,其他的都是为核心业务服务的业务流程,并不能真正称为业务,比如 Application 层的代码,现在领域专家说 @一个人的规则需要改变,或者通知规则需要变化,我们只需要修改 Mention 领域模型的代码就行了,其他的代码并不需要修改,这就是 DDD 设计最浅显的体现。

大致贴下 Application 层的伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
namespace CNBlogs.Mention.Application.Services
{
public class MentionService : IMentionService
{
private IMentionRepository _mentionRepository;
private IUnitOfWork _unitOfWork;

public MentionService(IUnitOfWork unitOfWork,
IMentionRepository mentionRepository)
{
_unitOfWork = unitOfWork;
_mentionRepository = mentionRepository;
}

public async Task<SubmitResult> Submit(string content ,int contentId, AppType appType)
{
var notifyMentions = new List<Domain.Mention>();
var existingQuery = _mentionRepository.Get(contentId, appType);
var mention = new Domain.Mention()
{
Content = content,
ContentId = contentId,
AppType = appType
};
var userIds = await mention.Extract();
foreach (var userId in userIds)
{
var userQuery = existingQuery.Where(x => x.UserId == userId);
if (await userQuery.AnyAsync())
{
await userQuery.UpdateAsync(x => new Domain.Mention { Content = content, DateUpdated = DateTime.Now });
}
else
{
mention.UserId = userId;
_unitOfWork.RegisterNew(mention);
notifyMentions.Add(mention);
}
}
if (await _unitOfWork.CommitAsync())
{
foreach (var notifyMention in notifyMentions)
{
await notifyMention.Notify();
}
return new SubmitResult();
}
return new SubmitResult { IsSucceed = false };
}
}
}

可以看到,Submit 中的操作基本上都是工作流程,先抽取用户,再进行判断更新,然后进行持久化,最后进行通知,没有任何业务体现,所以,如果核心业务发生了变化,这部分的代码并不需要随之改变。

如何 DDD?

引自:《Implementing DDD Reading - Strategic Design

如何 DDD?其实答案都在上面的图中,图中的设计在《实现领域驱动设计》书中,被定义为战略建模(Strategic Modeling),主要包含领域、核心域、子域、通用子域、支撑子域、限界上下文、协作上下文、上下文映射图等等概念,我之前的几篇《IDDD 实现领域驱动设计》系统文章,也有过相关的介绍,说实话,我只是当时读过写过有些记忆,现在让我再说任何一个概念,基本上我说不上来,对于我个人来说,战略建模是一种宏观的建模方式,你需要站在高处去俯瞰整个系统,并需要抽离出系统所包含的业务,并将它们一一划分,这个工作是非常难的,推荐几篇战略建模相关的文章:

除了战略建模,还有一种建模方式叫战术建模(Tactical Modeling),主要包含聚合(Aggregate)、实体(Entity)、值对象(Value Objects)、资源库(Repository)、领域服务(Domain Services)、领域事件(Domain Events)、模块(Modules)等等概念。

在《实现领域驱动设计》书中,Scrum 团队(一个实际项目的开发团队)一开始就是采用的战术建模,并且在开发的过程中,他们并不知道战略建模是什么?最后导致了很多问题,书中有个节点就专门讲了“战略设计为什么重要?”,但我个人觉得,战略建模的重要也只是相对而言,它在应对大型复杂性的业务系统设计中,可以充分发挥它的特点,但针对一些相对简单的系统,还不如直接进行战术建模,比如上面说的“提到我”系统。

所以,目前来说,进行战术建模比较现实和有意义,但在进行战术建模之前,我觉得还有一个重要的工作,就是和领域专家进行交流系统业务,这个工作并不包含具体的战术建模该如何设计,比如聚合、实体啥的,和领域专家并不需要讨论这部分内容,而是系统所包含的业务,就像“提到我”系统中,我问我自己“我做这个项目的核心业务是什么?”。

领域专家并不是一个职位,他可以是精通业务的任何人。他们可能了解更多的关于业务领域的背景知识,他们可能是软件产品的设计者,甚至有可能是销售员。

实际项目的实践

好,概念说太多没什么意义,实际应用才有价值,我现在在开发一个 JS 权限申请和审核系统,就是我们园子里的 JS 权限申请,因为现在申请 JS 权限需要发邮件进行申请,对于用户申请和我们处理来说,都比较麻烦并且费时间,所以为了解决这个问题,我们想做把 JS 权限申请做成像申请博客一样,园友填写申请内容,然后我们进行后台审核,效率可以提升很多,大概就是这样的一个系统,真实的不能再真实了,毕竟园友和我们都会实际接触并使用,这也是一个相对较小的系统,我们就拿它来开刀,看看 DDD 的这把刀锋不锋利。

对于 JS 权限申请和审核系统来说,领域专家是谁?应该是园友和管理员,毕竟他们在使用这个系统,没有人比他们更了解其中的业务了,所以他们就是这个系统的领域专家,需要强调的是,虽然有时候领域专家是开发人员,但在一开始探讨业务系统的时候,一定不能牵扯到数据库和代码的设计,我们应该忘掉数据库和代码,只是单纯的站在领域专家的角度,去探讨和思考业务系统,那领域专家该如何表述这个系统的业务呢?

下面我大致的表述下:

用户填写 JS 权限申请内容,管理员后台进行审核。

有没有搞错?就这么简单???好像又无言以对,因为关于 JS 权限申请和审核系统,最简单的表述就是这样,但如何提炼出所蕴含的业务呢?接下来需要我们深入的探讨下,作为领域专家身份的我,绘制了一张大致的业务流程图:

上面这张图可以进行反复的修改,每个领域专家都可以发表自己的意见和建议,经过最激烈的探讨才会让业务系统更加准确,当业务系统确定好之后,我们就可以从中抽离出核心业务了,上面这张图,哪些是核心业务?哪些又是业务流程呢?我大致圈一下:

方框圈的是核心业务,圆形圈的是实体的状态变化,核心业务一般包含在最简单的描述中,比如“提到我”系统中的表述“抽离”和“通知”,还有一种区分方式:判断其是否经常发生变化,对于业务流程来说,一般是不会发生变化的,变化的是核心业务,DDD 的设计应对的就是这个变化,再大致总结下:

  • 核心业务:验证用户信息,可以称为申请状态改为“待审核”的前提条件,主要是判断用户是否符合要求,前面的“验证申请状态”也属于这一类。
  • 实体状态变化:JS 权限申请就是一个实体,它有自己的生命周期,并且对于用户来说,它是唯一存在的,从上面的图中,我们可以看到 JS 权限申请的状态变化,这是领域所关心的,能改变实体的状态,就是业务。

那领域模型关心的是哪些业务?其实就是能影响 JS 权限申请状态变化的条件,暂时不看用户申请的部分,先看下管理员审核的部分,因为是人工审核的,所以这就有人为因素的产生,这部分我们在领域模型设计的时候,就没有办法把控,所以可以把这部分排除在领域模型之外,后面 JS 权限申请状态的改变,也是由人为进行导致的,也就是说,对于领域模型来说,我们没有办法进行控制 JS 权限申请状态的改变,所以后面的状态改变我们可以看作是业务流程或者工作流程,有人可能会问:“开通 JS 权限”和“消息通知用户”,算不算是业务?其实这部分可以算是业务,因为它是状态改变后的一种行为,我们可以使用领域事件实现它。

跟踪变化最实用的方法是领域事件和事件存储。我们为领域专家所关心的所以状态改变都创建单独的事件类型,事件的名字和属性表明发生了什么样的事件。当命令操作执行完后,系统发出这些领域事件。事件的订阅方可以接收发生在模型上的所有事件。在接收到事件后,订阅方将事件保存在事件存储中。

有点越说越乱的感觉,先暂时概括下我们设计领域模型所包含的东西:

  • 聚合根和实体:JS 权限申请,命名为 JsPermissionApply,具有唯一性。
  • 值对象:申请状态,命名为 Status,包含待审核、审核通过、审核不通过等。
  • 领域事件:处理 JsPermissionApply 状态改变后的一些工作(开通 JS 权限和发送消息通知)。
  • 领域服务:UserAuthenticationService,验证用户是否合法,以及验证此用户是否能创建 JsPermissionApply。
  • 实体验证:基本验证由 JsPermissionApply 自身负责,在 JsPermissionApply 的构造函数中处理。
  • 实体行为:管理员的审核处理 Process,领域事件在这里触发。

UserAuthenticationService 所做的工作就是上面图中第一个圈和第一个方框的内容,总的概述就是验证用户是否能创建 JsPermissionApply?我之前考虑用工厂实现,但感觉还是不太妥,因为工厂是为创建复杂实体服务的,内部会有一些复杂的操作,对于一些简单的实体创建,我们直接用实体的构造函数进行创建就行,比如 JsPermissionApply 的创建,既然用工厂实现不合适,那直接将操作放在 JsPermissionApply 中会怎样呢?验证自己能否被创建?想想还是有些别扭,所以还是用 UserAuthenticationService 领域服务实现吧,况且领域服务的定义就是如此。

领域服务表示一个无状态的操作,它用于实现特定于某个领域的任务,当某个操作不适合放在聚合和值对象上时,最好的方式便是使用领域服务。

另外,关于实体、值对象、领域事件、领域服务和仓储接口的实现,最好在不同的项目中,如果再同一个项目中的话,可能会造成循环引用的情况,比如仓储接口引用了实体,领域服务引用了仓储接口,如果实体和领域服务实现在同一个项目,就会出现循环引用的问题。

再来总结下,我们分析系统和设计领域模型的步骤:先和领域专家探讨业务系统,经过反反复复的研究,抽离出业务系统的核心业务,然后用战术建模的方式设计领域模型,最后用代码进行实现。领域模型设计好了之后,下面就开始用代码实现了,在代码实现的时候,最好解决方案中只有领域层、基础设施层和单元测试,并且一开始设计的时候,先编写领域层的代码,然后再编写单元测试,最后进行不断的测试和完善,关于数据的持久化,现在最好不要关注,尽量用 Mock 的方式模拟数据。

我先贴一下 JsPermissionApply 实体的部分代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
using CNBlogs.Apply.Domain.DomainEvents;
using CNBlogs.Apply.Domain.ValueObjects;
using System;
using Microsoft.Practices.Unity;
using CNBlogs.Apply.Infrastructure.IoC.Contracts;

namespace CNBlogs.Apply.Domain
{
public class JsPermissionApply : IAggregateRoot
{
private IEventBus eventBus;

public JsPermissionApply()
{ }

public JsPermissionApply(string reason, int userId, string ip)
{
if (string.IsNullOrEmpty(reason))
{
throw new ArgumentException("申请内容不能为空");
}
if (reason.Length > 3000)
{
throw new ArgumentException("申请内容超出最大长度");
}
if (userId == 0)
{
throw new ArgumentException("用户Id为0");
}
this.Reason = reason;
this.UserId = userId;
this.Ip = ip;
this.Status = Status.Wait;
}

public int Id { get; private set; }

public string Reason { get; private set; }

public int UserId { get; private set; }

public Status Status { get; private set; } = Status.Wait;

public string Ip { get; private set; }

public DateTime ApplyTime { get; private set; } = DateTime.Now;

public string ReplyContent { get; private set; }

public DateTime? ApprovedTime { get; private set; }

public bool IsActive { get; private set; } = true;

public void Process(string replyContent, Status status)
{
this.ReplyContent = replyContent;
this.Status = status;
this.ApprovedTime = DateTime.Now;

eventBus = IocContainer.Default.Resolve<IEventBus>();
if (this.Status == Status.Pass)
{
eventBus.Publish(new JsPermissionOpenedEvent() { UserId = this.UserId });
eventBus.Publish(new MessageSentEvent() { Title = "系统通知", Content = "审核通过", RecipientId = this.UserId });
}
else if (this.Status == Status.Deny)
{
eventBus.Publish(new MessageSentEvent() { Title = "系统通知", Content = "审核不通过", RecipientId = this.UserId });
}
}
}
}

JsPermissionApplyTest 单元测试的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
using CNBlogs.Apply.Domain.DomainServices;
using CNBlogs.Apply.Domain.ValueObjects;
using CNBlogs.Apply.Infrastructure.Interfaces;
using CNBlogs.Apply.Infrastructure.IoC.Contracts;
using CNBlogs.Apply.Repository.Interfaces;
using System;
using System.Data.Entity;
using System.Threading.Tasks;
using Xunit;
using Microsoft.Practices.Unity;

namespace CNBlogs.Apply.Domain.Tests
{
public class JsPermissionApplyTest
{
private IUserAuthenticationService _userAuthenticationService;
private IJsPermissionApplyRepository _jsPermissionApplyRepository;
private IUnitOfWork _unitOfWork;

public JsPermissionApplyTest()
{
CNBlogs.Apply.BootStrapper.Startup.Configure();

_userAuthenticationService = IocContainer.Default.Resolve<IUserAuthenticationService>();
_jsPermissionApplyRepository = IocContainer.Default.Resolve<IJsPermissionApplyRepository>();
_unitOfWork = IocContainer.Default.Resolve<IUnitOfWork>();
}

[Fact]
public async Task Apply()
{
var userId = 1;
var verfiyResult = await _userAuthenticationService.Verfiy(userId);
Console.WriteLine(verfiyResult);
Assert.Empty(verfiyResult);

var jsPermissionApply = new JsPermissionApply("我要申请JS权限", userId, "");
_unitOfWork.RegisterNew(jsPermissionApply);
Assert.True(await _unitOfWork.CommitAsync());
}

[Fact]
public async Task ProcessApply()
{
var userId = 1;
var jsPermissionApply = await _jsPermissionApplyRepository.GetByUserId(userId).FirstOrDefaultAsync();
Assert.NotNull(jsPermissionApply);

jsPermissionApply.Process("审核通过", Status.Pass);
_unitOfWork.RegisterDirty(jsPermissionApply);
Assert.True(await _unitOfWork.CommitAsync());
}
}
}

代码我就不分析了,基本上是按照我们上面的设计方案实现的,本来想在仓储层模拟数据的,但时间有限,还是使用的 EF 进行数据的持久化和访问,完整的解决方案目录:

开源地址:https://github.com/yuezhongxin/CNBlogs.Apply.Sample

上面提交的代码,只是一开始的实现,有些地方可能没有考虑全面,代码也可能会有些粗糙,但因为不是简简单单的示例 Demo,而是实际项目,所以后面我会不断的进行完善,大家如果有什么意见或建议,欢迎探讨~~~


实现领域驱动设计的方式有很多种,你可以战略设计、你也可以战术设计、你可以直接面向对象编写代码、你也可以和领域专家只画一张白板图、又或者写一篇分析业务系统的文章,但就像埃文斯(Eric Evans)的书名一样《领域驱动设计:软件核心复杂性应对之道》,领域驱动设计的核心目的,就是应对软件业务系统的复杂性,所以,不管哪种实现方式,只要能达到这个目的,那就是好的实现领域驱动设计的方式。

JS 权限申请和审核系统还没完,下面又要加一个 Blog 地址更改申请和审核系统,它们会碰撞什么样的火花呢?拭目以待吧。😏

上一篇:《DDD 领域驱动设计-如何 DDD?

开源地址:https://github.com/yuezhongxin/CNBlogs.Apply.Sample(代码已更新)

阅读目录:

  • JsPermissionApply 生命周期
  • 改进 JsPermissionApply 实体
  • 重命名 UserAuthenticationService
  • 改进 JsPermissionApplyRepository
  • 改进领域单元测试

如何完善领域模型?指的是完善 JS 权限申请领域模型,也就是 JsPermissionApply Domain Model

在上篇博文中,关于 JsPermissionApply 领域模型的设计,只是一个刚出生的“婴儿”,还不是很成熟,并且很多细致的业务并没有考虑到,本篇将对 JsPermissionApply 领域模型进行完善,如何完善呢?望着解决方案中的项目和代码,好像又束手无策,这时候如果没有一点思考,而是直接编写代码,到最后你会发现 DDD 又变成了脚本式开发,所以,我们在做领域模型开发的时候,需要一个切入点,把更多的精力放在业务上,而不是实现的代码上,那这个切入点是什么呢?没错,就是上篇博文中的“业务流程图”,又简单完善了下:

1. JsPermissionApply 生命周期

在完善 JsPermissionApply 领域模型之前,我们需要先探讨下 JsPermissionApply 实体的生命周期,这个在接下来完善的时候会非常重要,能影响 JsPermissionApply 实体生命周期的唯一因素,就是改变其自身的状态,从上面的业务流程图中,我们就可以看到改变状态的地方:“申请状态为待审核”、“申请状态改为通过”、“申请状态改为未通过”、“申请状态改为锁定”,能改变实体状态的行为都是业务行为,这个在领域模型设计的时候,要重点关注。

用户申请 JS 权限的最终目的是开通 JS 权限,对于 JsPermissionApply 实体而言,就是自身状态为“通过”,所以,我们可以认为,当 JsPermissionApply 实体状态为“通过”的时候,那么 JsPermissionApply 实体的生命周期就结束了,JsPermissionApply 生命周期开始的时候,就是创建 JsPermissionApply 实体对象的时候,也就是实体状态为“待审核”的时候。

好,上面的分析听起来很有道理,感觉应该没什么问题,但在实现 JsPermissionApplyRepository 的时候,就会发现有很多问题(后面会说到),JsPermissionApply 的关键字是 Apply(申请),对于一个申请来说,生命周期的结束就是其经过了审核,不论是通过还是不通过,锁定还是不锁定,这个申请的生命周期就结束了,再次申请就是另一个 JsPermissionApply 实体对象了,对于实体生命周期有效期内,其实体必须是唯一性的。

导致上面两种分析的不同,主要是关注点不同,第一种以用户为中心,第二种以申请为中心,以用户为中心的分析方式,在我们平常的开发过程中会经常遇到,因为我们开发的系统基本上都是给人用的,所以很多业务都是围绕用户进行展开,好像没有什么不对,但如果这样进行分析设计,那么每个系统的核心域都是用户了,领域模型也变成了用户领域模型,所以,我们在分析业务系统的时候,最好进行细分,并把用户的因素隔离开,最后把核心和非核心进行区分开。

2. 改进 JsPermissionApply 实体

先看下之前 JsPermissionApply 实体的部分代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
namespace CNBlogs.Apply.Domain
{
public class JsPermissionApply : IAggregateRoot
{
private IEventBus eventBus;

...

public void Process(string replyContent, Status status)
{
this.ReplyContent = replyContent;
this.Status = status;
this.ApprovedTime = DateTime.Now;

eventBus = IocContainer.Default.Resolve<IEventBus>();
if (this.Status == Status.Pass)
{
eventBus.Publish(new JsPermissionOpenedEvent() { UserId = this.UserId });
eventBus.Publish(new MessageSentEvent() { Title = "系统通知", Content = "审核通过", RecipientId = this.UserId });
}
else if (this.Status == Status.Deny)
{
eventBus.Publish(new MessageSentEvent() { Title = "系统通知", Content = "审核不通过", RecipientId = this.UserId });
}
}
}
}

Process 的设计会让领域专家看不懂,为什么?看下对应的单元测试:

1
2
3
4
5
6
7
8
9
10
11
[Fact]
public async Task ProcessApply()
{
var userId = 1;
var jsPermissionApply = await _jsPermissionApplyRepository.GetByUserId(userId).FirstOrDefaultAsync();
Assert.NotNull(jsPermissionApply);

jsPermissionApply.Process("审核通过", Status.Pass);
_unitOfWork.RegisterDirty(jsPermissionApply);
Assert.True(await _unitOfWork.CommitAsync());
}

Process 是啥?如果领域专家不是开发人员,通过一个申请,他会认为应该有一个直接通过申请的操作,而不是调用一个不知道干啥的 Process 方法,然后再传几个不知道的参数,在 IDDD 书中,代码也是和领域专家交流的通用语言之一,所以,开发人员编写的代码需要让领域专家看懂,至少代码要表达一个最直接的业务操作。

所以,对于申请的处理,通过就是通过,不通过就是不通过,要用代码表达的简单粗暴

改进代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
namespace CNBlogs.Apply.Domain
{
public class JsPermissionApply : IAggregateRoot
{
private IEventBus eventBus;

...

public async Task Pass()
{
this.Status = Status.Pass;
this.ApprovedTime = DateTime.Now;
this.ReplyContent = "恭喜您!您的JS权限申请已通过审批。";

eventBus = IocContainer.Default.Resolve<IEventBus>();
await eventBus.Publish(new JsPermissionOpenedEvent() { UserId = this.UserId });
await eventBus.Publish(new MessageSentEvent() { Title = "您的JS权限申请已批准", Content = this.ReplyContent, RecipientId = this.UserId });
}

public async Task Deny(string replyContent)
{
this.Status = Status.Deny;
this.ApprovedTime = DateTime.Now;
this.ReplyContent = $"抱歉!您的JS权限申请没有被批准,{(string.IsNullOrEmpty(replyContent) ? "" : $"具体原因:{replyContent}<br/>")}麻烦您重新填写申请理由。";

eventBus = IocContainer.Default.Resolve<IEventBus>();
await eventBus.Publish(new MessageSentEvent() { Title = "您的JS权限申请未通过审批", Content = this.ReplyContent, RecipientId = this.UserId });
}

public async Task Lock()
{
this.Status = Status.Lock;
this.ApprovedTime = DateTime.Now;
this.ReplyContent = "抱歉!您的JS权限申请没有被批准,并且申请已被锁定,具体请联系contact@cnblogs.com。";

eventBus = IocContainer.Default.Resolve<IEventBus>();
await eventBus.Publish(new MessageSentEvent() { Title = "您的JS权限申请已被锁定", Content = this.ReplyContent, RecipientId = this.UserId });
}
}
}

这样改进还有一个好处,就是改变 JsPermissionApply 状态会变的更加明了,也更加受保护,什么意思?比如之前的 Process 的方法,我们可以通过参数任意改变 JsPermissionApply 的状态,这是不被允许的,现在我们只能通过三个操作改变对应的三种状态。

JsPermissionApply 实体改变了,对应的单元测试也要进行更新(后面讲到)。

3. 重命名 UserAuthenticationService

UserAuthenticationService 是领域服务,一看到这个命名,会认为这是关于用户验证的服务,我们再看上面的流程图,会发现有一个“验证用户信息”操作,但前面还有一个“验证申请状态”操作,而在之前的设计实现中,这两个操作都是放在 UserAuthenticationService 中的,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
namespace CNBlogs.Apply.Domain.DomainServices
{
public class UserAuthenticationService : IUserAuthenticationService
{
private IJsPermissionApplyRepository _jsPermissionApplyRepository;

public UserAuthenticationService(IJsPermissionApplyRepository jsPermissionApplyRepository)
{
_jsPermissionApplyRepository = jsPermissionApplyRepository;
}

public async Task<string> Verfiy(int userId)
{
if (!await UserService.IsHasBlog(userId))
{
return "必须先开通博客,才能申请JS权限";
}
var entity = await _jsPermissionApplyRepository.GetByUserId(userId).FirstOrDefaultAsync();
if (entity != null)
{
if (entity.Status == Status.Pass)
{
return "您的JS权限申请正在处理中,请稍后";
}
if (entity.Status == Status.Lock)
{
return "您暂时无法申请JS权限,请联系contact@cnblogs.com";
}
}
return string.Empty;
}
}
}

IsHasBlog 属于用户验证,但下面的 jsPermissionApply.Status 验证就不属于了,放在 UserAuthenticationService 中也不合适,我的想法是把这部分验证独立出来,用 ApplyAuthenticationService 领域服务实现,后来仔细一想,似乎和上面实体生命周期遇到的问题有些类似,误把用户当作核心考虑了,在 JS 权限申请和审核系统中,对于用户的验证,其实就是对申请的验证,所验证的最终目的是:某个用户是否符合要求进行申请操作?

所以,对于申请相关的验证操作,应该命名为 ApplyAuthenticationService,并且验证代码都放在其中。

改进代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
namespace CNBlogs.Apply.Domain.DomainServices
{
public class ApplyAuthenticationService : IApplyAuthenticationService
{
private IJsPermissionApplyRepository _jsPermissionApplyRepository;

public ApplyAuthenticationService(IJsPermissionApplyRepository jsPermissionApplyRepository)
{
_jsPermissionApplyRepository = jsPermissionApplyRepository;
}

public async Task<string> Verfiy(int userId)
{
if (!await UserService.IsHasBlog(userId))
{
return "必须先开通博客,才能申请JS权限";
}
var entity = await _jsPermissionApplyRepository.GetEffective(userId).FirstOrDefaultAsync();
if (entity != null)
{
if (entity.Status == Status.Pass)
{
return "您的JS权限申请已开通,请勿重复申请";
}
if (entity.Status == Status.Wait)
{
return "您的JS权限申请正在处理中,请稍后";
}
if (entity.Status == Status.Lock)
{
return "您暂时无法申请JS权限,请联系contact@cnblogs.com";
}
}
return string.Empty;
}
}
}

除了 UserAuthenticationService 重命名为 ApplyAuthenticationService,还增加了对 JsPermissionApply 状态为 Lock 的验证,并且 IJsPermissionApplyRepository 的 GetByUserId 调用改为了 GetEffective,这个下面会讲到。

4. 改进 JsPermissionApplyRepository

原先的 IJsPermissionApplyRepository 设计:

1
2
3
4
5
6
7
namespace CNBlogs.Apply.Repository.Interfaces
{
public interface IJsPermissionApplyRepository : IRepository<JsPermissionApply>
{
IQueryable<JsPermissionApply> GetByUserId(int userId);
}
}

这样的 IJsPermissionApplyRepository 的设计,看似没什么问题,并且问题也不出现在实现,而是出现在调用的时候,GetByUserId 会在两个地方调用:

  • ApplyAuthenticationService.Verfiy 调用:获取 JsPermissionApply 实体对象,用于状态的验证,判断是否符合申请的要求。
  • 领域的单元测试代码中(或者应用层):获取 JsPermissionApply 实体对象,用于更新其状态。

对于上面两个调用方来说,GetByUserId 太模糊了,甚至不知道调用的是什么东西?并且这两个地方的调用,获取的 JsPermissionApply 实体对象也并不相同,严格来说,应该是不同状态下的 JsPermissionApply 实体对象,我们仔细分析下:

  • ApplyAuthenticationService.Verfiy 调用:判断是否符合申请的要求。什么情况下会符合申请要求呢?就是当状态为“未通过”的时候,对于申请验证来说,可以称之为“有效的”申请,相反,获取用于申请验证的 JsPermissionApply 实体对象,应该称为“无效的”,调用命名为 GetInvalid
  • 领域的单元测试代码中(或者应用层):用于更新 JsPermissionApply 实体状态。什么状态下的 JsPermissionApply 实体,可以更新其状态呢?答案就是状态为“待审核”,所以这个调用应该获取状态为“待审核”的 JsPermissionApply 实体对象,调用命名为 GetWaiting

改进代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
namespace CNBlogs.Apply.Repository
{
public class JsPermissionApplyRepository : BaseRepository<JsPermissionApply>, IJsPermissionApplyRepository
{
public JsPermissionApplyRepository(IDbContext dbContext)
: base(dbContext)
{ }

public IQueryable<JsPermissionApply> GetInvalid(int userId)
{
return _entities.Where(x => x.UserId == userId && x.Status != Status.Deny && x.IsActive);
}

public IQueryable<JsPermissionApply> GetWaiting(int userId)
{
return _entities.Where(x => x.UserId == userId && x.Status == Status.Wait && x.IsActive);
}
}
}

5. 改进领域单元测试

原先的单元测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
namespace CNBlogs.Apply.Domain.Tests
{
public class JsPermissionApplyTest
{
private IUserAuthenticationService _userAuthenticationService;
private IJsPermissionApplyRepository _jsPermissionApplyRepository;
private IUnitOfWork _unitOfWork;

public JsPermissionApplyTest()
{
CNBlogs.Apply.BootStrapper.Startup.Configure();

_userAuthenticationService = IocContainer.Default.Resolve<IUserAuthenticationService>();
_jsPermissionApplyRepository = IocContainer.Default.Resolve<IJsPermissionApplyRepository>();
_unitOfWork = IocContainer.Default.Resolve<IUnitOfWork>();
}

[Fact]
public async Task Apply()
{
var userId = 1;
var verfiyResult = await _userAuthenticationService.Verfiy(userId);
Console.WriteLine(verfiyResult);
Assert.Empty(verfiyResult);

var jsPermissionApply = new JsPermissionApply("我要申请JS权限", userId, "");
_unitOfWork.RegisterNew(jsPermissionApply);
Assert.True(await _unitOfWork.CommitAsync());
}

[Fact]
public async Task ProcessApply()
{
var userId = 1;
var jsPermissionApply = await _jsPermissionApplyRepository.GetByUserId(userId).FirstOrDefaultAsync();
Assert.NotNull(jsPermissionApply);

jsPermissionApply.Process("审核通过", Status.Pass);
_unitOfWork.RegisterDirty(jsPermissionApply);
Assert.True(await _unitOfWork.CommitAsync());
}
}
}

看起来似乎没什么问题,一个申请和一个审核测试,但我们仔细看上面的业务流程图,会发现这个测试代码并不能完全覆盖所有的业务,并且这个测试代码也有些太敷衍了,在测试驱动开发中,测试代码就是所有的业务表达,它应该是项目中最全面和最精细的代码,在领域驱动设计中,当领域层的代码完成后,领域专家查看的时候,不会看领域层,而是直接看单元测试中的代码,因为领域专家不懂代码,并且他也不懂你是如何实现的,它关心的是我该如何使用它?我想要的业务操作,你有没有完全实现?单元测试就是最好的体现。

我们该如何改进呢?还是回归到上面的业务流程图,并从中归纳出领域专家想要的几个操作:

  • 填写 JS 权限申请(需要填写申请理由)
  • 通过 JS 权限申请
  • 拒绝 JS 权限申请(需要填写拒绝原因)
  • 锁定 JS 权限申请
  • 删除(待考虑)

上面这几个操作,都必须在单元测试代码中有所体现,并且尽量让测试颗粒化,比如一个验证操作,你可以对不同的参数编写不同的单元测试代码。

改进代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
namespace CNBlogs.Apply.Domain.Tests
{
public class JsPermissionApplyTest
{
private IApplyAuthenticationService _applyAuthenticationService;
private IJsPermissionApplyRepository _jsPermissionApplyRepository;
private IUnitOfWork _unitOfWork;

public JsPermissionApplyTest()
{
CNBlogs.Apply.BootStrapper.Startup.Configure();

_applyAuthenticationService = IocContainer.Default.Resolve<IApplyAuthenticationService>();
_jsPermissionApplyRepository = IocContainer.Default.Resolve<IJsPermissionApplyRepository>();
_unitOfWork = IocContainer.Default.Resolve<IUnitOfWork>();
}

[Fact]
public async Task ApplyTest()
{
var userId = 1;
var verfiyResult = await _applyAuthenticationService.Verfiy(userId);
Console.WriteLine(verfiyResult);
Assert.Empty(verfiyResult);

var jsPermissionApply = new JsPermissionApply("我要申请JS权限", userId, "");
_unitOfWork.RegisterNew(jsPermissionApply);
Assert.True(await _unitOfWork.CommitAsync());
}

[Fact]
public async Task ProcessApply_WithPassTest()
{
var userId = 1;
var jsPermissionApply = await _jsPermissionApplyRepository.GetWaiting(userId).FirstOrDefaultAsync();
Assert.NotNull(jsPermissionApply);

await jsPermissionApply.Pass();
_unitOfWork.RegisterDirty(jsPermissionApply);
Assert.True(await _unitOfWork.CommitAsync());
}

[Fact]
public async Task ProcessApply_WithDenyTest()
{
var userId = 1;
var jsPermissionApply = await _jsPermissionApplyRepository.GetWaiting(userId).FirstOrDefaultAsync();
Assert.NotNull(jsPermissionApply);

await jsPermissionApply.Deny("理由太简单了。");
_unitOfWork.RegisterDirty(jsPermissionApply);
Assert.True(await _unitOfWork.CommitAsync());
}

[Fact]
public async Task ProcessApply_WithLockTest()
{
var userId = 1;
var jsPermissionApply = await _jsPermissionApplyRepository.GetWaiting(userId).FirstOrDefaultAsync();
Assert.NotNull(jsPermissionApply);

await jsPermissionApply.Lock();
_unitOfWork.RegisterDirty(jsPermissionApply);
Assert.True(await _unitOfWork.CommitAsync());
}
}
}

改进好了代码之后,对于开发人员来说,任务似乎完成了,但对于领域专家来说,仅仅是个开始,因为他必须要通过提供的四个操作,来验证各种情况下的业务操作是否正确,我们来归纳下:

  • 申请 -> 申请:ApplyTest -> ApplyTest
  • 申请 -> 通过:ApplyTest -> ProcessApply_WithPassTest
  • 申请 -> 拒绝:ApplyTest -> ProcessApply_WithDenyTest
  • 申请 -> 锁定:ApplyTest -> ProcessApply_WithLockTest
  • 申请 -> 通过 -> 申请:ApplyTest -> ProcessApply_WithPassTest -> ApplyTest
  • 申请 -> 拒绝 -> 申请:ApplyTest -> ProcessApply_WithDenyTest -> ApplyTest
  • 申请 -> 锁定 -> 申请:ApplyTest -> ProcessApply_WithLockTest -> ApplyTest

确认上面的所有测试都通过之后,就说明 JsPermissionApply 领域模型设计的还算可以。

DDD 倾向于“测试先行,逐步改进”的设计思路。测试代码本身便是通用语言在程序中的表达,在开发人员的帮助下,领域专家可以阅读测试代码来检验领域对象是否满足业务需求。

当领域层的代码基本完成之后,就可以在地基上添砖加瓦了,后面的实现都是工作流程的实现,没有任何业务的包含,比如上面对领域层的单元测试,其实就是应用层的实现,在添砖加瓦的过程中,切记地基的重要性,否则即使盖再高的摩天大楼,地基不稳,也照样垮塌。

实际项目的 DDD 应用很有挑战,也会很有意思。😏


无意间发现了 Visual Studio 2015 Update 2 一个很实用的功能:

上一篇:《DDD 领域驱动设计-如何完善 Domain Model(领域模型)?

开源地址:https://github.com/yuezhongxin/CNBlogs.Apply.Sample(代码已更新)

需要注意的是,业务流程并不是工作流程,在领域模型中,业务流程的控制很重要,在上篇的领域模型中我们就忽略了这一点,所以在后面的实现中,出现了一些严重的问题,主要是管理员审核 JS 权限申请的业务流程

先看一下 JsPermissionApply 实体中的 Pass 操作代码:

1
2
3
4
5
6
7
8
9
10
public async Task Pass()
{
this.Status = Status.Pass;
this.ApprovedTime = DateTime.Now;
this.ReplyContent = "恭喜您!您的JS权限申请已通过审批。";

eventBus = IocContainer.Default.Resolve<IEventBus>();
await eventBus.Publish(new JsPermissionOpenedEvent() { UserId = this.UserId });
await eventBus.Publish(new MessageSentEvent() { Title = "您的JS权限申请已批准", Content = this.ReplyContent, RecipientId = this.UserId });
}

对应的单元测试代码:

1
2
3
4
5
6
7
8
9
10
11
[Fact]
public async Task ProcessApply_WithPassTest()
{
var userId = 1;
var jsPermissionApply = await _jsPermissionApplyRepository.GetWaiting(userId).FirstOrDefaultAsync();
Assert.NotNull(jsPermissionApply);

await jsPermissionApply.Pass();
_unitOfWork.RegisterDirty(jsPermissionApply);
Assert.True(await _unitOfWork.CommitAsync());
}

有没有发现一些问题?开通 JS 权限和消息通知,发生在 JsPermissionApply 实体对象持久化之前,这本身的设计就有问题,另外,如果 JsPermissionApply 实体对象持久化失败的话,开通 JS 权限和消息通知会正常执行,相反,开通 JS 权限和消息通知如果出现问题,JsPermissionApply 实体对象持久化也会不受影响,还有就是开通 JS 权限和消息通知放在一起也会有问题。

造成上面这些问题的原因,就是我们之前画的业务流程图太敷衍了,没有具体的进行细化设计,针对管理员审核 JS 权限申请的业务流程,我们再详细的画一下:

可以看到,管理员审核通过 JS 权限申请,JS 权限申请的状态改为“通过”,再开通 JS 权限,然后持久化 JS 权限申请,最后再消息通知用户,整个 JS 权限申请通过的业务流程顺序应该是这样的,对照上面这张图,再看之前的实现,确实牛头不对马尾。

简单总结下审核通过 JS 权限申请的业务流程顺序:

  1. JS 权限申请状态改为“通过”。
  2. 开通 JS 权限。
  3. 消息通知用户。

好,来看一下改进后的 JsPermissionApply 实体代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
namespace CNBlogs.Apply.Domain
{
public class JsPermissionApply : IAggregateRoot
{
private IEventBus eventBus;

public JsPermissionApply()
{ }

public JsPermissionApply(string reason, int userId, string ip)
{
if (string.IsNullOrEmpty(reason))
{
throw new ArgumentException("申请内容不能为空");
}
if (reason.Length > 3000)
{
throw new ArgumentException("申请内容超出最大长度");
}
if (userId == 0)
{
throw new ArgumentException("用户Id为0");
}
this.Reason = reason;
this.UserId = userId;
this.Ip = ip;
this.Status = Status.Wait;
}

public int Id { get; private set; }

public string Reason { get; private set; }

public int UserId { get; private set; }

public Status Status { get; private set; } = Status.Wait;

public string Ip { get; private set; }

public DateTime ApplyTime { get; private set; } = DateTime.Now;

public string ReplyContent { get; private set; }

public DateTime? ApprovedTime { get; private set; }

public bool IsActive { get; private set; } = true;

public async Task<bool> Pass()
{
if (this.Status != Status.Wait)
{
return false;
}
this.Status = Status.Pass;
this.ApprovedTime = DateTime.Now;
this.ReplyContent = "恭喜您!您的JS权限申请已通过审批。";
eventBus = IocContainer.Default.Resolve<IEventBus>();
await eventBus.Publish(new JsPermissionOpenedEvent() { UserId = this.UserId });
return true;
}

public bool Deny(string replyContent)
{
if (this.Status != Status.Wait)
{
return false;
}
this.Status = Status.Deny;
this.ApprovedTime = DateTime.Now;
this.ReplyContent = $"抱歉!您的JS权限申请没有被批准,{(string.IsNullOrEmpty(replyContent) ? "" : $"具体原因:{replyContent}<br/>")}麻烦您重新填写申请理由。";
return true;
}

public bool Lock()
{
if (this.Status != Status.Wait)
{
return false;
}
this.Status = Status.Lock;
this.ApprovedTime = DateTime.Now;
this.ReplyContent = "抱歉!您的JS权限申请没有被批准,并且申请已被锁定,具体请联系contact@cnblogs.com。";
return true;
}

public async Task Passed()
{
if (this.Status != Status.Pass)
{
return;
}
eventBus = IocContainer.Default.Resolve<IEventBus>();
await eventBus.Publish(new MessageSentEvent() { Title = "您的JS权限申请已批准", Content = this.ReplyContent, RecipientId = this.UserId });
}

public async Task Denied()
{
if (this.Status != Status.Deny)
{
return;
}
eventBus = IocContainer.Default.Resolve<IEventBus>();
await eventBus.Publish(new MessageSentEvent() { Title = "您的JS权限申请未通过审批", Content = this.ReplyContent, RecipientId = this.UserId });
}

public async Task Locked()
{
if (this.Status != Status.Lock)
{
return;
}
eventBus = IocContainer.Default.Resolve<IEventBus>();
await eventBus.Publish(new MessageSentEvent() { Title = "您的JS权限申请未通过审批", Content = this.ReplyContent, RecipientId = this.UserId });
}
}
}

Passed, Denied, Locked 都是过去式,表示 Pass, Deny, Lock 操作完成之后的行为,可以看到,在这些操作的内容都有 Status 状态的判断,验证的是什么状态下的 JsPermissionApply 才能执行此行为,任何不符合状态的执行都是不合法的,比如执行 Pass 的前提条件是 Status 状态为 Wait,表示只有 Status 状态为 Wait 的时候,才能执行 Pass 并修改其状态,执行 Passed 的前提前提条件是 Status 状态为 Passed,意思就像其命名 Passed 一样,无需多说。

上面最重要的是开通 JS 权限的执行,因为这是 JS 权限申请最终的执行结果,所以我们后面的操作,都必须建立在其成功的基础之上,那有人会有疑问:为什么上面的业务流程顺序不是这样的呢?当申请状态改为“通过”之后,我们才能去开通 JS 权限,这是开通 JS 权限的前提条件,这时候 JS 权限申请状态是没有被持久化的,所以,如果开通 JS 权限失败,JS 权限申请状态是不会被保存的,另外,开通 JS 权限的领域事件并没有返回值,领域事件一般没有返回值的设计,它只是去通知事件订阅者执行,并不一定需要事件订阅者返回结果给它,那我们如果判断开通 JS 权限是否执行正确呢?就是通过异常判断,如果开通 JS 权限的领域事件发生异常,后面的操作也将不会正常执行。

改进后的 JsPermissionApplyTest 单元测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
namespace CNBlogs.Apply.Domain.Tests
{
public class JsPermissionApplyTest
{
private IApplyAuthenticationService _applyAuthenticationService;
private IJsPermissionApplyRepository _jsPermissionApplyRepository;
private IUnitOfWork _unitOfWork;

public JsPermissionApplyTest()
{
CNBlogs.Apply.BootStrapper.Startup.Configure();

_applyAuthenticationService = IocContainer.Default.Resolve<IApplyAuthenticationService>();
_jsPermissionApplyRepository = IocContainer.Default.Resolve<IJsPermissionApplyRepository>();
_unitOfWork = IocContainer.Default.Resolve<IUnitOfWork>();
}

[Fact]
public async Task ApplyTest()
{
var userId = 1;
var verfiyResult = await _applyAuthenticationService.Verfiy(userId);
Console.WriteLine(verfiyResult);
Assert.Empty(verfiyResult);

var jsPermissionApply = new JsPermissionApply("我要申请JS权限", userId, "");
_unitOfWork.RegisterNew(jsPermissionApply);
Assert.True(await _unitOfWork.CommitAsync());
}

[Fact]
public async Task ProcessApply_WithPassTest()
{
var userId = 1;
var jsPermissionApply = await _jsPermissionApplyRepository.GetWaiting(userId).FirstOrDefaultAsync();
Assert.NotNull(jsPermissionApply);

Assert.True(await jsPermissionApply.Pass());
_unitOfWork.RegisterDirty(jsPermissionApply);
Assert.True(await _unitOfWork.CommitAsync());
await jsPermissionApply.Passed();
}

[Fact]
public async Task ProcessApply_WithDenyTest()
{
var userId = 1;
var jsPermissionApply = await _jsPermissionApplyRepository.GetWaiting(userId).FirstOrDefaultAsync();
Assert.NotNull(jsPermissionApply);

Assert.True(jsPermissionApply.Deny("理由太简单了。"));
_unitOfWork.RegisterDirty(jsPermissionApply);
Assert.True(await _unitOfWork.CommitAsync());
await jsPermissionApply.Denied();
}

[Fact]
public async Task ProcessApply_WithLockTest()
{
var userId = 1;
var jsPermissionApply = await _jsPermissionApplyRepository.GetWaiting(userId).FirstOrDefaultAsync();
Assert.NotNull(jsPermissionApply);

Assert.True(jsPermissionApply.Lock());
_unitOfWork.RegisterDirty(jsPermissionApply);
Assert.True(await _unitOfWork.CommitAsync());
await jsPermissionApply.Locked();
}
}
}

从上面代码,我们可以清晰看到业务流程的执行顺序,Assert.NotNullAssert.True 就相当于应用层中的 if 判断,如果正确,则继续向下执行。


JsPermissionApply 领域模型经过三篇博文的完善,基本上符合要求了。

在解决方案中,我们可以看到只有领域层、基础设施层和领域层单元测试的项目,并没有应用层和表现层的实现,但到目前为止,我们似乎把整个系统都完成了一样,这种感觉是很美妙的,JsPermissionApply 领域模型在我手心中,任你是 Web 实现或者 WebApi 实现,又或者是其他技术框架,我都不怕,一切都是自然而然的工作,所以,关于后面的实现,你也可以交给其他人去完成,地基由我奠基,盖楼你来完成。

尽管这个系统很简单,但 DDD 确实是一种很美妙的艺术。😏

好久没写 DDD 领域驱动设计相关的文章了,嘎嘎!!!

这几天在开发一个新的项目,虽然不是基于领域驱动设计的,但我想把 DDD 架构设计的一些东西运用在上面,但发现了很多问题,这些在之前的短消息项目中也有,比如我一直想重构短消息 Repository 实现的一些东西,但之前完全没有头绪,因为内部的实现错综复杂,牵一发而动全身,不知道从哪下手。

正好这次新项目的开发,让我一步一步代码设计,所以之前疑惑的问题,可以很清晰的分析并解决,解决问题的过程最终形成了一个 DDD 框架示例,大家可以参考下:

开源地址:https://github.com/yuezhongxin/DDD.Sample

1. 一点一滴-疑惑出现

疑惑就是 Repository 和 IUnitOfWork,以及 Application Service 的调用,这样说可能很笼统,其实就是这三者如何更好的结合使用?并且让它们各司其职,发挥出自己的最大作用,下面我举个例子,可能大家更好理解一些。

首先,关于 IUnitOfWork 的定义实现,网上我搜了很多,很多都太一样,比如有人这样定义:

1
2
3
4
5
6
7
8
9
10
public interface IUnitOfWork
{
IQueryable<TEntity> Set<TEntity>() where TEntity : class;
TEntity Add<TEntity>(TEntity entity) where TEntity : class;
TEntity Attach<TEntity>(TEntity entity) where TEntity : class;
TEntity Remove<TEntity>(TEntity entity) where TEntity : class;
void Commit();
void Rollback();
IDbContext Context { get; set; }
}

是不是感觉有点像 EF 中的 DbContext 呢?所以这也是一个疑惑点,IUnitOfWork 和 DbContext 是什么关系?比如有很多人疑惑:EF 中有 SaveChanges,为什么还有包裹一层 IUnitOfWork?这个问题之前已经讨论了无数次,但这些都是纸上进行的,如果你实践起来可能会是另一种感受。

如果 IUnitOfWork 按照上面的代码进行设计,那 Repository 会是什么样的呢?我们来看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class StudentRepository: IStudentRepository
{
private IUnitOfWork _unitOfWork;

public StudentRepository(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}

public Student Get(int id)
{
return _unitOfWork.Set<Student>().Where(x => x.Id == id).FirstOrDefault();
}

public void Add(Student student)
{
return _unitOfWork.Set<Student>().Add(student);
}


}

上面是 Repository 的一种设计,也有人会这样定义:private IQueryable<Student> _students;,然后在 StudentRepository 构造函数中进行赋值,但不管怎么设计,我们一般会将 Repository 和 IUnitOfWork 结合起来使用,这是一个重要的疑惑点:Repository 和 IUnitOfWork 真的有关系么???

另外,关于 Repository 返回 IQueryable?还是 IEnumerable?可以参考之前的一篇博文,这里我采用的是“概念上的合理”,即 Not IQueryable。

如果 Repository 按照上面的代码进行设计,那 Application Service 会是什么样的呢?我们来看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class StudentService : IStudentService
{
private IUnitOfWork _unitOfWork;
private IStudentRepository _studentRepository;

public StudentService(IUnitOfWork unitOfWork,
IStudentRepository studentRepository)
{
_unitOfWork = unitOfWork;
_studentRepository = studentRepository;
}

public Student Get(int id)
{

return _studentRepository.Get(id);
}

public bool Add(Student student)
{

_studentRepository.Add(student);
return _unitOfWork.Commit();
}


}

看到上面的代码,我想你应该明白到底疑惑什么了?在 StudentService 中,StudentRepository 似乎变得有些多余,因为它所做的,UnitOfWork 也都可以做,随着项目的复杂,这样就会造成很多的问题,比如:

  • IUnitOfWork 的职责不明确。
  • Repository 的职责不明确。
  • Application Service 很困惑,因为它不知道该使用谁。
  • Application Service 的代码越来越乱。
  • ….

2. 一步一步-分析解决

其实问题是可以进行溯源,如果一开始的设计变的很糟,那么接下来相关的其它设计,也会变的很糟,我们可以发现,上面出现问题的根源,其实就是一开始 IUnitOfWork 的设计问题,网上有关 IUnitOfWork 的设计实现,简直五花八门,那我们应该相信谁呢?我们应该相信一开始关于 IUnitOfWork 的定义:http://martinfowler.com/eaaCatalog/unitOfWork.html

  • Unit of Work:维护受业务事务影响的对象列表,并协调变化的写入和并发问题的解决。工作单元记录在业务事务过程中对数据库有影响的所有变化,操作结束后,作为一种结果,工作单元了解所有需要对数据库做的改变,统一对数据库操作。

上面的文字定义要结合 IUnitOfWork 图中的实现,可以更好的理解一些。

我们发现,它和我们一开始的定义差别很大,比如:IQueryable<TEntity> Set<TEntity>() 这样的定义实现,如果结合上面的定义就不是很恰当,IUnitOfWork 是记录业务事务过程中对象列表的改变,平常我们所说对数据的增删改查,你可以理解为 IUnitOfWork 和增删改有关,和查询不太相关。

所以,我们完全按照定义,再重新实现一次 IUnitOfWork:

1
2
3
4
5
6
7
8
9
public interface IUnitOfWork
{
void RegisterNew<TEntity>(TEntity entity) where TEntity : class;
void RegisterDirty<TEntity>(TEntity entity) where TEntity : class;
void RegisterClean<TEntity>(TEntity entity) where TEntity : class;
void RegisterDeleted<TEntity>(TEntity entity) where TEntity : class;
bool Commit();
void Rollback();
}

你可以看到,我们完全按照定义进行实现的,甚至是接口名字都一样,下面我们看一下 IUnitOfWork 的具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class EfUnitOfWork : DbContext, IUnitOfWork
{
public EfUnitOfWork()
: base("name=db_school")
{ }

protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
modelBuilder.Entity<Student>();
modelBuilder.Entity<Teacher>();

base.OnModelCreating(modelBuilder);
}

public void RegisterNew<TEntity>(TEntity entity)
where TEntity : class
{
base.Set<TEntity>().Add(entity);
}

public void RegisterDirty<TEntity>(TEntity entity)
where TEntity : class
{
base.Entry<TEntity>(entity).State = EntityState.Modified;
}

public void RegisterClean<TEntity>(TEntity entity)
where TEntity : class
{
base.Entry<TEntity>(entity).State = EntityState.Unchanged;
}

public void RegisterDeleted<TEntity>(TEntity entity)
where TEntity : class
{
base.Set<TEntity>().Remove(entity);
}

public bool Commit()
{
return base.SaveChanges() > 0;
}

public void Rollback()
{
throw new NotImplementedException();
}
}

EfUnitOfWork 继承 DbContext 和 IUnitOfWork,使用 EF 作为对象管理和持久化,这样实现好像没有什么问题,我们一般也是这样做的,其实,这是一个坑,我们后面会讲到,另外,之前说到 EF 中有 SaveChanges,为什么还有包裹一层 IUnitOfWork?其实就是说的上面代码,因为所有的 IUnitOfWork 的实现,我们都是使用的 EF,既然如此,为啥不把 IUnitOfWork 这个空壳拿掉呢?有人会说了,IUnitOfWork 是 DDD 中的概念,巴拉巴拉,不能拿掉,要不然就不是 DDD 了呢?

上面的问题先放在这个,如果 EfUnitOfWork 这样实现 IUnitOfWork,那 Repository 会怎样?看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class StudentRepository: IStudentRepository
{
private IQueryable<Student> _students;

public StudentRepository(IUnitOfWork unitOfWork)
{
_students = unitOfWork.Set<Student>();
}

public Student Get(int id)
{
return _students.Where(x => x.Id == id).FirstOrDefault();
}


}

代码进行到这,突然进行不下去了,咋办呢?如果你回过头去修改 IUnitOfWork 的接口,比如增加 Set<TEntity>(),这时候将又回到开始的问题,一切将前功尽弃,这么解决呢?这时候要停下来,思考 Repository 和 IUnitOfWork 的关系,也就是之前提到的,它们俩有关系么???

IUnitOfWork 的定义上面说过了,我们再看下 Repository 的定义:

  • Repository:协调领域和数据映射层,利用类似于集合的接口来访问领域对象。

重点在于访问,Repository 是用来访问领域对象的,所以,之前我们在 IRepository 中定义 Add、Update、Renove 等等接口,我觉得这些不是很恰当,因为对象列表的更改,我们可以用 IUnitOfWork 记录和实现,Repository 和 IUnitOfWork 应该是平级的概念,如果在 Repository 中去使用 IUnitOfWork,就有点违背其定义了。

IUnitOfWork 有其 EfUnitOfWork 的实现,难道我们还要搞一个 IRepository 对应的 EfRepository 实现?很显然,如果这样设计是非常冗余的,这时候,你是不是想到了我们还没有提到的 IDbContext 呢???没错就是它,让 UnitOfWork 和 Repository 都依赖于 IDbContext,而不是依赖于具体的 EF,这样也就没有了之前一直提到的 UnitOfWork 和 EF 的问题,具体怎么做呢?我们得先定义 IDbContext:

1
2
3
4
5
6
7
8
9
10
public interface IDbContext
{
DbSet<TEntity> Set<TEntity>()
where TEntity : class;

DbEntityEntry<TEntity> Entry<TEntity>(TEntity entity)
where TEntity : class;

int SaveChanges();
}

IDbContext 的作用,就是提供对象列表的一切操作接口,接下来实现一个 SchoolDbContext:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class SchoolDbContext : DbContext, IDbContext
{
public SchoolDbContext()
: base("name=db_school")
{ }

protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
modelBuilder.Entity<Student>();
modelBuilder.Entity<Teacher>();

base.OnModelCreating(modelBuilder);
}
}

SchoolDbContext 有点像我们之前的 EfUnitOfWork,但它和 IUnitOfWork 没有任何关系,它的作用就是提供具体的对象持久化和访问,我们一般会放在 Infrastructure 层。另外,可以看到 SchoolDbContext 似乎并没有实现 IDbContext 的接口,为什么呢?因为我们继承了 DbContext,这些接口都在 DbContext 中进行进行实现了,你可以按 F12 进行查看。

接下来我们要对 EfUnitOfWork 进行改造了,IUnitOfWork 和 EF 已经没有半毛钱关系了,所以我们命名直接去掉 Ef,具体实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class UnitOfWork : IUnitOfWork
{
private IDbContext _dbContext;

public UnitOfWork(IDbContext dbContext)
{
_dbContext = dbContext;
}

public void RegisterNew<TEntity>(TEntity entity)
where TEntity : class
{
_dbContext.Set<TEntity>().Add(entity);
}

public void RegisterDirty<TEntity>(TEntity entity)
where TEntity : class
{
_dbContext.Entry<TEntity>(entity).State = EntityState.Modified;
}

public void RegisterClean<TEntity>(TEntity entity)
where TEntity : class
{
_dbContext.Entry<TEntity>(entity).State = EntityState.Unchanged;
}

public void RegisterDeleted<TEntity>(TEntity entity)
where TEntity : class
{
_dbContext.Set<TEntity>().Remove(entity);
}

public bool Commit()
{
return _dbContext.SaveChanges() > 0;
}

public void Rollback()
{
throw new NotImplementedException();
}
}

UnitOfWork 脱离了 EF, 是不是有种小清新的感觉?UnitOfWork 依赖于 IDbContext,所以,UnitOfWork 并不关心用哪种具体技术进行实现,你只需要给我对象访问和持久化接口就可以了,下面再看一下之前进行不下去的 Repository:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class StudentRepository: IStudentRepository
{
private IQueryable<Student> _students;

public StudentRepository(IDbContext dbContext)
{
_students = dbContext.Set<Student>();
}

public Student Get(int id)
{
return _students.Where(x => x.Id == id).FirstOrDefault();
}
}

Repository 脱离了 IUnitOfWork,也有种小清新的感觉,Repository 的定义中说到,访问领域对象的集合,这个我们可以从 IDbContext 中进行操作,再来看一下 Application Service 中的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class StudentService : IStudentService
{
private IUnitOfWork _unitOfWork;
private IStudentRepository _studentRepository;
private ITeacherRepository _teacherRepository;

public StudentService(IUnitOfWork unitOfWork,
IStudentRepository studentRepository,
ITeacherRepository teacherRepository)
{
_unitOfWork = unitOfWork;
_studentRepository = studentRepository;
_teacherRepository = teacherRepository;
}

public Student Get(int id)
{
return _studentRepository.Get(id);
}

public bool Add(string name)
{
var student = new Student { Name = name };
var teacher = _teacherRepository.Get(1);
teacher.StudentCount++;

_unitOfWork.RegisterNew(student);
_unitOfWork.RegisterDirty(teacher);
return _unitOfWork.Commit();
}
}

需要仔细看下 Add 中的方法,为了测试同一上下文中不同实体的操作,所以,我后面又增加了 Teacher 实体,因为这个方法比较有代表性,我大致分解下过程:

  1. 通过 _teacherRepository 获取 Teacher 对象,注意这个操作通过 TeacherRepository 中的 IDbContext 完成。
  2. teacher 对象的学生数量 +1。
  3. 通过 _unitOfWork.RegisterNew 标识添加实体对象 student。
  4. 通过 _unitOfWork.RegisterDirty 标识修改实体对象 teacher。
  5. 通过 _unitOfWork.Commit 提交更改。

这个方法测试的主要目的是,通过 Repository 获取对象,并进行相应修改,然后用 UnitOfWork 提交修改,另外,在这个过程中,也会有其它对象的一些操作,测试是可行的,可以很好的避免之前修改 Student 需要通过 StudentRepository,修改 Teacher 需要通过 TeacherRepository,然后 Commit 两次,别问我为什么?因为我之前就这样干过。。。

最后的最后,附上测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class StudentServiceTest
{
private IStudentService _studentService;

public StudentServiceTest()
{
var container = new UnityContainer();
container.RegisterType<IDbContext, SchoolDbContext>();
container.RegisterType<IUnitOfWork, UnitOfWork>();
container.RegisterType<IStudentRepository, StudentRepository>();
container.RegisterType<ITeacherRepository, TeacherRepository>();
container.RegisterType<IStudentService, StudentService>();

_studentService = container.Resolve<IStudentService>();
}

[Fact]
public void GetByIdTest()
{
var student = _studentService.Get(1);
Assert.NotNull(student);
}

[Fact]
public void AddTest()
{
var result = _studentService.Add("xishuai");
Assert.True(result);
}
}

DDD 领域驱动设计-谈谈 Repository、IUnitOfWork 和 IDbContext 的实践(2) - 田园里的蟋蟀 - 博客园

Excerpt

上一篇:《DDD 领域驱动设计-谈谈 Repository、IUnitOfWork 和 IDbContext 的实践(1)》,阅读目录:抽离 IRepository 并改造 Repository,IUnitOfWork 和 Application Service 的变化,总结三种设计方案,简单总结上


上一篇:《DDD 领域驱动设计-谈谈 Repository、IUnitOfWork 和 IDbContext 的实践(1)

阅读目录:

  1. 抽离 IRepository 并改造 Repository

  2. IUnitOfWork 和 Application Service 的变化

  3. 总结三种设计方案

简单总结上篇所做的两个改进:

  • 从 Repository 和 UnitOfWork 中抽离出 IDbContext,并且它们只依赖于 IDbContext。
  • Repository 和 UnitOfWork 为平级关系,UnitOfWork 负责维护对象状态(增删改),Repository 负责获取对象(查)。

后来,园友 Qlin 在评论中,提出了另外一种方式,大致为:

  • Repository 和 UnitOfWork 还是依赖于 IDbContext。
  • UnitOfWork 只有 Commit,Repository 提供对象的所有操作(增删改查)。

这篇文章我们就按照这种方式实现一下,关于 Repository、IUnitOfWork 和 IDbContext 的设计,以及 Application Service 的调用,上面是两种设计方案,加上上一篇博文开头说到的一种方案,我大致总结了三种,关于它们的优缺点,文章最后我再进行总结。

另外,关于 IDbContext 的接口设计,其实是有些模糊的,因为它并没有真正解耦 EF,比如 DbSet<TEntity> Set<TEntity>() 还是依赖于 EF,没办法,就像我们在 Repository 中返回 IQueryable,你在 Application Service 调用的时候,也必须引用 EF 一样,对于 IDbContext 来说,我们暂时把它看作是一个数据上下文容器,所有对象的持久化最后都通过它来完成,因为我们的解决方案暂时只能使用 EF,所以对于 IDbContext,我们先暂时这样设计。

下面我们开始进行设计。

1. 抽离 IRepository 并改造 Repository

抽离 IRepository 啥意思?我们直接来看下代码:

1
namespace DDD.Sample.Domain.IRepository { public interface IRepository<TAggregateRoot> where TAggregateRoot : class, IAggregateRoot { void Add(TAggregateRoot aggregateRoot); void Update(TAggregateRoot aggregateRoot); void Delete(TAggregateRoot aggregateRoot); TAggregateRoot Get(int id); } }

IRepository 是一个泛型接口,类型为 IAggregateRoot,我们在里面定义了增删改查的常用操作,它的作用就是减少 Repository 的冗余代码,我们看下 IStudentRepository 的定义:

1
namespace DDD.Sample.Domain.IRepository { public interface IStudentRepository : IRepository<Student> { Student GetByName(string name); } }

IStudentRepository 需要继承 IRepository,并确定泛型类型为 Student,Student 继承自 IAggregateRoot,因为增删改查常用操作已经定义,所以我们在其它类似的 IStudentRepository 中就不需要定义了。

IRepository 需要进行实现,如果在 StudentRepository 中进行实现,就没有什么作用了,所以我们需要一个 BaseRepository 来实现 IRepository:

1
namespace DDD.Sample.Repository { public abstract class BaseRepository<TAggregateRoot> : IRepository<TAggregateRoot> where TAggregateRoot : class, IAggregateRoot { public readonly IDbContext _dbContext; public BaseRepository(IDbContext dbContext) { _dbContext = dbContext; } public void Add(TAggregateRoot aggregateRoot) { _dbContext.Set<TAggregateRoot>().Add(aggregateRoot); } public void Update(TAggregateRoot aggregateRoot) { _dbContext.Entry<TAggregateRoot>(aggregateRoot).State = EntityState.Modified; } public void Delete(TAggregateRoot aggregateRoot) { _dbContext.Set<TAggregateRoot>().Remove(aggregateRoot); } public TAggregateRoot Get(int id) { return _dbContext.Set<TAggregateRoot>().FirstOrDefault(t => t.Id == id); } } }

咋一看 BaseRepository 有点像我们上篇的 UnitOfWork,因为我们把增删改放在 Repository 了,因为 Repository 还是和 UnitOfWork 为平级关系,所以我们在 Repository 中用的 IDbContext 而非 IUnitOfWork,这个没什么问题,我们看下 StudentRepository 的具体实现:

1
namespace DDD.Sample.Repository { public class StudentRepository : BaseRepository<Student>, IStudentRepository { public StudentRepository(IDbContext dbContext) : base(dbContext) { } public Student GetByName(string name) { return base._dbContext.Set<Student>().Where(x => x.Name == name).FirstOrDefault(); } } }

StudentRepository 很简单,因为常用操作 BaseRepository 已经实现了,base(dbContext) 的作用就是给 BaseRepository 注入 IDbContext 对象。

Repository 的改造基本上就这些,表面看起来确实很好,另外,如果没有 IUnitOfWork 和 Application Service,我们对 Domain 进行单元测试,也是能满足我们的需求,但需要将 IDbContext 再进行修改下。

2. IUnitOfWork 和 Application Service 的变化

我们先看下 IUnitOfWork 的变化,直接贴下代码:

1
namespace DDD.Sample.Infrastructure.Interfaces { public interface IUnitOfWork { bool Commit(); void Rollback(); } }

因为增删改都移到 Repository 中了,所以 IUnitOfWork 的工作就很简单,只有 Commit 和 Rollback,实现也比较简单,我们看下:

1
namespace DDD.Sample.Infrastructure { public class UnitOfWork : IUnitOfWork { private IDbContext _dbContext; public UnitOfWork(IDbContext dbContext) { _dbContext = dbContext; } public bool Commit() { return _dbContext.SaveChanges() > 0; } public void Rollback() { throw new NotImplementedException(); } } }

这个没啥说的,我们直接看下 Application Service 的代码:

1
namespace DDD.Sample.Application { public class StudentService : IStudentService { private IUnitOfWork _unitOfWork; private IStudentRepository _studentRepository; private ITeacherRepository _teacherRepository; public StudentService(IUnitOfWork unitOfWork, IStudentRepository studentRepository, ITeacherRepository teacherRepository) { _unitOfWork = unitOfWork; _studentRepository = studentRepository; _teacherRepository = teacherRepository; } public Student Get(int id) { return _studentRepository.Get(id); } public bool Add(string name) { var student = new Student { Name = name }; var teacher = _teacherRepository.Get(1); teacher.StudentCount++; _studentRepository.Add(student); _teacherRepository.Update(teacher); return _unitOfWork.Commit(); } } }

StudentService 其实变化不大,只是将原来的 _unitOfWork 添加修改操作,改成了 _studentRepository 和 _teacherRepository,执行下 StudentService.Add 的单元测试代码,发现执行不通过,为什么呢?因为 Repository 和 UnitOfWork 的 IDbContext 不是同一个对象,添加修改对象通过 Repository 注册到 IDbContext 中,最后 UnitOfWork 执行 Commit 却是另一个 IDbContext,所以我们需要确保 Repository 和 UnitOfWork 共享一个 IDbContext 对象,怎么实现呢?

我们进行改造下:

1
namespace DDD.Sample.Application { public class StudentService : IStudentService { private IDbContext _dbContext; private IUnitOfWork _unitOfWork; private IStudentRepository _studentRepository; private ITeacherRepository _teacherRepository; public StudentService(IDbContext dbContext) { _dbContext = dbContext; } public Student Get(int id) { _studentRepository = new StudentRepository(_dbContext); return _studentRepository.Get(id); } public bool Add(string name) { _unitOfWork = new UnitOfWork(_dbContext); _studentRepository = new StudentRepository(_dbContext); _teacherRepository = new TeacherRepository(_dbContext); var student = new Student { Name = name }; var teacher = _teacherRepository.Get(1); teacher.StudentCount++; _studentRepository.Add(student); _teacherRepository.Update(teacher); return _unitOfWork.Commit(); } } }

上面对应的测试代码执行通过,其实解决方式很简单,就是手动给 UnitOfWork、StudentRepository 和 TeacherRepository 注入相同的 IDbContext 对象,当然这是一种解决方式,还有人喜欢用属性注入,这都是可以的,无非最后就是想让 Repository 和 UnitOfWork 共享一个 IDbContext 对象。

本篇的相关代码已提交到 GitHub,大家可以参考下:https://github.com/yuezhongxin/DDD.Sample

3. 总结三种设计方案

关于 Repository、IUnitOfWork 和 IDbContext 的设计,以及 Application Service 的调用,我总结了三种设计方式,我觉得也是我们常用的几种方式,下面我大致分别说下。

1. IUnitOfWork -> EfUnitOfWork -> Repository -> Application Service

这种设计应该我们最熟悉,因为我们一开始就是这样设计的,但问题也是最多的,要不然我也不会写上一篇博文了,比如存在的问题:

  • IUnitOfWork 的职责不明确。
  • Repository 的职责不明确。
  • Application Service 很困惑,因为它不知道该使用谁。
  • Application Service 的代码越来越乱。
  • ….

上一篇博文最后分析出来是 IUnitOfWork 的设计问题,因为它做了太多的事,并且 Repository 依赖于 IUnitOfWork,以至于最后在 Application Service 的调用中,Repository 显得非常多余,这种设计最大的问题就是职责不明确

2. IDbContext -> IUnitOfWork/IRepository(only query) -> UnitOfWork/Repository -> Application Service

第二种设计是我比较倾向于的,因为第一种设计出现的问题,所以我对 IUnitOfWork 的设计非常看重,并且我读了《企业应用架构模式》中关于 UnitOfWork 的所有内容,其实就那么几个字可以概括:维护对象状态,统一提交更改。我个人觉得架构设计最重要的地方就是底层接口的设计,就像我们盖一栋摩天大楼,如果地基打不稳,最后的结果肯定是垮塌,所以,我比较坚持 IUnitOfWork 这样的设计:

相对于第一种设计,这种设计还有一个不同就是 IUnitOfWork 和 IRepository 为平级关系,为什么这样设计?因为我们不能通过 IUnitOfWork 提供查询操作,并且 IUnitOfWork 和 ORM 也没什么关系,所以我们最后抽离出来一个 IDbContext,并且用 EF 去实现它。

IRepository 只有查询,这是我们的定义,在 Application Service 的调用中,对象的新增和修改都是通过 IUnitOfWork 进行实现的,因为查询并不需要记录状态,所以我们并不需要将 IDbContext 在 IUnitOfWork 和 IRepository 之间进行共享,有人会说,IRepository 应该提供领域对象的增删改操作啊,我们再看下 Repository 的定义:协调领域和数据映射层,利用类似于集合的接口来访问领域对象。

集合访问领域对象,那 Repository 如果这样设计呢:

1
public class StudentRepository : IStudentRepository { private IQueryable<Student> _students; public StudentRepository(IDbContext dbContext) { _students = dbContext.Set<Student>(); } public Student GetByName(string name) { return _students.Where(x => x.Name == name).FirstOrDefault(); } }

这种 Repository 设计是比较符合定义的,另外,我们如果对 Domain 进行单元测试,集合性质的领域对象也是可以进行维护的,只不过没有持久化而已。

总的来说,第二种设计最大的优点就是职责明确,你想干坏事也干不了(因为接口已经被约束),目前来说没发现什么问题。

3. IDbContext -> IUnitOfWork(only commit)/IRepository -> UnitOfWork/Repository -> Application Service

第三种设计就是本篇博文讲述的,它其实是从第一种和第二种之间取一个中间值,做了一些妥协工作,具体的实现,上面已经详细说明了,我最接受不了的是对 IUnitOfWork 的更改,虽然表面看起来蛮好的,但我总觉得有些不对劲的地方,就像我们“迫于现实做一些违背道德的事”,可能现在觉察不到什么,但出来混的总是要还的。

关于 Repository、IUnitOfWork 和 IDbContext 的设计,以及 Application Service 的调用,我觉得应该是我们在 DDD 架构设计过程中,最普遍遇到的一个问题,但也是最困惑的一个问题,比如最近两个园友写的博文:

对于本篇博文,如果你有什么问题或疑问,欢迎探讨学习。😃