V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
tangkikodo
V2EX  ›  程序员

从前后端分工的利弊谈起,聊聊这套分工的未来方向

  •  1
     
  •   tangkikodo · 133 天前 · 3503 次点击
    这是一个创建于 133 天前的主题,其中的信息可能已经有所发展或是发生改变。

    以下的讨论范围局限于前端后端搭伙一起干功能的业务场景。(不涉及提供 public API 之类的场景)

    简单到复杂

    最初没有专职的前端, 页面的渲染都是后端的工作

    当浏览器功能复杂到一定程度,页面需求上升到一定程度,并且前端框架开始成熟, 独立的前端工种开始出现

    随之而来的变化, 是组织结构上,前后端的“分工”, 为了术业有专攻。

    但伴随而来的问题是, “沟通”和“迭代” 成本的上升。

    以前后端写页面的时候, 这也算是一种古老的全栈,一个人写节省了沟通成本。 并且通常会在 controller 中提前把需要的数据组装好再 render 到页面

    在分工的模式下, 一个功能,一个 story 需要至少两个人来一起完成。 一人负责提供 API , 另一个人负责消费 API 来构建页面。这些人都要参加需求会议, 还要保持一致的理解。产品遇到问题的时候, 往往就是先问前端, 然后排除前端嫌疑后再问后端, 路径就比较曲折, 前端也不胜其扰。

    后端给 API 会有两种选择, 可复用的功能, 做成通用 API 。尚不清楚全貌的功能, 做特供的 API 。通用的 API 可能会在多个页面都有用到, 产生了多个依赖。

    但业务总是在迭代, 早前通用的 API 可能变得不通用, 导致的结果要么是后端对其做特殊的扩展, 要么是前端做多 API 的组合。

    (如果之前多个页面依赖了一个 API , 则排查和调整的工作会更加复杂)

    因此出现了技术债,API 参数变得复杂,前端则混入了组合数据的”业务逻辑“。

    局部最优 不代表 全局最优

    引出了一个观点, 在前后端合作的项目上,不要去考虑”可复用的 API”, 应该考虑可复用的“服务”。 后端如果开始考虑 API 复用来减少自己的工作, 这可能往往就是麻烦的开始。

    API 只是一个和页面相关联的“管道”, 每个页面有自己独立的“管道”, 和后端“供应商”。这样后续的维护和迭代才能容易。每个页面严格扮演好后端对应服务的展示层( presenter)。

    如果发生了需求改变,影响的范围就只会出现在纵向,不会出现之前“改个 API”, 结果某个其他页面报错的意外。

    前后端分工后的另一个趋势是, 前端开始插手数据的处理,换个说法是开始做业务层相关的事情。

    原因从可以从分工减少沟通的角度来解释,也可以从“充分利用”浏览器性能的角度来解释。

    但这样做带来的后果就是一个完整的业务逻辑被分散到了前后两端,这对业务的完整理解就会有害,而且越是迭代频繁的项目,这样做的麻烦就越多。

    有一个概念叫做业务的本质复杂度,很多时候前后端分离模式下的代码的实现会在这层复杂度上增添厚厚的一层额外复杂度。

    马丁福勒在《企业应用架构模式》中说:

    处理领域逻辑时,其中一个最困难的部分就是区分什么是领域逻辑,什么是其他逻辑。我喜欢的一种不太正规的测试办法就是:假想向系统中增加一个完全不同的层,例如为 Web 应用增加一个命令行界面层。如果为了做到这一点,必须复制某些功能,则说明领域逻辑渗入了表示层。类似地,你也可以假想一下,将关系数据库更换成 XML 文件,看看情况又会如何?

    上述的这种情况在前后端分离模式下是很容易出现的。

    后端想着做通用接口, 前端想着做更多的事情, 两边的磨合的产物就是 BFF 。

    BFF 模式的诞生 (其实算是个 controller 层)

    BFF (backend for frontend) 出现的是引入了一个新的中间层,让后端专注在通用的的服务, 让前端专注在页面。 它来干中间的脏活, 构造特供的 API 。

    他的责任是从多个数据源聚合数据,然后将处理完整的数据提供给对应的前端, 从而避免不必要的前端业务处理和数据转换操作。

    如果后端 service 的封装良好, 可以让前端在一层理想的业务抽象之上开发功能。

    BFF 通常由前端来维护, 在 BFF 模式下,BFF + 前端 组成了一个轻量级的“全栈”开发模式。它区分了领域层和展示层(presenter)。

    这种分层在单体应用上对应的分层为 service, controller 和 presenter 三层。 约等于后端负责 service , 前端负责 controller 和 presenter.

    • service 提供业务逻辑封装
    • controller 组合各种业务逻辑, 满足各种灵活多变的数据需求
    • presenter 展示数据

    主流方案的比较

    当前主流的 BFF 方案有 graphql ,trpc 和基于 openapi 的 RESTFul 。

    1. graphql 存在引入成本较高,前端需要书写查询的 i 问题, 还有其他 graphql 的特有国情。

    2. trpc 很好用,但限定了后端为 ts , 约束了后端选型

    综合来看,openapi 的 RESTFul 接口,配合 openapi-ts 这类方案是最友好的,兼顾了后端实现的自由度和向前端提供类型和 client 的便利。而且整个的引入成本也很小, 有不少的框架都支持自动生成 openapi 接口文档。

    另外这个方案对功能迭代非常友好, 后端如果修改了方法和返回结构, 只要重新生成 client ,前端 (如果是 ts ) 就能立刻感知类型和接口发生的变化。

    在确定了 openapi 的方向之后,问题就简化成了,怎样才能方便地从多个数据源/数据库组合出来需要的数据?

    组合, 扩展 schema , 通过申明的方式来构造数据

    利用 orm 是一种手段, 但这个局限于数据库关联数据查询, 如果是跨多个服务的数据拼接, 常见的手段依然是手动循环拼接。

    这个方面 graphql 做得很好,搭配 resolve 和 dataloader 可以轻松得组合出自己所需的数据结构。在 resolver 中, 数据源既可以是 orm 的返回值, 也可以是第三方接口调用的数据。

    schema 申明了数据结构(接口定义),resolver 为所申明的数据结构提供真实数据(具体实现)。

    dataloader 则提供了通用的解决 N+1 查询的方法。

    按照上述的逻辑, 以 FastAPI pydantic 为例,

    1. 我们可以通过简单的继承来扩展已有的 schema , 添加所需的关联数据
    2. 让 resolver 来负责数据的具体加载
    class Sample1TeamDetail(tms.Team):
        sprints: list[Sample1SprintDetail] = []
        def resolve_sprints(self, loader=LoaderDepend(spl.team_to_sprint_loader)):
            return loader.load(self.id)
        
        members: list[us.User] = []
        def resolve_members(self, loader=LoaderDepend(ul.team_to_user_loader)):
            return loader.load(self.id)
    
    class Sample1SprintDetail(sps.Sprint):
        stories: list[Sample1StoryDetail] = []
        def resolve_stories(self, loader=LoaderDepend(sl.sprint_to_story_loader)):
            return loader.load(self.id)
    
    class Sample1StoryDetail(ss.Story):
        tasks: list[Sample1TaskDetail] = []
        def resolve_tasks(self, loader=LoaderDepend(tl.story_to_task_loader)):
            return loader.load(self.id)
    
        owner: Optional[us.User] = None
        def resolve_owner(self, loader=LoaderDepend(ul.user_batch_loader)):
            return loader.load(self.owner_id)
    
    class Sample1TaskDetail(ts.Task):
        user: Optional[us.User] = None
        def resolve_user(self, loader=LoaderDepend(ul.user_batch_loader)):
            return loader.load(self.owner_id)
    

    在定义完了期望的多层 schema 之后,我们只需要提供 root 数据, 既 Team 的数据, 其他 sprint, story, task 的数据都会在 resolve 的过程中自动获取到。 借助 dataloader 这样的过程只会触发额外三次查询。

    @route.get('/teams-with-detail', response_model=List[Sample1TeamDetail])
    async def get_teams_with_detail(session: AsyncSession = Depends(db.get_session)):
        teams = await tmq.get_teams(session)
        teams = [Sample1TeamDetail.model_validate(t) for t in teams]
        teams = await Resolver().resolve(teams)
        return teams
    

    在这样的模式下:

    1. service 层(后端)只需要提供 root 数据的查询, 和关联数据的 dataloader ( batch query), 就能高枕无忧
    2. controller 层( BFF )则只要对 schema 做简单的扩展, 并且调用合适的 dataloader , 就能轻松得组合出期望的数据

    https://github.com/allmonday/composition-oriented-development-pattern/blob/master/readme-cn.md 这个 demo 里面提供了多种组合模式的样例代码。

    总结

    1. 为每个页面提供独立的 API , 可以减少迭代中产生的问题。 也为接口优化提供了空间。 不复用 API , 复用 service 。
    2. 通过继承扩展 schema , 结合 resolver 模式, 可以在数据组合的效率上和 graphql 相媲美, 为每个页面构造独立的 API
    3. RESTFul 配合 openapi-ts 之类的 client 生成工具, 可以将方法和类型信息无缝传递给前端。

    每个页面独立的 API, 概念类似每个页面有个独立的 render(page_name, data)

    其他

    这个想法的 python 实现:pydantic-resolve https://github.com/allmonday/pydantic-resolve 已经在我司 FastAPI 项目上稳定运行了一年多, 欢迎尝试。

    这个模式在全栈的开发模式下的效率非常高, 自己定义好的接口, 一行命令 generate 就能在前端直接使用,特别清爽。 对比 graphql 省去了前端敲 query 的麻烦。

    最近还在琢磨 typescript 下的实现,进度缓慢前进中。

    32 条回复    2024-07-08 19:58:23 +08:00
    helbing
        1
    helbing  
       133 天前
    好巧,前几天在别人的博文( GraphQL 后端架构的经验分享)的评论中看到你,今天在 V2EX 又看到你
    tangkikodo
        2
    tangkikodo  
    OP
       133 天前
    @helbing 好巧~

    没有最好的工具, 只有最适合的工具,graphql 现在就处在被人当成最好的工具的“光环”中, 近年来也开始有人来祛魅

    在前后端开发中实践了 1 年 graphql 后, 我觉得 graphql 太重了, 而且向展示层暴露查询是一种危险的做法, 会反过来绑架后端的开发。

    fastapi 中 pydantic 本身就能定义类型, 处理数据加载和校验, 所以动起了用 pydantic + resolve +dataloader 构建一个后端定义数据, 加载数据, 这样一套模式的脑筋。(阉割版本的 graphql, 笑)

    实际使用体验非常不错。 当 schema 是固定的之后, 可以玩很多树状结构跨代的数据传输和收集, 在保持 service 提供数据不变的情况下, 满足各种展示层鬼畜的数据组合需求
    jones2000
        3
    jones2000  
       133 天前
    多一层, 就意味了多一次损耗, 出问题就需要查更多的模块。
    tangkikodo
        4
    tangkikodo  
    OP
       133 天前
    @jones2000 是的, 所以抽新的层一定要有必要才做

    bff 的模式已经在很多公司采用, 客观说明了这层的价值。

    让后端专注在服务, 前端专注在拼接和展示。(避免了后端为了响应 UI 层需求频繁调整接口的情景)

    不稳定的层一定要依赖于稳定的层, 因此后端的服务接口质量就尤为重要了
    FYFX
        5
    FYFX  
       132 天前
    如果数据查询有性能问题的话怎么处理呢? 感觉这种中间层在做性能优化的时候都会带来一些额外的复杂度
    tangkikodo
        6
    tangkikodo  
    OP
       132 天前
    @FYFX

    bff/controller 层做的事情,以 V2EX 为
    简单来描述是 1. 先获取主数据,以 blog 为例就是先获取 blogs, 2. 根据 schema 里面的扩展定义获取 comments ,浏览量等数据

    如果会出性能问题, 一般是主数据的量太大了, 比如没有采用分页方案,blog 获取了 1w 条, 那么对应的 dataloader batch 查询 就要处理 1w 个 blog_id 的参数, 这种性能问题的优化就是按照实际需要取主数据, 控制数量。

    batch 查询的接口也可以对热点数据做缓存。

    另外 resolver 的优点是组合灵活, 新增关联不需要去考虑 model/entity 层,ORM 那边定义新的 relationship 。

    但随着业务稳定下来以后, 性能优化的时候, 是可以逐步替换, 切换成 ORM 直接提供关联数据。

    (这也是为什么强调 API 要互相独立, 这样才能纵向的, 互不影响地优化 API )
    gogozs
        7
    gogozs  
       132 天前 via Android
    现实情况是大多数前端不会或不愿学后端,后端不会或不愿学前端。招个满足现代前端和后端要求的人太难了
    fpure
        8
    fpure  
       132 天前
    这样前端的工作越来越重了
    SenseHu
        9
    SenseHu  
       132 天前
    我是后端, 最近在学前端和客户端 (RN)
    工程化的领域可以看看这篇,我读下来觉得含金量可以, 对付超大规模以下都够了
    https://lailin.xyz/post/go-training-week4-clean-arch.html
    jones2000
        10
    jones2000  
       132 天前
    @tangkikodo 后台接口质量是基于合理的数据库设计,接口只是展示数据的最后一道工序,数据库设计才是关键,中间层没有对数据库做任何优化,基本没有什么用。
    yrj
        11
    yrj  
       132 天前
    如果业务稳定还好说,如果业务逻辑反复变更,前端多维护一层防腐层简直是噩梦。
    tangkikodo
        12
    tangkikodo  
    OP
       132 天前
    @jones2000
    是的,数据库设计和合理的领域模型设计是核心, 这个应该是后端 service 层聚焦的东西。

    独立的 bff 层或者 单体应用中的 controller 层聚焦的就是合理地组合 service

    因此, 如果 service 层抽象的比较烂, 做的不稳定, 后面的人总归会比较惨
    tangkikodo
        13
    tangkikodo  
    OP
       132 天前
    @yrj

    我对业务逻辑反复变更的观点是, 这是一个悲观主义者一定要考虑的情况。

    这也是为什么我觉得面向 OPENAPI 的 RESTful 接口 和 openapi-ts 之类的 client generator 的组合是所有方案中, 对前端展示层跟随变动最友好的方式了。

    不使用 client generator 的话, 前端变更简直火葬场

    使用 graphql 的话, 前端跟着还要调整维护 query , 也是包袱。

    但如果不维护这层,后端也不主动提供页面专供接口, 那后果必然是前端拼拼凑凑,混入很多业务逻辑。

    另外 BFF 这层防腐层, 看情况是可以放在后端的, 使用继承扩展 schema + resolver 的手段, 后端也能够轻松的构建 前端视图结构。 (这就看组织架构上, 根据开发资源决定哪边更适合做了)

    比如我们的项目就是把这个组合层放在后端 router / controller 。
    tangkikodo
        14
    tangkikodo  
    OP
       132 天前
    @SenseHu 架构整洁之道常看常新, 之前对依赖关系的描述还比较迷茫, 最近终于弄明白了。

    这个模式也是受了 DDD 的很多影响, 我也挺想总结为一套 clean architecture:)
    tangkikodo
        15
    tangkikodo  
    OP
       132 天前
    @yrj 如果发现 service 层都会反复变更的话=。=

    事情就难办咯
    dayeye2006199
        16
    dayeye2006199  
       132 天前
    快进到直接给数据库账号和密码
    justdoit123
        17
    justdoit123  
       131 天前
    没有细看过 trpc ,它跟 grpc 比起来有什么优势吗? grpc 也能生成 TS client ,不过感觉确实有类型丢失。
    yrj
        18
    yrj  
       131 天前
    @tangkikodo 有时候,公司新开展的业务,只能摸着石头过河,朝令夕改,很是痛苦,更可怕的是领导想要尽快看到效果,心理负担增大的情况下,代码很容易写成屎山。没办法,只能等业务稳定了一点点整理。
    tangkikodo
        19
    tangkikodo  
    OP
       131 天前
    @yrj

    调整心态吧, 有句话说“业务逻辑是最不讲逻辑的”

    在没有深度分析的情况下, 写 hard code 反而不失为一种最佳做法。

    如果业务是个比萨斜塔, 代码可能就是“高质量”的定制化支架。 囧
    tangkikodo
        20
    tangkikodo  
    OP
       131 天前
    @justdoit123
    前后端都是 ts 技术栈, 就非常丝滑。

    但缺点就是你不能指望啥都用 node 来写吧 ~~

    另外光 trpc 也不能解决如何 高效且可维护地组合数据 这个问题, 如果能在 typescript 里面用

    ```
    class Team {

    member: Member[]
    async resolve_member(loader=MemberLoader) {
    return await loader.load(this.team_id)
    }
    }
    ```

    这样地方法来组合数据, 也会优雅很多的
    ilvsxk
        21
    ilvsxk  
       131 天前
    今昔是何年,2024 年了吧,看楼主文章有种梦回 2018 年的感觉
    ilvsxk
        22
    ilvsxk  
       131 天前
    BFF 就是两头受气层,最适合背锅。
    还有 GraphQL ,并不会让工作量减少,甚至会变多,前端后端都不待见。
    tangkikodo
        23
    tangkikodo  
    OP
       131 天前
    @ilvsxk bff 应该一头受气才对呀~~
    justdoit123
        24
    justdoit123  
       131 天前
    GraphQL 之前的项目用过。现在接一些第三方服务也会用到。反正还是喜欢不起来,感觉国外比较受欢迎。

    我不喜欢大概有两点:
    1. 引入新的 DSL 。我觉得 API 对接还没到需要引入一个 DSL 的地步,围绕着这个 DSL 有好多生态要搭建,做不好体验就比较差。写 query 时的字段补全、嵌套 format 等等问题。感觉体验不太好。
    2. 容易写成一个臃肿的请求。
    ilvsxk
        25
    ilvsxk  
       131 天前
    @tangkikodo #23
    bff 既要对接前端也要对接后端,那就是干最脏的活拿最低的绩效,线上出了问题,bff 还得第一时间想办法自证不是自己的原因,前端后端只需要一句是 bff 的问题就完事,bff 要做的事就多了。
    tangkikodo
        26
    tangkikodo  
    OP
       131 天前
    @justdoit123 完全同意

    我觉得这个工具其实不是给前后端对接用的, 围绕着他的 DSL ,明明 语言原生就有的类型, 还得顺着它定义一套类型。 啰嗦了。

    臃肿的请求,前端自己通过查询串联起来许多服务, 导致后端要调整的时候非常被动。

    本来能用一个 client.load_xxx_data 搞定的事情, 还得在前端维护一大串查询, 这是对迭代很不友好的~
    tangkikodo
        27
    tangkikodo  
    OP
       131 天前
    @ilvsxk

    bff 如果不是前端去维护的话, 感觉这个 bff 的组织划分就有问题哎。。 (比如现在 node 就是 bff 的绝对优势语言(虽然我不太喜欢

    本质上就是让前端自己组合出所需的数据, 避免不必要的组合逻辑侵入到前端展示层。
    tangkikodo
        28
    tangkikodo  
    OP
       131 天前
    @justdoit123

    gql 的另一个问题是数据结构关联按照什么模式来组织

    如果按照业务模型的方式组织, 那么面向具体的展示层, 很多查到的关联结构要在 UI 上重新做调整。

    如果按照 UI 的需求来组织,那么这个 gql 写起来就很没劲了。。 变成了大号 rest
    ilvsxk
        29
    ilvsxk  
       131 天前
    @tangkikodo #27
    如果是前端来维护的话,那谁维护谁倒霉。
    本来前端只需要负责渲染层就行了,现在额外维护一个服务端的服务,图什么,直接拿后端的接口组合转换数据就好了呀,你既然能用 node 在服务端组合数据了,那也就一定能在前端组合数据不是么。这要是线上接口出了问题,本来没前端事的,维护的那个人现在也得爬起来跟着去修复,我上面回复的 bff 可以直接替换成维护的人。
    zhoudashuai777
        30
    zhoudashuai777  
       131 天前
    前后端都是 ts 技术栈。可以理解为前端使用 typeScript ,后端使用 node 吗
    fescover
        31
    fescover  
       131 天前
    还是推荐 graphql, 一个后端接口后续可能不止是 web 的 js 调用,还有安卓 kotlin, IOS 的 swift, 桌面的 c#等等,graphql 提供了很多语言的适配。
    tangkikodo
        32
    tangkikodo  
    OP
       131 天前
    @fescover
    是的 生态的优势非常重要
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   1094 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 37ms · UTC 23:31 · PVG 07:31 · LAX 15:31 · JFK 18:31
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.