如何在 10 分钟内组织干净的架构以实现模块化模式





5.00/5 (29投票s)
学习如何组织干净的架构以实现模块化模式
引言
在软件开发方面,项目架构对于我所做过的许多项目来说,**维护和可重用性**至关重要。软件架构确保您构建的软件拥有骨架基础。它就像房屋的主框架或背景。在此基础上,我们可以自由地构建任何我们想要的东西。
这些天一直困扰我的问题是,我们如何才能**结合清洁架构和模块化模式**?我已经在代码中做了一些实验,最后,我决定在这篇文章中写出来。目的是非常直接地分享我所学到的,并从反馈中学习更多。这篇文章基于我在软件开发方面的经验,以及模块化方法如何带来好处并应用清洁架构概念。
背景
模块化模式
我不想过多谈论理论,因为我想专注于它的实现。我想说的都是关于我使用这种架构和模式的经验。架构的模块化方法也是我们公司经常讨论的话题。回到五年前,我参与了一个大型项目,很多人参与其中,那时,我们已经将架构组织成模块化方法。随着时间的推移,我们知道通过模块化,我们可以**将大型单体软件分解成许多垂直的小型单体**,并**使团队工作更轻松**,因为**每个团队只需要专注于他们所工作的模块**。有人还记得大型项目中的代码冲突吗?你是否花费半天(或更多)只是为了合并代码?这真是一个噩梦,不是吗?
因此,在模块化方法中,我们需要确保模块足够独立,以便不同团队的独立开发人员可以对其进行工作。它应该采用逻辑设计风格,并为我们提供以下优点:
- 帮助我们的软件系统能够**可扩展、可重用、可维护和适应性强**。
- 将**大型单体堆栈**分解为灵活的协作模块组合(以单体风格)。
- 帮助新人**轻松理解业务功能和系统功能**(因为它足够小)
- 为**迁移到微服务架构**打开大门(如果需要,但从我的角度来看,采用它并不容易)
清洁架构
清洁架构自2012年由Uncle Bob提出以来,便成为软件架构领域的重要事物。我们可以看到Android架构将其与MVP模式结合使用来构建移动应用程序的软件架构。一些文章也建议将清洁架构用于Web应用程序。今年早些时候,Uncle Bob出版了一本书,名为“清洁架构:软件结构和设计的工匠指南”。如果您以前从未读过那本书,我强烈建议您阅读一下。这本书提到了许多在使用SOLID原则、设计模式以及部署工作中的一些技巧和诀窍时的最佳实践。
关于清洁架构的简要概述,如果您已经了解,请跳过此部分并直接进入实现部分。根据清洁架构,我们需要确保以下一些重要点:
- **独立于框架**。架构不依赖于任何功能丰富的软件库的存在。这使您可以将这些框架用作工具,而不必将系统塞入其有限的约束中。
- **可测试**。业务规则可以在没有UI、数据库、Web服务器或任何其他外部元素的情况下进行测试。
- **独立于UI**。UI可以轻松更改,而无需更改系统的其余部分。例如,Web UI可以替换为控制台UI,而无需更改业务规则。
- **独立于数据库**。您可以将Oracle或SQL Server替换为Mongo、BigTable、CouchDB或其他数据库。您的业务规则不受数据库的约束。
- **独立于任何外部机构**。实际上,您的业务规则根本不了解外部世界。
引用项目复制自Uncle Bob的《清洁架构》文章。感谢本文中令人愉悦的清洁架构和模式。
如何使其完美运行?
上下文为王,但客户重于一切。所以让我们从今天的主要故事开始,任何项目都必须分析并询问客户他们希望系统做什么以及他们的系统需要如何运作。他们会给我们一堆用例或用户故事(如果是敏捷项目)。最后一步,我们将不得不绘制用例图。对于那些至今未能理解我所说内容的人,我将分析一个博客引擎领域的示例。假设我们要构建一个博客网站,它具有一些功能,例如阅读博客,查看该博客的帖子,在公共界面中添加一些评论。我们肯定有一种方法可以对博客、帖子和评论执行CRUD操作。除此之外,我们还需要登录系统才能修改博客、帖子和评论。根据此应用程序的功能要求,我们将得到下面的用例图:
问题是如何才能使架构采用模块化方法?实际上,关键因素来自领域驱动设计(《领域驱动设计:软件核心复杂性应对之道》和《实现领域驱动设计》从我的角度来看都是很好的入门书籍),在这种情况下,我们使用**限界上下文设计模式**来分析和设计当前的业务领域。如果您查看上图,您会注意到我们只有三个主要管理项:身份验证、博客和帖子。我将我的应用程序领域划分为访问控制上下文、博客上下文和帖子上下文(我将其划分为三个限界上下文,因为我的领域知识是这样的,由于领域专家不同,它会与其他人的不同,但至少需要理清业务需求)。我们得到一张图,如下所示:
我们来简单解释一下上图,红色的“访问控制上下文”用于身份验证和授权任务。绿色的“博客上下文”用于博客管理,包括博客设置、状态分配和主题等。第三个是“帖子上下文”,它管理自己的评论和标签。正如你所看到的,“帖子上下文”与其他上下文有关系。有关如何设计限界上下文(聚合根)的更多信息,请参阅有效聚合设计文章,你将学到许多有用的东西。我向你保证。:)
由于本文的篇幅限制,我只能向您展示一个限界上下文的代码,我想选择Post Context,因为我认为这是您最感兴趣的一个。其余部分,您可以查看我的GitHub代码——**链接将在本文末尾提供给您**。到目前为止,你们中有些人会质疑Clean Architecture在这其中到底扮演了什么角色?别担心,让我先向您展示项目结构,然后您就会明白了。
现在我开始解释上面的结构。首先,我们在图中有一个**Framework文件夹**,它包含与项目所需工具包相关的所有内容。**请记住,我们将避免在代码中使用抽象,而是使用组合**(面向对象编程中的组合优于继承)。`BlogCore.Core`将不需要依赖任何框架或库,但肯定依赖.NET SDK(有些情况下我们称之为原始代码)。此外,我们有3个项目:`BlogCore.Infrastructure`、`BlogCore.Infrastructure.AspNetCore`和`BlogCore.Infrastructure.EfCore`,它们将依赖`EntityFrameworkCore`、`AspNetCore`以及`Autofac`、`AutoMapper`、`FluentValidation`、`MediatR`等其他库。
其次,图中部的**Hosts文件夹**,我们用它来放置主机项目。您可以看到我有两个主机:一个用于API(BlogCore.API
),另一个用于单页应用程序(BlogCore.App
)。
第三,**Migrations文件夹**用于执行迁移工作,在这种情况下,我们为访问控制上下文、博客上下文和帖子上下文迁移数据。我们可以选择使用Entity Framework迁移并为它们填充数据。否则,您也可以使用T-SQL脚本进行迁移。这取决于您,我不会说哪个比另一个更好。
最后,我们有**Modules文件夹**,其中包含此应用程序的核心。我们已将其划分为限界上下文文件夹,使我们的架构更加清晰。每个限界上下文都有两个子项目,例如`BlogCore.PostContext`和`BlogCore.PostContext.Core`。`BlogCore.PostContext.Core`只包含领域对象、契约和接口,这对于其他项目引用非常有益。规则是,如果另一个模块想要使用一些类、对象,那么它应该引用`
让我们详细看看Post限界上下文的结构。
我认为我们应该深入研究一些代码,以更多地了解如何为这个项目实现模块化模式的整洁架构。
我们有**Post.cs实体**,它在Post Context中充当聚合根,如下所示:
namespace BlogCore.PostContext.Core.Domain
{
public class Post : EntityBase
{
internal Post()
{
}
internal Post(BlogId blogId, string title,
string excerpt, string body, AuthorId authorId)
: this(blogId, IdHelper.GenerateId(), title, excerpt, body, authorId)
{
}
internal Post(BlogId blogId, Guid postId, string title,
string excerpt, string body, AuthorId authorId)
: base(postId)
{
Blog = blogId;
Title = title;
Excerpt = excerpt;
Slug = title.GenerateSlug();
Body = body;
Author = authorId;
CreatedAt = DateTimeHelper.GenerateDateTime();
Events.Add(new PostedCreated(postId));
}
public static Post CreateInstance(BlogId blogId, Guid postId,
string title, string excerpt, string body, AuthorId authorId)
{
return new Post(blogId, postId, title, excerpt, body, authorId);
}
public static Post CreateInstance(BlogId blogId, string title,
string excerpt, string body, AuthorId authorId)
{
return new Post(blogId, title, excerpt, body, authorId);
}
[Required]
public string Title { get; private set; }
[Required]
public string Excerpt { get; private set; }
[Required]
public string Slug { get; private set; }
[Required]
public string Body { get; private set; }
[Required]
public BlogId Blog { get; private set; }
public ICollection Comments { get; private set; } = new HashSet();
public ICollection Tags { get; private set; } = new HashSet();
[Required]
public AuthorId Author { get; private set; }
[Required]
public DateTime CreatedAt { get; private set; }
public DateTime UpdatedAt { get; private set; }
public Post ChangeTitle(string title)
{
if (string.IsNullOrEmpty(title))
{
throw new BlogCore.Core.ValidationException
("Title could not be null or empty.");
}
Title = title;
Slug = title.GenerateSlug();
return this;
}
public Post ChangeExcerpt(string excerpt)
{
if (string.IsNullOrEmpty(excerpt))
{
throw new BlogCore.Core.ValidationException
("Excerpt could not be null or empty.");
}
Excerpt = excerpt;
return this;
}
public Post ChangeBody(string body)
{
if (string.IsNullOrEmpty(body))
{
throw new BlogCore.Core.ValidationException
("Body could not be null or empty.");
}
Excerpt = body;
return this;
}
public bool HasComments()
{
return Comments?.Any() ?? false;
}
public Post AddComment(string body, AuthorId authorId)
{
Comments.Add(new Comment(body, authorId));
return this;
}
public Post UpdateComment(Guid commentId, string body)
{
var comment = Comments.FirstOrDefault(x => x.Id == commentId);
if (comment == null)
{
throw new NotFoundCommentException
($"Could not find the comment with Id={commentId} for updating.");
}
comment.UpdateComment(body);
return this;
}
public Post RemoveComment(Guid commentId)
{
var comment = Comments.FirstOrDefault(x => x.Id == commentId);
if (comment == null)
{
throw new NotFoundCommentException
($"Could not find the comment with Id={commentId} for deleting.");
}
Comments.Remove(comment);
return this;
}
public bool HasTags()
{
return Tags?.Any() ?? false;
}
public Post AssignTag(string name)
{
var tag = Tags.FirstOrDefault(x => x.Name == name);
if (tag == null)
{
Tags.Add(new Tag(IdHelper.GenerateId(), name, 1));
}
else
{
tag.IncreaseFrequency();
}
return this;
}
public Post RemoveTag(string name)
{
var tag = Tags.FirstOrDefault(x => x.Name == name);
if (tag != null)
{
tag.DecreaseFrequency();
Tags.Remove(tag);
}
return this;
}
}
}
然后,我们有 _PostGenericRepository.cs_
public class BlogEfRepository : EfRepository
where TEntity : EntityBase
{
public BlogEfRepository(PostDbContext dbContext)
: base(dbContext)
{
}
}
我们需要为 _PostContext.cs_ 创建 `DbContext`,如下所示:
namespace BlogCore.PostContext.Infrastructure
{
public class PostDbContext : DbContext
{
public PostDbContext(DbContextOptions options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var entityTypes = new List
{
typeof(Post),
typeof(Comment),
typeof(Tag)
};
var valueTypes = new List
{
typeof(BlogId),
typeof(AuthorId)
};
base.OnModelCreating(modelBuilder.RegisterTypes
(entityTypes, valueTypes, "post", "post"));
}
}
}
在整洁架构中,用例非常重要,应仔细设计。在我的项目中,我将其命名为 _ListOutPostByBlogInteractor.cs_。
namespace BlogCore.PostContext.UseCases.ListOutPostByBlog
{
public class ListOutPostByBlogInteractor
: IUseCaseRequestHandler<ListOutPostByBlogRequest,
PaginatedItem<ListOutPostByBlogResponse>>
{
private readonly IEfRepository<PostDbContext, Post> _postRepository;
public IOptions<PagingOption> _pagingOption;
public ListOutPostByBlogInteractor(
IEfRepository<PostDbContext, Post> postRepository,
IOptions<PagingOption> pagingOption)
{
_postRepository = postRepository;
_pagingOption = pagingOption;
}
public IObservable<PaginatedItem<ListOutPostByBlogResponse>>
Process(ListOutPostByBlogRequest request)
{
var criterion = new Criterion
(request.Page, _pagingOption.Value.PageSize, _pagingOption.Value);
var includes = new Expression<Func<Post, object>>[]
{ p => p.Comments, p => p.Author, p => p.Blog, p => p.Tags };
Expression<Func<Post, bool>> filterFunc = x => x.Blog.Id == request.BlogId;
return _postRepository.ListStream(filterFunc, criterion, includes)
.Select(y =>
{
return new PaginatedItem<ListOutPostByBlogResponse>(
y.TotalItems,
(int)y.TotalPages,
y.Items.Select(x =>
{
return new ListOutPostByBlogResponse(
x.Id,
x.Title,
x.Excerpt,
x.Slug,
x.CreatedAt,
new ListOutPostByBlogUserResponse(
x.Author.Id.ToString(),
string.Empty,
string.Empty
),
x.Tags.Select(
tag => new ListOutPostByBlogTagResponse(
tag.Id,
tag.Name))
.ToList()
);
}).ToList()
);
});
}
}
}
处理完 Post 限界上下文的业务案例后,我们需要聚合一些与访问控制限界上下文相关的数据,在这种情况下,我们获取其作者信息并使用 `IUserRepository` 接口获取作者的详细信息。因此,我们引入了另一个名为 _ListOutPostByBlogPresenter.cs_ 的类
namespace BlogCore.Api.Features.Posts.ListOutPostByBlog
{
public class ListOutPostByBlogPresenter
{
private readonly IUserRepository _userRepository;
public ListOutPostByBlogPresenter(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task<PaginatedItem<ListOutPostByBlogResponse>>
Transform(IObservable<PaginatedItem<ListOutPostByBlogResponse>> stream)
{
var result = await stream.Select(x => x);
var authors = result.Items
.Select(x => x.Author.Id)
.Distinct()
.Select(y => _userRepository.GetByIdAsync(y).Result)
.ToList();
var items = result.Items.Select(x =>
{
var author = authors.FirstOrDefault(au => au.Id == x.Author.Id.ToString());
return x.SetAuthor(author?.Id, author?.FamilyName, author?.GivenName);
});
return new PaginatedItem<ListOutPostByBlogResponse>(
result.TotalItems,
(int)result.TotalPages,
items.ToList());
}
}
}
我们需要有一个地方来注册这些依赖对象。因此,**依赖注入**发挥作用,我们在这个项目中使用Autofac模块。其思想是模块将注册其所有依赖项。
namespace BlogCore.PostContext
{
public class PostUseCaseModule : Module
{
protected override void Load(ContainerBuilder builder)
{
base.Load(builder);
builder.Register(x =>
DbContextHelper.BuildDbContext<PostDbContext>(
x.ResolveKeyed<string>("MainDbConnectionString")))
.SingleInstance();
builder.RegisterType<ListOutPostByBlogInteractor>()
.AsSelf()
.SingleInstance();
builder.RegisterType<ListOutPostByBlogPresenter>()
.AsSelf()
.SingleInstance();
}
}
}
是的,这已经足够了,然后我们只需要为我们所做的事情介绍**API**
namespace BlogCore.PostContext
{
[Produces("application/json")]
[Route("public/api/blogs")]
public class PostApiPublicController : Controller
{
private readonly ListOutPostByBlogInteractor _listOutPostByBlogInteractor;
private readonly ListOutPostByBlogPresenter _listOutPostByBlogPresenter;
public PostApiPublicController(
ListOutPostByBlogInteractor listOutPostByBlogInteractor,
ListOutPostByBlogPresenter listOutPostByBlogPresenter)
{
_listOutPostByBlogInteractor = listOutPostByBlogInteractor;
_listOutPostByBlogPresenter = listOutPostByBlogPresenter;
}
[HttpGet("{blogId:guid}/posts")]
public async Task> GetForBlog(Guid blogId, [FromQuery] int page)
{
var result = _listOutPostByBlogInteractor.Process
(new ListOutPostByBlogRequest(blogId, page <= 0 ? 1 : page));
return await _listOutPostByBlogPresenter.Transform(result);
}
}
}
回顾
今天,我带领大家走过了如何在清洁架构中让模块化良好运作的旅程。我们至少了解了什么是模块化,它实际上有多重要。我们回顾了清洁架构的一些概述,以及它的一些优点。最后但同样重要的是,我们了解了如何使用.NET Core 2.0来实现它。
希望大家能回答到目前为止标题中的那些问题。本文未涉及的是**数据流**、**限界上下文之间的同步**、**单元测试**、**清洁架构的部署**……如果您对此感兴趣,请在下面的评论框中留言,我会写更多关于它们的内容。
关注点
- 了解模块化模式如何在同一个堆栈中与整洁架构协同工作
- 了解模块化和整洁架构的一些优点和缺点
- 从实践角度了解整洁架构
- 了解如何使用.NET Core 2.0实现博客域
源代码
本文的源代码可在https://github.com/thangchung/blog-core找到。
历史
- 2017年10月22日:更正了拼写错误和词语,使文章更易阅读和理解
- 2017年10月18日:更新了内联代码,因为其中泛型类型有错误
- 2017年10月17日:初始化并完整撰写了文章