Undertow容器在Springboot中如何自定义修改文件名

2022-01-04 16:22:59 浏览数 (1)

背景

  • Springboot集成了众多容器(Tomcat、Jetty、Undertow)
  • Undertow是一款并发性能极高的容器,由于默认的容器是Tomcat,我们通常会把tomcat的jar包干掉并引入Undertow的jar包,由此开启Undertow容器
  • 项目需要记录AccessLog日志,来保存和查询接口调用情况
  • AccessLog日志文件默认会定时日志切割(每天凌晨,按照天维度拆分小文件),默认生成的文件名为: access_log.log access_log.2021-02-11.log
  • 默认AccessLog不会自动删除,时间久了可能导致硬盘空间不够
  • 公司有一款自动日志删除的功能代理服务(可以设置日志最大保留天数),但是日志文件名的格式需要设置统一标准。比如:必须符合".日期格式"(日期可以按照天和小时维度)如: access_log.log.2021-02-11
  • 由于access_log.2021-02-11.log不符合日志文件名标准,导致自动日志删除代理无法识别,日志会积压,只能手动去集群删除,比较耗费时间
  • 默认的Undertow无法修改和自定义文件名。虽然可以设置前缀、后缀,但是规则比较生硬、日期也无法调整在文件名中位置和日期格式、生成的日期结尾会自带"."开头不带"."、无法满足日志删除代理的匹配规则 accesslog: dir: "logs" # 路径 enabled: true # 是否启用 pattern: 'common' # 一条条请求的匹配模式(可以匹配接口path,时间,响应码,ip等),用于生成请求日志内容 prefix: "access_log." # 前缀 suffix: "log" # 后缀

抓手

  • 为了解决AccessLog文件名不支持自定义的问题,需要从Undertow源码入手
  • 从源码找到生成日志文件名的地方,重写这部分的逻辑

解决过程

1.首先打开Undertow的源码包

发现server.handlers.accesslog下有相关的accesslog的处理的类

2.接下来看接口

AccessLogReceiver接口有两个实现

代码语言:javascript复制
DefaultAccessLogReceiver和JBossLoggingAccessLogReceiver
代码语言:javascript复制
package io.undertow.server.handlers.accesslog;

/**
 * Interface that is used by the access log handler to send data to the log file manager.
 *
 * Implementations of this interface must be thread safe.
 *
 * @author Stuart Douglas
 */
public interface AccessLogReceiver {

    void logMessage(final String message);

}

看注释可以看到这个接口就是处理日志文件相关的,接下来进入到实现类一个一个看

代码语言:javascript复制
/**
 * Access log receiver that logs messages at INFO level.
 *
 * @author Stuart Douglas
 */
public class JBossLoggingAccessLogReceiver implements AccessLogReceiver {

    public static final String DEFAULT_CATEGORY = "io.undertow.accesslog";

    private final Logger logger;

    public JBossLoggingAccessLogReceiver(final String category) {
        this.logger = Logger.getLogger(category);
    }

    public JBossLoggingAccessLogReceiver() {
        this.logger = Logger.getLogger(DEFAULT_CATEGORY);
    }

    @Override
    public void logMessage(String message) {
        logger.info(message);
    }
}

JBossLoggingAccessLogReceiver并没有对日志文件进行什么处理,只是单纯的进行了日志的打印,接下来看另外一个

看DefaultAccessLogReceiver的类注释,大概就可以猜到是我们要找的地方了,接下来先找构造函数(变量的初始化的地方)

代码语言:javascript复制
public DefaultAccessLogReceiver(final Executor logWriteExecutor, final File outputDirectory, final String logBaseName) {
    this(logWriteExecutor, outputDirectory.toPath(), logBaseName, null);
}

public DefaultAccessLogReceiver(final Executor logWriteExecutor, final File outputDirectory, final String logBaseName, final String logNameSuffix) {
    this(logWriteExecutor, outputDirectory.toPath(), logBaseName, logNameSuffix, true);
}

public DefaultAccessLogReceiver(final Executor logWriteExecutor, final File outputDirectory, final String logBaseName, final String logNameSuffix, boolean rotate) {
    this(logWriteExecutor, outputDirectory.toPath(), logBaseName, logNameSuffix, rotate);
}

public DefaultAccessLogReceiver(final Executor logWriteExecutor, final Path outputDirectory, final String logBaseName) {
    this(logWriteExecutor, outputDirectory, logBaseName, null);
}

public DefaultAccessLogReceiver(final Executor logWriteExecutor, final Path outputDirectory, final String logBaseName, final String logNameSuffix) {
    this(logWriteExecutor, outputDirectory, logBaseName, logNameSuffix, true);
}

public DefaultAccessLogReceiver(final Executor logWriteExecutor, final Path outputDirectory, final String logBaseName, final String logNameSuffix, boolean rotate) {
    this(logWriteExecutor, outputDirectory, logBaseName, logNameSuffix, rotate, null);
}

private DefaultAccessLogReceiver(final Executor logWriteExecutor, final Path outputDirectory, final String logBaseName, final String logNameSuffix, boolean rotate, LogFileHeaderGenerator fileHeader) {
    this.logWriteExecutor = logWriteExecutor;
    this.outputDirectory = outputDirectory;
    this.logBaseName = logBaseName;
    this.rotate = rotate;
    this.fileHeaderGenerator = fileHeader;
    this.logNameSuffix = (logNameSuffix != null) ? logNameSuffix : DEFAULT_LOG_SUFFIX;
    this.pendingMessages = new ConcurrentLinkedDeque<>();
    this.defaultLogFile = outputDirectory.resolve(logBaseName   this.logNameSuffix);
    calculateChangeOverPoint();
}

可以看到多个构造函数都调用了一个地方,在这个地方可以看到我们在配置文件中配置的前缀、后缀、路径等关键参数。calculateChangeOverPoint() 这个方法比较特别,我们继续往下看

代码语言:javascript复制
private void calculateChangeOverPoint() {
    Calendar calendar = Calendar.getInstance();
    calendar.set(Calendar.SECOND, 0);
    calendar.set(Calendar.MINUTE, 0);
    calendar.set(Calendar.HOUR_OF_DAY, 0);
    calendar.add(Calendar.DATE, 1);
    SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd", Locale.US);
    currentDateString = df.format(new Date());
    // 如果存在现有的默认日志文件,请使用上次修改的日期而不是当前日期
    if (Files.exists(defaultLogFile)) {
        try {
            currentDateString = df.format(new Date(Files.getLastModifiedTime(defaultLogFile).toMillis()));
        } catch(IOException e){
            // 忽视。如果发生异常,请使用当前日期
        }
    }
    changeOverPoint = calendar.getTimeInMillis();
}

可以看到这个类指定了时间的格式,只能是日期"yyyy-MM-dd"的模式并赋值currentDateString为当前时间或者同名文件(即access_log.log)的最后的修改时间。

并同时记录changeOverPoint为明天凌晨的毫秒数(如明天是2020-02-19 00:00:00),作为判断依据来判断当前时间是否已经第二天了。

观察发现这个类还继承了Runnable,实现了run()方法,可以知道AccessLog日志文件的写入默认是异步进行的

代码语言:javascript复制
/**
 * processes all queued log messages
 */
@Override
public void run() {
    if (!stateUpdater.compareAndSet(this, 1, 2)) {
        return;
    }
    if (forceLogRotation) {
        doRotate();
    } else if (initialRun && Files.exists(defaultLogFile)) {
        // 如果有现有的日志文件,请检查是否应该切割
        long lm = 0;
        try {
            lm = Files.getLastModifiedTime(defaultLogFile).toMillis();
        } catch (IOException e) {
            UndertowLogger.ROOT_LOGGER.errorRotatingAccessLog(e);
        }
        Calendar c = Calendar.getInstance();
        c.setTimeInMillis(changeOverPoint);
        c.add(Calendar.DATE, -1);
        if (lm <= c.getTimeInMillis()) {
            doRotate();
        }
    }
    initialRun = false;
    List<String> messages = new ArrayList<>();
    String msg;
    // 一次最多只能抓取1000条消息
    for (int i = 0; i < 1000;   i) {
        msg = pendingMessages.poll();
        if (msg == null) {
            break;
        }
        messages.add(msg);
    }
    try {
        if (!messages.isEmpty()) {
            // 内容写入
            writeMessage(messages);
        }
    } finally {
        // 忽略先不看
    }
}

我们可以先看writeMessage(messages);这个方法

代码语言:javascript复制
private void writeMessage(final List<String> messages) {
    // 如果当前时间大于明天凌晨,说明发生了跨天,需要进行日志切割
    if (System.currentTimeMillis() > changeOverPoint) {
        doRotate();
    }
    try {
        if (writer == null) {
            boolean created = !Files.exists(defaultLogFile);
            writer = Files.newBufferedWriter(defaultLogFile, StandardCharsets.UTF_8, StandardOpenOption.APPEND, StandardOpenOption.CREATE);
            if(Files.size(defaultLogFile) == 0 && fileHeaderGenerator != null) {
                String header = fileHeaderGenerator.generateHeader();
                if(header != null) {
                    writer.write(header);
                    writer.newLine();
                    writer.flush();
                }
            }
        }
        for (String message : messages) {
            writer.write(message);
            writer.newLine();
        }
        writer.flush();
    } catch (IOException e) {
        UndertowLogger.ROOT_LOGGER.errorWritingAccessLog(e);
    }
}

这个方法调用了doRotate();进行日志的切割,我们接着看doRotate()方法

代码语言:javascript复制
private void doRotate() {
    forceLogRotation = false;
    if (!rotate) {
        return;
    }
    try {
        if (writer != null) {
            writer.flush();
            writer.close();
            writer = null;
        }
        if (!Files.exists(defaultLogFile)) {
            return;
        }
        // 找到了日志文件名的生成规则 (前缀 当前日期 "." 后缀)
        Path newFile = outputDirectory.resolve(logBaseName   currentDateString   "."   logNameSuffix);
        int count = 0;
        // 如果新生成的文件已经存在,则进行命名变更(多加了个计数),防止文件覆盖更新
        while (Files.exists(newFile)) {
              count;
            newFile = outputDirectory.resolve(logBaseName   currentDateString   "-"   count   "."   logNameSuffix);
        }
        Files.move(defaultLogFile, newFile);
    } catch (IOException e) {
        UndertowLogger.ROOT_LOGGER.errorRotatingAccessLog(e);
    } finally {
        calculateChangeOverPoint();
    }
}

可以看到newFile的文件名生成是写死了(实在是太坑了,太不灵活了)

我们需要重写的地方就找到了,接下来得看如何重写这一块的逻辑

3.查找重写的链路

首先看下这个DefaultAccessLogReceiver对象是怎么来的,如果是spring自动装配的bean,那么我们只需要把这个bean想办法替换调就可以,如果是写死new出来的,那只能一层一层网上找,直到找到spring bean的创建的地方

接下来从构造函数出发,搜索对象生成的地方

可以看到有两个地方

通过启动springboot,每个地方打个断点来看是否走到了这些地方来找到调用的流程(没错就是这么low),可以找到AccessLogHttpHandlerFactory这个类的getHandler方法生成了DefaultAccessLogReceiver对象

那只能继续往上层的调用来找(通过查询AccessLogHttpHandlerFactory的构造方法生成的地方)

可以看到UndertowWebServerFactoryDelegate这个类生成了AccessLogHttpHandlerFactory对象

我们继续通过构造方法 断点来顺藤摸瓜找调用方

竟然有这么多

最后定位到UndertowServletWebServerFactory这个工厂类,竟然还是直接new的,并且还是私有的(人类是有极限了,我不做人了,JOJO!!!)

继续找生成这个工厂的地方

最终终于找到了这个bean的创建的地方(只有能够到达那个地方(DIO))

可以看到这里还是有点良心的,设置了

代码语言:javascript复制
@ConditionalOnMissingBean(value = ServletWebServerFactory.class, search = SearchStrategy.CURRENT)

如果我们没有默认提供ServletWebServerFactory则会走这里,换句话说,我们只要提供下自定义的ServletWebServerFactory Bean 就可以覆盖以上的逻辑了

4.重写文件名生成规则

找到bean创建的地方后,可以直接进行新bean的注册

代码语言:javascript复制
@Configuration
public class ServletWebServerFactoryConfig {

  @Bean
  public CustomUndertowServletWebServerFactory customUndertowServletWebServerFactory(
      ObjectProvider<UndertowDeploymentInfoCustomizer> deploymentInfoCustomizers,
      ObjectProvider<UndertowBuilderCustomizer> builderCustomizers) {
    CustomUndertowServletWebServerFactory factory = new CustomUndertowServletWebServerFactory();
    factory.getDeploymentInfoCustomizers()
        .addAll(deploymentInfoCustomizers.orderedStream().collect(Collectors.toList()));
    factory.getBuilderCustomizers()
        .addAll(builderCustomizers.orderedStream().collect(Collectors.toList()));
    return factory;
  }

}

针对UndertowWebServerFactoryDelegate的修改,需要结合反射进行,生成我们自定义的CustomAccessLogHttpHandlerFactory

代码语言:javascript复制
public class CustomUndertowServletWebServerFactory extends UndertowServletWebServerFactory {

  @Override
  protected UndertowServletWebServer getUndertowWebServer(Builder builder,
      DeploymentManager manager, int port) {
    Object delegate = ReflectUtil.getFieldValue(this, "delegate");
    List<HttpHandlerFactory> httpHandlerFactories = createHttpHandlerFactories(delegate, this,
        new CustomDeploymentManagerHttpHandlerFactory(manager));
    return new UndertowServletWebServer(builder, httpHandlerFactories, getContextPath(), port >= 0);
  }

  List<HttpHandlerFactory> createHttpHandlerFactories(Object delegate,
      AbstractConfigurableWebServerFactory webServerFactory,
      HttpHandlerFactory... initialHttpHandlerFactories) {
    boolean useForwardHeaders = (Boolean) ReflectUtil.getFieldValue(delegate, "useForwardHeaders");
    File accessLogDirectory = (File) ReflectUtil.getFieldValue(delegate, "accessLogDirectory");
    String accessLogPattern = (String) ReflectUtil.getFieldValue(delegate, "accessLogPattern");
    String accessLogPrefix = (String) ReflectUtil.getFieldValue(delegate, "accessLogPrefix");
    String accessLogSuffix = (String) ReflectUtil.getFieldValue(delegate, "accessLogSuffix");
    Boolean accessLogRotate = (Boolean) ReflectUtil.getFieldValue(delegate, "accessLogRotate");

    List<HttpHandlerFactory> factories = createHttpHandlerFactories(
        webServerFactory.getCompression(),
        useForwardHeaders, webServerFactory.getServerHeader(), webServerFactory.getShutdown(),
        initialHttpHandlerFactories);
    if (isAccessLogEnabled()) {
      factories
          .add(new CustomAccessLogHttpHandlerFactory(accessLogDirectory, accessLogPattern,
              accessLogPrefix, accessLogSuffix, accessLogRotate));
    }
    return factories;
  }

  static List<HttpHandlerFactory> createHttpHandlerFactories(Compression compression,
      boolean useForwardHeaders,
      String serverHeader, Shutdown shutdown, HttpHandlerFactory... initialHttpHandlerFactories) {
    List<HttpHandlerFactory> factories = new ArrayList<>(
        Arrays.asList(initialHttpHandlerFactories));
    if (compression != null && compression.getEnabled()) {
      factories.add(new CustomCompressionHttpHandlerFactory(compression));
    }
    if (useForwardHeaders) {
      factories.add(Handlers::proxyPeerAddress);
    }
    if (StringUtils.hasText(serverHeader)) {
      factories.add((next) -> Handlers.header(next, "Server", serverHeader));
    }
    if (shutdown == Shutdown.GRACEFUL) {
      factories.add(Handlers::gracefulShutdown);
    }
    return factories;
  }
}

在CustomAccessLogHttpHandlerFactory中进行修改,改用我们自定义的CustomDefaultAccessLogReceiver

通过新建的类CustomDefaultAccessLogReceiver(这个类其实就是DefaultAccessLogReceiver的源码复制过来,之后重新修改了下doRatate方法中的文件生成规则),重写doRatate方法,进而改变文件命名规则

类似其他需要的类也需要一并复制过来

总结

  • 本次项目编写中遇到了实际的问题并结合源码一步一步的进行了分析。
  • 通过构造函数和断点分析法,找到了调用链路。
  • 通过对上层链路Bean以及部分源码的复制及替换,实现了整体功能的切换(万事万物皆对象)。
  • 通过这次的源码的分析的分享,希望可以提供一个解决问题的思路。

0 人点赞