当前位置: 移动技术网 > IT编程>开发语言>Java > 详解spring boot应用启动原理分析

详解spring boot应用启动原理分析

2019年07月19日  | 移动技术网IT编程  | 我要评论

前言

本文分析的是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时,通常会有这些疑问

  1. spring boot如何启动的?
  2. 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的概念

  1. archive即归档文件,这个概念在linux下比较常见
  2. 通常就是一个tar/zip格式的压缩包
  3. 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应用的启动流程:

  1. spring boot应用打包之后,生成一个fat jar,里面包含了应用依赖的jar包,还有spring boot loader相关的类
  2. fat jar的启动main函数是jarlauncher,它负责创建一个launchedurlclassloader来加载/lib下面的jar,并以一个新线程启动应用的main函数。

spring boot loader里的细节

代码地址:

jarfile url的扩展

spring boot能做到以一个fat jar来启动,最重要的一点是它实现了jar in jar的加载方式。

jdk原始的jarfile url的定义可以参考这里:

原始的jarfile url是这样子的:

jar:file:/tmp/target/demo-0.0.1-snapshot.jar!/

jar包里的资源的url:

复制代码 代码如下:
jar:file:/tmp/target/demo-0.0.1-snapshot.jar!/com/example/springbootdemoapplication.class

可以看到对于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:

复制代码 代码如下:
jar:file:/tmp/target/demo-0.0.1-snapshot.jar!/lib/spring-beans-4.2.3.release.jar!/meta-inf/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

参考:
https://docs.oracle.com/javase/8/docs/api/java/net/url.html#url-java.lang.string-java.lang.string-int-java.lang.string-

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。

复制代码 代码如下:
jar:file:/tmp/target/demo-0.0.1-snapshot.jar!/lib/spring-beans-4.2.3.release.jar!/meta-inf/manifest.mf

//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,它需要哪些能力?

  1. 查找资源
  2. 读取资源

对应的api是:

public url findresource(string name)
public inputstream getresourceasstream(string name)

上面提到,spring boot构造launchedurlclassloader时,传递了一个url[]数组。数组里是lib目录下面的jar的url。

对于一个url,jdk或者classloader如何知道怎么读取到里面的内容的?

实际上流程是这样子的:

  1. launchedurlclassloader.loadclass
  2. url.getcontent()
  3. url.openconnection()
  4. 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里的内容,整个过程是比较复杂的,总结下:

  1. spring boot注册了一个handler来处理”jar:”这种协议的url
  2. spring boot扩展了jarfile和jarurlconnection,内部处理jar in jar的情况
  3. 在处理多重jar in jar的url时,spring boot会循环处理,并缓存已经加载到的jarfile
  4. 对于多重jar in jar,实际上是解压到了临时目录来处理,可以参考jarfilearchive里的代码
  5. 在获取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里没有自带,在这里看到

在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的能力来实现的。


比如需要配置一个,这个可以直接放在代码里的src/main/resources/static目录下。

对于欢迎页,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:");
 }
 }

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方式运行时,会遇到一些问题。以下是个人看法:

  1. 日志不知道放哪,默认是输出到stdout的
  2. 数据目录不知道放哪, jenkinns的做法是放到 ${user.home}/.jenkins 下面
  3. 相对目录api不能使用,servletcontext.getrealpath(“/“) 返回的是null
  4. 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等都是深爱开发者喜爱的项目、特性。几乎可以肯定设计者是有丰富的一线开发经验,深知开发人员的痛点。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持移动技术网。

如对本文有疑问, 点击进行留言回复!!

相关文章:

验证码:
移动技术网