07 December 2014

Velocity 是一个基于Java的模板引擎。它高效、简洁,且有很好的可扩展性,适合在Web项目中做为MVC模型中的视图,也可以在内容生成、数据转换等应用中独立使用。本文将以源码阅读的方式,浅析velocity的实现机制,并通过几个小例子来阐述velocity中主要使用的技术点。

1.下载源码

Velocity是Apache的顶级项目。可以从 http://velocity.apache.org/ 上获取最新源码,最新版本是1.7。为了契合日常的工作,这里使用1.6.3版本做为本文基准版本,可以从 http://archive.apache.org/dist/velocity/engine/ 获取Older releases版本。

下载:http://archive.apache.org/dist/velocity/engine/1.6.3/velocity-1.6.3.tar.gz

原始的velocity使用ant构建工程,如果要使用maven可以单独下载对应的pom文件,重命名后放入工程目录。 http://archive.apache.org/dist/velocity/engine/1.6.3/velocity-1.6.3.pom

下载完成后,解压,放入pom.xml,然后 mvn eclipse:eclipse -DdownloadSources=true,可以愉快的看源码了~

2.代码结构

看代码前,先来了解一下velocity的工程目录结构:

目录 说明
build/ 存放一些ant构建脚本,比如可以使用ant javadocs生成javadoc
convert/ WebMacro到Velocity的转换程序,WebMacro是老牌模板引擎,Velocity脱胎于它,参见 http://velocity.apache.org/engine/releases/velocity-1.4/differences.html 了解它们的不同。
docs/ Velocity开发文档,快速指南等
docs/api/ Velocity Javadocs
examples/ 如何使用Velocity的几个例子
lib/ velocity依赖的包
lib/test/ 单元测试依赖的包
src/ 源码包
test/ 单测
xdocs/ 从xml构建docs站点的源文件
LICENSE Apache License Version 2.0 ,可作为开源或商业软件再发布
velocity-1.6.3.jar velocity jar
velocity-1.6.3-dep.jar velocity 将依赖打入的standalone jar

2.1.Hello World !

在开始Hello World前,可以思考一下,如果是自己来设计一个模板引擎需要哪些步骤?

嗯,应该需要一个上下文变量Context,用于存放一些会渲染到模板中的变量;然后通过文件或是常量字符串来获取模板;模板里面掏几个占位符,用一些特殊的标记隔离出来(比如#name#);最后解析模板中的占位符,将Context中的变量的实际值替换进去,然后输出merge后的结果完成模板渲染。(之前自定义短信模板这么做的,很轻巧;邮件内容较多,则使用velocity。)

思考过后,我们来run一个examples目录下的例子,找到应用程序的入口点,然后进行代码阅读,看看跟我们想的是否一样。 将examples/下的第一个例子app_example1/的样例文件(主要是Example.java、example.vm、velocity.properties)copy到src里。

Example

public class Example {
    public Example(String templateFile) {
        try {
            /*
             * Velocity的初始化可以使用Velocity类通过RuntimeSingleton类的单例模式初始化,或者使用
             * VelocityEngine类创建并初始化。两者用在不同的场景。
             */
            Velocity.init("velocity.properties");

            /*
             * 构建模板上下文,放入一些将会渲染的变量。
             */
            VelocityContext context = new VelocityContext();
            context.put("list", getNames());

            /*
             * 从文件获取模板:此过程会在调用Template.process()过程中生成抽象语法树AST。这个貌似比想象的要复杂一点,
             * 稍后来讨论AST相关内容。
             */
            Template template = null;
            try {
                template = Velocity.getTemplate(templateFile);
            } catch (ResourceNotFoundException rnfe) {
                System.out.println("Example : error : cannot find template "
                        + templateFile);
            } catch (ParseErrorException pee) {
                System.out.println("Example : Syntax error in template "
                        + templateFile + ":" + pee);
            }

            /*
             * 模板执行merge动作,将context的变量替换到template中,此过程使用到java的Introspection机制,实现了一套反射功能。
             */
            BufferedWriter writer = writer = new BufferedWriter(
                    new OutputStreamWriter(System.out));
            if (template != null)
                template.merge(context, writer);

            /*
             * flush and cleanup,放finally比较好,虽然只是测试代码
             */
            writer.flush();
            writer.close();
        } 
        ...
    }

    ...
}

通过阅读和执行Example例子,我们看到velocity运转时的几个关键步骤:

1.引擎初始化。

2.获取模板文件并解析生成AST。

3.构建context并将其merge到模板中输出。

接下来将就这三个重要步骤做进一步的阅读和分析。

3.初始化过程

RuntimeInstance.init()

public synchronized void init() throws Exception {
        ...
                // 初始化配置,先读org/apache/velocity/runtime/defaults/velocity.properties,然后再合并应用特殊配置。
                initializeProperties();

                // 初始化日志,可通过properties文件配置使用何种日志组件。
                initializeLog();

                // 初始化资源管理类,对模板资源的管理、缓存等。
                initializeResourceManager();

                // 初始化velocity指令配置:org/apache/velocity/runtime/defaults/directive.properties。加载8个预定义指令,比如Foreach,以及userdirective配置的用户指令。
                initializeDirectives();

                // 初始化5种事件处理器,包括空值处理、异常处理等。详见org.apache.velocity.app.event包。
                initializeEventHandlers();

                // 初始化解析器池。
                initializeParserPool();

                // 初始化反射处理类。
                initializeIntrospection();

                // 初始化宏工厂,我们日常使用的macros.vm。
                vmFactory.initVelocimacro();
        ...
    }

初始化过程涉及到比较多的点,本文将不展开分析,着重分析其中两个点:解析器和反射,也正是关键的运行步骤中的2、3。

4.抽象语法树AST

Velocity是通过JavaCC和JJTree生成抽象语法树的。说到JavaCC我们先来了解它相关的几个概念。

BNF -- 复杂语言的语法通常都是使用 BNF(巴科斯-诺尔范式,Backus-Naur Form)表示法或者其“近亲”― EBNF(扩展的 BNF)描述的。

BNF 规定是推导规则(产生式)的集合,写为:
<符号> ::= <使用符号的表达式>

这里的 <符号> 是非终结符,而表达式由一个符号序列,或用指示选择的竖杠 '|' 分隔的多个符号序列构成,每个符号序列整体都是左端的符号的一种可能的替代。从未在左端出现的符号叫做终结符。

JavaCC 是一个用于生成解析器的工具,它可以将一份语法定义(以.jj为后缀的文件)转化成Java代码用于检查一本文本是否符合这一份语法定义。

JJTree 是JavaCC提供的一个工具,JJTree可以将一份语法定义(以.jjt为后缀的文件,语法和.jj文件基本相同)转化成Java 代码,这段代码可以检查一份输入是否符合这一份语法定义。并且最后还会生成一颗抽象语法树提供给使用者来遍历。Velocity就将其模板语法定义成了一个jjt文件,然后根据这一份jjt文件生成了velocity模板的解析器。

4.1.JavaCC Examples

简单了解了JavaCC的相关概念之后,我们来看JavaCC自带的例子如何构建一个AST。

从http://javacc.java.net/下载JavaCC工具(内含JJTree工具)。本文使用5.0版本。

解压后,进入到javacc-5.0/examples/SimpleExamples目录,先看一个纯JavaCC的简单例子。

// 参数配置段:Simple1中列举了JavaCC配置的默认值。
options {
  ... 
}

// 解析器段:这里可以编写任意Java代码,只要保证PARSER_BEGIN、PARSER_END的参数与Java类名一致。
PARSER_BEGIN(Simple1)

/** Simple brace matcher. */
public class Simple1 {

  /** Main entry point. */
  public static void main(String args[]) throws ParseException {
    Simple1 parser = new Simple1(System.in);
    parser.Input();
  }

}

PARSER_END(Simple1)

// 产生式段
/** Root production. */
void Input() :
{}
{
  MatchedBraces() ("\n"|"\r")* <EOF>
}

/** Brace matching production. */
void MatchedBraces() :
{}
{
  "{" [ MatchedBraces() ] "}"
}

如上一个jj文件包含几个段落的代码,参数配置、解析器、产生式,常用的还有SKIP(跳词)、TOKEN(关键字)等,更多可参见 https://javacc.java.net/doc/javaccgrm.html。

正如上面介绍的BNF范式,jj文件中的产生式即是遵循它来实现,只是语法上更贴近Java的方法; 冒号左侧的"符号"即是方法名,冒号右侧的"使用符号的表达式"分为两部分:第一个{}是声明,用于声明变量和一些初始化的动作,如果没有可以忽略,使用方式参见Simple3例子;第二个{}是语法部分和动作部分,语法部分表示左侧可被替换的项,动作部分参见Simple3例子,可以是返回值或者一组动作。

Simple1的Input方法的语法部分 MatchedBraces() ("\n"|"\r")* <EOF> 的含义是:可被MatchedBraces这个产生式替代,随后可有任意个回车换行符号,最后以文件结束符结尾(是javacc系统定义的TOKEN,表示文件结束符号)。

MatchedBraces方法的语法部分 "{" [ MatchedBraces() ] "}" 的含义是:一个以{开始,并配对的以}结束的串,期间可以有任意个递归的MatchedBraces方法。

这份解析器可以解析如 "{ }", "{ { { { { } } } } }" 这样的串; 不能解析 "{ { { {", "{ } { }", "{ } }", "{ { } { } }", "{ }", "{x}" 这样的串。

4.2.JJTree Examples

接下来,我们通过编译运行的方式看一个结合了JJTree的例子。 进入到javacc-5.0/examples/JJTreeExamples目录,先来手工编译一把eg1.jjt。(本身例子自带ant编译文件,这里了解下编译过程)。

jjtree eg1.jjt // 生成Node文件及jj文件
javacc eg1.jj // 生成Eg1.java及相关解析文件
javac *.java // 编译Java源文件
java Eg1 // 运行例子,输入待解析的文本
1+1; // 回车生成一个AST

Start
 Expression
  AdditiveExpression
   MultiplicativeExpression
    UnaryExpression
     Integer
   MultiplicativeExpression
    UnaryExpression
     Integer

eg1例子实现了4则运算,先乘除后加减,如果有括号则先解析括号中的。如上如果是一个简单加法运算,它生成的AST将会包含空节点:MultiplicativeExpression(乘除)、UnaryExpression(括号),除此之外就是一颗树了。大家可以输入更为复杂的4则运算表达式以观察生成的树的构成。

例子中通过SimpleNode的dump方法输出了Tree。SimpleNode是JJTree中表示一个节点的父类。

SimpleNode n = t.Start();
n.dump("");

...

/** Main production. */
SimpleNode Start() : {}
{
  Expression() ";"
  { return jjtThis; }
}

4.2.从源码构建Velocity的解析器

看完上面的JJTree的简单例子我们就大体上清楚velocity解析器的构建方式。velocity的解析器需要JavaCC 3.2版本以上。

velocity的jjt文件在src/parser/Parser.jjt,产生的jj文件及其他生成的文件在org.apache.velocity.runtime.parser包下。在其子包node下,有jjt文件生成的一组继承自SimpleNode的节点。

jjtree Parser.jjt // 生成Parser.jj、一组Node的Java文件骨架(需要自行实现Node的功能性代码)等
javacc Parser.jj // 生成解析器

最终生成的解析器会在Template.process()方法中调用。 要想完整的理解Parser.jjt文件,还需要进一步阅读javacc-5.0/doc/JJTree.html文档,了解jjtree相关的扩展语法。

5.模板渲染

velocity中为了将context中的复杂对象merge到模板中渲染呈现,实现了一套自省机制。可以通过Enterprise Architect来对org.apache.velocity.util.introspection包下的代码做逆向工程,生成UML图以便阅读代码结构。如下图是去除枝节后的类图结构:

image

图1 velocity自省类图

通过类图我们来梳理一下其中的关系。 Uberspect是主要的自省/反射接口,提供方法如下:

init():初始化
getIterator():支持迭代 #foreach
getMethod():支持方法调用 $foo.bar( $woogie )
getPropertyGet():支持获取属性值 $bar.woogie
getPropertySet():支持设置属性值 #set($foo.bar = "geir")

Uberspect的实现UberspectImpl,该实现使用Introspector完成自省功能。Introspector扩展自基类IntrospectorBase,增添日志记录。 IntrospectorBase内部维护了一个IntrospectCache,用于缓存已经完成自省的类和方法信息。 IntrospectorCacheImpl内通过一个HashMap维护一个class与其对应的类型信息,类型信息用一个ClassMap表示。 一个ClassMap内部维护了一个MethodCache,用于缓存该类已经解析出得方法信息。MethodMap表示一个方法信息。

了解完类图之后,我们来看一个例子,通过这个例子可以更好的理解模板渲染过程。

5.1.velocity的AST

在org.apache.velocity.runtime.parser包下找到Parser.java(本类即上面通过jjtree&javacc构建的解析器入口文件)。 给Parser.java类加个main方法,以便将解析生成的AST dump输出出来。

public static void main(String[] args) {
    String temp = "我是一名来自$company的$person.business('short'),我叫$person.name";
    CharStream stream = new VelocityCharStream(new ByteArrayInputStream(temp.getBytes()), 0, 0);
    Parser t = new Parser(stream);
    try {
        SimpleNode n = t.process();
        n.dump("");
    } catch (Exception e) {
        e.printStackTrace();
    }
}

输出的AST:

*.node.ASTprocess@68a75974[id=0,info=0,invalid=false,children=6,tokens=[我是一名来自],    [$company], [的],  [$person],  [.],    [business], [(],    ['short'],  [)],    [,我叫],  [$person],  [.],    [name], [e]]
    *.node.ASTText@42e20459[id=19,info=0,invalid=false,children=0,tokens=[我是一名来自]]
    *.node.ASTReference@48b915d[id=16,info=0,invalid=false,children=0,tokens=[$company]]
    *.node.ASTText@66f472ff[id=19,info=0,invalid=false,children=0,tokens=[的]]
    *.node.ASTReference@3aa9f827[id=16,info=0,invalid=false,children=1,tokens=[$person],    [.],    [business], [(],    ['short'],  [)]]
        *.node.ASTMethod@6ce2e687[id=15,info=0,invalid=false,children=2,tokens=[business],  [(],    ['short'],  [)]]
            *.node.ASTIdentifier@248ce0ea[id=8,info=0,invalid=false,children=0,tokens=[business]]
            *.node.ASTStringLiteral@1d023565[id=7,info=0,invalid=false,children=0,tokens=['short']]
    *.node.ASTText@7bff88c3[id=19,info=0,invalid=false,children=0,tokens=[,我叫]]
    *.node.ASTReference@456bf9ce[id=16,info=0,invalid=false,children=1,tokens=[$person],    [.],    [name]]
        *.node.ASTIdentifier@33dd66fd[id=8,info=0,invalid=false,children=0,tokens=[name]]

可以看到,temp模板中的纯文本被解析为ASTText节点,变量被解析为ASTReference节点,如果是方法则被解析为ASTMethod(方法参数解析为各种类型节点,比如ASTStringLiteral),如果是属性解析为ASTIdentifier。

5.2.merge过程

Template.merge方法中调用了AST的根节点(ASTprocess)的render方法 ( (SimpleNode) data ).render( ica, writer); 。此调用将迭代处理各个子节点render方法。如果是ASTReference类型的节点则在render方法中会调用execute方法执行反射替换相关处理。

以ASTMethod为例,来看一下execute方法的反射过程。

public class ASTMethod extends SimpleNode {

public Object execute(Object o, InternalContextAdapter context)
        throws MethodInvocationException
    {
        // 获取方法
        VelMethod method = null;

        Object [] params = new Object[paramCount];

        try
        {
            // 获得参数类型
            final Class[] paramClasses = paramCount > 0 ? new Class[paramCount] : ArrayUtils.EMPTY_CLASS_ARRAY;

            for (int j = 0; j < paramCount; j++)
            {
                params[j] = jjtGetChild(j + 1).value(context);

                if (params[j] != null)
                {
                    paramClasses[j] = params[j].getClass();
                }
            }

            // 尝试从缓存中获取方法
            MethodCacheKey mck = new MethodCacheKey(methodName, paramClasses);
            IntrospectionCacheData icd =  context.icacheGet( mck );
            if ( icd != null && (o != null && icd.contextData == o.getClass()) )
            {
                method = (VelMethod) icd.thingy;
            }
            else
            {
                // 缓存获取不到则使用自省机制获取,并写入缓存
                method = rsvc.getUberspect().getMethod(o, methodName, params, new Info(getTemplateName(), getLine(), getColumn()));

                if ((method != null) && (o != null))
                {
                    icd = new IntrospectionCacheData();
                    icd.contextData = o.getClass();
                    icd.thingy = method;

                    context.icachePut( mck, icd );
                }
            }
            ...
        }
        ...

        try
        {
            // 通过反射调用方法,获得返回值
            Object obj = method.invoke(o, params);

            if (obj == null)
            {
                if( method.getReturnType() == Void.TYPE)
                {
                    return "";
                }
            }

            return obj;
        }
        ...
    }
}

ASTMethod的主要执行过程:首先从IntrospectionCache中获取方法,如果没有缓存过,则通过Uberspect反射出方法,并执行方法返回结果。 最终每个节点返回的结果会拼接成文本渲染输出。

6.小结

本文通过源码阅读、样例分析的方式,介绍了velocity如何使用Java提供的JavaCC、JJTree和自省工具达到模板渲染的目的。其中使用的工具和方法希望对读者在其他源码分析过程中有所帮助。

Java为了能够实现动态语言原生就支持的特性比如:反射、闭包等,需要做很多事情,弄的很复杂的样子;在某些场景下,更适合用动态语言来弥补Java的短板,比如模板、DSL等都可以通过在Java中方便的集成Groovy等脚本语言来支持。