Discourse 出品的 Active Record (半) 代替品

148 阅读6分钟
原文链接: ruby-china.org
jasl #1 · 2018年06月21日

不得不说,Sam Saffron 和 Mike Perham 频频对 Ruby 还有 Rails core team 就性能问题发难,而且有理有据,已经成功引起了重视。

另外我忘了在哪看到的他有提到 Discourse 会出钱赞助 AR 6 的重构,有钱事情应该就好办多了...

2楼 已删除 ksec #3 · 2018年06月21日 jasl 回复

有錢事情應該就好辦多了

我反而觉得这个不是钱的问题, 原因是根本没有人肯去处理. 世界上最难解决的问题就是人们不觉得这是一个问题. 至少 DHH 不觉得是.

jasl #4 · 2018年06月21日 ksec 回复

DHH 这种架构师角色的人,不会去关注那么细的工作,开源社区,谁行谁上

很多事情都需要社区推动的,比如 Shopify、Discourse 这种大厂去贡献,Basecamp 说白了还是体量太小,很多场景他们没需求自然也没有意识去做

ksec #5 · 2018年06月21日 jasl 回复

很多事情都需要社區推動的,比如 Shopify、Discourse 這種大廠去貢獻,Basecamp 說白了還是體量太小,很多場景他們沒需求自然也沒有意識去做

感动, 😭 难得有人有相同想法. 我经常在想怎么能够让 Shopify , Discourse, Cookpad, Github, AirBnB, Bloomberg, 每边找一两个人一同商讨. ( 其实 Apple Music 也是 Ruby Rails, 不过相信要他们派人简直难过登天 ) jasl #6 · 2018年06月21日 ksec 回复

你看 5.2 的发行注记,已经提到了是 Shopify 引入的 Bootsnap 加速应用启动,还有 SamSaffron 和 Mike Perham 他们“逼宫” Ruby team 引入一些有利于常驻服务场景的优化,可能之后 Ruby 会引入单独的编译参数把这些优化开启,类似 Java Hotspot VM 分服务器版本和标准版本一样。

CRuby 发展不如 NodeJS 其实道理也很简单,就是没钱... Ruby Together 算是一个社区组织改善公共基础设施的,但是那些开发也要吃饭,要赞助到一定规模(按月出钱)才能考虑全职去做事情。NodeJS 依赖 V8,Chrome 保持话语权的关键组件,Google 投人投钱去改进 V8,Node 自然坐享其成

nouse #7 · 2018年06月21日

一个裸sql就能代替ar?标题党啊

hooopo #8 · 2018年06月22日

这个确实很有用,之前用Rails做项目遇到统计需求很尴尬,用AR来做特别难,一般最终结果就写成了sql+rails的合体,导致可维护性特别差。

本质上是统计需求和ORM并不是同一个维度上的事情,ORM解决的是表和对象之间的关系,而统计需求其实是面向主题的。 另外一个问题是ORM做统计的话经常会产生N+1查询。所以统计需求我一般都直接纯sql的方式来写,不使用任何AR相关API,例如:

AR.connection.execute(<<-SQL)
  /* 按SKU分组,计算出出仓数量 */
  WITH shipment_items_by_sku AS (
      SELECT product_variant_id, SUM(quantity) AS ship_count
        FROM shipment_items
       WHERE deleted_at IS NULL AND company_id = 1
    GROUP BY 1
  ),
  /* 按SKU分组,计算出订单数量、最大金额等 */
  order_items_by_sku AS (
    SELECT pv.company_id,
           oi.product_variant_id,
           sum(oi.quantity)   AS order_count,
           sum(oi.sum_price)  AS sum_money,
           max(oi.price)      AS max_money,
           min(oi.price)      AS min_money,
           avg(oi.price)      AS avg_money
      FROM order_items AS oi
           INNER JOIN product_variants AS pv ON pv.id = oi.product_variant_id
     WHERE pv.deleted_at IS NULL AND oi.deleted_at IS NULL AND oi.company_id = 1
  GROUP BY 1,2
  ORDER BY 3 DESC
     LIMIT 10
  )

  SELECT order_items_by_sku.*, 
         coalesce(sibs.ship_count, 0) AS ship_count
    FROM order_items_by_sku
         LEFT JOIN shipment_items_by_sku AS sibs ON order_items_by_sku.product_variant_id = sibs.product_variant_id
ORDER BY order_items_by_sku.order_count DESC
SQL

但这么写的话带来一个问题,用户传参的地方需要自己去防止sql注入,不过mini sql的出现解决了sql注入问题(使用的prepared statment),我觉得mini sql当然不是去和ORM比,其实是弥补了ORM在统计和ETL领域的不足。看了一下mini sql在discourse的应用,也是用来做统计相关:

darkbaby123 #9 · 2018年06月23日

@hooopofind_by_sql 可以对付注入,就是不大好看,必须用某个具体的 model 来调用该方法。加上结果是该 model 的实例加上从 SELECT 部分动态生成的属性就非常奇怪了。这个问题可以通过建立一个专门的 stat model 来解决,避免尴尬。另外 sanitize_sql_xxx 的那批方法应该也可以解决注入问题(没试过),不过用起来就非常麻烦了。

虽然统计不是 ORM 需要解决的问题,一个是 OLAP 一个是 OLTP 。但有时候两者都需要共用一些查询条件和表关联,而且有时候相似的统计 SQL 也会有些代码碎片是重复的,这时候就又回到如何重用的问题上来了。query builder 本身用在构造 SQL 上是合适的,问题是 AR 的 query builder 抽象程度还不够,经常写成了大批的 SQL 片段,也就是你说的 SQL + Rails 的方式。

hooopo #10 · 2018年06月24日 darkbaby123 回复

Yes.

对于简单场景,用ORM来做reporting也是可行的。对复杂场景,不能说AR抽象程度不够,本质上每个gem有自己的解决的领域。

# Sam: complicated reports are really hard in AR

代码重复问题,其实这就是一个tradeoff,ORM里的方法对于reporting来说,理论上确实可以共用,但实现上是很难共用,硬要共用的话,必然是写成N+1的方式,之前做过一个项目,reporting部分就是这种思路写的,数据量还不大的时候一个页面就可以达到几十秒。

如果reporting部分想共用的话,其实需要写UDF和view,试过几次效果还不错。

刚看了这篇文章,mini sql的用途写的非常清晰了:meta.discourse.org/t/new-offic…

 # Sam: complicated reports are really hard in AR
 builder = DB.build <<-SQL
SELECT ftl.url,
       COALESCE(ft.title, ftl.title) AS title,
       ftl.link_topic_id,
       ftl.reflection,
       ftl.internal,
       ftl.domain,
       MIN(ftl.user_id) AS user_id,
       SUM(clicks) AS clicks
FROM topic_links AS ftl
LEFT JOIN topics AS ft ON ftl.link_topic_id = ft.id
LEFT JOIN categories AS c ON c.id = ft.category_id
/*where*/
GROUP BY ftl.url, ft.title, ftl.title, ftl.link_topic_id, ftl.reflection, ftl.internal, ftl.domain
ORDER BY clicks DESC, count(*) DESC
LIMIT 50
SQL

 builder.where('ftl.topic_id = :topic_id', topic_id: topic_id)
darkbaby123 #11 · 2018年06月24日 hooopo 回复

所以呢我说的其实是 query builder 的抽象能力。因为大部分 ORM 都有个 query builder 因此它也算是 ORM 的一部分。如果完全没有抽象的话对查询的共用部分就只能拼接 SQL 字符串了。mini_sql 的 builder.where 看起来已经够用了。我再举个 Ecto (Elixir 语言的 data wrapper)的例子,实现 Sam 文中提到的完整 SQL :

from(
  ftl in TopicLink,
  left_join: ft in assoc(ftl, :link_topic),
  left_join: c in assoc(ft, :category),
  group_by: [ftl.url, ft.title, ftl.title, ftl.link_topic_id, ftl.reflection, ftl.internal, ftl.domain],
  order_by: [desc: sum(ftl.clicks), desc: count(ftl.id)],
  limit: 50,
  select: %{
    url: ftl.url,
    title: coalesce(ft.title, ftl.title),
    link_topic_id: ftl.link_topic_id,
    reflection: ftl.reflection,
    internal: ftl.internal,
    domain: ftl.domain,
    user_id: min(ftl.user_id),
    clicks: sum(clicks)
  }
)
|> where([ftl, ...], ftl.topic_id == ^topic_id)
|> where([_, ft, ...], is_nil(ft.deleted_at))
|> where([ftl, ...], not (ilike(ftl.url, "%.png") or ilike(ftl.url, "%.jpg") or ilike(ftl.url, "%.gif")))
|> where([_, ft, ...], coalesce(ft.archetype, "regular") != ^Archetype.private_message)
|> secure_category(guardian.secure_category_ids)
|> Repo.all()

拿它举例并不是抬杠或者吹 Ecto 如何,我相信以 Ruby 的灵活性可以做到同样的事情(只是感觉没多少人觉得它很重要)。只是说明语言层面提供的抽象可以用来更好的组织和重用代码。这点我个人还是觉得很重要的。毕竟 SQL 作为声明式的语言不善于表达过程式的代码,而生成 query 的过程恰好是过程式的,应用层的语言擅长此道。

另外拿我最近做的一个统计做例子:

query_1 =
  FunnelItem
  |> filter_by_subject(subject_id)
  |> filter_by_type(:fam)
  |> score_btw(3, 4)

query_2 =
  FunnelItem
  |> filter_by_subject(subject_id)
  |> filter_by_type(:ove)
  |> score_btw(6, 7)

query_3 =
  FunnelItem
  |> filter_by_subject(subject_id)
  |> filter_by_type(:puc)
  |> score_eq(4)

[query_1, query_2, query_3]
|> join_queries()
|> stat_by_filters(filter_key, filter_value_ids)

上面的例子把部分 where 条件抽成了共用的函数,再自定义了一些函数来组合查询,比如 join_queries

def join_queries(queries) do
  Enum.reduce(queries, fn query, acc ->
    from(
      [f1, ...] in acc,
      join: ft in ^query,
      on: ft.sample_id == f1.sample_id and ft.subject_id == f1.subject_id
    )
  end)
end