背景
在移动互联网时代,用户体验是最重要的。美团服务体验平台期望可以协助客户处理在选择美团产品、购买美团产品以及使用美团产品过程中碰到的各类问题,切实达成“以客户为中心”,为客户排除困难,解决问题。服务体验平台内部仅维护客户的客诉数据,为精准预判并更好解决客户遇到的问题,系统须依赖业务部门提供的一些业务数据,这些业务数据包括但不限于订单数据、退款数据、产品数据等。本文会着重讲述在整个系统交互过程中遇到的一些问题,接着分享在实践中探索出的经验和方法论,期望能给大家带来一些启发。
问题对接场景广而杂
首先,接入服务体验平台服务的业务方数量众多且繁杂,并且还在持续拓展,其中包括直接面向用户的C端服务、面向客服的工单服务等。美团拥有众多业务线,例如外卖、酒店、旅游、打车、交通、到店餐饮、到店综合、猫眼等。其中部分业务延伸出多条子业务线,大交通部门就包含火车票,汽车票,国内机票,国际机票,船票等。具体到每条子业务线的每个业务场景,客户都可能遇到问题。针对这些场景,服务体验平台服务需调用对应的业务数据接口,以帮助用户自助或客服协助解决这些问题。就美团当前的业务来说,这样的场景数量会达到万级,并且业务形态持续迭代,还会挖掘出更多场景,这些都需要持续对接更多业务数据来提供支撑。
接入场景定制化要求高
其次,接入服务体验平台服务的业务方定制化要求极高。业务场景差异化显著,不同接入方都期望定制特殊复杂逻辑,需要服务体验平台提供与业务深度耦合的服务解决方案。这就要求服务体验平台侧深入了解接入方业务逻辑和数据接口,对这些业务数据进行组装,针对每个场景开展定制开发。
方案早期方案
为解决上述问题,初期做系统设计时,考虑到业务方多为既有系统,因此服务体验平台服务倾向于平台化设计,还引入了适配层。服务体验平台内部对所有业务数据和逻辑进行统一抽象,对内标准化接口,屏蔽业务逻辑和接口的差异。所有定制化逻辑都在适配层中封装。但这需要客服侧RD针对所有场景编写适配器代码,客服侧RD要从一个或多个业务部门接口获取业务数据,然后将这些业务数据转换成内部实际场景所需的数据。
其系统交互如下图所示:
缺点
上述系统设计可以满足业务方面的要求,不过,它存在两个较为明显的缺点 。
编码工作量繁重
如上图所示,每个业务场景都要编写适配器以满足需求,要是依赖的外部接口较少,场景又比较单一,按上述方案实施尚可接受。然而业务接入繁多且杂乱,给客服侧RD带来了极为繁重的工作量,这工作量涵盖适配器编写,以及后续维护过程中对下游业务接口的持续跟踪和监控。
客服侧RD需要深入了解业务方逻辑
另外,客服侧RD不熟悉业务模型,解析业务模型并组装最终展示给客户的数据,要比业务方RD花更多时间梳理和实现,还要花更多时间验证正确性。比如下面是一个真实案例:组装业务接口,对业务数据进行处理
公共类TicketAdapterServiceImpl实现了OrderAdapterService接口
@Resource,其name属性的值为"tradeQueryClient"
私有贸易票据查询客户端,贸易票据查询客户端名为tradeTicketQueryClient;
@Resource
拥有一个名为哥伦布票务服务的对象,将其命名为哥伦布票务服务对象,此对象属于私有类型,把它定义为哥伦布票务服务 。
/**
根据订单ID,获取与门票相关的订单数据,获取与门票相关的数据,获取退款数据等
**/
@Override
处理订单请求数据传输对象,返回公共订单信息数据传输对象
ListtradeDetailFieldList被创建,其为一个新的ArrayList 。();
将 IT radeTicketQueryService.TradeDetailField.ORDER 添加到 tradeDetailFieldList 中 。
将 IT radeTicketQueryService.TradeDetailField.TICKET 添加到 tradeDetailFieldList 中 。
将“ITradeTicketQueryService.TradeDetailField.REFUND_REQUEST”添加到“tradeDetailFieldList”中 。
try {
//通过接口A得到部分订单数据、门票数据和退款数据
RichOrderDetail richOrderDetail被获取,通过tradeTicketQueryClient调用getRichOrderDetailById方法,该方法的参数为orderRequestDTO.getOrderId()以及tradeDetailFieldList 。
如果丰富的订单详情为空,
return null;
}
如果富订单详情中的订单详情为空 ,
return null;
}
获取丰富订单详情中的订单详情,将其赋值给订单详情对象
获取丰富订单详情中的退款详情,将其赋值给退款详情变量
创建一个名为OrderInfoDTO的对象,将其命名为orderInfoDTO 。
解析接口A返回的字段,处理这些字段,从而得到客服侧场景真正需要的数据。
将“dealId”这个键值对放入“orderInfoDTO”中,其对应的值是“orderDetail”的“mtDealId”属性的值 。
将代金券代码的值放入订单信息数据传输对象中,该代金券代码通过获取丰富订单详情得到,获取代金券代码的方法为调用getVoucherCode(richOrderDetail) 。
将 DomesticTicketField.REFUND_CHECK_DUE 的值放入 orderInfoDTO 中,放入的值是通过 getRefundCheckDueDate(richOrderDetail) 获取的退款检查截止日期 。
将 DomesticTicketField.REFUND_RECEIVED_DUE 的值放入 orderInfoDTO 中,放入的值为通过 richOrderDetail 获取退款到期日期的结果 。
//根据接口B获取另外一些订单数据、门票详情数据、退款数据
ColumbusTicketDTO 由 columbusTicketService 通过 richOrderDetail 的订单详情中的 MtDealId(转换为整数类型)来获取 ,并赋值给 columbusTicketDTO 。
如果哥伦布票务数据传输对象为空,
return orderInfoDTO;
}
解析接口B返回的字段,处理这些字段,从而得到客服侧场景真正需要的数据。
将哥伦布机票DTO中的退款信息,放入订单信息DTO里,对应的键是国内机票字段中的退款信息的值 。
将“DomesticTicketField.USE_METHODS.getValue()”对应的值放入“orderInfoDTO”中,放入的值是“columbusTicketDTO.getUseMethods()”的值 。
将哥伦布机票DTO中的预订信息,放入订单信息DTO的国内机票字段的预订信息位置 。
将“DomesticTicketField.INTO_METHOD.getValue()”对应的值,放入“orderInfoDTO”中,该值取自“columbusTicketDTO.getIntoMethod()” 。
return orderInfoDTO;
} catch (TException e) {
记录错误信息为“查询不到对应的订单详情”,同时记录异常e 。
return null;
}
}
}
探索将适配层交由业务方实现
为了克服早期方案的两个不足,一开始,我们期望把场景数据的准备工作交给对业务较为熟悉的团队,同时把业务模型的解析工作也交给该团队,也就是将适配层交由业务方去实现 。
这样做的话优势和劣势也比较明显。
优势
客服只需专注自身领域的服务,将平台化工作做好,把数据提供的任务交给业务团队,如此便解放了客服侧的RD。
劣势
但这给业务方带来了较大的工作量,业务方现有的服务复用性很低,对于客服侧每一个需要数据的场景,都得重新封装新的服务。
更好的解决方案?
这个时候我们进行思考,思考的内容是:是否可以做到既能让业务方解析自身的业务数据,又能够尽可能地利用已有的服务呢?我们经过考虑后,打算把既有服务的组装过程,以及模型的转换,都交由一个服务编排的中间件来实现。使用这个中间件存在一个前提,业务方提供的既有服务要支持泛化调用,要避免调用方直接依赖服务方客户端,文章下一个小节会补充对于泛化调用的解释。其交互模型如下图所示:
结果-海盗中间件简介什么是海盗?
海盗是一个中间件,它能对支持泛化调用(上述所说)的服务进行编排,进而获取预期结果。使用这个中间件,调用方能够依据场景对目标服务进行编排,并按需调用。
何为泛化调用?
通常情况下,服务提供方所提供的服务,都会拥有自身的接口协议,例如,存在一个用于获取订单数据的服务:
package com.dianping.demo;
public interface DemoService{
获取通过订单ID查询的订单数据传输对象,订单ID以字符串形式传入
}
调用方调用该服务时,需要引入该接口协议,也就是依赖该服务提供的JAR包。若调用方要集成多方数据,就需要依赖大量API。并且服务方接口升级时,客户端也得跟着升级。泛化调用能够解决这个问题,借助泛化调用,客户端可在服务方未提供接口协议时对服务进行调用,且不依赖服务方API,通过类似这样一个接口来处理所有的服务请求。
如下是一个泛化调用的Demo:
public class DemoInvoke{
public void genericInvoke(){
/** 调用方配置 **/
InvokerConfig创建一个新的调用器配置对象,其服务接口为“com.dianping.demo.DemoService”,服务类为“com.dianping.pigeon.remoting.common.service.GenericService.class”
设置调用者配置的超时时间为1000 ,
调用者配置设置通用类型为JSON的名称
将调用者配置的调用类型设置为“同步” 。
/** 泛化调用 **/
最终,获取一个通用服务对象,该对象通过服务工厂根据调用者配置来获取 。
List paramTypes = new ArrayList();
参数类型添加“java.lang.String”
List paramValues = new ArrayList();
参数值添加“0000000001”
将字符串 result 赋值为,通过通用服务调用名为 getById 的方法,传入参数类型 paramTypes 和参数值 paramValues 所得到的结果。
}
}
有了这个泛化调用的前提条件,我们便能够将重点放在思考如何对服务进行编排上,之后再对取得的结果进行处理 。
DSL设计
首先重新梳理一下海盗的设计目标:
为了实现服务编排,要定义一个数据结构,用来描述服务之间的依赖关系、调用顺序、调用服务的入参和出参等。对获取的结果进行处理时,也需在这个数据结构中具体描述对何种数据进行怎样的处理等。
所以我们要定义一套 DSL(领域特定语言),用它来描述整个服务编排的蓝图,其语法是这样的:
{
定义好需要调用的接口,明确接口之间的依赖关系,一个接口调用就是一个task。
"tasks": [
//第一个task
{
"url" 是pigeon发布的远程服务地址,其值为 "http://helloWorld.test.hello" 。
“alias”是别名,在结果取值时,能够通过别名进行引用。
“taskType”的类别,一般可以设置为“PigeonGeneric”,默认方式是“pigeonAgent” 。
这是要调用的pigeon接口的方法名,其值为getByDoubleRequest
"timeout": 3000, //task的超时时间
入参情况,多个入参以 key:value 的结构来书写,key 的类别由下面的 inputsExtra 定义 。
"helloWorld": {
"name": "csophys", 可以通过#orderId从上下文中获取值,还可以通过$d1.orderId的形式从其他的task中获取值。
"sex": "boy"
},
"name": "winnie"
},
“inputsExtra”,这是入参key的类别定义
"helloWorld"的值为,"com.dianping.csc.pirate.remoting.pigeon.pigeon_generic_demo_service.HelloWorld"
"name": "java.lang.String"
}
},
//另一个task
{
“url”的值为“http://helloWorld.test.hello” 。
"alias": "d2",
"taskType": "PigeonGeneric",
“方法”为“通过双重请求获取”
"inputsExtra": {
"helloWorld": "com.dianping.csc.pirate.remoting.pigeon.pigeon_generic_demo_service.HelloWorld",
"name": "java.lang.String"
},
"timeout": 3000,
"inputs": {
"helloWorld": {
"name": "csophys",
"sex": "boy"
},
"name": "winnie"
}
}
],

“name”是DSL的名称定义,它暂时没有特别含义 ,其值为“pigeonGenericUnitDemo” 。
“pigeon泛型调用测试”,这是DSL的描述 。
定义好最后输出的数据模型,其名为“outputs” 。
"d1name": "$d1.name",
"languages": "$d2.languages",
“language1”的值为“$d2.languages数组中的第一个元素” 。
"name": "csophys"
}
}
架构设计
有了用于描述整个编排蓝图的 DSL 之后,海盗会对该 DSL 进行解析,之后对服务进行具体调用,其整体架构如下所示:
其中涉及到几个重点概念:
主要
海盗具有如下主要特点:
场景:需要依据订单ID来查询订单状态,还需要查询支付状态,然而当前并没有现成的接口能够支持这个功能,不过存在两个既有接口,分别是:
那我们可以对这两个接口进行编排,编写DSL如下:
{
"tasks": [
{
"url": "http://test.service",
"alias": "d1",
"taskType": "PigeonGeneric",
"method": "getByOrderId",
"timeout": 3000,
"inputs": {
"orderId": "#orderId"
},
"inputsExtra": {
"name": "java.lang.String"
}
},
{
"url": "http://test.service",
"alias": "d2",
"taskType": "PigeonGeneric",
"method": "getPayStatus",
"timeout": 3000,
"inputs": {
“支付序列号”为“$d1.paySerialNo”
},
"inputsExtra": {
"time": "java.lang.String"
}
}
],
"name": "test",
组装上述接口,以此获取订单状态,还能获取支付状态。
"outputs": {
“订单状态”,等于“$d1.订单状态” 。
"payStatus": "$d2.payStatus"
}
}
然后客户端进行调用:
String DSL = "上述DSL文件";
字符串参数的值为,{"orderId":"000000001"}。
响应对象resp被赋值为,通过海盗引擎调用DSL,并传入参数params所得到的结果 。
最后得到的数据即为调用场景真正需要的数据:
{
"orderStatus":1,
"payStatus":2
}
开发流程变化
因为获取数据的架构产生了变化,开发流程也随之发生改变。
如图所示,由于客服侧RD不再频繁向业务方RD确认返回数据的含义与逻辑,双方RD能够各自专注于熟悉的领域,开发效率得以显著提升,最终结果的准确性也有明显提高。
总结和展望最后总结一下使用海盗之后的优势展望
海盗的技术规划:
作者简介招聘广告
服务体验平台能够深入触及公司的全部业务,推动业务发展以改善产品,进而提升客户的服务体验,打造出一个贴近客户的智能服务助手,借助技术手段更迅速地解决客户问题,同时最大程度节省客服的人力成本。欢迎有意愿的同学加入服务体验平台,上海和北京均有需求。简历请投递至:.chen#
工作时间:8:00-18:00
电子邮件
扫码二维码
获取最新动态