《最佳化介面設計的思路》系列:第三篇—留下使用者呼叫介面的痕跡

sum墨發表於2023-09-18

前言

大家好!我是sum墨,一個一線的底層碼農,平時喜歡研究和思考一些技術相關的問題並整理成文,限於本人水平,如果文章和程式碼有表述不當之處,還請不吝賜教。

作為一名從業已達六年的老碼農,我的工作主要是開發後端Java業務系統,包括各種管理後臺和小程式等。在這些專案中,我設計過單/多租戶體系系統,對接過許多開放平臺,也搞過訊息中心這類較為複雜的應用,但幸運的是,我至今還沒有遇到過線上系統由於程式碼崩潰導致資損的情況。這其中的原因有三點:一是業務系統本身並不複雜;二是我一直遵循某大廠程式碼規約,在開發過程中儘可能按規約編寫程式碼;三是經過多年的開發經驗積累,我成為了一名熟練工,掌握了一些實用的技巧。

介面設計是整個系統設計中非常重要的一環,其中包括限流、許可權、入參出參、切面等方面。設計一個好的介面可以幫助我們省去很多不必要的麻煩,從而提升整個系統的穩定性和可擴充套件性。作為介面設計經驗分享的第三篇,我想分享一下如何在使用者使用過程中留下操作痕跡。在實際開發中,我會採取一些手段來記錄使用者操作,例如使用日誌記錄使用者行為,或者在資料庫中儲存使用者操作記錄。這些痕跡可以幫助我們快速定位和解決問題,同時也可以為後續資料分析和最佳化提供有價值的參考。

方法一、將介面的引數和結果列印在日誌檔案中

日誌檔案是我們記錄使用者使用痕跡的第一個地方,我之前寫過一篇SpringBoot專案如何配置logback.xml的文章來實現系統日誌輸出,有興趣的同學可以去看看。
這裡我主要講一下怎麼方便將所有介面的出入參列印出來。

1、使用aop監控介面

依賴如下

<!-- aspectj -->
<dependency>
  <groupId>org.aspectj</groupId>
  <artifactId>aspectjweaver</artifactId>
  <version>1.9.5</version>
</dependency>

如果有同學不知道aspectj是啥的,可以看我這篇文章SpringBoot整合aspectj實現面向切面程式設計(即AOP)

關鍵程式碼如下

package com.summo.aspect;

import java.util.Objects;

import javax.servlet.http.HttpServletRequest;

import com.alibaba.druid.util.StringUtils;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

@Aspect
@Component
@Slf4j
public class ControllerLoggingAspect {

    /**
     * 攔截所有controller包下的方法
     */
    @Pointcut("execution(* com.summo.controller..*.*(..))")
    private void controllerMethod() {

    }

    @Around("controllerMethod()")
    public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
        long startTime = System.currentTimeMillis();
        //獲取本次介面的唯一碼
        String token = java.util.UUID.randomUUID().toString().replaceAll("-", "").toUpperCase();
        MDC.put("requestId", token);

        //獲取HttpServletRequest
        RequestAttributes ra = RequestContextHolder.getRequestAttributes();
        ServletRequestAttributes sra = (ServletRequestAttributes)ra;
        HttpServletRequest request = sra.getRequest();

        // 獲取請求相關資訊
        String url = request.getRequestURL().toString();
        String method = request.getMethod();
        String uri = request.getRequestURI();
        String params = request.getQueryString();
        if (StringUtils.isEmpty(params) && StringUtils.equals("POST", method)) {
            if (Objects.nonNull(joinPoint.getArgs())) {
                for (Object arg : joinPoint.getArgs()) {
                    params += arg;
                }
            }
        }
        // 獲取呼叫方法相信
        Signature signature = joinPoint.getSignature();
        String className = signature.getDeclaringTypeName();
        String methodName = signature.getName();
        log.info("@http請求開始, {}#{}() URI: {}, method: {}, URL: {}, params: {}",
            className, methodName, uri, method, url, params);
        //result的值就是被攔截方法的返回值
        try {
            //proceed方法是呼叫實際所攔截的controller中的方法,這裡的result為呼叫方法後的返回值
            Object result = joinPoint.proceed();
            long endTime = System.currentTimeMillis();
            //定義請求結束時的返回資料,包括呼叫時間、返回值結果等
            log.info("@http請求結束, {}#{}(), URI: {}, method: {}, URL: {}, time: {}ms ",
                className, methodName, uri, method, url, (endTime - startTime));

            return result;
        } catch (Exception e) {
            long endTime = System.currentTimeMillis();
            log.error("@http請求出錯, {}#{}(), URI: {}, method: {}, URL: {}, time: {}ms",
                className, methodName, uri, method, url, (endTime - startTime), e);
            throw e;
        } finally {
            MDC.remove("requestId");
        }
    }
}

2、增加requestId

由於介面的呼叫都是非同步的,所以一旦QPS上來,那麼介面的呼叫就會很混亂,不加一個標識的話,就不知道哪個返回值屬於那個請求的了。
這個時候我們則需要加一個requestId(或者叫traceId)用來標識一個請求。

也即這段程式碼

//獲取本次介面的唯一碼
String token = java.util.UUID.randomUUID().toString().replaceAll("-", "").toUpperCase();
MDC.put("requestId", token);

... ... 
MDC.remove("requestId");

同時logback.xml中也需要加一下requestId的列印,在logback.xml中可以使用%X{requestId}獲取到MDC中新增的遍歷。
完整的logback.xml配置檔案如下:

<configuration>
    <!-- 預設的一些配置 -->
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    <!-- 定義應用名稱,區分應用 -->
    <property name="APP_NAME" value="monitor-test"/>
    <!-- 定義日誌檔案的輸出路徑 -->
    <property name="LOG_PATH" value="${user.home}/logs/${APP_NAME}"/>
    <!-- 定義日誌檔名稱和路徑 -->
    <property name="LOG_FILE" value="${LOG_PATH}/application.log"/>
    <!-- 定義警告級別日誌檔名稱和路徑 -->
    <property name="WARN_LOG_FILE" value="${LOG_PATH}/warn.log"/>
    <!-- 定義錯誤級別日誌檔名稱和路徑 -->
    <property name="ERROR_LOG_FILE" value="${LOG_PATH}/error.log"/>

    <!-- 自定義控制檯列印格式 -->
    <property name="FILE_LOG_PATTERN" value="%green(%d{yyyy-MM-dd HH:mm:ss.SSS}) [%blue(requestId: %X{requestId})] [%highlight(%thread)] ${PID:- } %logger{36} %-5level - %msg%n"/>

    <!-- 將日誌滾動輸出到application.log檔案中 -->
    <appender name="APPLICATION"
              class="ch.qos.logback.core.rolling.RollingFileAppender">
        <!-- 輸出檔案目的地 -->
        <file>${LOG_FILE}</file>
        <encoder>
            <pattern>${FILE_LOG_PATTERN}</pattern>
            <charset>utf8</charset>
        </encoder>
        <!-- 設定 RollingPolicy 屬性,用於配置檔案大小限制,保留天數、檔名格式 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <!-- 檔案命名格式 -->
            <fileNamePattern>${LOG_FILE}.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <!-- 檔案保留最大天數 -->
            <maxHistory>7</maxHistory>
            <!-- 檔案大小限制 -->
            <maxFileSize>50MB</maxFileSize>
            <!-- 檔案總大小 -->
            <totalSizeCap>500MB</totalSizeCap>
        </rollingPolicy>
    </appender>

    <!-- 摘取出WARN級別日誌輸出到warn.log中 -->
    <appender name="WARN" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${WARN_LOG_FILE}</file>
        <encoder>
            <!-- 使用預設的輸出格式列印 -->
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
            <charset>utf8</charset>
        </encoder>
        <!-- 設定 RollingPolicy 屬性,用於配置檔案大小限制,保留天數、檔名格式 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <!-- 檔案命名格式 -->
            <fileNamePattern>${LOG_PATH}/warn.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <!-- 檔案保留最大天數 -->
            <maxHistory>7</maxHistory>
            <!-- 檔案大小限制 -->
            <maxFileSize>50MB</maxFileSize>
            <!-- 檔案總大小 -->
            <totalSizeCap>500MB</totalSizeCap>
        </rollingPolicy>
        <!-- 日誌過濾器,將WARN相關日誌過濾出來 -->
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>WARN</level>
        </filter>
    </appender>

    <!-- 摘取出ERROR級別日誌輸出到error.log中 -->
    <appender name="ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${ERROR_LOG_FILE}</file>
        <encoder>
            <!-- 使用預設的輸出格式列印 -->
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
            <charset>utf8</charset>
        </encoder>
        <!-- 設定 RollingPolicy 屬性,用於配置檔案大小限制,保留天數、檔名格式 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <!-- 檔案命名格式 -->
            <fileNamePattern>${LOG_PATH}/error.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <!-- 檔案保留最大天數 -->
            <maxHistory>7</maxHistory>
            <!-- 檔案大小限制 -->
            <maxFileSize>50MB</maxFileSize>
            <!-- 檔案總大小 -->
            <totalSizeCap>500MB</totalSizeCap>
        </rollingPolicy>
        <!-- 日誌過濾器,將ERROR相關日誌過濾出來 -->
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>ERROR</level>
        </filter>
    </appender>

    <!-- 配置控制檯輸出 -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${FILE_LOG_PATTERN}</pattern>
            <charset>utf8</charset>
        </encoder>
    </appender>


    <!-- 配置輸出級別 -->
    <root level="INFO">
        <!-- 加入控制檯輸出 -->
        <appender-ref ref="CONSOLE"/>
        <!-- 加入APPLICATION輸出 -->
        <appender-ref ref="APPLICATION"/>
        <!-- 加入WARN日誌輸出 -->
        <appender-ref ref="WARN"/>
        <!-- 加入ERROR日誌輸出 -->
        <appender-ref ref="ERROR"/>
    </root>
</configuration>

3、效果如下圖

4、介面監控遇到的一些坑

返回值資料量很大會刷屏,儘量不要列印返回值。
檔案上傳介面會直接掛掉,所以上傳的介面一般不會加入監控。

方法二、將風險高的操作儲存到資料庫中

雖然方法一能夠記錄每個介面的日誌,但這些日誌只存在於伺服器上,並且有大小和時間限制,到期後就會消失。這種做法對所有請求或操作都一視同仁,不會對風險較高的請求進行特殊處理。為了解決危險操作帶來的風險,我們需要將其持久化,以便在出現問題時能夠快速找到原因。最常見的做法是將風險高的操作儲存到資料庫中。
實現原理還是使用方法一種的切面,不過這裡使用的是註解切面,具體做法請見下文。

1、新建一張log表,儲存風險操作

表結構如下:

建表語句我也貼出來

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for user_oper_log
-- ----------------------------
DROP TABLE IF EXISTS `user_oper_log`;
CREATE TABLE `user_oper_log` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '物理主鍵',
  `operation` varchar(64) DEFAULT NULL COMMENT '操作內容',
  `time` bigint DEFAULT NULL COMMENT '耗時',
  `method` text COMMENT '操作方法',
  `params` text COMMENT '引數內容',
  `ip` varchar(64) DEFAULT NULL COMMENT 'IP',
  `location` varchar(64) DEFAULT NULL COMMENT '操作地點',
  `response_code` varchar(32) DEFAULT NULL COMMENT '應答碼',
  `response_text` text COMMENT '應答內容',
  `gmt_create` datetime DEFAULT NULL COMMENT '建立時間',
  `gmt_modified` datetime DEFAULT NULL COMMENT '更新時間',
  `creator_id` bigint DEFAULT NULL COMMENT '建立人',
  `modifier_id` bigint DEFAULT NULL COMMENT '更新人',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4  COMMENT='使用者操作日誌表';

SET FOREIGN_KEY_CHECKS = 1;

核心欄位為操作方法、引數內容、IP、操作地點、應答碼、應答內容、建立人這些,其中IP和操作地址這兩個是推算的,不一定很準。這些欄位也不是非常全面,如果大家還有自己想記錄的欄位資訊也可以加進來。

2、新建@Log註解和切面處理類LogAspect

註解類

package com.summo.log;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Log {
    /**
     * 介面功能描述
     *
     * @return
     */
    String methodDesc() default "";
}

切面處理類

package com.summo.log;

import java.io.Serializable;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.List;
import java.util.Map;
import java.util.Set;

import javax.servlet.http.HttpServletRequest;

import com.alibaba.fastjson.JSONObject;

import com.summo.entity.UserOperInfoDO;
import com.summo.repository.UserOperInfoRepository;
import com.summo.util.HttpContextUtil;
import com.summo.util.IPUtil;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.LocalVariableTableParameterNameDiscoverer;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

@Slf4j
@Aspect
@Component
public class LogAspect {

    @Autowired
    private UserOperInfoRepository userOperInfoRepository;

    @Pointcut("@annotation(com.summo.log.Log)")
    public void pointcut() {
        // do nothing
    }

    @Around("pointcut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        Object result = null;
        //預設操作物件為-1L
        MethodSignature signature = (MethodSignature)joinPoint.getSignature();
        Method method = signature.getMethod();
        Log logAnnotation = method.getAnnotation(Log.class);
        UserOperInfoDO log = new UserOperInfoDO();
        if (logAnnotation != null) {
            // 註解上的描述
            log.setOperation(logAnnotation.methodDesc());
        }
        // 請求的類名
        String className = joinPoint.getTarget().getClass().getName();
        // 請求的方法名
        String methodName = signature.getName();
        log.setMethod(className + "." + methodName + "()");
        // 請求的方法引數值
        Object[] args = joinPoint.getArgs();
        // 請求的方法引數名稱
        LocalVariableTableParameterNameDiscoverer u = new LocalVariableTableParameterNameDiscoverer();
        String[] paramNames = u.getParameterNames(method);
        if (args != null && paramNames != null) {
            StringBuilder params = new StringBuilder();
            params = handleParams(params, args, Arrays.asList(paramNames));
            log.setParams(params.toString());
        }
        log.setGmtCreate(Calendar.getInstance().getTime());
        long beginTime = System.currentTimeMillis();
        // 執行方法
        result = joinPoint.proceed();
        // 執行時長(毫秒)
        long time = System.currentTimeMillis() - beginTime;
        HttpServletRequest request = HttpContextUtil.getHttpServletRequest();
        // 設定 IP 地址
        String ip = IPUtil.getIpAddr(request);
        log.setIp(ip);
        log.setTime(time);

        //儲存操作記錄到資料庫中
        userOperInfoRepository.save(log);
        return result;
    }

    /**
     * 引數列印合理化
     *
     * @param params     引數字串
     * @param args       引數列表
     * @param paramNames 引數名
     * @return
     */
    private StringBuilder handleParams(StringBuilder params, Object[] args, List paramNames) {
        for (int i = 0; i < args.length; i++) {
            if (args[i] instanceof Map) {
                Set set = ((Map)args[i]).keySet();
                List<Object> list = new ArrayList<>();
                List<Object> paramList = new ArrayList<>();
                for (Object key : set) {
                    list.add(((Map)args[i]).get(key));
                    paramList.add(key);
                }
                return handleParams(params, list.toArray(), paramList);
            } else {
                if (args[i] instanceof Serializable) {
                    Class<?> aClass = args[i].getClass();
                    try {
                        aClass.getDeclaredMethod("toString", new Class[] {null});
                        // 如果不丟擲 NoSuchMethodException 異常則存在 toString 方法 ,安全的 writeValueAsString ,否則 走 Object的
                        // toString方法
                        params.append(" ").append(paramNames.get(i)).append(": ").append(
                            JSONObject.toJSONString(args[i]));
                    } catch (NoSuchMethodException e) {
                        params.append(" ").append(paramNames.get(i)).append(": ").append(
                            JSONObject.toJSONString(args[i].toString()));
                    }
                } else if (args[i] instanceof MultipartFile) {
                    MultipartFile file = (MultipartFile)args[i];
                    params.append(" ").append(paramNames.get(i)).append(": ").append(file.getName());
                } else {
                    params.append(" ").append(paramNames.get(i)).append(": ").append(args[i]);
                }
            }
        }
        return params;
    }
}

3、使用方法

在需要監控的介面方法上加上@Log註解

@PostMapping("/saveRel")
@Log(methodDesc = "新增記錄")
public Boolean saveRel(@RequestBody SaveRelReq saveRelReq) {
    return userRoleRelService.saveRel(saveRelReq);
}

@DeleteMapping("/delRel")
@Log(methodDesc = "刪除記錄")
public Boolean delRel(Long relId) {
    return userRoleRelService.delRel(relId);
}

呼叫一下測試功能

資料庫中儲存的記錄

這裡可以看到已經有記錄儲存在資料庫中了,包括兩次新增操作、一次刪除操作,並且記錄了操作人的IP地址(這裡我使用的是localhost所以IP是127.0.0.1)和操作時間。但是這裡有一個問題:沒有記錄操作人的ID,也即creator_id欄位為空,如果不知道這條記錄是誰的,那這個功能就沒有意義了,所以在方法三我將會說一下如何記錄每一行資料的建立者和修改者。

方法三、記錄每一行資料的建立者和修改者

這個功能的實現需要用到一個非常關鍵的東西:使用者上下文。如何實現請看:《最佳化介面設計的思路》系列:第二篇—介面使用者上下文的設計與實現。

那麼現在假設我已經有了GlobalUserContext.getUserContext()方法可以獲取到使用者上下文資訊,如何使用呢?
方法二沒有記錄操作人的ID,現在可以可以透過下面這種方法獲取當前操作人的ID:

log.setCreatorId(GlobalUserContext.getUserContext().getUserId());

但是!!!我這裡的標題是:記錄每一行資料的建立者和修改者,可不僅僅是隻操作user_oper_log的每一行資料,而是系統中的每一張表的每一行資料!那現在問題來了,如何實現這個需求?
最笨的辦法就是在每個新增、更新的程式碼下都加上setCreatorId和setModifierId這些程式碼,實現是可以實現,但是感覺太low了,所以我這裡提供一個思路和一個例子來最佳化這些程式碼。

1、統一欄位名和型別

在每張表中都加入gmt_create(datetime 建立時間)、gmt_modified(datetime 更新時間)、creator_id(bigint 建立人ID)、modifier_id(bigint 更新人ID),我們將所有表中的這些輔助欄位統一命名、統一型別,這樣給我們統一處理提供了基礎。

2、將這些欄位整合到一個抽象類中

這樣做的好處有兩個:

  • 其他表的DO類繼承這個抽象類,那麼DO中就不需要再定義以上4個欄位
  • 統一處理的類只有抽象類一個了

tips:非常建議使用mybatis-plus來實現這個功能,maven依賴如下:

 <!-- mybatis-plus -->
<dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.3.2</version>
</dependency>
<dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-extension</artifactId>
            <version>3.3.2</version>
</dependency>

類名定義和程式碼如下
AbstractBaseDO.java

package com.summo.entity;

import java.io.Serializable;
import java.util.Date;

import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class AbstractBaseDO<T extends Model<T>> extends Model<T> implements Serializable {

    /**
     * 建立時間
     */
    @TableField(fill = FieldFill.INSERT)
    private Date gmtCreate;

    /**
     * 修改時間
     */
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Date gmtModified;

    /**
     * 建立人ID
     */
    @TableField(fill = FieldFill.INSERT)
    private Long creatorId;

    /**
     * 修改人ID
     */
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long modifierId;

}

3、使用mybatis-plus的MetaObjectHandler全域性攔截insert和update操作

自定義MetaObjectHandlerConfig繼承MetaObjectHandler,程式碼如下
MetaObjectHandlerConfig.java

package com.summo.entity;

import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;

@Configuration
public class MetaObjectHandlerConfig implements MetaObjectHandler {
    @Override
    public void insertFill(MetaObject metaObject) {

    }

    @Override
    public void updateFill(MetaObject metaObject) {

    }
}

邏輯補全的程式碼如下

package com.summo.entity;

import java.util.Calendar;
import java.util.Date;

import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.summo.context.GlobalUserContext;
import com.summo.context.UserContext;
import org.apache.ibatis.reflection.MetaObject;

@Configuration
public class MetaObjectHandlerConfig implements MetaObjectHandler {
    @Override
    public void insertFill(MetaObject metaObject) {
        //獲取使用者上下文
        UserContext userContext = GlobalUserContext.getUserContext();
        //獲取建立時間
        Date date = Calendar.getInstance().getTime();
        //設定gmtCreate
        this.fillStrategy(metaObject, "gmtCreate", date);
        //設定gmtModified
        this.fillStrategy(metaObject, "gmtModified", date);
        //設定creatorId
        this.fillStrategy(metaObject, "creatorId", userContext.getUserId());
        //設定modifierId
        this.fillStrategy(metaObject, "modifierId", userContext.getUserId());
    }

    @Override
    public void updateFill(MetaObject metaObject) {
        //獲取使用者上下文
        UserContext userContext = GlobalUserContext.getUserContext();
        //獲取更新時間
        Date date = Calendar.getInstance().getTime();
        //更新操作修改gmtModified
        this.setFieldValByName("gmtModified", date, metaObject);
        //更新操作修改modifierId
        this.setFieldValByName("modifierId", userContext.getUserId(), metaObject);
    }
}

相關文章