Java 代码审计基础

学完这篇,要能做什么?
拿到一个不太复杂的 Java Web 项目后,能找到请求入口,读懂参数如何传递,跟进业务方法和数据库操作,并说清楚:数据从哪里来,经过哪些处理,最后用在什么地方,哪些检查真的生效了。

这篇不要求你先成为 Java 开发工程师,也不把“搜到一个关键词”当作发现漏洞。它要补齐的是后续学习 SQL 注入、身份认证、权限控制、XXE、文件处理等专题时反复会用到的基础。

阅读地图

第一次按顺序读。之后遇到看不懂的代码,再回来查对应章节。

阶段章节学完后的检查方法
读懂一小段 Java0~5能解释变量、方法、对象、接口和集合,能自己预测简单程序的结果
看懂项目如何工作6~10能找到依赖、配置和路由,解释 Spring 如何把对象连接起来
看懂常见数据操作11~12能分清参数绑定与文本替换,认识文件路径和 XML 解析器
连起来读一条业务链13~14能从请求入口跟到结果返回,记录依据而不是只写猜测
检查学习效果15~16能完成自测,并知道下一阶段需要深入哪些专题

文中的代码分两种:

  • 可独立运行:给出文件名、完整类和 main,保存后可以编译运行。
  • 项目片段:用于读懂 Spring、JDBC 等代码,依赖项目中的类、配置或数据库,不能单独复制就运行。

不要把两者混在一起。初学时很多“为什么报错”,其实只是把项目片段当成了完整程序。

学习这篇笔记的方法

不要一开始就背漏洞 API

Java 代码审计不是“看到 Runtime.exec 就记一个漏洞,看到 parse 就记一个漏洞”。真正需要掌握的是下面四个问题:

  1. 这个值从哪里来? 是代码里写死的,还是来自请求参数、Cookie、请求体、数据库或配置文件?
  2. 这个值经过了什么处理? 有没有类型转换、长度限制、允许列表、参数绑定、权限判断?
  3. 这个值最后去了哪里? 是拼接 SQL、读取文件、解析 XML、调用命令,还是直接输出到页面?
  4. 这条代码路径真的能被走到吗? 路由、认证、异常分支、配置和代理是否允许它执行?

可以把审计过程先记成一句话:

来源 Source → 传递 Flow → 检查 Validation → 使用 Sink

例如:

HTTP 参数 orderId
    ↓
Controller 的 id
    ↓
Service 的 orderId
    ↓
Mapper 的 #{id}
    ↓
数据库查询参数

看到一行代码时,不要只问“这一行危险吗”,还要把它放回完整的数据流里。

初学者的阅读顺序

第一次看一段代码,可以按照这个顺序:

  1. 先找类名,知道这段代码属于谁。
  2. 再找方法名,知道它提供了什么行为。
  3. 看方法的参数,记录输入从哪里进入。
  4. 从上到下读 if、循环、return 和异常处理。
  5. 遇到方法调用就跳转到它的定义,不要只凭方法名猜。
  6. 最后看返回值去了哪里,是否进入数据库、文件或响应。

刚开始看不懂很正常。先画出“谁调用谁”,再补每个类的细节,比从第一行开始硬背所有语法更有效。


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 或 falseJava 不把整数 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 的实现。
    }
}

输出为“小林打卡”“小林写代码”。普通类只能直接继承一个父类,可以实现多个接口;抽象子类也可以继续保留未实现的抽象方法。初学阶段先会顺着继承关系找到实际方法即可。

名称例子主要由什么决定
重载 overloadfind(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编译器检查方法重写关系
@RestControllerSpring注册 Web 控制器
@ServiceSpring注册业务 Bean
@TransactionalSpring 事务机制管理事务边界
@NotBlankBean Validation 实现声明输入约束
@DataLombok 注解处理器编译时生成 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()
CookieCookie 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注册方式、顺序、路径和是否继续调用链
HandlerInterceptorpreHandle 等回调覆盖了哪些路径
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]

审计时确认:

  1. 方法是否真的处于事务中。
  2. 调用是否经过代理,而不是只看注解。
  3. 异常是否传播到事务边界,或被捕获后继续执行。
  4. 是否配置 rollbackFor、noRollbackFor 和传播行为。
  5. 内层调用是否标记了 rollback-only。

“catch 了异常就一定提交”也不准确,必须看实际事务状态。

rollback-only 可以先理解成“事务已经被标记为只能回滚”。即使外层捕获了异常、最后正常 return,也不保证这个标记被清除。这里先认识判断时不能省略的条件,传播级别和隔离级别可留到事务专题。

10.3 并发与 ThreadLocal

  • 共享 Bean 的成员字段可能被多个请求共同访问。
  • ThreadLocal 是线程关联的数据,不天然等于请求关联的数据。
  • 线程池会复用线程,自己设置上下文时通常要在 finally 中 remove()。
  • synchronized 不解决多个应用实例之间的竞争。

11. 数据库访问:先看懂正常查询

11.1 SQL 最小阅读能力

数据库保存数据;关系型数据库通常用表组织记录。先想象 orders 是下面这张表,列是字段,行是记录:

id(订单编号)owner_id(所属用户编号)title(标题)
10017Java 笔记
10028学习资料

应用不是把整个数据库都搬到 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>

追踪时记录:

值来源去向要确认什么
orderIdURL 路径Service → Mapper转换、范围、参数绑定
actorId已验证身份Service → Mapper是否不是客户端任意字段
资源条件Mapper WHERE数据库是否同时限制资源和所属用户
查不到记录Mapper 返回空Service 抛异常是否终止并正确响应
返回对象数据库记录响应体是否只返回应公开字段

变量换名不代表来源改变;值从数据库读出,也不代表一定可信。

按一次具体调用走完

假设服务端已经把当前身份确认为用户 7,并且订单 1001 属于用户 7:

顺序代码位置当前值与动作
1Controller 的 id路径中取得 1001,绑定成 long
2Controller 的 actorId从已验证身份取得 7
3Service 形参orderId 为 1001,actorId 为 7;名字改变,含义没有改变
4Mapper 形参id 为 1001,ownerId 为 7,分别绑定到 SQL
5SQL WHERE同时要求 id=1001 与 owner_id=7
6Service 的 row收到匹配记录,取需要的字段构造 OrderView
7Controller 返回框架根据配置将对象变成响应体

如果同样的资源编号配上已验证用户 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. 开始读真实项目时,按这个顺序

  1. 看 README、构建配置和模块结构,确认技术栈与运行前提。
  2. 找入口路由,记下完整路径、HTTP 方法与输入位置。
  3. 找身份来源和适用安全规则,确认配置是否覆盖入口。
  4. 进入 Service,读分支、异常、事务和业务条件。
  5. 跟到 Mapper、数据库、文件或解析 API。
  6. 倒着核对重要参数从哪里来,中间如何转换。
  7. 看响应对象和异常处理,确认实际返回什么。
  8. 用本地测试与调试验证理解。

14.1 source、sink 和数据流

  • Source:数据进入分析范围的位置,例如 HTTP 参数、消息队列或上传文件。
  • Sink:需要重点确认使用方式的操作,例如执行查询、访问文件或解析 XML。
  • 数据流:值经过参数、返回值、字段、集合和转换后如何到达使用位置。
  • 控制流:哪些分支决定操作是否执行。
  • 信任边界:数据从不可信来源进入需要依赖它做决策的区域。

不是每个 source 到 sink 的路径都有漏洞。要判断具体使用方式和有效约束。编码、转换、校验、参数绑定、权限判断分别解决不同问题。

14.2 IDE 中真正有用的操作

功能用来回答什么问题
跳转到声明类、字段或方法在哪里定义?
跳转到实现接口方法有哪些实现?
查找用法谁调用它?是否还有另一条入口?
调用层次上游和下游大致是什么?
全局搜索路由、SQL、配置和注解处理器在哪里?
断点与单步运行时到底走了哪个分支?
调用栈是谁一路调用到这里?有没有框架和代理?
变量观察当前值和运行时类型是什么?

调试器中主动求值方法可能产生副作用,优先观察简单变量,不随意执行写入、发送或删除方法。

14.3 记录结论时分清证据

写法是否合适
“搜索发现 parse(),存在 XXE。”不充分,还要确认解析器、配置和输入来源
“方法没有权限注解,所以没有鉴权。”不充分,规则可能在过滤器、Service 或数据层
“看到 SQL 字符串拼接,记录为待确认线索。”合适,接下来确认来源和构造方式
“本地测试中非所属身份被拒绝,断点确认走了所属用户条件。”合适,说明了验证范围
“本地样例通过,所以整个项目安全。”不成立,超出测试覆盖范围

记录模板:

功能与预期规则:
项目版本 / 分支 / 运行配置:
入口文件、方法与行号:
输入字段及来源:
可信身份如何取得:
调用链:
关键操作及参数:
已有约束及其生效依据:
正常 / 缺失 / 非法 / 不允许操作的测试结果:
已确认的事实:
尚待确认的条件:

15. 自测与学习验收

15.1 概念题

  1. String 是基本类型吗?String[] 呢?
  2. 两个字符串比较内容,为什么不应依赖 ==?
  3. final List<String> 能不能添加元素?
  4. 方法没有返回值,为什么传入的 Map 内容仍可能变化?
  5. public 方法是不是所有人都能通过浏览器调用?
  6. 接口里没有方法体,调用时执行的逻辑可能在哪里?
  7. 自定义 @CheckPermission 会自动获得权限控制吗?
  8. @RequestParam 与 @RequestBody 常用于什么数据?
  9. 参数能转成 long,是否证明当前用户有权查看该编号?
  10. PreparedStatement 为什么还要检查 SQL 的构建过程?
  11. 捕获异常后,事务一定提交吗?
  12. normalize() 为什么不能独立完成文件访问安全限制?
  13. 出现 XML parse(),为什么还不足以判断 XXE?
  14. 一个订单查询本地通过,能证明整个项目权限正确吗?

参考答案:

  1. 都是引用类型;Java 基本类型表里没有 String,也没有数组。
  2. == 对引用比较对象身份,内容比较用 equals 等方法。
  3. 如果列表支持修改,可以添加;final 只限制引用重新赋值。
  4. 方法接收引用值的副本,仍可修改共同引用的对象。
  5. 不是。Java 访问规则与 HTTP 路由、认证、授权是不同层面。
  6. 具体实现类、框架代理、Mapper XML、注解或 Provider 等。
  7. 不会。需要处理它的机制,而且要注册、生效并覆盖调用。
  8. 前者通常接查询或表单参数,后者读取请求体,例如 JSON。
  9. 不证明,只说明类型转换成功。
  10. 如果不可信内容已经拼进 SQL,API 名字不会撤销这种拼接。
  11. 不一定,要看异常传播、回滚规则、事务状态和代理。
  12. 它只做路径规范化,不解决符号链接、权限和竞争条件。
  13. 要确认实际解析器、配置、输入来源和功能是否启用。
  14. 不能,只能支持已覆盖的配置、入口和用例。

15.2 动手题

建议先按下面的学习顺序完成,不要只复制代码看一眼输出:

练习阶段推荐程序你要说得出来的事
先学执行顺序HelloWorld、MethodDemo、ArrayDemo、LoopDemo从哪开始,哪一行先执行,变量怎样变化
再学对象StateDemo、ReferenceDemo、ObjectDemo两个变量是不是同一对象,改字段与改引用有什么区别
读实现关系InheritanceDemo、PolymorphismDemo、CollectionsDemo接口或父类只是类型,实际执行哪个实现
读框架原理与 I/OAnnotationDemo、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 配置、资源权限、测试矩阵
XXEXML、DTD、解析器工厂与安全配置各解析 API 的配置差异和资源限制
文件上传与路径处理字节流、Path、Files、工作目录存储命名、目录限制、内容验证、竞争条件
SSRFHTTP、外部输入、数据流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

标签: none