命令行参数处理是一项令人厌烦的零碎工作,不管您过去已经处理过多少次了,它好像总能重新摆在您的面前。与其一遍又一遍地编写同一块代码的不同变种,为什么不利用反射来简化参数处理的工作呢?Java 顾问 Dennis Sosnoski 向您展示了如何做到这一点。在本文中,Dennis 简明扼要地介绍了一个开源库,这个库可以使得命令行参数实际上自己处理自己。
在 上个月的文章中,我介绍了Java Reflection API,并简要地讲述了它的一些基本功能。我还仔细研究了反射的性能,并且在文章的最后给出了一些指导方针,告诉读者在一个应用程序中何时应该使用反射,何时不应该使用反射。在本月这一期的文章中,我将通过查看一个应用程序来更深入地讨论这一问题,这个应用程序是用于命令行参数处理的一个库,它能够很好地体现反射的强项和弱点。
一开始,在真正进入编写实现代码的工作之前,我将首先定义要解决的问题,然后为这个库设计一个接口。不过,在开发这个库的时候,我并不是按照上述步骤进行的――我先是尽力简化一群有公共代码基础的应用程序中的现有代码,然后使之通用化。本文中使用的“定义-设计-构建”这种线性序列比起完完整整地描述开发过程要简练得多,而且,按照这种方式来组织对开发过程的描述,我可以修正我原先的一些假设,并清理掉这个库的代码中一些不必要的方面。您完全有希望发现将上述方式作为开发您自己的基于反射的应用程序时所使用的模型十分管用。
定义问题
我曾经写过许多使用命令行参数的Java应用程序。一开始,大多数应用程序都很小,但最后有些应用程序却变得大到出乎我的意料。下面是我观察到的这些应用程序的变大过程的标准模式:
一开始只有一个或者两个参数,按照某种特定的顺序排列。
考虑到这个应用程序有更多的事情要做,于是添加更多的参数。
厌倦了每次都输入所有的参数,于是让一些参数成为可选的参数,让这些参数带有默认的值。
忘记了参数的顺序,于是修改代码,允许参数以任何顺序排列。
将这个应用程序交给其他感兴趣的人。但是他们并不知道这些参数各自代表什么,于是又为这些参数添加更完善的错误检查和“帮助”描述。
当我进入到第5步的时候,我通常会后悔没有将整个过程都放在第一步来做。好在我很快就会忘记后面的那些阶段,不到一两个星期,我又会考虑另外一个简单的小命令行程序,我想拥有这个应用程序。有了这个想法之后,上述整个恶心的循环过程的重现只是时间的问题。
有一些库可以用来帮助进行命令行参数处理。不过,在本文中我会忽略掉这些库,而是自己动手创建一个库。这不是(或者不仅仅是)因为我有着“非此处发明(not invented here)”的态度(即不愿意用外人发明的东西,译者注),而是因为想拿参数处理作为一个实例。这样一来,反射的强项和弱点便正好体现了对参数处理库的需求。特别地,参数处理库:
需要一个灵活的接口,用以支持各种应用程序。
对于每个应用程序,都必须易于配置。
不要求顶级的性能,因为参数只需处理一次。
不存在访问安全性问题,因为命令行应用程序运行的时候通常不带安全管理器。
这个库中实际的反射代码只代表整个实现的一小部分,因此我将主要关注与反射最相关的一些方面。如果您想找到有关这个库的更多内容(或许还想将它用到您自己的简单命令行应用程序中去),您可以在 参考资料部分找到指向Web站点的链接。
草拟出一份设计
应用程序访问参数数据最方便的方式或许是通过该应用程序的 main 对象的一些字段。例如,假设您正在编写一个用于生成业务计划的应用程序。您可能想使用一个 boolean 标记来控制业务计划是简要的还是冗长的,使用一个 int 作为第一年的收入,使用一个 String 作为对产品的描述。我将把这些会影响应用程序的运行的变量称作 形参(parameters),以便与命令行提供的 实参(arguments)――即形参的值区分开来。通过为这些形参使用字段,将使得在需要形参的应用程序代码中的任何地方都可以方便地调用它们。而且,如果使用字段的话,在定义形参字段时为任意形参设置默认值也很方便,如清单1所示:
清单 1.业务计划生成器(部分清单)
public class PlanGen {
private boolean m_isConcise; // rarely used, default false
private int m_initialRevenue = 1000; // thousands, default is 1M
private float m_growthRate = 1.5; // default is 50% growth rate
private String m_productDescription = // McD look out, here I come
"eFood - (Really) Fast Food Online";
...
private int revenueForYear(int year) {
return (int)(m_initialRevenue * Math.pow(m_growthRate, year-1));
}
...
反射将使得应用程序可以直接访问这些私有字段,允许参数处理库在应用程序代码中没有任何特殊钩子的情况下设置参数的值。但是我 的确需要某种方法能让这个库将这些字段与特定的命令行参数相关起来。在我能够定义一个参数和一个字段之间的这种关联如何与库进行通信之前,我需要决定我希望如何格式化这些命令行参数。
对于本文,我将定义一种命令行格式,这是UNIX惯例的一种简化版本。形参的实参值可以以任何顺序提供,在最前面使用一个连字符以指示一个实参给出了一个或者多个单字符的形参标记(与实际的形参的值相对)。对于这个业务计划生成器,我将采用以下形参标记字符:
c -- 简要计划
f -- 第一年收入(千美元)
g -- 增长率(每年)
n -- 产品名称
boolean 形参只需标记字符本身就可以设置一个值,而其他类型的形参还需要某种附加的实参信息。对于数值实参,我只将它的值紧跟在形参标记字符之后(这意味着数字不能用作标记字符),而对于带 String 类型值的形参,我将在命令行中使用跟在标记字符后面的实参作为实际的值。最后,如果还需要一些形参(例如业务计划生成器的输出文件的文件名),我假设这些形参的实参值跟在命令行中可选形参值的后面。有了上面给出的这些约定,业务计划生成器的命令行看上去就是这个样子:
java PlanGen -c -f2500 -g2.5 -n "iSue4U - Litigation at Internet Speed" plan.txt
如果把它放在一起,那么每个实参的意思就是:
-c -- 生成简要计划
-f2500 -- 第一年收入为 $2,500,000
-g2.5 -- 每年增长率为250%
-n "iSue4U . . ." -- 产品名称是 "iSue4U . . ."
plan.txt -- 需要的输出文件名
这时,我已经得到了参数处理库的基本功能的规范说明书。下一步就是为这个应用代码定义一个特定的接口,以使用这个库。
选择接口
您可以使用单个的调用来负责命令行参数的实际处理,但是这个应用程序首先需要以某种方式将它的特定的形参定义到库中。这些形参可以具有不同的几种类型(对于业务计划生成器的例子,形参的类型可以是 boolean , int、 float 和 java.lang.String )。每种类型可能又有一些特殊的需求。例如,如果给出了标记字符的话,将 boolean 形参定义为 false 会比较好,而不是总将它定义为 true 。而且,为一个 int 值定义一个有效范围也很有用。
我处理这些不同需求的方法是,首先为所有形参定义使用一个基类,然后为每一种特定类型的形参细分类这个基类。这种方法使得应用程序可以以基本形参定义类的实例数组的形式将形参定义提供给这个库,而实际的定义则可以使用匹配每种形参类型的子类。对于业务计划生成器的例子,这可以采用清单2中所示的形式:
清单 2. 业务计划生成器的形参定义
private static final ParameterDef[] PARM_DEFS = {
new BoolDef('c', "m_isConcise"),
new IntDef('f', "m_initialRevenue", 10, 10000),
new FloatDef('g', "m_growthRate", 1.0, 100.0),
new StringDef('n', "m_productDescription")
}
有了得到允许的在一个数组中定义的形参,应用程序对参数处理代码的调用就可以像对一个静态方法的单个调用一样简单。为了允许除形参数组中定义的实参之外额外的实参(要么是必需的值,要么是可变长度的值),我将令这个调用返回被处理实参的实际数量。这样应用程序便可以检查额外的实参并适当地使用它们。最后的结果看上去如清单3所示:
清单 3. 使用库
public class PlanGen
{
private static final ParameterDef[] PARM_DEFS = {
...
};
public static void main(String[] args) {
// if no arguments are supplied, assume help is needed
if (args.length > 0) {
// process arguments directly to instance
PlanGen inst = new PlanGen();
int next = ArgumentProcessor.processArgs
(args, PARM_DEFS, inst);
// next unused argument is output file name
if (next >= args.length) {
System.err.println("Missing required output file name");
System.exit(1);
}
File outf = new File(args[next++]);
...
} else {
System.out.println("\nUsage: java PlanGen " +
"[-options] file\nOptions are:\n c concise plan\n" +
"f first year revenue (K$)\n g growth rate\n" +
"n product description");
}
}
}
最后剩下的部分就是处理错误报告(例如一个未知的形参标记字符或者一个超出范围的数字值)。出于这个目的,我将定义 ArgumentErrorException 作为一个未经检查的异常,如果出现了某个这一类的错误,就将抛出这个异常。如果这个异常没有被捕捉到,它将立即关闭应用程序,并将一条错误消息和栈跟踪 输出到控制台。一个替代的方法是,您也可以在代码中直接捕捉这个异常,并且用其他方式处理异常(例如,可能会与使用信息一起输出真正的错误消息)。
实现库
为了让这个库像计划的那样使用反射,它需要查找由形参定义数组指定的一些字段,然后将适当的值存到这些来自相应的命令行参数的字段中。这项任务可以通过只查找实际的命令行参数所需的字段信息来处理,但是我反而选择将查找和使用分开。我将预先找到所有的字段,然后只使用在参数处理期间已经被找到的信息。
预先找到所有的字段是一种防错性编程的步骤,这样做可以消除使用反射时带来的一个潜在的问题。如果我只是查找需要的字段,那么就很容易破坏一个形参定义(例如,输错相应的字段名),而且还不能认识到有错误发生。这里不会有编译时错误,因为字段名是作为 String 传递的,而且,只要命令行没有指定与已破坏的形参定义相匹配的实参,程序也可以执行得很好。这种被蒙蔽的错误很容易导致不完善代码的发布。
假设我想在实际处理实参之前查找字段信息,清单4显示了用于形参定义的基类的实现,这个实现带有一个 bindToClass() 方法,用于处理字段查找。
清单 4. 用于形参定义的基类
public abstract class ParameterDef
{
protected char m_char; // argument flag character
protected String m_name; // parameter field name
protected Field m_field; // actual parameter field
protected ParameterDef(char chr, String name) {
m_char = chr;
m_name = name;
}
public char getFlag() {
return m_char;
}
protected void bindToClass(Class clas) {
try {
// handle the field look up and accessibility
m_field = clas.getDeclaredField(m_name);
m_field.setAccessible(true);
} catch (NoSuchFieldException ex) {
throw new IllegalArgumentException("Field '" +
m_name + "' not found in " + clas.getName());
}
}
public abstract void handle(ArgumentProcessor proc);
}
实际的库实现还涉及到本文没有提及的几个类。我不打算一一介绍每一个类,因为其中大部分类都与库的反射方面不相关。我将提到的是,我选择将目标对象存为 ArgumentProcessor 类的一个字段,并在这个类中实现一个形参字段的真正设置。这种方法为参数处理提供了一个简单的模式: ArgumentProcessor 类扫描实参以发现形参标记,为每个标记查找相应的形参定义(总是 ParameterDef 的一个子类),再调用这个定义的 handle() 方法。 handle() 方法在解释完实参值之后,又调用 ArgumentProcessor 的 setValue() 方法。清单5显示了 ArgumentProcessor 类的不完整版本,包括在构造函数中的形参绑定调用以及 setValue() 方法:
清单 5. 主库类的部分清单
public class ArgumentProcessor
{
private Object m_targetObject; // parameter value object
private int m_currentIndex; // current argument position
...
public ArgumentProcessor(ParameterDef[] parms, Object target) {
// bind all parameters to target class
for (int i = 0; i < parms.length; i++) {
parms[i].bindToClass(target.getClass());
}
// save target object for later use
m_targetObject = target;
}
public void setValue(Object value, Field field) {
try {
// set parameter field value using reflection
field.set(m_targetObject, value);
} catch (IllegalAccessException ex) {
throw new IllegalArgumentException("Field " + field.getName() +
" is not accessible in object of class " +
m_targetObject.getClass().getName());
}
}
public void reportArgumentError(char flag, String text) {
throw new ArgumentErrorException(text + " for argument '" +
flag + "' in argument " + m_currentIndex);
}
public static int processArgs(String[] args,
ParameterDef[] parms, Object target) {
ArgumentProcessor inst = new ArgumentProcessor(parms, target);
...
}
}
最后,清单6显示了 int 形参值的形参定义子类的部分实现。这包括对基类的 bindToClass() 方法(来自 清单4)的重载,这个重载的方法首先调用基类的实现,然后检查找到的字段是否匹配预期的类型。其他特定形参类型( boolean、 float、 String ,等等)的子类与此十分相似。
清单 6. int 形参定义类
public class IntDef extends ParameterDef
{
private int m_min; // minimum allowed value
private int m_max; // maximum allowed value
public IntDef(char chr, String name, int min, int max) {
super(chr, name);
m_min = min;
m_max = max;
}
protected void bindToClass(Class clas) {
super.bindToClass(clas);
Class type = m_field.getType();
if (type != Integer.class && type != Integer.TYPE) {
throw new IllegalArgumentException("Field '" + m_name +
"'in " + clas.getName() + " is not of type int");
}
}
public void handle(ArgumentProcessor proc) {
// set up for validating
boolean minus = false;
boolean digits = false;
int value = 0;
// convert number supplied in argument list to 'value'
...
// make sure we have a valid value
value = minus ? -value : value;
if (!digits) {
proc.reportArgumentError(m_char, "Missing value");
} else if (value < m_min || value > m_max) {
proc.reportArgumentError(m_char, "Value out of range");
} else {
proc.setValue(new Integer(value), m_field);
}
}
}
结束语
在本文中,我讲述了一个用于处理命令行参数的库的设计过程,作为反射的一个实际的例子。这个库很好地阐明了如何有效地使用反射――它简化应用程序的代码,而且不用明显地牺牲性能。牺牲了多少性能呢?从对我的开发系统的一些快速测试中可以看出,一个简单的测试程序在使用整个库进行了参数处理时比起不带任何参数处理时运行起来平均只慢40毫秒。多出来的这些时间大部分是花在库类和库代码所使用的其他类的装载上,因此,即使是对于那些定义了许多命令行形参和许多实参值的应用程序,也不大可能会比这一结果糟很多。对于我的命令行应用程序,额外的40毫秒根本不能引起我的注意。
通过 参考资源中的链接可以找到完整的库代码。它包括我在本文没有提到的一些特性,包括这样一些细节,比如钩子,用于容易地生成一列格式化的形参标记,还有一些描述,有助于为应用程序提供使用指令。欢迎您在自己的程序中使用这个库,并以任何您发现有用的方式扩展这个库。
现在我已讲过了 第1部分中Java类的基础,也讲过了 第2部分中的 Java Reflection API 的原理以及第3部分,本系列剩下的部分将改变话题,讲讲大家不大熟悉的字节码处理。在第4部分,我将从容易的开始,先看看用于使用二进制类的用户友好的 Javassist 库。您是否想转换方法,但是又不愿在字节码中启动程序呢?Javassist 正好适合您的需求。下个月我们将看看如何实现这一点。