[toc]
前言
本文来自拉勾网课程整理
首先请你想一想:如果没有一套灵活的可扩展的系统架构,结果会怎样?
这方面我深有感触,在我们的App
没有良好的系统架构之前,每一个微小的改动都需要“大动干戈”
。具体来说,由于强耦合性,每次改动我们都需要和各个业务部门商讨详细的技术方案;功能开发完毕后,又要协调各个部门进行功能回归测试。整个过程下来,不仅耗费太多精力和时间,还容易在跨部门、跨团队沟通之间生出许多事来。
而一套良好的系统架构,不仅仅是一款App
的基石,也是整套代码库的规范。有了良好的系统架构,业务功能开发者就能做到有据可依,团队之间的沟通变成十分顺畅;各个功能团队之间也能并行开发,保证彼此快速迭代,提高效率。
因此,我们在推动工程化实践的同时也需要不断优化系统架构。在2017
年,我和公司同事就设计与实现了一套基于原生技术的跨平台系统架构,能让所有开发者同时在iOS
和 Android
平台上工作。
如今这套架构经过不断改进,依然在使用。我们现在开发的 Moments App
,它所用的跨平台系统架构,正是我吸取了当初的经验与教训,使用 BFF
和 MVVM
重新架构与实现的。
这一章,我们主要先聊聊如何使用 BFF
(backend for frontend
,服务于前端的后端)来设计跨平台的系统架构,以提高可重用性,进而提升开发效率。MVVM
的设计与实现,我会在后面几章详细介绍。
为什么使用 BFF ?
我们的 Moments App
是一款类朋友圈的App
,随着功能的不断完善,目前几乎所有 App
的数据源都由多个微服务所支持。在 Moments App
中,后台微服务包括:
- 用于用户管理与鉴权的用户服务
- 用于记录朋友关系的朋友关系服务
- 用于拉黑管理的黑名单服务
- 用于记录每条朋友圈信息的信息服务
- 用于头像管理的头像服务
- 用于点赞管理的点赞服务等等。
当我们需要呈现朋友圈界面时,App
需要给各个微服务发送请求,然后把返回的信息整理,合并和转换成我们所需要的信息进行呈现。
这些网络请求的顺序和逻辑非常复杂。有些请求需要串行处理,例如只有完成了
用户服务的请求以后,才能继续其他请求;而有些请求却可以并行发送,比如在得到信
息服务的返回结果以后,可以同时
向头像服务和点赞服务发送请求。
接着,在得到了所有结果以后,Ap
p 需要整理和合并数据的逻辑也非常复杂
,如果请求返回结果的顺序不一致,往往会导致程序出错。于是,为了解决这一系列的问题,我们引入了 BFF
服务。
BFF
是一个服务于不同前端的后台服务,所有的前端(比如 iOS
, Android
和Web
) 都依赖它。而且 BFF
是一个整合服务,它负责把前端的请求统一分发到各个具体的微服务上
,然后把返回数据整合在一起
统一返回给前端。
可以说,有了 BFF
,我们的 App
就不再需要往多个微服务发送请求,也不再需要处理复杂的并发请求,这样就有效减低了复杂度,避免竞态条件等非预期情况发生。除此以外, 使用BFF
还有以下好处。
首先,App
仅需依赖一个 BFF
微服务,就能有效地管理 App
对微服务的依赖。众所周知,当 App
版本发布以后,我们没有办法强迫用户更新他们设备上的 App
,如果我们需要变动某个微服务的地址,原有的 App
将无法访问新的微服务地址,但是有了 BFF
以后,我们可以通过 BFF
统一路由到新的微服务去。
第二,不同的微服务可能提供不一样
的数据传输方式,例如有的提供 RESI API
,有的提供 gRPC
,而有的提供 GraphQL
。在没有 BFF
的情况下,App
端必须实现各个技术栈来访问各个微服务。一旦有了 BFF
以后,App
只需要支持一种传输方式,极大减轻移动端开发和维护成本。
第三,由于 BFF
统一处理所有的数据,iOS
和 Android
两端都可以得到由 BFF
清理并转换好的数据,无须在各端重复开发一样的数据处理代码。这极大减少了工作量,让我们可以把重心放在提高用户体验
上。
第四,BFF
在提升整套系统安全性的同时,提高整体性能。
具体来说,因为我们的 App
是通过公网连接到后台微服务的,所有微服务都需要公开给所有外部系统进行访问。这就会面临隐私信息暴露等安全问题,比如用户会通过 App
获得本来不应该公开的黑名单信息。
但我们引入 BFF
以后,可以为微服务配置安全规则(如 AWS
上的 Security Group
)只允许 BFF
能访问,例如上述的黑名单管理服务,就可以设置除了 BFF
以外不允许任何其他外部系统(包括我们的 App
)直接访问,从而有效保证了隐私信息与公网的隔离。
与此同时, BFF
还可以同步访问多个不同的数据源,统一管理数据缓存,这无疑能有效提升整套系统的性能。
BFF 的技术选型——GraphQL
既然 BFF
那么好用,那应该怎样实现一个 BFF
服务呢?我经过多个项目的实践总结发现,GraphQL
是目前实现 BFF
架构的最优方案。
什么是 GraphQL?
具体来说,和 REST API
,gRPC
以及 SOAP
相比, GraphQL
架构有以下几大优点。
-
GraphQL
允许客户端按自身的需要通过Query
来请求不同数据集,而不像REST API
和gRPC
那样每次都是返回全部数据,这样能有效减轻网络负载。 -
GraphQL
能减轻为各客户端开发单独Endpoint
的工作量。比如当我们开发App Clip
的时候,App Clip
可以在Query
中以指定子数据集的方式来使用和主App
相同的Query
,而无须重新开发新Endpoint
。 -
GraphQL
服务能根据客户端的Query
来按需请求数据源,避免无必要的数据请求,减轻服务端的负载。
下面我们以一个例子来看看GraphQL
是怎样处理不同的 Query
的。
假设我们要开发一个显示某大 V
朋友圈的 App Clip
,当用户使用 App Clip
时不需要鉴权,不必
查看黑名单,就直接可以看到该大 V
的朋友圈信息,那么我们在访问GraphQL
的流程会就简化了(如下图所示)。
和我们的主App
请求相比,App Clip
不需要显示点赞信息,返回的结果就可以精简了。而且由于不需要进行鉴权,也不需要查询朋友关系、黑名单和点赞等信息,BFF
也无须向这些微服务发起请求,从而有效减轻了 BFF
服务的负载。
另外一方面,和 REST API
相比,GraphQL
的数据交换都由 Schema
统一管理,能有效减少由于数据类型和可空类型不匹配所导致的问题。
除此之外,GraphQL
还能减轻版本管理的工作量。因为 GraphQL
能支持返回不同数据集,从而无须像 REST API
那样为每个新功能不断地更新 Endpoint
的版本号。
如何使用 GraphQL 实现 BFF
既然我们确定了 GraphQL
,那需要选择一个服务框架来帮我们实现。具体怎么实现呢?为了方便演示,我选择了 Apollo Serve
。
Apollo Serve
是基于 Node.js
的 GraphQL
服务器,目前非常流行。使用它,可以很方便地结合 Express
等 Web
服务,而且还可以部署到亚马逊Lambda
,微软 Azure Functions
等 Serverless
服务上。
再加上 Apollo Serve
在我们公司的生产环境上使用多年,一直稳定地支撑着 App
正常运行,因为比较熟悉,所以我就选了它。
下面一起看看具体怎么做。
第一步,使用 GraphQL
,我们先要为前后端传递的数据定义 schema。 在这里我写了 Moment
类型的部分 Schema
定义。比如在 Moment
类型里,我定义了 id,type,title
和 user details
等属性,其中 user details
属性的类型是 User Details
,它定义了 name
和 avatar
等属性。其的代码示例如下所示。
enum MomentType {
URL
PHOTOS
}
type Moment {
id: ID!
userDetails: UserDetails!
type: MomentType!
title: String # nullable
photos: [String!]! # non-nullable but can be empty
}
type UserDetails {
id: ID!
name: String!
avatar: String!
backgroundImage: String!
}
如果你想要查看完整定义,可以点击仓库中查看。
GraphQL
支持枚举类型,比如上面的MomentType
就是一个枚举类型,它只有两个值URL
和PHOTOS
,在数据传输过程中,它们是通过字符串传送给前端的。
Moment
是一个类型定义,在 Swift
中可以对应成struct
,而在 Kotlin
中则对应为data class
。这个类型有id、userDetails
等属性。这些属性可以是基础数据类型,如String、ID、Int
等;也可以是自定义类型,如自定义的UserDetails
。
当数据类型后面有!
时,表示该属性不能为null
。这其中需要注意一点,那就是!在数组定义里面的使用。比如photos: [String!]!
,表示该数组不能为null
,而且不能存放值为null
的数据。而photos: [String!]
则表示photos
数组自身可能为null
,但还是不能存放值为null
的数据 。再来看photos: [String]!
,这表示photos
数组自己不可以为null
, 但是可以放值为null
的数据。
第二步,有了 Schema 的定义以后,接下来我们可以定义 Query 和 Mutation,以便为 App 提供查询和更新的接口。
type Query {
getMomentsDetailsByUserID(userID: ID!): MomentsDetails!
}
这表示该 GraphQL
服务提供一个名叫getMomentsDetailsByUserID
的 Query
,该Query
接受userID
作为入口参数,并返回MomentsDetails
。
一般 Query
只能用于查询,如果要更新,则需要使用Mutation
,下面是一个 Mutation
的定义
type Mutation {
updateMomentLike(momentID: ID!, userID: ID!, isLiked: Boolean!): MomentsDetails!
}
其实 Mutation
是一个会更新状态的Query
,因为在更新后还是可以返回数据的。例如上例中updateMomentLike
接受了momentID
、userID
和isLiked
作为入口参数,在更新状态后也可以返回MomentsDetails
。
第三步,有了以上的定义以后,我们可以借助 resolver 来查询或者更新数据。
const resolvers = {
Query: {
getMomentsDetailsByUserID: (_, {userID}) => momentsDetails,
},
Mutation: {
updateMomentLike: (_, {momentID, userID, isLiked}) => {
for (const i in momentsDetails.moments) {
if (momentsDetails.moments[i].id === momentID) {
if (momentsDetails.moments[i].isLiked === isLiked) {
break
}
momentsDetails.moments[i].isLiked = isLiked;
if (isLiked) {
const likedUserDetails = getUserDetailsByID(userID)
momentsDetails.moments[i].likes.push(likedUserDetails);
} else {
// remove the item for that user
momentsDetails.moments[i].likes = momentsDetails.moments[i].likes.filter((item) => item.id !== userID);
}
break;
}
}
return momentsDetails;
}
}
};
resolvers
的大致逻辑是,在 get Moments Details By User ID
查询里面,直接把momentsDetails
的数据返回。在 update moment like
更新里面,我们更新了momentsDetails 的 is Liked
属性来表示用户是否点赞。在 Moments App
的 BFF
中,我们维护了一个内存数据库,而在真实生产环境中,可以访问 MySQL、MongoDB
来直接存储数据,或者通过其他微服务来桥接数据库的访问。
到此为止,我们就通过GraphQL
实现了一个 BFF
。 注意,这只是一个例子,并不是每个 BFF
都必须通过 Apollo Server
以及 Node.js
来实现。你可以根据所做团队成员的技能来挑选适合你们的技术栈。
比如,Kotlin
是一个不错的选择,因为大部分 Android
开发者都熟悉Kotlin
语言,而且 Kotlin
还可以完美兼容JVM
。特别JVM
生态非常发达,我们可以利用Kotlin
和基于JVM
的开源库构建稳定的BFF
方案。
总结
这一章我介绍了如何使用 BFF
来设计跨平台的系统架构,以及如何使用 GraphQL
实现 BFF
。虽然GraphQL
有众多优点,但并非十全十美,甚至可以说,世界上并没有完美的技术。所以,在使用 GraphQL
过程中,我们需要注意以下两点。
- 在定义
Schema
的过程中,需要前后台开发者共同协商沟通,特别要注意nullable
类型的处理,如果前端定义有误,很容易引起App
的崩溃。 -
GraphQL
通常使用HTTP POST
请求,但有些CDN (content delivery network
,内容分发网络)对POST
缓存支持不好,当我们把GraphQL
的请求换成GET
时,整个Query
会变成JSON-encoded
字符串并放在Query String
里面进行发送。此时,要特别注意该Query String
的长度不要超过CDN
所支持的长度限制(比如Akamai
支持最长的URL
是 8892 字节),否则请求将会失败。