如何设计一个前后端同构框架?
项目地址
https://github.com/ling-moe/pisces
背景
我深信未来的Web开发将会向着统一语言的方向发展,这一语言不仅能够成为个人从事全栈开发的得力工具,还有望支持前后端分离的协同开发。然而,眼下的Web开发却正逐渐显露出一系列棘手问题,主要集中在前端与后端开发团队在进行协作时,对于数据格式、逻辑处理和关注焦点上的不一致,这可能导致沟通困难、集成问题增多。
想象一个用户注册场景,业务要求需要输入两次密码且两次密码必须一致。在前端追求用户体验倾向于实时提醒,需要实时比较用户输入的两次密码快速进行提醒是否出错,而后端为了保障数据的完整性和安全性同样需要对密码进行验证并进行整体整体合规检查。,双方都要做并且做好。一个是否相同的检查要用不同的语言写且至少要写两次。这种重复的校验逻辑不仅增加了开发的复杂度,更是浪费了开发者的时间和精力。更不用说如果增加历史密码的检验、加密传输的要求等还要在两端做多少的工作,我想各位开发应该都体验过这种场景。
面对这种场景,我们会提出一个问题:我们是否能够实现一种统一的校验机制,既能满足前端体验需求,又兼顾后端安全性?我们是否有办法解决或减轻这些问题?并且重复实现必然会导致同步问题。就如同CAP的网络分区问题,在对逻辑实现达到最终一致性的过程中,我们又会花费多少时间进行协商,又将因此造成多少开发浪费呢?
因此,我们必须正视当前Web开发中存在的问题,并积极寻求解决方案。统一语言的理念在团队协作中具有相当高的价值。不同开发者或团队处理相同业务逻辑时,由于对逻辑实现语言不一致的存在,会导致代码细节的不一致,最终影响系统的稳定性。引入统一的开发语言和规范,能够有效减少沟通成本,降低开发浪费,使团队更高效协同工作。只有通过统一语言来打破前后端之间的壁垒,我们才能在未来的Web开发中迎来更为高效、协同和创新的时代。
设计要求
那么,设计一个前后端同构框架,我们有哪些基本要求呢?
- 必须使用同一种语言,且语言可以在多端运行,确保代码可以复用。
- 这门语言必须有前后端框架或对应的库,重新造轮子成本太高。
- 设计或引入一个RPC库,以简化跨端调用的复杂性。
- 选择项目管理工具,设计良好的工程结构、包依赖结构。
- 可独立开发也可前后端分离开发,易于开发与调试,支持热重载。
对于这五条要求,我们逐条进行解决。
选择TypeScript
在前端领域,不同平台使用不同的语言,例如JavaScript代表着网页端,Qt用于PC,而Android则使用Kotlin,iOS则使用Swift。后端领域具有更加丰富的语言,如Java、Go、C#、Python、Node.js、Ruby等,形成了百花齐放的局面。
有这么多的语言,对未来的跨平台框架而言,需要综合考虑语言特性、互操作性、多端编译、开发体验以及未来发展趋势。基于以上的考虑,目前我们的选择其实只有3个:kotlin,js/ts,dart。
这个列表里没有java是因为java图形化比较难用,且越来越小众。并且语言的互操作性很低;没有C#是因为微软已经基于其开发了非常优秀的Xamarin,这已经是一个很成熟的框架了;没有python是因为其弱类型在团队协作上容易造成冲突,且python在浏览器端操作dom或与js代码互相调用实在比较小众,有问题很难解决。
Flutter在移动端表现出色,在PC端也有不错的表现,然而,在网页端,其采用的canvas实现方式引起了一些争议。问题主要集中在路由导航、文字排版渲染等方面,因为全部都是通过canvas实现,导致原有的web组件需要重新实现。虽然这个重实现的成本并不需要我们承担,但这也意味着我们之前积累的网页端经验基本失效,需要重新学习和掌握技术。虽然有一些混编方案,但复杂性和实施成本较高,学习曲线也较为陡峭,因此并不建议使用。Dart虽然可以转译为JavaScript,但在针对JavaScript的打包优化方面仍有改进的空间。值得一提的是Angular Dart这个项目,它在dart上实现来angular框架。可是官方停止了Angular Dart的开发,值得庆幸的是社区重新启动了这个项目,但我觉得还需要给他们一些时间,暂持乐观的观望态度。
Kotlin作为一门语言,本身的定位就是一门多平台语言。其中最知名的框架就是Jetpack Compose。这是是一个优秀的跨平台开发框架,重点放在移动端,特别是Android端。尽管它可以编译为pc端原生程序,在Web开发中有Kotlin/JS和Kotlin/Wasm来支持,但与JavaScript的互操作性仍然存在一些麻烦。相关的跨平台框架实现如Kivsion可以与原生JavaScript或React框架集成,但在开发体验上,编译速度慢且Dom操作复杂。React只能以组件的方式使用,如果在其中使用原生HTML标签,就需要比较复杂的、套娃式的声明。如果要使用kotlin,则需要等待其Kotlin/Wasm模块的成熟,或者Kotlin/JS的进一步优化
JavaScript本身作为浏览器原生语言无须多言,并且它还有nodeJS来支持服务端编程,但是由于其原生的弱类型不利于团队协作,所以我选择使用TypeScript。JavaScript的单线程问题可以用Woker Thread来解决,这一套解决方案在服务端和浏览器端都适用。异步嵌套的问题可以用async/await来解决。到这一步,似乎已经没有比TypeScript更合适的语言了,我们选择使用它来实现我们的前后端同构框架。
Angular + NestJS
前端有很多的选择:Vue、React、Angular、Svelte、JQuery等。但是其中很多都是一个库(Library),而非一个框架(Framework)。

Library与Framework对比
这里我们主要讨论Vue、React和Angular,其他的可以选择基本是要么与上述三种选择相似,要么以Library居多,不需要过多讨论。
考虑到React被官方定位为一个UI库,其生态基本都是由社区维护,更适合构建组件而非整个应用程序。且React 18已经有两年没有发生重大更新,后面的维护是个问题。不过,现在比较火热的是基于react的nextjs,这个框架让我又想起了PHP、JSP的模板渲染时代。我认为前后端分离开发是刚需,可以有效的提高效率,因此NextJS不满足我们的需求。
Vue的定位是一个渐进式框架,它是一个约束力比较低的框架。Vue的工程化虽然在某些方面表现不俗,但相较于Angular的全面性、官方最佳实践、约束力等有些欠缺。这里也需要提一下基于vue的NuxtJS,它的定位与NextJS类似,同样不满足我们的需求。
在大型项目中,我认为Angular的优势变得更为显著。因为它不仅仅是一个UI库,而是一个功能强大的大型框架。Angular提供了丰富的官方组件和最佳实践,可以通过规范的方式对库进行集成与移除,对代码的约束力强,为项目提供了可靠的基础。
在后端设计方面,比如Express和Fastily都是属于服务器的库而非框架。我倾向于将Web服务器仅视为入口,而将功能管理和模块化与Web无关,由我们自行设计。这种设计有着诸多优势,比如实现各种功能组件的灵活插拔、服务器更换和调优等。这种解耦的设计使得系统更具弹性,可以轻松适应不同需求和变化。
通过将功能模块与Web解耦,我们可以轻松实现功能组件的插拔,无需影响整个系统的稳定性。这意味着我们可以随时添加或移除特定功能,而不必担心对整体架构的影响。同时,服务器的更换和调优也变得更加容易,后端逻辑与Web服务器紧密耦合的情况将大为减少。
NestJS这种设计理念的好处还可以在不同场景下得到体现。例如,当需要适应不同的客户端类型(Web、移动端、桌面端)时,只需调整前端界面而不必改动后端逻辑,大大提高了开发的灵活性和效率。
选择Angular和NestJS,将为项目带来更好的可维护性、可扩展性和稳定性。在这个充满变化的技术世界中,我们需要的是一种更具弹性的架构,以适应未来的挑战。
学习成本很低的RPC
在我进行RPC调用的设计时,深入研究了许多知名的RPC库,如gRPC、Thrift和TRPC,发现它们都是基于平台无关设计的,为了通用性,采用了一套自己的使用逻辑和哲学,而这就造成了有一定的学习成本和集成门槛。
然而我觉得RPC并不需要这么复杂,因为我已经确定在NextJS和Angular中使用它。因此我选择使用TRPC的思想,决定构建一个简化的RPC库。基于NextJS和Angular中的DI进行集成,同时保留TRPC端到端类型安全的思想。
这个库的在调用时的心智负担非常低,与正常的函数调用几乎一样,只需要三步:
- 定义接口
export interface UserDomainService {
/**
* 获取用户列表
* @pageRequest 分页参数
* @query 用户查询条件
*/
pageUser(pageRequest: PageRequest<User>, query?: UserQuery): Page<User>
}- 实现接口
@Injectable()
export class UserRepository implements Provider<UserDomainService>{
constructor(
private readonly prisma: PrismaService,
private readonly authClsStore: ClsService<{ 'currentUser': User; }>,
private readonly cachehelper: CacheHelper
) { }
async pageUser(pageRequest: PageRequest<User> = DEFAULT_PAGE, query?: UserQuery): Promise<Page<User>> {
return await paginator(pageRequest)(this.prisma.user, { where: query });
}
}- 调用接口
@Component({
selector: 'pisces-user-list',
templateUrl: './list.component.html',
styleUrls: ['./list.component.scss'],
})
export class UserListComponent {
constructor(
@Inject(RemoteService) private userRepository: Consumer<UserDomainService>,
) {
}
query(pageEvent?: PageEvent) {
const pageRequest = PageRequest.of<User>();
this.userRepository.pageUser(pageRequest).subscribe(users => {
console.log(users);
});
}
}集成也非常简单,只要分别在前后端中声明即可:
- 在Angular的根模块中注入令牌:
import { musubiProvider } from '@pisces/musubi/client';
@NgModule({
providers: [
musubiProvider
]
})
export class IamFrontendModule {}- 在NestJS模块中进行注册
import { MusubiModule } from '@pisces/musubi/server';
// 注意:这里使用的是@MusubiModule而非NestJS的@Module,@MusubiModulewa完全兼容@Module,
// 意味着在这里也可以声明provider、controller等
@MusubiModule({
remotes: [
UserRepository
]
})
export class IamBackendModule {}这种方式也有利有弊,最直观的问题是所有方法均不允许重名,这样个我建议在开发的过程中,将自己的功能模块缩写添加到方法名上,这样可以减少命名空间的冲突。
如果想要某个方法不暴露为http接口,只需要在方法前加个**$**即可。当然,也可以在@MusubiModule的属性中定义方法名特征,或者将其声明为私有方法。
需要注意的是,目前的RPC暂时还不支持Request和Response对象的注入,也不支持文件上传。我认为这些问题可以在后续解决,没有太大的技术难点需要进行攻关。
monorepo对我们来说更合适
无论是在Node端还是浏览器端,monorepo(单一仓库)似乎已经成为JavaScript项目的标配。然而,在这里,我想谈谈我选择采用monorepo的理由以及对于可能出现的缺点的解决方案。这个选择其实也涉及到monorepo和polyrepo多年的争论,这种争论归根结底在于到底是倾向于集中式还是分布式。集中式意味着便于共享与协作,而分布式则意味着更加独立的管理与部署。
我的选择是monorepo,而我采用的管理工具是nx。这使得协作和版本管理变得异常便捷。对于仓库体积庞大的问题,我采取的解决方案是利用git submodule对应用模块进行管理,这样可以更灵活地控制仓库的大小。而构建速度较慢的问题,则得到了nx-cloud的有效缓解,通过云端缓存的方式,构建变得更为高效。
以nx为例,其提供的强大功能使得开发者能够更加轻松地协同工作。通过将应用拆分成模块,不仅方便了团队成员之间的合作,也为版本管理提供了更细粒度的控制。这种模块化的设计使得团队可以更灵活地管理项目的不同部分,有助于提高整体的开发效率。
在面对仓库庞大、难以维护的问题时,git submodule的引入为项目带来了清晰的层次结构。通过将不同的功能模块(一般是业务模块)拆分为独立的子仓库,我们能够更好地维护和更新特定部分的代码,而不必操心整个仓库的复杂性。这种模块化的管理方式不仅提高了代码的可维护性,还使团队更容易应对复杂项目的演进和变化。
此外,对于构建速度缓慢的问题,nx-cloud的运用则是一个极具创新性的解决方案。通过将构建过程缓存到云端,我们能够极大地减少本地构建的时间,提高开发效率。这对于大型项目而言尤为重要,特别是在团队协作的情境下,能够更迅速地反馈变更,推动项目的迭代。在克服了仓库体积和构建速度等潜在问题后,monorepo搭配强大的工具nx,为团队提供了更灵活、高效的开发环境,
水到渠成的DDD工程结构
我深信DDD(领域驱动设计)是一种极为精妙的设计理念,我们在其中将用户操作接口的概念添加到适配器中,即将view也视为一种api。其最显著的优势在于将控制器(Controller)的角色转变为视图(View)。这个转变不仅使得Web接口更贴近,也有机会重新审视整个DDD的工程结构。
站在这个更为直观的层面,我们有机会审视整个DDD工程结构。领域(Domain)的范围并未改变,仍然承担着定义实体、值对象(VO)、关键业务逻辑的任务。RPC接口的定义也自然而然地归属于领域,几乎不会涉及使用中间件的代码。领域专注于实体和方法的接口定义,而具体的实现则由Repository、View和Service共同完成。
在View、Repository和Service这三个层次中,Repository充当着后端逻辑的书写者,相当于后端的服务。View和Service则专注于前端的视图逻辑渲染。通过RPC的客户端,我们可以在View和Service中调用我们的RPC声明接口,它们会自动触发Repository中的实现。
由于Repository层和View层天然隔离,极其有效地防止了两者之间业务逻辑的相互渗透。与视图变化相关的代码不会侵入到业务逻辑层,反之亦然。如果不慎发生了逻辑的相互渗透,首先表明开发者在分层和设计方面可能存在一些淡薄的意识,接着逻辑的复杂性将使整个代码结构变得混乱不堪。而在我们这套DDD的工程结构中,如果逻辑没有得到有效隔离,在编译时就会迅速报错。因为RPC会转换为HTTP调用,混在一起就相当于让前端去编译后端的Node.js代码,大部分情况下编译是不可能通过的,因为会涉及到打包格式和FFI的问题。这种强制性的错误提示有助于开发者更加关注设计,编写更易理解、更优雅的代码。
最后,我们谈到了Infra层,这是一个充满着配置和工具类的领域。在这一层,我们编写一些配置,如公共启动配置、模块的配置、常量等。这些内容,虽然相对不太引人注目,却是整个DDD工程结构中不可或缺的一部分,为整体提供了必要的模块声明与划分。
只需要一个开发服务
在我们进行异构的前后端开发时,常常需要为前端和后端分别启动独立的服务。然而,在同构的前后端开发中,为何还要额外启动两个服务器呢?devserver底层都采用了Node.js,为何不将它们合而为一呢?
让我们深入分析一下这一需求。在正常的开发过程中,前端通常需要快速编译并即时展示效果,因此对于HMR(热模块替换)的依赖非常高。与此不同,后端一般在整体逻辑完成后进行调试,对于实时的HMR并没有那么迫切的需求。
得益于nx最新的executor,我们现在可以在vite开发服务器中注入一个中间件。这意味着我们可以将nestjs以中间件的形式注入到devserver中,从而实现前后端同构的需求。这种巧妙的解决方案为我们提供了更加高效和无缝的开发体验。
美中不足的是,后端服务器目前并没有实现HMR功能,这也成为我们下一步需要改进的重点。我们的目标是让后端能够实现手动控制的HMR,而不是每次都需要重新启动服务器。虽然这一步相对来说优先级较低,但它将会是我们未来持续优化的方向。
因此,启动开发服务我们只需要输入:
nx run iam:serve我们真的需要运行时类型吗?
在软件开发领域中,是否真正需要运行时类型一直是一个备受争议的话题。我认为,对于某些情况,确实存在有限度的需要。让我们以Java为例,这门编程语言有许多规范,如POJO、BO、QO、DTO、VO等,旨在规范代码结构,使其更加清晰易读。然而,我们不禁要问,是否真的需要这么多的对象类型呢?
从实际开发的角度看,我们实际上只需要不同类型所包含的字段,以便更好地组织代码、使其各司其职。毕竟,代码是写给人看的。而这正是TypeScript的一大优势所在。比如,当我在实体中进行一次字段变更时,如果涉及多个对象类型,那么手动修改所有相关对象的字段将成为一项昂贵的操作,不仅增加维护成本,也不利于日后的代码维护。
TypeScript通过强大的类型系统,可以在编译期间进行类型检查和规范,帮助我们避免手动维护多个对象类型的繁琐工作。这一切都在不影响运行时性能的前提下完成,因为这些类型检查是在编译期间处理的,而在运行时无需关心这些类型信息。
然而对于库或框架代码的编写而言,有时在运行时获取类型元数据是必要的。举例来说,比如在处理RPC(远程过程调用)时,可能需要在运行时使用类型元数据。虽然在TypeScript中使用元数据需要手动添加注解,但在某些情况下,为了简便起见,我们可能选择不添加注解,仅采用最小的步骤实现流程。比如我之前提到的RPC,尽管可以使用元数据,但为了追求简便,我可能更倾向于用最少的步骤来实现整个流程,而不是在声明过程中加入注解让使用变得繁琐。
因此,对于运行时类型的需求,我认为是有限的。在实际开发中,根据具体情况来选择是否需要在运行时保留类型信息,以在简化代码结构的同时满足特定需求。这种灵活性正是TypeScript所带来的价值之一。
结语
到这里,我们的设计要求已经都被满足了,前后端同构的架子也基本搭上了。虽然很多东西都处于缺失状态,里面的很多设计本身不是很难做,但是如果要让开发者用的顺手,一个易用的框架就很难做了。
这是项目的地址:https://github.com/ling-moe/pisces,感兴趣的朋友可以点点star。
其实我还是很期望kotlin和dart能硬起来(或者ts可以直接编译为字节码?)。后续还需要往这个架子上添加很多东西,比如基于DDD的快速开发平台,多租户化以及微服务展开,这些在前后端同构的条件下很有挑战性,我对此也很有兴趣。还是那句话:需求驱动学习,实用推动执行。