共计 9142 个字符,预计需要花费 23 分钟才能阅读完成。
文章首发于我的博客 https://github.com/mcuking/bl…
译者:这篇文章是在 medium 解说前端架构分层系列的第一篇文章,分层和之前翻译的文章相似,绝对一般我的项目多进去两层,畛域层(从业务抽离进去畛域实体)和用例层(实现利用业务逻辑)。另外在编程范式上,绝对面对对象,作者更偏向于采纳函数式,读者可依据我的项目特点抉择适宜本人的形式。
原文链接 https://blog.codeminer42.com/…
这篇博客是《可扩大的前端》系列的一部分,你能够看到其余局部:
#1 — Architecture 和 #3 — The State Layer。
让咱们持续探讨前端可扩展性!在上一篇文章中,咱们仅在概念上探讨了前端应用程序中的架构根底。当初,咱们将入手操作理论代码。
常见模式
如第一篇文章所述,咱们如何实现架构?与咱们过来的做法有什么不同?咱们如何将所有这些与依赖注入联合起来?
不论你应用哪个库来形象 view 或治理 state,前端应用程序中都有反复呈现的模式。在这里,咱们将探讨其中的一部分,因而请系紧安全带!
译者解读:联合上篇文章分成的四层:application 层、domain 层、infrastructure 层、view 层。上面解说的内容中:用例属于 application 层的外围概念,实体 / 值对象 / 聚合属于 domain 层外围概念,Repositories 属于 infrastructure 外围概念。
用例(Use Case)
咱们抉择用例作为第一种模式,因为在架构方面,它们是咱们与软件进行交互的形式。用例阐明了咱们的应用程序的顶层性能;它是咱们性能的秘诀;application 层的次要模块。他们定义了应用程序自身。
用例通常也称为 interactors,它们负责在其余层之间执行交互。它们:
- 由 view 层调用,
- 利用它们的算法,
- 使 domain 和 infrastructure 层交互而无需关怀它们在外部的工作形式,并且,
- 将后果状态返回到 view 层。后果状态用来表明用例是胜利还是失败,起因是外部谬误、失败的验证、前提条件等。
晓得后果状态很有用,因为它有助于确定要为后果收回什么 action,从而容许 UI 中蕴含更丰盛的音讯,以便用户晓得故障下出了什么问题。然而有一个重要的细节:后果状态的逻辑应该在用例之内,而不是 view 层 – 因为晓得这一点不是 view 层的责任。这意味着 view 层不应从用例中接管通用谬误对象,而应应用 if 语句来找出失败的起因 – 例如查看 error.message 属性或 instanceof 以查问谬误的类。
这给咱们带来了一个辣手的事实:从用例返回 promise 可能不是最佳的设计决策,因为 promise 只有两个可能的后果:胜利或失败,这就要求咱们在条件语句来发现 catch()
语句中失败的起因。是否意味着咱们应该跳过软件中的 promise?不!齐全能够从其余局部返回 promise,例如 actions、repositories、services。克服此限度的一种简略办法是对用例的每种可能后果状态进行回调。
用例的另一个重要特色是,即便在只有单个入口点的前端,它们也应该来遵循分层之间的边界,不必晓得哪个入口点在调用它们。这意味着咱们不应该批改用例内的浏览器全局变量,特定 DOM 的值或任何其余低级对象。例如:咱们不应该将 <input />
元素的实例作为参数,而后再读取其值;view 层应该是负责提取该值并将其传递给用例。
没有什么比一个例子更分明地表白一个概念了:
createUser.js
export default ({validateUser, userRepository}) => async (
userData,
{onSuccess, onError, onValidationError}
) => {if (!validateUser(userData)) {return onValidationError(new Error('Invalid user'));
}
try {const user = await userRepository.add(userData);
onSuccess(user);
} catch (error) {onError(error);
}
};
userAction.js
const createUserAction = userData => (dispatch, getState, container) => {
container.createUser(userData, {
// notice that we don't add conditionals to emit any of these actions
onSuccess: user => dispatch(createUserSuccessAction(user)),
onError: error => dispatch(createUserErrorAction(error)),
onValidationError: error => dispatch(createUserValidationErrorAction(error))
});
};
本示例应用 Redux 和 Redux-Thunk。容器将作为 thunk 的第三个参数注入。
请留神,在 userAction 中,咱们不会对 createUser 用例的响应进行任何断言;咱们置信用例将为每个后果调用正确的回调。另外,即便 userData 对象中的值来自 HTML 输出,用例对此也不理解。它仅接管提取的数据并将其转发。
就是这样!用例不能做的更多。你能看到当初测试它们有多容易吗?咱们能够简略地注入所需性能的模仿依赖项,并测试咱们的用例是否针对每种状况调用了正确的回调。
实体、值对象和聚合(Entities, value objects, and aggregates)
实体是咱们 domain 层的外围:它们代表了咱们软件所解决的概念。假如咱们正在构建博客引擎应用程序,在这种状况下,如果咱们的引擎容许,咱们可能会有一个 User
实体,Article
实体,甚至还有 Comment
实体。因而,实体只是保留数据和这些概念的行为的对象,而不必思考技术实现。实体不应被视为 Active Record 设计模式的模型或实现;他们对数据库、AJAX 或持久数据无所不知。它们只是代表概念和围绕该概念的业务规定。
因而,如果咱们博客引擎的用户在评论无关暴力的文章时有年龄限度,咱们会有一个 user.isMajor()
办法,该办法将在 article.canBeCommentedBy(user)
外部调用,以某种形式将年龄分类规定保留在 user
对象内,并将年龄限度规定保留在 article
对象内。AddCommentToArticle
用例是将用户实例传递给 article.canBeCommentedBy,而用例则是在它们之间执行 interaction 的中央。
有一种办法能够辨认代码库中某物是否为实体:如果一个对象代表一个 domain 概念并且它具备标识符属性(例如,id 或文档编号),则它是一个实体。此标识符的存在很重要,因为它是辨别实体和值对象的起因。
只管实体具备标识符属性,但值对象的身份由其所有属性的值组合而成。凌乱?思考一个色彩对象。当用对象示意色彩时,咱们通常不给该对象一个 ID。咱们给它提供红色,绿色和蓝色的值,这三个属性联合在一起能够辨认该对象。当初,如果咱们更改红色属性的值,咱们能够说它代表了另一种色彩,然而用 id 标识的用户却不会产生同样的状况。如果咱们更改用户的 name 属性的值但保留雷同的 ID,则示意它依然是同一用户,对吗?
在本节的结尾,咱们说过在实体中应用办法以及给定实体的业务规定和行为是很广泛的。然而在前端,将业务规定作为实体对象的办法并不总是很好。考虑一下函数式编程:咱们没有实例办法,或者 this
, 可变性 – 这是一种应用一般 JavaScript 对象而不是自定义类的实例的,很好兼容单向数据流的范例。那么在应用函数式编程时,实体中具备办法是否有意义?当然没有。那么咱们如何创立具备此类限度的实体?咱们采纳函数式形式!
咱们将不应用带有 user.isMajor()
实例办法的 User
类,而是应用一个名为 User 的模块,该模块导出 isMajor(user)
函数,该函数会返回具备用户属性的对象,就像 User 类的 this
。该参数不用是特定类的实例,只有它具备与用户雷同的属性即可。这很重要:属性(用户实体的预期参数)应以某种形式形式化。你能够在具备工厂性能的纯 JavaScript 中进行操作,也能够应用 Flow 或 TypeScript 更明确地进行操作。
为了更容易了解,咱们看下前后比照。
应用类实现的实体
// User.js
export default class User {
static LEGAL_AGE = 21;
constructor({id, age}) {
this.id = id;
this.age = age;
}
isMajor() {return this.age >= User.LEGAL_AGE;}
}
// usage
import User from './User.js';
const user = new User({id: 42, age: 21});
user.isMajor(); // true
// if spread, loses the reference for the class
const user2 = {...user, age: 20};
user2.isMajor(); // Error: user2.isMajor is not a function
应用函数实现的实体
// User.js
const LEGAL_AGE = 21;
export const isMajor = user => {return user.age >= LEGAL_AGE;};
// this is a user factory
export const create = userAttributes => ({
id: userAttributes.id,
age: userAttributes.age
});
// usage
import * as User from './User.js';
const user = User.create({id: 42, age: 21});
User.isMajor(user); // true
// no problem if it's spread
const user2 = {...user, age: 20};
User.isMajor(user2); // false
当与 Redux 之类的状态管理器打交道时,越容易反对 immutable(不变性)就越好,因而无奈开展对象来进行浅拷贝并不是一件坏事。应用函数式形式会强制解耦,并且咱们能够开展对象。
所有这些规定都实用于值对象,但它们还有另一个重要性:它们有助于缩小实体的收缩。通常,实体中有很多彼此不间接相干的属性,这可能表明咱们能够提取其中一些属性给值对象。举例来说,假如咱们有一个椅子实体,其属性有 id,cushionType,cushionColor,legsCount,legsColor 和 legsMaterial。留神到 cushionType 和 cushionColor 与 legsCount,legsColor 和 legsMaterial 不相干,因而在提取了一些值对象之后,咱们的椅子将缩小为三个属性:id,cushion 和 legs。当初,咱们能够持续为 cushion 和 legs 增加属性,而不会使椅子变得更繁冗。
<font size=2> 提取键值对之前 </font>
<font size=2> 提取键值对之后 </font>
然而,仅从实体中提取值对象并不总是足够的。你会发现,通常会有与主要实体相关联的实体,其中次要概念由第一个实体示意,依赖于这些主要实体作为一个整体,而仅存在这些主要实体是没有意义的。当初你的脑海中必定会有些凌乱,所以让咱们革除一下。
想一下购物车。购物车能够由购物车实体示意,该实体将由订单项组成,而订单项又是实体,因为它们具备本人的 ID。订单项只能通过次要实体购物车对象进行交互。想晓得特定产品是否在购物车内?调用 cart.hasProduct(product) 办法,而不是像 cart.lineItems.find(…) 那样间接拜访 lineItems 属性。对象之间的这种关系称为聚合,给定聚合的次要实体(在本例中为 cart 对象)称为聚合根。代表聚合及其所有组件概念的实体只能通过购物车进行拜访,但聚合外部的实体从内部援用对象是能够的。咱们甚至能够说,在单个实体可能代表整个概念的状况下,该实体也是由单个实体及其值对象(如果有)组成的聚合。因而,当咱们说“聚合”时,从当初开始,你必须将其解释为适当的聚合和繁多实体聚合。
<font size=2> 内部无法访问聚合的外部实体,然而主要实体能够从聚合内部拜访事物,例如 products。</font>
在咱们的代码库中具备明确定义的实体,汇合和值对象,并以领域专家如何援用它们来命名可能十分有价值(无双关语)。因而,在将代码丢到其余中央之前,请始终留神是否能够应用它们来形象一些货色。另外,请务必理解实体和聚合,因为它对下一种模式很有用!
Repositories
你是否留神到咱们还没有议论长久化呢?思考这一点很重要,因为它会强制执行咱们从一开始就讲过的话:长久化是实现细节,是主要关注点。只有在软件中将负责解决的局部正当地封装并且不影响其余代码,将内容长久化到哪里就没什么关系。在大多数基于分层的架构中,这就是 repository 的职责,该 repository 位于 infrastructure 层内。
Repositories 是用于长久化和读取实体的对象,因而它们应实现使它们感觉像汇合的办法。如果你有 article 对象并心愿保留它,则可能有一个带有 add(article) 办法的 ArticleRepository,该办法将文章作为参数,将其保留在某个中央,而后返回带有附加的仅保留属性(如 id)的文章正本。
我说过咱们会有一个 ArticleRepository,然而咱们如何长久化其余对象呢?咱们是否应该应用其余 repository 来长久存储用户?咱们应该有多少个 repository,它们应该有多少颗粒度?冷静下来,规定并不难把握。你还记得聚合吗?那是咱们切入的中央。依据教训个别是为代码库的每个聚合提供一个 repository。咱们也能够为主要实体创立 repository,但仅在须要时才能够。
好吧,好吧,这听起来很像后端谈话。那么,repository 在前端做什么?咱们那里没有数据库!这就是要留神的问题:进行将 repository 与数据库相关联。repository 与整个持久性无关,而不仅仅是数据库。在前端,repository 解决数据源,例如 HTTP API,LocalStorage,IndexedDB 等。在上一个示例中,咱们的 ArticleRepository.add 办法将 Article 实体作为输出,将其转换为 API 冀望的 JSON 格局,对 API 进行 AJAX 调用,而后将 JSON 响应映射回 Article 实体的实例。
很快乐留神到,例如,如果 API 仍在开发中,咱们能够通过实现一个名为 LocalStorageArticleRepository 的 ArticleRepository 来模仿它,该 ArticleRepository 与 LocalStorage 而不是与 API 交互。当 API 准备就绪时,咱们而后创立另一个称为 AjaxArticleRepository 的实现,从而替换 LocalStorage 实现 – 只有它们都共享雷同的接口,并注入通用名称即可,而不须要展现底层技术,例如 articleRepository。
咱们在这里应用“接口”一词来示意对象应实现的一组办法和属性,因而请不要将其与图形用户界面(也称为 GUI)混同。如果你应用的是纯 JavaScript,则接口仅是概念性的;它们是虚构的,因为该语言不反对接口的显式申明,然而如果你应用的是 TypeScript 或 Flow,则它们能够是显性的。
Services
这是最初一种模式,不是偶尔。正是在这里,因为它应该被视为“最初的资源”。如果你无奈将概念实用于上述任何一种模式,则只有在那时才思考创立服务。在代码库中,任何可重用的代码被抛出到所谓的“服务对象”中是很广泛的,它不过是一堆没有封装概念的可重用逻辑。始终要意识到这一点,不要让这种状况在你的代码库中产生,并且要防止创立服务而不是用例的激动,因为它们不是一回事。
简而言之:服务是一个对象,它实现了畛域对象中不适宜的过程。例如,领取网关。
让咱们设想一下,咱们正在建设一个电子商务,并且须要与领取网关的内部 API 交互以获取购买的受权令牌。付款网关不是一个畛域概念,因而非常适合 PaymentService。向其中增加不会走漏技术细节的办法,例如 API 响应的格局,而后你将领有一个通用对象,能够很好地封装你的软件和领取网关之间的交互。
就是这样,这里不是机密。尝试使你的畛域概念适应上述模式,如果它们不起作用,则仅思考提供服务。它对代码库的所有层都很重要!
文件组织
许多开发人员误会了架构和文件组织之间的区别,认为后者定义了应用程序的架构。甚至领有良好的文件组织,应用程序就能够很好地扩大,这齐全是一种误导。即便是最完满的文件组织,你依然可能在代码库中遇到性能和可维护性问题,因而这是本文的最初主题。让咱们揭开文件组织的神秘面纱,以及如何将其与架构联合应用以实现可读且可保护的我的项目构造。
基本上,文件组织是你从视觉上拆散应用程序各局部的形式,而架构是从概念上拆散应用程序的形式。你能够很好地放弃雷同的架构,并且在文件组织计划时依然能够有多个抉择。然而,最好是组织文件以反映架构的各个档次,并帮忙代码库的读者,以便他们仅查看文件树即可理解会产生什么。
没有完满的文件组织,因而请依据你的爱好和需要进行理智的抉择。然而,有两种办法对突出本文探讨的层特地有用。让咱们看看它们中的每一个。
第一个是最简略的,它包含将 src 文件夹的根分为几层,而后是架构的概念。例如:
.
|-- src
| |-- app
| | |-- user
| | | |-- CreateUser.js
| | |-- article
| | | |-- GetArticle.js
| |-- domain
| | |-- user
| | | |-- index.js
| |-- infra
| | |-- common
| | | |-- httpService.js
| | |-- user
| | | |-- UserRepository.js
| | |-- article
| | | |-- ArticleRepository.js
| |-- store
| | |-- index.js
| | |-- user
| | | |-- index.js
| |-- view
| | |-- ui
| | | |-- Button.js
| | | |-- Input.js
| | |-- user
| | | |-- CreateUserPage.js
| | | |-- UserForm.js
| | |-- article
| | | |-- ArticlePage.js
| | | |-- Article.js
当这种文件组织与 React 和 Redux 配合应用时,通常会看到诸如 components, containers, reducers, actions 等文件夹。咱们偏向于更进一步,将类似的职责分组在同一文件夹中。例如,咱们的 components 和 containers 都将在 view 文件夹中,而 actions 和 reducers 将在 store 文件夹中,因为它们遵循将出于雷同起因而扭转的事物收集在一起的规定。以下是该文件组织的立场:
- 你不应该通过文件夹来反映技术角色,例如“controllers”,“components”,“helpers”等;
- 实体位于 domain/<concept> 文件夹中,其中“concept”是实体所在的汇合的名称,并通过 domain / <concept> /index.js 文件导出;
- 只有不会引起耦合,就能够在同一层的概念之间导入文件。
咱们的第二个抉择包含按性能分隔 src 文件夹的根。假如咱们正在解决文章和用户;在这种状况下,咱们将有两个性能文件夹来组织它们,而后是第三个文件夹,用于解决诸如通用 Button 组件之类的常见事物,甚至是仅用于 UI 组件的性能文件夹:
.
|-- src
| |-- common
| | |-- infra
| | | |-- httpService.js
| | |-- view
| | | |-- Button.js
| | | |-- Input.js
| |-- article
| | |-- app
| | | |-- GetArticle.js
| | |-- domain
| | | |-- Article.js
| | |-- infra
| | | |-- ArticleRepository.js
| | |-- store
| | | |-- index.js
| | |-- view
| | | |-- ArticlePage.js
| | | |-- ArticleForm.js
| |-- user
| | |-- app
| | | |-- CreateUser.js
| | |-- domain
| | | |-- User.js
| | |-- infra
| | | |-- UserRepository.js
| | |-- store
| | | |-- index.js
| | |-- view
| | | |-- UserPage.js
| | | |-- UserForm.js
该组织的立场与第一个组织的立场基本相同。对于这两种状况,你都应将 dependencies container
保留在 src 文件夹的根目录中。
同样,这些选项可能无奈满足你的需要,可能不是你现实的文件组织形式。因而,请花一些工夫来挪动文件和文件夹,直到取得能够更轻松地找到所需工件为止。这是找出最适宜你们团队的最佳办法。请留神,仅将代码分成文件夹不会使你的应用程序更易于保护!你必须放弃雷同的心态,同时在代码中拆散职责。
接下来
哇!很多内容,对不对?没关系,咱们在这里谈到了很多模式,所以不要一口气读懂所有这些内容。随时从新浏览并查看该系列的第一篇文章和咱们的示例,直到你对体系结构及其实现的轮廓感到更称心为止。
在下一篇文章中,咱们还将探讨理论示例,但将齐全集中在状态治理上。
如果你想看到此架构的理论实现,请查看此示例博客引擎应用程序的代码,点击查看。请记住,没有什么是变化无穷的,在当前的文章中,咱们还会探讨一些模式。
举荐浏览链接
Mark Seemann — Functional architecture — The pits of success
Scott Wlaschin — Functional Design Patterns