Play framework源碼解析 Part1:Play framework 介紹、項目構成及啓動腳本解析

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

介紹

Play framework是個輕量級的RESTful框架,致力於讓java程序員實現快速高效開發,它具備如下幾個方面的優點:java

  1. 熱加載。在調試模式下,全部修改會及時生效。
  2. 拋棄xml配置文件。約定大於配置。
  3. 支持異步編程
  4. 無狀態mvc框架,拓展性良好
  5. 簡單的路由設置

這裏附上Play framework的文檔地址,官方有更爲詳盡的功能敘述。Play framework文檔python


項目構成

play framework的初始化很是簡單,只要下載了play的軟件包後,在命令行中運行play new xxx便可初始化一個項目。
自動生成的項目結構以下:
play項目結構程序員

運行play程序也很是簡單,在項目目錄下使用play run便可運行。數據庫


啓動腳本解析

play framework軟件包目錄

爲了更好的瞭解play framework的運做原理,咱們來從play framework的啓動腳本開始分析,分析啓動腳本有助於咱們瞭解play framework的運行過程。
play framework1.2.6軟件包解壓後的文件以下:
play項目結構
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命令tomcat

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目錄下並無找到時是否忽略,如需忽略那麼在啓動時須要加入--forcemvc

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內的值只有當配置文件中找不到時纔會生效。app

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的啓動類是如何運做的。。

相關文章
相關標籤/搜索