详解spring boot应用启动原理分析
前言
本文分析的是spring boot 1.3. 的工作原理。spring boot 1.4. 之后打包结构发现了变化,增加了boot-inf目录,但是基本原理还是不变的。
关于spring boot 1.4.* 里classloader的变化,可以参考:
spring boot quick start
在spring boot里,很吸引人的一个特性是可以直接把应用打包成为一个jar/war,然后这个jar/war是可以直接启动的,不需要另外配置一个web server。
如果之前没有使用过spring boot可以通过下面的demo来感受下。
下面以这个工程为例,演示如何启动spring boot项目:
git clone git@github.com:hengyunabc/spring-boot-demo.git mvn spring-boot-demo java -jar target/demo-0.0.1-snapshot.jar
如果使用的ide是spring sts或者idea,可以通过向导来创建spring boot项目。
对spring boot的两个疑问
刚开始接触spring boot时,通常会有这些疑问
- spring boot如何启动的?
- spring boot embed tomcat是如何工作的? 静态文件,jsp,网页模板这些是如何加载到的?
下面来分析spring boot是如何做到的。
打包为单个jar时,spring boot的启动方式
maven打包之后,会生成两个jar文件:
demo-0.0.1-snapshot.jar demo-0.0.1-snapshot.jar.original
其中demo-0.0.1-snapshot.jar.original是默认的maven-jar-plugin生成的包。
demo-0.0.1-snapshot.jar是spring boot maven插件生成的jar包,里面包含了应用的依赖,以及spring boot相关的类。下面称之为fat jar。
先来查看spring boot打好的包的目录结构(不重要的省略掉):
├── meta-inf │ ├── manifest.mf ├── application.properties ├── com │ └── example │ └── springbootdemoapplication.class ├── lib │ ├── aopalliance-1.0.jar │ ├── spring-beans-4.2.3.release.jar │ ├── ... └── org └── springframework └── boot └── loader ├── executablearchivelauncher.class ├── jarlauncher.class ├── javaagentdetector.class ├── launchedurlclassloader.class ├── launcher.class ├── mainmethodrunner.class ├── ...
依次来看下这些内容。
manifest.mf
manifest-version: 1.0 start-class: com.example.springbootdemoapplication implementation-vendor-id: com.example spring-boot-version: 1.3.0.release created-by: apache maven 3.3.3 build-jdk: 1.8.0_60 implementation-vendor: pivotal software, inc. main-class: org.springframework.boot.loader.jarlauncher
可以看到有main-class是org.springframework.boot.loader.jarlauncher ,这个是jar启动的main函数。
还有一个start-class是com.example.springbootdemoapplication,这个是我们应用自己的main函数。
@springbootapplication public class springbootdemoapplication { public static void main(string[] args) { springapplication.run(springbootdemoapplication.class, args); } }
com/example 目录
这下面放的是应用的.class文件。
lib目录
这里存放的是应用的maven依赖的jar包文件。
比如spring-beans,spring-mvc等jar。
org/springframework/boot/loader 目录
这下面存放的是spring boot loader的.class文件。
archive的概念
- archive即归档文件,这个概念在linux下比较常见
- 通常就是一个tar/zip格式的压缩包
- jar是zip格式
在spring boot里,抽象出了archive的概念。
一个archive可以是一个jar(jarfilearchive),也可以是一个文件目录(explodedarchive)。可以理解为spring boot抽象出来的统一访问资源的层。
上面的demo-0.0.1-snapshot.jar 是一个archive,然后demo-0.0.1-snapshot.jar里的/lib目录下面的每一个jar包,也是一个archive。
public abstract class archive { public abstract url geturl(); public string getmainclass(); public abstract collection<entry> getentries(); public abstract list<archive> getnestedarchives(entryfilter filter);
可以看到archive有一个自己的url,比如:
jar:file:/tmp/target/demo-0.0.1-snapshot.jar!/
还有一个getnestedarchives函数,这个实际返回的是demo-0.0.1-snapshot.jar/lib下面的jar的archive列表。它们的url是:
jar:file:/tmp/target/demo-0.0.1-snapshot.jar!/lib/aopalliance-1.0.jar jar:file:/tmp/target/demo-0.0.1-snapshot.jar!/lib/spring-beans-4.2.3.release.jar
jarlauncher
从manifest.mf可以看到main函数是jarlauncher,下面来分析它的工作流程。
jarlauncher类的继承结构是:
class jarlauncher extends executablearchivelauncher class executablearchivelauncher extends launcher
以demo-0.0.1-snapshot.jar创建一个archive:
jarlauncher先找到自己所在的jar,即demo-0.0.1-snapshot.jar的路径,然后创建了一个archive。
下面的代码展示了如何从一个类找到它的加载的位置的技巧:
protected final archive createarchive() throws exception { protectiondomain protectiondomain = getclass().getprotectiondomain(); codesource codesource = protectiondomain.getcodesource(); uri location = (codesource == null ? null : codesource.getlocation().touri()); string path = (location == null ? null : location.getschemespecificpart()); if (path == null) { throw new illegalstateexception("unable to determine code source archive"); } file root = new file(path); if (!root.exists()) { throw new illegalstateexception( "unable to determine code source archive from " + root); } return (root.isdirectory() ? new explodedarchive(root) : new jarfilearchive(root)); }
获取lib/下面的jar,并创建一个launchedurlclassloader
jarlauncher创建好archive之后,通过getnestedarchives函数来获取到demo-0.0.1-snapshot.jar/lib下面的所有jar文件,并创建为list。
注意上面提到,archive都是有自己的url的。
获取到这些archive的url之后,也就获得了一个url[]数组,用这个来构造一个自定义的classloader:launchedurlclassloader。
创建好classloader之后,再从manifest.mf里读取到start-class,即com.example.springbootdemoapplication,然后创建一个新的线程来启动应用的main函数。
/** * launch the application given the archive file and a fully configured classloader. */ protected void launch(string[] args, string mainclass, classloader classloader) throws exception { runnable runner = createmainmethodrunner(mainclass, args, classloader); thread runnerthread = new thread(runner); runnerthread.setcontextclassloader(classloader); runnerthread.setname(thread.currentthread().getname()); runnerthread.start(); } /** * create the {@code mainmethodrunner} used to launch the application. */ protected runnable createmainmethodrunner(string mainclass, string[] args, classloader classloader) throws exception { class<?> runnerclass = classloader.loadclass(runner_class); constructor<?> constructor = runnerclass.getconstructor(string.class, string[].class); return (runnable) constructor.newinstance(mainclass, args); }
launchedurlclassloader
launchedurlclassloader和普通的urlclassloader的不同之处是,它提供了从archive里加载.class的能力。
结合archive提供的getentries函数,就可以获取到archive里的resource。当然里面的细节还是很多的,下面再描述。
spring boot应用启动流程总结
看到这里,可以总结下spring boot应用的启动流程:
- spring boot应用打包之后,生成一个fat jar,里面包含了应用依赖的jar包,还有spring boot loader相关的类
- fat jar的启动main函数是jarlauncher,它负责创建一个launchedurlclassloader来加载/lib下面的jar,并以一个新线程启动应用的main函数。
spring boot loader里的细节
代码地址:
jarfile url的扩展
spring boot能做到以一个fat jar来启动,最重要的一点是它实现了jar in jar的加载方式。
jdk原始的jarfile url的定义可以参考这里:
http://docs.oracle.com/javase/7/docs/api/java/net/jarurlconnection.html
原始的jarfile url是这样子的:
jar:file:/tmp/target/demo-0.0.1-snapshot.jar!/
jar包里的资源的url:
可以看到对于jar里的资源,定义以'!/‘来分隔。原始的jarfile url只支持一个'!/‘。
spring boot扩展了这个协议,让它支持多个'!/‘,就可以表示jar in jar,jar in directory的资源了。
比如下面的url表示demo-0.0.1-snapshot.jar这个jar里lib目录下面的spring-beans-4.2.3.release.jar里面的manifest.mf:
自定义urlstreamhandler,扩展jarfile和jarurlconnection
在构造一个url时,可以传递一个handler,而jdk自带有默认的handler类,应用可以自己注册handler来处理自定义的url。
public url(string protocol, string host, int port, string file, urlstreamhandler handler) throws malformedurlexception
spring boot通过注册了一个自定义的handler类来处理多重jar in jar的逻辑。
这个handler内部会用softreference来缓存所有打开过的jarfile。
在处理像下面这样的url时,会循环处理'!/‘分隔符,从最上层出发,先构造出demo-0.0.1-snapshot.jar这个jarfile,再构造出spring-beans-4.2.3.release.jar这个jarfile,然后再构造出指向manifest.mf的jarurlconnection。
//org.springframework.boot.loader.jar.handler public class handler extends urlstreamhandler { private static final string separator = "!/"; private static softreference<map<file, jarfile>> rootfilecache; @override protected urlconnection openconnection(url url) throws ioexception { if (this.jarfile != null) { return new jarurlconnection(url, this.jarfile); } try { return new jarurlconnection(url, getrootjarfilefromurl(url)); } catch (exception ex) { return openfallbackconnection(url, ex); } } public jarfile getrootjarfilefromurl(url url) throws ioexception { string spec = url.getfile(); int separatorindex = spec.indexof(separator); if (separatorindex == -1) { throw new malformedurlexception("jar url does not contain !/ separator"); } string name = spec.substring(0, separatorindex); return getrootjarfile(name); }
classloader如何读取到resource
对于一个classloader,它需要哪些能力?
- 查找资源
- 读取资源
对应的api是:
public url findresource(string name) public inputstream getresourceasstream(string name)
上面提到,spring boot构造launchedurlclassloader时,传递了一个url[]数组。数组里是lib目录下面的jar的url。
对于一个url,jdk或者classloader如何知道怎么读取到里面的内容的?
实际上流程是这样子的:
- launchedurlclassloader.loadclass
- url.getcontent()
- url.openconnection()
- handler.openconnection(url)
最终调用的是jarurlconnection的getinputstream()函数。
//org.springframework.boot.loader.jar.jarurlconnection @override public inputstream getinputstream() throws ioexception { connect(); if (this.jarentryname.isempty()) { throw new ioexception("no entry name specified"); } return this.jarentrydata.getinputstream(); }
从一个url,到最终读取到url里的内容,整个过程是比较复杂的,总结下:
- spring boot注册了一个handler来处理”jar:”这种协议的url
- spring boot扩展了jarfile和jarurlconnection,内部处理jar in jar的情况
- 在处理多重jar in jar的url时,spring boot会循环处理,并缓存已经加载到的jarfile
- 对于多重jar in jar,实际上是解压到了临时目录来处理,可以参考jarfilearchive里的代码
- 在获取url的inputstream时,最终获取到的是jarfile里的jarentrydata
这里面的细节很多,只列出比较重要的一些点。
然后,urlclassloader是如何getresource的呢?
urlclassloader在构造时,有url[]数组参数,它内部会用这个数组来构造一个urlclasspath:
urlclasspath ucp = new urlclasspath(urls);
在 urlclasspath 内部会为这些urls 都构造一个loader,然后在getresource时,会从这些loader里一个个去尝试获取。
如果获取成功的话,就像下面那样包装为一个resource。
resource getresource(final string name, boolean check) { final url url; try { url = new url(base, parseutil.encodepath(name, false)); } catch (malformedurlexception e) { throw new illegalargumentexception("name"); } final urlconnection uc; try { if (check) { urlclasspath.check(url); } uc = url.openconnection(); inputstream in = uc.getinputstream(); if (uc instanceof jarurlconnection) { /* need to remember the jar file so it can be closed * in a hurry. */ jarurlconnection juc = (jarurlconnection)uc; jarfile = jarloader.checkjar(juc.getjarfile()); } } catch (exception e) { return null; } return new resource() { public string getname() { return name; } public url geturl() { return url; } public url getcodesourceurl() { return base; } public inputstream getinputstream() throws ioexception { return uc.getinputstream(); } public int getcontentlength() throws ioexception { return uc.getcontentlength(); } }; }
从代码里可以看到,实际上是调用了url.openconnection()。这样完整的链条就可以连接起来了。
注意,urlclasspath这个类的代码在jdk里没有自带,在这里看到 http://grepcode.com/file/repository.grepcode.com/java/root/jdk/openjdk/7u40-b43/sun/misc/urlclasspath.java#506
在ide/开放目录启动spring boot应用
在上面只提到在一个fat jar里启动spring boot应用的过程,下面分析ide里spring boot是如何启动的。
在ide里,直接运行的main函数是应用自己的main函数:
@springbootapplication public class springbootdemoapplication { public static void main(string[] args) { springapplication.run(springbootdemoapplication.class, args); } }
其实在ide里启动spring boot应用是最简单的一种情况,因为依赖的jar都让ide放到classpath里了,所以spring boot直接启动就完事了。
还有一种情况是在一个开放目录下启动spring boot启动。所谓的开放目录就是把fat jar解压,然后直接启动应用。
java org.springframework.boot.loader.jarlauncher
这时,spring boot会判断当前是否在一个目录里,如果是的,则构造一个explodedarchive(前面在jar里时是jarfilearchive),后面的启动流程类似fat jar的。
embead tomcat的启动流程
判断是否在web环境
spring boot在启动时,先通过一个简单的查找servlet类的方式来判断是不是在web环境:
private static final string[] web_environment_classes = { "javax.servlet.servlet", "org.springframework.web.context.configurablewebapplicationcontext" }; private boolean deducewebenvironment() { for (string classname : web_environment_classes) { if (!classutils.ispresent(classname, null)) { return false; } } return true; }
如果是的话,则会创建annotationconfigembeddedwebapplicationcontext,否则spring context就是annotationconfigapplicationcontext:
//org.springframework.boot.springapplication protected configurableapplicationcontext createapplicationcontext() { class<?> contextclass = this.applicationcontextclass; if (contextclass == null) { try { contextclass = class.forname(this.webenvironment ? default_web_context_class : default_context_class); } catch (classnotfoundexception ex) { throw new illegalstateexception( "unable create a default applicationcontext, " + "please specify an applicationcontextclass", ex); } } return (configurableapplicationcontext) beanutils.instantiate(contextclass); }
获取embeddedservletcontainerfactory的实现类
spring boot通过获取embeddedservletcontainerfactory来启动对应的web服务器。
常用的两个实现类是tomcatembeddedservletcontainerfactory和jettyembeddedservletcontainerfactory。
启动tomcat的代码:
//tomcatembeddedservletcontainerfactory @override public embeddedservletcontainer getembeddedservletcontainer( servletcontextinitializer... initializers) { tomcat tomcat = new tomcat(); file basedir = (this.basedirectory != null ? this.basedirectory : createtempdir("tomcat")); tomcat.setbasedir(basedir.getabsolutepath()); connector connector = new connector(this.protocol); tomcat.getservice().addconnector(connector); customizeconnector(connector); tomcat.setconnector(connector); tomcat.gethost().setautodeploy(false); tomcat.getengine().setbackgroundprocessordelay(-1); for (connector additionalconnector : this.additionaltomcatconnectors) { tomcat.getservice().addconnector(additionalconnector); } preparecontext(tomcat.gethost(), initializers); return gettomcatembeddedservletcontainer(tomcat); }
会为tomcat创建一个临时文件目录,如:
/tmp/tomcat.2233614112516545210.8080,做为tomcat的basedir。里面会放tomcat的临时文件,比如work目录。
还会初始化tomcat的一些servlet,比如比较重要的default/jsp servlet:
private void adddefaultservlet(context context) { wrapper defaultservlet = context.createwrapper(); defaultservlet.setname("default"); defaultservlet.setservletclass("org.apache.catalina.servlets.defaultservlet"); defaultservlet.addinitparameter("debug", "0"); defaultservlet.addinitparameter("listings", "false"); defaultservlet.setloadonstartup(1); // otherwise the default location of a spring dispatcherservlet cannot be set defaultservlet.setoverridable(true); context.addchild(defaultservlet); context.addservletmapping("/", "default"); } private void addjspservlet(context context) { wrapper jspservlet = context.createwrapper(); jspservlet.setname("jsp"); jspservlet.setservletclass(getjspservletclassname()); jspservlet.addinitparameter("fork", "false"); jspservlet.setloadonstartup(3); context.addchild(jspservlet); context.addservletmapping("*.jsp", "jsp"); context.addservletmapping("*.jspx", "jsp"); }
spring boot的web应用如何访问resource
当spring boot应用被打包为一个fat jar时,是如何访问到web resource的?
实际上是通过archive提供的url,然后通过classloader提供的访问classpath resource的能力来实现的。
index.html
比如需要配置一个index.html,这个可以直接放在代码里的src/main/resources/static目录下。
对于index.html欢迎页,spring boot在初始化时,就会创建一个viewcontroller来处理:
//resourceproperties public class resourceproperties implements resourceloaderaware { private static final string[] servlet_resource_locations = { "/" }; private static final string[] classpath_resource_locations = { "classpath:/meta-inf/resources/", "classpath:/resources/", "classpath:/static/", "classpath:/public/" };
//webmvcautoconfigurationadapter @override public void addviewcontrollers(viewcontrollerregistry registry) { resource page = this.resourceproperties.getwelcomepage(); if (page != null) { logger.info("adding welcome page: " + page); registry.addviewcontroller("/").setviewname("forward:index.html"); } }
template
像页面模板文件可以放在src/main/resources/template目录下。但这个实际上是模板的实现类自己处理的。比如thymeleafproperties类里的:
public static final string default_prefix = "classpath:/templates/";
jsp
jsp页面和template类似。实际上是通过spring mvc内置的jstlview来处理的。
可以通过配置spring.view.prefix来设定jsp页面的目录:
spring.view.prefix: /web-inf/jsp/
spring boot里统一的错误页面的处理
对于错误页面,spring boot也是通过创建一个basicerrorcontroller来统一处理的。
@controller @requestmapping("${server.error.path:${error.path:/error}}") public class basicerrorcontroller extends abstracterrorcontroller
对应的view是一个简单的html提醒:
@configuration @conditionalonproperty(prefix = "server.error.whitelabel", name = "enabled", matchifmissing = true) @conditional(errortemplatemissingcondition.class) protected static class whitelabelerrorviewconfiguration { private final spelview defaulterrorview = new spelview( "<html><body><h1>whitelabel error page</h1>" + "<p>this application has no explicit mapping for /error, so you are seeing this as a fallback.</p>" + "<div id='created'>${timestamp}</div>" + "<div>there was an unexpected error (type=${error}, status=${status}).</div>" + "<div>${message}</div></body></html>"); @bean(name = "error") @conditionalonmissingbean(name = "error") public view defaulterrorview() { return this.defaulterrorview; }
spring boot的这个做法很好,避免了传统的web应用来出错时,默认抛出异常,容易泄密。
spring boot应用的maven打包过程
先通过maven-shade-plugin生成一个包含依赖的jar,再通过spring-boot-maven-plugin插件把spring boot loader相关的类,还有manifest.mf打包到jar里。
spring boot里有颜色日志的实现
当在shell里启动spring boot应用时,会发现它的logger输出是有颜色的,这个特性很有意思。
可以通过这个设置来关闭:
spring.output.ansi.enabled=false
原理是通过ansioutputapplicationlistener ,这个来获取这个配置,然后设置logback在输出时,加了一个 colorconverter,通过org.springframework.boot.ansi.ansioutput ,对一些字段进行了渲染。
一些代码小技巧
实现classloader时,支持jdk7并行加载
可以参考launchedurlclassloader里的lockprovider
public class launchedurlclassloader extends urlclassloader { private static lockprovider lock_provider = setuplockprovider(); private static lockprovider setuplockprovider() { try { classloader.registerasparallelcapable(); return new java7lockprovider(); } catch (nosuchmethoderror ex) { return new lockprovider(); } } @override protected class<?> loadclass(string name, boolean resolve) throws classnotfoundexception { synchronized (launchedurlclassloader.lock_provider.getlock(this, name)) { class<?> loadedclass = findloadedclass(name); if (loadedclass == null) { handler.setusefastconnectionexceptions(true); try { loadedclass = doloadclass(name); } finally { handler.setusefastconnectionexceptions(false); } } if (resolve) { resolveclass(loadedclass); } return loadedclass; } }
检测jar包是否通过agent加载的
inputargumentsjavaagentdetector,原理是检测jar的url是否有”-javaagent:”的前缀。
private static final string java_agent_prefix = "-javaagent:";
获取进程的pid
applicationpid,可以获取pid。
private string getpid() { try { string jvmname = managementfactory.getruntimemxbean().getname(); return jvmname.split("@")[0]; } catch (throwable ex) { return null; } }
包装logger类
spring boot里自己包装了一套logger,支持java, log4j, log4j2, logback,以后有需要自己包装logger时,可以参考这个。
在org.springframework.boot.logging包下面。
获取原始启动的main函数
通过堆栈里获取的方式,判断main函数,找到原始启动的main函数。
private class<?> deducemainapplicationclass() { try { stacktraceelement[] stacktrace = new runtimeexception().getstacktrace(); for (stacktraceelement stacktraceelement : stacktrace) { if ("main".equals(stacktraceelement.getmethodname())) { return class.forname(stacktraceelement.getclassname()); } } } catch (classnotfoundexception ex) { // swallow and continue } return null; }
spirng boot的一些缺点:
当spring boot应用以一个fat jar方式运行时,会遇到一些问题。以下是个人看法:
- 日志不知道放哪,默认是输出到stdout的
- 数据目录不知道放哪, jenkinns的做法是放到 ${user.home}/.jenkins 下面
- 相对目录api不能使用,servletcontext.getrealpath(“/“) 返回的是null
- spring boot应用喜欢把配置都写到代码里,有时会带来混乱。一些简单可以用xml来表达的配置可能会变得难读,而且凌乱。
总结
spring boot通过扩展了jar协议,抽象出archive概念,和配套的jarfile,jarurlconnection,launchedurlclassloader,从而实现了上层应用无感知的all in one的开发体验。尽管executable war并不是spring提出的概念,但spring boot让它发扬光大。
spring boot是一个惊人的项目,可以说是spring的第二春,spring-cloud-config, spring-session, metrics, remote shell等都是深爱开发者喜爱的项目、特性。几乎可以肯定设计者是有丰富的一线开发经验,深知开发人员的痛点。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。