需求
为了满足从业务整体的维度 实现监控和链路复原,我们希望对于一个业务接口,记录一行请求日志,并通过某个 Unique Id(如UserId、OrderId)将多行日志关联起来,最终产出一批和业务强相关的数据,帮助业务或管理层更加清晰、及时的了解到业务变化情况,做出更合理的业务发展判断。
在需求中,涉及到了digest日志记录、日志数据清洗、日志数据呈现方式等,但在本文档中,我们重点讨论项目中digest日志的记录方案。
digest日志实现时,需要考虑的能力
- 使用成本(监控配置):
- 一行日志,包含一个接口请求的所有监控要素,实现从接口的角度复原业务链路情况;
- 记录规则清晰,持续维护使用文档
- 对比按需打印的业务日志而言,digest日志具有更高的业务监控价值,按需打印的日志更适用于复原某个接口的处理过程
- 扩展性:
- 对每个监控要素,新建唯一的 index,新增监控要素时,只需分配新的 index 即可,不影响原有的监控
- 维护成本:
- 通过添加单测,上线前校验index的唯一性,保证不出现重复index覆盖日志内容
实现方案详情
日志打印框架功能要点:
-
埋点方法:
- 在上下文中新增日志对象,在系统的入口、出口及其他关键位置设置埋点,将关键信息填充至日志对象中。针对每个关键信息分配唯一 index,同类型信息使用连续的index
- 使用 ThreadLocal ,方便在业务流程中,添加日志信息
-
日志打印方法:
- 将关键信息按照 LogFiledEnum 中定义的 Index 排序后,依次打印
-
日志记录不阻塞业务链路:
- 日志记录过程中,捕获所有异常
digest 日志实现方案
实现流程梳理
时序图:

说明:
- RouterTemplate类是所有业务执行时通用模板,用来完成一些公共的、非业务的操作
- AbstractThreadRunnable是提供给业务层、对Runnable二次封装的异步线程执行抽象工具类
- 图中除了1、2两个类,其他为digest基础框架实现类
类图:

说明:
- LogFieldEnum中,name为日志含义名称,其中的属性index表示日志记录的索引位置
- AddDigestFromComplexTypeConsumer中,定义了从复杂对象中,解析并记录日志到指定索引的静态方法
日志打印样例:
2022-10-18 12:49:46,251 [0602257d166609738088945222092 0.9.10 - /// - ] INFO IGPAYROUTER-DIGEST - [0602257d166609738088945222092,0,GNETW7CN,][0,OTHER_SYSTEM][1,alipay.aps.payments.userInitiatedPay][2,1.0][3,3863][4,ApsUserInitiatedPaymentRouterService][5,true][6,SUCCESS][7,-][8,RouterApiRequest][9,1022172000000000001][10,-][11,Success][21,3073][22,alipay.aps.payments.userInitiatedPay][23,1.0][24,true][25,SUCCESS][26,Success][27,1022188000000000001][29,-][30,-][31,3.0.1][32,SUCCESS][33,true][40,1022188000000000001][41,1022172000000000001][42,NET][43,-][50,2019101411058602000007700084912][51,adyen-平台][52,US][53,2188410000010007][54,adyen-平台][55,2019101411058602000007700084912][60,ORDER_CODE_PAYMENT][61,false][62,PAY_ROUTE][63,2022101819074101000050006322437][64,VzbzGHHzhjLCRBMEsSkqJpUWWlBCyFcU][65,-][66,-][67,ACCEPT][68,PAY_ORDER][69,false][70,-][71,-][72,-][73,-][74,ACCEPT][75,-][76,1022188000000000001|2188410000010007|2019101411058602000007700084912][77,-][78,1022188000000000001@2188410000010007@2019101411058602000007700084912][79,-][80,KRW][81,777][82,USD][83,68][84,KRW][85,777][86,-][87,-][100,false][101,false][102,-][103,false][104,-][105,-][106,-][107,-][108,WEB][109,-][110,https://global.alipay.com/281002040017Rc4B1k6dQ9fwjd3pdiscount][111,-][112,false][113,-][114,-][115,-][116,API][117,-][118,-][119,-][120,-][200,-][201,-][202,-][203,-][220,-][221,-][222,-][223,-][224,-][300,true][301,NO_USER_ID][320,-][321,-][323,-][324,MN400401000000000122][325,-][328,-][331,-][332,-][333,-][334,-][335,-][336,-][337,-][338,-][339,-][340,-][1000,-][1003,-][1006,-][1007,-][1008,-][1009,-][1010,-][1011,false][1012,false][1013,-][1014,-][1015,-][1016,true][1017,-][1018,1022172000000000001][1019,-][1020,true][1021,-][1022,-][1023,-]
关键代码梳理与说明:
digest日志核心模型:
/*** digest日志记录、打印核心模型**/
public class RouterDigestLog {private static final Logger LOGGER = LoggerFactory.getLogger("IGPAYROUTER-DIGEST");private final static ThreadLocal<RouterDigestLog> TL = ThreadLocal.withInitial(RouterDigestLog::new);//digest日志上下文。为避免扩容,initialCapacity = LogFieldEnum.values().length/0.75+1private Map<LogFieldEnum, Object> digestFields = new HashMap<>();/*** 在日志上下文中添加值** @param fieldEnum* @param value*/public static void add(LogFieldEnum fieldEnum, Object value) {if (Objects.nonNull(fieldEnum)) {TL.get().digestFields.put(fieldEnum, value);}}/*** 打印日志并清理线程上下文*/public static void printAndRemove() {try {// 记录一些与业务完全无关的digest内容// 记录一些与业务完全无关的digest内容--endLogUtil.info(LOGGER, TL.get().toString());} finally {remove();}}/*** 获取当前上下文中的值,不存在时返回空字符串** @param fieldEnum* @return null:未填值*/public static Object getFieldValue(LogFieldEnum fieldEnum) {return TL.get().digestFields.get(fieldEnum);}/*** 获取当前上下文中的值,并将转换为Sting类型,值为空时返回"-"** @param fieldEnum* @return*/private static String getFieldStringValue(LogFieldEnum fieldEnum) {return Objects.toString(getFieldValue(fieldEnum), "-");}/*** 清理线程上下文*/public static void remove() {TL.remove();}/*** 重写 toString* * @return 只返回在digestFields中,填充过LogFieldEnum的内容,未填充过的不记录*/@Overridepublic String toString() {//对digest上下文的key进行进行排序后,再转stringreturn digestFields.entrySet().stream().sorted(Comparator.comparingInt(e -> e.getKey().getIndex())).map(e -> "[" + e.getKey().getIndex() + ","+ getFieldStringValue(e.getKey()).replaceAll("[\\[\\],\n]", " ") + "]").collect(Collectors.joining());}/*** 克隆摘要日志上下文实例** @return*/public RouterDigestLog cloneInstance() {RouterDigestLog routerDigestLog = new RouterDigestLog();Map<LogFieldEnum, Object> digestFields = new HashMap<>(this.digestFields);routerDigestLog.setDigestFields(digestFields);return routerDigestLog;}/*** 获取摘要日志上下文** @return*/public static RouterDigestLog get() {return TL.get();}/*** 设置摘要日志上下文。** @param context 上下文内容*/public static void set(RouterDigestLog context) {TL.set(context);}/*** Setter method for property <tt>digestFields</tt>.** @param digestFields value to be assigned to property digestFields*/public void setDigestFields(Map<LogFieldEnum, Object> digestFields) {this.digestFields = digestFields;}
}
复杂对象 digest 内容解析:
/*** 复杂对象与解析器的关系枚举*/
public enum ClassForDigestEnum {/*** 从commonOrder对象中添加摘要日志** @see CommonOrder*/COMMON_ORDER(CommonOrder.class, AddDigestFromComplexTypeConsumer::addFromCommonOrder),/*** 异常对象** @see RouterCommonException*/EXCEPTION(RouterCommonException.class, AddDigestFromComplexTypeConsumer::addFromException);/*** 复杂对象类型*/private Class classType;/*** 按照复杂类型添加摘要日志处理器*/private Consumer addDigestConsumer;<T> ClassForDigestEnum(Class<T> clazz, Consumer<T> addDigestConsumer) {this.classType = clazz;this.addDigestConsumer = addDigestConsumer;}/*** 用复杂对象批量添加摘要日志,兼容null对象** @param object*/static void addFieldsFromObject(Object object) {for (ClassForDigestEnum value : values()) {if (value.classType.isInstance(object)) {value.addDigestConsumer.accept(object);return;}}}
}
/*** 从复杂类型中添加digest日志方法*/
class AddDigestFromComplexTypeConsumer {/*** 从commonOrder中添加digest日志**/static void addFromCommonOrder(CommonOrder commonOrder) {//指定向ACQUIRE_ID的位置,添加内容addIfValueEmpty(LogFieldEnum.ACQUIRE_ID, commonOrder.getSourceSiteId());forceAdd(LogFieldEnum.REPEAT_REQUEST, commonOrder.isRepeatRequest());}
}
异步任务中 digest 的复制
//统一线程池执行器管理对象
protected ThreadPoolExecutorManager threadPoolExecutorManager;……//异步任务提交入口。初始化AbstractThreadRunnable时,拷贝当前线程的digest日志上下文到创建的新线程中
threadPoolExecutorManager.getExecutor().submit(new AbstractThreadRunnable() {@Overridepublic void doRealRun() {DigestLogUtil.forceAdd(LogFieldEnum.REQUEST_SOURCE,RequestSourceEnum.ASYN_THREAD);forwardAndApprovePayment(payOrder, srPayRequest, response, function);}});……
/*** 异步线程执行器**/
public abstract class AbstractThreadRunnable extends EchoxRunnable {/*** BIZ-LOGGER*/private static final Logger BIZ_ERROR = LoggerFactory.getLogger(LoggerConstant.ROUTER_BIZ_ERROR);/*** ERROR-LOGGER*/private static final Logger SYSTEM_ERROR = LoggerFactory.getLogger(LoggerConstant.SYSTEM_ERROR);/*** 新建线程时,临时存储日志上下文*/private RouterDigestLog routerDigestLog;/*** 构造方法*/public AbstractThreadRunnable() {initRouterRunnable();}/*** 初始化方法*/public final void initRouterRunnable() {super.init();// 初始化子线程上下文信息routerDigestLog = RouterDigestLog.get().cloneInstance();}@Overridepublic void doRun() {try {beforeRun();doRealRun();} catch (RouterCommonException e) {DigestLogUtil.addFieldsFromObject(e);LogUtil.warn(BIZ_ERROR, e, "ASYNC_THREAD_EXCEPTION", e.getResultCode().getErrorName(),e.getResultCode().getResultMsg());} catch (Throwable t) {LogUtil.error(SYSTEM_ERROR, t, "ASYNC_THREAD_EXCEPTION.");} finally {afterRun();}}/*** 执行真正的操作。交给子类实现*/public abstract void doRealRun();/*** 前置方法,添加上下文,添加日志等*/private void beforeRun() {// 添加摘要日志上下文RouterDigestLog.set(routerDigestLog);// 修改摘要日志来源DigestLogUtil.forceAdd(LogFieldEnum.REQUEST_SOURCE, RequestSourceEnum.ASYN_THREAD);}/*** 后置方法,清理上下文,打印日志等*/private void afterRun() {// 打印新摘要日志DigestLogUtil.printAndRemove();}
}
对方案的思考:
- 复杂对象字段的解析行为,是通过遍历 enum 中维护的映射来完成的。该行为属于复杂对象模型的行为,却不在模型中,不够内聚,不符合项目中DDD的规范。可以考虑解析的动作定义在基类中,由具体的子类来实现解析的动作。
- 日志的落地,使用文件的方式进行记录,没有从代码层面上预留扩展能力,未来需要将 digest 日志同时落地到DB或投递到Msg时,需要修改已有的代码,不符合Open Closed Principle。
监控:
- 监控规则:按 左起第0个 “[indexValue,” ,右至第0个 “]” 的规则,来实现对每个业务接口中的字段进行监控
- 由于digest日志是系统接口层面的,所以当需要复原一个完整业务链路时,需要借助贯穿业务链路的Unique Id,将多个digest日志串联起来。常用的Unique Id有UserId、OrderId等。
数据分析:
实现方案 v2.0
针对思考中提到的两个问题,我们做如下两个方面的升级:
- 对于项目内自建的复杂对象,其digest日志解析方法,内聚到各自的请求 DTO / Request / Domain 中,通过重写基类中定义的 extractFields2DigestContext 方法来实现digest日志字段定制化添加。
- 对于集成的第三方对象,如果需要解析digest日志,有如下两种方案的对比选型:
- 在业务流程中,按需提取需要的部分字段
- 优点:日志字段解析在业务流程中,更好的贴合业务,适用场景更广
- 缺点:日志解析逻辑分散在各个业务流程中,不方便查看digest字段的记录情况
- 将对象的digest日志解析,参考方案一中的方式,将对象和对应的日志解析方法用AddDigestFromComplexTypeConsumer维护起来
- 优点:集成的第三方对象中,digest日志被统一管理起来,方便查看对象有哪些解析的字段
- 缺点:如果对象中的日志字段,需要按业务场景进行记录时,需要在业务中使用forceAdd
- 在业务流程中,按需提取需要的部分字段
- 日志的落地,使用责任链设计模式,按需选择一种或多种落地的方式
- 对于方案的选用,需结合实际业务发展方向,不要做过度的设计,但也不要不预留扩展能力。
实现方案详情:
时序图:

类图:




















