《Spring实战》是学习Spring框架的一本非常经典的书籍,之前阅读了这本书,只是在书本上写写画画,最近整理了一下《Spring实战》的读书笔记,通过博客的方式进行记录分享。俗话说,好记性不如烂笔头,把学习到的知识记录下来,方便温故知新,让更多的读者可以学习到有关Spring框架的知识。
序号
内容
链接地址
1
《Spring实战》读书笔记-第1章 Spring之旅
https://blog.csdn.net/ThinkWon/article/details/103097364
2
《Spring实战》读书笔记-第2章 装配Bean
https://blog.csdn.net/ThinkWon/article/details/103527675
3
《Spring实战》读书笔记-第3章 高级装配
https://blog.csdn.net/ThinkWon/article/details/103536621
4
《Spring实战》读书笔记-第4章 面向切面的Spring
https://blog.csdn.net/ThinkWon/article/details/103541166
5
《Spring实战》读书笔记-第5章 构建Spring Web应用程序
https://blog.csdn.net/ThinkWon/article/details/103550083
6
《Spring实战》读书笔记-第6章 渲染Web视图
https://blog.csdn.net/ThinkWon/article/details/103559672
7
《Spring实战》读书笔记-第7章 Spring MVC的高级技术
https://blog.csdn.net/ThinkWon/article/details/103562467
文章目录3.1 环境与profile3.2 条件化的bean3.3 处理自动装配的歧义性3.4 Bean的作用域3.5 运行时植注入3.6 小结本章内容:
Spring profile条件化的bean声明自动装配与歧义性bean的作用域Spring表达式语言在上一章中,我们看到了一些最为核心的bean装配技术。你可能会发现上一章学到的知识有很大的用处。但是,bean装配所涉及的领域并不仅仅局限于上一章 所学习到的内容。Spring提供了多种技巧,借助它们可以实现更为高级的bean装配功能。
在本章中,我们将会深入介绍一些这样的高级技术。本章中所介绍的技术也许你不会天天都用到,但这并不意味着它们的价值会因此而降低。
3.1 环境与profile在开发软件的时候,有一个很大的挑战就是将应用程序从一个环境迁移到另外一个环境。开发阶段中,某些环境相关做法可能并不适合迁移到生产环境中,甚至即便迁移过去也无法正常工作。数据库配置、加密算法以及与外部系统的集成是跨环境部署时会发生变化的几个典型例子。
比如,考虑一下数据库配置。在开发环境中,我们可能会使用嵌入式数据库,并预先加载测试数据。
数据源的有三种连接配置,分别是
// 通过EmbeddedDatabaseBuilder会搭建一个嵌入式的Hypersonic的数据库 @Bean(destroyMethod = "shutdown") @Profile("dev") public DataSource embeddedDataSource() { return new EmbeddedDatabaseBuilder() .setType(EmbeddedDatabaseType.H2) .addScript("classpath:schema.sql") .addScript("classpath:test-data.sql") .build(); } // 通过JNDI获取DataSource能够让容器决定该如何创建这个DataSource @Bean @Profile("prod") public DataSource jndiDataSource() { JndiObjectFactoryBean jndiObjectFactoryBean = new JndiObjectFactoryBean(); jndiObjectFactoryBean.setJndiName("jdbc/myDS"); jndiObjectFactoryBean.setResourceRef(true); jndiObjectFactoryBean.setProxyInterface(javax.sql.DataSource.class); return (DataSource) jndiObjectFactoryBean.getObject(); } // 还可以配置为Commons DBCP连接池,BasicDataSource可替换为阿里的DruidDataSource连接池 @Bean(destroyMethod = "close") @Profile("qa") public DataSource datasource(){ BasicDataSource datasource = new BasicDataSource(); datasource.setUrl("jdbc:h2:tcp://dbserver/~/test"); datasource.setDriverClassName("org.h2.Driver"); datasource.setUsername("sa"); datasource.setPassword("password"); datasource.setInitialSize(20); datasource.setMaxActive(30); return dataSource; }Spring为环境相关的bean所提供的解决方案不是在构建的时候做出决定,而是等待运行时再来确定。Spring引入了bean的profile的功能,在每个数据库连接配置的bean上添加@Profile,指定这个bean属于哪一个profile。 Spring3.1需要将@Profile指定在配置类上,Spring3.2就可以指定在方法上了。
我们也可以在XML中通过<bean>元素的profile属性指定。例如:
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:jdbc="http://www.springframework.org/schema/jdbc" xmlns:jee="http://www.springframework.org/schema/jee" xmlns:p="http://www.springframework.org/schema/p" xsi:schemaLocation=" http://www.springframework.org/schema/jee http://www.springframework.org/schema/jee/spring-jee.xsd http://www.springframework.org/schema/jdbc http://www.springframework.org/schema/jdbc/spring-jdbc.xsd http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd"> <beans profile="dev"> <jdbc:embedded-database id="dataSource" type="H2"> <jdbc:script location="classpath:schema.sql" /> <jdbc:script location="classpath:test-data.sql" /> </jdbc:embedded-database> </beans> <beans profile="prod"> <jee:jndi-lookup id="dataSource" lazy-init="true" jndi-name="jdbc/myDatabase" resource-ref="true" proxy-interface="javax.sql.DataSource" /> </beans> </beans>下一步就是激活某个profile
Spring在确定哪个profile处于激活状态时,需要依赖两个独立的属性:spring.profiles.active和spring.profiles.default。如果设置了spring.profiles.active属性的话,那么它的值就会用来确定哪个profile是激活的,但如果没有设置spring.profiles.active属性的话,那Spring将会查找spring.profiles.default的值。如果spring.profiles.active和spring.profiles.default均没有设置的话,那就没有激活的profile,因此只会创建那些没有定义在profile中的bean。
有多种方式来设置这两个属性:
作为DispatcherServlet的初始化参数;作为Web应用的上下文参数;作为JNDI条目;作为环境变量;作为JVM的系统属性;在集成测试类上,使用@ActiveProfiles注解设置。例如,在web应用中,设置spring.profiles.default的web.xml文件会如下所示:
<?xml version="1.0" encoding="UTF-8"?> <web-app version="2.5" xmlns="http://java.sun.com/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd"> <context-param> <param-name>contextConfigLocation</param-name> <param-value>/WEB-INF/spring/root-context.xml</param-value> </context-param> <!--为上下文设置默认的profile--> <context-param> <param-name>spring.profiles.default</param-name> <param-value>dev</param-value> </context-param> <listener> <listener-class> org.springframework.web.context.ContextLoaderListener </listener-class> </listener> <servlet> <servlet-name>appServlet</servlet-name> <servlet-class> org.springframework.web.servlet.DispatcherServlet </servlet-class> <init-param> <!--为Servlet设置默认的profile--> <param-name>spring.profiles.default</param-name> <param-value>dev</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>appServlet</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> </web-app>3.2 条件化的beanSpring4实现了条件化配置,需要引入@Conditional(可以用到带有@bean注解的方法上)注解。如果给定条件为true,则创建这个bean,反之,不创建。
例如:
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; @Configuration public class MagicConfig { @Bean @Conditional(MagicExistsCondition.class) // 条件化创建bean public MagicBean magicBean() { return new MagicBean(); } }@Conditional中给定了一个Class,它指明了条件——本例中是MagicExistsCondition。@Conditional将会通过Condition接口进行条件对比:
public interface Condition { boolean matches(ConditionContext ctxt AnnotatedTypeMetadata metadata); }接下来是MagicExistsCondition的实现类:
import org.springframework.context.annotation.Condition; import org.springframework.context.annotation.ConditionContext; import org.springframework.core.env.Environment; import org.springframework.core.type.AnnotatedTypeMetadata; public class MagicExistsCondition implements Condition { @Override public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { Environment env = context.getEnvironment(); // 根据环境中是否存在magic属性来决策是否创建MagicBean return env.containsProperty("magic"); } }ConditionContext是一个接口,大致如下所示:
public interface ConditionContext { BeanDefinitionRegistry getRegistry(); ConfigurableListableBeanFactory getBeanFactory(); Environment getEnvironment(); ResourceLoader getResourceLoader(); ClassLoader getClassLoader(); }ConditionContext实现的考量因素可能会更多,通过ConditionContext,我们可以做到如下几点:
借助getRegistry() 返回的BeanDefinitionRegistry检查bean定义;借助getBeanFactory() 返回的ConfigurableListableBeanFactory检查bean是否存在,甚至探查bean的属性;借助getEnvironment() 返回的Environment检查环境变量是否存在以及它的值是什么;读取并探查getResourceLoader() 返回的ResourceLoader所加载的资源。借助getClassLoader() 返回的ClassLoader加载并检查类是否存在。AnnotatedTypeMetadata则能够让我们检查带有@Bean注解的方法上还有什么其他的注解,它也是一个接口,如下所示:
public interface AnnotatedTypeMeta { boolean isAnnotated(String annotationType); Map<String, Object> getAnnotationAttributes(String annotationType); Map<String, Object> getAnnotationAttributes(String annotationType, boolean classValuesAsString); MultiValueMap<String, Object> getAllAnnotationAttributes(String annotationType); MultiValueMap<String, Object> getAllAnnotationAttributes(String annotationType, boolean classValuesAsString); }借助isAnnotated()方法,能够判断带有@Bean注解的方法是不是还有其他特定的注解。
3.3 处理自动装配的歧义性当自动装配bean时,遇到多个实现类的情况下,就出现了歧义,例如:
@Autowired public void setDessert(Dessert dessert) { this.dessert = dessert; }Dessert是一个接口,并且有三个类实现了这个接口,如下所示:
@Component public class Cake implements Dessert { ... } @Component public class Cookies implements Dessert { ... } @Component public class IceCream implements Dessert { ... }三个实现均使用了@Component,在组件扫描时,能够创建它们的bean。但Spring试图自动装配setDessert()中的Dessert参数是,它并没有唯一、无歧义的可选值,Spring无法做出选择,则会抛出NoUniqueBeanDefinitionException的异常。
两种解决办法:
第一种方法:标示首选的bean
如下所示:
@Component @Primary public class IceCream implements Dessert { ... }或者,如果通过JavaConfig配置,如下:
@Bean @Primary public Dessert iceCream() { return new IceCream(); }或者,使用XML配置bean的话,如下:
<bean id="iceCream" class="com.desserteater.IceCream" primary="true" />需要注意的是:不能标示两个或更多的首选bean,这样会引来新的歧义。
第二种方法:限定自动装配的bean
如下所示:
@Autowired @Qualifier("iceCream") public void setDessert(Dessert dessert) { this.dessert = dessert; }如果不想用默认的bean的名称,也可以创建自定义的限定符
@Component @Qualifier("cold") public class IceCream implements Dessert { ... } @Autowired @Qualifier("cold") public void setDessert(Dessert dessert) { this.dessert = dessert; }或者使用JavaConfig配置
@Bean @Qualifier("cold") public Dessert iceCream() { return new IceCream(); }如果出现多个Qualifier,尝试为bean也标示多个不同的Qualifier来表明要注入的bean。
@Component @Qualifier("cold") @Qualifier("creamy") public class IceCream implements Dessert { ... } @Component @Qualifier("cold") @Qualifier("fruity") public class Popsicle implements Dessert { ... } @Autowired @Qualifier("cold") @Qualifier("creamy") public void setDessert(Dessert dessert) { this.dessert = dessert; }但有个问题,Java不允许在同一个条目上重复出现相同类型的注解,编译器会提示错误。
解决办法是我们可以自定义注解:
@Targe({ElementType.CONSTRUCTOR, ElementType.FIELD, ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Qualifier public @interface Cold { } @Targe({ElementType.CONSTRUCTOR, ElementType.FIELD, ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Qualifier public @interface Creamy { } @Targe({ElementType.CONSTRUCTOR, ElementType.FIELD, ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Qualifier public @interface Fruity { }重新标注IceCream
@Component @Cold @Creamy public class IceCream implements Dessert { ... } @Component @Cold @Fruity public class Popsicle implements Dessert { ... }注入setDessert() 方法
@Autowired @Cold @Creamy public void setDessert(Dessert dessert) { this.dessert = dessert; }3.4 Bean的作用域默认情况下,Spring应用上下文所有bean都是作为以单例的形式创建的。 Spring定义了多种作用域,可以基于这些作用域创建bean,包括:
单例(Singleton):在整个应用中,只创建bean的一个实例。原型(Prototype):每次注入或者通过Spring应用上下文获取的时候,都会创建一个新的bean实例。会话(Session):在Web应用中,为每个会话创建一个bean实例。请求(Request):在Web应用中,为每个请求创建一个bean实例。例如,如果你使用组件扫描,可以在bean的类上使用@Scope注解,将其声明为原型bean:
@Component @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) public class Notepad { ... }或者在JavaConfig上声明:
@Bean @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) public Notepad notepad { return new Notepad(); }或者在XML上声明:
<bean id="notepad" class="com.myapp.Notepad" scope="prototype" />在web应用中,如果能够实例化在会话和请求范围内共享bean,那将很有价值。例如:电子商务的购物车,会话作用域最为适合。
@Component @Scope(value=WebApplicationContext.SCOPE_SESSION, proxyMode=ScopedProxyMode.TARGET_CLASS) public class ShoppingCart { ... }注入一个服务类
@Component public class StoreService { @Autowired public void setShoppingCart (ShoppingCart shoppingCart) { this.shoppingCart = shoppingCart; } }因为StoreService是一个单例的bean,会在Spring应用上下文加载的时候创建。当它创建的时候,Spring会试图将ShoppingCart bean注入到setShoppingCart() 方法中。但是ShoppingCart bean是会话作用域的,此时不存在。直到某个用户进入系统,创建了会话之后,才会出现ShoppingCart实例。
另外,系统中将会有多个ShoppingCart实例:每个用户一个。我们并不想让Spring注入某个固定的ShoppingCart实例到StoreService中。我们希望的是当StoreService处理购物车功能时,它所用的ShoppingCart实例恰好是当前会话所对应的那一个。
Spring并不会将实际的ShoppingCart bean注入到StoreService中,Spring会注入一个到ShoppingCart bean的代理,如下图。这个代理会暴露与ShoppingCart相同的方法,所以StoreService会认为它就是一个购物车。但是,当StoreService调用ShoppingCart的方法时,代理会对其进行懒解析并将调用委托给会话作用域内真正的ShoppingCart bean。
如果ShoppingCart是接口而不是类的话,就用ScopedProxyMode.TARGET_INTERFACES(用JDK的代理)。如果是类而不是接口,就必须使用CGLib来生成基于类的代理,所以要用ScopedProxyMode.TARGET_CLASS。
请求的作用域原理与会话作用域原理一样。
作用域代理能够延迟注入请求和会话作用域的bean
也可用XML配置
<bean id="cart" class="com.myapp.ShoppingCart" scope="session" > <aop:scoped-proxy /> </bean><aop:scoped-proxy />是与@Scope注解的proxy属性功能相同的SpringXML配置元素。它会告诉Spring为bean创建一个作用域代理。默认情况下,它会使用CGLib创建目标类的代理。我们也可以将proxy-targe-class属性设置为false,进而要求生成基于接口的代理:
<bean id="cart" class="com.myapp.ShoppingCart" scope="session" > <aop:scoped-proxy proxy-targe-class="false"/> </bean>3.5 运行时植注入我们之前在javaConfig配置中,配置了BlankDisc:
@Bean public CompactDisc sgtPeppers() { return new BlankDisc ( "Sgt. Pepper's Lonely Hearts Club Band", "The Beatles" ); }这种硬编码实现了要求,但有时我们希望避免,而是想让这些值在运行时再确定。为了实现这些功能,Spring提供了两种在运行时求值的方式:
属性占位符 (Property placeholder)。Spring表达式语言(SpEL)。在Spring中,最简单的方式就是声明属性源并通过Spring的Environment来检索属性。
package com.springinaction; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.PropertySource; import org.springframework.core.env.Environment; @Configuration @ComponentScan("com.springinaction") @PropertySource("app.properties") public class AppConfig { @Autowired Environment environment; @Bean public BlankDisc disc(){ return new BlankDisc( environment.getProperty("disc.title"), environment.getProperty("disc.artist")); } }在本例中,@PropertySource引用了类路径中一个名为app.properties的文件,如下所示:
disc.title=Sgt. Peppers Lonely Hearts Club Band disc.artist=The BeatlesEnvironment中getProperty有四个重载方法:
String getProperty(String key); String getProperty(String key, String defaultValue); T getProperty(String key, Class<T> type); T getProperty(String key, Class<T> type, T defaultValue);第二个方法与第一个的差别就是有了默认值。 第三、四个方法不会将所有值视为String,可以转换为别的类型,如
int connectionCount = env.getProperty("db.connection.count", Integer.class, 30);其他方法还有
// 如果key没有,则抛出IllegalStateException异常 String getRequiredProperty(String key); // 检查key的value是否存在 boolean containsProperty(String key) // 将属性解析为类 Class<T> getPropertyAsClass(String key, Class<T> type); // 返回激活profile名称的数组 String[] getActiveProfiles(); // 返回默认profile名称的数组 String[] getDefaultProfiles() // 如果environment支持给定profile的话,就返回true boolean acceptsProfiles(String... profiles)我们还可以用属性占位符来注入,占位符的形式为使用“${ … }”包装的属性名称。
<bean id="sgtPeppers" class="soundsystem.BlankDisc" c:_title="${disc.title}" c:_artist="${disc.artist}" />如果我们依赖组件扫描和自动装配来创建初始化的话
public BlankDisc ( @Value("disc.title") String title, @Value("disc.artist") String artist) { this.title = title; this.artist = artist; }为了使用占位符,我们必须要配置一个PropertyPlaceholderConfigurer bean或PropertySourcesPlaceholderConfigurer bean。推荐后者。 如果在javaConfig配置文件中声明:
@Bean public static PropertySourcesPlaceholderConfigurer placeholderConfigurer(){ return new PropertySourcesPlaceholderConfigurer(); }如果在XML配置文件中声明:
<context: property-placeholder />下面我们来看Spring表达式语言进行装配
SpEl表达式会在运行时计算得到值。SpEl拥有很多特性,包括:
使用bean的ID来引用bean;调用方法和访问对象的属性;对值进行算术、关系和逻辑运算;正则表达式匹配;集合操作。常用用法:
1. SpEL表达式要放到“# { ... }”, 如: #{1} 2. ‘# {T(System).currentTimeMillis()}’ ,它的最终结果是计算表达式的那一刻当前时间的毫秒数。T () 表达式会将java.lang.System视为Java中对应的类型,因此可以调用其static修饰的currentTimeMillis()方法。 3. SpEL表达式可以引用其他的bean或其他bean的属性。 例如,引用sgtPeppers的bean ‘# { sgtPeppers }’ 例如,如下的表达式会计算得到ID为sgtPeppers的bean的artist属性: ‘# { sgtPeppers.artist }’ 4. 还可以通过systemProperties对象引用系统属性: ‘# { systemProperties['disc.title'] }’ 5. 表示字面值: ‘# { 3.1415926 } ’ ‘# { 9.87E4 } ’ ‘# { 'Hello' } ’ ‘# { false }’ 6. 引用其他的bean的方法 ‘# { artistSelector.selectArtist () }’ 为了防止方法值为null,抛出异常,可以使用“?.” ‘# { artistSelector.selectArtist ()?.toUpperCase() }’ 不是null,正常返回;如果是null,不执行后面的方法,直接返回null 7. 如果要在SpEL中访问类作用域的方法和常量的话,要依赖T() 这个关键的运算符。 ‘# { T(java.lang.Math).PI }’ ‘# { T(java.lang.Math).random() }’ 8. 还可以将运算符用在表达式上,如: ‘# { 2 * T(java.lang.Math).PI * circle.radius }’ ‘# { disc.title + ' by ' + disc.artist }’ 9. 比较数字相等的写法 ‘# { counter.total == 100 }’ ‘# { counter.total eq 100 }’ 10. 三元运算符 ‘# { scoreboard.score > 1000 ? "Winner!" : "Loser" }’ ‘# { disc.title ?: 'Rattle and Hum' } ’ // 如果disc.title的值为空,返回'Rattle and Hum' 11. 支持正则表达式 ‘# { admin.email matches '[a-zA-Z0-9.*%+-]+@[a-zA-Z0-9.*]+.com' }’ 12. 支持与集合和数组相关的表达式 ‘# { jukebox.songs[4].title }’ ‘# { jukebox.songs[T(java.lang.Math).random() * jukebox.songs.size()].title }’ ‘# { 'This is a test' [3] }’ // 引用第4个字符 - “s” 13. 支持查询运算符 例如你希望得到jukebox中artist属性为Aerosmith的所有歌曲: ‘# { jukebox.songs.?[artist eq 'Aerosmith'] }’ 查找列表中第一个artist属性为Aerosmith的歌曲: ‘# { jukebox.songs.^[artist eq 'Aerosmith'] }’ 查找列表中最后一个artist属性为Aerosmith的歌曲: ‘# { jukebox.songs.$[artist eq 'Aerosmith'] }’ 14. 支持投影运算符 假设我们不想要歌曲对象的集合,而是所有歌曲名称的集合。如下表达式会将title属性投影到一个新的String类型的集合中: ‘# { jukebox.songs.![title]}’ 获取Aerosmith所有歌曲的title ‘# { jukebox.songs.?[artist eq 'Aerosmith'].![title] }’3.6 小结我们在本章介绍了许多背景知识,在第2章所介绍的基本bean装配基础之上,又学习了一些强大的高级装配技巧。
首先,我们学习了Spring profile,它解决了Spring bean要跨各种部署环境的通用问题。在运行时,通过将环境相关的bean与当前激活的profile进行匹配,Spring能够让相同的部署单元跨多种环境运行,而不需要进行重新构建。
Profile bean是在运行时条件化创建bean的一种方式,但是Spring 4提供了一种更为通用的方式,通过这种方式能够声明某些bean的创建与否要依赖于给定条件的输出结果。结合使用@Conditional注解和Spring Condition接口的实现,能够为开发人员提供一种强大和灵活的机制,实现条件化地创建bean。
我们还看了两种解决自动装配歧义性的方法:首选bean以及限定符。尽管将某个bean设置为首选bean是很简单的,但这种方式也有其局限性,所以我们讨论了如何将一组可选的自动装配bean,借助限定符将其范围缩小到只有一个符合条件的bean。除此之外,我们还看到了如何创建自定义的限定符注解,这些限定符描述了bean的特性。
尽管大多数的Spring bean都是以单例的方式创建的,但有的时候其他的创建策略更为合适。Spring能够让bean以单例、原型、请求作用域或会话作用域的方式来创建。在声明请求作用域或会话作用域的bean的时候,我们还学习了如何创建作用域代理,它分为基于类的代理和基于接口的代理的两种方式。
最后,我们学习了Spring表达式语言,它能够在运行时计算要注入到bean属性中的值。
对于bean装配,我们已经掌握了扎实的基础知识,现在我们要将注意力转向面向切面编程(aspect-oriented programming ,AOP)了。依赖注入能够将组件及其协作的其他组件解耦,与之类似,AOP有助于将应用组件与跨多个组件的任务进行解耦。在下一章,我们将会深入学习在Spring中如何创建和使用切面。
本文由来源 ThinkWon的博客,由 system_mush 整理编辑,其版权均为 ThinkWon的博客 所有,文章内容系作者个人观点,不代表 Java架构师必看 对观点赞同或支持。如需转载,请注明文章来源。
---来自腾讯云社区的---Java架构师必看
微信扫一扫打赏
支付宝扫一扫打赏