业务维度digest日志的记录与监控方案
需求
为了满足从业务整体的维度 实现监控和链路复原,我们希望对于一个业务接口,记录一行请求日志,并通过某个 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+1
private 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内容--end
LogUtil.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的内容,未填充过的不记录
*/
@Override
public String toString() {
//对digest上下文的key进行进行排序后,再转string
return 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() {
@Override
public 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();
}
@Override
public 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
- 在业务流程中,按需提取需要的部分字段
- 日志的落地,使用责任链设计模式,按需选择一种或多种落地的方式
- 对于方案的选用,需结合实际业务发展方向,不要做过度的设计,但也不要不预留扩展能力。
实现方案详情:
时序图:
类图: