背景java
移动互联网时代,用户体验为王。美团服务体验平台但愿可以帮助客户解决在选、购、用美团产品过程当中遇到的各类问题,真正作到“以客户为中心”,为客户排忧解难。后端
但服务体验平台内部只维护客户的客诉数据,为了精准地预判和更好地解决客户遇到的问题,系统必须依赖业务部门提供的一些业务数据,包括但不限于订单数据、退款数据、产品数据等等。 本文会着重讲一下在整个系统交互过程当中遇到的一些问题,而后分享一下在实践中探索出来的经验和方法论,但愿可以给你们带来一些启发。性能优化
问题数据结构
对接场景广而杂架构
首先,须要接入服务体验平台服务(包括直接面向用户的C端服务、面向客服的工单服务等等)的业务方很是多且杂,并且在不断拓展。美团有很是多的业务线,好比外卖、酒店、旅游、打车、交通、到店餐饮、到店综合、猫眼等等。其中部分业务又延展出多条子业务线,好比大交通部门包含火车票、汽车票、国内机票、国际机票、船票等等。具体到每一条子业务线的每个业务场景,客户都有可能会遇到问题。ide
对于这些场景,服务体验平台服务都须要调用对应的业务数据接口,来帮助用户自助或者客服协助解决这些问题。就美团现有的业务而言,这样的场景数量会达到万级。并且业务形态在不断迭代,还会有更多的场景被挖掘出来,这些都须要持续对接更多的业务数据来进行支撑。函数
接入场景定制化要求高工具
其次,接入服务体验平台服务的业务方定制化要求很高。由于业务场景的差别化很是大,不一样的接入方都但愿可以定制特殊复杂逻辑,须要服务体验平台提供的服务解决方案与业务深度耦合。这就须要服务体验平台侧对接入方业务逻辑和数据接口深刻了解,并对这些业务数据进行组装,针对每一个场景进行定制开发。性能
方案单元测试
早期方案
为了解决上述问题,初期在作系统设计时候,考虑业务方可能是既有系统,因此服务体验平台服务趋向平台化设计,并引入了适配层。服务体验平台内部对全部的业务数据和逻辑进行统一抽象,对内标准化接口,屏蔽掉业务逻辑和接口的差别。全部的定制化逻辑都在适配层中封装。但这须要客服侧RD对全部的场景去编写适配器代码,将从一个或者多个业务部门接口中拿到的业务数据,转成内部实际场景须要的数据。
其系统交互以下图所示:
缺点
虽然上述系统设计能知足业务上的要求,可是存在两个比较明显的缺点:
编码工做量繁重
如上图所示,每一个业务场景都须要编写适配器来知足需求,若是依赖的外部接口比较少,场景也比较单一,按照上述方案实施还能够接受。但业务接入很是多且杂,给客服侧RD带来了很是繁重的工做量,包括适配器编写以及后续维护过程当中对下游业务接口的持续跟踪和监控。客服侧RD须要深刻了解业务方逻辑
另外,因为客服侧RD对于业务模型的不熟悉,解析业务模型而后组装最终展现给客户的数据,须要比业务方RD花更多的时间来梳理和实现,而且花费更多的时间来验证正确性。好比下面是一个真实的组装业务接口并对业务数据进行处理的案例:
public class TicketAdapterServiceImpl implements OrderAdapterService {
@Resource(name = "tradeQueryClient")
private TradeTicketQueryClient tradeTicketQueryClient;
@Resource
private ColumbusTicketService columbusTicketService;
/**
* 根据订单ID获取门票相关的订单数据、门票数据、退款数据等
**/
@Override
public OrderInfoDTO handle(OrderRequestDTO orderRequestDTO) {
List<ITradeTicketQueryService.TradeDetailField> tradeDetailFieldList = new ArrayList<ITradeTicketQueryService.TradeDetailField>();
tradeDetailFieldList.add(ITradeTicketQueryService.TradeDetailField.ORDER);
tradeDetailFieldList.add(ITradeTicketQueryService.TradeDetailField.TICKET);
tradeDetailFieldList.add(ITradeTicketQueryService.TradeDetailField.REFUND_REQUEST);
try {
//经过接口A获得部分订单数据、门票数据和退款数据
RichOrderDetail richOrderDetail = tradeTicketQueryClient.getRichOrderDetailById(orderRequestDTO.getOrderId(), tradeDetailFieldList);
if (richOrderDetail == null) {
return null;
}
if (richOrderDetail.getOrderDetail() == null) {
return null;
}
OrderDetail orderDetail = richOrderDetail.getOrderDetail();
RefundDetail refundDetail = richOrderDetail.getRefundDetail();
OrderInfoDTO orderInfoDTO = new OrderInfoDTO();
//解析和处理接口A返回的字段,获得客服侧场景真正须要的数据
orderInfoDTO.put("dealId", orderDetail.getMtDealId());
orderInfoDTO.put(DomesticTicketField.VOUCHER_CODE.getValue(), getVoucherCode(richOrderDetail));
orderInfoDTO.put(DomesticTicketField.REFUND_CHECK_DUE.getValue(), getRefundCheckDueDate(richOrderDetail));
orderInfoDTO.put(DomesticTicketField.REFUND_RECEIVED_DUE.getValue(), getRefundReceivedDueDate(richOrderDetail));
//根据接口B获取另一些订单数据、门票详情数据、退款数据
ColumbusTicketDTO columbusTicketDTO = columbusTicketService.getByDealId((int) richOrderDetail.getOrderDetail().getMtDealId());
if (columbusTicketDTO == null) {
return orderInfoDTO;
}
//解析和处理接口B返回的字段,获得客服侧场景真正须要的数据
orderInfoDTO.put(DomesticTicketField.REFUND_INFO.getValue(), columbusTicketDTO.getRefundInfo());
orderInfoDTO.put(DomesticTicketField.USE_METHODS.getValue(), columbusTicketDTO.getUseMethods());
orderInfoDTO.put(DomesticTicketField.BOOK_INFO.getValue(), columbusTicketDTO.getBookInfo());
orderInfoDTO.put(DomesticTicketField.INTO_METHOD.getValue(), columbusTicketDTO.getIntoMethod());
return orderInfoDTO;
} catch (TException e) {
Cat.logError("查询不到对应的订单详情", e);
return null;
}
}
}
探索
将适配层交由业务方实现
为了克服早期方案的两个缺点,最初,咱们但愿可以把场景数据的准备和业务模型的解析工做,都交给对业务比较熟悉的团队来处理,即将适配层交由业务方来实现。
这样作的话优点和劣势也比较明显:
优点
客服这边关注本身的领域服务就好,作好平台化,数据提供都交给业务团队,解放了客服侧RD。
劣势
但对业务方来讲带来了比较大的工做量,业务方既有服务的复用性很低,对客服侧每个须要数据的场景,都要从新封装新的服务。
更好的解决方案?
这个时候咱们思考:是否能够既能让业务方解析本身的业务数据,又可以尽可能利用既有服务呢?咱们考虑把既有服务的组装过程以及模型的转换都让一个服务编排的中间件来实现。可是使用这个中间件有一个前提,就是业务方提供出来的既有服务必须支持泛化调用,避免调用方直接依赖服务方客户端(文章下一个小节也会补充下对于泛化调用的解释)。其交互模型以下图所示:
海盗中间件
简介
什么是海盗?
海盗就是一个用来对支持泛化调用(上述所说)的服务进行编排,而后获取预期结果的一个中间件。使用该中间件调用方能够根据场景来对目标服务进行编排,按需调用。
何为泛化调用?
一般服务提供方提供的服务都会有本身的接口协议,好比一个获取订单数据的服务:
package com.dianping.demo;
public interface DemoService{
OrderDTO getById(String orderId);
}
而调用方调用该服务须要引入该接口协议,即依赖该服务提供的JAR包。若是调用方须要集成多方数据,那就须要依赖很是多的API,同时服务方接口升级客户端也须要随之进行升级。而泛化调用就能够解决这个问题,经过泛化调用客户端能够在服务方没有提供接口协议和不依赖服务方API的状况下对服务进行调用,经过相似GenericService
这样一个接口来处理全部的服务请求。
以下是一个泛化调用的Demo:
public class DemoInvoke{
public void genericInvoke(){
/** 调用方配置 **/
InvokerConfig<GenericService> invokerConfig = new InvokerConfig("com.dianping.demo.DemoService", com.dianping.pigeon.remoting.common.service.GenericService.class);
invokerConfig.setTimeout(1000);
invokerConfig.setGeneric(GenericType.JSON.getName());
invokerConfig.setCallType("sync");
/** 泛化调用 **/
final GenericService genericService = ServiceFactory.getService(invokerConfig);
List<String> paramTypes = new ArrayList<String>();
paramTypes.add("java.lang.String");
List<String> paramValues = new ArrayList<String>();
paramValues.add("0000000001");
String result = genericService.$invoke("getById", paramTypes, paramValues);
}
}
有了这个泛化调用的前提,咱们就能够重点去思考如何对服务进行编排,而后对取得的结果进行处理了。
DSL设计
首先从新梳理一下海盗的设计目标:
对既有服务进行编排调用
对获取的数据进行处理
而为了实现服务编排,须要定义一个数据结构来描述服务之间的依赖关系、调用顺序、调用服务的入参和出参等等。以后对获取的结果进行处理,也须要在这个数据结构中具体描述对什么样的数据进行怎么样的处理等等。
因此咱们须要定义一套DSL(领域特定语言)来描述整个服务编排的蓝图,其语法以下:
{
//定义好须要调用的接口以及接口之间的依赖关系,一个接口调用即为一个task
"tasks": [
//第一个task
{
"url": "http://helloWorld.test.hello", //url 为pigeon发布的远程服务地址:
"alias": "d1", //别名,结果取值的时候能够经过别名引用
"taskType": "PigeonGeneric", //task的类别通常能够设置为PigeonGeneric,默认是pigeonAgent方式。
"method": "getByDoubleRequest", //要调用的pigeon接口的方法名
"timeout": 3000, //task的超时时间
"inputs": { //入参状况,多个入参经过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",
"method": "getByDoubleRequest",
"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": "pigeonGenericUnitDemo", //DSL的名称定义,暂时没有特别含义
"description": "pigeon泛型调用测试", //DSL的描述
"outputs": { //定义好最后输出的数据模型
"d1name": "$d1.name",
"languages": "$d2.languages",
"language1": "$d2.languages[0]",
"name": "csophys"
}
}
架构设计
有了DSL来描述整个编排蓝图以后,海盗天然要对该DSL进行解析,而后对服务进行具体调用。其总体架构以下所示:
其中涉及到几个重点概念:
Facade:对外提供统一接口,供客户端调用。
Parser:对于输入的DSL进行解析,解析成内部流转的数据结构,同时获得全部的task,而且构建task调用逻辑树。
Executor:真实发起调用的模块,目前支持平台内部的Pigeon和MTThrift调用方式,同时对HTTP等其余协议有良好的扩展性。
DataProcessor:数据后处理。这边会把全部接口拿到的数据转换层客服场景这边须要的数据,而且经过设计的一些内部函数,能够支持一些如数据半脱敏等功能。
组件插件化:对日志等功能实现可插拔,调用方能够自定义这些组件,即插即用。
主要Feature
海盗具备以下主要特色:
采用去中心化的设计思路,引擎集成在SDK中。方案通用化,每个须要业务数据的场景均可以经过海盗直接调用数据提供方。
服务编排支持并行和串行调用,使用方能够根据实际场景本身构造服务调用树。经过DSL的方式把以前硬编码组装的逻辑实现了配置化,而后经过海盗引擎把能并行调用的服务都执行了并行调用,数据使用方不用再本身处理性能优化。
使用JSON DSL 描述整个工做蓝图,简单易学。
支持JSONPath语法对服务返回的结果进行取值。
支持内置函数和自定义指令(语法参考ftl)对取到的元数据进行处理,获得须要的最终结果。
编排服务树可视化。
目前集团内部RPC中间件包括Pigeon、MTThrift,已进行了泛化调用支持,能够经过海盗实现Pigeon服务和MTThrift的服务编排。不须要限制业务团队的服务提供方式,但须要升级中间件版本。这里特别感谢服务治理团队的大力支持。
Tutorial
场景:须要根据订单ID查询订单状态和支付状态,但目前没有现成的接口支持该功能,但有两个既有接口分别是:
接口1:根据订单ID,获取到订单状态和支付流水号
接口2:根据支付流水号获取支付状态
那咱们能够对这两个接口进行编排,编写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": {
"paySerialNo": "$d1.paySerialNo"
},
"inputsExtra": {
"time": "java.lang.String"
}
}
],
"name": "test",
"description": "组装上述接口获取订单状态和支付状态",
"outputs": {
"orderStatus": "$d1.orderStatus",
"payStatus": "$d2.payStatus"
}
}
而后客户端进行调用:
String DSL = "上述DSL文件";
String params = "{\"orderId\":\"000000001\"}";
Response resp = PirateEngine.invoke(DSL, params);
最后获得的数据即为调用场景真正须要的数据:
{
"orderStatus":1,
"payStatus":2
}
开发流程变化
由于获取数据的架构产生了变化,开发流程也随之发生改变。
如图所示,由于减小了客服侧RD不断去向业务方RD确认返回的数据含义和逻辑,双方RD各自专一各自熟悉的领域,开发效率和最终结果准确性都有显著提高。
总结和展望
最后总结一下使用海盗以后的优点:
去中心化的设计,可用性获得保证。
服务复用性高,领域划分更加清晰,让RD专一在本身熟悉的领域,下降研发成本。
由于流程变化后,业务方能够提早验证提供的数据,高质量交付。
客服侧对数据获取进行统一收口,能够对全部调用服务统一监控并对数据统一处理。
展望
海盗的技术规划:
丰富内部函数和运算表达式
目前海盗提供了一部分简单的内部函数用来对取到的值进行简单处理,同时正在实现支持调用方自定义运算表达式来支持复杂场景的数据处理,这部分须要持续完善。屏蔽远程调用协议异构性
目前海盗只支持对美团Pigeon和MTThrift服务进行编排,这里要对协议进行扩展,支持相似HTTP等通用协议,同时支持调用方自定义协议和调用实现。运营工具完善
提供一个比较完整的运营工具,调用方能够自行配置DSL并进行校验,而后一键调用查询最终结果。同时调用方能够经过该工具进行日志、报表等相关数据查询。自动生成单元测试
可以把通过验证的DSL生成相应的单元测试用例给到数据提供方,持续保障提供的DSL的可用性和正确性。
做者简介
王彬,美团资深研发工程师,毕业于南京大学,2017年2月加入美团。目前主要专一于智能客服领域,从过后端工做。
陈胜,海盗项目负责人,智能客服技术负责人,2013年加入大众点评。在将来智能客服组会持续在平台化和垂直领域方向深刻下去,为消费者、商家、企业提供更加智能的客户服务体验。
---------- END ----------
招聘信息
服务体验平台能够深刻接触到公司的全部业务,推动业务改善产品。提高客户的服务体验。打造一个客户贴身的智能服务助手。经过技术的手段更快地解决客户的问题,而且最大程度地节省客服的人力成本。欢迎有意向的同窗加入服务体验平台,上海、北京都有需求。简历请投递至:sheng.chen#dianping.com
也许你还想看