# 《Spring注解驱动开发》第16章:使用@Scope注解设置组件的作用域
# 写在前面
Spring容器中的组件默认是单例的,在Spring启动时就会实例化并初始化这些对象,将其放到Spring容器中,之后,每次获取对象时,直接从Spring容器中获取,而不再创建对象。如果每次从Spring容器中获取对象时,都要创建一个新的实例对象,该如何处理呢?此时就需要使用@Scope注解设置组件的作用域。
项目工程源码已经提交到GitHub:https://github.com/binghe001/spring-annotation (opens new window)
# 本文内容概览
- @Scope注解概述
- 单实例bean作用域
- 多实例bean作用域
- 单实例bean作用域如何创建对象?
- 多实例bean作用域如何创建对象?
- 单实例bean注意的事项
- 多实例bean注意的事项
- 自定义Scope的实现
# @Scope注解概述
@Scope注解能够设置组件的作用域,我们先来看@Scope注解类的源码,如下所示。
package org.springframework.context.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.core.annotation.AliasFor;
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Scope {
@AliasFor("scopeName")
String value() default "";
/**
* Specifies the name of the scope to use for the annotated component/bean.
* <p>Defaults to an empty string ({@code ""}) which implies
* {@link ConfigurableBeanFactory#SCOPE_SINGLETON SCOPE_SINGLETON}.
* @since 4.2
* @see ConfigurableBeanFactory#SCOPE_PROTOTYPE
* @see ConfigurableBeanFactory#SCOPE_SINGLETON
* @see org.springframework.web.context.WebApplicationContext#SCOPE_REQUEST
* @see org.springframework.web.context.WebApplicationContext#SCOPE_SESSION
* @see #value
*/
@AliasFor("value")
String scopeName() default "";
ScopedProxyMode proxyMode() default ScopedProxyMode.DEFAULT;
}
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
28
29
30
31
从源码中可以看出,在@Scope注解中可以设置如下值。
ConfigurableBeanFactory#SCOPE_PROTOTYPE
ConfigurableBeanFactory#SCOPE_SINGLETON
org.springframework.web.context.WebApplicationContext#SCOPE_REQUEST
org.springframework.web.context.WebApplicationContext#SCOPE_SESSION
2
3
4
很明显,在@Scope注解中可以设置的值包括ConfigurableBeanFactory接口中的SCOPE_PROTOTYPE和SCOPE_SINGLETON,以及WebApplicationContext类中SCOPE_REQUEST和SCOPE_SESSION。这些都是什么鬼?别急,我们来一个个查看。
首先,我们进入到ConfigurableBeanFactory接口中,发现在ConfigurableBeanFactory类中存在两个常量的定义,如下所示。
public interface ConfigurableBeanFactory extends HierarchicalBeanFactory, SingletonBeanRegistry {
String SCOPE_SINGLETON = "singleton";
String SCOPE_PROTOTYPE = "prototype";
/*****************此处省略N多行代码*******************/
}
2
3
4
5
没错,SCOPE_SINGLETON就是singleton,SCOPE_PROTOTYPE就是prototype。
那么,WebApplicationContext类中SCOPE_REQUEST和SCOPE_SESSION又是什么鬼呢?就是说,当我们使用了Web容器来运行Spring应用时,在@Scope注解中可以设置WebApplicationContext类中SCOPE_REQUEST和SCOPE_SESSION的值,而SCOPE_REQUEST的值就是request,SCOPE_SESSION的值就是session。
综上,在@Scope注解中的取值如下所示。
- singleton:表示组件在Spring容器中是单实例的,这个是Spring的默认值,Spring在启动的时候会将组件进行实例化并加载到Spring容器中,之后,每次从Spring容器中获取组件时,直接将实例对象返回,而不必再次创建实例对象。从Spring容器中获取对象,小伙伴们可以理解为从Map对象中获取对象。
- prototype:表示组件在Spring容器中是多实例的,Spring在启动的时候并不会对组件进行实例化操作,而是每次从Spring容器中获取组件对象时,都会创建一个新的实例对象并返回。
- request:每次请求都会创建一个新的实例对象,request作用域用在spring容器的web环境中。
- session:在同一个session范围内,创建一个新的实例对象,也是用在web环境中。
- application:全局web应用级别的作用于,也是在web环境中使用的,一个web应用程序对应一个bean实例,通常情况下和singleton效果类似的,不过也有不一样的地方,singleton是每个spring容器中只有一个bean实例,一般我们的程序只有一个spring容器,但是,一个应用程序中可以创建多个spring容器,不同的容器中可以存在同名的bean,但是sope=aplication的时候,不管应用中有多少个spring容器,这个应用中同名的bean只有一个。
其中,request和session作用域是需要Web环境支持的,这两个值基本上使用不到,如果我们使用Web容器来运行Spring应用时,如果需要将组件的实例对象的作用域设置为request和session,我们通常会使用request.setAttribute("key",object)和session.setAttribute("key", object)的形式来将对象实例设置到request和session中,通常不会使用@Scope注解来进行设置。
# 单实例bean作用域
首先,我们在io.mykit.spring.plugins.register.config包下创建PersonConfig2配置类,在PersonConfig2配置类中实例化一个Person对象,并将其放置在Spring容器中,如下所示。
package io.mykit.spring.plugins.register.config;
import io.mykit.spring.bean.Person;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author binghe
* @version 1.0.0
* @description 测试@Scope注解设置的作用域
*/
@Configuration
public class PersonConfig2 {
@Bean("person")
public Person person(){
return new Person("binghe002", 18);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
接下来,在SpringBeanTest类中创建testAnnotationConfig2()测试方法,在testAnnotationConfig2()方法中,创建ApplicationContext对象,创建完毕后,从Spring容器中按照id获取两个Person对象,并打印两个对象是否是同一个对象,代码如下所示。
@Test
public void testAnnotationConfig2(){
ApplicationContext context = new AnnotationConfigApplicationContext(PersonConfig2.class);
//从Spring容器中获取到的对象默认是单实例的
Object person1 = context.getBean("person");
Object person2 = context.getBean("person");
System.out.println(person1 == person2);
}
2
3
4
5
6
7
8
由于对象在Spring容器中默认是单实例的,所以,Spring容器在启动时就会将实例对象加载到Spring容器中,之后,每次从Spring容器中获取实例对象,直接将对象返回,而不必在创建新对象实例,所以,此时testAnnotationConfig2()方法会输出true。如下所示。
这也验证了我们的结论:对象在Spring容器中默认是单实例的,Spring容器在启动时就会将实例对象加载到Spring容器中,之后,每次从Spring容器中获取实例对象,直接将对象返回,而不必在创建新对象实例。
# 多实例bean作用域
修改Spring容器中组件的作用域,我们需要借助于@Scope注解,此时,我们将PersonConfig2类中Person对象的作用域修改成prototype,如下所示。
@Configuration
public class PersonConfig2 {
@Scope("prototype")
@Bean("person")
public Person person(){
return new Person("binghe002", 18);
}
}
2
3
4
5
6
7
8
9
其实,使用@Scope设置作用域就等同于在XML文件中为bean设置scope作用域,如下所示。
此时,我们再次运行SpringBeanTest类的testAnnotationConfig2()方法,此时,从Spring容器中获取到的person1对象和person2对象还是同一个对象吗?
通过输出结果可以看出,此时,输出的person1对象和person2对象已经不是同一个对象了。
# 单实例bean作用域何时创建对象?
接下来,我们验证下在单实例作用域下,Spring是在什么时候创建对象的呢?
首先,我们将PersonConfig2类中的Person对象的作用域修改成单实例,并在返回Person对象之前打印相关的信息,如下所示。
@Configuration
public class PersonConfig2 {
@Scope
@Bean("person")
public Person person(){
System.out.println("给容器中添加Person....");
return new Person("binghe002", 18);
}
}
2
3
4
5
6
7
8
9
接下来,我们在SpringBeanTest类中创建testAnnotationConfig3()方法,在testAnnotationConfig3()方法中,我们只创建Spring容器,如下所示。
@Test
public void testAnnotationConfig3(){
ApplicationContext context = new AnnotationConfigApplicationContext(PersonConfig2.class);
}
2
3
4
此时,我们运行SpringBeanTest类中的testAnnotationConfig3()方法,输出的结果信息如下所示。
从输出的结果信息可以看出,Spring容器在创建的时候,就将@Scope注解标注为singleton的组件进行了实例化,并加载到Spring容器中。
接下来,我们运行SpringBeanTest类中的testAnnotationConfig2(),结果信息如下所示。
说明,Spring容器在启动时,将单实例组件实例化之后,加载到Spring容器中,以后每次从容器中获取组件实例对象,直接返回相应的对象,而不必在创建新对象。
# 多实例bean作用域何时创建对象?
如果我们将对象的作用域修改成多实例,那什么时候创建对象呢?
此时,我们将PersonConfig2类的Person对象的作用域修改成多实例,如下所示。
@Configuration
public class PersonConfig2 {
@Scope("prototype")
@Bean("person")
public Person person(){
System.out.println("给容器中添加Person....");
return new Person("binghe002", 18);
}
}
2
3
4
5
6
7
8
9
10
我们再次运行SpringBeanTest类中的testAnnotationConfig3()方法,输出的结果信息如下所示。
可以看到,终端并没有输出任何信息,说明在创建Spring容器时,并不会实例化和加载多实例对象,那多实例对象是什么时候实例化的呢?接下来,我们在SpringBeanTest类中的testAnnotationConfig3()方法中添加一行获取Person对象的代码,如下所示。
@Test
public void testAnnotationConfig3(){
ApplicationContext context = new AnnotationConfigApplicationContext(PersonConfig2.class);
Object person1 = context.getBean("person");
}
2
3
4
5
此时,我们再次运行SpringBeanTest类中的testAnnotationConfig3()方法,结果信息如下所示。
从结果信息中,可以看出,当向Spring容器中获取Person实例对象时,Spring容器实例化了Person对象,并将其加载到Spring容器中。
那么,问题来了,此时Spring容器是否只实例化一个Person对象呢?我们在SpringBeanTest类中的testAnnotationConfig3()方法中再添加一行获取Person对象的代码,如下所示。
@Test
public void testAnnotationConfig3(){
ApplicationContext context = new AnnotationConfigApplicationContext(PersonConfig2.class);
Object person1 = context.getBean("person");
Object person2 = context.getBean("person");
}
2
3
4
5
6
此时,我们再次运行SpringBeanTest类中的testAnnotationConfig3()方法,结果信息如下所示。
从输出结果可以看出,当对象的Scope作用域为多实例时,每次向Spring容器获取对象时,都会创建一个新的对象并返回。此时,获取到的person1和person2就不是同一个对象了,我们也可以打印结果信息来进行验证,此时在SpringBeanTest类中的testAnnotationConfig3()方法中打印两个对象是否相等,如下所示。
@Test
public void testAnnotationConfig3(){
ApplicationContext context = new AnnotationConfigApplicationContext(PersonConfig2.class);
Object person1 = context.getBean("person");
Object person2 = context.getBean("person");
System.out.println(person1 == person2);
}
2
3
4
5
6
7
此时,我们再次运行SpringBeanTest类中的testAnnotationConfig3()方法,结果信息如下所示。
可以看到,当对象是多实例时,每次从Spring容器中获取对象时,都会创建新的实例对象,并且每个实例对象都不相等。
# 单实例bean注意的事项
单例bean是整个应用共享的,所以需要考虑到线程安全问题,之前在玩springmvc的时候,springmvc中controller默认是单例的,有些开发者在controller中创建了一些变量,那么这些变量实际上就变成共享的了,controller可能会被很多线程同时访问,这些线程并发去修改controller中的共享变量,可能会出现数据错乱的问题;所以使用的时候需要特别注意。
# 多实例bean注意的事项
多例bean每次获取的时候都会重新创建,如果这个bean比较复杂,创建时间比较长,会影响系统的性能,这个地方需要注意。
# 自定义Scope
如果Spring内置的几种sope都无法满足我们的需求的时候,我们可以自定义bean的作用域。
# 1.如何实现自定义Scope
自定义Scope主要分为三个步骤,如下所示。
(1)实现Scope接口
我们先来看下Scope接口的定义,如下所示。
package org.springframework.beans.factory.config;
import org.springframework.beans.factory.ObjectFactory;
import org.springframework.lang.Nullable;
public interface Scope {
/**
* 返回当前作用域中name对应的bean对象
* name:需要检索的bean的名称
* objectFactory:如果name对应的bean在当前作用域中没有找到,那么可以调用这个ObjectFactory来创建这个对象
**/
Object get(String name, ObjectFactory<?> objectFactory);
/**
* 将name对应的bean从当前作用域中移除
**/
@Nullable
Object remove(String name);
/**
* 用于注册销毁回调,如果想要销毁相应的对象,则由Spring容器注册相应的销毁回调,而由自定义作用域选择是不是要销毁相应的对象
*/
void registerDestructionCallback(String name, Runnable callback);
/**
* 用于解析相应的上下文数据,比如request作用域将返回request中的属性。
*/
@Nullable
Object resolveContextualObject(String key);
/**
* 作用域的会话标识,比如session作用域将是sessionId
*/
@Nullable
String getConversationId();
}
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
28
29
30
31
32
33
34
35
36
37
38
(2)将Scope注册到容器
需要调用org.springframework.beans.factory.config.ConfigurableBeanFactory#registerScope的方法,看一下这个方法的声明
/**
* 向容器中注册自定义的Scope
*scopeName:作用域名称
* scope:作用域对象
**/
void registerScope(String scopeName, Scope scope);
2
3
4
5
6
(3)使用自定义的作用域
定义bean的时候,指定bean的scope属性为自定义的作用域名称。
# 2.自定义Scope实现案例
例如,我们来实现一个线程级别的bean作用域,同一个线程中同名的bean是同一个实例,不同的线程中的bean是不同的实例。
这里,要求bean在线程中是共享的,所以我们可以通过ThreadLocal来实现,ThreadLocal可以实现线程中数据的共享。
此时,我们在io.mykit.spring.plugins.register.scope包下新建ThreadScope类,如下所示。
package io.mykit.spring.plugins.register.scope;
import org.springframework.beans.factory.ObjectFactory;
import org.springframework.beans.factory.config.Scope;
import org.springframework.lang.Nullable;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
/**
* 自定义本地线程级别的bean作用域,不同的线程中对应的bean实例是不同的,同一个线程中同名的bean是同一个实例
*/
public class ThreadScope implements Scope {
public static final String THREAD_SCOPE = "thread";
private ThreadLocal<Map<String, Object>> beanMap = new ThreadLocal() {
@Override
protected Object initialValue() {
return new HashMap<>();
}
};
@Override
public Object get(String name, ObjectFactory<?> objectFactory) {
Object bean = beanMap.get().get(name);
if (Objects.isNull(bean)) {
bean = objectFactory.getObject();
beanMap.get().put(name, bean);
}
return bean;
}
@Nullable
@Override
public Object remove(String name) {
return this.beanMap.get().remove(name);
}
@Override
public void registerDestructionCallback(String name, Runnable callback) {
//bean作用域范围结束的时候调用的方法,用于bean清理
System.out.println(name);
}
@Nullable
@Override
public Object resolveContextualObject(String key) {
return null;
}
@Nullable
@Override
public String getConversationId() {
return Thread.currentThread().getName();
}
}
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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
在ThreadScope类中,我们定义了一个常量THREAD_SCOPE,在定义bean的时候给scope使用。
接下来,我们在io.mykit.spring.plugins.register.config包下创建PersonConfig3类,并使用@Scope("thread")注解标注Person对象的作用域为Thread范围,如下所示。
package io.mykit.spring.plugins.register.config;
import io.mykit.spring.bean.Person;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;
/**
* @author binghe
* @version 1.0.0
* @description 测试@Scope注解设置的作用域
*/
@Configuration
public class PersonConfig3 {
@Scope("thread")
@Bean("person")
public Person person(){
System.out.println("给容器中添加Person....");
return new Person("binghe002", 18);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
最后,我们在SpringBeanTest类中创建testAnnotationConfig4()方法,在testAnnotationConfig4()方法中创建Spring容器,并向Spring容器中注册ThreadScope对象,接下来,使用循环创建两个Thread线程,并分别在每个线程中获取两个Person对象,如下所示。
@Test
public void testAnnotationConfig4(){
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(PersonConfig3.class);
//向容器中注册自定义的scope
context.getBeanFactory().registerScope(ThreadScope.THREAD_SCOPE, new ThreadScope());
//使用容器获取bean
for (int i = 0; i < 2; i++) {
new Thread(() -> {
System.out.println(Thread.currentThread() + "," + context.getBean("person"));
System.out.println(Thread.currentThread() + "," + context.getBean("person"));
}).start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
此时,我们运行SpringBeanTest类的testAnnotationConfig4()方法,输出的结果信息如下所示。
从输出中可以看到,bean在同样的线程中获取到的是同一个bean的实例,不同的线程中bean的实例是不同的。
注意:这里,我将Person类进行了相应的调整,去掉Lombok的注解,手动写构造函数和setter与getter方法,如下所示。
package io.mykit.spring.bean;
import java.io.Serializable;
/**
* @author binghe
* @version 1.0.0
* @description 测试实体类
*/
public class Person implements Serializable {
private static final long serialVersionUID = 7387479910468805194L;
private String name;
private Integer age;
public Person() {
}
public Person(String name, Integer age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
}
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
28
29
30
31
32
33
34
35
36
37
38
好了,咱们今天就聊到这儿吧!别忘了给个在看和转发,让更多的人看到,一起学习一起进步!!
项目工程源码已经提交到GitHub:https://github.com/binghe001/spring-annotation (opens new window)
# 星球服务
加入星球,你将获得:
1.项目学习:微服务入门必备的SpringCloud Alibaba实战项目、手写RPC项目—所有大厂都需要的项目【含上百个经典面试题】、深度解析Spring6核心技术—只要学习Java就必须深度掌握的框架【含数十个经典思考题】、Seckill秒杀系统项目—进大厂必备高并发、高性能和高可用技能。
2.框架源码:手写RPC项目—所有大厂都需要的项目【含上百个经典面试题】、深度解析Spring6核心技术—只要学习Java就必须深度掌握的框架【含数十个经典思考题】。
3.硬核技术:深入理解高并发系列(全册)、深入理解JVM系列(全册)、深入浅出Java设计模式(全册)、MySQL核心知识(全册)。
4.技术小册:深入理解高并发编程(第1版)、深入理解高并发编程(第2版)、从零开始手写RPC框架、SpringCloud Alibaba实战、冰河的渗透实战笔记、MySQL核心知识手册、Spring IOC核心技术、Nginx核心技术、面经手册等。
5.技术与就业指导:提供相关就业辅导和未来发展指引,冰河从初级程序员不断沉淀,成长,突破,一路成长为互联网资深技术专家,相信我的经历和经验对你有所帮助。
冰河的知识星球是一个简单、干净、纯粹交流技术的星球,不吹水,目前加入享5折优惠,价值远超门票。加入星球的用户,记得添加冰河微信:hacker_binghe,冰河拉你进星球专属VIP交流群。
# 星球重磅福利
跟冰河一起从根本上提升自己的技术能力,架构思维和设计思路,以及突破自身职场瓶颈,冰河特推出重大优惠活动,扫码领券进行星球,直接立减149元,相当于5折, 这已经是星球最大优惠力度!
领券加入星球,跟冰河一起学习《SpringCloud Alibaba实战》、《手撸RPC专栏》和《Spring6核心技术》,更有已经上新的《大规模分布式Seckill秒杀系统》,从零开始介绍原理、设计架构、手撸代码。后续更有硬核中间件项目和业务项目,而这些都是你升职加薪必备的基础技能。
100多元就能学这么多硬核技术、中间件项目和大厂秒杀系统,如果是我,我会买他个终身会员!
# 其他方式加入星球
- 链接 :打开链接 http://m6z.cn/6aeFbs (opens new window) 加入星球。
- 回复 :在公众号 冰河技术 回复 星球 领取优惠券加入星球。
特别提醒: 苹果用户进圈或续费,请加微信 hacker_binghe 扫二维码,或者去公众号 冰河技术 回复 星球 扫二维码加入星球。
# 星球规划
后续冰河还会在星球更新大规模中间件项目和深度剖析核心技术的专栏,目前已经规划的专栏如下所示。
# 中间件项目
- 《大规模分布式定时调度中间件项目实战(非Demo)》:全程手撸代码。
- 《大规模分布式IM(即时通讯)项目实战(非Demo)》:全程手撸代码。
- 《大规模分布式网关项目实战(非Demo)》:全程手撸代码。
- 《手写Redis》:全程手撸代码。
- 《手写JVM》全程手撸代码。
# 超硬核项目
- 《从零落地秒杀系统项目》:全程手撸代码,在阿里云实现压测(已上新)。
- 《大规模电商系统商品详情页项目》:全程手撸代码,在阿里云实现压测。
- 其他待规划的实战项目,小伙伴们也可以提一些自己想学的,想一起手撸的实战项目。。。
既然星球规划了这么多内容,那么肯定就会有小伙伴们提出疑问:这么多内容,能更新完吗?我的回答就是:一个个攻破呗,咱这星球干就干真实中间件项目,剖析硬核技术和项目,不做Demo。初衷就是能够让小伙伴们学到真正的核心技术,不再只是简单的做CRUD开发。所以,每个专栏都会是硬核内容,像《SpringCloud Alibaba实战》、《手撸RPC专栏》和《Spring6核心技术》就是很好的示例。后续的专栏只会比这些更加硬核,杜绝Demo开发。
小伙伴们跟着冰河认真学习,多动手,多思考,多分析,多总结,有问题及时在星球提问,相信在技术层面,都会有所提高。将学到的知识和技术及时运用到实际的工作当中,学以致用。星球中不少小伙伴都成为了公司的核心技术骨干,实现了升职加薪的目标。
# 联系冰河
# 加群交流
本群的宗旨是给大家提供一个良好的技术学习交流平台,所以杜绝一切广告!由于微信群人满 100 之后无法加入,请扫描下方二维码先添加作者 “冰河” 微信(hacker_binghe),备注:星球编号
。
# 公众号
分享各种编程语言、开发技术、分布式与微服务架构、分布式数据库、分布式事务、云原生、大数据与云计算技术和渗透技术。另外,还会分享各种面试题和面试技巧。内容在 冰河技术 微信公众号首发,强烈建议大家关注。
# 视频号
定期分享各种编程语言、开发技术、分布式与微服务架构、分布式数据库、分布式事务、云原生、大数据与云计算技术和渗透技术。另外,还会分享各种面试题和面试技巧。
# 星球
加入星球 冰河技术 (opens new window),可以获得本站点所有学习内容的指导与帮助。如果你遇到不能独立解决的问题,也可以添加冰河的微信:hacker_binghe, 我们一起沟通交流。另外,在星球中不只能学到实用的硬核技术,还能学习实战项目!
关注 冰河技术 (opens new window)公众号,回复 星球
可以获取入场优惠券。