虽然公司内的存储和数据系统名目繁多,但一般难以满足领域(就是 DDD 里说的那个 domain)内或子域(subdomain)内的所有需求,在业务部门内部还有着花样繁多的各种数据系统。这样的现状会导致业务接入数据服务的开发成本非常高。
这么说可能比较抽象,举个例子,如果你们公司有 LBS 相关的业务,一般一定会有一个坐标相关的服务,可以使用用户 id 获取瞬时坐标信息。也可能用一个订单或者配送 id 获得某件事情的发生轨迹。坐标轨迹相关的服务和地图类的数据强相关,所以这种服务一般是由单独的部门提供。
再比如说,公司内有公共的订单服务,但主服务不可能为各种相对独立的支撑域(supporting domain)的业务需求,无穷无尽地加字段,这种时候一般支持域会在其内部构建自己业务强相关的业务数据服务。这里的支持域可以认为是例如运营、安全、反作弊之类的支持系统,这些系统不得不做,但与核心业务不相关;支持业务发展,但不是公司业务的核心诉求。
在大公司这样的架构应该是较为普遍的现状,在某公司内,接入任一外部系统时,均需使用其提供的 SDK,这些 sdk 其实就是一种 RPC 功能的 client,在引入到你的系统之后,看起来对外部系统的调用就是一个函数调用。这是服务化/微服务化的一种优势,号称“降低了程序员在分布式服务情境下的心智负担”。但如果我的核心业务流程要接入 50+ 或者 200+ 的其它数据系统,这件事情就不好玩了。
心智负担倒是降低了,但是体力活却明显增加了。我们理想状态下的,高度抽象的数据服务应该都能够拥有统一的接口,比如就像数据库提供的 SQL 一样,如果我去任意的下游服务读数据,只是在配置系统里加个数据源,再随便加几行配置,并且这个过程还能够 UI 化。那么这个接入成本就很低,比如:
从订单服务拿数据,我只需要指定 db = order_system && user = system_name && pass = my_password
,然后 select * from order where order_id = xxx
或者 select * from order where driver_id = yyy
或者 select * from order where passenger_id = zzz
。如果要接入另外的系统了,那可能是 db = user_system && user = system_name && pass = my_password
,然后 selevt * from user where user_id = aaa
。
可能你还没有看明白使用 SQL 的好处,我来帮你总结一下,我们可以用简单的元组来描述任意的下游数据:
既然能以某种形式来将这些数据定义出来,那我们就可以将数据获取流程进行完全的配置化。现实的情况是,我们下游一般都不可能提供这样 SQL 类型的接口,这里我们来做一一分析。
有不少内网服务提供的是 http 接口,这类服务可以再分为两个类型。
还是按照 DB/SQL 的思路,这种外部服务我们可以用下列元组来进行描述:
vip + port 可以让我们请求找到正确的内网 nginx 集群。uri 让我们找到正确的服务和接口。request type 让我们区分怎么样构建 http 请求的 header。request template 则可能是根据 request type 来自动对应的工场方法,填入适当的配置便可以生成正确的 query string 和 http body。result description 则帮助我们正确解析服务返回的结果。
前五条应该不难理解,我们来重点说一说最后一个 result description。下面是一个比较典型的服务返回结果:
{ "errno": 0, "errmsg": "success", "data": { "driver_id": 110, "name": "Xargin", "num": 1314 } }
json 结构本身和树的意思差不多,可以用路径来描述其中某一项的值,在上面的结果中,如果我想知道结果的 driver_id
,那么我可以用 #data.driver_id 来描述,这种字符串更权威的叫法是 "jpath"。使用 jpath 便可以将下游返回中的字段一一映射到 jpath,那么我们获取一项数据可以用非常简单的字符串来表示:
#errno == 0 ? -1 : #data.driver_id;
这里 -1 只是为了描述下游出错,可以先不用在意。我们自己实现这样的 jpath 并不是很难,比如最直观的想法,把 json 结构 unmarshal 到 map[string]interface{},再用一个简单的递归/for 循环就可以做到数据的抽取了。
公司内如果有服务发现系统的话,会给每一个服务独立的名字,有些可能还有个 namespace,这也不要紧,总之对服务的描述一定是一个唯一字符串。其它的和上面 vip + uri 的外部服务基本一致。
在服务发现的情况下,我们想要只写一套代码去支持所有下游请求,理论上挺难的。一般情况下每个服务都会有独立的 SDK,不集成进系统就没有办法访问该服务。所以无论如何也省不掉这个集成的成本。但接入之后,对下游系统请求、以及返回结果的描述与上一节是完全一致的。
如果恰巧使用的是 Go 语言,即使我们做到了高度的配置化,那从 api -> function call 的过程,也必须有类似下面这样的 map
map[string]func() => get_order : GetOrderHandler, get_driver : GetDriverHandler,
handler 可能长这样
func GetOrderHandler(param map[string]string)
和上面 提供服务发现端点的外部服务 一节讲的差不多:
不再赘述。
既然我们可以用这些 N 元组来描述外部的数据,那如果系统内接入了多种存储,是不是也可以用类似的思路来做呢?当然可以:
用 redis 内部的结构名字,基本就可以确认在 Get 的时候我们需要干什么,比如:
用 key / subkey,就可以定位数据位置。
其实和 http 差不多,没什么可说的。
在文章开头已经说过了,这里不再赘述。
上面是目前各类乌七八糟的存储系统,怎么去做抽象。因为中大型公司的系统接口基本上很难推动升级,所以基本上在这样的前提下,能把接入做到完全配置化就已经是胜利了。但这实际上也只是解决了眼下的问题,从组织的角度来讲,公共的数据系统多起来,每一个部门都会面临同样的问题,大家都在做着类似的抽象,有的部门做的好,有的部门做的烂。这部分其实也是一笔可观的人力浪费。
本文中没有讨论资源管理的问题,无论我们使用哪种外部服务,都会涉及到一些资源管理问题,这里面最多的其实也就是一些网络资源如连接池的管理。使用短连接效率太低,但使用连接池又需要程序在启动时创建大量连接,虽然理论上我们也可以通过配置下发来进行动态的连接池管理,但至少目前我所在的这家公司还没有看到哪个神仙系统实现了这样的操作,嗯。
如果你们公司在技术方面比较激进,并且可以尝试一些更好的方案,那要怎么做呢?个人思考,大概是三种思路
一是在收集到所有数据系统的需求之后,大家公用一套 IDL,无论是 gRPC 或者 Thrift 都好,保证同样类型的系统,只有一套 API 方案。这样流程系统在接入数据系统时会轻松很多,可以进行统一抽象。
提供公共的数据层 proxy,公司所有部门接入数据系统全部要经过该 proxy,由 proxy 进行统一抽象,降低流程系统的接入负担。
直接使用业界的一些既有方案,比如:GraphQL,并强制要求所有系统必须支持该标准。
可惜的是,目前我们哪一个都没有。