14

开个通用的 HTTP 接口,让前端写 SQL 咋样?

 3 years ago
source link: https://zhuanlan.zhihu.com/p/337185052
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

开个通用的 HTTP 接口,让前端写 SQL 咋样?

problem solver

陶文:如何让前端程序员没有后端也能完成项目? 简单概述了 Backend as a "Database"。对于前端怎么查询后端并没有详细展开。那么后端是不是应该开个通用的 HTTP 接口,这样前端同学直接写 SQL 就好了呢?

最近有一个开源项目 Tencent/APIJSON 就是这么搞的。GraphQL 其实也是差不多,写起来是这个样子

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

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

ORM 的写法一般是

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

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

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

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

简单的单表读操作

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

// 查询多条
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 的开销。都是在把一个关系型数据库当对象图数据库在用。太阳底下真没啥新雪。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK