在敏捷开发盛行的今天,这个问题是所有前端同学可能会遇到的,敏捷开发强调前后端研发人员之间的紧密协作,建立在极高信任度的基础上快速迭代并满足产品需求。看似是一个对产品上市周期极为有利的协作方法,但实际上风险也很高,问题就出在信任。
我们不妨把标题拆分为以下问题:
- 如果接口挂了,甚至服务宕了,前端能否正常开发?
- 如果网络出现故障,前端能否离线运行?
- 前端是否需要关心核心业务?
- 前端是否永远要等接口出来才能完成,能否比后端更快下班?
审视以上问题,评估前端能力边界
我们正在开发,但接口还没完成,或者突然接口挂掉了,我们要等待后端修复好才能继续开发;
网络出现故障如服务宕机、网关故障、请求超时、限流、网卡炸了,此时前端客户端无法尽可能利用已有数据继续使用;
如果会遇到以上问题,那么我们前端的能力边界是这样的模型 👇🏻
前端通过交互与后端通信存取数据并展示界面。正是这种能力边界模型的限制导致了我们前端不可能脱离后端的数据、服务独立运行。
有同学会建议:我们可以用Mock数据的方式来让前端开发过程中不用等待后端。这其实并不是合理的解决方式,mock数据的能力非常有限,它是无状态无实体的,例如用户A上传了一篇文章,我们无法在数据仓储中真正的存放这篇文章的数据,也就说我们只能mock上传接口成功返回201,并不能让用户A的个人中心中多一篇文章。 mock数据也无法使用在生产环境,它只是一种测试的手段,在敏捷开发中为了不等待后端可以作为过程中的工具使用,但不能发布到生产环境。
为什么前端的能力边界被束缚了?
上面的模型图是典型的敏捷开发,前后端分工明确,没有交集,前端需要高度信任后端提供的数据 以及数据背后的服务。实际上束缚的原因在于业务的核心仅掌握在后端。实际的模型如下👇🏻
由于高度追求敏捷,前端不掌握核心业务,只单纯为了实现产品需求,以最少工作量,高度信任并依赖后端经过业务逻辑提供的数据服务,导致前端缺少业务的核心逻辑,无法脱离后端独立运行。
是否应该打破束缚
我觉得应该。前端应该随着时间逐步由敏捷—>健壮来过渡,即使前后端分离,我们前端也应该自身具备完备的软件架构设计,来确保外部的腐化不会影响客户端本身,尤其是对于大型复杂的业务系统 前端依赖不止一个后端服务,我们更应该对这些服务及时防腐,尽可能小的影响我们前端本身。
请注意,我们并不能一味地追求完全脱离后端,那样就变成去中心化了,这不符合主流软件架构,我们依然是以软件的健壮和用户体验为基础尽可能的拓展边界能力,这个边界有个度(不然你把后端的饭吃了后端吃什么呢臭小痣🤯)。
建立机制的信任大于人员的信任,架构就是机制。在企业中后端的离职需要遵循交接的机制,一定程度上确保了产品的正常运转和迭代,但对于大型复杂的企业和产品来说,组织架构的机制肯定是不够的,一个月的交接很难确保后端能彻底的交付给另一个后端还不会延缓整个研发团队的进度。所以我们技术本身也需要优化架构,降低各个职能之间的依赖程度,确保后端A的离职尽可能小的影响到后端A_B,尽可能小的影响到前端。
现代前端应用本身也变得越来越复杂,从早期的切图仔到前端岗位的诞生,从以前传统的官网展示到现在的复杂企业级应用 客户端消费级应用,前端本身就具备的越来越重的业务职责,往往和后端扯皮就是在扯一个业务前端也能做 后端也能做 那到底谁来做?
从前端生态上看也越来越多拓宽前端能力边界的轮子,例如nextjs remix全栈框架,Webassembly API,PWA等等。尤其是WebAssembly和PWA技术提供了web强大的本地计算和存储能力,说白了让客户端做一部分较重的业务逻辑计算非常具有可行性,分担了服务端的压力,减少了大量远程通信带来的延迟,离线能力提供更丝滑的用户体验。从此前后端不再扯皮,真正成为互帮互助的好哥们儿。
你行我也行,你不行我替你行
优化边界模型,打破束缚
看到这里你应该很清楚了,前端要突破能力边界,重点是把核心业务逻辑也搬过来。这样才能降低对后端的依赖程度。
新的模型很明显前端的边界能力变宽了🤩(PPT是不是有东西可写了?还不快去找老板加薪?)
前端拥有部分与后端一致的核心业务模型,以此来满足离线化服务,这样我们终于不用等接口了,也不需要mock没啥用的假数据,就能脱离后端独立完成一个业务完备的前端软件,等到后端完成后我们只需要少量的适配就能接入,如果后端跑路,我们可以快速的利用serverless服务来自己搭建服务了,如果A后端不稳定,我们还能及时切换到B后端,类似nginx的负载均衡,但是比它要更及时感知异常更及时切换。
前端拥有完备的接近真实的单元测试环境,数据不再依赖远程请求,通过单元内的业务模型运算我们能得到准确的测试结果,结合CICD能及时发现错误。
以此,单元测试不仅能测试工具类方法,还能测试真实的业务功能。另外用e2e测试页面显示+业务功能的完整性。
新的架构设计
目标很明确了,我们需要在前端完成业务模型建模、本地服务、本地数据仓储等一系列工作。在涉及到具体实现之前,我们需要改变传统前端架构设计,由于前端不再是简单的页面展示和交互逻辑,而是具备了复杂的核心业务逻辑,传统前端架构有点不太够用,需要融合几种后端常见的架构设计。
- MVC架构 (不推荐,传统前端就是,包括mvvm也是mvc的变体)
- 3层架构 或多层架构 (推荐,简单易懂,容易快速上手,不适合比较复杂的业务系统)
- 六边形架构 (推荐,稍比3层架构复杂一点,但适合复杂的业务系统)
- 整洁架构(推荐,比3层稍微复杂一点,适合复杂的业务系统,比六边形更贴合实际用例)
本文不可能讲完上面所有架构设计,任何一种都足以写成一本书了,我们可以渐进式的先从了解开始,先从目录结构了解这几种架构
3层架构目录结构
src
├── pages
└── features
└── posts
├── components
│ └── create-post.jsx
├── services
│ └── posts.service.js
└── repositories
└── posts.repository.js
这种架构最清晰易懂,在posts特性模块中,我们区分为3层:
- UI层(UIL) - 组件、hooks、状态管理...
- 服务层(BLL) - 管理数据扭转、核心业务逻辑、应用程序本身运行相关
- 仓储层(DAL) - 提供数据模型的数据访问能力、indexeddb、sql、localstorage、in-memory...
components目录中定义UI组件,组件内部只负责 ✅
- 数据展示
- 状态管理
- 元素事件处理
不负责 ❌
- 核心业务逻辑 (请求api、 业务逻辑计算)
- 数据存储 (调用数据库、 直接读取localstorage)
示例组件 create-post.jsx
// 组件接受文章模板数据,提交文章的事件
export function CreaetPost({templateData, onSubmit}) {
function handleSubmit(data) {
onSubmit(data)
}
return <Form defaultData={templateData} onSubmit={handleSubmit}>...</Form>
};
services目录中定义服务方法
示例服务 posts.service.js
export const postsService = {
async create(postRepository, postData) {
try {
const newPost = await postRepository.save(postData);
} catch {
throw new Error("数据库错误...");
}
return newPost;
},
async getById(postRepository, postId) {
const post = await postRepository.findById(postId);
if (!post) throw new Error("文章不存在");
return post;
},
getAll(postRepository) {
return postRepository.findAll();
},
};
repositories目录中定义仓储方法
示例仓储 post.repository.js
export const postLocalStorageRepository = {
store: new Map(),
async save(postData) {
const newPost = {id: nanoid(), ...postData}
this.store.set(newPost.id, newPost)
return newPost
},
async findById(postId) {
return this.store.get(postId)
},
findAll() {
return Array.from(this.store.values())
},
};
最终我们在pages集合3层 pages/create-post.page.jsx
// 依赖注入
const postRepository = postLocalStorageRepository
const createPostUsecase = postData => postsService.create(postRepository)
export function CreatePostPage() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState()
async function createPost(postData) {
setLoading(true)
try {
await createPostUsecase(postData)
}catch (err){
setError(err)
}finally {
setLoading(false)
}
}
if(loading) return <Loading />
if(error) return <Error error={error} />
return <Page>
...
<CreatePost onSubmit={createPost} />
</Page>
};
六边形架构
src
├── pages
└── features
└── posts
├── components
│ └── create-post.jsx
├── application
│ ├── services
│ │ └── posts.service.js
│ └── ports
│ └── posts.repository.js
└── infrastructure
└── persistence
└── posts-local-storage.repository.js
与3层架构类似,但是以依赖倒置的原则,应用的核心逻辑封装在application内,应用依赖的外部以接口的形式定义在ports内,而外部的具体实现则全部在infrastructure内。
听起来有点复杂,其实我们换个具象的方式理解,六边形就是一个孤岛,这个岛本身是业务的核心也就是application,岛有很多个港口(ports)通过港口的进出口来与外界联动,这些港口类似六边形。抽象接口就是进出口的规范,外部的一切要提前按照这个规范。infrastructure就等同于外部的具体实现,例如本地存储,孤岛收到创建post的服务,内部经过一系列校验和计算最终调用外部的仓储来存放这个新的post。这样做的好处就是外部和内部完全隔离开,遵守内部的规范,外部的腐化不会影响内部,外部可以随时被替换。
注意这里只是简化了,实际上ports还可以分为in-ports和out-ports
整洁架构
src
├── pages
└── features
└── posts
├── components
│ └── create-post.jsx
├── entities
│ └── post.entity.js
├── application
│ ├── usecases
│ │ ├── create-post.js
│ │ └── delete-post.js
│ └── ports
│ └── posts.repository.js
└── infrastructure
└── persistence
└── posts-local-storage.repository.js
与六边形架构类似,只不过将应用层以更贴合业务实际用例的usecases方式定义,实际上将服务拆分为具体的用例,这样更能清晰的表达使用场景。
还多了一个entities目录,用来定义与核心业务紧密相关的数据模型,你也可以叫他为models,只不过实体entities更能表达它的含义。整个应用层与实体紧密相关,通过基于用例的方式处理实体数据并依赖外部的仓储进行存储,因此外部的仓储类也依赖于实体。
业务模型的统一
上面有提到一个优化后的模型是这样的
这其实没什么问题,很多大型复杂应用也是这样设计的,例如腾讯文档,figma等应用都能在离线模式下运行,说明他们都在前端实现了一套核心业务逻辑,或许你们也已经在前端实现了少量的核心业务只是没有意识到。
但是两边都维护其实有很多缺点的
- 两边都要维护,多出时间成本
- 很难确保前后端同一版本下实现了相同的业务逻辑
- 规则难以100%统一 (例如username必填 长度限制 前后端都要定义相同规则)
- 领域边界不清晰 (前端将user和account定义为同一个领域,而后端分为两个领域)
那么理想中我们希望只维护一份核心业务的模型,然后前后端共用
这就需要引入领域驱动设计方法论,用统一的语言建立通用模型。本文可能不会展开。
实战,以掘金为例
我们复刻掘金的文章模块(超简化) code.juejin.cn/pen/7426769…
全部代码
import React, { useState, useEffect } from 'react';
import ReactDom from 'react-dom';
const uuid = () => btoa(Math.floor(Math.random() * 1000))
const fakeUser = {
id: 'user1',
username: 'yoki',
}
// entity
class Post {
id = uuid()
authorId = ''
title = ''
content = ''
createdAt = new Date()
updatedAt = new Date()
status = 'draft' // draft草稿 publishing审核中 published已发布通过
constructor({ authorId, title, content }) {
this.authorId = authorId
this.title = title
this.content = content
}
}
// service
class PostsService {
constructor(postRepository) {
this.postRepository = postRepository
}
async createPost({ title, content }) {
const post = new Post({ authorId: fakeUser.id, title, content })
await this.postRepository.save(post)
return post
}
async getAllPosts() {
return await this.postRepository.findAll()
}
async publishPost({ postId }) {
const post = await this.postRepository.findById(postId)
post.status = 'publishing'
return post
}
async updatePost({ postId, title, content }) {
const post = await this.postRepository.findById(postId)
if (!post) throw new Error('Post not found')
if (title) post.title = title
if (content) post.content = content
post.updatedAt = new Date()
post.status = 'draft'
await this.postRepository.save(post)
return post
}
}
// repository
class PostInMemoryRepository {
db = new Map()
save(post) {
this.db.set(post.id, post)
}
findById(postId) {
return this.db.get(postId)
}
findAll() {
return Array.from(this.db.values())
}
}
// DI dependency
const postRepository = new PostInMemoryRepository()
const postsService = new PostsService(postRepository)
const PostPage = function () {
const [allPosts, setAllPosts] = useState([]);
// init
useEffect(() => {
postsService.getAllPosts().then(setAllPosts)
}, [])
async function handleSubmitNewPost({ title, content }) {
const fallbackPosts = allPosts
// 乐观更新
const newPost = new Post({ authorId: fakeUser.id, title, content })
setAllPosts(allPosts.concat(newPost))
try {
await postsService.createPost({ title, content })
} catch (err) {
// 失败后回滚
alert('New Post failed: ' + err.message)
setAllPosts(fallbackPosts)
}
}
return <div>
<PostsList posts={allPosts} />
<NewPost onSubmit={handleSubmitNewPost} />
</div>;
};
function PostsList({ posts }) {
return <ul>
{posts?.map(post => <li key={post.id}>
<h1>ID: {post.id}</h1>
<h1>{post.title}</h1>
<p>{post.content}</p>
</li>)}
</ul>
}
function NewPost({ onSubmit }) {
return <button onClick={() => onSubmit({ title: 'Fake title', content: 'Fake content .........' })}>Fake New Post</button>
}
ReactDom.render(<PostPage />, document.getElementById('app'));
代码很长,可以不看。整体以3层架构展开,UI -> 服务 -> 仓储。箭头左边依赖箭头右边。本例没有使用任何第三方轮子,实现了乐观更新,虽然swr或react-query之类的库提供了关于乐观更新和回滚的机制,但我希望我们先用原生实现,抛开一切甜食,戒糖戒盐,良好的架构从不依赖任何轮子以及任何时候能替换轮子。
PostsList 渲染所有文章列表,allPosts数据从postsService中获取,并最终通过useState+useEffect状态管理渲染出来。
NewPost 创建伪文章的按钮,提交一个onSubmit事件,调用postsService中的createPost方法来创建一篇新文章并存储到postRepository仓储中,最终通过调用useState来更新视图。
我们将对数据的创建和查询等业务操作从组件中抽离出来了,以前直接在组件中硬编码的这些业务操作都抽离到另外的服务层和仓储层,这样做的好处是能减轻UI层的职责,UI从此只需要专注于视图的显示和交互事件的绑定。
我们能更轻松的替换服务类,如果未来后端开发完并提供了远程服务,我们可以将本例中基于内存存储的服务直接切换为api调用。亦或是替换仓储层,把后端的api当做仓储,而服务依然是我们前端内部的服务(它可能承载业务计算、校验等)。
能够更轻松的实现基于实际业务的单元测试,我们将服务类和仓储类替换为基于本地内存的实例后能够脱离后端api,独立测试仓储、服务、UI,一切都是分离的,并且避免了网络的不稳定性和延迟导致速度缓慢,单测最重要的一点就是避免异步IO,只有这样才能稳定测试应用的性能指标(如果调用api网络花费10秒,你很难知道前端应用性能到底如何)。
还没写完,持续更新...🫡
后面不知道怎么写了,求掘友们在评论区给点建议,如果展开聊架构会特别冗长,没有几万字很难讲全,但是单讲概念又可能很难理解,直接给最佳实践又会太公式化 缺乏一些思考。正考虑要不要删掉“新的架构设计”这一段 直接上最佳实践。求建议...