缘起


哈喽大家好,又是周二了,时间很快,我的第二个系列DDD领域驱动设计讲解已经接近尾声了,除了今天的时间驱动EDA(也有可能是两篇),然后就是下一篇的事件回溯,就剩下最后的权限验证了,然后就完结了,这两个月我也是一直在自学,然后再想栗子,个人感觉收获还是很大的,比如DDD领域分层设计、CQRS读写分离、CommandBus命令总线、EDA事件驱动、四色原理等等,如果大家真的能踏踏实实的看完,或者说多看看书,对个人的思想提高有很大的帮助,这里要说两点,可能会有一些小伙伴不开心,但是还是要说说:

1、很多小伙伴一直问我看什么书,我个人感觉,只要是书看就对了,与其纠结哪本,还不如踏踏实实先看一本。

2、还有小伙伴问,为啥还没有看到微服务的内容?

我想说,其实微服务是一个很宽泛的领域,比如.net
core的深入学习,依赖注入的使用,仓储契约、DDD+事件总线的学习、中介者模式、Docker的学习、容器化设计等等等等,这些都属于微服务的范畴,如果这些基础知识不会的话,可能是学不好微服务的。

周末的时候,我又好好的整理了下我的Github上的代码,然后新建了一些分支(如果你不会使用Git命令,可以看我的一个文章:
https://www.jianshu.com/p/2b666a08a3b5 <https://www.jianshu.com/p/2b666a08a3b5>
,会一直更新),主要是这样的(这个数字是对应的文章,比如今天的是第 12 ):



其实我这个系列所说的
DDD领域驱动设计,是一个很丰富的概念,里边包含了DDD的多层设计思想、CQRS、Bus、EDA、ES等等,所以如果你只想要其中的一部分,可以对应的分支进行Clone,比如你单纯想要一个干净的基于DDD四层设计的模板,可以克隆
Framework_DDD_8 这个分支,如果你想带有读写分离,可以克隆 CQRS_DDD_9 这个分支等等,也方便好好研究。

关于CQRS读写分离概念,请注意,分离不一定是分库,一个数据库也能实现读写分离,最简单的就是从Code上来区分。

 

前言

好啦,上边说了一些周末的思考,现在马上进入正文,不知道大家对上周的内容还有没有印象,主要用两篇文章来说明了命令总线的设计思想和执行过程《十
║领域驱动【实战篇·中】:命令总线Bus分发(一)
<https://www.cnblogs.com/laozhang-is-phi/p/10000662.html>》、《十一 ║
基于源码分析,命令分发的过程(二) <https://www.cnblogs.com/laozhang-is-phi/p/10025913.html>
》,咱们很好的实现了多个复杂模型间的解耦,成功的简化了API接口层和 Application应用服务层,把重心真正的转义到了领域层。

当然其中也有一些新的问题出现了,这个也可以当作今天的每篇一问:

首先,对领域通知的处理上,目前用的是通过一个 ErrorData 的key
来把错误通知放到了内存里,然后去读取,这样有一个很危险的问题,就是生命周期的问题,如果在当前实例中,没有及时删除,可能会出现错误通知的混乱,这是致命的,当然还有
key 的问题,因为几乎每一个 Command 都会有不同的信息,我们不能通过简简单单的人为取名字来实现这个逻辑,这是荒唐的。

其次,如果我们 Command 执行完成,是如何发布通知的,比如注册成功的邮件,短信分发,站内推送等等。

最后,不知道大家有没有深入的去学习,去了解 MediatR 中介者的两个模式:请求/响应模式 与 发布/订阅模式的区别和联系(详细的下边会说到)。

 你会说,很简单呀,我们直接在 CommandHandler
命令处理程序中处理不就行了,一步一步往下走就可以了呀,如果你现在还有这样的思维,那DDD可真的好好再学习了,为什么呢?很简单,我们当时为什么要把
contrller 的业务逻辑剥离到领域模型,就是为了业务独立化,不让多个不相干的业务缠绕(比如我们之前是把model 验证、错误返回、发邮件等,都是写在
controller 里的),那如果我们再把过多的业务逻辑写到命令处理程序中的话,那命令处理模型不就成为了第二个 controller 了么?我们为业务把
controller 剥离了一次,那今天咱们就继续从 命令处理程序中,再优化一次。

 

零、今天要实现右下角蓝色的部分



 

(周末有一个小伙伴问这个软件的地址:https://www.mindmeister.com <https://www.mindmeister.com>
,应该需要FQ)

 

一、领域事件驱动设计 —— EDA

1、什么是领域事件 

我们先看看官网,在《实现领域驱动设计》一书中对领域事件的定义如下:

领域专家所关心的发生在领域中的一些事件。

将领域中所发生的活动建模成一系列的离散事件。

每个事件都用领域对象来表示,领域事件是领域模型的组成部分,表示领域中所发生的事情。

领域事件:Domain
Event,是针对某个业务来说的,或者说针对某个聚合的业务来说的,例如订单生成这种业务,它可以同时对应一种事件,比如叫做OrderGeneratorEvent,而你的零散业务可能随时会变,加一些业务,减一些业务,而对于订单生成这个事件来说,它是唯一不变的,而我们需要把这些由产生订单而发生变化的事情拿出来,而拿出来的这些业务就叫做"领域事件".其中的领域指的就是订单生成这个聚合;而事件指的就是那些零散业务的统称.

 

2、领域事件包含了哪些内容


如果你对上一篇命令总线很熟悉,这里就特别简单,几乎是一个模式,只不过总线发布的方式不一样罢了,如果你比较熟悉命令驱动,这里正好温习。如果不了解,这里就一起看吧,千万记得再回去看前两篇内容哟。

在面向对象的编程世界里,做这种事情我们需要几个抽象:

领域对象事件标示:标示接口,接口的一种,用来约束一批对象,IEvent(当前也可以使用抽象类,本文即是)

领域对象的处理方法行为:比如 StudentEventHandler。(我们的命令处理程序也是如此)

事件总线:事件处理核心类,承载了事件的发布,订阅与取消订阅的逻辑,EventBus(这个和我们的命令总线CommandBus很类似)

某个领域对象的事件:它是一个事件处理类,它实现了 EventHandler,它所处理的事情需要在Handle里去完成。


一个领域事件可以理解为是发生在一个特定领域中的事件,是你希望在同一个领域中其他部分知道并产生后续动作的事件。一个领域事件必须对业务有价值,有助于形成完整的业务闭环,也即一个领域事件将导致进一步的业务操作。就比如我们今天说到的领域通知,就应该是一个事件,我们从命令中产生的错误提示,通过处理程序,引发到事件总线内,并返回到前台。

 

3、为什么需要领域事件

领域事件也是一种基于事件的架构(EDA)。事件架构的好处可以把处理的流程解耦,实现系统可扩展性,提高主业务流程的内聚性。

在咱们文章的开头,可说到了这个问题,不知道大家是否还记得,咱们再分析一下:

我们提交了一个添加Student
的申请,系统在完成保存后,可能还需要发送一个通知(当然这里错误信息,也有成功的),当然肯定还会会一些其他的后台服务的活动。如果把这一系列的动作放入一个处理过程中,会产生几个的明显问题:

1、一个是命令提交的的事务比较长,性能会有问题,甚至在极端情况下容易引发数据库的严重故障(服务器方面);

2、另外提交的服务内聚性差,可维护性差,在业务流程发生变更时候,需要频繁修改主程序(程序员方面)。


3、我们有时候只关心核心的流程,就比如添加Student,我们只关心是否添加成功,而且我们需要对这个成功有反馈,但是发邮件的功能,我们却不用放在主业务中,甚至发送成功与否,不影响
Student 的正常添加,这样我们就把后续的这些活动事件,从主业务中剥离开,实现了高内聚和低耦合(业务方面)。

还记得 MediatR 有两个中介者模式么:请求/响应 和 发布/订阅。在我们的系统中,添加一个学生命令,就是用到的请求/响应 IRequest
模式,因为我们需要等待当前操作完成,我们需要总线对我们的请求做出响应。


但是有时候我们不需要在同一请求/响应中立即执行一个动作的结果,只要异步执行这个动作,比如发送电子邮件。在这种情况下,我们使用发布/订阅模式,以异步方式发送电子邮件,并避免让用户等待发送电子邮件。

 

4、领域事件驱动是如何运行的呢?

这个时候,就用到之前我画的图了,中介者模式下,上半部的命令总线已经说完,今天说另一半事件总线:



 

当然这里也有一个网上的栗子,很不错:



 


 从图中我们也可以看到,事件驱动的工作流程呢,在命令模式下,主要是在我们的命令处理程序中出现,在我们对数据进行持久化操作的时候,作为一个后续活动事件来存在,比如我们今天要实现的两个处理工作:

1、通知信息的收集(之前我们是采用的缓存 Memory 来实现的);

2、领域通知处理程序(比如发邮件等);

 

这个时候,如果你对事件驱动有了一定的理解的话,你就会问,那我们在项目中具体的应该使用呢,请往下看。

 

二、创建事件总线

这个整体流程其实和命令总线分发很像,所以原理就不分析了,相信你如果看了之前的两篇文章的话,一定能看懂今天的内容的。

1、定义领域事件标识基类

就如上边我们说到的,我们可以定义一个接口,也可以定义一个抽象类,我比较习惯用抽象类,在核心领域层 Christ3D.Domain.Core 中的Events
文件夹中,新建Event.cs 事件基类:
namespace Christ3D.Domain.Core.Events { /// <summary> /// 事件模型 抽象基类,继承
INotification/// 也就是说,拥有中介者模式中的 发布/订阅模式 /// </summary> public abstract class
Event : INotification {// 时间戳 public DateTime Timestamp { get; private set; } //
每一个事件都是有状态的 protected Event() { Timestamp = DateTime.Now; } } }


 

2、定义事件总线接口

在中介处理接口IMediatorHandler中,定义引发事件接口,作为发布者,完整的 IMediatorHandler.cs 应该是这样的
namespace Christ3D.Domain.Core.Bus { /// <summary> /// 中介处理程序接口 /// 可以定义多个处理程序
/// 是异步的 /// </summary> public interface IMediatorHandler { /// <summary> ///
发送命令,将我们的命令模型发布到中介者模块/// </summary> /// <typeparam name="T"> 泛型 </typeparam> ///
<param name="command"> 命令模型,比如RegisterStudentCommand </param> ///
<returns></returns> Task SendCommand<T>(T command) where T : Command; ///
<summary> /// 引发事件,通过总线,发布事件 /// </summary> /// <typeparam name="T"> 泛型 继承
Event:INotification</typeparam> /// <param name="event">
事件模型,比如StudentRegisteredEvent,</param> ///
请注意一个细节:这个命名方法和Command不一样,一个是RegisterStudentCommand注册学生命令之前,一个是StudentRegisteredEvent学生被注册事件之后
/// <returns></returns> Task RaiseEvent<T>(T @event) where T : Event; } }
 

 

3、实现总线分发接口

 在基层设施总线层的记忆总线 InMemoryBus.cs 中,实现我们上边的事件分发总线接口:
/// <summary> /// 引发事件的实现方法 /// </summary> /// <typeparam name="T">泛型 继承
Event:INotification</typeparam> /// <param name="event">
事件模型,比如StudentRegisteredEvent</param> /// <returns></returns> public Task
RaiseEvent<T>(T @event)where T : Event { // MediatR中介者模式中的第二种方法,发布/订阅模式 return
_mediator.Publish(@event); }
 


注意这里使用的是中介模式的第二种——发布/订阅模式,想必这个时候就不用给大家解释为什么要使用这个模式了吧(提示:不需要对请求进行必要的响应,与请求/响应模式做对比思考)。现在我们把事件总线定义(是一个发布者)好了,下一步就是如何定义事件模型和处理程序了也就是订阅者,如果上边的都看懂了,请继续往下走。

 

三、事件模型的处理与使用


 可能这句话不是很好理解,那说人话就是:我们之前每一个领域模型都会有不同的命令,那每一个命令执行完成,都会有对应的后续事件(比如注册和删除用户肯定是不一样的),当然这个是看具体的业务而定,就比如我们的订单领域模型,主要的有下单、取消订单、删除订单等。

我个人感觉,每一个命令模型都会有对应的事件模型,而且一个命令处理方法可能有多个事件方法。具体的请看:

1、定义添加Student 的事件模型

当然还会有删除和更新的事件模型,这里就用添加作为栗子,在领域层 Christ3D.Domain 中,新建  Events 文件夹,用来存放我们所有的事件模型,

因为是 Student 模型,所以我们在 Events 文件夹下,新建 Student 文件夹,并新建 StudentRegisteredEvent.cs
学生添加事件类:
namespace Christ3D.Domain.Events { /// <summary> /// Student被添加后引发事件 ///
继承事件基类标识/// </summary> public class StudentRegisteredEvent : Event { //
构造函数初始化,整体事件是一个值对象 public StudentRegisteredEvent(Guid id, string name, string
email, DateTime birthDate,string phone) { Id = id; Name = name; Email = email;
BirthDate= birthDate; Phone = phone; } public Guid Id { get; set; } public
string Name { get; private set; } public string Email { get; private set; }
public DateTime BirthDate { get; private set; } public string Phone { get;
private set; } } }


 

2、定义领域事件的处理程序Handler

这个和我们的命令处理程序一样,只不过我们的命令处理程序是总线在应用服务层分发的,而事件处理程序是在领域层的命令处理程序
中被总线引发的,可能有点儿拗口,看看下边代码就清楚了,就是一个引用场景的顺序问题。

在领域层Chirst3D.Domain 中,新建 EventHandlers 文件夹,用来存放我们的事件处理程序,然后新建
Student事件模型的处理程序 StudentEventHandler.cs:

 
namespace Christ3D.Domain.EventHandlers { /// <summary> /// Student事件处理程序 ///
继承INotificationHandler<T>,可以同时处理多个不同的事件模型 /// </summary> public class
StudentEventHandler : INotificationHandler<StudentRegisteredEvent>,
INotificationHandler<StudentUpdatedEvent>, INotificationHandler
<StudentRemovedEvent> { // 学习被注册成功后的事件处理方法 public Task
Handle(StudentRegisteredEvent message, CancellationToken cancellationToken) {//
恭喜您,注册成功,欢迎加入我们。 return Task.CompletedTask; } // 学生被修改成功后的事件处理方法 public Task
Handle(StudentUpdatedEvent message, CancellationToken cancellationToken) {//
恭喜您,更新成功,请牢记修改后的信息。 return Task.CompletedTask; } // 学习被删除后的事件处理方法 public Task
Handle(StudentRemovedEvent message, CancellationToken cancellationToken) {//
您已经删除成功啦,记得以后常来看看。 return Task.CompletedTask; } } }
相信大家应该都能看的明白,在上边的注释已经很清晰的表达了响应的作用,如果有看不懂,咱们可以一起交流。

好啦,现在第二步已经完成,剩下最后一步:如何通过事件总线分发我们的事件模型了。



 

3、在事件总线EventBus中引发事件

这个使用起来很简单,主要是我们在命令处理程序中,处理完了持久化以后,接下来调用我们的事件总线,对不同的事件模型进行分发,就比如我们的 添加Student
命令处理程序方法中,我们通过工作单元添加成功后,需要做下一步,比如发邮件,那我们就需要这么做。

在命令处理程序 StudentCommandHandler.cs 中,完善我们的提交成功的处理:
// 持久化 _studentRepository.Add(customer); // 统一提交 if (Commit()) { //
提交成功后,这里需要发布领域事件// 比如欢迎用户注册邮件呀,短信呀等 Bus.RaiseEvent(new
StudentRegisteredEvent(customer.Id, customer.Name, customer.Email,
customer.BirthDate,customer.Phone)); }
这样就很简单的将我们的事件模型分发到了事件总线中去了,这个时候记得要在 IoC
原生注入类NativeInjectorBootStrapper中,进行注入。关于触发过程下边我简单说一下。

 

4、整体事件驱动执行过程

 说到了这里,你可能发现和命令总线很相似,也可能不是很懂,简单来说,整体流程是这样的:

1、首先我们在命令处理程序中调用事件总线来引发事件  Bus.RaiseEvent(........);

2、然后在Bus中,将我们的事件模型进行包装成固定的格式   _mediator.Publish(@event);

3、然后通过注入的方法,将包装后的事件模型与事件处理程序进行匹配,系统执行事件模型,就自动实例化事件处理程序 StudentEventHandler;

4、最后执行我们Handler 中各自的处理方法 Task Handle(StudentRegisteredEvent message)。

希望正好也温习下命令总线的执行过程。

 

5、依赖注入事件模型和处理程序
// Domain - Events // 将事件模型和事件处理程序匹配注入 services.AddScoped<INotificationHandler
<StudentRegisteredEvent>,StudentEventHandler>(); services.AddScoped<
INotificationHandler<StudentUpdatedEvent>, StudentEventHandler>();
services.AddScoped<INotificationHandler<StudentRemovedEvent>,
StudentEventHandler>();
 

这个时候,我们DDD领域驱动设计核心篇的第一部分就是这样了,还剩下最后的,事件驱动的事件源和事件存储/回溯,我们下一讲再说。



 


接下来咱们说说领域通知,为什么要说领域通知呢,大家应该还记得我们之前将错误信息放到了内存中,无论是操作还是业务上都很严重的问题,肯定是不可取的。那我们应该采用什么办法呢,欸?!没错,你会发现,通过上边的事件驱动设计,发现领域通知我们也可以采用这个方法,首先是多个模型之间相互通讯,但又不相互引用;而且也在命令处理程序中,对信息进行分发,和发邮件很类似,那具体如何操作呢,请往下看。

 

四、事件分发的另一个用途 —— 领域通知

1、领域通知模型 DomainNotification 


 这个通知模型,就像是一个消息队列一样,在我们的内存中,通过通知处理程序进行发布和使用,有自己的生命周期,当被访问并调用完成的时候,会手动对其进行回收,以保证数据的完整性和一致性,这个就很好的解决了咱们之前用Memory缓存通知信息的弊端。

在我们的核心领域层 Christ3D.Domain.Core 中,新建文件夹 Notifications
,然后添加领域通知模型 DomainNotification.cs:
namespace Christ3D.Domain.Core.Notifications { /// <summary> ///
领域通知模型,用来获取当前总线中出现的通知信息/// 继承自领域事件和 INotification(也就意味着可以拥有中介的发布/订阅模式) ///
</summary> public class DomainNotification : Event { // 标识 public Guid
DomainNotificationId {get; private set; } // 键(可以根据这个key,获取当前key下的全部通知信息) //
这个我们在事件源和事件回溯的时候会用到,伏笔 public string Key { get; private set; } // 值(与key对应)
public string Value { get; private set; } // 版本信息 public int Version { get;
private set; } public DomainNotification(string key, string value) {
DomainNotificationId= Guid.NewGuid(); Version = 1; Key = key; Value = value; }
} }
 

 

2、领域通知处理程序 DomainNotificationHandler

该处理程序,可以理解成,就像一个类的管理工具,在每次对象生命周期内 ,对领域通知进行实例化,获取值,手动回收,这样保证了每次访问的都是当前实例的数据。

 还是在文件夹 Notifications 下,新建处理程序 DomainNotificationHandler.cs:
namespace Christ3D.Domain.Core.Notifications { /// <summary> ///
领域通知处理程序,把所有的通知信息放到事件总线中/// 继承 INotificationHandler<T> /// </summary> public
class DomainNotificationHandler : INotificationHandler<DomainNotification> { //
通知信息列表 private List<DomainNotification> _notifications; // 每次访问该处理程序的时候,实例化一个空集合
public DomainNotificationHandler() { _notifications = new
List<DomainNotification>(); } // 处理方法,把全部的通知信息,添加到内存里 public Task
Handle(DomainNotification message, CancellationToken cancellationToken) {
_notifications.Add(message);return Task.CompletedTask; } // 获取当前生命周期内的全部通知信息
public virtual List<DomainNotification> GetNotifications() { return
_notifications; }// 判断在当前总线对象周期中,是否存在通知信息 public virtual bool
HasNotifications() {return GetNotifications().Any(); } // 手动回收(清空通知) public void
Dispose() { _notifications= new List<DomainNotification>(); } } }
到了目前为止,我们的DDD领域驱动设计中的核心领域层部分,已经基本完成了(还剩下下一篇的事件源、事件回溯):



 

3、在命令处理程序中发布通知

 我们定义好了领域通知的处理程序,我们就可以像上边的发布事件一样,来发布我们的通知信息了。这里用一个栗子来试试:

在学习命令处理程序 StudentCommandHandler.cs 中的 RegisterStudentCommand 处理方法中,完善:
// 判断邮箱是否存在 // 这些业务逻辑,当然要在领域层中(领域命令处理程序中)进行处理 if
(_studentRepository.GetByEmail(customer.Email) !=null) { ///
/这里对错误信息进行发布,目前采用缓存形式 //List<string> errorInfo = new List<string>() {
"该邮箱已经被使用!" };//Cache.Set("ErrorData", errorInfo); //引发错误事件 Bus.RaiseEvent(new
DomainNotification("", "该邮箱已经被使用!")); return Task.FromResult(new Unit()); }

这个时候,我们把错误通知信息在事件总线中发布出去,剩下的就是需要在别的任何地方订阅即可,还记得哪里么,没错就是我们的自定义视图组件中,我们需要订阅通知信息,展示在页面里。

注意:我们还要修改一下之前我们的命令处理程序基类 CommandHandler.cs
的验证信息收集方法,因为之前是用缓存来实现的,我们这里也用发布事件来实现:
//将领域命令中的验证错误信息收集 //目前用的是缓存方法(以后通过领域通知替换) protected void
NotifyValidationErrors(Command message) { List<string> errorInfo = new List<
string>(); foreach (var error in message.ValidationResult.Errors) { //
errorInfo.Add(error.ErrorMessage);//将错误信息提交到事件总线,派发出去 _bus.RaiseEvent(new
DomainNotification("", error.ErrorMessage)); } //将错误信息收集一:缓存方法(错误示范) //
_cache.Set("ErrorData", errorInfo); }
 

 

4、在视图组件中获取通知信息

这个很简单,之前我们用的是注入 IMemory 的方式,在缓存中获取,现在我们通过注入领域通知处理程序来实现,在视图组件
AlertsViewComponent.cs 中:
public class AlertsViewComponent : ViewComponent { //
缓存注入,为了收录信息(错误方法,以后会用通知,通过领域事件来替换)// private IMemoryCache _cache; // 领域通知处理程序
private readonly DomainNotificationHandler _notifications; // 构造函数注入 public
AlertsViewComponent(INotificationHandler<DomainNotification> notifications) {
_notifications = (DomainNotificationHandler)notifications; } /// <summary> ///
Alerts 视图组件/// 可以异步,也可以同步,注意方法名称,同步的时候是Invoke /// 我写异步是为了为以后做准备 /// </summary>
/// <returns></returns> public async Task<IViewComponentResult> InvokeAsync() {
// 从通知处理程序中,获取全部通知信息,并返回给前台 var notificacoes = await
Task.FromResult((_notifications.GetNotifications())); notificacoes.ForEach(c=>
ViewData.ModelState.AddModelError(string.Empty, c.Value)); return View(); } }
 

5、StudentController 判断是否有通知信息

 通过注入的方式,把 INotificationHandler<DomainNotification>
注入控制器,然后因为这个接口可以实例化多个对象,那我们就强类型转换成 DomainNotificationHandler:





 

这里要说明下,记得要对事件处理程序注入,才能使用:
// 将事件模型和事件处理程序匹配注入 services.AddScoped<INotificationHandler<DomainNotification
>,DomainNotificationHandler>();
 

 

五、结语


好啦,今天的讲解基本就到这里了,今天重点说明了,我们如何使用事件总线,已经事件驱动模型下如何定义事件模型和事件处理程序,如果你都看懂了呢,这里可以简单回想一下以下几个问题:

1、为什么要定义事件驱动呢?(提示词:业务分离)

2、我们是在哪里发布这些事件的呢?(提示词:.publish()方法) 

3、事件驱动中的生命周期是从哪里开始到哪里接受的?(提示:处理程序Handler)

 

如果你对以上的内容还是比较困惑呢,这里有两个文章可以参考,当然,多沟通才是关键!

https://www.cnblogs.com/lori/p/4080426.html
<https://www.cnblogs.com/lori/p/4080426.html>

https://blog.csdn.net/sD7O95O/article/details/79609305
<https://blog.csdn.net/sD7O95O/article/details/79609305>

 

六、GitHub & Gitee

https://github.com/anjoy8/ChristDDD <https://github.com/anjoy8/ChristDDD>

https://gitee.com/laozhangIsPhi/ChristDDD
<https://gitee.com/laozhangIsPhi/ChristDDD> 

 

 

--END

友情链接
KaDraw流程图
API参考文档
OK工具箱
云服务器优惠
阿里云优惠券
腾讯云优惠券
华为云优惠券
站点信息
问题反馈
邮箱:ixiaoyang8@qq.com
QQ群:637538335
关注微信