最详细、最全面的【Java日志框架】介绍,建议收藏,包含JUL、log4j、logback、log4j2等所有主流框架(中)

本文涉及的产品
云数据库 RDS MySQL,集群系列 2核4GB
推荐场景:
搭建个人博客
RDS MySQL Serverless 基础系列,0.5-2RCU 50GB
云数据库 RDS MySQL,高可用系列 2核4GB
简介: 最详细、最全面的【Java日志框架】介绍,建议收藏,包含JUL、log4j、logback、log4j2等所有主流框架(中)

6️⃣配置文件


我们看看一个文件处理器的源码是怎么读配置项的:

private void configure() {
        LogManager manager = LogManager.getLogManager();
        String cname = getClass().getName();
        pattern = manager.getStringProperty(cname + ".pattern", "%h/java%u.log");
        limit = manager.getLongProperty(cname + ".limit", 0);
        if (limit < 0) {
            limit = 0;
        }
        count = manager.getIntProperty(cname + ".count", 1);
        if (count <= 0) {
            count = 1;
        }
        append = manager.getBooleanProperty(cname + ".append", false);
        setLevel(manager.getLevelProperty(cname + ".level", Level.ALL));
        setFilter(manager.getFilterProperty(cname + ".filter", null));
        setFormatter(manager.getFormatterProperty(cname + ".formatter", new XMLFormatter()));
        // Initialize maxLocks from the logging.properties file.
        // If invalid/no property is provided 100 will be used as a default value.
        maxLocks = manager.getIntProperty(cname + ".maxLocks", MAX_LOCKS);
        if(maxLocks <= 0) {
            maxLocks = MAX_LOCKS;
        }
        try {
            setEncoding(manager.getStringProperty(cname +".encoding", null));
        } catch (Exception ex) {
            try {
                setEncoding(null);
            } catch (Exception ex2) {
                // doing a setEncoding with null should always work.
                // assert false;
            }
        }
    }

可以从以下源码中看到配置项:

public class FileHandler extends StreamHandler {
    private MeteredStream meter;
    private boolean append;
    // 限制文件大小
    private long limit;       // zero => no limit.
    // 控制日志文件的数量
    private int count;
    // 日志文件的格式化方式
    private String pattern;
    private String lockFileName;
    private FileChannel lockFileChannel;
    private File files[];
    private static final int MAX_LOCKS = 100;
    // 可以理解为同时可以有多少个线程打开文件,源码中有介绍
    private int maxLocks = MAX_LOCKS;
    private static final Set<String> locks = new HashSet<>();
}

我们已经知道系统默认的配置文件的位置,那我们能不能自定义呢?当然可以了,我们从jdk中赋值一个配置文件过来:

.level= INFO
# default file output is in user's home directory.
java.util.logging.FileHandler.pattern = %h/java%u.log
java.util.logging.FileHandler.limit = 50000
java.util.logging.FileHandler.count = 1
# Default number of locks FileHandler can obtain synchronously.
# This specifies maximum number of attempts to obtain lock file by FileHandler
# implemented by incrementing the unique field %u as per FileHandler API documentation.
java.util.logging.FileHandler.maxLocks = 100
java.util.logging.FileHandler.formatter = java.util.logging.XMLFormatter
# Limit the message that are printed on the console to INFO and above.
java.util.logging.ConsoleHandler.level = INFO
java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter
# java.util.logging.SimpleFormatter.format=%4$s: %5$s [%1$tc]%n
pattern = manager.getStringProperty(cname + ".pattern", "%h/java%u.log");
static File generate(String pat, int count, int generation, int unique)
            throws IOException
{
    Path path = Paths.get(pat);
    Path result = null;
    boolean sawg = false;
    boolean sawu = false;
    StringBuilder word = new StringBuilder();
    Path prev = null;
    for (Path elem : path) {
        if (prev != null) {
            prev = prev.resolveSibling(word.toString());
            result = result == null ? prev : result.resolve(prev);
        }
        String pattern = elem.toString();
        int ix = 0;
        word.setLength(0);
        while (ix < pattern.length()) {
            char ch = pattern.charAt(ix);
            ix++;
            char ch2 = 0;
            if (ix < pattern.length()) {
                ch2 = Character.toLowerCase(pattern.charAt(ix));
            }
            if (ch == '%') {
                if (ch2 == 't') {
                    String tmpDir = System.getProperty("java.io.tmpdir");
                    if (tmpDir == null) {
                        tmpDir = System.getProperty("user.home");
                    }
                    result = Paths.get(tmpDir);
                    ix++;
                    word.setLength(0);
                    continue;
                } else if (ch2 == 'h') {
                    result = Paths.get(System.getProperty("user.home"));
                    if (jdk.internal.misc.VM.isSetUID()) {
                        // Ok, we are in a set UID program.  For safety's sake
                        // we disallow attempts to open files relative to %h.
                        throw new IOException("can't use %h in set UID program");
                    }
                    ix++;
                    word.setLength(0);
                    continue;
                } else if (ch2 == 'g') {
                    word = word.append(generation);
                    sawg = true;
                    ix++;
                    continue;
                } else if (ch2 == 'u') {
                    word = word.append(unique);
                    sawu = true;
                    ix++;
                    continue;
                } else if (ch2 == '%') {
                    word = word.append('%');
                    ix++;
                    continue;
                }
            }
            word = word.append(ch);
        }
        prev = elem;
    }
    if (count > 1 && !sawg) {
        word = word.append('.').append(generation);
    }
    if (unique > 0 && !sawu) {
        word = word.append('.').append(unique);
    }
    if (word.length() > 0) {
        String n = word.toString();
        Path p = prev == null ? Paths.get(n) : prev.resolveSibling(n);
        result = result == null ? p : result.resolve(p);
    } else if (result == null) {
        result = Paths.get("");
    }
    if (path.getRoot() == null) {
        return result.toFile();
    } else {
        return path.getRoot().resolve(result).toFile();
    }
}
System.out.println(System.getProperty("user.home") );

我们将拷贝的文件稍作修改:

.level= INFO
# default file output is in user's home directory.
java.util.logging.FileHandler.pattern = D:/log/java%u.log
java.util.logging.FileHandler.limit = 50000
java.util.logging.FileHandler.count = 1
java.util.logging.FileHandler.maxLocks = 100
java.util.logging.FileHandler.formatter = java.util.logging.XMLFormatter
# Limit the message that are printed on the console to INFO and above.
java.util.logging.ConsoleHandler.level = INFO
java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter
# java.util.logging.SimpleFormatter.format=%4$s: %5$s [%1$tc]%n
@Test
public void testProperties() throws Exception {
    // 读取自定义配置文件
    InputStream in =
        JULTest.class.getClassLoader().getResourceAsStream("logging.properties");
    // 获取日志管理器对象
    LogManager logManager = LogManager.getLogManager();
    // 通过日志管理器加载配置文件
    logManager.readConfiguration(in);
    Logger logger = Logger.getLogger("com.ydlclass.log.JULTest");
    logger.severe("severe");
    logger.warning("warning");
    logger.info("info");
    logger.config("config");
    logger.fine("fine");
    logger.finer("finer");
    logger.finest("finest");
}

配置文件:

handlers= java.util.logging.ConsoleHandler,java.util.logging.FileHandler
.level= INFO
java.util.logging.FileHandler.pattern = D:/logs/java%u.log
java.util.logging.FileHandler.limit = 50000
java.util.logging.FileHandler.count = 1
java.util.logging.FileHandler.maxLocks = 100
java.util.logging.FileHandler.formatter = java.util.logging.XMLFormatter
# Limit the message that are printed on the console to INFO and above.
java.util.logging.ConsoleHandler.level = INFO
java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter

4d8723e47cb642cfbc9d1d1a928b45b2.png

文件中也出现了:


c61b64db0b7447cba7602d71e8acbe79.png


打开日志发现是xml,因为这里用的就是XMLFormatter:

7ce2ee25cb9247058be91db5d7b8d14f.png


上边我们配置了两个handler给根Logger,我们还可以给其他的Logger做独立的配置:

handlers = java.util.logging.ConsoleHandler
.level = INFO
# 对这个logger独立配置
com.ydlclass.handlers = java.util.logging.FileHandler
com.ydlclass.level = ALL
com.ydlclass.useParentHandlers = false
# 修改了名字
java.util.logging.FileHandler.pattern = D:/logs/ydl-java%u.log
java.util.logging.FileHandler.limit = 50000
java.util.logging.FileHandler.count = 1
java.util.logging.FileHandler.maxLocks = 100
java.util.logging.FileHandler.formatter = java.util.logging.SimpleFormatter
# 文件使用追加方式
java.util.logging.FileHandler.append = true
# Limit the message that are printed on the console to INFO and above.
java.util.logging.ConsoleHandler.level = INFO
java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter
# 修改日志格式
java.util.logging.SimpleFormatter.format=%4$s: %5$s [%1$tc]%n

执行发现控制台没有内容,文件中有了,说明没有问题OK了:


7768d6580c21444792dee20d0fc8f517.png

日志出现以下内容:

a04140cc124c4782ae7367dca0014fb7.png


三、Log4j日志框架


Log4j是Apache下的一款开源的日志框架。 官方网站:http://logging.apache.org/log4j/1.2/ ,这是一款比较老的日志框架,目前新的log4j2做了很大的改动,任然有一些项目在使用log4j。


1️⃣Log4j入门


🍀(1)建立maven工程

🍀(2)添加相关依赖

<dependencies>
    <dependency>
        <groupId>log4j</groupId>
        <artifactId>log4j</artifactId>
        <version>1.2.17</version>
    </dependency>
    <dependency>
        <groupId>junit</groupId>
        <artifactId>junit</artifactId>
        <version>4.13.2</version>
    </dependency>
</dependencies>
<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.8.1</version>
            <configuration>
                <source>${maven.compiler.source}</source>
                <target>${maven.compiler.target}</target>
                <encoding>UTF-8</encoding>
            </configuration>
        </plugin>
    </plugins>
</build>

🍀(3)编写java代码

@Test
public void testLogger() {
    Logger logger = Logger.getLogger(Log4jTest.class);
    // 日志记录输出
    logger.info("hello log4j");
    // 日志级别
    logger.fatal("fatal"); // 严重错误,一般会造成系统崩溃和终止运行
    logger.error("error"); // 错误信息,但不会影响系统运行
    logger.warn("warn"); // 警告信息,可能会发生问题
    logger.info("info"); // 程序运行信息,数据库的连接、网络、IO操作等
    logger.debug("debug"); // 调试信息,一般在开发阶段使用,记录程序的变量、参数等
    logger.trace("trace"); // 追踪信息,记录程序的所有流程信息
}

发现会有一些警告,JUL可以直接在控制台输出是因为他有默认的配置文件,而这个独立的第三方的日志框架却没有配置文件:

log4j:WARN No appenders could be found for logger (com.wang.entity.Log4jTest).
log4j:WARN Please initialize the log4j system properly.
log4j:WARN See http://logging.apache.org/log4j/1.2/faq.html#noconfig for more info.

我们在执行代码之前,加上以下代码,它会初始化一个默认配置:


BasicConfigurator.configure();


🍀(4)结果与分析

0 [main] INFO com.wang.entity.Log4jTest  - hello log4j
1 [main] FATAL com.wang.entity.Log4jTest  - fatal
1 [main] ERROR com.wang.entity.Log4jTest  - error
1 [main] WARN com.wang.entity.Log4jTest  - warn
1 [main] INFO com.wang.entity.Log4jTest  - info
1 [main] DEBUG com.wang.entity.Log4jTest  - debug

从源码看,这一行代码给我们的RootLogger加入一个控制台的输出源,就和JUL中的handler一样:

public static void configure() {
    Logger root = Logger.getRootLogger();
    root.addAppender(new ConsoleAppender(new PatternLayout("%r [%t] %p %c %x - %m%n")));
}


log4j定义了以下的日志的级别,和JUL的略有不同:


fatal 指出每个严重的错误事件将会导致应用程序的退出。

error 指出虽然发生错误事件,但仍然不影响系统的继续运行。

warn 表明会出现潜在的错误情形。

info 一般和在粗粒度级别上,强调应用程序的运行全程。

debug 一般用于细粒度级别上,对调试应用程序非常有帮助。

trace 是程序追踪,可以用于输出程序运行中的变量,显示执行的流程。

和JUL一样,log4j还有两个特殊的级别:OFF,可用来关闭日志记录。 ALL,启用所有消息的日志记录。


一般情况下,我们只使用4个级别,优先级从高到低为:ERROR > WARN > INFO > DEBUG。


2️⃣Log4j组件讲解


Log4J 主要由 Loggers (日志记录器)、Appenders(输出端)和 Layout(日志格式化器)组成。其中 Loggers 控制日志的输出级别与日志是否输出;Appenders 指定日志的输出方式(输出到控制台、文件 等);Layout 控制日志信息的输出格式。


🍀(1)Loggers


日志记录器:负责收集处理日志记录,实例的命名就是类“XX”的full quailied name(类的全限定名), Logger的名字大小写敏感,其命名有继承机制:例如:name为com.ydlclass.service的logger会继承 name为com.ydlclass的logger,和JUL一致。


Log4J中有一个特殊的logger叫做“root”,他是所有logger的根,也就意味着其他所有的logger都会直接 或者间接地继承自root。root logger可以用Logger.getRootLogger()方法获取。 JUL是不是也有一个名为.的根。


🍀(2)Appenders


Appender和JUL的Handler很像,用来指定日志输出到哪个地方,可以同时指定日志的输出目的地。Log4j 常用的输出目的地 有以下几种:


输出端类型 作用
ConsoleAppender 将日志输出到控制台
FileAppender 将日志输出到文件中
DailyRollingFileAppender 将日志输出到一个日志文件,并且每天输出到一个新的文件
RollingFileAppender 将日志信息输出到一个日志文件,并且指定文件的尺寸,当文件大 小达到指定尺寸时,会自动把文件改名,同时产生一个新的文件
JDBCAppender 把日志信息保存到数据库中
// 配置一个控制台输出源
ConsoleAppender consoleAppender = new ConsoleAppender();
consoleAppender.setName("ydl");
consoleAppender.setWriter(new PrintWriter(System.out));
logger.addAppender(consoleAppender);

🍀(3)Layouts

Layout layout = new Layout() {
    @Override
    public String format(LoggingEvent loggingEvent) {
        return loggingEvent.getLoggerName() + " "
            +loggingEvent.getMessage() + "\r\n";
    }
    @Override
    public boolean ignoresThrowable() {
        return false;
    }
    @Override
    public void activateOptions() {
    }
};

有一些默认的实现类:


Layout layout = new SimpleLayout();


02e6788d21e44f588e7279b1efeedb3c.png


3️⃣Log4j配置


log4j不仅仅可以在控制台,文件文件中输出日志,甚至可以在数据库中,我们先使用配置的方式完成日志的输入:

#指定日志的输出级别与输出端
log4j.rootLogger=INFO,Console,ydl
# 控制台输出配置
log4j.appender.Console=org.apache.log4j.ConsoleAppender
log4j.appender.Console.layout=org.apache.log4j.PatternLayout
log4j.appender.Console.layout.ConversionPattern=%d [%t] %-5p [%c] - %m%n
# 文件输出配置
log4j.appender.ydl = org.apache.log4j.DailyRollingFileAppender
#指定日志的输出路径
log4j.appender.ydl.File = D:/logs/ydl.log
log4j.appender.ydl.Append = true
#使用自定义日志格式化器
log4j.appender.ydl.layout = org.apache.log4j.PatternLayout
#指定日志的输出格式
log4j.appender.ydl.layout.ConversionPattern = %-d{yyyy-MM-dd HH:mm:ss} [%t:%r] -[%p] %m%n
#指定日志的文件编码
log4j.appender.ydl.encoding=UTF-8

有了这个配置文件我们些代码就简单了一些:

@Test
public void testConfig(){
    // 获取一个logger
    Logger logger = Logger.getLogger(TestLog4j.class);
    logger.warn("warning");
}
结果:
    2021-10-21 21:37:06,705 [main] WARN  [com.wang.TestLog4j] - warning

同时日志文件也会产生:

c3c80a32339d47ab90b8b681df282112.png

日志文件内容如下:


2a3eba280862415fbd6c58388722512f.png当然日志配置文件是什么时候读取的呢?每一个logger都是LogManager创建的,而LogManager有一个静态代码块帮助我们解析配置文件。


我们还可以直接添加一个数据源,将日志输出到数据库中,就是一个和数据库链接的输出源。


加入一个数据库的日志输出源:

#mysql
log4j.appender.logDB=org.apache.log4j.jdbc.JDBCAppender
log4j.appender.logDB.layout=org.apache.log4j.PatternLayout
log4j.appender.logDB.Driver=com.mysql.cj.jdbc.Driver
log4j.appender.logDB.URL=jdbc:mysql://localhost:3306/ssm
log4j.appender.logDB.User=root
log4j.appender.logDB.Password=root
log4j.appender.logDB.Sql=INSERT INTO log(project_name,create_date,level,category,file_name,thread_name,line,all_category,message) values('ydlclass','%d{yyyy-MM-ddHH:mm:ss}','%p','%c','%F','%t','%L','%l','%m')

需要创建保存日志的数据表:

CREATE TABLE `log` (
    `log_id` int(11) NOT NULL AUTO_INCREMENT,
    `project_name` varchar(255) DEFAULT NULL COMMENT '目项名',
    `create_date` varchar(255) DEFAULT NULL COMMENT '创建时间',
    `level` varchar(255) DEFAULT NULL COMMENT '优先级',
    `category` varchar(255) DEFAULT NULL COMMENT '所在类的全名',
    `file_name` varchar(255) DEFAULT NULL COMMENT '输出日志消息产生时所在的文件名称 ',
    `thread_name` varchar(255) DEFAULT NULL COMMENT '日志事件的线程名',
    `line` varchar(255) DEFAULT NULL COMMENT '号行',
    `all_category` varchar(255) DEFAULT NULL COMMENT '日志事件的发生位置',
    `message` varchar(4000) DEFAULT NULL COMMENT '输出代码中指定的消息',
    PRIMARY KEY (`log_id`)
);

在pom.xml中添加驱动:

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.22</version>
</dependency>

再次执行,发现除了控制台,文件,数据库中也有了日志了:


4️⃣自定义Logger

# RootLogger配置
log4j.rootLogger = trace,console
# 自定义Logger
log4j.logger.com.ydlclass= WARN,logDB
log4j.logger.org.apache = error

由此我们发现,我们可以很灵活的自定义,组装不同logger的实现,接下来我们写代码测试:

@Test
public void testDefineLogger() throws Exception {
    Logger logger1 = Logger.getLogger(Log4jTest.class);
    logger1.fatal("fatal"); // 严重错误,一般会造成系统崩溃和终止运行
    logger1.error("error"); // 错误信息,但不会影响系统运行
    logger1.warn("warn"); // 警告信息,可能会发生问题
    logger1.info("info"); // 程序运行信息,数据库的连接、网络、IO操作等
    logger1.debug("debug"); // 调试信息,一般在开发阶段使用,记录程序的变量、参数等
    logger1.trace("trace"); // 追踪信息,记录程序的所有流程信息
    // 自定义 org.apache
    Logger logger2 = Logger.getLogger(Logger.class);
    logger2.fatal("fatal logger2"); // 严重错误,一般会造成系统崩溃和终止运行
    logger2.error("error logger2"); // 错误信息,但不会影响系统运行
    logger2.warn("warn logger2"); // 警告信息,可能会发生问题
    logger2.info("info logger2"); // 程序运行信息,数据库的连接、网络、IO操作等
    logger2.debug("debug logger2"); // 调试信息,一般在开发阶段使用,记录程序的变量、参数等
    logger2.trace("trace logger2"); // 追踪信息,记录程序的所有流程信息
}

我们发现logger1的日志级别成了warn,并且在数据库中有了日志,logger2级别成了error,他们其实都继承了根logger的一些属性。


四、日志门面


当我们的系统变的复杂的之后,难免会集成其他的系统,不同的系统之间可能会使用不同的日志系统。那么在一个系统中,我们的日志框架可能会出现多个,会出现混乱,而且随着时间的发展,可能会出现新的效率更高的日志系统,如果我们想切换代价会非常的大。如果我们的日志系统能和jdbc一样,有一套自己的规范,其他实现均按照规范去实现,就能很灵活的使用日志框架了。


日志门面就是为了解决这个问题而出现的一种技术,日志门面是规范,其他的实现按照规范实现各自的日志框架即可,我们程序员基于日志门面编程即可。举个例子:日志门面就好比菜单,日志实现就好比厨师,我们去餐馆吃饭按照菜单点菜即可,厨师是谁其实不重要,但是有一个符合我口味的厨师当然会更好。


常见的日志门面: JCL、slf4j


常见的日志实现: JUL、log4j、logback、log4j2


日志框架出现的历史顺序: log4j -->JUL–>JCL–> slf4j --> logback --> log4j2

f5338978ab754bc18ebefaf2efbaedb1.png

1️⃣Slf4j日志门面


简单日志门面(Simple Logging Facade For Java) SLF4J主要是为了给Java日志访问提供一套标准、规范的API框架,其主要意义在于提供接口,具体的实现可以交由其他日志框架,例如log4j和logback等。 当然slf4j自己也提供了功能较为简单的实现,但是一般很少用到。对于一般的Java项目而言,日志框架 会选择slf4j-api作为门面,配上具体的实现框架(log4j、logback等),中间使用桥接器完成桥接。官方网站: https://www.slf4j.org/


SLF4J是目前市面上最流行的日志门面。现在的项目中,基本上都是使用SLF4J作为我们的日志系统。


SLF4J日志门面主要提供两大功能:


日志框架的绑定

日志框架的桥接

🍀(1)阿里日志规约


(1)应用中不可直接使用日志系统(Log4j、Logback)中的API,而应依赖使用日志框架SLF4J中的API。使用门面模式的日志框架,有利于维护和各个类的日志处理方法统一。

(2)日志文件推荐至少保存15天,因为有些异常具备以“周”为频次发生的特点。

(3)应用中的扩展日志(如打点、临时监控、访问日志等)命名方式:appName_logType_logName.log。logType为日志类型,推荐分类有stats/monitor/visit等;

(4)logName为日志描述。这种命名的好处:通过文件名就可以知道日志文件属于哪个应用,哪种类型,有什么目的,这也有利于归类查找。

(5)对trace/debug/info级别的日志输出,必须使用条件输出形式或者占位符的方式。

(6)避免重复打印日志,否则会浪费磁盘空间。务必在日志配置文件中设置additivity=false。

(7)异常信息应该包括两类:案发现场信息和异常堆栈信息。如果不处理,那么通过关键字向上抛出。

(8)谨慎地记录日志。生产环境禁止输出debug日志;有选择地输出info日志;如果使用warn记录刚上线时的业务行为信息,一定要注意日志输出量的问题,避免吧服务器磁盘撑爆,并及时删除这些观察日志。

(9)可以使用warn日志级别记录用户输入参数错误的情况,避免当用户投诉时无所适从。

🍀(2)SLF4J实战


(1)添加依赖

<!--slf4j core 使用slf4j必須添加-->
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>1.7.27</version>
</dependency>
<!--slf4j 自带的简单日志实现 -->
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-simple</artifactId>
    <version>1.7.27</version>
</dependency>

(2)编写测试代码

public class TestSlf4j {
    // 声明日志对象
    public final static Logger LOGGER =
            LoggerFactory.getLogger(TestSlf4j.class);
    @Test
    public void testSlfSimple()  {
        //打印日志信息
        LOGGER.error("error");
        LOGGER.warn("warn");
        LOGGER.info("info");
        LOGGER.debug("debug");
        LOGGER.trace("trace");
        // 使用占位符输出日志信息
        String name = "lucy";
        Integer age = 18;
        LOGGER.info("{}今年{}岁了!", name, age);
        // 将系统异常信息写入日志
        try {
            int i = 1 / 0;
        } catch (Exception e) {
            // e.printStackTrace();
            LOGGER.info("出现异常:", e);
        }
    }
}

🍀(3)绑定其他日志的实现(Binding)


如前所述,SLF4J支持各种日志框架。SLF4J发行版附带了几个称为“SLF4J绑定”的jar文件,每个绑定对应一个受支持的框架。


使用slf4j的日志绑定流程:


(1)添加slf4j-api的依赖

(2)使用slf4j的API在项目中进行统一的日志记录

(3)绑定具体的日志实现框架

a. 绑定已经实现了slf4j的日志框架,直接添加对应依赖

b. 绑定没有实现slf4j的日志框架,先添加日志的适配器,再添加实现类的依赖

(4)slf4j有且仅有一个日志实现框架的绑定(如果出现多个默认使用第一个依赖日志实现)

绑定JUL的实现:


<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>1.7.27</version>
</dependency>
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-jdk14</artifactId>
    <version>1.7.25</version>
</dependency>

绑定log4j的实现:

<!--slf4j core 使用slf4j必須添加-->
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>1.7.27</version>
</dependency>
<!-- log4j-->
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-log4j12</artifactId>
    <version>1.7.27</version>
</dependency>
<dependency>
    <groupId>log4j</groupId>
    <artifactId>log4j</artifactId>
    <version>1.2.17</version>
</dependency>

要切换日志框架,只需替换类路径上的slf4j绑定。例如,要从java.util.logging切换到log4j,只需将 slf4j-jdk14-1.7.27.jar替换为slf4j-log4j12-1.7.27.jar即可。


SLF4J不依赖于任何特殊的类装载。实际上,每个SLF4J绑定在编译时都是硬连线的, 以使用一个且只有 一个特定的日志记录框架。例如,slf4j-log4j12-1.7.27.jar绑定在编译时绑定以使用log4j。


🍀(4)桥接旧的日志框架(Bridging)


通常,您依赖的某些组件依赖于SLF4J以外的日志记录API。您也可以假设这些组件在不久的将来不会切换到SLF4J。为了解决这种情况,SLF4J附带了几个桥接模块,这些模块将对log4j,JCL和 java.util.logging API的调用重定向,就好像它们是对SLF4J API一样。


就是你还用log4j的api写代码,但是具体的实现给你抽离了,我们依赖了一个中间层,这个层其实是用旧的api操作slf4j,而不是操作具体的实现。


桥接解决的是项目中日志的遗留问题,当系统中存在之前的日志API,可以通过桥接转换到slf4j的实现:


(1)先去除之前老的日志框架的依赖,必须去掉。

(2)添加SLF4J提供的桥接组件,这个组件就是模仿之前老的日志写了一套相同的api,只不过这个api是在调用slf4j的api。

(3)为项目添加SLF4J的具体实现。


迁移的方式:

<!-- 桥接的组件 -->
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>log4j-over-slf4j</artifactId>
    <version>1.7.27</version>
</dependency>
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>1.7.27</version>
</dependency>
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-simple</artifactId>
    <version>1.7.27</version>
</dependency>

SLF4J提供的桥接器:

<!-- log4j-->
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>log4j-over-slf4j</artifactId>
    <version>1.7.27</version>
</dependency>
<!-- jul -->
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>jul-to-slf4j</artifactId>
    <version>1.7.27</version>
</dependency>
<!--jcl -->
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>jcl-over-slf4j</artifactId>
    <version>1.7.27</version>
</dependency>


注意问题:


(1)jcl-over-slf4j.jar和 slf4j-jcl.jar不能同时部署。前一个jar文件将导致JCL将日志系统的选择委托给SLF4J,后一个jar文件将导致SLF4J将日志系统的选择委托给JCL,从而导致无限循环

(2)log4j-over-slf4j.jar和slf4j-log4j12.jar不能同时出现

(3)jul-to-slf4j.jar和slf4j-jdk14.jar不能同时出现

(4)所有的桥接都只对Logger日志记录器对象有效,如果程序中调用了内部的配置类或者是Appender,Filter等对象,将无法产生效果


🍀(5)SLF4J原理解析


(1)SLF4J通过LoggerFactory加载日志具体的实现对象

(2)LoggerFactory在初始化的过程中,会通过performInitialization()方法绑定具体的日志实现

(3)在绑定具体实现的时候,通过类加载器,加载org/slf4j/impl/StaticLoggerBinder.class

(4)所以,只要是一个日志实现框架,在org.slf4j.impl包中提供一个自己的StaticLoggerBinder类,在其中提供具体日志实现的LoggerFactory就可以被SLF4J所加载


在slf4j中创建logger的方法是:

public static Logger getLogger(String name) {
    ILoggerFactory iLoggerFactory = getILoggerFactory();
    return iLoggerFactory.getLogger(name);
}

继续进入查看,核心就是performInitialization();:

public static ILoggerFactory getILoggerFactory() {
    if (INITIALIZATION_STATE == UNINITIALIZED) {
        synchronized (LoggerFactory.class) {
            if (INITIALIZATION_STATE == UNINITIALIZED) {
                INITIALIZATION_STATE = ONGOING_INITIALIZATION;
                performInitialization();
            }
        }
    }
}

继续进入查看,核心就是bind(),这个方法应该就能绑定日志实现了:

private final static void performInitialization() {
        bind();
        if (INITIALIZATION_STATE == SUCCESSFUL_INITIALIZATION) {
            versionSanityCheck();
        }
    }
}

来到这里,看看绑定的方法:

private final static void bind() {
        try {
            ...
            // 以下内容就绑定成功了
            StaticLoggerBinder.getSingleton();
            INITIALIZATION_STATE = SUCCESSFUL_INITIALIZATION;
            reportActualBinding(staticLoggerBinderPathSet);
            fixSubstituteLoggers();
            replayEvents();
            // release all resources in SUBST_FACTORY
            SUBST_FACTORY.clear();
        } catch (NoClassDefFoundError ncde) {
            String msg = ncde.getMessage();
            if (messageContainsOrgSlf4jImplStaticLoggerBinder(msg)) {
                INITIALIZATION_STATE = NOP_FALLBACK_INITIALIZATION;
                Util.report("Failed to load class \"org.slf4j.impl.StaticLoggerBinder\".");
                Util.report("Defaulting to no-operation (NOP) logger implementation");
                Util.report("See " + NO_STATICLOGGERBINDER_URL + " for further details.");
            } else {
                failedBinding(ncde);
                throw ncde;
            }
        } catch (java.lang.NoSuchMethodError nsme) {
            String msg = nsme.getMessage();
            if (msg != null && msg.contains("org.slf4j.impl.StaticLoggerBinder.getSingleton()")) {
                INITIALIZATION_STATE = FAILED_INITIALIZATION;
                Util.report("slf4j-api 1.6.x (or later) is incompatible with this binding.");
                Util.report("Your binding is version 1.5.5 or earlier.");
                Util.report("Upgrade your binding to version 1.6.x.");
            }
            throw nsme;
        } catch (Exception e) {
            failedBinding(e);
            throw new IllegalStateException("Unexpected initialization failure", e);
        }
    }

每一个日志实现的中间包都有一个StaticLoggerBinder:

ded0062bee4c41eab349845faa7f7aa0.png

public class StaticLoggerBinder implements LoggerFactoryBinder {
    /**
     * The unique instance of this class.
     * 
     */
    private static final StaticLoggerBinder SINGLETON = new StaticLoggerBinder();
    /**
     * Return the singleton of this class.
     * 
     * @return the StaticLoggerBinder singleton
     */
    public static final StaticLoggerBinder getSingleton() {
        return SINGLETON;
    }
    /**
     * Declare the version of the SLF4J API this implementation is compiled against. 
     * The value of this field is modified with each major release. 
     */
    // to avoid constant folding by the compiler, this field must *not* be final
    public static String REQUESTED_API_VERSION = "1.6.99"; // !final
    private static final String loggerFactoryClassStr = Log4jLoggerFactory.class.getName();
    /**
     * The ILoggerFactory instance returned by the {@link #getLoggerFactory}
     * method should always be the same object
     */
    private final ILoggerFactory loggerFactory;
    private StaticLoggerBinder() {
        loggerFactory = new Log4jLoggerFactory();
        try {
            @SuppressWarnings("unused")
            Level level = Level.TRACE;
        } catch (NoSuchFieldError nsfe) {
            Util.report("This version of SLF4J requires log4j version 1.2.12 or later. See also http://www.slf4j.org/codes.html#log4j_version");
        }
    }
    public ILoggerFactory getLoggerFactory() {
        return loggerFactory;
    }
    public String getLoggerFactoryClassStr() {
        return loggerFactoryClassStr;
    }
}

2️⃣JCL日志门面


全称为Jakarta Commons Logging,是Apache提供的一个通用日志API。 改日志门面的使用并不是很广泛。


它是为 "所有的Java日志实现"提供一个统一的接口,它自身也提供一个日志的实现,但是功能非常常弱 (SimpleLog)。所以一般不会单独使用它。他允许开发人员使用不同的具体日志实现工具: Log4j, Jdk 自带的日志(JUL)


JCL 有两个基本的抽象类:Log(基本记录器)和LogFactory(负责创建Log实例)。


🍀JCL入门


(1)建立maven工程


(2)添加依赖

<dependency>
    <groupId>commons-logging</groupId>
    <artifactId>commons-logging</artifactId>
    <version>1.2</version>
</dependency>

(3)入门代码

public class JULTest {
    @Test
    public void testQuick() throws Exception {
        // 创建日志对象
        Log log = LogFactory.getLog(JULTest.class);
        // 日志记录输出
        log.fatal("fatal");
        log.error("error");
        log.warn("warn");
        log.info("info");
        log.debug("debug");
    }
}

们为什么要使用日志门面:


  • 面向接口开发,不再依赖具体的实现类。减少代码的耦合
  • 项目通过导入不同的日志实现类,可以灵活的切换日志框架
  • 统一API,方便开发者学习和使用
  • 统一配置便于项目日志的管理


🍀JCL原理


dc28d376aef742349726ed7e790ba4fd.png

(1)通过LogFactory动态加载Log实现类

7c1bfaeb6efa400ea9d62373f9951d98.png


(2)日志门面支持的日志实现数组

private static final String[] classesToDiscover =
    new String[]{"org.apache.commons.logging.impl.Log4JLogger",
                 "org.apache.commons.logging.impl.Jdk14Logger",
                 "org.apache.commons.logging.impl.Jdk13LumberjackLogger",
                 "org.apache.commons.logging.impl.SimpleLog"};

(3)获取具体的日志实现

for(int i = 0; i < classesToDiscover.length && result == null; ++i) {
    result = this.createLogFromClass(classesToDiscover[i], logCategory,
                                     true);
}

五、Logback日志框架


Logback是由log4j创始人设计的另一个开源日志组件,性能比log4j要好。官方网站:https://logback.qos.ch/index.html

Logback主要分为三个模块:


logback-core:其它两个模块的基础模块

logback-classic:它是log4j的一个改良版本,同时它完整实现了slf4j API

logback-access:访问模块与Servlet容器集成提供通过Http来访问日志的功能

后续的日志代码都是通过SLF4J日志门面搭建日志系统,所以在代码是没有区别,主要是通过修改配置文件和pom.xml依赖


1️⃣Logback入门


🍀(1)添加依赖

<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <version>1.2.3</version>
</dependency>

🍀(2)java代码

public class TestLogback {
    private final static Logger logger = LoggerFactory.getLogger(TestLog4j.class);
    @Test
    public void testLogback(){
        //打印日志信息
        logger.error("error");
        logger.warn("warn");
        logger.info("info");
        logger.debug("debug");
        logger.trace("trace");
    }
}

其实我们发现即使项目中没有引入slf4j我们这里也是用的slf4j门面进行编程。

f8d1122dd2e043e28b9d646608ec7839.png

从logback’的pom依赖中我们看到slf4j,依赖会进行传递

41924d190b95442e81b634bb6c0c8ea4.png


2️⃣Logback源码解析


🍀(1)spi机制


SPI全称Service Provider Interface,是Java提供的一套用来被第三方实现或者扩展的API,它可以用来启用框架扩展和替换组件。他是一种服务发现机制。它通过在ClassPath路径下的META-INF/services文件夹查找文件,自动加载文件里所定义的类。


主要是使用,java.util包下的ServiceLoader实现:

public static <S> ServiceLoader<S> load(Class<S> service, ClassLoader loader)
{
    return new ServiceLoader<>(service, loader);
}

🍀(2)源码解析

源码看一下启动过程:

我们从日志工厂的常见看起,这里是slf4j的实现:


private final static Logger logger = LoggerFactory.getLogger(TestLog4j.class);


核心方法只有一句:

public static Logger getLogger(Class<?> clazz) {
    Logger logger = getLogger(clazz.getName());
    ...中间的逻辑判断省略掉
    return logger;
}


看一下getLogger方法,这里是先获取日志工厂,在从工厂中提取日志对象,我们不考虑日志对象,主要看看日志工厂的环境怎么初始化的:

public static Logger getLogger(String name) {
    ILoggerFactory iLoggerFactory = getILoggerFactory();
    return iLoggerFactory.getLogger(name);
}

日志工厂的创建方法:

public static ILoggerFactory getILoggerFactory() {
  ...去掉其他的代码,从这一行看。
    return StaticLoggerBinder.getSingleton().getLoggerFactory();
}


这里就进入了,StaticLoggerBinder这个对象,这是日志实现用来和slf4j进行绑定的类,从此就进入日志实现中了。


StaticLoggerBinder.getSingleton()这里看到出来是一个单例,来到这个类当中,我们看到,直接返回了defaultLoggerContext。

public ILoggerFactory getLoggerFactory() {
        if (!initialized) {
            return defaultLoggerContext;
        }
... 省略其他
    }

这是个日志上下文,一定保存了我们的环境,配置内容一定在这个里边,那么哪里初始化他了呢,我们能想到的就是静态代码块了:

我们发现这个类中还真有:

static {
    SINGLETON.init();
}

我们看到init()方法中,有一个autoConfig(),感觉就像在自动配置:

void init() {
    try {
        try {
            new ContextInitializer(defaultLoggerContext).autoConfig();
        } catch (JoranException je) {
            Util.report("Failed to auto configure default logger context", je);
        }
        ...其他省略
    }
}

默认配置:ContextInitializer类是初始化的关键。

自动配置是这么玩的,先找配置文件。

public void autoConfig() throws JoranException {
        StatusListenerConfigHelper.installIfAsked(loggerContext);
        // 这就是去找配置文件
        URL url = findURLOfDefaultConfigurationFile(true);
        if (url != null) {
            // 解析配置
            configureByResource(url);
        } else {
            // 没有找到文件,就去使用spi机制找一个配置类,这个配置类是在web中用的
            Configurator c = EnvUtil.loadFromServiceLoader(Configurator.class);
            if (c != null) {
                try {
                    c.setContext(loggerContext);
                    c.configure(loggerContext);
                } catch (Exception e) {
                    throw new LogbackException(String.format("Failed to initialize Configurator: %s using ServiceLoader", c != null ? c.getClass()
                                    .getCanonicalName() : "null"), e);
                }
            } else {
                // 如果没有找到,就做基本的配置
                BasicConfigurator basicConfigurator = new BasicConfigurator();
                basicConfigurator.setContext(loggerContext);
                basicConfigurator.configure(loggerContext);
            }
        }
    }

寻找配置文件的过程:

final public static String GROOVY_AUTOCONFIG_FILE = "logback.groovy";
final public static String AUTOCONFIG_FILE = "logback.xml";
final public static String TEST_AUTOCONFIG_FILE = "logback-test.xml";
public URL findURLOfDefaultConfigurationFile(boolean updateStatus) {
    ClassLoader myClassLoader = Loader.getClassLoaderOfObject(this);
    URL url = findConfigFileURLFromSystemProperties(myClassLoader, updateStatus);
    if (url != null) {
        return url;
    }
    url = getResource(TEST_AUTOCONFIG_FILE, myClassLoader, updateStatus);
    if (url != null) {
        return url;
    }
    url = getResource(GROOVY_AUTOCONFIG_FILE, myClassLoader, updateStatus);
    if (url != null) {
        return url;
    }
    return getResource(AUTOCONFIG_FILE, myClassLoader, updateStatus);
}
 public void configureByResource(URL url) throws JoranException {
        if (url == null) {
            throw new IllegalArgumentException("URL argument cannot be null");
        }
        final String urlString = url.toString();
        if (urlString.endsWith("groovy")) {
            if (EnvUtil.isGroovyAvailable()) {
                // avoid directly referring to GafferConfigurator so as to avoid
                // loading groovy.lang.GroovyObject . See also http://jira.qos.ch/browse/LBCLASSIC-214
                GafferUtil.runGafferConfiguratorOn(loggerContext, this, url);
            } else {
                StatusManager sm = loggerContext.getStatusManager();
                sm.add(new ErrorStatus("Groovy classes are not available on the class path. ABORTING INITIALIZATION.", loggerContext));
            }
        } else if (urlString.endsWith("xml")) {
            JoranConfigurator configurator = new JoranConfigurator();
            configurator.setContext(loggerContext);
            configurator.doConfigure(url);
        } else {
            throw new LogbackException("Unexpected filename extension of file [" + url.toString() + "]. Should be either .groovy or .xml");
        }
    }

基础配置的代码:

public class BasicConfigurator extends ContextAwareBase implements Configurator {
    public BasicConfigurator() {
    }
    public void configure(LoggerContext lc) {
        addInfo("Setting up default configuration.");
        ConsoleAppender<ILoggingEvent> ca = new ConsoleAppender<ILoggingEvent>();
        ca.setContext(lc);
        ca.setName("console");
        LayoutWrappingEncoder<ILoggingEvent> encoder = new LayoutWrappingEncoder<ILoggingEvent>();
        encoder.setContext(lc);
        // same as 
        // PatternLayout layout = new PatternLayout();
        // layout.setPattern("%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n");
        TTLLLayout layout = new TTLLLayout();
        layout.setContext(lc);
        layout.start();
        encoder.setLayout(layout);
        ca.setEncoder(encoder);
        ca.start();
        Logger rootLogger = lc.getLogger(Logger.ROOT_LOGGER_NAME);
        rootLogger.addAppender(ca);
    }
}

我们先不说配置的事情,从源码中我们可以看出有几种配置,因为有了

我们先模仿BasicConfigurator写一个类,只做略微的改动:

public class MyConfigurator extends ContextAwareBase implements Configurator {
    public MyConfigurator() {
    }
    public void configure(LoggerContext lc) {
        addInfo("Setting up default configuration.");
        ConsoleAppender<ILoggingEvent> ca = new ConsoleAppender<ILoggingEvent>();
        ca.setContext(lc);
        ca.setName("console");
        LayoutWrappingEncoder<ILoggingEvent> encoder = new LayoutWrappingEncoder<ILoggingEvent>();
        encoder.setContext(lc);
        // same as
        // PatternLayout layout = new PatternLayout();
        // layout.setPattern("%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n");
        PatternLayout layout = new PatternLayout();
        layout.setPattern("%d{HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n");
        layout.setContext(lc);
        layout.start();
        encoder.setLayout(layout);
        ca.setEncoder(encoder);
        ca.start();
        Logger rootLogger = lc.getLogger(Logger.ROOT_LOGGER_NAME);
        rootLogger.addAppender(ca);
    }
}

在resource中新建META-INF目录,下边在新建services文件夹,再新建一个名字叫ch.qos.logback.classic.spi.Configurator的文件,内容是:com.ydlclass.MyConfigurator。


7f8854250dd04b948bf836d9056bd7f8.png

相关实践学习
日志服务之使用Nginx模式采集日志
本文介绍如何通过日志服务控制台创建Nginx模式的Logtail配置快速采集Nginx日志并进行多维度分析。
相关文章
|
27天前
|
Kubernetes Ubuntu Windows
【Azure K8S | AKS】分享从AKS集群的Node中查看日志的方法(/var/log)
【Azure K8S | AKS】分享从AKS集群的Node中查看日志的方法(/var/log)
|
9天前
|
Java
日志框架log4j打印异常堆栈信息携带traceId,方便接口异常排查
日常项目运行日志,异常栈打印是不带traceId,导致排查问题查找异常栈很麻烦。
|
19天前
|
存储 监控 数据可视化
SLS 虽然不是直接使用 OSS 作为底层存储,但它凭借自身独特的存储架构和功能,为用户提供了一种专业、高效的日志服务解决方案。
【9月更文挑战第2天】SLS 虽然不是直接使用 OSS 作为底层存储,但它凭借自身独特的存储架构和功能,为用户提供了一种专业、高效的日志服务解决方案。
50 9
|
1月前
|
数据采集 监控 数据安全/隐私保护
掌握Selenium爬虫的日志管理:调整–log-level选项的用法
在Selenium Web数据采集时,日志管理至关重要。通过调整`–log-level`参数可优化日志详细度,如设置为`INFO`记录一般操作信息。结合代理IP、Cookie及user-agent配置,不仅能提高采集成功率,还能规避反爬机制。合理选择日志级别有助于调试与性能平衡,在复杂的数据采集任务中保持程序稳定与可控。
掌握Selenium爬虫的日志管理:调整–log-level选项的用法
|
27天前
|
开发框架 .NET Docker
【Azure 应用服务】App Service .NET Core项目在Program.cs中自定义添加的logger.LogInformation,部署到App Service上后日志不显示Log Stream中的问题
【Azure 应用服务】App Service .NET Core项目在Program.cs中自定义添加的logger.LogInformation,部署到App Service上后日志不显示Log Stream中的问题
|
1月前
|
存储 监控 安全
|
1月前
|
XML Java Maven
log4j 日志的简单使用
这篇文章介绍了Log4j日志框架的基本使用方法,包括在Maven项目中添加依赖、配置`log4j.properties`文件以及在代码中创建和使用Logger对象进行日志记录,但实际打印结果中日志级别没有颜色显示。
log4j 日志的简单使用
|
1月前
|
XML Java Maven
Spring5入门到实战------16、Spring5新功能 --整合日志框架(Log4j2)
这篇文章是Spring5框架的入门到实战教程,介绍了Spring5的新功能——整合日志框架Log4j2,包括Spring5对日志框架的通用封装、如何在项目中引入Log4j2、编写Log4j2的XML配置文件,并通过测试类展示了如何使用Log4j2进行日志记录。
Spring5入门到实战------16、Spring5新功能 --整合日志框架(Log4j2)
|
20天前
|
API C# 开发框架
WPF与Web服务集成大揭秘:手把手教你调用RESTful API,客户端与服务器端优劣对比全解析!
【8月更文挑战第31天】在现代软件开发中,WPF 和 Web 服务各具特色。WPF 以其出色的界面展示能力受到欢迎,而 Web 服务则凭借跨平台和易维护性在互联网应用中占有一席之地。本文探讨了 WPF 如何通过 HttpClient 类调用 RESTful API,并展示了基于 ASP.NET Core 的 Web 服务如何实现同样的功能。通过对比分析,揭示了两者各自的优缺点:WPF 客户端直接处理数据,减轻服务器负担,但需处理网络异常;Web 服务则能利用服务器端功能如缓存和权限验证,但可能增加服务器负载。希望本文能帮助开发者根据具体需求选择合适的技术方案。
56 0
|
20天前
|
C# Windows 监控
WPF应用跨界成长秘籍:深度揭秘如何与Windows服务完美交互,扩展功能无界限!
【8月更文挑战第31天】WPF(Windows Presentation Foundation)是 .NET 框架下的图形界面技术,具有丰富的界面设计和灵活的客户端功能。在某些场景下,WPF 应用需与 Windows 服务交互以实现后台任务处理、系统监控等功能。本文探讨了两者交互的方法,并通过示例代码展示了如何扩展 WPF 应用的功能。首先介绍了 Windows 服务的基础知识,然后阐述了创建 Windows 服务、设计通信接口及 WPF 客户端调用服务的具体步骤。通过合理的交互设计,WPF 应用可获得更强的后台处理能力和系统级操作权限,提升应用的整体性能。
44 0