记得那年我在科研所的机房里敲完第 17 个 Servlet 客户端方法,手指在键盘上僵了半分钟 —— 屏幕上的代码像复制粘贴出来的双胞胎:都是创建缓冲区、写请求头、发数据、读响应,唯一的区别只是方法名和参数类型。那时我就想:要是能让计算机替我写这些重复代码,我就能早点回家陪刚满月的女儿了。
如今翻到这一章,像是看到了当年的心愿照进现实。这一章的核心,就是教我们打造一个 “代码小助手”,把 Servlet 客户端和服务器端那些 “换汤不换药” 的工作自动化。

一、 编写客户程序总是大同小异
做程序员久了会发现,很多工作就像餐馆里的流水线:洗菜、切菜、下锅,步骤永远不变,变的只是食材。Servlet 客户端程序就是如此 —— 无论你是做加法计算,还是查赛车记录,核心流程永远逃不出三步。
上一章我们写过客户端代理,当时没细想,现在回头看,所有方法的骨架都一模一样(图 11.1、图 11.2)。就拿 “lite” 版本的add方法来说(图 11.1):第一步创建ByteArrayOutputStream当内存缓冲区,像准备一个干净的菜板;第二步调用_createHeader做请求头,好比给食材套上保鲜膜;第三步用_invokeMethod发请求、读响应,就是把食材下锅再装盘。哪怕换成正规 HTTP 隧道的query方法(图 11.2),也只是把 “菜板” 换成了ResultSet,“保鲜膜” 换成了PreparedStatement,流程半分没变。
public double add(double a, double b) {
double n = 0;
try {
// 1. 准备缓冲区(菜板)
ByteArrayOutputStream baos = new ByteArrayOutputStream();
// 2. 构建请求头(保鲜膜)
DataOutputStream out = (DataOutputStream) _createHeader(baos, 0);
// 填参数(放食材)
out.writeDouble(a);
out.writeDouble(b);
// 3. 发请求+读响应(下锅+装盘)
DataInputStream in = (DataInputStream) _invokeMethod(baos.toByteArray());
// 取结果(盛菜)
n = in.readDouble();
// 收尾(洗工具)
out.close();
in.close();
} catch (Exception ex) {
ex.printStackTrace();
}
return n;
}
更有意思的是,“lite” 版本和正规版本的区别,本质上只是 “工具材质” 不同。“lite” 用DataInputStream/DataOutputStream,兼容老版 JDK(比如 1.0.2),像用不锈钢锅,耐用但功能简单;正规版本用ObjectInputStream/ObjectOutputStream,能传自定义对象(比如IndyRecord),好比用不粘锅,能处理更复杂的食材,但对 “炉灶”(JDK 版本)有要求。
可不管用什么工具,程序员都在重复劳动。我当年写过一个物流查询的客户端,12 个方法,每个方法都要复制粘贴这套流程,改改参数名和返回值 —— 现在想起来,那些深夜里敲键盘的声音,更像是机械的重复,而非创造。
如果说客户端程序是餐馆的前台流水线,那服务器端程序就是后厨的备餐流程 —— 步骤更繁琐,但同样重复。你回忆一下,上一章写的服务器代码存根,是不是都在走这七步:
- 读客户端请求(接订单);
- 拿服务器对象实例(找厨师);
- 建响应头(准备餐盘);
- 开内存缓冲区(铺餐布);
- 读方法序号(看订单要什么菜);
- 调用对应方法(做菜);
- 发响应(送菜上桌)。
“lite” 隧道服务端代码,和正规隧道代码,完美印证了这一点。服务端的核心是_invokeMethod方法,里面用switch(ordinal)匹配方法序号 ——ordinal=0调add,ordinal=1调subtract,像厨师看订单编号做菜:#0 是番茄炒蛋,#1 是青椒肉丝,流程都是切菜、下锅、调味。
public void _invokeMethod(Object serverObject, int ordinal, DataInput in, DataOutput out) throws Exception {
// 2. 拿服务器对象(找厨师:Math类负责计算)
Math math = (Math) serverObject;
DataInputStream dataIn = (DataInputStream) in;
DataOutputStream dataOut = (DataOutputStream) out;
// 5. 读方法序号(看订单编号)
switch (ordinal) {
case 0: // add(#0号菜:加法)
// 6. 读参数+调用方法(备菜+做菜)
double a0 = dataIn.readDouble();
double b0 = dataIn.readDouble();
double n0 = math.add(a0, b0);
// 7. 写返回值(装盘送出去)
out.writeDouble(n0);
break;
case 1: // subtract(#1号菜:减法)
double a1 = dataIn.readDouble();
double b1 = dataIn.readDouble();
double n1 = math.subtract(a1, b1);
out.writeDouble(n1);
break;
// ... 其他方法(其他菜品)
default:
throw new Exception("Invalid ordinal: " + ordinal); // 没这个菜
}
}
正规隧道的服务端代码也是同理,只是把 “食材处理工具” 从DataInputStream换成了ObjectInputStream,能处理IndyRecord这种自定义对象 —— 好比厨师换了套能处理海鲜的工具,但备餐流程还是老样子。
我当年维护过一个电商项目的服务器端,20 多个接口,每个接口的服务端代码都要写一遍switch(ordinal)。有次手滑把case 3写成了case 5,线上报了一堆 “无效方法序号” 的错,排查了半天才找到 —— 现在想,这种重复劳动不仅浪费时间,还容易出错。就像厨师天天重复切菜,总有一天会切到手。
二、让 Java 为你编写客户端和服务器
既然客户端和服务器端代码都在重复,那能不能让 Java 替我们写?这个想法在 2002 年第一次冒出来时,我还以为是异想天开 —— 直到发现了 Reflection API,才知道 Java 早就留了 “后门”。
要实现自动化,得解决三个问题:首先,得知道要生成什么代码(确定 “食材”);其次,得能 “看透” 类的结构(知道 “食材” 怎么处理);最后,得能把代码写出来(把 “菜” 做出来)。这三点,正好对应我们要做的三件事:定义接口、用 Reflection API 分析类、用模板生成代码。
2.1 使用 Reflection API:ShowClass
Reflection API 就像给 Java 装了一副 X 光眼镜 —— 不管是类的方法、属性,还是父类、接口,都能看得一清二楚。JDK 1.1 之后就有了这个功能,藏在java.lang.reflect包下,核心是java.lang.Class类 —— 所有 Java 类的 “祖宗”,它的方法(表 11.1)能把类的五脏六腑都扒出来。
关键 Reflection 方法
|
方法 |
作用描述 |
|
getDeclaredMethods |
获取类中自己声明的所有方法(不包括继承的),像看一个人自己会的技能 |
|
getInterfaces |
获取类实现的所有接口,像看一个人加入的所有社团 |
|
getSuperclass |
获取类的直接父类,像看一个人的父亲 |
|
getModifiers |
获取类的修饰符(如 public、private),像看一个人的身份标签 |
|
getFields |
获取类的所有字段(包括继承的),像看一个人身上带的所有物品 |
为了用好这副 “X 光眼镜”,我们写了个 ShowClass 程序(图 11.5),功能比 JDK 自带的javap还直观 —— 输入类名,就能把类的继承链、接口、方法全列出来,像给类拍了张全身 CT。
ShowClass 的核心是go方法,流程很简单:先通过Class.forName(className)加载类(好比找到要拍 CT 的人),再用getSuperClasses查父类(查家族史)、getInterfaces查接口(查社团)、getMethods查方法(查技能),最后把信息打印出来(出 CT 报告)。
public void go(String className) {
try {
// 1. 加载类(找到要拍CT的人)
Class c = Class.forName(className);
// 2. 查父类(家族史)
Vector extendList = getSuperClasses(c);
// 3. 查接口(社团)
Vector interfaceList = getInterfaces(c);
// 4. 查方法(技能)
Vector methods = getMethods(c);
// 5. 打印结果(出CT报告)
System.out.println(getModifierString(c.getModifiers()) + " " + c.getName());
printList("extends", extendList); // 打印父类
printList("implements", interfaceList); // 打印接口
printList("Methods", methods); // 打印方法
} catch (ClassNotFoundException ex) {
System.out.println("Class '" + className + "' not found."); // 没找到人
}
}
当年我用 ShowClass 排查过一个经典 bug:有个同事写的 Servlet,客户端调用query方法总报 “参数类型不匹配”,查了半天没找到问题。我用 ShowClass 扫了一眼服务端类,发现接口里定义的是query(int year),但他实际实现时写成了query(Integer year)—— 就像 CT 照出了隐藏的肿瘤,Reflection API 一下就揪出了这个类型错误。
不过要注意,当年很多浏览器把 Reflection API 当成 “危险工具”—— 就像医院不会随便给健康人拍 CT,浏览器也不允许 Applet 用 Reflection,怕它 “偷看” 本地类的结构。所以我们当时有个规矩:Reflection 只在服务端用,生成代码时 “偷偷” 用,绝不放 Applet 里。
2.2 编写 ServletGen
有了 Reflection API 这副 “X 光眼镜”,下一步就是打造 “代码小助手”——ServletGen。它的核心思路很简单:用模板当 “菜谱”,用 Reflection API 当 “备菜工具”,自动生成客户端和服务器端代码。
用模板当 “菜谱”
写代码就像做菜,菜谱上会写 “放 2 勺盐”“炒 3 分钟”,模板里就会写 “生成客户端类名”“插入方法代码”。图 11.11 是客户端代理模板,里面的%CLIENT_NAME%、%METHODS%就是 “菜谱里的空白处”,等着 ServletGen 填空。
模板里还有个小细节:每行开头的#号是用来控制缩进的 —— 一个#对应一层缩进(比如 4 个空格)。有人喜欢紧凑的代码,有人喜欢宽松的,改改#号的替换规则就行,像菜谱里 “盐放多少” 可以按口味调。
// 图11.11 客户端代理模板(关键部分)
/*
* 生成的类名:%CLIENT_NAME%(如MathClient)
* 生成时间:%TIMESTAMP%(如2025-10-28 15:30:00)
*/
%PACKAGE_STATEMENT% // 包声明(如package com.example;)
import java.io.*;
import javaservlets.tunnel.client.*;
public class %CLIENT_NAME%
#extends %SUPER_CLASS% // 父类(如BaseClient)
#implements %INTERFACE_NAME% // 接口(如MathInterface)
{
#// 构造方法(固定模板)
#public %CLIENT_NAME%(String url) throws TunnelException, IOException {
###_setURL(new java.net.URL(url));
###_initialize();
#}
#%METHODS% // 这里自动生成所有接口方法(如add、subtract)
}
让 ServletGen “按菜谱做菜”
ServletGen 的核心是 BaseCodeGen 类(图 11.12),它就像 “厨师长”,负责读模板、处理标识符、生成代码。流程分三步:
- 读模板:打开模板文件,像厨师拿出菜谱;
- 填模板:用 Reflection API 获取接口信息,替换模板里的%XXX%(比如把%CLIENT_NAME%换成MathClient),像按菜谱放食材;
- 写代码:把填好的模板写入.java文件,像把做好的菜装盘。
其中最关键的是processTag方法,它负责处理模板里的标识符。比如遇到%METHODS%,就调用processMethods方法,用 Reflection API 扫接口的所有方法,自动生成每个方法的代码 —— 就像厨师看到菜谱里 “做 10 道菜”,就按顺序把 10 道菜全做出来。
protected String processTag(String line, String tag, int pos, int numIndent, PrintWriter out) throws IOException {
String code = null;
if (tag.equals("METHODS")) {
// 用Reflection API扫接口方法,生成代码
Class c = getInterfaceClass(); // 获取接口类
Method[] methods = c.getMethods(); // 扫所有方法
for (Method method : methods) {
if (Modifier.isPublic(method.getModifiers())) {
codeMethod(method, numIndent, out); // 生成单个方法代码
}
}
line = null; // 方法代码直接写输出流,不用替换原行
}
// 处理其他标识符(如%CLIENT_NAME%、%TIMESTAMP%)
// ...
return line;
}
当年第一次用 ServletGen 时,我输入MathInterface接口名,不到 1 秒就生成了MathClient(客户端)和MathServer(服务端)两个类,共 300 多行代码。旁边的测试工程师看了说:“你这比我写测试用例还快!”—— 那一刻,我想起了当年深夜敲重复代码的日子,突然觉得,技术的进步,就是让程序员少做 “机械活”,多做 “创造性活”。
2.3 隧道实例再访:RemoteMathLite
光说不练假把式,我们用 “RemoteMathLite” 实例,看看 ServletGen 到底好不好用。这个实例是 “lite” 版本的 HTTP 隧道,实现简单的加减乘除计算,核心是MathInterface接口(定义add、subtract等方法)。
第一步:定义接口(定 “食材清单”)
先写MathInterface接口,里面就 4 个方法:add、subtract、multiply、divide—— 像给 ServletGen 列了份 “食材清单”,告诉它要生成哪些方法的代码。
// MathInterface接口(食材清单)
public interface MathInterface {
double add(double a, double b);
double subtract(double a, double b);
double multiply(double a, double b);
double divide(double a, double b);
}
第二步:用 ServletGen 生成代码(按清单做菜)
运行 ServletGen,输入接口名MathInterface,选择 “lite” 版本模板,它会自动做三件事:
- 生成客户端MathClientLite:里面的每个方法都遵循 “缓冲区→请求头→发请求→读响应” 的流程(图 11.1 的风格),不用我们手写;
- 生成服务器端MathServerLite:里面的_invokeMethod方法用switch(ordinal)匹配方法,参数和返回值都用DataInputStream/DataOutputStream处理;
- 生成配置文件:告诉 Servlet 容器,哪个 URL 对应哪个服务端类(如/math对应MathServerLite)。
// 测试RemoteMathLite
public class MathTest {
public static void main(String[] args) {
try {
// 1. 创建客户端实例(连接服务端)
MathClientLite client = new MathClientLite("http://localhost:8080/RemoteMathLite/math");
// 2. 调用自动生成的方法(测试加减乘除)
double addResult = client.add(10.5, 2.3);
double subResult = client.subtract(15.8, 4.2);
double mulResult = client.multiply(6.0, 7.5);
double divResult = client.divide(20.0, 4.0);
// 3. 打印结果
System.out.println("10.5 + 2.3 = " + addResult); // 输出 12.8
System.out.println("15.8 - 4.2 = " + subResult); // 输出 11.6
System.out.println("6.0 * 7.5 = " + mulResult); // 输出 45.0
System.out.println("20.0 / 4.0 = " + divResult); // 输出 5.0
// 4. 关闭客户端
client.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
运行这个测试类时,我特意留意了时间 —— 从编译代码到看到结果,总共不到 2 分钟。对比当年手动写客户端、服务端代码的 2 小时,效率提升了 60 倍。更重要的是,自动生成的代码没有语法错误,也没有 “把 case3 写成 case5” 的低级 bug—— 这就像工厂流水线生产的零件,比手工打磨的更标准、更可靠。
还有个细节值得一提:RemoteMathLite 的服务端MathServerLite,会自动复用第 10 章写的LiteTunnelServlet基础类。就像盖房子时复用预制的墙体,不用再从地基开始砌砖。这种 “复用 + 自动化” 的组合,让我们在 2003 年的科研项目中,把原本需要 1 周的 Servlet 开发时间,压缩到了 1 天。
2.4 隧道实例再访:RemoteIndy
如果说 RemoteMathLite 是 “家常菜”,那 RemoteIndy 就是 “大餐”—— 它是正规 HTTP 隧道的实例,用来查询印第安纳波利斯 500 赛车(Indy 500)的历史记录,核心是处理自定义对象IndyRecord(包含年份、车手、速度三个字段)。
第一步:定义接口与实体类(定 “大餐菜谱”)
先写IndyInterface接口,包含connect(连接数据库)、close(关闭连接)、query(查询记录)三个方法,其中query的返回值是IndyRecord:
// IndyInterface接口
import java.sql.SQLException;
public interface IndyInterface {
// 连接数据库
boolean connect() throws SQLException;
// 关闭数据库连接
void close() throws SQLException;
// 根据年份查询赛车记录
IndyRecord query(int year) throws SQLException;
}
// 自定义实体类IndyRecord(需要实现Serializable接口,支持对象传输)
import java.io.Serializable;
public class IndyRecord implements Serializable {
public int year; // 比赛年份
public String driver; // 冠军车手
public double speed; // 平均速度(英里/小时)
}
第二步:用 ServletGen 生成代码(按 “大餐菜谱” 备菜)
运行 ServletGen 时,选择 “正规隧道模板”,输入IndyInterface接口名。这次生成的代码和 RemoteMathLite 有两个关键区别:
- 流类型不同:客户端用ObjectInputStream/ObjectOutputStream,支持传输IndyRecord对象;
- 异常处理不同:自动添加SQLException捕获,符合数据库操作的场景。
生成的客户端IndyClient中,query方法的核心代码如下:
// 自动生成的IndyClient.query方法
public IndyRecord query(int year) throws SQLException {
IndyRecord record = null;
try {
// 1. 准备缓冲区(比lite版本多了对象流初始化)
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream out = (ObjectOutputStream) _createHeader(baos, 2); // ordinal=2对应query
// 2. 写入参数(年份)
out.writeInt(year);
// 3. 发送请求并读取响应(用ObjectInputStream读自定义对象)
ObjectInputStream in = (ObjectInputStream) _invokeMethod(baos.toByteArray());
// 4. 读取返回值(直接获取IndyRecord对象)
record = (IndyRecord) in.readObject();
// 5. 关闭流
out.close();
in.close();
} catch (ClassNotFoundException e) {
throw new SQLException("IndyRecord类未找到", e);
} catch (IOException e) {
throw new SQLException("IO异常", e);
}
return record;
}
第三步:测试运行(尝 “大餐”)
部署后,我写了个测试类查询 1965 年的 Indy 500 记录 —— 那年的冠军是 Jim Clark,平均速度 150.686 英里 / 小时:
// 测试RemoteIndy
public class IndyTest {
public static void main(String[] args) {
try {
IndyClient client = new IndyClient("http://localhost:8080/RemoteIndy/indy");
client.connect(); // 连接数据库
// 查询1965年的记录
IndyRecord record = client.query(1965);
if (record != null) {
System.out.println("1965年Indy 500冠军:");
System.out.println("车手:" + record.driver); // 输出 Jim Clark
System.out.println("速度:" + record.speed); // 输出 150.686
}
client.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
运行结果和预期完全一致。最让我感慨的是,当年手动写这种带自定义对象传输的 Servlet,光是处理对象序列化、异常转换就花了大半天,而现在用 ServletGen,5 分钟就能搞定全套代码。这让我想起一句话:“好的工具,能让程序员从‘体力劳动者’变回‘脑力劳动者’”。
最后小结
翻完这一章的代码,我又想起 2001 年那个敲重复代码的深夜。那时的我不会想到,十几年后,Spring Boot 的@RestController能自动生成接口,MyBatis 的Mapper能自动生成 SQL—— 但这些技术的核心思想,和我们当年的 ServletGen 其实是相通的:用自动化消除重复劳动,让程序员专注于业务逻辑。
总结一下本章的核心收获,其实就三句话:
- 重复的代码该 “自动化”:客户端的 “缓冲区→请求→响应”、服务端的 “读参数→调方法→写结果”,都是重复劳动,适合用工具自动生成;
- Reflection API 是 “钥匙”:它能 “看透” 类的结构,让工具知道该生成哪些方法、用什么参数 —— 这是自动化的基础;
- 模板是 “骨架”:用模板定义代码的固定结构,用标识符填充变量部分,既灵活又易维护,就像用模具批量生产零件。
从桌面开发到 Web 2.0,再到现在的微服务,我经历过很多技术迭代,但 “消除重复” 的原则从未变过。当年我们用 ServletGen 解决的问题,现在可能被更先进的框架替代,但这种 “用技术解决技术痛点” 的思维,才是程序员最该保留的能力。未完待续........

1874

被折叠的 条评论
为什么被折叠?



