业务维度digest日志的记录与监控方案

需求

​   为了满足从业务整体的维度 实现监控和链路复原,我们希望对于一个业务接口,记录一行请求日志,并通过某个 Unique Id(如UserId、OrderId)将多行日志关联起来,最终产出一批和业务强相关的数据,帮助业务或管理层更加清晰、及时的了解到业务变化情况,做出更合理的业务发展判断。

   在需求中,涉及到了digest日志记录、日志数据清洗、日志数据呈现方式等,但在本文档中,我们重点讨论项目中digest日志的记录方案。

digest日志实现时,需要考虑的能力

  • 使用成本(监控配置)
    • 一行日志,包含一个接口请求的所有监控要素,实现从接口的角度复原业务链路情况;
    • 记录规则清晰,持续维护使用文档
    • 对比按需打印的业务日志而言,digest日志具有更高的业务监控价值,按需打印的日志更适用于复原某个接口的处理过程
  • 扩展性
    • 对每个监控要素,新建唯一的 index,新增监控要素时,只需分配新的 index 即可,不影响原有的监控
  • 维护成本
    • 通过添加单测,上线前校验index的唯一性,保证不出现重复index覆盖日志内容

实现方案详情

日志打印框架功能要点:

  • 埋点方法:

    • 在上下文中新增日志对象,在系统的入口、出口及其他关键位置设置埋点,将关键信息填充至日志对象中。针对每个关键信息分配唯一 index,同类型信息使用连续的index
    • 使用 ThreadLocal ,方便在业务流程中,添加日志信息
  • 日志打印方法:

    • 将关键信息按照 LogFiledEnum 中定义的 Index 排序后,依次打印
  • 日志记录不阻塞业务链路:

    • 日志记录过程中,捕获所有异常

digest 日志实现方案

实现流程梳理

时序图

请添加图片描述

说明:

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

类图:
请添加图片描述

说明:

  1. LogFieldEnum中,name为日志含义名称,其中的属性index表示日志记录的索引位置
  2. 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();
    }
}

对方案的思考:

  1. 复杂对象字段的解析行为,是通过遍历 enum 中维护的映射来完成的。该行为属于复杂对象模型的行为,却不在模型中,不够内聚,不符合项目中DDD的规范。可以考虑解析的动作定义在基类中,由具体的子类来实现解析的动作。
  2. 日志的落地,使用文件的方式进行记录,没有从代码层面上预留扩展能力,未来需要将 digest 日志同时落地到DB或投递到Msg时,需要修改已有的代码,不符合Open Closed Principle。

监控:

  • 监控规则:按 左起第0个 “[indexValue,” ,右至第0个 “]” 的规则,来实现对每个业务接口中的字段进行监控
  • 由于digest日志是系统接口层面的,所以当需要复原一个完整业务链路时,需要借助贯穿业务链路的Unique Id,将多个digest日志串联起来。常用的Unique Id有UserId、OrderId等。

数据分析:

实现方案 v2.0

针对思考中提到的两个问题,我们做如下两个方面的升级:

  1. 对于项目内自建的复杂对象,其digest日志解析方法,内聚到各自的请求 DTO / Request / Domain 中,通过重写基类中定义的 extractFields2DigestContext 方法来实现digest日志字段定制化添加。
  2. 对于集成的第三方对象,如果需要解析digest日志,有如下两种方案的对比选型:
    • 在业务流程中,按需提取需要的部分字段
      • 优点:日志字段解析在业务流程中,更好的贴合业务,适用场景更广
      • 缺点:日志解析逻辑分散在各个业务流程中,不方便查看digest字段的记录情况
    • 将对象的digest日志解析,参考方案一中的方式,将对象和对应的日志解析方法用AddDigestFromComplexTypeConsumer维护起来
      • 优点:集成的第三方对象中,digest日志被统一管理起来,方便查看对象有哪些解析的字段
      • 缺点:如果对象中的日志字段,需要按业务场景进行记录时,需要在业务中使用forceAdd
  3. 日志的落地,使用责任链设计模式,按需选择一种或多种落地的方式
    • 对于方案的选用,需结合实际业务发展方向,不要做过度的设计,但也不要不预留扩展能力。

实现方案详情:

时序图:

请添加图片描述

类图:

请添加图片描述

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值