Serverless 如何让前端程序员没有后端也能完成项目?

taowen · 2021年01月10日 · 205 次阅读
本帖已被设为精华帖!

答:Backend as a Database, Sort Of

直接把 Mysql 暴露在公网给前端使用会有什么问题:

  • I/O 边界 => 性能考虑
    • 批量获取多条数据
    • 对数据进行 count / sum 等聚合
    • 把聚合结果做提前计算,冗余字段
  • 组织边界 => 安全考虑
    • 数据权限校验,防止非法访问
    • 校验业务规则
    • 产生的订单价格应该等于商品单价之和
    • 出库单要创建,需要 xxx 领导签字单

要做到 Backend as a "Database",就是回答以上问题如何解决。

User 表

Mysql / Postgresql 的权限太粗了。肯定是要在 Mysql / Postgresql 外边套一层去校验权限。这里有如下的挑战要解决

  • B 端权限需要非常细致,不仅仅要到行,甚至到格。权限可能是按职位授予,也可能是因为工单分配临时授予。
  • 权限校验开销不能太大,如果拿来做 C 端业务,单据数量和用户数量都可能会非常大。要减少因为行级别权限引入的开销。所以要能按需打开不同的授权模式。
  • 没有登录的用户需要登录,或者能够匿名浏览。如何方便给没有用户的访问做适当的提权。
  • 非真实人类用户的访问,比如夜间的批处理,需要能够有机器人的账号
  • 账号的初始化是如何描述的,谁来分配最初的那个管理员
  • 如果有多个 Project,每个 Project 有自己的用户体系,不同的 User 表。这两个 Project 要互相访问,用什么 User 身份?

解决了以上问题,我们就获得了一个内建权限的 “Database”,可以开放到公网给前端访问。实际拉数据的时候,用的是人类用户自己的身份。只要用户自己对要访问的数据表或者行有权限,就可以访问到,否则就访问不到。

业务数据表

写一个 Typescript 的类,然后把 Mysql 的表建好。和 Java Hibernate 一样

@Biz.profile('买家', 'read')
@Biz.profile('店长', 'read', 'create', 'update', 'delete')
@Biz.profile('管理员', 'read', 'create', 'update', 'delete')
@Biz.authentic({ rowPolicy: 'public' })
@Biz.published
export class RegionFreightTmpl extends Biz.ActiveRecord {
    @Biz.lookup
    public readonly freightTmpl: FreightTmpl;
    // 计费模式:按件(目前只有按件)
    public freightTmplType: string;
    public firstPrice: number;
    public firstAmount: number = 1;
    public additionalPrice: number;
    public additionalAmount: number = 1;
    public regions?: string;
}

查询的时候直接用这个 class 来指代这张数据库表就可以了。

Data Migration

数据库表结构变更了肯定还是要写 SQL 来升级数据库的,标准做法没啥说的。

视图表

能够做好权限校验的前提是只暴露单表的简单查询。那么 count / sum / join 这些怎么处理? 难道是要发明一种 SQL 变种,然后搞 SQL 解析么?

一个简单的做法就是引入 “视图表” 的概念。把这些聚合查询都建模成一张虚拟的视图表。这样在查询的时候仍然是单表查询。

  • 解决了 rpc 请求的时候如何表达复杂 query 的问题,避免了 sql over http
  • 解决了权限校验难以确定目标是什么的问题

物化视图表

如果所有的聚合查询都要按需计算则会非常慢。经常我们需要一些按日期,按维度提前聚合好的中间结果。这个可以用物化视图表来表达

@(Biz.view`SELECT id, gender, age, city, SUM(OrderItem.cost) AS total
FROM ${impactSet}
JOIN ${User} on User.id = impactSet.userId
LEFT JOIN ${Order} on User.id = Order.userId
LEFT JOIN ${OrderItem} on Order.id = OrderItem.orderId`)
@(impactSet`SELECT DISTINCT(Order.userId) AS userId FROM ${ Order }`)
@(impactSet`SELECT DISTINCT(Order.userId) AS userId FROM ${ OrderItem } JOIN ${ Order } on Order.id = OrderItem.orderId`)
@Biz.source(Starriness, { dataSource: 'clickhouse' })
export class UserWithTotal extends Biz.SqlView {
    public readonly id: string;
    public readonly userId: string;
    public readonly created_at: Date;
    public readonly total: number;
}

物化视图的问题在于什么时候刷新。通过用 SQL 定义 impactSet,我们可以由 mysql binlog 触发物化在 clickhouse 中的物化视图表刷新。

"AI" 表

视图表就是用 SQL 这种编程语言写一个函数,把函数映射成表来查询。那么我们有 Python / Julia 等 AI 语言,也可以把 Python / Julia 函数/机器学习模型映射成表来查询。

用户行为表

物化视图的来源是业务数据。用户行为因为数据量比较大,不太适合直接插入到 mysql 中。把用户行为单独提供一张宽表来记录用户做过什么操作。写入可以是内存缓冲,或者经过 kafka 这样的队列缓冲。

宽表的列应该是按业务需求扩展的,如果业务上关心用户操作的订单 id,或者商品 id,则要加上这些字段。大概的 api 也类似 mixpanel 这些老牌分析厂商的上报接口。

物化视图在计算报表的时候可以 join 用户行为表和业务数据表来得出分析结果。

批量查询

一次 rpc roundtrip 只能发一条查询太慢了。那支持一个数组,一次可以提交多条查询就好了。至于前端代码中怎么把多个组件的查询聚合到一个 rpc 中,这个就看前端的 data query 框架是怎么来弄了。无非就是全局搞个 buffer,在 “合适的时候” 刷一下这个 buffer,批量查一次。

存储过程

细粒度的用户权限只能解决数据完全被一个用户拥有的问题。很多时候数据是协作数据,有多个 stakeholder,那么就必须经过协商好的规则去修改数据,而不是一个用户说了算。例如你可以决定今天的日记本里随便写啥,但是不能决定把今天晚饭的订单改成 0。日记是你拥有的,但是订单是多个相关方都关心的。

解决业务规则校验后写入的问题就是存储过程了。前端同学肯定是希望用 javascript 来写存储过程。实际上就是所谓 FaaS 的云函数。本质上就是后端代码仍然有,只是换了一拨人来写。

如果业务规则比较简单,例如只是一个状态机的转换图,则可以用配置替代 code。当然大部分时候,复杂的业务逻辑,上 javascript 是最直观的。

ORM 的写法一般是

const doc = scene.load(Document, { id: 123 });
doc.content = 'hello';
scene.commit();

通过拦截对象的改动,知道修改什么。然后在 scene.commit 的时候,提交到数据库。这种写法仅仅适用于给用户编辑个自己拥有的文档的场景。我们显然不能允许用户拿这个接口去下订单。

scene.call(PlaceOrder, { products: xxx ... });

所以写操作没啥花样的。GraphQL 称之为 mutation。其实就是 RPC,远程方法调用。

分段执行的存储过程

有的时候一个存储过程执行时间太长了,会导致稳定性问题。例如下单操作,可以以事务完整性为边界,把不影响下单成功与否的业务放到写入订单之后异步完成。本质上就是把一个连续的存储过程分段执行。

这个可以把 kafka 之类的 queue 理解为一张 “事件表”。插入订单表,同时也是触发了一个 OrderCreated 事件。通过描述性的订阅规则,把多个存储过程用事件串联起来执行。对应于流行的 FaaS 概念中 Pub/Sub 触发的 Cloud Function。

对于已有数据的修改如果有 Audit Trail 的需求,也可以类似的自动同步一份 Audit Trail 表来跟踪历史上的修改操作。

GraphQL

GraphQL 写起来是这个样子

{
  Movie(filter: { OR: [{ year_lt: 1920 }, { title_contains: "River Runs" }] }) {
    title
    year
  }
}

某种程度上这些都是 "SQL over HTTP" 的搞法。如果不这么搞,那么我们应该暴露什么样的 API 给前端呢?

简单的单表读操作

很自然的,我们会想到如下的方式去封装读接口:

// 查询多条
scene.query(Document);
scene.query(Document, { author: 'xyz' });
// 查询有且仅一有一条
scene.load(Document, { id: 123 });
scene.load(RefundPolicy);
// 查询可能有一条
scene.tryLoad(Refund, { orderId: 123 ]); // 返回值可能是 undefined
从 HTTP 接口的角度就两个参数
  • Target:查询哪种表
  • KeyValues:表达查询条件的 key - value 对

但是这样的查询接口显然没有办法满足如下的需求:

  • 大于小于等 operator:GraphQL 的做法是 year_lt: 1920 来代表 year < 1920。这个做法在 SQLAlchemy 等 ORM 上就在用
  • OR 条件:多个 key/value 对是 OR 的关系
  • Limit/Offset
  • 各种 Join / Group By / Having

强行把这些需求加到查询接口上,就是得到 APIJSON 或者 GraphQL 这样的东西。

预先定义 SQL

解决办法就像 MyBatis 那样,提前把 SQL 定义好。

@Biz.view`SELECT id, userId, created_at, SUM(OrderItem.cost) AS total
FROM ${Order}
LEFT JOIN ${OrderItem} on Order.id = OrderItem.orderId`
@Biz.source(Starriness, { dataSource: 'mysql' })
export class OrderWithTotal extends Biz.SqlView {
    public readonly id: string;
    public readonly userId: string;
    public readonly created_at: Date;
    public readonly total: number;
}

这样 OrderWithTotal 这张虚拟的表就代表了一条 SQL。

scene.query(OrderWithTotal)

这样就执行了一条 SQL 查询。SQL 也可以添加一些变量,让查询的时候指定

@Biz.view`SELECT id, userId, created_at, SUM(OrderItem.cost) AS total
FROM ${Order}
LEFT JOIN ${OrderItem} on Order.id = OrderItem.orderId
WHERE :lowerTotal <= total AND total <= :upperTotal`
@Biz.source(Starriness, { dataSource: 'mysql' })
export class OrderWithTotal extends Biz.SqlView {
    public readonly id: string;
    public readonly userId: string;
    public readonly created_at: Date;
    public readonly total: number;

    public lowerTotal: number;
    public upperTotal: number;

    @viewFilter('OrderItem.type')
    public type: string;
}

通过传入 lowerTotal, upperTotal, type 就可以做一些自定义

scene.query(OrderWithTotal, { type: 'preorder', lowerTotal: 100, upperTotal: 200 });

通过预定义 SQL,基本上可以满足各种 OLAP 的需求。但是这种写法对于 OLTP 来说是过于麻烦了的。我们不希望 SELECT *,然后把表的字段又重新定义一遍。

SELECT * 的查询

为了快速表达 SELECT * 这样的查询,我们仅需要写 where 部分

@Biz.where('seatCount >= :from AND seatCount <= :to')
@Biz.orderBy('created_at', 'ASC')
export class Reservation_SeatInRange extends Biz.Subset<Reservation> {
    public from: number;
    public to: number;
}

当查询 Reservation_SeatInRange 的时候,仅仅指定条件,拿到的是 Reservation。也就是省掉了 SQL 的 SELECT 部分

const revervations = scene.query(Reservation, { from: 2, to: 5 })

总体的写法和 MyBatis Dynamic SQL 是非常类似的。

解决多次网络请求带来的延迟问题

如果你需要查询 a.b.c.d,前端需要发多次请求到后端拉取数据。拿到了 b 之后才能去拿 c,拿到了 c 之后,才能去拿 d。一次公网上的请求至少 100ms,多次往返就会造成肉眼可见的延迟。

这个问题也是 GraphQL 要解决的初衷,也就是快速从后端加载一个对象图到前端。这个问题有三个不同程度的解决方案

  • 加载对象图的根,并且 prefetch 这个根下面的所有子对象。这个在 MyBatis 中叫 collection property,在 Hibernate 中叫 eager loading。
  • 只加载一部分对象图,用条件进行过滤:这个是 GraphQL 的能力,可以在每个分叉都做过滤
  • 在对象图中做 pattern search:这个是图数据库解决的问题。图数据库的查询语言都有比较复杂的 pattern 声明能力。

大部分 OLTP 业务都不涉及到很大的对象图加载到前端显示的问题。尤其是在手机屏幕上显示。如果需要从大量数据中做转换和聚合,那个应该归纳到 OLAP 查询,用 SQL 去解决。所以剩余的需求就是对于 a.b.c.d 这样的对象图做 prefetch 的问题。

  • 声明对象图:无论是 MyBatis 还是 Hibernate,都有这样的 one-to-many 的关联关系的声明
  • 查询的时候指定 prefetch 对象图

声明大概是这样

export class Order extends Biz.ActiveRecord {
  @Biz.query
  public readonly orderProductItems: OrderProductItem[];
  @Biz.query
  public readonly orderShipment: Ordershipment;
}

然后查询一条订单的时候,我们指定 prefetch

const order = scene.load(Order, { id: 123 }, { orderProductItems: true })
console.log(order.orderProductItems) // already prefetched

这个 prefetch 参数,其实就是一种简化后的 GraphQL

scene.load(Order, { id: 123 }, { 
  orderShipment: true,
  orderProductItems: {
    product: {
      productImages: true
    }
  } 
})

用 true 表示需要 prefetch 这个关联关系,用嵌套的 object 表达还有进一步 prefetch。比 GraphQL 减少的功能是没有办法对关联的关系指定过滤条件,只能全部 prefetch 出来。

用 Hibernate 的写法:

Criteria criteria = sessionFactory.getCurrentSession().createCritiera(Person.class);
criteria.add(Restrictions.idEq(personId);
criteria.setFetchMode("roles", FetchMode.SUBSELECT);

大概的意思是差不多的。但是相比 Hibernate 这种后端数据库访问库,前后端通信更需要解决这种多次网络 roundtrip 的问题。

查询语法小结

  • 提前定义好 SQL,而不是 ad-hoc query。基本上照搬了 MyBatis 的做法
  • 网络请求参数极其简单,就是 Target / KeyValues
  • 对象图的加载带来的网络延迟问题是必须解决的。对象图关系提前声明,添加 Prefetch 参数指定要预取的对象图

前端查询后端,和后端通过 MyBatis / Hibernate 查询数据库是解决差不多的问题。都是在尽量方便的情况下减少 I/O 的开销。都是在把一个关系型数据库当对象图数据库在用。太阳底下真没啥新雪。

Backend as a Database

  • 用 RPC 接口提供了一个类似 Database 的东西
  • 把后端业务切分成了 User 表/业务数据表/视图表/物化视图表/用户行为表/存储过程/DataMigration 等预设的概念,变得更规整
  • 除了 “需要经过业务规则校验之后写入” 这个例外,其他的后端接口都不需要上通用编程语言,可以用配置或者 SQL 定义解决
  • 这个解法既不是 GraphQL,也不是经典的 BFF。排序是 GraphQL - BaaD - BFF,BFF 更后端,GraphQL 更前端,BaaD 介于两者中间。

这不过是历史的又一个轮回。Relational database 刚出来的时候,是以 Rapid application development 平台的方式登场的。在那之前,是每个业务应用都要自己写一个数据库出来。Oracle / Sql 这些免除了每个应用都要发明一套自己的数据库的痛苦,让业务开发可以专注于业务

「软件匠艺社区」旨在传播匠艺精神,通过分享好的「工作方式」,让帮助程序员更加快乐高效地编程!

seabornlee 将本帖设为了精华贴 01月10日 07:55
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册