一.背景
公司最近对现有系统架构更新,发现目前服务使用log4j2进行日志管理,但是只是用其进行日志级别的设置,没有使用其自动切割日志功能,而是通过定时任务对日志检测并实现分割,扩展性不好且无法达到精确控制,若定时任务执行间隔稍长则会导致在极端情况加单个日志文件会变的很大,可能会导致内存溢出情况,而没有使用log4j2实现日志定时分割功能的原因为需求要求对分割的日志进行数字签名,且将签名值和公钥写入切割后的日志文件开头,在网上没有查询到log4j2自定义分割日志时操作的相关文章,通过阅读log4j2的源码,找到一种解决方法,但感觉此方法有点别扭,原因是log4j2对这块的扩展功能有限?或是我没有找到扩展的更好入口,这块后边会提到,下面分享一下我的解决方案。
二.解决思路,log4j2源码分析
首先log4j2实现日志定时分割的appender首先想到的就是RollingFile,其中Policies定义了何时进行日志文件切割,而RolloverStrategy定义了日志如何分割,要在日志切割时对日志文件操作肯定是在RolloverStrategy上下功夫。
AbstractRolloverStrategy就两个子类,最常使用的也就是DefaultRolloverStrategy,而执行日志切割操作时就是执行该类的rollover方法,截取该方法一段代码。
可以看到这个方法最后返回了一个RolloverDescriptionImpl类,其中第三四个参数需要注意,看到了一个FileRenameAction,通过类名可以判断其与文件重命名有关,而日志自动分割操作肯定涉及到对日志文件的重命名,而第四个参数也是一个Action接口的实现类,而FileRenameAction就是Action接口的一个实现类,因此进入RolloverDescriptionImpl类中。
可以看到其中定义了两个Action类型的属性分别为synchronous和asynchonous,从属性名可以看到一个是同步一个是异步,而刚才的FileRenameAction被放在了同步的Action属性中,还有一个异步的属性字段,通过DefaultRolloverStrategy#rollover方法可以看到asyncAction是通过merge方法返回的,看一下merge方法
功能大概为将一个Action接口实现类的List集合转为一个CompositeAction对象,这个类也实现了Action接口,因此可以看出,这里就是把多个Action聚合为一个可以遍历的CompositeAction对象。
返回DefaultRolloverStrategy#rollover方法看到调用merge方法传入的第二个参数是List<Action> customActions,而customActions是在protected DefaultRolloverStrategy构造方法中给其赋值的。
那就看是谁调用的这个构造方法,看到是DefaultRolloverStrategy类中的静态内部类Builder#build方法调用的
而customActions是静态内部类Builder中的一个Action[]类型的属性,并且带有@PluginElement("Actions")注解,然后查看是谁调用的静态内部类Builder中的build方法,但只找到了一个创建Builder对象的方法,为DefaultRolloverStrategy#newBuilder方法,且该方法带有@PluginBuilderFactory注解
通过分析该注解,了解到log4j加载时会找到该注解标注的方法,执行方法返回Builder对象的build方法,回到DefaultRolloverStrategy$Builder类中看到customActions属性标注的@PluginElement("Actions")注解,log4j在解析xml配置文件时,遇到标签时会查找该标签名是否有和@Plugin注解name属性名相同的类,如果有则会调用该类中被@PluginBuilderFactory标注方法返回Builder对象的build方法生成一个对象作为该标签的插件对象,这里还有另一种实现方式,找到@Plugin标注的对象,若存在@PluginFactory注解标注的方法,则会执行该方法,返回的对象作为该标签的插件对象。
标签对象中的属性如何注入对象呢,若是@PluginFactory注解标注的方法,则通过参数且在参数前标注@PluginAttribute()注解,若是@PluginBuilderFactory则通过Builder方法属性上标注@PluginBuilderAttribute注解,value值代表标签的属性名。
下面我们只需要自定义一个Action接口的子类,通过上述方法将其声明为一个插件,且在xml配置文件中的 DefauleRolloverStrategy标签下定义自定义的Action插件名标签即可完成对DefaultRolloverStrategy$Builder中customActions属性的注入,然后需要知道DefaultRolloverStrategy#rollover方法返回的RolloverDescriptionImpl对象被谁用了,了解到该方法在RollingFileManager类的rollover方法中被调用。
在这里调用了 RolloverDescriptionImpl对象中的同步Action和异步Action的execute方法,而Action继承了Runnable接口本质上是一个任务,他的抽象基类AbstractAction重写了Runnable接口的run方法,在其中调用了Action接口中的execute方法,那么Action的子实现类就是一个任务类,而具体任务就是execute方法,则我们只需要在自定义的Action子实现类的execute实现对日志文件签名并写入日志文件的操作即可。
但是在实现时发现取到的数据和写入的数据是切割后的日志文件,不是切割前的,不符合预期,发现是因为在RollingFileManager类中先调用的同步Action,再调用异步的Action,而自定义的签名Action为异步的Action,他在执行时重命名的Action已经将原日志文件重命名了。因此,需要在FileRenameAction之前执行自定义的签名Action,但是在DefauleRolloverStrategy#rollover方法中直接将FileRenameAction放入了同步Action中,没有给用户插入同步Action列表中的方法,因此需要定义DefauleRolloverStrategy类的子类,重写rollover方法,在实现父类逻辑的同时将用户注入到异步Action中的签名Action放入同步Action列表中,且在FileRenameAction之前。
三.代码实现
1.定义SignAction插件类,实现具体的签名和写入文件操作
插件名称为SignAction,在定义标签时需要注入一个标签属性fileName,指定日志文件绝对路径名
package com.fkp.action;
import com.fkp.util.RSAUtils;
import org.apache.commons.io.IOUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.core.Core;
import org.apache.logging.log4j.core.appender.rolling.action.AbstractAction;
import org.apache.logging.log4j.core.config.plugins.Plugin;
import org.apache.logging.log4j.core.config.plugins.PluginAttribute;
import org.apache.logging.log4j.core.config.plugins.PluginFactory;
import java.io.*;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.util.Base64;
@Plugin(name = "SignAction", category = Core.CATEGORY_NAME)
public final class SignAction extends AbstractAction {
private final Logger log = LogManager.getLogger(SignAction.class);
private final String fileName;
private SignAction(String fileName) {
this.fileName = fileName;
}
@PluginFactory
public static SignAction createMyAction(
@PluginAttribute("fileName") String fileName) throws IOException {
return new SignAction(fileName);
}
@Override
public boolean execute(){
File file = new File(fileName);
if(file.exists()){
try (FileInputStream fis = new FileInputStream(file)){
KeyPair keyPair = RSAUtils.getKeyPair();
PrivateKey privateKey = keyPair.getPrivate();
byte[] data = IOUtils.toByteArray(fis);
byte[] signData = RSAUtils.sign(data, privateKey);
Base64.Encoder base64Encoder = Base64.getEncoder();
String encodeSignData = base64Encoder.encodeToString(signData);
String encodePublicKey = base64Encoder.encodeToString(keyPair.getPublic().getEncoded());
getExecShellProcess("sed -i \'1i\\signData: " + encodeSignData + "\' " + fileName).waitFor();
getExecShellProcess("sed -i \'1i\\publicKey: " + encodePublicKey + "\' " + fileName).waitFor();
} catch (Exception e) {
log.error("Failed to sign log file during log cutting.", e);
}
}
return true;
}
public static Process getExecShellProcess(String command) throws IOException {
String[] cmd = {"/bin/sh", "-c", command };
return Runtime.getRuntime().exec(cmd);
}
}
2.定义SignRolloverStrategy插件类,实现将SignAction放在FileRenameAction前且封装为同步Action
重写父类的rollover方法,调用父类rollover方法返回RolloverDescription对象后重新封装该对象,把SignAction放到FileRenameAction前变,并放入同步Action集合中。
package com.fkp.action;
import org.apache.logging.log4j.core.Core;
import org.apache.logging.log4j.core.appender.rolling.DefaultRolloverStrategy;
import org.apache.logging.log4j.core.appender.rolling.RollingFileManager;
import org.apache.logging.log4j.core.appender.rolling.RolloverDescription;
import org.apache.logging.log4j.core.appender.rolling.RolloverDescriptionImpl;
import org.apache.logging.log4j.core.appender.rolling.action.Action;
import org.apache.logging.log4j.core.appender.rolling.action.CompositeAction;
import org.apache.logging.log4j.core.config.plugins.Plugin;
import org.apache.logging.log4j.core.config.plugins.PluginBuilderFactory;
import java.util.ArrayList;
import java.util.List;
@Plugin(name = "SignRolloverStrategy", category = Core.CATEGORY_NAME, printObject = true)
public class SignRolloverStrategy extends DefaultRolloverStrategy {
private SignRolloverStrategy(DefaultRolloverStrategy defaultRolloverStrategy){
super(defaultRolloverStrategy.getMinIndex(), defaultRolloverStrategy.getMaxIndex(), defaultRolloverStrategy.isUseMax(), defaultRolloverStrategy.getCompressionLevel(),
defaultRolloverStrategy.getStrSubstitutor(), defaultRolloverStrategy.getCustomActions().toArray(new Action[0]), defaultRolloverStrategy.isStopCustomActionsOnError(),
defaultRolloverStrategy.getTempCompressedFilePattern() == null ? null : defaultRolloverStrategy.getTempCompressedFilePattern().getPattern());
}
@PluginBuilderFactory
public static Builder newBuilder() {
return new MyBuild();
}
private static class MyBuild extends Builder{
@Override
public DefaultRolloverStrategy build() {
DefaultRolloverStrategy defaultRolloverStrategy = super.build();
return new SignRolloverStrategy(defaultRolloverStrategy);
}
}
@Override
public RolloverDescription rollover(RollingFileManager manager) throws SecurityException {
RolloverDescriptionImpl rollover = (RolloverDescriptionImpl) super.rollover(manager);
CompositeAction asynchronous = (CompositeAction) rollover.getAsynchronous();
if(asynchronous != null){
Action[] actions = asynchronous.getActions();
if(actions != null){
List<Action> syncActions = new ArrayList<>();
List<Action> asyncActions = new ArrayList<>();
for (Action action : actions) {
if(action instanceof SignAction){
syncActions.add(action);
}else {
asyncActions.add(action);
}
}
Action synchronous = rollover.getSynchronous();
syncActions.add(synchronous);
return new RolloverDescriptionImpl(rollover.getActiveFileName(), false, merge(null, syncActions, false), merge(null, asyncActions, false));
}
}
return rollover;
}
}
3.xml配置文件使用自定义插件标签
<?xml version="1.0" encoding="UTF-8"?>
<configuration status="info" monitorInterval="30">
<Properties>
<Property name="pattern">log4j2:[%-5p]:%d{YYYY-MM-dd HH:mm:ss} [%t]%c{1}:%L - %msg%n</Property>
<!-- <Property name="log_home">E:\Example\Java\log\log4j2/logs</Property>-->
<!-- <Property name="fileName">E:\Example\Java\log\log_demo\sign-log4j2-demo\logs\fkp.log</Property>-->
<Property name="fileName">/opt/logs/fkp.log</Property>
</Properties>
<appenders>
<!--console:控制台输出的配置-->
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="${pattern}"/>
</Console>
<!-- <File name="file" fileName="${log_home}/myfile.log">-->
<!-- <PatternLayout>-->
<!-- <Pattern>log4j2:[%-5p]:%d{YYYY-MM-dd HH:mm:ss} [%t]%c{1}: - %msg%n</Pattern>-->
<!-- </PatternLayout>-->
<!-- </File>-->
<!--按照一定规则拆分的日志文件的 appender-->
<RollingFile name="rollingFile" fileName="${fileName}"
filePattern="${fileName}-%d{yyyy-MM-dd-HH-mm}-%i">
<!--日志级别过滤器-->
<!-- <ThresholdFilter level="debug" onMatch="ACCEPT" onMismatch="DENY" />-->
<!--日志消息格式-->
<PatternLayout pattern="[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%-5level] %l %c{36} - %msg%n" />
<Policies>
<!--在系统启动时,出发拆分规则,生产一个新的日志文件-->
<OnStartupTriggeringPolicy />
<!--按照文件大小拆分,10MB -->
<SizeBasedTriggeringPolicy size="1 MB" />
<!--按照时间节点拆分,规则根据filePattern定义的-->
<TimeBasedTriggeringPolicy />
</Policies>
<!--在同一个目录下,文件的个数限定为 5 个,超过进行覆盖-->
<!--使用自定义策略插件-->
<SignRolloverStrategy max="5">
<!--使用自定义Action插件-->
<SignAction fileName="${fileName}"/>
</SignRolloverStrategy>
</RollingFile>
</appenders>
<loggers>
<logger name="org.springframework" level="INFO"/>
<root level="info">
<appender-ref ref="Console"/>
<appender-ref ref="rollingFile"/>
</root>
<!-- 配置异步logger,
name指定使用异步日志的包,
level为日志的输出级别,
includeLocation关闭行号输出,若开启则会大大降低性能,
additivity配置为false不继承rootlogger,即不会在root指定的appender中输出,默认为true-->
<!-- <asyncLogger name="com.fkp.log.log4j2" level="INFO" includeLocation="false" additivity="true">-->
<!--<!– <appender-ref ref="file"/>–>-->
<!-- -->
<!-- </asyncLogger>-->
</loggers>
</configuration>
4.代码中签名验签工具类
package com.fkp.util;
import javax.crypto.Cipher;
import java.io.ByteArrayOutputStream;
import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
public class RSAUtils {
/**
* RSA最大加密明文大小
*/
private static final int MAX_ENCRYPT_BLOCK = 117;
/**
* RSA最大解密密文大小
*/
private static final int MAX_DECRYPT_BLOCK = 128;
/**
* 编码
*/
private static String charset = "utf-8";
/**
* 获取密钥对
*
* @return 密钥对
*/
public static KeyPair getKeyPair() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(1024);
return generator.generateKeyPair();
}
/**
* 获取私钥
*
* @param privateKey 私钥字符串
* @return
*/
public static PrivateKey getPrivateKey(String privateKey) throws Exception {
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
byte[] decodedKey = Base64.getDecoder().decode(privateKey.getBytes(charset));
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(decodedKey);
return keyFactory.generatePrivate(keySpec);
}
/**
* 获取公钥
*
* @param publicKey 公钥字符串
* @return
*/
public static PublicKey getPublicKey(String publicKey) throws Exception {
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
byte[] decodedKey = Base64.getDecoder().decode(publicKey.getBytes(charset));
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(decodedKey);
return keyFactory.generatePublic(keySpec);
}
/**
* RSA加密
*
* @param data 待加密数据
* @param publicKey 公钥
* @return
*/
public static String encrypt(String data, PublicKey publicKey) throws Exception {
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
int inputLen = data.getBytes(charset).length;
ByteArrayOutputStream out = new ByteArrayOutputStream();
int offset = 0;
byte[] cache;
int i = 0;
// 对数据分段加密
while (inputLen - offset > 0) {
if (inputLen - offset > MAX_ENCRYPT_BLOCK) {
cache = cipher.doFinal(data.getBytes(charset), offset, MAX_ENCRYPT_BLOCK);
} else {
cache = cipher.doFinal(data.getBytes(charset), offset, inputLen - offset);
}
out.write(cache, 0, cache.length);
i++;
offset = i * MAX_ENCRYPT_BLOCK;
}
byte[] encryptedData = out.toByteArray();
out.close();
// 获取加密内容使用base64进行编码,并以UTF-8为标准转化成字符串
// 加密后的字符串
return Base64.getEncoder().encodeToString(encryptedData);
}
/**
* RSA解密
*
* @param data 待解密数据
* @param privateKey 私钥
* @return
*/
public static String decrypt(String data, PrivateKey privateKey) throws Exception {
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.DECRYPT_MODE, privateKey);
byte[] dataBytes = Base64.getDecoder().decode(data);
int inputLen = dataBytes.length;
ByteArrayOutputStream out = new ByteArrayOutputStream();
int offset = 0;
byte[] cache;
int i = 0;
// 对数据分段解密
while (inputLen - offset > 0) {
if (inputLen - offset > MAX_DECRYPT_BLOCK) {
cache = cipher.doFinal(dataBytes, offset, MAX_DECRYPT_BLOCK);
} else {
cache = cipher.doFinal(dataBytes, offset, inputLen - offset);
}
out.write(cache, 0, cache.length);
i++;
offset = i * MAX_DECRYPT_BLOCK;
}
byte[] decryptedData = out.toByteArray();
out.close();
// 解密后的内容
return new String(decryptedData, "UTF-8");
}
/**
* 签名
*
* @param data 待签名数据
* @param privateKey 私钥
* @return 签名
*/
public static byte[] sign(byte[] data, PrivateKey privateKey) throws Exception {
byte[] keyBytes = privateKey.getEncoded();
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PrivateKey key = keyFactory.generatePrivate(keySpec);
Signature signature = Signature.getInstance("MD5withRSA");
signature.initSign(key);
signature.update(data);
return signature.sign();
}
/**
* 验签
*
* @param srcData 原始字符串
* @param publicKey 公钥
* @param sign 签名
* @return 是否验签通过
*/
public static boolean verify(String srcData, PublicKey publicKey, String sign) throws Exception {
byte[] keyBytes = publicKey.getEncoded();
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PublicKey key = keyFactory.generatePublic(keySpec);
Signature signature = Signature.getInstance("MD5withRSA");
signature.initVerify(key);
signature.update(srcData.getBytes(charset));
return signature.verify(Base64.getDecoder().decode(sign.getBytes(charset)));
}
// public static void main(String[] args) {
// try {
// // 生成密钥对
// //KeyPair keyPair = getKeyPair();
// //String privateKey = new String(Base64.encodeBase64(keyPair.getPrivate().getEncoded()));
// //String publicKey = new String(Base64.encodeBase64(keyPair.getPublic().getEncoded()));
// //System.out.println("私钥:" + privateKey);
// //System.out.println("公钥:" + publicKey);
// // RSA加密
// String publicKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCIDV8I1zpoazcFmv3VNtG/E9/QC14gDhBoW9Yq6o9UNLaOZC41yoGa7hjHqjuPOcmPJ61Wmv7i5UbB5BceGRl2i0pSyOzeAeYpoY5cNRStfQlXFlwV1Ig1P081rxBcCgkWZvhodsWp9yRdKOTTHUCj0FpgD94/2QhvqkxOaW9vAwIDAQAB";
// String privateKey = "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAIgNXwjXOmhrNwWa/dU20b8T39ALXiAOEGhb1irqj1Q0to5kLjXKgZruGMeqO485yY8nrVaa/uLlRsHkFx4ZGXaLSlLI7N4B5imhjlw1FK19CVcWXBXUiDU/TzWvEFwKCRZm+Gh2xan3JF0o5NMdQKPQWmAP3j/ZCG+qTE5pb28DAgMBAAECgYBihmxgFp0xqRL7eDaCBWT3nwjhvJm5VPYE3RzHj32kWVgq3dmpErGw5OQFE/51xj908CLTKQOUhL0tBGTJYxvQci8y9c0Ajt+epQt8wfe1pGJ/ORFP8AAFttMUYRqvjWX+kE+nmnM9jYe2Zqnj7PeUbNFCjwdUEhEeRDflYubzQQJBAMgsI6mWFJ4X7uS+hIemme5hgPQDg8aeoubdSOFYb34Em3wZO7lPHa/Y0UNFsmgE2NAfVuoWx7TNg/A2EbPhx6sCQQCt/zHV6nZNFcR5vfI60L7vd3cNAR4IY55C/n9TXZpihLT3WjOyo1yGyWDiD1o8Y4HKik8/JHeNK3Lv176EbD4JAkEAhYLrRnGTzt6nuGpaex/kC9t850Rw4Elu3g06TxNtSeBI1Lz/2NmsM12qNfSGylpxQl+k2P3Ytf9dwRpPNGujgQJAThhXXuMQXALkH5xQp3Nf741YQt74gt1rgDhIH7vIemWD7+1tfMVz1w91y6EGaEplS+oOLZIJkrQor1vPKBKJOQJAFRSzcW4F+GznqXVZ43o9UnyyqnZbEnDX+lssZdg3q5bJhvk1vjUTRq+uBLfNZX/x9kfVXAm6zn1YNMFXwEsRSg==";
// String data = "12312aawevrdgr312332312312312";
// String encryptData = encrypt(data, getPublicKey(publicKey));
// System.out.println("加密后内容:" + encryptData);
// // RSA解密
// String decryptData = decrypt(encryptData, getPrivateKey(privateKey));
// System.out.println("解密后内容:" + decryptData);
//
// // RSA签名
// String sign = sign(data, getPrivateKey(privateKey));
// // RSA验签
// boolean result = verify(data, getPublicKey(publicKey), sign);
// System.out.print("验签结果:" + result);
// } catch (Exception e) {
// e.printStackTrace();
// System.out.print("加解密异常");
// }
// }
}
5.对签名后的日志文件验签工具
package com.fkp.log.verify;
import com.fkp.log.verify.util.RSAUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.Base64;
import java.util.Scanner;
public class Client {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入日志文件路径:(输入0结束程序)");
while (scanner.hasNext()){
String fileName = scanner.next();
if("0".equals(fileName)){
break;
}
File file = new File(fileName);
if(!file.exists()){
System.out.println("路径错误,请重新输入:");
continue;
}
try (BufferedReader reader = new BufferedReader(new InputStreamReader(Files.newInputStream(file.toPath()), StandardCharsets.UTF_8))){
String publicKeyLine = reader.readLine();
String signDataLine = reader.readLine();
if(StringUtils.isBlank(publicKeyLine) || StringUtils.isBlank(signDataLine) || !publicKeyLine.contains("publicKey: ") || !signDataLine.contains("signData: ")){
System.out.println("日志文件格式错误,请重新输入:");
continue;
}
String publicKeyStr = publicKeyLine.substring("publicKey: ".length());
String signDataStr = signDataLine.substring("signData: ".length());
byte[] contentBytes = IOUtils.toByteArray(reader, StandardCharsets.UTF_8);
boolean verify = RSAUtils.verify(contentBytes, RSAUtils.getPublicKey(publicKeyStr), Base64.getDecoder().decode(signDataStr));
if(verify){
System.out.println("验签成功");
}else {
System.out.println("验签失败");
}
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("请输入日志文件路径:(输入0结束程序)");
}
}
}
四.存在问题
通过该实现运行项目时发现普通maven项目在运行时会报非法的标签,即找不到自定义的插件,使用springboot项目无此问题,普通maven项目启动报错StatusLogger Unrecognized format specifier。
原因:打包方式不对,上述问题参考解决方案:http://t.csdn.cn/jijme