示例项目
本文以一个 Spring Boot 2.7.18 单模块 Maven 工程为示例,Java 版本为 8,pom.xml 如下:
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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>spring-boot-demo</artifactId>
<version>1.0.0-SNAPSHOT</version>
<name>spring-boot-demo</name>
<properties>
<java.version>8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
后续所有下载、解压、目录结构的说明均围绕这个 pom.xml 声明的依赖展开。
源码从哪里来
Maven 仓库中,一个正式发布的构件通常同时附带一份源码包,命名遵循固定规则:
text
<artifactId>-<version>-sources.jar
jar 内部按原始包路径存放 .java 源文件。例如 spring-boot-2.7.18-sources.jar 解开后会得到 org/springframework/boot/SpringApplication.java。源码包与编译包分离发布,仓库里有 jar 不代表一定有 sources jar,但主流开源框架基本都会提供。
IDE 能看,但被 jar 框住
VSCode、IntelliJ 等主流 IDE 都支持关联源码包,在跳转或断点时自动展示 .java 内容,体验接近阅读自身项目代码。这份能力依赖 IDE 在后台解压 jar 并建立索引。
问题在于,源码始终以 jar 形态存在。一旦需要在源码上做全局检索、跨包比对,或让外部工具读取某段实现逻辑,jar 的封闭结构就成了障碍。IDE 之外的工具链无法像人一样点击跳转,它们面对的是一个二进制压缩包。
Agent 协同开发的盲区
当前 Agent 辅助开发越来越普遍。当代码补全、问题排查、重构建议需要参考第三方库实现细节时,Agent 需要直接读取源文件内容。
而 sources jar 是压缩归档,Agent 无法像读取工程内普通文件那样直接访问其中的 .java。要让 Agent 真正看见第三方源码,最直接的方式是把源码从 jar 中释放出来,以裸文件形式落到工程目录下。
这正是下载源码并解压到 external/ 目录的核心动机:把只读的参考源码工程化,让 IDE 与 Agent 都能便捷访问。
把源码从 jar 中解出来
Maven 提供了官方命令下载全部依赖的源码包:
bash
mvn dependency:sources
执行后,sources jar 会落到本地仓库固定路径:
text
~/.m2/repository/<groupId 转斜杠>/<artifactId>/<version>/<artifactId>-<version>-sources.jar
例如 Spring Boot 核心包:
text
~/.m2/repository/org/springframework/boot/spring-boot/2.7.18/spring-boot-2.7.18-sources.jar
到这一步,源码仍在仓库深处。下一步是把它们解压到工程下的 external/ 目录,以 jar 名作为子目录名:
bash
GROUP_ID="org.springframework.boot"
ARTIFACT_ID="spring-boot"
VERSION="2.7.18"
GROUP_PATH=$(echo "$GROUP_ID" | tr '.' '/')
SOURCE_JAR="$HOME/.m2/repository/$GROUP_PATH/$ARTIFACT_ID/$VERSION/$ARTIFACT_ID-$VERSION-sources.jar"
TARGET_DIR="external/${ARTIFACT_ID}-${VERSION}-sources"
mkdir -p "$TARGET_DIR"
unzip -o "$SOURCE_JAR" -d "$TARGET_DIR"
解压后的目录结构:
text
java-project/
├── external/
│ ├── spring-boot-2.7.18-sources/
│ │ └── org/springframework/boot/
│ │ └── SpringApplication.java
│ ├── spring-web-5.3.31-sources/
│ │ └── org/springframework/web/
│ │ └── ...
│ └── ...
├── pom.xml
external/ 目录定位为只读参考区,不参与项目编译,仅用于集中存放与检索第三方源码。
starter 是空壳,源码在子依赖里
示例项目 pom.xml 引入的 spring-boot-starter-web 是个典型例子。它本身是空壳 POM,只负责聚合以下依赖:
text
spring-boot-starter
spring-boot-starter-json
spring-boot-starter-tomcat
spring-web
spring-webmvc
这些 starter 内部没有 Java 代码,自然没有 sources jar。真正的源码分布在 spring-web、spring-webmvc、spring-boot 等子依赖中。
因此下载源码不能只看 pom 直接声明的依赖,必须遍历全部传递依赖,逐个定位真正包含代码的构件。
自动化脚本一键完成
手动逐个解压不现实。以下是自动化脚本的完整内容:
bash
#!/bin/bash
set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$PROJECT_DIR"
EXTERNAL_DIR="$PROJECT_DIR/external"
DEPS_FILE="$PROJECT_DIR/target/third-party-deps.txt"
M2_REPO="${HOME}/.m2/repository"
echo "==> Project directory: $PROJECT_DIR"
echo "==> External directory: $EXTERNAL_DIR"
mkdir -p "$EXTERNAL_DIR"
echo ""
echo "==> Step 1: Download dependency source jars"
mvn dependency:sources
echo ""
echo "==> Step 2: Resolve dependency list"
mkdir -p target
mvn dependency:list -DoutputFile="$DEPS_FILE"
echo ""
echo "==> Step 3: Extract source jars to $EXTERNAL_DIR"
extracted_count=0
missing_count=0
while IFS= read -r line || [[ -n "$line" ]]; do
line=$(echo "$line" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
[[ -z "$line" ]] && continue
[[ "$line" == "The following files have been resolved:" ]] && continue
[[ "$line" == "The following files have NOT been resolved:" ]] && continue
[[ "$line" =~ ^\[INFO\] ]] && continue
IFS=':' read -r group artifact type version scope <<< "$line"
[[ -z "$group" || -z "$artifact" || -z "$version" ]] && continue
# 跳过 test scope 依赖
scope=$(echo "$scope" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
[[ "$scope" == "test" ]] && continue
group_path=$(echo "$group" | tr '.' '/')
jar_name="${artifact}-${version}-sources.jar"
jar_path="$M2_REPO/$group_path/$artifact/$version/$jar_name"
# 目录名直接使用 jar 文件名(去掉 .jar 后缀)
dir_name="${jar_name%.jar}"
target_dir="$EXTERNAL_DIR/$dir_name"
if [[ ! -f "$jar_path" ]]; then
echo " [SKIP] source jar not found: $jar_path"
missing_count=$((missing_count + 1))
continue
fi
mkdir -p "$target_dir"
echo " [EXTRACT] $group:$artifact:$version -> $target_dir"
unzip -o -q "$jar_path" -d "$target_dir"
extracted_count=$((extracted_count + 1))
done < "$DEPS_FILE"
echo ""
echo "==> Done. Extracted: $extracted_count, missing sources: $missing_count"
脚本分三步完成:第一步调用 mvn dependency:sources 下载全部依赖的源码包到本地仓库;第二步调用 mvn dependency:list 将完整依赖清单输出到 target/third-party-deps.txt,供后续遍历;第三步逐行读取依赖清单,按 groupId 转路径规则定位 sources jar,跳过不存在的和 test 作用域的,解压到 external/ 目录下以 jar 名命名的子目录。脚本结束会输出成功解压数量与缺失数量,便于核对覆盖率。
实际落地结果
在示例项目执行脚本后,external/ 目录下落地二十多个源码包,覆盖 Spring 核心生态与内嵌 Tomcat。典型几个:
text
spring-boot-2.7.18-sources
spring-boot-autoconfigure-2.7.18-sources
spring-context-5.3.31-sources
spring-core-5.3.31-sources
spring-web-5.3.31-sources
spring-webmvc-5.3.31-sources
tomcat-embed-core-9.0.83-sources
logback-core-1.2.12-sources
snakeyaml-1.30-sources
部分 starter 类依赖确实没有 sources jar,脚本自动跳过并计数,印证了前面提到的空壳特性。
注意事项
源码下载是一次性动作。除非依赖版本升级或新增依赖,否则无需重复执行,本地仓库已有的 sources jar 会被复用。
个别依赖未发布 sources jar 时,脚本会跳过并计入 missing 计数,多见于小众库或内部私有库,主流框架基本齐全。
external/ 目录定位为只读参考区,不参与编译。它的价值在于让第三方源码以裸文件形式集中存放,IDE 与 Agent 都能便捷访问。