Play framework源码解析 Part1:Play framework 介绍、项目构成及启动脚本解析

注:本系列文章所用play版本为1.2.6

介绍

Play framework是个轻量级的RESTful框架,致力于让java程序员实现快速高效开发,它具有以下几个方面的优势:

  1. 热加载。在调试模式下,所有修改会及时生效。
  2. 抛弃xml配置文件。约定大于配置。
  3. 支持异步编程
  4. 无状态mvc框架,拓展性良好
  5. 简单的路由设置

这里附上Play framework的文档地址,官方有更为详尽的功能叙述。Play framework文档


项目构成

play framework的初始化非常简单,只要下载了play的软件包后,在命令行中运行play new xxx即可初始化一个项目。
自动生成的项目结构如下:
Play framework源码解析 Part1:Play framework 介绍、项目构成及启动脚本解析_第1张图片

运行play程序也非常简单,在项目目录下使用play run即可运行。


启动脚本解析

play framework软件包目录

为了更好的了解play framework的运作原理,我们来从play framework的启动脚本开始分析,分析启动脚本有助于我们了解play framework的运行过程。
play framework1.2.6软件包解压后的文件如下:
Play framework源码解析 Part1:Play framework 介绍、项目构成及启动脚本解析_第2张图片
play的启动脚本是使用python编写的,脚本的入口为play软件包根目录下的play文件,下面我们将从play这个脚本的主入口开始分析。

play脚本解析

play脚本在开头引入了3个类,分别为play.cmdloader,play.application,play.utils,从添加的系统参数中可以看出play启动脚本的存放路径为 framework/pym
cmdloader.py的主要作用为加载framework/pym/commands下的各个脚本文件,用于之后对命令参数的解释运行
application.py的主要作用为解析项目路径下conf/中的配置文件、加载模块、拼接最后运行的java命令

sys.path.append(os.path.join(os.path.dirname(os.path.realpath(sys.argv[0])), 'framework', 'pym'))

from play.cmdloader import CommandLoader
from play.application import PlayApplication
from play.utils import *

在脚本的开头,有这么一段代码,意味着只要在play主程序根目录下创建一个名为id的文件,即可设置默认的框架id

    play_env["id_file"] = os.path.join(play_env['basedir'], 'id')
    if os.path.exists(play_env["id_file"]):
        play_env["id"] = open(play_env["id_file"]).readline().strip()
    else:
        play_env["id"] = ''

命令参数的分隔由以下代码完成,例如使用了play run --%test,那么参数列表就是["xxxx\play","run","--%test"],这段代码也说明了,play的命令格式为 play cmd [app_path] [--options]

    application_path = None
    remaining_args = []
    if len(sys.argv) == 1:
        application_path = os.getcwd()
    if len(sys.argv) == 2:
        application_path = os.getcwd()
        remaining_args = sys.argv[2:]
    if len(sys.argv) > 2:
        if sys.argv[2].startswith('-'):
            application_path = os.getcwd()
            remaining_args = sys.argv[2:]
        else:
            application_path = os.path.normpath(os.path.abspath(sys.argv[2]))
            remaining_args = sys.argv[3:]

在play参数中,有一个ignoreMissing,这个参数的全称其实是ignoreMissingModules,作用就是在当配置文件中配置有模块但是在play目录下并没有找到时是否忽略,如需忽略那么在启动时需要加入--force

    ignoreMissing = False
    if remaining_args.count('--force') == 1:
        remaining_args.remove('--force')
        ignoreMissing = True

脚本通过play_app = PlayApplication(application_path, play_env, ignoreMissing)cmdloader = CommandLoader(play_env["basedir"])来进行PlayApplication类和CommandLoader类的初始化。
在PlayApplication类的初始化过程中,它创建了PlayConfParser类用来解析配置文件,这也就是说play的配置文件解析是在脚本启动阶段进行的,这也是为什么修改配置文件无法实时生效需要重启的原因
在PlayConfParser中有一个常量DEFAULTS储存了默认的http端口和jpda调试端口,分别为9000和8000,需要添加默认值可以修改DEFAULTS,DEFAULTS内的值只有当配置文件中找不到时才会生效。

    DEFAULTS = {
        'http.port': '9000',
        'jpda.port': '8000'
    }

值得一提的是,配置文件中的http.port的优先级是小于命令参数中的http.port的

    #env为命令参数
    if env.has_key('http.port'):
        self.entries['http.port'] = env['http.port']

CommandLoader类的功能很简单,就是遍历framework/pym/commands下的.py文件,依次加载然后读取他的全局变量COMMANDS和MODULE储存用于之后的命令处理和模块加载。

回到play脚本中,在PlayApplication类和CommandLoader类初始化完成之后,play进行"--deps"参数的检查,如果存在--deps,则调用play.deps.DependenciesManager类来进行依赖的检查、更新。DependenciesManager的解析将放到后话详解。

    if remaining_args.count('--deps') == 1:
        cmdloader.commands['dependencies'].execute(command='dependencies', app=play_app, args=['--sync'], env=play_env, cmdloader=cmdloader)
        remaining_args.remove('--deps')

接下来,play便正式进行启动过程,play按照以下的顺序进行加载:

  1. 加载配置文件中记录的模块的命令信息
  2. 加载参数中指定的模块的命令信息
  3. 运行各模块中的before函数
  4. 执行play_command,play_command即为run,test,war等play需要的执行的命令
  5. 运行各模块中的after函数
  6. 结束脚本
    if play_command in cmdloader.commands:
        for name in cmdloader.modules:
            module = cmdloader.modules[name]
            if 'before' in dir(module):
                module.before(command=play_command, app=play_app, args=remaining_args, env=play_env)
        status = cmdloader.commands[play_command].execute(command=play_command, app=play_app, args=remaining_args, env=play_env, cmdloader=cmdloader)
        for name in cmdloader.modules:
            module = cmdloader.modules[name]
            if 'after' in dir(module):
                module.after(command=play_command, app=play_app, args=remaining_args, env=play_env)
        sys.exit(status)

下面,我们来看看play常用命令的运行过程...

Play常用运行命令解析

在本节的一开始,我决定先把play所有的可用命令先列举一下,以便读者可以选择性阅读。

命令名称 命令所在文件 作用
antify ant.py 初始化ant构建工具的build.xml文件
run base.py 运行程序
new base.py 新建play应用
clean base.py 删除临时文件,即清空tmp文件夹
test base.py 运行测试程序
autotest、auto-test base.py 自动运行所有测试项目
id base.py 设置项目id
new,run base.py 新建play应用并启动
clean,run base.py 删除临时文件并运行
modules base.py 显示项目用到的模块,注:这里显示的模块只是在项目配置文件中引用的模块,命令参数中添加的模块不会显示
check check.py 检查play更新
classpath、cp classpath.py 显示应用的classpath
start daemon.py 在后台运行play程序
stop daemon.py 停止正在运行的程序
restart daemon.py 重启正在运行的程序
pid daemon.py 显示运行中的程序的pid
out daemon.py 显示输出
dependencies、deps deps.py 运行DependenciesManager更新依赖
eclipsify、ec eclipse.py 创建eclipse配置文件
evolutions evolutions.py 运行play.db.Evolutions进行数据库演变检查
help help.py 输出所有play的可用命令
idealize、idea intellij.py 生成idea配置文件
javadoc javadoc.py 生成javadoc
new-module、nm modulesrepo.py 创建新模块
list-modules、lm modulesrepo.py 显示play社区中的模块
build-module、bm modulesrepo.py 打包模块
add modulesrepo.py 将模块添加至项目
install modulesrepo.py 安装模块
netbeansify netbeans.py 生成netbeans配置文件
precompile precompile.py 预编译
secret secret.py 生成secret key
status status.py 显示运行中项目的状态
version version.py 显示play framework的版本号
war war.py 将项目打包为war文件

run与start

run应该是我们平时用的最多的命令了,run命令的作用其实很简单,就是根据命令参数拼接java参数,然后调用java来运行play.server.Server,run函数的代码如下:

def run(app, args):
    #app即为play脚本中创建的PlayApplication类
    global process
    #这里检查是否存在conf/routes和conf/application.conf
    app.check()

    print "~ Ctrl+C to stop"
    print "~ "
    java_cmd = app.java_cmd(args)
    try:
        process = subprocess.Popen (java_cmd, env=os.environ)
        signal.signal(signal.SIGTERM, handle_sigterm)
        return_code = process.wait()
    signal.signal(signal.SIGINT, handle_sigint)
        if 0 != return_code:
            sys.exit(return_code)
    except OSError:
        print "Could not execute the java executable, please make sure the JAVA_HOME environment variable is set properly (the java executable should reside at JAVA_HOME/bin/java). "
        sys.exit(-1)
    print

app.java_cmd(args)的实现代码如下:

def java_cmd(self, java_args, cp_args=None, className='play.server.Server', args = None):
    if args is None:
        args = ['']
    memory_in_args=False

    #检查java参数中是否有jvm内存设置
    for arg in java_args:
        if arg.startswith('-Xm'):
            memory_in_args=True
    #如果参数中无jvm内存设置,那么在配置文件中找是否有jvm内存设置,若还是没有则在环境变量中找是否有JAVA_OPTS
    #这里其实有个问题,这里假定的是JAVA_OPTS变量里只存了jvm内存设置,如果JAVA_OPTS还存了其他选项,那对运行可能有影响
    if not memory_in_args:
        memory = self.readConf('jvm.memory')
        if memory:
            java_args = java_args + memory.split(' ')
        elif 'JAVA_OPTS' in os.environ:
            java_args = java_args + os.environ['JAVA_OPTS'].split(' ')
    #获取程序的classpath
    if cp_args is None:
        cp_args = self.cp_args()
    #读取配置文件中的jpda端口
    self.jpda_port = self.readConf('jpda.port')
    #读取配置文件中的运行模式
    application_mode = self.readConf('application.mode').lower()
    #如果模式是prod,则用server模式编译
    if application_mode == 'prod':
        java_args.append('-server')
    # JDK 7 compat
    # 使用新class校验器 (不知道作用)
    java_args.append('-XX:-UseSplitVerifier')
    #查找配置文件中是否有java安全配置,如果有则加入java参数中
    java_policy = self.readConf('java.policy')
    if java_policy != '':
        policyFile = os.path.join(self.path, 'conf', java_policy)
        if os.path.exists(policyFile):
            print "~ using policy file \"%s\"" % policyFile
            java_args.append('-Djava.security.manager')
            java_args.append('-Djava.security.policy==%s' % policyFile)
    #加入http端口设置
    if self.play_env.has_key('http.port'):
        args += ["--http.port=%s" % self.play_env['http.port']]
    #加入https端口设置
    if self.play_env.has_key('https.port'):
        args += ["--https.port=%s" % self.play_env['https.port']]

    #设置文件编码
    java_args.append('-Dfile.encoding=utf-8')
    #设置编译命令 (这边使用了jregex/Pretokenizer类的next方法,不知道有什么用)
    java_args.append('-XX:CompileCommand=exclude,jregex/Pretokenizer,next')

    #如果程序模式在dev,则添加jpda调试器参数
    if self.readConf('application.mode').lower() == 'dev':
        if not self.play_env["disable_check_jpda"]: self.check_jpda()
        java_args.append('-Xdebug')
        java_args.append('-Xrunjdwp:transport=dt_socket,address=%s,server=y,suspend=n' % self.jpda_port)
        java_args.append('-Dplay.debug=yes')
    #拼接java参数
    java_cmd = [self.java_path(), '-javaagent:%s' % self.agent_path()] + java_args + ['-classpath', cp_args, '-Dapplication.path=%s' % self.path, '-Dplay.id=%s' % self.play_env["id"], className] + args
    return java_cmd

start命令与run命令很类似,执行步骤为:

  1. 依次查找play变量pid_file、系统环境变量PLAY_PID_PATH、项目根目录下server.pid,查找是否存在指定pid
  2. 若第一步找到pid,查找当前进程列表中是否存在此pid进程,存在则试图关闭进程。(如果此pid不是play的进程呢。。。)
  3. 在配置文件中找application.log.system.out看是否关闭了系统输出
  4. 启动程序,这里与run命令唯一的区别就是他指定了stdout位置,这样就变成了后台程序
  5. 将启动后的程序的pid写入server.pid

stop命令即关闭当前进程,这里要提一下,play有个注解叫OnApplicationStop,即会在程序停止时触发,而OnApplicationStop的实现主要是调用了Runtime.getRuntime().addShutdownHook();来完成

test与autotest

使用play test命令可以让程序进入测试模式,test命令和run命令其实差别不大,唯一的区别就在于使用test命令时,脚本会将play id自动替换为test,当play id为test时会自动引入testrunner模块,testrunner模块主要功能为添加@tests路由并实现了test测试页面,他的具体实现过程后续再谈。
autotest命令的作用是自动测试所有的测试用例,他的执行顺序是这样的:

  1. 检查是否存在tmp文件夹,存在即删除
  2. 检查是否有程序正在运行,如存在则关闭程序
  3. 检查程序是否配置了ssl但是没有指定证书
  4. 检查是否存在test-result文件夹,存在即删除
  5. 使用test作为id重启程序
  6. 调用play.modules.testrunner.FirePhoque来进行自动化测试
  7. 关闭程序

autotest的脚本的步骤1和2我觉得是有问题的,应该换下顺序,不然如果程序正在向tmp文件夹插入临时文件,那么tmp文件夹就删除失败了。
关闭程序的代码调用了http://localhost:%http_port/@kill进行关闭,@kill的实现方法在play.CorePlugin下,注意,@kill在prod模式下无效(这是废话)
由于使用了python作为启动脚本,无法通过java内部变量值判断程序是否开启,只能查看控制台的输出日志,所以在使用test作为id重启后,脚本使用下面的代码判断程序是否完全启动:

try:
    #这里启动程序
    play_process = subprocess.Popen(java_cmd, env=os.environ, stdout=sout)
    except OSError:
        print "Could not execute the java executable, please make sure the JAVA_HOME environment variable is set properly (the java executable should reside at JAVA_HOME/bin/java). "
        sys.exit(-1)
    #打开日志输出文件
    soutint = open(os.path.join(app.log_path(), 'system.out'), 'r')
    while True:
        if play_process.poll():
            print "~"
            print "~ Oops, application has not started?"
            print "~"
            sys.exit(-1)
        line = soutint.readline().strip()
        if line:
            print line
            #若出现'Server is up and running'则正常启动
            if line.find('Server is up and running') > -1: # This line is written out by Server.java to system.out and is not log file dependent
                soutint.close()
                break

firephoque类的实现过程我们之后再详解。

new与clean

我们使用new来创建一个新项目,play new的使用方法为play new project-name [--with] [--name]
with参数为项目所使用的模块。
name为项目名,这里要注意一点,projectname和--name的参数可以设置为不同值,projectname是项目建立的文件夹名,--name的值为项目配置文件中的application.name。
如果不加--name,脚本会提示你是否使用projectname作为名字,在确认之后,脚本会将resources/application-skel中的所有文件拷贝到projectname文件夹下,然后用输入的name替换项目配置文件下的application.name,并生成一个64位的secretKey替换配置文件中的secretKey。
接着,脚本会查找使用的模块中是否存在dependencies.yml,并将dependencies.yml中的内容加入项目的dependencies.yml中,并调用DependenciesManager检查依赖状态。

new函数的主要代码如下:

    print "~ The new application will be created in %s" % os.path.normpath(app.path)
    if application_name is None:
        application_name = raw_input("~ What is the application name? [%s] " % os.path.basename(app.path))
    if application_name == "":
        application_name = os.path.basename(app.path)
    copy_directory(os.path.join(env["basedir"], 'resources/application-skel'), app.path)
    os.mkdir(os.path.join(app.path, 'app/models'))
    os.mkdir(os.path.join(app.path, 'lib'))
    app.check()
    replaceAll(os.path.join(app.path, 'conf/application.conf'), r'%APPLICATION_NAME%', application_name)
    replaceAll(os.path.join(app.path, 'conf/application.conf'), r'%SECRET_KEY%', secretKey())
    print "~"

clean命令非常简单,就是删除整个tmp文件夹

war与precompile

很多时候,我们需要使用tomcat等服务器容器作为服务载体,这时候就需要将play应用打包为war
war的使用参数是play war project-name [-o/--output][filename] [--zip] [--exclude][exclude-directories]
使用-o或--output来指定输出文件夹,使用--zip压缩为war格式,使用--exclude来包含另外需要打包的文件夹
要注意的是,必须在项目目录外进行操作,不然会失败
在参数处理完毕后,脚本正式开始打包过程,分为2个步骤:1.预编译。2:打包
预编译即用到了precompile命令,precompile命令与run命令几乎一样,只是在java参数中加入了precompile=yes,这里要注意下,这里加入的precompile值是yes,不是true,所以Play类中的usePrecompiled是false这里搞错了,Play类中的usePrecompiled检查的参数是precompiled,而不是precompile
让我们来看一下加入了这个java参数对程序的影响。
与预编译有关的代码主要是下面2段:

    static boolean preCompile() {
        if (usePrecompiled) {
            if (Play.getFile("precompiled").exists()) {
                classloader.getAllClasses();
                Logger.info("Application is precompiled");
                return true;
            }
            Logger.error("Precompiled classes are missing!!");
            fatalServerErrorOccurred();
            return false;
        }
        //这里开始预编译
        try {
            Logger.info("Precompiling ...");
            Thread.currentThread().setContextClassLoader(Play.classloader);
            long start = System.currentTimeMillis();
            //getAllClasses方法较长,就不贴了,下面一段代码在getAllClasses方法中进入
            classloader.getAllClasses();

            if (Logger.isTraceEnabled()) {
                Logger.trace("%sms to precompile the Java stuff", System.currentTimeMillis() - start);
            }

            if (!lazyLoadTemplates) {
                start = System.currentTimeMillis();
                //编译模板
                TemplateLoader.getAllTemplate();

                if (Logger.isTraceEnabled()) {
                    Logger.trace("%sms to precompile the templates", System.currentTimeMillis() - start);
                }
            }
            return true;
        } catch (Throwable e) {
            Logger.error(e, "Cannot start in PROD mode with errors");
            fatalServerErrorOccurred();
            return false;
        }
    }
    public byte[] enhance() {
        this.enhancedByteCode = this.javaByteCode;
        if (isClass()) {

            // before we can start enhancing this class we must make sure it is not a PlayPlugin.
            // PlayPlugins can be included as regular java files in a Play-application.
            // If a PlayPlugin is present in the application, it is loaded when other plugins are loaded.
            // All plugins must be loaded before we can start enhancing.
            // This is a problem when loading PlayPlugins bundled as regular app-class since it uses the same classloader
            // as the other (soon to be) enhanched play-app-classes.
            boolean shouldEnhance = true;
            try {
                CtClass ctClass = enhanceChecker_classPool.makeClass(new ByteArrayInputStream(this.enhancedByteCode));
                if (ctClass.subclassOf(ctPlayPluginClass)) {
                    shouldEnhance = false;
                }
            } catch( Exception e) {
                // nop
            }

            if (shouldEnhance) {
                Play.pluginCollection.enhance(this);
            }
        }

        //主要是这一段,他将增强处理后的字节码写入了文件,增强处理在之后会深入展开
        if (System.getProperty("precompile") != null) {
            try {
                // emit bytecode to standard class layout as well
                File f = Play.getFile("precompiled/java/" + (name.replace(".", "/")) + ".class");
                f.getParentFile().mkdirs();
                FileOutputStream fos = new FileOutputStream(f);
                fos.write(this.enhancedByteCode);
                fos.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return this.enhancedByteCode;
    }

预编译过程结束后,脚本正式开始打包过程,打包过程比较简单,就是讲预编译后的字节码文件、模板文件、配置文件、使用的类库、使用的模块类库等移动至WEB-INF文件夹中,如果使用了--zip,那么脚本会将生成的文件夹用zip格式打包。

secret和status

secret命令能生成一个新的secret key
status命令是用于实时显示程序的运行状态,脚本的运作十分简单,步骤如下:

  1. 检查是否有--url参数,有则在他之后添加@status
  2. 检查是否存在--secret
  3. 如果没有--url,则使用http://localhost:%http_port/@status;如果没有 --secret,则从配置文件中读取secret key
  4. 将secret_key、'@status'使用sha加密,并加入Authorization请求头
  5. 发送请求

@status的实现和@kill一样在CorePlugin类中,这在之后再进行详解。

总结

Play的启动脚本分析至此就结束了,从脚本的分析过程中我们可以稍微探究下Play在脚本启动阶段有何行为,这对我们进行脚本改造或者启动优化还是非常有帮助的。
下一篇,我们来看看Play的启动类是如何运作的。。

你可能感兴趣的:(java,playframework,play框架,源码分析)