java代码审计基础
Java 代码审计基础
学完这篇,要能做什么?
拿到一个不太复杂的 Java Web 项目后,能找到请求入口,读懂参数如何传递,跟进业务方法和数据库操作,并说清楚:数据从哪里来,经过哪些处理,最后用在什么地方,哪些检查真的生效了。这篇不要求你先成为 Java 开发工程师,也不把“搜到一个关键词”当作发现漏洞。它要补齐的是后续学习 SQL 注入、身份认证、权限控制、XXE、文件处理等专题时反复会用到的基础。
阅读地图
第一次按顺序读。之后遇到看不懂的代码,再回来查对应章节。
| 阶段 | 章节 | 学完后的检查方法 |
|---|---|---|
| 读懂一小段 Java | 0~5 | 能解释变量、方法、对象、接口和集合,能自己预测简单程序的结果 |
| 看懂项目如何工作 | 6~10 | 能找到依赖、配置和路由,解释 Spring 如何把对象连接起来 |
| 看懂常见数据操作 | 11~12 | 能分清参数绑定与文本替换,认识文件路径和 XML 解析器 |
| 连起来读一条业务链 | 13~14 | 能从请求入口跟到结果返回,记录依据而不是只写猜测 |
| 检查学习效果 | 15~16 | 能完成自测,并知道下一阶段需要深入哪些专题 |
文中的代码分两种:
- 可独立运行:给出文件名、完整类和
main,保存后可以编译运行。 - 项目片段:用于读懂 Spring、JDBC 等代码,依赖项目中的类、配置或数据库,不能单独复制就运行。
不要把两者混在一起。初学时很多“为什么报错”,其实只是把项目片段当成了完整程序。
学习这篇笔记的方法
不要一开始就背漏洞 API
Java 代码审计不是“看到 Runtime.exec 就记一个漏洞,看到 parse 就记一个漏洞”。真正需要掌握的是下面四个问题:
- 这个值从哪里来? 是代码里写死的,还是来自请求参数、Cookie、请求体、数据库或配置文件?
- 这个值经过了什么处理? 有没有类型转换、长度限制、允许列表、参数绑定、权限判断?
- 这个值最后去了哪里? 是拼接 SQL、读取文件、解析 XML、调用命令,还是直接输出到页面?
- 这条代码路径真的能被走到吗? 路由、认证、异常分支、配置和代理是否允许它执行?
可以把审计过程先记成一句话:
来源 Source → 传递 Flow → 检查 Validation → 使用 Sink例如:
HTTP 参数 orderId
↓
Controller 的 id
↓
Service 的 orderId
↓
Mapper 的 #{id}
↓
数据库查询参数看到一行代码时,不要只问“这一行危险吗”,还要把它放回完整的数据流里。
初学者的阅读顺序
第一次看一段代码,可以按照这个顺序:
- 先找类名,知道这段代码属于谁。
- 再找方法名,知道它提供了什么行为。
- 看方法的参数,记录输入从哪里进入。
- 从上到下读
if、循环、return和异常处理。 - 遇到方法调用就跳转到它的定义,不要只凭方法名猜。
- 最后看返回值去了哪里,是否进入数据库、文件或响应。
刚开始看不懂很正常。先画出“谁调用谁”,再补每个类的细节,比从第一行开始硬背所有语法更有效。
0. 第一次看代码,先认这些写法
这一小节不谈漏洞,先解决“代码里到处是括号、点号和类型名,应该怎么读”。后面所有示例都会用到它们。
0.1 一行代码怎样拆开
// 这是一段方法内部的代码,不是完整程序。
// int 是类型,age 是变量名,18 是要保存的值。
// = 在这里表示赋值:把右边的值交给左边的变量,不是数学中的相等判断。
int age = 18;
// 后面不再写 int,因为 age 已经声明过了;这里只给它重新赋值。
age = 20;
// println 是打印方法;括号里的内容是传给它的参数。
// age 没有双引号,所以打印的是变量保存的值:20。
System.out.println(age);
// 有双引号时,它就是一段固定文字,打印 age 这几个字母。
System.out.println("age");先用 类型 变量名 = 值; 这个结构读变量声明。变量是程序里给某个值起的名字,用名字引用它,后面就不必一直写死同一个值。
0.2 符号速查
| 写法 | 初学时怎样读 | 一个小例子 |
|---|---|---|
; | 通常结束一条语句 | int n = 1; |
{ ... } | 把一组代码放在一起,也形成作用域 | 类、方法、if 都会用 |
( ... ) | 方法参数、调用参数或条件;还可能用来分组表达式 | say("小林")、if (n > 0) |
. | 找某个对象或类的成员;包名里也使用点号 | user.getName() |
"文字" | 字符串字面量,即固定文本 | "hello" |
'A' | char 字面量,不是 String | 'A' 与 "A" 类型不同 |
[] | 数组类型或取某个位置的元素 | String[] names、names[0] |
= | 赋值 | n = 3 |
==、!= | 是否相等、是否不相等;引用类型还涉及对象身份 | n == 3、user != null |
>、>=、<、<= | 大于、大于等于、小于、小于等于 | age >= 18 |
+ | 数值相加,或与字符串拼接 | 1 + 2、"页码:" + page |
// | 从这里到行尾是注释,不参与执行 | // 解释这行代码 |
/* ... */ | 跨多行的注释 | 可写较长说明 |
Java 区分大小写:String 和 string 不是同一个名字。字符串使用英文半角双引号,不能用中文输入法产生的弯引号代替。
0.3 表达式是“算出一个结果”的代码
// 方法内片段:加法表达式先求出 5,再把 5 存入 count。
int count = 2 + 3;
// 比较表达式先求出 true,再保存到布尔变量 enough。
boolean enough = count >= 5;
// 从左到右计算:前两个整数先相加得到 3,再与字符串拼接。
System.out.println(1 + 2 + "本书"); // 3本书
// 先遇到字符串,之后的 + 进行字符串拼接。
System.out.println("本数:" + 1 + 2); // 本数:12
// 用括号要求先算加法。
System.out.println("本数:" + (1 + 2)); // 本数:3看到 service.find(id),先按“把 id 交给 service 的 find 方法,得到一个结果”来读。至于它查数据库还是读缓存,要跳到实现里确认,不能只凭名字猜。
[!tip] 第一次学习的范围
先把 0~5 章的完整小程序运行一遍,再看 HTTP 和 Spring。代理、事务、泛型的底层原理可以暂时不深挖,但要知道它们在代码里表达什么。不要一口气把后半篇当成需要背完的术语表。
1. 先认识一个 Java 程序
1.1 从源代码到执行
HelloWorld.java -- javac 编译 --> HelloWorld.class -- java 启动 --> JVM 执行
源代码 字节码 运行时- JDK:开发工具包,包含编译器
javac、运行程序需要的工具和运行时等。 - JVM:执行 Java 字节码的虚拟机。
- classpath:运行时查找应用类和依赖的位置。代码编译成功,不代表运行时一定找得到依赖。
本文用 Java 17 作为学习基线,不要求为了读笔记立刻升级已有项目。真实项目以 pom.xml、Gradle 配置和部署环境为准。
可独立运行:HelloWorld.java
// 声明一个名叫 HelloWorld 的类,保存为 HelloWorld.java。
// 先把类理解成组织代码的结构;后面还会讲它与对象的关系。
public class HelloWorld {
// 启动这个例子时,JVM 从 main 方法进入。
// args 用来接收启动参数;这次没有使用它,可以先保留原样。
public static void main(String[] args) {
// System 是 JDK 提供的类,out 是标准输出流,println 是打印并换行的方法。
// 双引号中的文本是传给 println 的实参。
System.out.println("Hello, Java!");
} // 这里结束 main,不是结束整个源文件。
} // 这里才结束 HelloWorld 类。执行:
javac -encoding UTF-8 HelloWorld.java
java HelloWorld这里 java 后面写类名,不写 .class。
正常结果只有一行 Hello, Java!。类中“声明了方法”不代表它立刻执行:启动时先进入 main,main 再调用其他方法。
如果使用 IntelliJ IDEA,可以创建普通 Java 项目,选择项目 JDK,在源码目录创建 HelloWorld 类,把完整示例放进去,再点 main 旁边的运行按钮。先不需要 Spring 项目,也不需要数据库。
| 现象 | 先检查什么 |
|---|---|
| 提示找不到 javac | 安装的是否是 JDK,终端的 PATH 是否包含相应 bin 目录 |
| 文件看似叫 HelloWorld.java,仍提示文件名不匹配 | 是否实际保存成了 .java.txt,类名大小写是否一致 |
| 找不到主类 | 是否在 .class 所在目录,是否需要配置 classpath 或包名 |
| 提示类版本不支持 | java 与 javac 是否来自不同 JDK;不要只检查其中一个 |
| 中文乱码 | 源文件编码、编译编码和控制台显示编码是否匹配 |
| 写法 | 含义 |
|---|---|
public | 访问修饰符,允许其他代码在符合 Java 规则时访问 |
static | 方法属于类,调用它不需要先创建这个类的对象 |
void | 不返回一个值,不是“不执行任何事情” |
main | 方法名 |
String[] | 字符串数组类型 |
args | 形参名,本身不是关键字 |
[!warning] 不要把 main 当作所有请求的入口main可以启动 Spring Boot 应用,但处理 HTTP 请求时,通常是 Web 容器和框架调用 Controller 方法。读接口业务逻辑时,不必每次从main追起。
1.2 包、导入和方法
下面是项目中的类定义片段,没有 main,不是让你直接启动的完整小程序。包名用于组织类并形成完整名称,例如这里的类名是 com.example.order.OrderService,常见文件位置为 src/main/java/com/example/order/OrderService.java。
package com.example.order; // 当前类属于哪个包
import java.util.List; // 让后面可以直接写 List
public class OrderService {
public String formatName(String name) { // 输入一个 String,返回一个 String
return "用户:" + name; // return 把值交回调用者,并结束当前方法
}
}import 解决的是“这个类名指谁”,不是下载安装依赖。缺少第三方库时,还要检查 Maven 或 Gradle 配置。
方法声明里的变量叫形参;调用时实际传入的值叫实参。[1] 后面跟踪用户输入时,就是把这个过程跨几个方法重复下去。
1.3 把一次方法调用完整走完
可独立运行:MethodDemo.java
public class MethodDemo {
// 定义方法:它暂时不会执行,要等别处调用。
// String 是返回值类型;formatName 是名字;name 是输入参数。
// static 让本例可以直接从 main 调用,暂时不用创建对象。
static String formatName(String name) {
String result = "用户:" + name; // 此次调用里,name 的值是“小林”。
return result; // 返回“用户:小林”,回到调用位置。
}
static void printDivider() { // void 表示不返回一个值。
System.out.println("----"); // 但仍然可以执行打印这样的操作。
}
public static void main(String[] args) {
String input = "小林"; // 第一步:准备输入。
String output = formatName(input); // 第二步:调用方法,等它返回后赋给 output。
System.out.println(output); // 第三步:打印返回的字符串。
printDivider(); // 第四步:调用没有返回值的方法。
}
}输出:
用户:小林
----按实际执行顺序看:
| 位置 | 当时发生什么 |
|---|---|
main 的 input 声明 | 保存字符串“小林” |
调用 formatName(input) | 把 input 的值交给形参 name,进入 formatName |
return result | 返回结果,离开 formatName |
String output = ... | 得到返回值后,完成 output 的赋值 |
println(output) | 使用 output;它不是自动打印出来的 |
方法定义顺序不等于执行顺序。即使 formatName 写在 main 上面,也要等 main 调用它才会执行。return 结束的是当前方法,不一定结束整个程序。
[!note] 后面说的“调用链”是什么
main 调用 A,A 再调用 B,就形成main → A → B。B 返回后通常回到 A,A 返回后回到 main。审计中需要同时看传下去的参数、传回来的结果,以及过程中有没有抛异常。
2. 变量、类型与字符串
2.1 基本类型、引用类型和 null
Java 的基本类型包括 byte、short、int、long、float、double、char 和 boolean。String、数组、List、User、Integer 都是引用类型。
| 类型分类 | 先认识的用途 | 注意 |
|---|---|---|
byte、short、int、long | 保存整数,常见的 int 用于数量、long 用于较大编号 | 每种类型都有范围,不是无限大 |
float、double | 保存近似小数 | 不适合直接假设金额计算完全精确 |
char | 保存一个 UTF-16 代码单元 | 不保证一个 char 就是一个完整的 Unicode 字符 |
boolean | 保存 true 或 false | Java 不把整数 0、1 自动当成布尔值 |
“引用类型”暂时这样理解:变量保存的是用来访问对象的引用值,不是对象内容的全部副本。String 是一种类,对应的对象表示字符串。后面见到 new String(...),意思是创建一个这个类的对象;初学阶段不需要把引用当成可以直接操作的内存地址。
int page = 1; // 整数,适合页码等一般数量。
long orderId = 1001L; // long 表示较大范围整数,L 标明这个字面量为 long。
boolean enabled = true; // 布尔值只有 true 和 false,不是字符串“true”。
String name = "小林"; // 字符串对象,String 不是基本类型。
String[] roles = {"reader", "editor"}; // 字符串数组,现在有两个元素。
Integer optionalAge = null; // Integer 是 int 的包装类,可以表示没有取得值。null 不是空字符串,也不是数字零:
String a = null; // 没有指向字符串对象,不能调用 a.length()。
String b = ""; // 有对象,但内容为空,b.length() 为 0。
String c = " "; // 内容是一个空格,c.length() 为 1。对 a.length() 这样的调用会抛出 NullPointerException。审计时要留意缺少参数、查不到数据库记录、配置没加载时变量是否可能为 null。
2.2 ==、equals 与字符串不可变
可独立运行:StringBasicsDemo.java
import java.util.Objects; // JDK 工具类,提供可处理 null 的比较方法。
public class StringBasicsDemo {
public static void main(String[] args) {
String a = new String("reader"); // 为演示创建第一个字符串对象。
String b = new String("reader"); // 创建第二个对象,内容一样,身份不同。
System.out.println(a == b); // false:比较是否是同一个对象
System.out.println(a.equals(b)); // true:比较内容
String role = null; // 模拟缺少角色值;这里只演示比较,不是可信身份来源。
System.out.println("admin".equals(role)); // false,避免 role 为 null
System.out.println(Objects.equals(role, null)); // true
String raw = " alice "; // 保留原始文本,以便比较处理前后的差别。
String cleaned = raw.trim(); // 得到新结果,不修改 raw 指向的对象
System.out.println("[" + raw + "]"); // [ alice ]
System.out.println("[" + cleaned + "]"); // [alice]
}
}String 不可变,指的是已经创建的对象内容不能原地修改。变量仍然可以重新指向另一个字符串:
String name = "alice";
name = "bob";一个常见误区:input.trim(); 没有接住返回值,后续使用 input 时内容仍是原来的。即使写成 input = input.trim(),也只是去除首尾空白,不等于安全过滤。
2.3 包装类型、转换与字段状态
Integer 是 int 的包装类,可以作为对象使用,也可以表示 null;这不代表所有数字都应该改成 Integer。常见的 long/Long、boolean/Boolean 也是同样关系。局部变量使用前要完成赋值,而实例字段、静态字段会先得到默认值,例如 int 为 0、boolean 为 false、引用为 null。
Integer count = 3; // 自动装箱
int next = count + 1; // 自动拆箱
int page = Integer.parseInt("12"); // 字符串转 int| 写法或想法 | 注意事项 |
|---|---|
Integer n = null; int x = n; | 自动拆箱时抛空指针异常 |
Integer.parseInt(text) | 格式不正确或超范围会抛异常 |
Integer a, b; a == b | 不要用引用比较数值;缓存会制造“碰巧正确” |
参数转成 long | 只证明类型正确,不证明编号属于当前用户 |
用 double 表示金额 | 可能出现浮点精度问题;通常考虑 BigDecimal |
可独立运行:StateDemo.java
import java.util.ArrayList;
import java.util.List;
public class StateDemo {
private int count; // 实例字段:每个对象一份
private static int total; // 静态字段:属于这个类
public void increment() {
int step = 1; // 局部变量,只在这个方法块内可见
count += step; // 等价于这里的 count = count + step,只改变当前对象的字段。
total += step; // 同一个类的实例共享这个静态字段。
}
public static void main(String[] args) {
StateDemo a = new StateDemo(); // 创建对象 A,count 初始为 0。
StateDemo b = new StateDemo(); // 再创建对象 B,它也有自己的 count。
a.increment(); // 只对 A 调用,所以 A.count 变成 1,B.count 仍为 0。
System.out.println(a.count); // 1
System.out.println(b.count); // 0
System.out.println(StateDemo.total); // 1
final List<String> names = new ArrayList<>(); // final 固定引用的赋值
names.add("alice"); // 列表内容仍可修改
// names = new ArrayList<>(); // 不能重新赋值
}
}“局部变量出了方法就销毁”太粗糙。更准确的理解是:它的作用域结束了,但它曾引用的对象可能仍被其他地方引用。
读这个示例时,可以把变量分成三份:每次进入 increment 都有本次调用的 step;对象 A、B 分别有自己的 count;它们使用同一个类的静态 total。方法参数也是局部变量的一种,不是字段。
[!tip] 和 Web 项目的联系
一个 Service 对象可能同时被多个请求使用。把“当前用户”“本次请求的订单号”放到共享字段里,可能造成请求之间互相干扰。请求数据通常应沿方法参数和局部变量传递。
2.4 Java 传参是值传递
对基本类型,传进去的是值的副本;对引用类型,传进去的是引用值的副本,所以两个变量可能访问同一个对象。
可独立运行:ReferenceDemo.java。这里先看“修改对象”与“重新指向对象”的区别,构造方法将在第 4 章详细解释。
public class ReferenceDemo {
// 静态嵌套类只是为了把练习放在一个文件中,不表示所有 User 共用同一个 name。
static class User {
String name; // 每个 User 对象自己的字段。
User(String name) { // 构造方法:new User(...) 时执行。
this.name = name; // this.name 是字段,右边 name 是参数。
}
}
static void changeName(User user) {
user.name = "bob"; // 修改双方共同引用的对象
}
static void replaceUser(User user) {
user = new User("carol"); // 只改变方法内形参的指向
}
public static void main(String[] args) {
User user = new User("alice"); // 先创建一个对象,调用者的 user 引用它。
changeName(user); // 把引用值的副本传进去,两边可以访问同一对象。
System.out.println(user.name); // bob
replaceUser(user); // 这里没有接收新引用;调用者的 user 仍引用原对象。
System.out.println(user.name); // 仍然是 bob
}
}这解释了为什么某个方法没有返回值,却可能改变传入的 DTO、Map 或 List。跟踪数据时,不能只盯着 return。
可以画成这样:
调用 changeName 时: main 的 user ─┐
├──→ 同一个 User 对象
方法的 user ─┘ name 从 alice 变成 bob
replaceUser 内部: 方法的 user ───→ 新 User 对象(carol)
main 的 user ───→ 原 User 对象(bob)2.5 数组、下标和类型转换再练一次
数组是长度固定的一组同类型元素。两个元素的数组,合法下标是 0、1,不是 1、2。列表 List 则可以在支持修改的实现中增加或删除元素,第 5 章再讲。
可独立运行:ArrayDemo.java
public class ArrayDemo {
public static void main(String[] args) {
String[] names = {"小林", "小周"}; // 创建长度为 2 的数组。
System.out.println(names.length); // 数组用 length 字段,输出 2。
System.out.println(names[0]); // 取第一个元素“小林”。
names[1] = "小陈"; // 替换第二个位置的元素,不改变数组长度。
System.out.println(names[1]); // 小陈。
String pageText = "12"; // 模拟来自文本的值,此刻还不是数字。
int page = Integer.parseInt(pageText); // 成功转换后才可以按整数计算。
System.out.println(page + 1); // 13,而不是字符串拼接出来的“121”。
// System.out.println(names[2]); // 如果取消注释,会发生数组下标越界异常。
}
}| 容易混淆的地方 | 应该怎样区分 |
|---|---|
"12" 与 12 | 前者是字符串,后者是整数 |
数组的 length、字符串的 length()、集合的 size() | 名字类似但属于不同类型,不能随意互换 |
| 把所有空值都换成 0 | 可能混淆“用户提交了 0”和“没有提交”,要按业务区分 |
| 成功转成数字就放心使用 | 转换不代替范围校验,更不代替资源权限校验 |
3. 条件、循环和异常
3.1 if 与短路逻辑
程序通常从上到下执行,但条件决定哪些代码执行、哪些跳过。下面是项目片段:假设 user 是一个用户对象,并提供 isEnabled 方法。方法名以 is 开头通常表示布尔结果,但仍要看实际定义。
if (user != null && user.isEnabled()) { // 先检查对象存在
process(user); // 只有对象非空、且 isEnabled() 返回 true 时才调用。
}| 运算符 | 含义 | 是否短路 | ||
|---|---|---|---|---|
&& | 两边都为 true | 左边为 false 时不执行右边 | ||
| `\ | \ | ` | 至少一边为 true | 左边为 true 时不执行右边 |
! | 布尔取反 | 不适用 | ||
&、`\ | ` | 布尔运算或位运算 | 布尔运算不短路 |
把 && 写成 & 后,即使 user 是 null,也会执行 user.isEnabled()。
这里比较两个非空字符串内容用 equals,但比较两个 long 数值可以用 ==。如果两侧都是包装类型 Long,== 可能变成引用身份比较,因此下面明确假设 getOwnerId 和 getId 都返回基本类型 long。可空 ID 还需要先处理缺失值,不能因为两个值都为 null 就把它当作归属匹配。
复杂条件可以拆成有名字的变量:
boolean loggedIn = actor != null; // 这里只先检查对象非空,不等于已证明认证机制可信。
boolean ownsOrder = loggedIn && order.getOwnerId() == actor.getId(); // 假设 order 非空,两个 ID 是 long。
boolean allowed = loggedIn && ownsOrder; // 用 && 表达“同时满足”。这仍只是片段。actor 从哪里来、order 怎么查到、ID 是否属于同一业务范围,都要继续确认。
最普通的 if / else 可以先这样读:
// 方法内部片段;age 假设已声明为 int。
if (age >= 18) { // 条件成立,只执行这一组语句。
System.out.println("已成年");
} else { // 条件不成立,改走这一组。
System.out.println("未成年");
}
// 无论走哪条分支,正常情况下都会继续走到这里。
System.out.println("年龄判断结束");3.2 return、throw、break、continue
| 语句 | 作用 |
|---|---|
return | 结束当前方法,可以带返回值 |
throw | 抛出异常,交给匹配的处理逻辑 |
break | 跳出循环或传统 switch |
continue | 结束本次循环,进入下一次循环 |
写了错误日志,或者给响应写了错误消息,不等于方法自动停止。要看后面有没有 return、throw,以及框架怎样处理。
3.3 switch、循环和异常
switch (status) { // 传统 String switch 遇到 null 会抛异常
case "CREATED":
showCreated(); // 匹配创建状态,调用对应方法。
break; // 离开 switch;不代表结束整个方法。
case "PAID":
showPaid(); // 匹配付款状态。
break;
default:
showUnknown(); // 没有匹配的 case 时处理。
}循环的审计关注点不只有“会不会死循环”,还包括次数是否由用户无限放大、每轮是否访问数据库、校验失败后是否仍继续处理。
可独立运行:LoopDemo.java
public class LoopDemo {
public static void main(String[] args) {
String[] names = {"小林", "小周"}; // 准备两个名字。
// for 的括号分三部分:初始化;继续循环的条件;每轮结束后的更新。
// i++ 表示让 i 增加 1;这里依次使用下标 0、1。
for (int i = 0; i < names.length; i++) {
System.out.println(i + ":" + names[i]); // 本轮取下标 i 对应的元素。
}
// 增强 for 不需要手动管理下标:每轮把一个元素交给 name。
for (String name : names) {
System.out.println("你好," + name);
}
int remaining = 2; // while 循环开始前先准备条件使用的变量。
while (remaining > 0) { // 条件仍成立才进入下一轮。
System.out.println("剩余:" + remaining);
remaining--; // 减 1;如果永不改变条件,这个循环就可能一直执行。
}
}
}第一轮 for 开始时 i 为 0,打印第一个名字;更新为 1 后再次判断;更新为 2 时 2 < 2 不成立,循环结束。常见错误是写成 i <= names.length,让下标走到数组长度,发生越界。
3.4 异常:一次失败怎样传回调用者
异常可以先理解成“当前流程没法按原计划继续”的通知。例如把 abc 转成整数失败。异常抛出后,不会自动接着执行下一行,而是沿调用关系寻找能匹配的 catch。
常见层级:Throwable 下有 Error 和 Exception;RuntimeException 是 Exception 的子类。受检异常(如 IOException)通常需要在代码中捕获或声明继续抛出;非受检异常(如 NullPointerException)没有同样的编译期处理要求。Error 通常不适合随意捕获后继续运行。
异常会改变控制流:
throw new ...:在这里抛出异常。throws IOException:声明异常可能继续向外传播。catch:处理异常;处理后不再抛出,调用者可能看不到失败。finally:通常用于清理资源,但其中再抛异常或return可能遮住原结果。
可独立运行:ExceptionDemo.java
public class ExceptionDemo {
static int parsePage(String text) { // 输入文本,成功时返回页码整数。
try { // 这段代码执行期间出现的匹配异常,交给下面 catch。
int page = Integer.parseInt(text); // "2" 成功;"abc" 在这里抛 NumberFormatException。
if (page < 1) { // 转换成功不等于符合业务范围,所以还要检查。
throw new IllegalArgumentException("页码必须为正数"); // 终止当前正常路径。
}
return page; // 返回前先执行 finally,再把页码交给调用者。
} catch (NumberFormatException e) { // e 是异常对象;这里只处理数字转换失败。
throw new IllegalArgumentException("页码必须是整数", e); // 包装并保留原始原因。
} finally { // 正常返回或这里的异常退出,都会先经过这段。
System.out.println("页码解析结束"); // 不在 finally 里 return,以免遮住原结果。
}
}
public static void main(String[] args) {
System.out.println(parsePage("2")); // 调用成功:先看到“页码解析结束”,再看到 2。
try {
parsePage("abc"); // 内部转换失败,异常从 parsePage 向这里传播。
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage()); // 输出我们定义的提示:页码必须是整数。
}
}
}catch (Exception e) {} 什么也不做,可能吞掉失败,让后续逻辑误以为操作成功。把 e.getMessage() 直接返回客户端,可能泄露路径、SQL 或组件信息;服务端记录堆栈和把堆栈返回客户端是两件事。
try 不是“出错就重试”。它只是建立异常处理范围。finally 在这里用于说明顺序,进程被强制终止等情形不在它的保证范围内。
| 输入 | parsePage 内部路径 | 最终结果 |
|---|---|---|
"2" | 转换成功 → 范围正确 → finally → 返回 | 返回 2 |
"abc" | 转换失败 → catch 包装异常 → finally → 向外抛出 | main 的 catch 输出提示 |
"0" | 转换成功 → 范围失败 → finally → 向外抛出 | 不进入内部的 NumberFormatException catch |
关闭文件流、数据库语句时,经常看到 try-with-resources:
// 项目片段:假设 path、consume 以及 import 已在项目中定义。
try (InputStream input = Files.newInputStream(path)) { // 打开资源并交给 try 管理。
consume(input); // 在资源可用期间处理内容。
} // 正常或异常退出时,都会尝试关闭 input;关闭本身也可能失败。括号里的资源需要实现 AutoCloseable。
4. 面向对象:看懂方法究竟是谁实现的
4.1 类、对象、封装和访问修饰符
先不背“面向对象三大特征”。读下面这个例子就够了:User 类描述用户应该保存什么数据、提供哪些操作;new User(...) 创建一个具体用户对象。一个类可以创建多个对象,各自保存不同的字段值。
下面是类定义片段;完整运行形式在后面的 ObjectDemo 中。字段通常写在类里面、方法外面,方法中的局部变量则只在相应作用域内使用。
public class User {
private final long id; // 字段:该用户的编号,构造时赋值后不再重新赋值。
private String displayName; // 字段:展示名,可通过 setter 修改。
public User(long id, String displayName) { // 构造方法:没有 void,也没有其他返回类型。
this.id = id; // 左边是当前对象字段;右边是传进来的参数。
setDisplayName(displayName); // 调用本类方法,复用下面的校验。
}
public long getId() {
return id; // getter 提供读取通道;名字只是约定,不是语言关键字。
}
public void setDisplayName(String displayName) {
if (displayName == null || displayName.trim().isEmpty()) { // 缺少值或去空格后为空。
throw new IllegalArgumentException("展示名不能为空"); // 拒绝,不进行赋值。
}
this.displayName = displayName; // 通过检查后,保存到当前对象字段。
}
}封装让其他代码通过方法操作对象。不过,private 保护的是 Java 层访问方式,不是 HTTP 层面的身份认证或权限控制。
可独立运行:ObjectDemo.java
public class ObjectDemo {
// 静态嵌套类是为了单文件运行。它的 name 仍然是每个对象各有一份的实例字段。
static class User {
private String name; // 其他顶层类不能直接访问;本例外层与嵌套类间有特殊访问规则。
User(String name) { // 创建对象时执行,用参数初始化字段。
setName(name); // 统一使用下面的检查。
}
String getName() { // getter 返回字段内容。
return name;
}
void setName(String name) { // setter 接收准备写入的值。
if (name == null || name.trim().isEmpty()) {
throw new IllegalArgumentException("名字不能为空");
}
this.name = name; // this 是“这次操作的那个 User 对象”。
}
}
public static void main(String[] args) {
User first = new User("小林"); // 创建对象 A,执行一次构造方法。
User second = new User("小周"); // 创建对象 B,有自己的 name 字段。
first.setName("小陈"); // 通过 A 的 setter 修改 A;B 不受影响。
System.out.println(first.getName()); // 小陈。
System.out.println(second.getName()); // 小周。
}
}User first = new User("小林"); 从左往右看,左边声明一个 User 类型的变量 first,右边创建对象并调用构造方法,再把对象引用交给 first。实际执行时先完成右边的创建,才能完成赋值。
| 常见疑惑 | 解释 |
|---|---|
构造方法能不能写成 void User(...)? | 那会变成一个普通方法,不再是构造方法 |
this.name = name 两边名字一样,怎么区分? | this.name 是对象字段,单独的 name 在这里优先表示同名参数 |
| 有 getter/setter 就一定安全? | 不一定。它们可以包含检查,也可以只是原样读写,要看方法体 |
| 同一个文件里为什么还有一个 User 类? | 这是嵌套类;实际项目也常拆成 User.java 等多个文件 |
| 修饰符 | 入门理解 |
|---|---|
public | 其他包通常可以访问 |
protected | 同包可访问;跨包子类也有额外访问规则 |
| 不写 | package-private,只对同包可见 |
private | 入门可理解为类内部;同一顶层类内的嵌套类型之间还有相应访问规则 |
public 方法不是自动暴露的 HTTP 接口。路由注册、框架配置和调用链才决定它是否能被请求触发。
4.2 static、final、abstract、synchronized
| 关键字 | 作用 | 不要误解成 |
|---|---|---|
static | 类级字段或方法 | 自动线程安全,或所有进程共享一份 |
final | 变量不能重新赋值;方法不能重写;类不能继承 | 引用对象完全不可变 |
abstract | 抽象类不能直接实例化;抽象方法由子类实现 | 抽象类不能有普通方法 |
synchronized | 对同一监视器提供同步访问 | 自动解决分布式并发问题 |
“监视器”这里先按“被共同使用的同步锁”理解,不必现在学习 JVM 的锁实现。static 是类级成员,final 限制重新赋值,两者不是同义词。不同进程、不同类加载器定义的类可能分别有自己的静态状态。
4.3 继承、接口、多态、重载和重写
extends:类继承另一个类。implements:类实现接口。- 接口约定“能做什么”,实现类提供“怎么做”。
super访问父类构造方法或成员。
可独立运行:PolymorphismDemo.java。
public class PolymorphismDemo {
interface Formatter { // 接口:约定“可以格式化名字”这个能力。
String format(String name); // 这里只写约定,没有方法体;具体实现由下面的类提供。
}
static class PlainFormatter implements Formatter { // 第一种实现。
@Override // 让编译器检查这确实实现了接口中的方法,不负责权限判断。
public String format(String name) {
return "用户:" + name; // 它选择加“用户:”前缀。
}
}
static class BracketFormatter implements Formatter { // 第二种实现。
@Override
public String format(String name) {
return "[" + name + "]"; // 同名方法在这个实现里选择加中括号。
}
}
static void show(Formatter formatter) { // 只要求传入对象具备 Formatter 能力。
String result = formatter.format("alice"); // 调用实际对象对应的实现。
System.out.println(result); // 先返回再打印,不把多次调用挤在一行。
}
public static void main(String[] args) {
show(new PlainFormatter()); // 传入第一种对象,输出 用户:alice。
show(new BracketFormatter()); // 传入第二种对象,输出 [alice]。
}
}读到 UserService service,不能只看接口声明就停下。实际可能运行具体实现、测试替身、条件配置或代理对象。
接口也是一种类型,但不能直接 new Formatter() 来创建普通接口实例。先找哪些类 implements Formatter,再找调用时传入的是哪一个。接口还可以有 default、static 等方法,本例只使用最基本的抽象方法约定。
继承与抽象类也可以用一个很小的完整例子理解。
可独立运行:InheritanceDemo.java
public class InheritanceDemo {
static abstract class Employee { // 抽象类,不能直接 new Employee(...)。
protected final String name; // 子类在继承相关规则下可以使用。
Employee(String name) { // 抽象类也有构造方法,由子类构造过程调用。
this.name = name;
}
void clockIn() { // 已有的普通方法,子类可以复用。
System.out.println(name + "打卡");
}
abstract void work(); // 抽象方法:没有方法体,具体子类需要实现。
}
static class Developer extends Employee { // extends 建立继承关系。
Developer(String name) {
super(name); // 调用父类构造方法,初始化父类定义的字段。
}
@Override
void work() { // 重写父类约定的方法。
System.out.println(name + "写代码");
}
}
public static void main(String[] args) {
Employee employee = new Developer("小林"); // 左边父类类型,右边实际为子类对象。
employee.clockIn(); // 执行父类已有的实现。
employee.work(); // 执行子类 Developer 的实现。
}
}输出为“小林打卡”“小林写代码”。普通类只能直接继承一个父类,可以实现多个接口;抽象子类也可以继续保留未实现的抽象方法。初学阶段先会顺着继承关系找到实际方法即可。
| 名称 | 例子 | 主要由什么决定 |
|---|---|---|
| 重载 overload | find(long) 和 find(String) | 编译时按参数签名选择 |
| 重写 override | 子类重新实现父类方法 | 普通实例方法运行时按实际对象选择 |
4.4 Object、equals、hashCode、toString
普通类最终都继承自 Object:
toString():对象的文本表示,日志和字符串拼接可能会调用它。equals():逻辑相等关系,默认实现比较对象身份。hashCode():支持 HashMap、HashSet 等散列容器。
重写 equals 通常要配套重写 hashCode。还要留意自动生成的 toString 是否包含密码、令牌等敏感字段。
5. 集合、泛型与 Lambda
5.1 List、Set、Map
| 类型 | 适合表达什么 | 注意事项 |
|---|---|---|
List<T> | 有顺序、允许重复的一组元素 | 下标从 0 开始,可能越界 |
Set<T> | 不希望重复的一组元素 | 重复判断依赖相等规则 |
Map<K,V> | 根据键查找值 | 相同键再次 put 通常替换原值;是否线程安全取决于实现,HashMap 不是 |
List<String> names = new ArrayList<>(); // 左边约定能力,右边选择实现泛型主要帮助编译期检查,不会自动验证 HTTP 数据,也不代替反序列化配置。
把 List<String> 读作“元素类型为 String 的列表”,把 Map<Long, Order> 读作“键为 Long、值为 Order 的映射”。<> 里的名称是类型,不是变量名。泛型参数不能直接写基本类型,所以这里用 Long,而不是 long。
new ArrayList<>() 右边空尖括号表示编译器可根据上下文推断类型,不是“里面随便装什么”。实际项目用 List 作为变量类型,是为了让调用者依赖列表能力,而不必绑定到某个实现。
可独立运行:CollectionsDemo.java
import java.util.ArrayList; // 可变列表的一种实现。
import java.util.HashMap; // 键值映射的一种实现。
import java.util.Iterator; // 遍历容器的迭代器接口。
import java.util.List; // 列表接口。
import java.util.Map; // 映射接口。
public class CollectionsDemo {
public static void main(String[] args) {
List<String> names = new ArrayList<>(); // 现在是空列表。
names.add("alice"); // 把第一个元素加到列表尾部。
names.add("bob"); // 第二个元素,下标是 1。
names.add("alice"); // List 允许重复,因此有两个 alice。
Iterator<String> iterator = names.iterator(); // 获取这个列表的遍历状态。
while (iterator.hasNext()) { // 还有下一个元素时才进入循环。
String name = iterator.next(); // 取得一个元素,并向前推进遍历位置。
if ("bob".equals(name)) {
iterator.remove(); // 使用迭代器安全删除当前元素
}
}
Map<String, String> settings = new HashMap<>(); // 两个 String 分别是键和值的类型。
settings.put("theme", "light"); // 保存一组键值:theme → light。
settings.put("theme", "dark"); // 相同键覆盖旧值
System.out.println(settings.get("theme")); // dark
System.out.println(settings.get("missing")); // null
names.stream() // 从列表创建处理序列;不表示自动另开线程。
.filter(name -> name.startsWith("a")) // 留下以 a 开头的名字。
.forEach(System.out::println); // 对留下的每个元素调用打印方法,输出两行 alice。
}
}不要在增强 for 遍历 ArrayList 时随意调用同一个列表的 remove。集合是否允许修改,要看具体实现。
列表与映射的查询也要区分:names.get(0) 用位置找元素;settings.get("theme") 用键找值。HashMap 不保证遍历顺序。get 返回 null 时,可能没有这个键,也可能是这个键对应的值就是 null;需要区分时使用 containsKey。
5.2 Lambda 和 Optional
Lambda 是把一小段行为交给方法的简洁写法。name -> name.startsWith("a") 表示“接收一个 name,返回它是否以 a 开头”。先按输入、处理、输出读,不用现在学习所有函数式接口。
// 项目片段:users 假设为 List<User>,并已导入 Collectors。
List<String> names = users.stream() // 从用户列表开始处理。
.filter(user -> user.isEnabled()) // 每个 user 都要返回 true 才保留。
.map(user -> user.getName()) // 把 User 对象转换成它的名字。
.collect(Collectors.toList()); // 最后执行处理,收集为字符串列表。按顺序读:取用户集合 → 保留启用用户 → 取名字 → 收集成列表。这里的 filter 是集合操作,和 Servlet Filter 不是一种东西,也不自动代表安全校验。
// 项目片段:findById 返回 Optional<User>,不直接返回 User。
User user = repository.findById(id) // 取得“可能有用户、也可能没有”的结果。
.orElseThrow(() -> new IllegalArgumentException("用户不存在")); // 没有时创建异常并抛出。Optional 处理“有没有”,没有回答“当前访问者能不能查看这个用户”。
() 在这个 Lambda 中表示“不需要输入参数”。System.out::println 是方法引用,在本例中可按 value -> System.out.println(value) 理解。链式写法就是把上一步返回的对象继续用作下一步接收者,不是所有点号都在调用同一个对象。
第一次遇到很长的链,可以先拆开变量。本篇订单练习会把 Optional 分成“查询结果”“没有则抛异常”“取标题”三步,而不把它们挤成一行。
6. 拿到项目以后,先认路
“项目”通常是许多 Java 文件、依赖库、配置和测试的组合。框架是已经写好的一套通用代码,用来处理对象管理、HTTP 路由等重复工作;业务代码在这套机制之上完成订单、用户等功能。
几个名字容易混在一起:Spring 是一组应用开发基础设施;Spring MVC 处理 Web 请求;Spring Boot 帮助启动应用并提供约定和自动配置;MyBatis 是数据库映射工具;Tomcat 是常见 Servlet 容器。它们不是同一个东西,也不是每个项目都必须同时使用。
6.1 常见目录和构建文件
project/
├── pom.xml Maven 项目与依赖配置
├── src/main/java/
│ └── com/example/
│ ├── Application.java 常见启动类
│ ├── controller/ HTTP 请求处理
│ ├── service/ 业务规则
│ ├── mapper/ 或 repository/ 数据库访问
│ ├── entity/ 持久化对象
│ ├── dto/ 输入或输出结构
│ └── config/ 应用配置
├── src/main/resources/
│ ├── application.yml
│ └── mapper/
└── src/test/java/这只是常见组织方式。有些项目按业务模块分包,也可能使用 Gradle。
Maven 先看 pom.xml 的父 POM、模块、依赖、插件、编译版本和测试配置。一个依赖还可能引入传递依赖。
先看一个依赖片段,它不是完整 pom.xml:
<!-- 告诉 Maven:这个项目需要 Spring Boot 的 Web starter。 -->
<dependency>
<!-- groupId 与 artifactId 标识需要的构件。 -->
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<!-- 版本也可能由父 POM 或依赖管理提供,不一定直接写在这里。 -->
</dependency>import 和依赖不是一回事:前者让源码使用简短类名,后者让构建和运行过程取得这个类所在的库。java.util.List 属于 JDK,Spring 的类通常来自外部依赖。JAR 可以先理解成打包后的 Java 类与资源文件。
在自己负责的可信项目里,可用下面的命令检查构建环境、依赖和测试:
mvn -version
mvn dependency:tree
mvn test构建过程可能下载依赖、执行插件或启动测试服务。来源不明的仓库先检查构建配置,不必为了看代码立刻启动整个项目。
6.2 配置也会改变行为
阅读 application.yml 时,至少留意:应用路径前缀、profile、数据库、认证与安全过滤器、文件位置、上传限制和外部服务地址。
server:
servlet:
context-path: /app # 应用路径前缀,不是源码目录。
spring:
profiles:
active: dev # 选择一组配置环境;具体行为要看对应文件和其他配置源。Controller 映射 /orders 时,实际路径可能包含 /app/orders。环境变量、命令行参数和 profile 还可能覆盖文件里的值。在 Git 里搜到一份配置,不等于生产环境使用它。
6.3 Java 版本与 javax / jakarta
Spring Boot 2 时代常见 javax.servlet.*,Spring Boot 3 使用 jakarta.servlet.*,Java 基线为 17。包名相似不代表能混用,要看项目依赖。
不是所有 javax 包都改名了,后面的 javax.xml.parsers 和 javax.xml.XMLConstants 仍然是 JDK 中的 XML API。
检查环境时一起看 java -version、javac -version 和 mvn -version。出现 UnsupportedClassVersionError 时,先核对编译目标与运行时版本。
7. 注解、反射与代理
7.1 注解不是自动执行的代码
看到 @Something,先理解为给类、方法或字段附加元数据。有没有效果,取决于谁读取它、何时读取、读到后做什么。
| 注解 | 常见处理者 | 常见用途 |
|---|---|---|
@Override | 编译器 | 检查方法重写关系 |
@RestController | Spring | 注册 Web 控制器 |
@Service | Spring | 注册业务 Bean |
@Transactional | Spring 事务机制 | 管理事务边界 |
@NotBlank | Bean Validation 实现 | 声明输入约束 |
@Data | Lombok 注解处理器 | 编译时生成 getter/setter 等常见方法 |
自定义 @CheckPermission 不会凭空获得权限检查。需要找到处理它的切面、拦截器或其他机制,还要确认它已注册并覆盖调用路径。
比如 @GetMapping("/orders") 中的括号保存注解属性,声明这个方法与哪个路径关联;它不是普通的方法调用。@Override 则主要交给编译器检查,不会在运行时帮你判断用户权限。
7.2 反射
反射让程序在运行时查询或调用类的成员。框架可以用它读取注解、寻找处理方法。
可独立运行:AnnotationDemo.java
import java.lang.annotation.ElementType; // 注解可以标记的位置种类。
import java.lang.annotation.Retention; // 指定注解保留到什么阶段。
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target; // 限定注解能够写在什么位置。
import java.lang.reflect.Method; // 用对象表示一个方法,以便运行时查看或调用。
public class AnnotationDemo {
@Retention(RetentionPolicy.RUNTIME) // 保留到运行时,否则不能按本例方式读取。
@Target(ElementType.METHOD) // 只能标在方法上。
@interface Label { // @interface 声明注解类型,不是普通接口声明。
String value(); // 声明一个字符串属性,可以写成 @Label("...")。
}
@Label("问候方法") // 给 hello 附加一份数据,不会让它自己开始执行。
public String hello(String name) {
return "你好," + name; // 普通方法逻辑。
}
public static void main(String[] args) throws ReflectiveOperationException {
// 找到公开的 hello 方法,并说明它接收一个 String 参数。
Method method = AnnotationDemo.class.getMethod("hello", String.class);
Label label = method.getAnnotation(Label.class); // 读取该方法上的注解。
System.out.println(label.value()); // 问候方法。
AnnotationDemo target = new AnnotationDemo(); // 方法需要在哪个对象上执行。
Object result = method.invoke(target, "小林"); // 反射调用 hello,参数为“小林”。
System.out.println(result); // 你好,小林。
}
}本例里,真正“处理注解”的代码是 getAnnotation 及其后对属性的使用。Class 对象描述一个类,Method 对象描述一个方法,invoke 才是实际发起调用。先分清“拿到方法的信息”和“执行方法”是两步。
这里的注解仍只是数据。以后遇到根据外部输入选择类或方法的代码,检查类名和方法名的允许范围、调用权限和初始化行为,不要一见反射就直接下结论。
常见写法:
| 写法 | 意义 |
|---|---|
User.class | 取得 Class 对象 |
obj.getClass() | 取得对象运行时类型 |
Class.forName(name) | 按名字加载类,常见单参数重载还会触发初始化 |
getDeclaredMethods() | 查询该类声明的方法,不代表都能直接访问 |
Method.invoke(...) | 反射调用方法,异常可能被包装 |
7.3 代理
可以把代理理解成:调用者拿到一个对象,调用方法时先经过额外处理,再进入实际业务。
调用者 → 代理对象 → 事务 / 日志 / 方法安全等处理 → 目标业务方法Spring AOP 可以使用 JDK 动态代理或基于类的 CGLIB 代理,具体受类型和配置影响。[2]
审计时要看业务方法本身,也要看包围它的切面、事务和方法安全规则。常见代理模式下,同一个对象里通过 this 自调用,不会像从外部经过代理那样再次触发代理拦截。
这里的 AOP 可以先理解成“在一批方法调用的前后统一加逻辑”。“切点”说明匹配哪些调用,“通知”说明加什么处理。只在方法上看见事务或权限注解,还要确认框架实际上建立了相关代理。第一次阅读到这里,知道这个额外调用层存在就够了,不需要先自己实现代理。
8. HTTP 与 Servlet
前面的小程序由我们自己调用 main。Web 应用不同:它启动后等待请求,浏览器或其他客户端发来请求,框架才选择某个处理方法。后端运行在服务端;浏览器里显示的页面、按钮和输入框属于前端,不能替代后端检查。
“接口”在这里还有一个新含义:HTTP 接口是通过路径和请求方法访问的服务入口;Java 的 interface 是一种语言类型。两者都常被叫接口,阅读时要根据上下文区分。
8.1 一个请求的组成
POST /orders?preview=true HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Cookie: JSESSIONID=example-session
{"productId":1001,"quantity":2}| 部分 | 代码可能怎样读取 |
|---|---|
| 路径 | @RequestMapping、@PathVariable |
| 查询参数 | @RequestParam、request.getParameter() |
| 请求头 | @RequestHeader、request.getHeader() |
| Cookie | Cookie API、Session 机制 |
| 请求体 | @RequestBody、输入流、消息转换器 |
getParameter() 常用于查询参数和表单参数,不会自动把任意 JSON 请求体解析成同名参数。路径、请求头、Cookie、JSON 字段都可能来自客户端。
JSON 可以先看作带字段名的数据格式。{"productId":1001,"quantity":2} 包含两个数字字段,它本身不是一个 Java 对象;框架需要解析它,才能生成供业务代码使用的对象。
Servlet 是一套 Java 服务端规范,容器把网络请求表示为 HttpServletRequest 对象,并提供 HttpServletResponse 用于响应。Spring MVC 的 DispatcherServlet 再寻找匹配的 Controller。这里的“请求对象”是框架根据网络内容创建的,不是浏览器直接上传一个 Java 对象。
先按一个正常请求记住分工:GET /orders/1001 到达容器 → Spring MVC 找到注册路由 → 绑定 id 为 1001 → 调用 Controller → Controller 调用业务方法 → 返回对象 → 转换成响应。这就是框架“调用我们写的方法”,不是 main 自己不断调用每一个接口。
8.2 参数绑定之前和之后是什么
// Servlet 项目片段:省略 import,request 是框架提供的请求对象。
// 假设 URL 查询部分是 ?page=2。
String pageText = request.getParameter("page"); // 得到字符串 "2";不是整数 2。
int page = Integer.parseInt(pageText); // 转换成整数;缺少值或格式不正确需要处理。Spring 可以代做某些绑定和转换。例如把请求值转换成 long 后交给方法参数。但它解决的是如何得到参数,不是这个参数对应的资源是否允许当前用户访问。
服务端处理完还要返回响应:
HTTP/1.1 200 OK
Content-Type: application/json
{"id":1001,"title":"Java 笔记"}响应的状态码、响应头与响应体是不同部分。200 常表示处理成功,400 常用于无效请求,401 常用于缺少有效认证,403 常表示权限不允许,404 常表示资源未找到。项目可能有不暴露资源是否存在等策略,要看约定。返回字符串 "error" 并不会自动变成 4xx;JSON 内容里写了 code: 403 也不一定代表 HTTP 状态码就是 403。
8.3 Session、身份、认证和授权
// 本次请求中的属性,通常由服务端代码设置;它仍可能间接来源于客户端输入。
request.setAttribute("traceId", traceId);
// 获取或创建当前会话,再保存一个非敏感的偏好设置。
// Session 通常保存服务端状态,浏览器通过会话标识关联它;不要把它与请求参数混淆。
request.getSession().setAttribute("theme", "dark");取得 Session 不等于已经登录。Token 或 JWT 只是凭据形式;必须经过验证并形成可信身份,才能用于权限决策。仅解码内容不等于验证成功。
- 认证 Authentication:当前访问者是谁,身份是否可信。
- 授权 Authorization:这个身份能对哪个对象执行什么操作。
“已经登录”不等于“能读取所有订单”。前端隐藏按钮、字段是 private、参数不为空、ID 能转数字,都不能代替服务端权限检查。
用两个固定测试用户理解:用户 7 的身份已经确认,这只说明“当前是用户 7”;还需要根据订单的 ownerId 判断“用户 7 是否能看这份订单”。不要让客户端提交一个 ownerId,就把它直接当成当前身份。
9. Spring:对象如何连接,请求如何进入业务
9.1 IoC、DI 和 Bean
不用框架时可能手动创建:
// 项目片段:dataSource 是数据库连接来源,JdbcOrderRepository 是一个实现类。
OrderRepository repository = new JdbcOrderRepository(dataSource); // 先创建数据访问对象。
OrderService service = new OrderService(repository); // 再把它作为参数交给业务对象。Spring 可以按配置创建对象并装配关系:
- Bean:由 Spring 容器管理的对象。
- IoC:对象创建和装配交给容器管理。
- DI:容器把依赖交给对象。
这里的“依赖”指协作者:OrderService 需要 OrderRepository 才能查订单,不是说 OrderService 的参数都是客户端请求。第 13 章的完整练习会先手动组合它们,让你看到即使没有 Spring,对象也能这样协作。
@Service // 符合组件扫描与注册条件时,Spring 创建并管理这个对象。
public class OrderService {
private final OrderRepository repository; // 依赖引用,不是一个客户端可直接填写的字段。
public OrderService(OrderRepository repository) { // 构造器注入
this.repository = repository; // 保存容器提供的实现,供后面业务方法使用。
}
}默认 singleton Bean 是每个容器内每个 Bean 定义通常一份实例,不是整个 JVM 所有地方只有一个,也不保证线程安全。
常见疑问是:“代码里没看到 new OrderService,谁创建了它?”答案是容器按配置创建。还要看启动类的位置、组件扫描、@Bean 方法和条件配置;加了注解但不在扫描范围内,可能根本没注册。多个实现并存时,继续查看 @Qualifier、@Primary 或显式配置,不能随便选一个实现阅读。
@Component 是常见组件标记;@Service、@Repository 表达更具体的分层语义;@Configuration 常用于声明配置;@Bean 标记的方法可以向容器提供对象。初学时先认识它们,之后再按实际项目查注册关系。
9.2 Controller 参数绑定
// 项目片段:需要 Spring Web 依赖、组件扫描和对应的 OrderService / OrderView。
@RestController // 注册控制器,并让方法结果按响应体处理。
@RequestMapping("/orders") // 类上提供公共路径前缀。
public class OrderController {
private final OrderService service; // Controller 把业务工作交给 Service。
public OrderController(OrderService service) { // 容器注入对象,不是从 HTTP 取得这个参数。
this.service = service; // 保存到当前控制器字段。
}
@GetMapping("/{id}") // 与类路径组合,形成 GET /orders/{id}。
public OrderView getOrder(@PathVariable("id") long id) { // /orders/1001 会绑定为 id = 1001。
OrderView view = service.getOrder(id); // 进入业务层;身份与权限规则需要继续查。
return view; // 返回对象,框架通常再将它转换为 JSON 等响应内容。
}
}这个片段只演示路由和参数绑定,不能据此认定接口安全或不安全。
同样是方法参数,构造方法的 OrderService service 来自容器,而 getOrder 的 long id 来自请求绑定。不要把“所有参数”都理解成用户输入。来源要逐项确认。
@RestController 与普通 @Controller 也有区别:前者具有响应体语义;后者的 String 返回值可能被解释为视图名。若返回对象,一般由消息转换器选择如何输出。对象变成 JSON 的过程,也属于序列化。
| 注解 | 常见含义 |
|---|---|
@RequestParam | 查询或表单参数 |
@PathVariable | 路径变量 |
@RequestBody | 请求体转 DTO,例如 JSON |
@RequestHeader | 请求头 |
@ModelAttribute | 模型属性和参数绑定 |
@ResponseBody | 返回值写入响应体 |
9.3 DTO、Entity、VO 和校验
先看职责,不用急着背缩写:Entity 常表达数据库记录,输入 DTO 表达这次操作允许接收的字段,输出 DTO/VO 表达返回给前端的字段。不同项目的命名习惯不同,不是类名里有 DTO 就一定只用作输入。
输入 DTO 最好只包含本次操作允许提交的字段,而不是直接接收包含 role、ownerId 等服务端字段的完整 Entity。
// 项目片段:需要 Jakarta Bean Validation 的注解及实现,省略 import。
public class UpdateProfileRequest {
@NotBlank // 声明:值不能为 null,且需要包含非空白内容。
@Size(max = 40) // 声明长度上限;不是自动限制整个 HTTP 请求的大小。
private String displayName; // 这里只接收展示名,不接收 role 等服务端管理的属性。
public String getDisplayName() { // 输出当前字段值。
return displayName;
}
public void setDisplayName(String displayName) { // 数据绑定可经由 setter 写入。
this.displayName = displayName; // 这里仅赋值,约束由验证机制处理。
}
}@Valid 参与触发校验,还需要正确依赖和框架处理。格式校验解决“值是否符合约束”,不是“这个人是否有权限”。字段为 private 也不代表不能被 JSON 绑定,setter、构造器、反射和库配置都可能参与。
// Controller 方法片段:不是完整类,省略导入与字段定义。
@PostMapping("/profile") // 接收 POST 请求,路径还可能与类级映射组合。
public ProfileView update(@Valid @RequestBody UpdateProfileRequest body) {
// RequestBody 把 JSON 转成输入对象,Valid 参与触发它声明的约束。
String displayName = body.getDisplayName(); // 取出绑定后的字段值。
return profileService.updateCurrentUser(displayName); // 身份及权限仍要在实际业务中确认。
}“有校验注解”不代表所有调用都会触发验证,例如手动 new 一个 DTO 再调用普通 Java 方法,不会因为字段有注解就自动检查一切。审计时需要找到触发机制和异常处理。
10. Filter、Interceptor、AOP 与事务
10.1 一次典型请求的大致路线
HTTP 请求
↓
Servlet Filter 链
↓
DispatcherServlet
↓
HandlerInterceptor
↓
Controller → Service → Repository / Mapper → 数据库
↓
响应处理这只是建立方位感。异常处理、异步请求、转发和 AOP 会改变具体过程。
| 机制 | 阅读重点 |
|---|---|
| Servlet Filter | 注册方式、顺序、路径和是否继续调用链 |
| HandlerInterceptor | preHandle 等回调覆盖了哪些路径 |
| Spring AOP | 切点、代理方式、方法是否匹配、调用是否经过代理 |
| Spring Security | 过滤链、身份来源、匹配规则、资源权限策略 |
不要仅凭“有一个登录拦截器”就认为安全控制完整。
Filter 可以先理解为处理链中的一站。chain.doFilter(request, response) 表示交给下一站;Interceptor 的 preHandle 返回值则按 Spring MVC 规则控制后续处理。这些机制和第 5 章对 List 的 stream().filter(...) 不是同一种接口。
它们的注册位置、匹配路径和顺序也是代码的一部分。Controller 本身没有权限注解,不表示上游一定没检查;反过来,有安全组件也不表示每个入口都被覆盖。结论必须结合配置和实际调用。
10.2 事务
事务组织数据库操作的提交和回滚。它不自动验证金额、检查权限,也不让外部 HTTP 或文件写入跟数据库一起回滚。
例如创建订单需要写“订单主表”和“订单明细”。如果第一步成功、第二步失败,而程序仍留下半份订单,数据就不完整。事务希望按规则把相关操作作为一个整体提交,失败时撤销相应未提交操作。它不是“发生异常就能撤销程序做过的一切”。
// Service 方法片段:需要事务管理器、适当数据源及有效调用边界。
@Transactional(rollbackFor = Exception.class) // 显式声明 Exception 及其子类的回滚规则。
public void createOrder(CreateOrderCommand command) throws Exception {
orderRepository.insert(command); // 第一步:插入订单主记录。
itemRepository.insertItems(command.getItems()); // 第二步:插入明细;失败可能抛异常。
}Spring 默认常规声明式事务规则对 RuntimeException 和 Error 回滚;受检异常默认不因此回滚,项目可以改变规则。[3]
审计时确认:
- 方法是否真的处于事务中。
- 调用是否经过代理,而不是只看注解。
- 异常是否传播到事务边界,或被捕获后继续执行。
- 是否配置
rollbackFor、noRollbackFor和传播行为。 - 内层调用是否标记了 rollback-only。
“catch 了异常就一定提交”也不准确,必须看实际事务状态。
rollback-only 可以先理解成“事务已经被标记为只能回滚”。即使外层捕获了异常、最后正常 return,也不保证这个标记被清除。这里先认识判断时不能省略的条件,传播级别和隔离级别可留到事务专题。
10.3 并发与 ThreadLocal
- 共享 Bean 的成员字段可能被多个请求共同访问。
ThreadLocal是线程关联的数据,不天然等于请求关联的数据。- 线程池会复用线程,自己设置上下文时通常要在
finally中remove()。 synchronized不解决多个应用实例之间的竞争。
11. 数据库访问:先看懂正常查询
11.1 SQL 最小阅读能力
数据库保存数据;关系型数据库通常用表组织记录。先想象 orders 是下面这张表,列是字段,行是记录:
| id(订单编号) | owner_id(所属用户编号) | title(标题) |
|---|---|---|
| 1001 | 7 | Java 笔记 |
| 1002 | 8 | 学习资料 |
应用不是把整个数据库都搬到 Java 里,而是通过 SQL 描述这次要读写哪些数据。下面是供数据库访问代码使用的参数化查询模板,不能把问号原样当成已经填好值的普通 SQL 去执行。
SELECT id, title
FROM orders
WHERE id = ? AND owner_id = ?这句话表示:从 orders 表取 id 和 title,只取同时符合订单编号和所属用户编号的记录。
如果两个参数分别绑定为 1001、7,就能得到上表第一行对应的结果;如果是 1001、8,没有记录同时满足这两个条件,所以查不到结果。AND 表示同时满足,不是依次执行两次独立查询。
| SQL 部分 | 在这句话里做什么 |
|---|---|
SELECT id, title | 决定返回哪几列 |
FROM orders | 决定查哪张表 |
WHERE ... | 决定保留哪些记录 |
id = ? | 第一个条件:订单编号匹配绑定值 |
AND owner_id = ? | 同时要求所属用户编号匹配第二个绑定值 |
Java 中的 == 是比较运算符,SQL 条件里的 = 则是相等比较,不能把两种语言的符号规则直接照搬。SQL 最终由数据库理解,Java 字符串只是应用传递它的一种载体。
注意区分:表名、列名是 SQL 结构,查询值是数据。这关系到哪些位置可以参数绑定。
11.2 JDBC
JDBC 是 Java 访问关系型数据库的标准 API。数据库驱动负责与具体数据库通信。读代码时先认清下面四种角色:
| 名字 | 先理解成什么 |
|---|---|
DataSource | 提供连接的组件,项目中经常与连接池配合 |
Connection | 与数据库进行操作的连接上下文 |
PreparedStatement | 带固定语句结构与绑定参数的数据库语句对象 |
ResultSet | 查询返回的结果集,需要移动游标再读取一行 |
下面是方法内部片段,假设连接由调用者管理、存在相应表和 OrderView 类,不能作为单文件直接运行。这里的 try 关闭语句和结果集,不关闭由外部管理的 connection;连接在哪里归还或关闭也需要确认。
String sql = "SELECT id, title FROM orders WHERE id = ? AND owner_id = ?"; // SQL 结构固定。
try (PreparedStatement statement = connection.prepareStatement(sql)) { // 先交给驱动创建语句对象。
statement.setLong(1, orderId); // 参数位置从 1 开始,第一个 ? 绑定订单编号。
statement.setLong(2, actorId); // 第二个 ? 绑定服务端可信身份中的用户编号。
try (ResultSet result = statement.executeQuery()) { // 执行 SELECT,拿到结果集。
if (result.next()) { // 第一次 next 才移动到第一行;没有记录时为 false。
long id = result.getLong("id"); // 取当前行 id 列,并转换为 long。
String title = result.getString("title"); // 取当前行 title 列。
return new OrderView(id, title); // 创建输出对象;然后离开当前方法并关闭资源。
}
return null; // 本例用 null 表示没有记录,调用者必须处理这个分支。
}
}? 是数据参数的占位符,不是任意 SQL 语法的占位符。动态表名、列名和排序方向不能简单地全部改成 ?,应从固定允许集合中选择。
还要检查 sql 是怎样构建的。先把不可信内容拼进 SQL,再调用 prepareStatement,不会自动把已经拼入的内容变成绑定参数。
此处参数绑定的要点是“语句结构和参数分别交给数据库驱动”,不是在 Java 中把问号手工替换成字符串。参数绑定处理的是数据使用方式;owner_id 条件处理的是本例资源范围,这两种防护不能互相替代。
11.3 MyBatis
MyBatis 帮你处理不少 JDBC 的重复工作:拿参数、执行语句、把结果转成 Java 对象。代价是逻辑分散在接口、XML、注解和配置中,所以不能只在 Java 文件里找方法体。
// 项目片段:需要正确的 Mapper 注册或扫描,省略 @Param 的 import。
public interface OrderMapper {
OrderRow findOwned(
@Param("id") long id, // 给这个方法参数起一个映射名,XML 中用 #{id} 引用。
@Param("ownerId") long ownerId // 与第二个绑定名对应。
); // 分号结束,没有方法体;MyBatis 的代理按映射执行。
}对应 XML 可能是:
<mapper namespace="com.example.order.OrderMapper">
<!-- namespace 需要与 Mapper 接口的完整名对应;id 对应方法名。 -->
<select id="findOwned" resultType="com.example.order.OrderRow">
SELECT id, owner_id AS ownerId, title
FROM orders
WHERE id = #{id} AND owner_id = #{ownerId}
</select>
</mapper>namespace 对应接口完整名,select 的 id 对应方法名,@Param 对应 SQL 参数名。MyBatis 中 #{...} 通常生成预处理参数;${...} 是文本替换,应检查来源和允许范围。[4]
resultType 指结果映射到哪种 Java 类型;AS ownerId 是给数据库列起别名,帮助它与 Java 属性命名对应。真实项目也可能使用 resultMap 或下划线转驼峰等配置。XML 的 <select> 是映射定义,里面的 SELECT 是 SQL,两种语法不要混为一层。
初学时可以用下面的对应关系定位代码:
Service 调用 orderMapper.findOwned(1001L, 7L)
↓
Mapper 形参 id = 1001,ownerId = 7
↓
XML 找到 namespace 对应接口、id="findOwned" 的定义
↓
#{id} 和 #{ownerId} 成为绑定值,而不是让你自己拼接 SQL
↓
数据库记录按配置映射成 OrderRow,交回 Service| 误区 | 更可靠的判断 |
|---|---|
| 用了 MyBatis 就不会有 SQL 问题 | 仍要看最终 SQL 如何构造 |
出现 ${} 就已证明漏洞 | 还要看是否可控、是否有允许列表、是否执行到 |
出现 #{} 就整个方法安全 | 它不解决权限、其他拼接和业务逻辑 |
| 接口没有方法体,所以逻辑丢了 | 可能由 XML、注解或 Provider 执行 |
还要留意 <include>、<if>、<foreach>、@Select 和 SQL Provider。
11.4 JPA / Hibernate
常见线索包括 @Entity、JpaRepository、EntityManager。需要区分方法名生成查询、JPQL/HQL、原生 SQL 和动态查询。ORM 不等于所有查询都自动安全。
12. 文件、编码与 XML
12.1 字节与字符
InputStream、OutputStream处理字节。Reader、Writer处理字符。- 转换时要明确字符编码,例如 UTF-8。
// 方法内部片段:假设 text 是一个非 null 的 String,并已导入 StandardCharsets。
byte[] bytes = text.getBytes(StandardCharsets.UTF_8); // 字符串编码为 UTF-8 字节。
String decoded = new String(bytes, StandardCharsets.UTF_8); // 按 UTF-8 把字节解码成字符串。Base64 是编码,不是加密。URL 解码、JSON 解析、字符串替换和 HTML 输出编码也不是同一个动作。审计时记清“检查发生在转换前还是转换后”。
比如字符串中的一个中文字符,在 UTF-8 中可能需要多个字节保存,所以“字符长度”和“字节长度”不一定相同。文件大小限制、文本长度限制不能只凭变量名混用。
12.2 Path 和 Files
// 项目片段:示意服务器的目录,不要求你在自己的电脑上创建这个位置。
Path base = Path.of("/srv/app/data"); // 只生成路径对象,不创建目录。
Path file = base.resolve("demo.txt"); // 得到目录中的 demo.txt 路径。
String text = Files.readString(file, StandardCharsets.UTF_8); // 这一步才真正读取文件。相对路径相对于程序当前工作目录,不保证是项目根目录。
| API | 做什么 | 边界 |
|---|---|---|
Path.of | 创建路径对象 | 不代表文件存在 |
resolve | 组合路径 | 不是权限检查 |
normalize | 处理部分冗余路径 | 不处理真实符号链接,也不解决竞争条件 |
toRealPath | 解析真实路径 | 需要访问文件系统,不能单独解决检查与使用之间的竞争 |
readAllLines | 读取全部行到列表 | 大文件会占用内存,不是流式读取 |
deleteIfExists | 存在时尝试删除 | 权限和 I/O 仍可能失败 |
客户端提交的文件名不应直接决定服务端任意读写路径。后续学习目录约束、链接和竞争条件时,会用到这里的基础。
先用一个只操作自己临时文件的小程序练习读写,避免拿真实配置文件做实验。
可独立运行:FileBasicsDemo.java,需要 Java 11 或以上版本。
import java.io.IOException; // 文件操作可能抛出的受检异常。
import java.nio.charset.StandardCharsets; // 明确 UTF-8 编码。
import java.nio.file.Files; // 提供读写、创建、删除等文件操作。
import java.nio.file.Path; // 表示路径的类型。
public class FileBasicsDemo {
public static void main(String[] args) throws IOException {
// 创建本例专用临时目录,实际位置由运行时临时目录配置决定。
Path directory = Files.createTempDirectory("java-reading-");
Path file = directory.resolve("demo.txt"); // 此时 demo.txt 还没有创建。
try {
Files.writeString(file, "你好,Java", StandardCharsets.UTF_8); // 创建并写入固定文本。
String text = Files.readString(file, StandardCharsets.UTF_8); // 读取小文件全部内容。
System.out.println(text); // 你好,Java。
System.out.println(Files.exists(file)); // true,说明文件当前存在。
} finally {
try {
Files.deleteIfExists(file); // 仅尝试删除本例的文件,不递归删除其他内容。
} finally {
Files.deleteIfExists(directory); // 再尝试清理本例目录,I/O 问题仍可能抛异常。
}
}
}
}12.3 XML 与 XXE 基础
<order>
<id>1001</id>
<title>Java 入门笔记</title>
</order>DOM 通常构造可遍历的树,SAX 是事件驱动,StAX 是拉取式处理。DTD 可以描述结构并声明实体;实体可能涉及替换内容或外部资源。
先把 XML 想成一棵层级结构:order 是根元素,它下面有 id 和 title 子元素。解析器把文本转换成程序能操作的结构,业务再从里面取值。DTD、实体、外部资源是解析器额外支持的功能,不是普通订单数据本身必需的内容。
工厂 factory 负责按配置创建解析器 builder,解析器再解析输入得到文档 document。配置工厂 → 创建解析器 → 解析输入的顺序很重要;配置一个没被实际使用的工厂,并不会保护另外一个解析器。
XXE 的核心认识:解析 XML 不一定只是拆标签。当解析器允许相应特性时,可能尝试解析外部实体。需要检查实际解析器、配置和输入来源,不能只见到 parse() 就下结论。[5]
下面只演示防御性解析:业务不需要 DTD 时拒绝它,并限制外部访问。
可独立运行:SafeXmlDemo.java。先理解步骤,不要求背这些配置项的 URI。
import java.io.StringReader; // 把内存字符串作为字符输入读取。
import javax.xml.XMLConstants; // JDK 中 XML 安全配置的常量。
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory; // DOM 解析器工厂。
import org.w3c.dom.Document; // 表示解析后的 XML 文档树。
import org.xml.sax.InputSource; // 包装输入来源。
public class SafeXmlDemo {
public static Document parse(String xml) throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); // 第一步:创建工厂。
// 第二步:在创建解析器之前配置安全限制。
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); // 启用安全处理限制。
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); // 本例拒绝 DOCTYPE。
factory.setFeature("http://xml.org/sax/features/external-general-entities", false); // 不解析外部通用实体。
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); // 不解析外部参数实体。
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); // 不加载外部 DTD。
factory.setXIncludeAware(false); // 不启用 XInclude 包含其他资源的功能。
factory.setExpandEntityReferences(false); // 这项不能独自替代全部防护。
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); // 空字符串表示不允许外部 DTD 访问协议。
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); // 限制外部 schema 访问。
DocumentBuilder builder = factory.newDocumentBuilder(); // 第三步:创建采用这些设置的解析器。
StringReader reader = new StringReader(xml); // XML 来自本次传入的字符串。
InputSource input = new InputSource(reader); // 包装为解析器能够接受的输入形式。
return builder.parse(input); // 第四步:解析成文档;失败则向调用者抛出异常。
}
public static void main(String[] args) throws Exception {
String xml = "<order><id>1001</id></order>"; // 固定的本地练习数据,不访问网络或其他文件。
Document document = parse(xml); // 调用上面的安全解析方法。
System.out.println(document.getDocumentElement().getTagName()); // 根标签名:order。
}
}有的 parse 重载接收一个 String,却把它理解成 URI 而不是 XML 内容,所以本例显式使用 StringReader 和 InputSource 表达“从字符串内容读取”。具体含义应查对应方法签名,不能只看方法名。
某些设置依赖解析器实现。如果设置不被支持,应视作配置失败,不能捕获异常后继续用部分加固的解析器处理不可信 XML。[5] 不同 XML API 要分别检查,限制外部资源也不能替代大小、深度和处理时限等资源约束。
12.4 序列化和反序列化
- 序列化:把对象转换成适合存储或传输的形式。
- 反序列化:把数据转换回程序使用的结构或对象。
- JSON 映射与 Java 原生对象序列化是不同机制。
ObjectInputStream.readObject()是 Java 原生反序列化的常见 API,不要把不可信对象流直接交给它。
串回前面的 Web 例子:请求里的 JSON 文本转换成 UpdateProfileRequest,是一种反序列化/数据绑定;返回的 OrderView 转换成响应 JSON,是序列化。它们不等于调用 Java 原生 readObject,审计时要先确认实际使用哪一种机制和库。
13. 串起来读:从请求到订单结果
规则只有一个:用户可以查看属于自己的订单。
orderId:客户端提出想看哪份订单。actorId:服务端完成身份认证后知道是谁在看。
即使两个值都是 long,可信程度和业务含义也不同。
这一章沿用第 11 章的 Mapper 参数名。下面三段是同一阅读示例的不同层,省略了完整类、import 和配置,不是直接复制运行的 Spring 项目。currentUser 是这里假设的身份提供组件,不是 JDK 或 Spring 的固定 API;必须在真实项目中查它的实现。随后的小程序不依赖这些框架组件,可以直接运行。
// Controller 方法片段:接收资源编号,并从服务端取得身份。
@GetMapping("/orders/{id}") // 假设没有额外的类级路径前缀。
public OrderView get(@PathVariable("id") long id) { // 客户端只能表达“想看哪份订单”。
long actorId = currentUser.requireUserId(); // 需要确认来源是已验证身份,不是任意请求头或参数。
OrderView view = orderService.getOwnedOrder(id, actorId); // 两类编号分别传入,不混用。
return view; // 方法返回后,框架再处理响应转换。
}// Service 方法片段:表达本例的资源归属规则。
public OrderView getOwnedOrder(long orderId, long actorId) {
OrderRow row = orderMapper.findOwned(orderId, actorId); // 使用第 11 章的 @Param 映射。
if (row == null) { // 没查到,可能是订单不存在,也可能是不属于该用户。
throw new OrderNotFoundException(); // 示例自定义异常;由项目的错误处理规则映射响应。
}
return new OrderView(row.getId(), row.getTitle()); // 选取可对外返回字段,不直接返回全部数据库记录。
}<!-- Mapper 内部片段:与前面 OrderMapper.findOwned 方法相对应。 -->
<select id="findOwned" resultType="com.example.order.OrderRow">
SELECT id, title
FROM orders
WHERE id = #{id} AND owner_id = #{ownerId}
</select>追踪时记录:
| 值 | 来源 | 去向 | 要确认什么 |
|---|---|---|---|
orderId | URL 路径 | Service → Mapper | 转换、范围、参数绑定 |
actorId | 已验证身份 | Service → Mapper | 是否不是客户端任意字段 |
| 资源条件 | Mapper WHERE | 数据库 | 是否同时限制资源和所属用户 |
| 查不到记录 | Mapper 返回空 | Service 抛异常 | 是否终止并正确响应 |
| 返回对象 | 数据库记录 | 响应体 | 是否只返回应公开字段 |
变量换名不代表来源改变;值从数据库读出,也不代表一定可信。
按一次具体调用走完
假设服务端已经把当前身份确认为用户 7,并且订单 1001 属于用户 7:
| 顺序 | 代码位置 | 当前值与动作 |
|---|---|---|
| 1 | Controller 的 id | 路径中取得 1001,绑定成 long |
| 2 | Controller 的 actorId | 从已验证身份取得 7 |
| 3 | Service 形参 | orderId 为 1001,actorId 为 7;名字改变,含义没有改变 |
| 4 | Mapper 形参 | id 为 1001,ownerId 为 7,分别绑定到 SQL |
| 5 | SQL WHERE | 同时要求 id=1001 与 owner_id=7 |
| 6 | Service 的 row | 收到匹配记录,取需要的字段构造 OrderView |
| 7 | Controller 返回 | 框架根据配置将对象变成响应体 |
如果同样的资源编号配上已验证用户 8,查询就没有匹配记录,Service 不走构造成功结果的路径。这里采用“不存在或不可访问”的统一处理思路;具体状态码和错误结构需要在项目统一异常处理器中确认。自定义异常本身不会自动保证正确 HTTP 状态码。
13.1 不依赖 Spring 的小练习
下面用内存数据代替数据库,身份由固定本地测试值模拟,不是登录系统。
可独立运行:OrderReadingDemo.java。它只在内存中操作固定教学数据,没有 HTTP 接口。authenticatedUserId 在 main 里直接给定,只是模拟可信身份;如果放到真实 Web 入口,不能让客户端直接任意指定它。
import java.util.HashMap; // 内存映射实现,代替真实数据库。
import java.util.Map; // 根据订单编号查找订单。
import java.util.Optional; // 表示查询可能找不到可返回的订单。
public class OrderReadingDemo {
// 这里把相关类放进同一个外层类,只为让你保存一个文件就能运行。
static class Order { // 数据对象:描述一份订单。
final long id; // 订单编号。
final long ownerId; // 所属用户编号,不等于订单编号。
final String title; // 标题,本例只返回这个内容。
Order(long id, long ownerId, String title) {
this.id = id; // 用构造参数初始化字段。
this.ownerId = ownerId;
this.title = title;
}
}
interface OrderRepository { // 约定查询能力,还没有决定如何存储数据。
Optional<Order> findOwned(long id, long ownerId); // 只返回属于该用户的订单。
}
static class MemoryOrderRepository implements OrderRepository { // 内存版实现。
private final Map<Long, Order> orders = new HashMap<>(); // 键为订单 ID,值为订单对象。
MemoryOrderRepository() {
orders.put(1001L, new Order(1001L, 7L, "Java 笔记")); // 固定教学数据:订单 1001 属于用户 7。
}
@Override
public Optional<Order> findOwned(long id, long ownerId) {
Order order = orders.get(id); // 按资源编号取记录,不存在时得到 null。
if (order == null || order.ownerId != ownerId) { // || 短路,null 时不会再读取 ownerId。
return Optional.empty(); // 没记录或不属于此用户,都不返回这个对象。
}
return Optional.of(order); // 有记录且归属匹配,用 Optional 包装它。
}
}
static class OrderService {
private final OrderRepository repository; // 依赖接口,不把查询实现写死在业务方法里。
OrderService(OrderRepository repository) { // 手动注入一个实现对象。
this.repository = repository;
}
String readTitle(long orderId, long authenticatedUserId) {
if (orderId <= 0 || authenticatedUserId <= 0) { // 本例约定编号必须为正数。
throw new IllegalArgumentException("编号必须为正数");
}
// 特意拆开链式表达式,便于分别观察查询结果、异常和返回数据。
Optional<Order> result = repository.findOwned(orderId, authenticatedUserId);
Order order = result.orElseThrow( // 有对象则取出;没有则执行下面 Lambda 创建异常。
() -> new IllegalArgumentException("订单不存在或不可访问")
);
return order.title; // 只有上面成功取得对象,才执行这一行。
}
}
static void expectRejected(OrderService service, long orderId, long userId) { // 本地测试辅助方法。
try {
service.readTitle(orderId, userId); // 这些用例预期不能成功读取。
throw new AssertionError("预期拒绝,却返回了结果"); // 没抛业务异常就让测试失败。
} catch (IllegalArgumentException expected) { // 仅接住预期的拒绝,不吞掉上面的 AssertionError。
System.out.println("按预期拒绝"); // 说明确实走到失败路径。
}
}
public static void main(String[] args) {
OrderRepository repository = new MemoryOrderRepository(); // 先创建查询组件并初始化数据。
OrderService service = new OrderService(repository); // 再创建业务组件,交给它查询依赖。
String title = service.readTitle(1001L, 7L); // 所属用户查询自己的订单,应当成功。
if (!"Java 笔记".equals(title)) { // 不只检查“没报错”,还核对返回内容。
throw new AssertionError("订单标题不符合预期");
}
System.out.println(title); // Java 笔记。
expectRejected(service, 1001L, 8L); // 存在的订单,但不是这个用户的。
expectRejected(service, 9999L, 7L); // 编号合法,但订单不存在。
expectRejected(service, -1L, 7L); // 编号本身不合法。
}
}不翻前文回答:接口在哪里、实现在哪里、参数在哪一层被检查、查不到订单为什么不会继续返回标题?
正常输出如下;任一用例不符合预期,程序会抛 AssertionError,而不是只打印“成功”糊弄过去:
Java 笔记
按预期拒绝
按预期拒绝
按预期拒绝这个练习让你先掌握对象协作与资源归属规则。它不验证真实登录、Spring 配置、SQL 驱动或 HTTP 状态码,也不能替代它们的集成测试。读完再回到前面的 Spring/Mapper 片段,就更容易区分“业务规则相同”和“具体框架实现不同”。
14. 开始读真实项目时,按这个顺序
- 看 README、构建配置和模块结构,确认技术栈与运行前提。
- 找入口路由,记下完整路径、HTTP 方法与输入位置。
- 找身份来源和适用安全规则,确认配置是否覆盖入口。
- 进入 Service,读分支、异常、事务和业务条件。
- 跟到 Mapper、数据库、文件或解析 API。
- 倒着核对重要参数从哪里来,中间如何转换。
- 看响应对象和异常处理,确认实际返回什么。
- 用本地测试与调试验证理解。
14.1 source、sink 和数据流
- Source:数据进入分析范围的位置,例如 HTTP 参数、消息队列或上传文件。
- Sink:需要重点确认使用方式的操作,例如执行查询、访问文件或解析 XML。
- 数据流:值经过参数、返回值、字段、集合和转换后如何到达使用位置。
- 控制流:哪些分支决定操作是否执行。
- 信任边界:数据从不可信来源进入需要依赖它做决策的区域。
不是每个 source 到 sink 的路径都有漏洞。要判断具体使用方式和有效约束。编码、转换、校验、参数绑定、权限判断分别解决不同问题。
14.2 IDE 中真正有用的操作
| 功能 | 用来回答什么问题 |
|---|---|
| 跳转到声明 | 类、字段或方法在哪里定义? |
| 跳转到实现 | 接口方法有哪些实现? |
| 查找用法 | 谁调用它?是否还有另一条入口? |
| 调用层次 | 上游和下游大致是什么? |
| 全局搜索 | 路由、SQL、配置和注解处理器在哪里? |
| 断点与单步 | 运行时到底走了哪个分支? |
| 调用栈 | 是谁一路调用到这里?有没有框架和代理? |
| 变量观察 | 当前值和运行时类型是什么? |
调试器中主动求值方法可能产生副作用,优先观察简单变量,不随意执行写入、发送或删除方法。
14.3 记录结论时分清证据
| 写法 | 是否合适 |
|---|---|
“搜索发现 parse(),存在 XXE。” | 不充分,还要确认解析器、配置和输入来源 |
| “方法没有权限注解,所以没有鉴权。” | 不充分,规则可能在过滤器、Service 或数据层 |
| “看到 SQL 字符串拼接,记录为待确认线索。” | 合适,接下来确认来源和构造方式 |
| “本地测试中非所属身份被拒绝,断点确认走了所属用户条件。” | 合适,说明了验证范围 |
| “本地样例通过,所以整个项目安全。” | 不成立,超出测试覆盖范围 |
记录模板:
功能与预期规则:
项目版本 / 分支 / 运行配置:
入口文件、方法与行号:
输入字段及来源:
可信身份如何取得:
调用链:
关键操作及参数:
已有约束及其生效依据:
正常 / 缺失 / 非法 / 不允许操作的测试结果:
已确认的事实:
尚待确认的条件:15. 自测与学习验收
15.1 概念题
String是基本类型吗?String[]呢?- 两个字符串比较内容,为什么不应依赖
==? final List<String>能不能添加元素?- 方法没有返回值,为什么传入的 Map 内容仍可能变化?
public方法是不是所有人都能通过浏览器调用?- 接口里没有方法体,调用时执行的逻辑可能在哪里?
- 自定义
@CheckPermission会自动获得权限控制吗? @RequestParam与@RequestBody常用于什么数据?- 参数能转成
long,是否证明当前用户有权查看该编号? PreparedStatement为什么还要检查 SQL 的构建过程?- 捕获异常后,事务一定提交吗?
normalize()为什么不能独立完成文件访问安全限制?- 出现 XML
parse(),为什么还不足以判断 XXE? - 一个订单查询本地通过,能证明整个项目权限正确吗?
参考答案:
- 都是引用类型;Java 基本类型表里没有 String,也没有数组。
==对引用比较对象身份,内容比较用equals等方法。- 如果列表支持修改,可以添加;final 只限制引用重新赋值。
- 方法接收引用值的副本,仍可修改共同引用的对象。
- 不是。Java 访问规则与 HTTP 路由、认证、授权是不同层面。
- 具体实现类、框架代理、Mapper XML、注解或 Provider 等。
- 不会。需要处理它的机制,而且要注册、生效并覆盖调用。
- 前者通常接查询或表单参数,后者读取请求体,例如 JSON。
- 不证明,只说明类型转换成功。
- 如果不可信内容已经拼进 SQL,API 名字不会撤销这种拼接。
- 不一定,要看异常传播、回滚规则、事务状态和代理。
- 它只做路径规范化,不解决符号链接、权限和竞争条件。
- 要确认实际解析器、配置、输入来源和功能是否启用。
- 不能,只能支持已覆盖的配置、入口和用例。
15.2 动手题
建议先按下面的学习顺序完成,不要只复制代码看一眼输出:
| 练习阶段 | 推荐程序 | 你要说得出来的事 |
|---|---|---|
| 先学执行顺序 | HelloWorld、MethodDemo、ArrayDemo、LoopDemo | 从哪开始,哪一行先执行,变量怎样变化 |
| 再学对象 | StateDemo、ReferenceDemo、ObjectDemo | 两个变量是不是同一对象,改字段与改引用有什么区别 |
| 读实现关系 | InheritanceDemo、PolymorphismDemo、CollectionsDemo | 接口或父类只是类型,实际执行哪个实现 |
| 读框架原理与 I/O | AnnotationDemo、FileBasicsDemo、SafeXmlDemo | 注解谁处理,资源何时访问,配置作用于哪个对象 |
| 最后串业务 | ExceptionDemo、OrderReadingDemo | 正常、非法和不允许的路径分别在哪里终止 |
每个“可独立运行”示例保存成注释上方指定的文件名,使用自己的 JDK 编译运行。多个示例互相独立,不需要复制到一个巨大的 main 里。框架片段则用于学习阅读,缺少依赖和配套类时不能单文件运行。
练习 A:运行与解释
运行 StringBasicsDemo、ReferenceDemo、PolymorphismDemo。先预测输出,再解释不一致的地方。
练习 B:阅读业务链
在 OrderReadingDemo 中增加一条属于同一用户的订单,补上读取断言。不要改所属用户检查。画出 main → Service → Repository → Map 路线。
练习 C:处理失败输入
给 ExceptionDemo 增加 "0" 和超出 int 范围的输入,解释分别在哪一步失败,是否进入 catch,以及 finally 是否执行。
练习 D:检查 XML 配置
保持 SafeXmlDemo 的安全配置,验证普通 XML 能解析;再给固定文本添加不包含外部资源的 DOCTYPE,确认解析被拒绝。只检查防御配置,不访问网络或其他文件。
15.3 进入漏洞专题前的验收标准
- [ ] 能解释方法的输入、输出与可能抛出的异常。
- [ ] 能区分对象、引用、接口和实现类。
- [ ] 知道注解需要被什么机制处理才生效。
- [ ] 能找到 HTTP 路由,并说明参数来自哪里。
- [ ] 能从 Controller 跟到 Service 和实际数据操作。
- [ ] 能说明当前身份从哪里来,资源权限在哪里判断。
- [ ] 能区分 SQL 参数绑定与文本替换。
- [ ] 能解释 Path、字符编码和 XML 解析器的基本作用。
- [ ] 会覆盖正常、缺失、非法和不允许的本地测试。
- [ ] 能把待确认线索和有依据的结论分开写。
16. 接下来学什么
| 后续专题 | 本篇已经准备的基础 | 下一阶段重点 |
|---|---|---|
| SQL 注入 | 字符串、调用链、JDBC、MyBatis、绑定参数 | 动态 SQL、数据库差异、回归测试 |
| 认证与授权 | HTTP、Session、身份、Filter、AOP、资源归属 | Spring Security 配置、资源权限、测试矩阵 |
| XXE | XML、DTD、解析器工厂与安全配置 | 各解析 API 的配置差异和资源限制 |
| 文件上传与路径处理 | 字节流、Path、Files、工作目录 | 存储命名、目录限制、内容验证、竞争条件 |
| SSRF | HTTP、外部输入、数据流 | URL 解析、目标策略、重定向和出口控制 |
| XSS | 请求来源、响应体、字符串和编码 | 输出上下文、模板引擎和编码 |
| 反序列化 | 对象、反射、依赖、数据绑定 | 类型约束、配置和对象创建过程 |
| 业务逻辑与并发 | 分支、异常、共享状态、事务 | 业务不变量、幂等性、状态机和并发测试 |
建议下一篇先学“参数化数据库查询”或“单个资源的权限控制”,它们最容易把本篇的知识用起来。
如果现在还不能独立解释方法参数和对象,就先做第 15 章的基础练习;如果能解释订单链但看不懂真实框架配置,再补 Spring MVC 与依赖注入。学习深度可以分层,不必把代理底层、复杂泛型和所有数据库框架一次学完。
附录:常见误区速查
| 容易记错的说法 | 应该怎样记 |
|---|---|
| 所有 Java 逻辑都从 main 开始追 | Web 请求通常从框架映射的入口开始读 |
| String 是基本类型 | String 和数组都是引用类型 |
| private 能拦住网络访问者 | 它是语言访问规则,不是业务授权规则 |
| final 就是对象完全不能改 | final 引用不可重新赋值,对象内部仍可能可变 |
| static 全局唯一且线程安全 | 它有进程、类加载器和并发边界 |
| 有注解就一定执行检查 | 要确认处理器、注册条件、代理和调用路径 |
| 使用 ORM 就不用看 SQL | 查询生成、文本拼接、绑定方式仍需检查 |
| readAllLines 是流式读取 | 它会把全部行读入列表 |
| 看见危险 API 就能下结论 | 还要确认来源、执行路径、约束和证据 |
延伸阅读
本文涉及的参数、代理、事务、MyBatis 和 XML 配置,分别参考 Oracle Java Tutorials、Spring Framework Reference、MyBatis 文档和 OWASP XML External Entity Prevention Cheat Sheet。Oracle 页面针对 JDK 8,阅读新语法和框架行为时要结合项目实际版本。
Sources
[1] Oracle Java Tutorials: Passing Information to a Method or a Constructor
https://docs.oracle.com/javase/tutorial/java/javaOO/arguments.html
[2] Spring Framework Reference: Proxying Mechanisms
https://docs.spring.io/spring-framework/reference/core/aop/proxying.html
[3] Spring Framework Reference: Rolling Back a Declarative Transaction
https://docs.spring.io/spring-framework/reference/data-access/transaction/declarative/rolling-back.html
[4] MyBatis 3: Mapper XML Files
https://mybatis.org/mybatis-3/sqlmap-xml.html
[5] OWASP: XML External Entity Prevention Cheat Sheet
https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html