spring mvc之WebApplicationContext的继承关系

spring mvc里的root/child WebApplicationContext的继承关系

在传统的spring mvc程序里会有两个WebApplicationContext,一个是parent,从applicationContext.xml里加载的,一个是child,从servlet-context.xml里加载的。
两者是继承关系,child WebApplicationContext 可以通过getParent()函数获取到root WebApplicationContext。

简单地说child WebApplicationContext里的bean可以注入root WebApplicationContext里的bean,而parent WebApplicationContext的bean则不能注入chile WebApplicationContext里的bean。

一个典型的web.xml的内容是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<!– The definition of the Root Spring Container shared by all Servlets and Filters –>
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath*:/applicationContext.xml</param-value>
</context-param>

<!– Creates the Spring Container shared by all Servlets and Filters –>
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>

<!– Processes application requests –>
<servlet>
<servlet-name>appServlet</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>/WEB-INF/servlet-context.xml</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
<async-supported>true</async-supported>
</servlet>

<servlet-mapping>
<servlet-name>appServlet</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>

其中root WebApplicationContext是通过listener初始化的,child WebApplicationContext是通过servlet初始化的。

而在applicationContext.xml里通常只component-scan非Controller的类,如:

1
2
3
4
5
6
<context:component-scan base-package=”io.github.test”>
<context:exclude-filter expression=”org.springframework.stereotype.Controller”
type=”annotation” />
<context:exclude-filter type=”annotation”
expression=”org.springframework.web.bind.annotation.ControllerAdvice” />
</context:component-scan>

servlet-context.xml里通常只component-scan Controller类,如:

1
2
3
4
5
6
<context:component-scan base-package=”io.github.test.web” use-default-filters=”false”>
<context:include-filter expression=”org.springframework.stereotype.Controller”
type=”annotation” />
<context:include-filter type=”annotation”
expression=”org.springframework.web.bind.annotation.ControllerAdvice” />
</context:component-scan>

如果不这样子分别component-scan的话,可能会出现Bean重复初始化的问题。

上面是Spring官方开始时推荐的做法。

root/child WebApplicationContext继承关系带来的麻烦

root WebApplicationContext里的bean可以在不同的child WebApplicationContext里共享,而不同的child WebApplicationContext里的bean区不干扰,这个本来是个很好的设计。

但是实际上有会不少的问题:

  • 不少开发者不知道Spring mvc里分有两个WebApplicationContext,导致各种重复构造bean,各种bean无法注入的问题。
  • 有一些bean,比如全局的aop处理的类,如果先root WebApplicationContext里初始化了,那么child WebApplicationContext里的初始化的bean就没有处理到。如果在chile WebApplicationContext里初始化,在root WebApplicationContext里的类就没有办法注入了。
  • 区分哪些bean放在root/child很麻烦,不小心容易搞错,而且费心思。

一劳永逸的解决办法:bean都由root WebApplicationContext加载

在一次配置metrics-spring时,对配置@EnableMetrics配置在哪个WebApplicationContext里,感到很蛋疼。最终决定试下把所有的bean,包括Controller都移到root WebApplicationContext,即applicationContext.xml里加载,而servlet-context.xml里基本是空的。结果发现程序运行完全没问题。

后面在网上搜索了下,发现有一些相关的讨论:

http://forum.spring.io/forum/spring-projects/container/89149-servlet-context-vs-application-context

spring boot里的做法

在spring boot里默认情况下不需要component-scan的配置,于是猜测在Spring boot里是不是只有一个WebApplicationContext?

后面测试下了,发现在spring boot里默认情况下的确是只有一个WebApplicationContext:

org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext

所以在spring boot里省事了很多。

总结

spring 的ApplicationContext继承机制是一个很好的设计,在很多其它地方都可以看到类似的思路,比如Java的class loader。但是在大部分spring web程序里,实际上只要一个WebApplicationContext就够了。如果分开rott/child WebApplicationContext会导致混乱,而没什么用。

所以推荐把所有的Service/Controller都移到root WebApplicationContext中初始化。

Spring Cloud 接入 EDAS 之服务发现篇(转)

目前 EDAS 已经完全支持 Spring Cloud 应用的部署了,您可以直接将
Spring Cloud 应用部署到 EDAS 中。
同时,为了更好地将阿里中间件的功能以云服务的方式提供给大家,我们也对 Spring Cloud 中的一些组件进行了加强或替换的工作。
让我们先来聊聊服务发现。 
我们知道原生的 Spring Cloud 支持多种服务注册与发现的方式,Eureka 、 Consul 、 Zookeeper 等,目前使用最多最广的就是 Eureka了,那我们就先从一个简单的 Eureka Demo 说起。

Eureka Demo

创建服务注册中心

创建一个基础的 Spring Cloud 工程,命名为 eureka-server,并在 pom.xml 中引入需要的依赖内容:

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>1.5.8.RELEASE</version>
        <relativePath/>
    </parent>


    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-eureka-server</artifactId>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>Dalston.SR4</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

通过 @EnableEurekaServer 注解来启动一个服务注册中心。只需要在一个普通的 Spring Boot 应用中添加这个注解就能开启此功能,代码如下:

    @SpringBootApplication
    @EnableEurekaServer
    public class EurekaServerApplication {
        public static void main(String[] args) {
            SpringApplication.run(EurekaServerApplication.class, args);
        }
    }

这样启动时,应用将完全只用默认配置,如果想给服务命名,或者是修改监听端口,可以在 resource/application.properties 中进行如下配置。 
由于此工程就是唯一的一个 EurekaServer ,这里就不向自己注册自己了,将 register-with-eureka 设置成 false。

spring.application.name=eureka-server
server.port=8761
eureka.client.register-with-eureka=false 

只需要直接运行 EurekaServerApplication 的 main 函数,eureka server 即可启动成功。 
启动成功后,可以在 http://localhost:8761 页面查看详情。

5a7b6781-f069-46b0-8df0-ab07ae4ca490.png | center

页面打开成功,表明服务已经启动,目前 instances 为空,表明还没有服务注册上来。

创建服务提供者

创建一个 Spring Cloud 工程,命名为 service-provider。同样,首先在 pom.xml 中引入需要的依赖内容:

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>1.5.8.RELEASE</version>
        <relativePath/>
    </parent>


    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-eureka</artifactId>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>Dalston.SR4</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

接着是服务提供端的代码,其中 @EnableDiscoveryClient 注解表明此应用需开启服务注册与发现功能。

@SpringBootApplication
@EnableDiscoveryClient
public class ServerApplication {

    public static void main(String[] args) {
        SpringApplication.run(ServerApplication.class, args);
    }
}

既然是服务提供者,所以我们还需要提供一个简单的服务

@RestController
public class EchoController {
    @RequestMapping(value = "/echo/{string}", method = RequestMethod.GET)
    public String echo(@PathVariable String string) {
        return string;
    }
}

最后同样是配置,除去配置应用名与监听端口外,还需要配置一下 Eureka Server 的地址。

spring.application.name=service-provider
server.port=18081
eureka.client.serviceUrl.defaultZone=http://localhost:8761/eureka/

启动 service-provider 服务,在 Eureka 页面查看服务是否已经注册成功。

ab52a743-9e67-4be7-8c75-aa1bce6c9471.png | center

可以看到 instances 中已经存在的实例 service-provider,端口是 18081。

创建服务消费者

这个例子中,我们将不仅仅是演示服务发现的功能,同时还将演示 Eureka 服务发现 与 RestTemplate、AsyncRestTemplate、FeignClient这三个客户端是如何结合的。因为实际使用中,我们更多使用的是用这三个客户端进行服务调用。

创建一个 Spring Cloud 工程,命名为 service-consumer。同样,首先在 pom.xml 中引入需要的依赖内容:

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>1.5.8.RELEASE</version>
        <relativePath/>
    </parent>


    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-eureka</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-feign</artifactId>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>Dalston.SR4</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

因为在这里我们要演示 FeignClient 的使用,所以与 service-provider 相比,pom.xml文件中的依赖增加了一个 spring-cloud-starter-feign。

配置好依赖后,首先在启动函数里完成三件事:
使用 @EnableDiscoveryClient 注解启用服务注册与发现;使用 @EnableFeignClients 注解激活 FeignClients;添加 @LoadBalanced 注解将 RestTemplate 与 AsyncRestTemplate 与服务发现结合。

@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
public class ConsumerApplication {

    @LoadBalanced
    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }

    @LoadBalanced
    @Bean
    public AsyncRestTemplate asyncRestTemplate(){
        return new AsyncRestTemplate();
    }
    
    public static void main(String[] args) {
        SpringApplication.run(ConsumerApplication.class, args);
    }

}

在使用 FeignClient 之前,我们还需要完善它的配置,配置服务名以及方法对应的HTTP请求,其中代码如下

@FeignClient(name = "service-provider")
public interface EchoService {
    @RequestMapping(value = "/echo/{str}", method = RequestMethod.GET)
    String echo(@PathVariable("str") String str);
}

然后,我们就可以在 Controller 中直接使用他们。

@RestController
public class Controller {

    @Autowired
    private RestTemplate restTemplate;
    @Autowired
    private AsyncRestTemplate asyncRestTemplate;
    @Autowired
    private  EchoService echoService;

    @RequestMapping(value = "/echo-rest/{str}", method = RequestMethod.GET)
    public String rest(@PathVariable String str) {
        return restTemplate.getForObject("http://service-provider/echo/" + str, String.class);
    }
    @RequestMapping(value = "/echo-async-rest/{str}", method = RequestMethod.GET)
    public String asyncRest(@PathVariable String str) throws Exception{
        ListenableFuture<ResponseEntity<String>> future = asyncRestTemplate.
                getForEntity("http://service-provider/echo/"+str, String.class);
        return future.get().getBody();
    }
    @RequestMapping(value = "/echo-feign/{str}", method = RequestMethod.GET)
    public String feign(@PathVariable String str) {
        return echoService.echo(str);
    }

}

最后,还是不能忘了配置,特别是服务注册中心的地址。

spring.application.name=service-consumer
server.port=18082
eureka.client.serviceUrl.defaultZone=http://localhost:8761/eureka/

启动服务,分别进行调用,可以看到调用都成功了。

32b244d5-53c6-426c-9edd-13414c8fd91f.png | center

注意:AsyncRestTemplate 接入 服务发现的时间比较晚,需要在 Dalston 之后的版本才能使用,具体详情参见此 pull request

Eureka 的烦恼

前面的例子在本机工作起来是很方便的,但是很遗憾,这只是一个 demo ,实际部署中我们可能都踩过坑或者有这么一些不爽。

  • 只有一个服务注册中心,显然这不符合高可用的原则,高可用就得增加
    eureka server 的数量,维护成本太高了。
  • 实际生产中,不会将服务注册中心与业务服务部署在同一台机器上。实际部署中,当 eureka server 的地址发生变化时,还得修改配置文件里 eureka server的地址,太麻烦了。
  • 实际使用中,服务注册发现中心的安全性也是需要考虑的,应该对服务注册和发现的请求进行鉴权,来确保服务的安全性,安全也是急需解决的问题。
  • eureka 使用过程中,有可能出现注册上去的服务地址不是一个 ip ,而是一个 hostname 的情况,事实上又无法通过 hostname 进行服务调用。其实只是因为没有增加 eureka.instance.prefer-ip-address=true这个配置,依旧需要添加配置。
  • eureka 因为缓存设计的原因,使得服务注册上去之后,最迟需要两分钟后才能发现。

或许你希望有人提供一个 安全、稳定、高可用、高性能、简单易用的服务注册中心。 
然后,你也不想配置那么一大堆地址了 
最后,你甚至也不想修改原有已经接入 Eureka 的代码

是的,EDAS 服务注册中心,就是这样一个解决方案。 
只需要修改两行代码以及 pom 依赖,无缝将服务注册中心从 Eureka 切换到 EDAS 服务注册中心。 
你将得到

  • 稳定高可用的服务注册中心
  • 安全的服务注册、服务发现
  • 秒级的服务发现机制
  • 无需再关心服务注册中心的地址

EDAS 服务注册中心

服务注册中心

EDAS 自身维护了服务注册中心,如果需要本地搭建,请参考 轻量级配置中心

如何接入

源码的修改,只有两行,需要在 main 函数中添加两行,修改之后的 service-provider 的 main 函数如下。

public static void main(String[] args) {
    PandoraBootstrap.run(args);
    SpringApplication.run(ServerApplication.class, args);
    PandoraBootstrap.markStartupAndWait();
}

pom.xml 的修改有两点 
首先就是将原来的 eureka 的 starter 替换成 EDAS 服务注册中心的starter,并加入 pandora 的依赖。 
修改之后的 service-provider 的 dependencies 依赖如下。

<dependencies>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-vipclient</artifactId>
        <version>1.1</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-pandora</artifactId>
        <version>1.2</version>
    </dependency>
</dependencies>

在 build 的 plugins 中,也需要修改成 EDAS 的方式,修改后的内容如下,版本号后续可能会升级。

<build>
    <plugins>
        <plugin>
            <groupId>com.taobao.pandora</groupId>
            <artifactId>pandora-boot-maven-plugin</artifactId>
            <version>2.1.7.8</version>
            <executions>
                <execution>
                    <phase>package</phase>
                    <goals>
                        <goal>repackage</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

service-consumer 的修改方式与 service-provider 的修改方式完全一样。

注意 由于目前 spring cloud for aliware 尚未进入中央仓库,需要配置 maven 的私服地址,配置详情参考 私服配置

使用方式

使用方式?使用方式已经在接入方式里了,其他方式和原生的完全一样。 
如果你习惯了 Eureka 页面查看服务状态的方式,EDAS 控制台同样也提供了相关的功能。

本地调试

本地调试时,需要下载轻量级配置中心,并将其启动,详情参见 轻量级配置中心

最后在应用的启动时,配置 JVM 参数,配置如下。

-Dvipserver.server.port=8080 

Demo 下载

server-demo

client-demo

工作原理

换了一个依赖就把 Eureka 替换成了 EDAS 服务注册中心,虽然方便,但是这对于你来说也许相当于是一个黑盒,黑盒总是让人很没有安全感。 
下面我们将从 服务注册中心寻址、服务注册与下线、客户端结合、高可用、安全等多个方面来分析原理。

服务注册中心寻址

既然不需要在配置文件里配置服务注册中心的地址了,那么客户端是如何找到服务中心的呢? 
其实是通过一个 http 请求来实现的,http://jmenv.tbsite.net/vipserver/serverlist
不仅仅是客户端,服务端也是通过这个地址来互相发现的。 在 EDAS 的机器上, jmenv.tbsite.net 是自动配置的。
如果是使用轻量级配置中心做本地的开发调试,还需要做一点额外配置,即 -Dvipserver.server.port=8080,如果你的轻量级配置中心与应用部署在不同的机器上,还需进行 hosts 绑定,详情见 轻量级配置中心

服务注册与下线

服务注册的通信协议是 HTTP 协议,默认注册的应用名是 spring.application.name ,如果有需要将某个应用发布成多个服务名的话,可以试试在 /resource/application.properties 中配置 vipserver.register.doms 的方式来实现,多个服务名中间用英文逗号 , 隔开。 如vipserver.register.doms=service1,service2

服务注册成功后,client 端将会主动向 server 端发送心跳,当超过一定时间内 server 端没有收到 client 端的心跳时,会将服务标记成不可用,这样其他 client 在查询时就能发现此服务当前处于不可用的状态。 
如果短时间内,大量 client 与 server 心跳失败,则会出发降级保护机制,服务会暂时不被标记成不可用的状态。

客户端结合

与客户端结合的方式, EDAS 服务发现组件与 Eureka 是完全一致的。 
对于 RestTemplate 和 AsyncRestTemplate 来说,添加上 @LoadBalanced 注解,即可直接接入服务发现以及负载均衡。 
添加了此注解后,他们将分别会被添加 LoadBalancerInterceptor 和 AsyncLoadBalancerInterceptor 这两个拦截器。 
执行的过程中,所有请求都会分别被这两个 Interceptor 所拦截,通过其所持有的 LoadBalancerClient 对象去执行这个请求。 
而这个 LoadBalancerClient 对象,其实就是 RibbonLoadBalancerClient 的一个实例,在其源码中,execute 方法的执行逻辑如下

    ILoadBalancer loadBalancer = getLoadBalancer(serviceId);
    Server server = getServer(loadBalancer);

首先会拿到一个 ILoadBalancer 对象,然后再通过这个 loadBalancer 对象去拿到真正需要调用的服务的地址。 
ILoadBalancer有这么几个实现类,BaseLoadBalancer、DynamicServerListLoadBalancer、ZoneAwareLoadBalancer等。

看看这个类RibbonClientConfiguration,默认注入的是这个,ZoneAwareLoadBalancer

    @Bean
    @ConditionalOnMissingBean
    public ILoadBalancer ribbonLoadBalancer(IClientConfig config,
            ServerList<Server> serverList, ServerListFilter<Server> serverListFilter,
            IRule rule, IPing ping, ServerListUpdater serverListUpdater) {
        if (this.propertiesFactory.isSet(ILoadBalancer.class, name)) {
            return this.propertiesFactory.get(ILoadBalancer.class, config, name);
        }
        return new ZoneAwareLoadBalancer<>(config, rule, ping, serverList,
                serverListFilter, serverListUpdater);
    }

关于ServerList的来源,我们看这个类 EurekaRibbonClientConfiguration

    @Bean
    @ConditionalOnMissingBean
    public ServerList<?> ribbonServerList(IClientConfig config, 
                                          Provider<EurekaClient> eurekaClientProvider) {
        if (this.propertiesFactory.isSet(ServerList.class, serviceId)) {
            return this.propertiesFactory.get(ServerList.class, config, serviceId);
        }
        DiscoveryEnabledNIWSServerList discoveryServerList = new DiscoveryEnabledNIWSServerList(
                config, eurekaClientProvider);
        DomainExtractingServerList serverList = new DomainExtractingServerList(
                discoveryServerList, config, this.approximateZoneFromHostname);
        return serverList;
    }

可以看到,是通过 EurekeClient 来维护实例的地址列表的。

目前的植入做的很简单,单纯地注入了上文中提到的两个 Bean。

    @Bean
    @ConditionalOnMissingBean
    public ServerList<Server> ribbonServerList(IClientConfig config) {
        return new VipserverList(config.getClientName());
    }

    @Bean
    @ConditionalOnMissingBean
    public ILoadBalancer ribbonLoadBalancer(IClientConfig config,
                                            ServerList<Server> serverList, ServerListFilter<Server> serverListFilter,
                                            IRule rule, IPing ping) {
        return new DynamicServerListLoadBalancer<Server>(config, rule, ping, serverList, serverListFilter,
                new PollingServerListUpdater(1000L, 1000L));
    }

高可用实现

服务端高可用

  • eureka 
    Eureka的多个server 是对等的实体,在 CAP 中选择了 AP。

节点间的数据使用的是最终一致性,eureka 会将注册的信息同步到 peer 节点,但是 peer 节点不会二次传播。 
peer节点需要显示地在配置中设置。如果 peer 节点配置的不全,那么集群的概念也不存在了,节点之间的关系是通过 peer 节点的显示配置来维护的。

  • EDAS 服务注册发现组件 
    EDAS 服务注册中心的多个 server,存在主从,各节点之间使用 raft 协议保证一致性。

server 之间的互相感知是通过访问 http://jmenv.tbsite.net/vipserver/serverlist 来获取其他 peer 节点地址来实现的。 
然后通过自定义的端口和协议来进行选举和数据同步等操作。CAP 中选择的是 CP。

客户端高可用

  • eureka 
    通过本地缓存来实现,当 server 连接不上时,直接使用本地缓存。每 30s 异步更新一次缓存,避免了每次请求都强依赖于服务注册中心。
  • EDAS 服务注册发现组件 
    通过本地缓存来实现,当 server 连接不上时,直接使用本地缓存。异步更新缓存,避免了每次请求都强依赖于服务注册中心。同时,还提供了通过 UDP 主动 push 的方式在新服务节点加入时及时通知。

安全的实现

EDAS 服务注册发现组件,结合 EDAS 已有的安全功能,在每次注册、心跳和查询请求中都添加了验签鉴权的操作,保护了服务的安全性。