【小家Spring】Spring MVC容器的web九大组件之---HandlerMapping源码详解(二)---RequestMappingHandlerMapping系列
前言上篇博客:【小家Spring】Spring MVC容器的web九大组件之—HandlerMapping源码详解(一)已经分析过了HandlerMapping的一些抽象实现原理,以及AbstractHandlerMapping的一个主要分支:AbstractUrlHandlerMapping体系的实现原理,可以知道它是基于类级别的Handler实现本文将介绍它的另外一个系列:Abstr...
每篇一句
面试不等于工作,工作不等于能力,这些都不是划等号的
相关阅读
【小家Spring】Spring MVC容器的web九大组件之—HandlerMapping源码详解(一)—BeanNameUrlHandlerMapping系列
【小家Spring】Spring MVC容器的web九大组件之—HandlerMapping源码详解(二)—RequestMappingHandlerMapping系列
【小家Spring】Spring MVC容器启动时,web九大组件初始化详解(Spring MVC的运行机制)
前言
上篇博客:
【小家Spring】Spring MVC容器的web九大组件之—HandlerMapping源码详解(一)—BeanNameUrlHandlerMapping系列
分析过了HandlerMapping
的一些抽象实现,以及AbstractHandlerMapping
的一个主要分支:AbstractUrlHandlerMapping体系
的实现原理分析:它是基于类级别的Handler
实现,大体上和源生servlet如出一辙,也还没有脱离源生servlet的API。作为第一版的实现,便捷度自然存在一些欠缺,但大的框架还是非常稳的。可以看出Spring的眼光、抽象思维算是顶级水准~
本文将介绍它的另外一个系列:AbstractHandlerMethodMapping系列
,基于方法级别的Handler实现。也是当下最为主流的实现方式,更是最为常用使用方式
AbstractHandlerMethodMapping系列
AbstractHandlerMethodMapping
系列是将method
作为handler
来使用的,比如@RequestMapping
所注释的方法就是这种handler
(当然它并不强制你一定得使用@RequestMapping
这样的注解)。
在前面我们已经知道了AbstractHandlerMethodMapping
的父类AbstractHandlerMapping
,其定义了抽象方法getHandlerInternal(HttpServletRequest request)
,那么这里主要看看它对此抽象方法的实现:
// @since 3.1 Spring3.1之后才出现,这个时候注解驱动也出来了
// 实现了initializingBean接口,其实主要的注册操作则是通过afterPropertiesSet()接口方法来调用的
// 它是带有泛型T的。
// T:包含HandlerMethod与传入请求匹配所需条件的handlerMethod的映射~
public abstract class AbstractHandlerMethodMapping<T> extends AbstractHandlerMapping implements InitializingBean {
// SCOPED_TARGET的BeanName的前缀
private static final String SCOPED_TARGET_NAME_PREFIX = "scopedTarget.";
private static final HandlerMethod PREFLIGHT_AMBIGUOUS_MATCH = new HandlerMethod(new EmptyHandler(), ClassUtils.getMethod(EmptyHandler.class, "handle"));
// 跨域相关
private static final CorsConfiguration ALLOW_CORS_CONFIG = new CorsConfiguration();
static {
ALLOW_CORS_CONFIG.addAllowedOrigin("*");
ALLOW_CORS_CONFIG.addAllowedMethod("*");
ALLOW_CORS_CONFIG.addAllowedHeader("*");
ALLOW_CORS_CONFIG.setAllowCredentials(true);
}
// 默认不会去祖先容器里面找Handlers
private boolean detectHandlerMethodsInAncestorContexts = false;
// @since 4.1提供的新接口
// 为处HandlerMetho的映射分配名称的策略接口 只有一个方法getName()
// 唯一实现为:RequestMappingInfoHandlerMethodMappingNamingStrategy
// 策略为:@RequestMapping指定了name属性,那就以指定的为准 否则策略为:取出Controller所有的`大写字母` + # + method.getName()
// 如:AppoloController#match方法 最终的name为:AC#match
// 当然这个你也可以自己实现这个接口,然后set进来即可(只是一般没啥必要这么去干~~)
@Nullable
private HandlerMethodMappingNamingStrategy<T> namingStrategy;
// 内部类:负责注册~
private final MappingRegistry mappingRegistry = new MappingRegistry();
// 此处细节:使用的是读写锁 比如此处使用的是读锁 获得所有的注册进去的Handler的Map
public Map<T, HandlerMethod> getHandlerMethods() {
this.mappingRegistry.acquireReadLock();
try {
return Collections.unmodifiableMap(this.mappingRegistry.getMappings());
} finally {
this.mappingRegistry.releaseReadLock();
}
}
// 此处是根据mappingName来获取一个Handler 此处需要注意哦~~~
@Nullable
public List<HandlerMethod> getHandlerMethodsForMappingName(String mappingName) {
return this.mappingRegistry.getHandlerMethodsByMappingName(mappingName);
}
// 最终都是委托给mappingRegistry去做了注册的工作 此处日志级别为trace级别
public void registerMapping(T mapping, Object handler, Method method) {
if (logger.isTraceEnabled()) {
logger.trace("Register \"" + mapping + "\" to " + method.toGenericString());
}
this.mappingRegistry.register(mapping, handler, method);
}
public void unregisterMapping(T mapping) {
if (logger.isTraceEnabled()) {
logger.trace("Unregister mapping \"" + mapping + "\"");
}
this.mappingRegistry.unregister(mapping);
}
// 这个很重要,是初始化HandlerMethods的入口~~~~~
@Override
public void afterPropertiesSet() {
initHandlerMethods();
}
// 看initHandlerMethods(),观察是如何实现加载HandlerMethod
protected void initHandlerMethods() {
// getCandidateBeanNames:Object.class相当于拿到当前容器(一般都是当前容器) 内所有的Bean定义信息
// 如果阁下容器隔离到到的话,这里一般只会拿到@Controller标注的web组件 以及其它相关web组件的 不会非常的多的~~~~
for (String beanName : getCandidateBeanNames()) {
// BeanName不是以这个打头得 这里才会process这个BeanName~~~~
if (!beanName.startsWith(SCOPED_TARGET_NAME_PREFIX)) {
// 会在每个Bean里面找处理方法,HandlerMethod,然后注册进去
processCandidateBean(beanName);
}
}
// 略:它就是输出一句日志:debug日志或者trace日志 `7 mappings in 'requestMappingHandlerMapping'`
handlerMethodsInitialized(getHandlerMethods());
}
// 确定指定的候选bean的类型,如果标识为Handler类型,则调用DetectHandlerMethods
// isHandler(beanType):判断这个type是否为Handler类型 它是个抽象方法,由子类去决定到底啥才叫Handler~~~~
// `RequestMappingHandlerMapping`的判断依据为:该类上标注了@Controller注解或者@Controller注解 就算作是一个Handler
// 所以此处:@Controller起到了一个特殊的作用,不能等价于@Component的哟~~~~
protected void processCandidateBean(String beanName) {
Class<?> beanType = null;
try {
beanType = obtainApplicationContext().getType(beanName);
} catch (Throwable ex) {
// 即使抛出异常 程序也不会终止~
}
if (beanType != null && isHandler(beanType)) {
// 这个和我们上篇博文讲述的类似,都属于detect探测系列~~~~
detectHandlerMethods(beanName);
}
}
// 在指定的Handler的bean中查找处理程序方法Methods 找打就注册进去:mappingRegistry
protected void detectHandlerMethods(Object handler) {
Class<?> handlerType = (handler instanceof String ?
obtainApplicationContext().getType((String) handler) : handler.getClass());
if (handlerType != null) {
Class<?> userType = ClassUtils.getUserClass(handlerType);
// 又是非常熟悉的方法:MethodIntrospector.selectMethods
// 它在我们招@EventListener、@Scheduled等注解方法时已经遇到过多次
// 此处特别之处在于:getMappingForMethod属于一个抽象方法,由子类去决定它的寻找规则~~~~ 什么才算作一个处理器方法
Map<Method, T> methods = MethodIntrospector.selectMethods(userType,
(MethodIntrospector.MetadataLookup<T>) method -> {
try {
return getMappingForMethod(method, userType);
} catch (Throwable ex) {
throw new IllegalStateException("Invalid mapping on handler class [" + userType.getName() + "]: " + method, ex);
}
});
// 把找到的Method 一个个遍历,注册进去~~~~
methods.forEach((method, mapping) -> {
// 找到这个可调用的方法(AopUtils.selectInvocableMethod)
Method invocableMethod = AopUtils.selectInvocableMethod(method, userType);
registerHandlerMethod(handler, invocableMethod, mapping);
});
}
}
}
该抽象类完成了所有的Handler
以及handler
里面所有的HandlerMethod
的模版操作,但是决定哪些Bean是Handler类
和哪些方法
才是HandlerMathod
,这些逻辑都是交给子类自己去实现,所以这层抽象可谓也是非常的灵活,并没有把Handler
的实现方式定死,允许不同
这里面有个核心内容:那就是注册handlerMethod
,是交给AbstractHandlerMethodMapping
的一个内部类MappingRegistry
去完成的,用来专门维持所有的映射关系,并提供方法去查找方法去提供当前url映射的方法。
AbstractHandlerMethodMapping.MappingRegistry:内部类注册中心
维护几个Map(键值对),用来存储映射的信息, 还有一个MappingRegistration
专门保存注册信息
MappingRegistration
:就是一个private的内部类,维护着T mapping、HandlerMethod handlerMethod、List<String> directUrls、String mappingName
等信息,提供get方法访问。木有任何其它逻辑
class MappingRegistry {
// mapping对应的其MappingRegistration对象~~~
private final Map<T, MappingRegistration<T>> registry = new HashMap<>();
// 保存着mapping和HandlerMethod的对应关系~
private final Map<T, HandlerMethod> mappingLookup = new LinkedHashMap<>();
// 保存着URL与匹配条件(mapping)的对应关系 当然这里的URL是pattern式的,可以使用通配符
// 这里的Map不是普通的Map,而是MultiValueMap,它是个多值Map。其实它的value是一个list类型的值
// 至于为何是多值?有这么一种情况 URL都是/api/v1/hello 但是有的是get post delete等方法 所以有可能是会匹配到多个MappingInfo的
private final MultiValueMap<String, T> urlLookup = new LinkedMultiValueMap<>();
// 这个Map是Spring MVC4.1新增的(毕竟这个策略接口HandlerMethodMappingNamingStrategy在Spring4.1后才有,这里的name是它生成出来的)
// 保存着name和HandlerMethod的对应关系(一个name可以有多个HandlerMethod)
private final Map<String, List<HandlerMethod>> nameLookup = new ConcurrentHashMap<>();
// 这两个就不用解释了
private final Map<HandlerMethod, CorsConfiguration> corsLookup = new ConcurrentHashMap<>();
// 读写锁~~~ 读写分离 提高启动效率
private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
... // 提供一些查找方法,都不是线程安全的
// 读锁提供给外部访问,写锁自己放在内部即可~~~
public void acquireReadLock() {
this.readWriteLock.readLock().lock();
}
public void releaseReadLock() {
this.readWriteLock.readLock().unlock();
}
// 注册Mapping和handler 以及Method 此处上写锁保证线程安全~
public void register(T mapping, Object handler, Method method) {
this.readWriteLock.writeLock().lock();
try {
// 此处注意:都是new HandlerMethod()了一个新的出来~~~~
HandlerMethod handlerMethod = createHandlerMethod(handler, method);
// 同样的:一个URL Mapping只能对应一个Handler
// 这里可能会出现常见的一个异常信息:Ambiguous mapping. Cannot map XXX
assertUniqueMethodMapping(handlerMethod, mapping);
// 缓存Mapping和handlerMethod的关系
this.mappingLookup.put(mapping, handlerMethod);
// 保存url和RequestMappingInfo(mapping)对应关系
// 这里注意:多个url可能对应着同一个mappingInfo呢~ 毕竟@RequestMapping的url是可以写多个的~~~~
List<String> directUrls = getDirectUrls(mapping);
for (String url : directUrls) {
this.urlLookup.add(url, mapping);
}
// 保存name和handlerMethod的关系 同样也是一对多
String name = null;
if (getNamingStrategy() != null) {
name = getNamingStrategy().getName(handlerMethod, mapping);
addMappingName(name, handlerMethod);
}
CorsConfiguration corsConfig = initCorsConfiguration(handler, method, mapping);
if (corsConfig != null) {
this.corsLookup.put(handlerMethod, corsConfig);
}
// 注册mapping和MappingRegistration的关系
this.registry.put(mapping, new MappingRegistration<>(mapping, handlerMethod, directUrls, name));
}
// 释放锁
finally {
this.readWriteLock.writeLock().unlock();
}
}
// 相当于进行一次逆向操作~
public void unregister(T mapping) { ... }
...
}
这个注册中心,核心是保存了多个Map映射关系,相当于缓存下来。在请求过来时需要查找的时候,可以迅速定位到处理器
下面继续,终于来到AbstractHandlerMethodMapping
它对父类抽象方法:getHandlerInternal
的实现如下:
public abstract class AbstractHandlerMethodMapping<T> extends AbstractHandlerMapping implements InitializingBean {
...
@Override
protected HandlerMethod getHandlerInternal(HttpServletRequest request) throws Exception {
// 要进行匹配的 请求的URI path
String lookupPath = getUrlPathHelper().getLookupPathForRequest(request);
this.mappingRegistry.acquireReadLock();
try {
//委托给方法lookupHandlerMethod() 去找到一个HandlerMethod去最终处理~
HandlerMethod handlerMethod = lookupHandlerMethod(lookupPath, request);
return (handlerMethod != null ? handlerMethod.createWithResolvedBean() : null);
}
finally {
this.mappingRegistry.releaseReadLock();
}
}
@Nullable
protected HandlerMethod lookupHandlerMethod(String lookupPath, HttpServletRequest request) throws Exception {
// Match是一个private class,内部就两个属性:T mapping和HandlerMethod handlerMethod
List<Match> matches = new ArrayList<>();
// 根据lookupPath去注册中心里查找mappingInfos,因为一个具体的url可能匹配上多个MappingInfo的
// 至于为何是多值?有这么一种情况 URL都是/api/v1/hello 但是有的是get post delete等方法 当然还有可能是headers/consumes等等不一样,都算多个的 所以有可能是会匹配到多个MappingInfo的
// 所有这个里可以匹配出多个出来。比如/hello 匹配出GET、POST、PUT都成,所以size可以为3
List<T> directPathMatches = this.mappingRegistry.getMappingsByUrl(lookupPath);
if (directPathMatches != null) {
// 依赖于子类实现的抽象方法:getMatchingMapping() 看看到底匹不匹配,而不仅仅是URL匹配就行
// 比如还有method、headers、consumes等等这些不同都代表着不同的MappingInfo的
// 最终匹配上的,会new Match()放进matches里面去
addMatchingMappings(directPathMatches, matches, request);
}
// 当还没有匹配上的时候,别无选择,只能浏览所有映射
// 这里为何要浏览所有的mappings呢?而不是报错404呢?这里我有点迷糊,愿有知道的指明这个设计意图~~~
if (matches.isEmpty()) {
// No choice but to go through all mappings...
addMatchingMappings(this.mappingRegistry.getMappings().keySet(), matches, request);
}
// 单反只要找到了一个匹配的 就进来这里了~~~
// 请注意:因为到这里 匹配上的可能还不止一个 所以才需要继续处理~~
if (!matches.isEmpty()) {
// getMappingComparator这个方法也是抽象方法由子类去实现的。
// 比如:`RequestMappingInfoHandlerMapping`的实现为先比较Method,patterns、params
Comparator<Match> comparator = new MatchComparator(getMappingComparator(request));
matches.sort(comparator);
// 排序后的最佳匹配为get(0)
Match bestMatch = matches.get(0);
// 如果总的匹配个数大于1的话
if (matches.size() > 1) {
if (CorsUtils.isPreFlightRequest(request)) {
return PREFLIGHT_AMBIGUOUS_MATCH;
}
// 次最佳匹配
Match secondBestMatch = matches.get(1);
// 如果发现次最佳匹配和最佳匹配 比较是相等的 那就报错吧~~~~
// Ambiguous handler methods mapped for~~~
// 注意:这个是运行时的检查,在启动的时候是检查不出来的~~~ 所以运行期的这个检查也是很有必要的~~~ 否则就会出现意想不到的效果
if (comparator.compare(bestMatch, secondBestMatch) == 0) {
Method m1 = bestMatch.handlerMethod.getMethod();
Method m2 = secondBestMatch.handlerMethod.getMethod();
String uri = request.getRequestURI();
throw new IllegalStateException(
"Ambiguous handler methods mapped for '" + uri + "': {" + m1 + ", " + m2 + "}");
}
}
// 把最最佳匹配的方法 放进request的属性里面~~~
request.setAttribute(BEST_MATCHING_HANDLER_ATTRIBUTE, bestMatch.handlerMethod);
// 它也是做了一件事:request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, lookupPath)
handleMatch(bestMatch.mapping, lookupPath, request);
// 最终返回的是HandlerMethod~~~
return bestMatch.handlerMethod;
}
// 一个都没匹配上,handleNoMatch这个方法虽然不是抽象方法,protected方法子类复写
// RequestMappingInfoHandlerMapping有复写此方法~~~~
else {
return handleNoMatch(this.mappingRegistry.getMappings().keySet(), lookupPath, request);
}
}
...
// 因为上面说了mappings可能会有多个,比如get post put的都算~~~这里就是要进行筛选出所有match上的
private void addMatchingMappings(Collection<T> mappings, List<Match> matches, HttpServletRequest request) {
for (T mapping : mappings) {
// 只有RequestMappingInfoHandlerMapping 实现了一句话:return info.getMatchingCondition(request);
// 因此RequestMappingInfo#getMatchingCondition() 方法里大有文章可为~~~
// 它会对所有的methods、params、headers... 都进行匹配 但凡匹配不上的就返回null
T match = getMatchingMapping(mapping, request);
if (match != null) {
matches.add(new Match(match, this.mappingRegistry.getMappings().get(mapping)));
}
}
}
}
// ===============RequestMappingInfo 的源码部分讲解================
public final class RequestMappingInfo implements RequestCondition<RequestMappingInfo> {
// 这些个匹配器都继承自AbstractRequestCondition,会进行各自的匹配工作
// 下面会以PatternsRequestCondition为例进行示例讲解~~~~~
// 他们顶级抽象接口为:RequestCondition @since 3.1 :Contract for request mapping conditions
private final PatternsRequestCondition patternsCondition;
private final RequestMethodsRequestCondition methodsCondition;
private final ParamsRequestCondition paramsCondition;
private final HeadersRequestCondition headersCondition;
private final ConsumesRequestCondition consumesCondition;
private final ProducesRequestCondition producesCondition;
private final RequestConditionHolder customConditionHolder;
// 因为类上和方法上都可能会有@RequestMapping注解,所以这里是把语意思合并 该方法来自顶层接口
@Override
public RequestMappingInfo combine(RequestMappingInfo other) {
String name = combineNames(other);
PatternsRequestCondition patterns = this.patternsCondition.combine(other.patternsCondition);
RequestMethodsRequestCondition methods = this.methodsCondition.combine(other.methodsCondition);
ParamsRequestCondition params = this.paramsCondition.combine(other.paramsCondition);
HeadersRequestCondition headers = this.headersCondition.combine(other.headersCondition);
ConsumesRequestCondition consumes = this.consumesCondition.combine(other.consumesCondition);
ProducesRequestCondition produces = this.producesCondition.combine(other.producesCondition);
RequestConditionHolder custom = this.customConditionHolder.combine(other.customConditionHolder);
return new RequestMappingInfo(name, patterns,
methods, params, headers, consumes, produces, custom.getCondition());
}
// 合并后,就开始发挥作用了,该接口来自于顶层接口~~~~
@Override
@Nullable
public RequestMappingInfo getMatchingCondition(HttpServletRequest request) {
RequestMethodsRequestCondition methods = this.methodsCondition.getMatchingCondition(request);
if (methods == null) {
return null;
}
ParamsRequestCondition params = this.paramsCondition.getMatchingCondition(request);
if (params == null) {
return null;
}
HeadersRequestCondition headers = this.headersCondition.getMatchingCondition(request);
if (headers == null) {
return null;
}
ConsumesRequestCondition consumes = this.consumesCondition.getMatchingCondition(request);
if (consumes == null) {
return null;
}
ProducesRequestCondition produces = this.producesCondition.getMatchingCondition(request);
if (produces == null) {
return null;
}
PatternsRequestCondition patterns = this.patternsCondition.getMatchingCondition(request);
if (patterns == null) {
return null;
}
RequestConditionHolder custom = this.customConditionHolder.getMatchingCondition(request);
if (custom == null) {
return null;
}
return new RequestMappingInfo(this.name, patterns,
methods, params, headers, consumes, produces, custom.getCondition());
}
}
到这里,这个抽象类所做的工作都全部完成了。
可以看到它做的事还是非常非常多的。它用泛型来抽象Mapping关系(包括条件、属性等),实现并不要求一定是@RequestMapping
这种注解的方式,可以是任意方式,体现了它对扩展开放的设计思想~
Spring MVC请求URL带后缀匹配的情况
,如/hello.json也能匹配/hello
RequestMappingInfoHandlerMapping
在处理http请求的时候, 如果 请求url 有后缀,如果找不到精确匹配的那个@RequestMapping
方法。
那么,就把后缀去掉,然后.*
去匹配,这样,一般都可以匹配,默认这个行为是被开启的。
比如有一个@RequestMapping("/rest")
, 那么精确匹配的情况下, 只会匹配/rest
请求。 但如果我前端发来一个 /rest.abcdef
这样的请求, 又没有配置 @RequestMapping("/rest.abcdef")
这样映射的情况下, 那么@RequestMapping("/rest")
就会生效。
这样会带来什么问题呢?绝大多数情况下是没有问题的,但是如果你是一个对权限要求非常严格的系统,强烈关闭此项
功能
,否则你会有意想不到的"收获"
。
究其原因咱们可以接着上面的分析,其实就到了PatternsRequestCondition
这个类上,具体实现是它的匹配逻辑来决定的。
public final class PatternsRequestCondition extends AbstractRequestCondition<PatternsRequestCondition> {
...
@Override
@Nullable
public PatternsRequestCondition getMatchingCondition(HttpServletRequest request) {
// patterns表示此MappingInfo可以匹配的值们。一般对应@RequestMapping注解上的patters数组的值
if (this.patterns.isEmpty()) {
return this;
}
// 拿到待匹配的值,比如此处为"/hello.json"
String lookupPath = this.pathHelper.getLookupPathForRequest(request);
// 最主要就是这个方法了,它拿着这个lookupPath匹配~~~~
List<String> matches = getMatchingPatterns(lookupPath);
// 此处如果为empty,就返回null了~~~~
return (!matches.isEmpty() ? new PatternsRequestCondition(matches, this.pathHelper, this.pathMatcher, this.useSuffixPatternMatch, this.useTrailingSlashMatch, this.fileExtensions) : null);
}
public List<String> getMatchingPatterns(String lookupPath) {
List<String> matches = new ArrayList<>();
for (String pattern : this.patterns) {
// 最最最重点就是在getMatchingPattern()这个方法里~~~ 拿着lookupPath和pattern看它俩合拍不~
String match = getMatchingPattern(pattern, lookupPath);
if (match != null) {
matches.add(match);
}
}
// 解释一下为何匹配的可能是多个。因为url匹配上了,但是还有可能@RequestMapping的其余属性匹配不上啊,所以此处需要注意 是可能匹配上多个的 最终是唯一匹配就成~
if (matches.size() > 1) {
matches.sort(this.pathMatcher.getPatternComparator(lookupPath));
}
return matches;
}
// // ===============url的真正匹配规则 非常重要~~~===============
// 注意这个方法的取名,上面是负数,这里是单数~~~~命名规范也是有艺术感的
@Nullable
private String getMatchingPattern(String pattern, String lookupPath) {
// 完全相等,那就不继续聊了~~~
if (pattern.equals(lookupPath)) {
return pattern;
}
// 注意了:useSuffixPatternMatch 这个属性就是我们最终要关闭后缀匹配的关键
// 这个值默外部给传的true(其实内部默认值是boolean类型为false)
if (this.useSuffixPatternMatch) {
// 这个意思是若useSuffixPatternMatch=true我们支持后缀匹配。我们还可以配置fileExtensions让只支持我们自定义的指定的后缀匹配,而不是下面最终的.*全部支持
if (!this.fileExtensions.isEmpty() && lookupPath.indexOf('.') != -1) {
for (String extension : this.fileExtensions) {
if (this.pathMatcher.match(pattern + extension, lookupPath)) {
return pattern + extension;
}
}
}
// 若你没有配置指定后缀匹配,并且你的handler也没有.*这样匹配的,那就默认你的pattern就给你添加上后缀".*",表示匹配所有请求的url的后缀~~~
else {
boolean hasSuffix = pattern.indexOf('.') != -1;
if (!hasSuffix && this.pathMatcher.match(pattern + ".*", lookupPath)) {
return pattern + ".*";
}
}
}
// 若匹配上了 直接返回此patter
if (this.pathMatcher.match(pattern, lookupPath)) {
return pattern;
}
// 这又是它支持的匹配规则。默认useTrailingSlashMatch它也是true
// 这就是为何我们的/hello/也能匹配上/hello的原因
// 从这可以看出,Spring MVC的宽容度是很高的,容错处理做得是非常不错的~~~~~~~
if (this.useTrailingSlashMatch) {
if (!pattern.endsWith("/") && this.pathMatcher.match(pattern + "/", lookupPath)) {
return pattern + "/";
}
}
return null;
}
}
分析了URL的匹配原因,现在肯定知道为何默认情况下"/hello.aaaa"或者"/hello.aaaa/“或者”"/hello/""能匹配上我们/hello
的原因了吧~~~
Spring和SpringBoot中如何关闭此项功能呢?
为何要关闭的理由,上面其实已经说了。当我们涉及到严格的权限校验(强权限控制
)的时候。特备是一些银行系统、资产系统等等,关闭后缀匹配事非常有必要的。
public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMapping implements MatchableHandlerMapping, EmbeddedValueResolverAware {
private boolean useSuffixPatternMatch = true;
private boolean useTrailingSlashMatch = true;
}
可以看到这两个属性值都直接冒泡到RequestMappingHandlerMapping
这个实现类上来了,所以我们直接通过配置来改变它的默认行为就成。
@Configuration
@EnableWebMvc
public class WebMvcConfig extends WebMvcConfigurerAdapter {
// 关闭后缀名匹配,关闭最后一个/匹配
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
configurer.setUseSuffixPatternMatch(false);
configurer.setUseTrailingSlashMatch(false);
}
}
**就这么一下,我们的URL就安全了,再也不能后缀名任意匹配了。**在想用后缀匹配,就甩你四个大字:
RequestMappingInfoHandlerMapping
提供匹配条件RequestMappingInfo
的解析处理。
// @since 3.1 此处泛型为:RequestMappingInfo 用这个类来表示mapping映射关系、参数、条件等
public abstract class RequestMappingInfoHandlerMapping extends AbstractHandlerMethodMapping<RequestMappingInfo> {
// 专门处理Http的Options方法的HandlerMethod
private static final Method HTTP_OPTIONS_HANDLE_METHOD;
static {
try {
HTTP_OPTIONS_HANDLE_METHOD = HttpOptionsHandler.class.getMethod("handle");
} catch (NoSuchMethodException ex) {
throw new IllegalStateException("Failed to retrieve internal handler method for HTTP OPTIONS", ex);
}
}
// 构造函数:给set了一个HandlerMethodMappingNamingStrategy
protected RequestMappingInfoHandlerMapping() {
setHandlerMethodMappingNamingStrategy(new RequestMappingInfoHandlerMethodMappingNamingStrategy());
}
// 复写父类的抽象方法:获取mappings里面的patters们~~~
@Override
protected Set<String> getMappingPathPatterns(RequestMappingInfo info) {
return info.getPatternsCondition().getPatterns();
}
// 校验看看这个Mapping是否能匹配上这个request,若能匹配上就返回一个RequestMappingInfo
@Override
protected RequestMappingInfo getMatchingMapping(RequestMappingInfo info, HttpServletRequest request) {
return info.getMatchingCondition(request);
}
@Override
protected Comparator<RequestMappingInfo> getMappingComparator(final HttpServletRequest request) {
return (info1, info2) -> info1.compareTo(info2, request);
}
...
}
它主要做的事就是确定了泛型类型为:RequestMappingInfo
,然后很多方法都依托它来完成判定逻辑,比如上面三个@Override
方法就是对父类抽象方法的实现。委托给RequestMappingInfo
去实现的~
而RequestMappingInfo
的构建工作,Spring MVC理论上是可以允许有多种方案。鉴于Spring MVC给出的唯一实现类为RequestMappingHandlerMapping
下面就介绍Spring MVC目前的唯一构造方案:通过@RequestMapping
来构造一个RequestMappingInfo
RequestMappingHandlerMapping 唯一实现类
根据@RequestMapping
注解生成RequestMappingInfo
,同时提供isHandler
实现。
直到这个具体实现类,才与具体的实现方式
@RequestMapping
做了强绑定了
有了三层抽象的实现,其实留给本类需要实现的功能已经不是非常的多了~
// @since 3.1 Spring3.1才提供的这种注解扫描的方式的支持~~~ 它也实现了MatchableHandlerMapping分支的接口
// EmbeddedValueResolverAware接口:说明要支持解析Spring的表达式~
public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMapping
implements MatchableHandlerMapping, EmbeddedValueResolverAware {
...
private Map<String, Predicate<Class<?>>> pathPrefixes = new LinkedHashMap<>();
// 配置要应用于控制器方法的路径前缀
// @since 5.1:Spring5.1才出来的新特性,其实有时候还是很好的使的 下面给出使用的Demo
// 前缀用于enrich每个@RequestMapping方法的映射,至于匹不匹配由Predicate来决定 有种前缀分类的效果~~~~
// 推荐使用Spring5.1提供的类:org.springframework.web.method.HandlerTypePredicate
public void setPathPrefixes(Map<String, Predicate<Class<?>>> prefixes) {
this.pathPrefixes = Collections.unmodifiableMap(new LinkedHashMap<>(prefixes));
}
// @since 5.1 注意pathPrefixes是只读的~~~因为上面Collections.unmodifiableMap了 有可能只是个空Map
public Map<String, Predicate<Class<?>>> getPathPrefixes() {
return this.pathPrefixes;
}
public void setUseRegisteredSuffixPatternMatch(boolean useRegisteredSuffixPatternMatch) {
this.useRegisteredSuffixPatternMatch = useRegisteredSuffixPatternMatch;
this.useSuffixPatternMatch = (useRegisteredSuffixPatternMatch || this.useSuffixPatternMatch);
}
// If enabled a method mapped to "/users" also matches to "/users/".
public void setUseTrailingSlashMatch(boolean useTrailingSlashMatch) {
this.useTrailingSlashMatch = useTrailingSlashMatch;
}
@Override
public void afterPropertiesSet() {
// 对RequestMappingInfo的配置进行初始化 赋值
this.config = new RequestMappingInfo.BuilderConfiguration();
this.config.setUrlPathHelper(getUrlPathHelper()); // 设置urlPathHelper默认为UrlPathHelper.class
this.config.setPathMatcher(getPathMatcher()); //默认为AntPathMatcher,路径匹配校验器
this.config.setSuffixPatternMatch(this.useSuffixPatternMatch); // 是否支持后缀补充,默认为true
this.config.setTrailingSlashMatch(this.useTrailingSlashMatch); // 是否添加"/"后缀,默认为true
this.config.setRegisteredSuffixPatternMatch(this.useRegisteredSuffixPatternMatch); // 是否采用mediaType匹配模式,比如.json/.xml模式的匹配,默认为false
this.config.setContentNegotiationManager(getContentNegotiationManager()); //mediaType处理类:ContentNegotiationManager
// 此处 必须还是要调用父类的方法的
super.afterPropertiesSet();
}
...
// 判断该类,是否是一个handler(此处就体现出@Controller注解的特殊性了)
// 这也是为何我们的XXXController用@Bean申明是无效的原因(前提是类上木有@RequestMapping注解,否则也是阔仪的哦~~~)
// 因此我个人建议:为了普适性,类上的@RequestMapping也统一要求加上,即使你不写@Value也木关系,这样是最好的
@Override
protected boolean isHandler(Class<?> beanType) {
return (AnnotatedElementUtils.hasAnnotation(beanType, Controller.class) ||
AnnotatedElementUtils.hasAnnotation(beanType, RequestMapping.class));
}
// 还记得父类:AbstractHandlerMethodMapping#detectHandlerMethods的时候,回去该类里面找所有的指定的方法
// 而什么叫指定的呢?就是靠这个来判定方法是否符合条件的~~~~~
@Override
@Nullable
protected RequestMappingInfo getMappingForMethod(Method method, Class<?> handlerType) {
// 第一步:先拿到方法上的info
RequestMappingInfo info = createRequestMappingInfo(method);
if (info != null) {
// 方法上有。在第二步:拿到类上的info
RequestMappingInfo typeInfo = createRequestMappingInfo(handlerType);
if (typeInfo != null) {
// 倘若类上面也有,那就combine把两者结合
// combile的逻辑基如下:
// names:name1+#+name2
// path:路径拼接起来作为全路径(容错了方法里没有/的情况)
// method、params、headers:取并集
// consumes、produces:以方法的为准,没有指定再取类上的
// custom:谁有取谁的。若都有:那就看custom具体实现的.combine方法去决定把 简单的说就是交给调用者了~~~
info = typeInfo.combine(info);
}
// 在Spring5.1之后还要处理这个前缀匹配~~~
// 根据这个类,去找看有没有前缀 getPathPrefix():entry.getValue().test(handlerType) = true算是hi匹配上了
// 备注:也支持${os.name}这样的语法拿值,可以把前缀也写在专门的配置文件里面~~~~
String prefix = getPathPrefix(handlerType);
if (prefix != null) {
// RequestMappingInfo.paths(prefix) 相当于统一在前面加上这个前缀~
info = RequestMappingInfo.paths(prefix).build().combine(info);
}
}
return info;
}
// 根据此方法/类,创建一个RequestMappingInfo
@Nullable
private RequestMappingInfo createRequestMappingInfo(AnnotatedElement element) {
// 注意:此处使用的是findMergedAnnotation 这也就是为什么虽然@RequestMapping它并不具有继承的特性,但是你子类仍然有继承的效果的原因~~~~
RequestMapping requestMapping = AnnotatedElementUtils.findMergedAnnotation(element, RequestMapping.class);
// 请注意:这里进行了区分处理 如果是Class的话 如果是Method的话
// 这里返回的是一个condition 也就是看看要不要处理这个请求的条件~~~~
RequestCondition<?> condition = (element instanceof Class ?
getCustomTypeCondition((Class<?>) element) : getCustomMethodCondition((Method) element));
// 这个createRequestMappingInfo就是根据一个@RequestMapping以及一个condition创建一个
// 显然如果没有找到此注解,这里就返回null了,表面这个方法啥的就不是一个info~~~~
return (requestMapping != null ? createRequestMappingInfo(requestMapping, condition) : null);
}
// 他俩都是返回的null。protected方法留给子类复写,子类可以据此自己定义一套自己的规则来限制匹配
// Provide a custom method-level request condition.
// 它相当于在Spring MVC默认的规则的基础上,用户还可以自定义条件进行处理~~~~
@Nullable
protected RequestCondition<?> getCustomTypeCondition(Class<?> handlerType) {
return null;
}
@Nullable
protected RequestCondition<?> getCustomMethodCondition(Method method) {
return null;
}
// 根据@RequestMapping 创建一个RequestMappingInfo
protected RequestMappingInfo createRequestMappingInfo(RequestMapping requestMapping, @Nullable RequestCondition<?> customCondition) {
RequestMappingInfo.Builder builder = RequestMappingInfo
// 强大的地方在此处:path里竟然还支持/api/v1/${os.name}/hello 这样形式动态的获取值
// 也就是说URL还可以从配置文件里面读取 Spring考虑很周到啊~~~
// @GetMapping("/${os.name}/hello") // 支持从配置文件里读取此值 Windows 10
.paths(resolveEmbeddedValuesInPatterns(requestMapping.path()))
.methods(requestMapping.method())
.params(requestMapping.params())
.headers(requestMapping.headers())
.consumes(requestMapping.consumes())
.produces(requestMapping.produces())
.mappingName(requestMapping.name());
// 调用者自定义的条件~~~
if (customCondition != null) {
builder.customCondition(customCondition);
}
// 注意此处:把当前的config设置进去了~~~~
return builder.options(this.config).build();
}
@Override
public RequestMatchResult match(HttpServletRequest request, String pattern) { ... }
// 支持了@CrossOrigin注解 Spring4.2提供的注解
@Override
protected CorsConfiguration initCorsConfiguration(Object handler, Method method, RequestMappingInfo mappingInfo) { ... }
}
至此RequestMappingHandlerMapping的初始化完成了。像pathPrefixes
这种配置,可以全局统一配置来控制每个Controller
,如常用的/api/v1
前缀~
如何配置呢?我给出个示例供给你参考:
@Configuration
@EnableWebMvc
public class WebMvcConfig extends WebMvcConfigurerAdapter {
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
//configurer.setUseSuffixPatternMatch(false); //关闭后缀名匹配,关闭最后一个/匹配
//configurer.setUseTrailingSlashMatch(false);
// 这样HelloController上的方法自动就会有此前缀了,而别的controller上是不会有的
// 注意:这是Spring5.1后才支持的新特性
configurer.addPathPrefix("/api/v1", clazz -> clazz.isAssignableFrom(HelloController.class));
// 使用Spring提供的HandlerTypePredicate,更加的强大
HandlerTypePredicate predicate = HandlerTypePredicate.forBasePackage("com.fsx");
//HandlerTypePredicate predicate = HandlerTypePredicate.forBasePackageClass(HelloController.class);
//HandlerTypePredicate predicate = HandlerTypePredicate.forAssignableType(...);
//HandlerTypePredicate predicate = HandlerTypePredicate.forAnnotation(...);
//HandlerTypePredicate predicate = HandlerTypePredicate.builder()
// .basePackage()
// .basePackageClass()
// .build();
configurer.addPathPrefix("/api/v2", predicate);
}
}
细节注意:若添加了两prefix都可以作用在某个Controller
上,那么会按照放入的顺序(因为它是LinkedHashMap
)以先匹配上的为准,可参考RequestMappingHandlerMapping#getPathPrefix
方法~
RequestMappingHandlerMapping
向容器中注册的时候,检测到实现了 InitializingBean
接口,容器去执行afterPropertiesSet()
,在afterPropertiesSet
中完成Controller中完成方法的映射
以上就是Spring MVC在容器启动过程中,完成URL到Handler映射的所有内容~
@RequestMapping
属性详解
使用@RequestMapping
来映射URL 到控制器类,或者是到Controller 控制器的处理方法上。
当@RequestMapping
标记在Controller
类上的时候,里面使用@RequestMapping
标记的方法的请求地址都是相对于类上的@RequestMapping
而言的;当Controller
类上没有标记@RequestMapping
注解时,方法上的@RequestMapping
都是绝对路径。
这种绝对路径和相对路径所组合成的最终路径
都是相对于根路径“/ ”而言的
。
这个注解的属性众多,下面逐个解释一下:
// @since 2.5 用于将Web请求映射到具有灵活方法签名的请求处理类中的方法的注释 Both Spring MVC and `Spring WebFlux` support this annotation
// @Mapping这个注解是@since 3.0 但它目前还只有这个地方使用到了~~~ 我感觉是多余的
@Target({ElementType.METHOD, ElementType.TYPE}) // 能够用到类上和方法上
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Mapping
public @interface RequestMapping {
//给这个Mapping取一个名字。若不填写,就用HandlerMethodMappingNamingStrategy去按规则生成
String name() default "";
// 路径 数组形式 可以写多个。 一般都是按照Ant风格进行书写~
@AliasFor("path")
String[] value() default {};
@AliasFor("value")
String[] path() default {};
// 请求方法:GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE
// 显然可以指定多个方法。如果不指定,表示适配所有方法类型~~
// 同时还有类似的枚举类:org.springframework.http.HttpMethod
RequestMethod[] method() default {};
// 指定request中必须包含某些参数值时,才让该方法处理
// 使用 params 元素,你可以让多个处理方法处理到同一个URL 的请求, 而这些请求的参数是不一样的
// 如:@RequestMapping(value = "/fetch", params = {"personId=10"} 和 @RequestMapping(value = "/fetch", params = {"personId=20"}
// 这两个方法都处理请求`/fetch`,但是参数不一样,进入的方法也不一样~~~~
// 支持!myParam和myParam!=myValue这种~~~
String[] params() default {};
// 指定request中必须包含某些指定的header值,才能让该方法处理请求
// @RequestMapping(value = "/head", headers = {"content-type=text/plain"}
String[] headers() default {};
// 指定处理请求request的**提交内容类型**(Content-Type),例如application/json、text/html等
// 相当于只有指定的这些Content-Type的才处理
// @RequestMapping(value = "/cons", consumes = {"application/json", "application/XML"}
// 不指定表示处理所有~~ 取值参见枚举类:org.springframework.http.MediaType
// 它可以使用!text/plain形如这样非的表达方式
String[] consumes() default {};
// 指定返回的内容类型,返回的内容类型必须是request请求头(Accept)中所包含的类型
// 仅当request请求头中的(Accept)类型中包含该指定类型才返回;
// 参见枚举类:org.springframework.http.MediaType
// 它可以使用!text/plain形如这样非的表达方式
String[] produces() default {};
}
Spring4.3之后提供了组合注解5枚:
@GetMapping
@PostMapping
@PutMapping
@DeleteMapping
@PatchMapping
consumes 与 headers 区别
consumes produces params headers
四个属性都是用来缩小请求范围。
consumes
只能指定 content-Type
的内容类型,但是headers
可以指定所有。
所以可以认为:headers是更为强大的(所有需要指定key和value嘛),而consumes
和produces
是专用的,头的key是固定的,所以只需要写value
值即可,使用起来也更加的方便~。
推荐一个类:
org.springframework.http.HttpHeaders
,它里面有常量:几乎所有的请求头的key,以及我们可以很方便的构建一个HttpHeader,平时可以作为参考使用
Spring MVC默认使用的HandlerMapping是什么?
Spring对这块的设计也是很灵活的,允许你自己配置,也允许你啥都不做使用Spring默认的配置。处理代码在:DispatcherServlet#initHandlerMappings
public class DispatcherServlet extends FrameworkServlet {
// 为此DispatcherServlet 初始化HandlerMappings
// 备注:DispatcherServlet是允许你有多个的~~~~
private void initHandlerMappings(ApplicationContext context) {
this.handlerMappings = null;
//detectAllHandlerMappings该属性默认为true,表示会去容器内找所有的HandlerMapping类型的定义信息
// 若想改为false,请调用它的setDetectAllHandlerMappings() 自行设置值(绝大部分情况下没啥必要)
if (this.detectAllHandlerMappings) {
// 这里注意:若你没有标注注解`@EnableWebMvc`,那么这里找的结果是空的
// 若你标注了此注解,这个注解就会默认向容器内注入两个HandlerMapping:RequestMappingHandlerMapping和BeanNameUrlHandlerMapping
Map<String, HandlerMapping> matchingBeans = BeanFactoryUtils.beansOfTypeIncludingAncestors(context, HandlerMapping.class, true, false);
if (!matchingBeans.isEmpty()) {
this.handlerMappings = new ArrayList<>(matchingBeans.values());
// 多个的话 还需要进行一次排序~~~
AnnotationAwareOrderComparator.sort(this.handlerMappings);
}
}
// 不全部查找,那就只找一个名字为`handlerMapping`的HandlerMapping 实现精准控制
// 绝大多数情况下 我们并不需要这么做~
else {
try {
HandlerMapping hm = context.getBean(HANDLER_MAPPING_BEAN_NAME, HandlerMapping.class);
this.handlerMappings = Collections.singletonList(hm);
} catch (NoSuchBeanDefinitionException ex) {
// Ignore, we'll add a default HandlerMapping later.
}
}
// 若一个都没找到自定义的,回滚到Spring的兜底策略,它会想容器注册两个:RequestMappingHandlerMapping和BeanNameUrlHandlerMapping
if (this.handlerMappings == null) {
this.handlerMappings = getDefaultStrategies(context, HandlerMapping.class);
// 输出trace日志:表示使用了兜底策略~
// 兜底策略配置文件:DispatcherServlet.properties
if (logger.isTraceEnabled()) {
logger.trace("No HandlerMappings declared for servlet '" + getServletName() +
"': using default strategies from DispatcherServlet.properties");
}
}
}
}
通过这段代码,我们能够很清晰的看到。绝大部分情况下,我们容器内会有这两个HandlerMapping Bean:
RequestMappingHandlerMapping和BeanNameUrlHandlerMapping
换句话说,默认情况下@RequestMapping和BeanNameUrl
的方式都是被支持的~
请注意:使用
@EnableWebMvc
和不使用它有一个非常非常重要的区别:
使用@EnableWebMvc
原来是依托于这个WebMvcConfigurationSupport
config类向容器中注入了对应的Bean,所以他们都是交给了Spring管理的(所以你可以@Autowired
他们)
但是,但是,但是(重说三),若是走了Spring它自己去读取配置文件走默认值,它的Bean是没有交给Spring管理的,没有交给Spring管理的。它是这样创建的:context.getAutowireCapableBeanFactory().createBean(clazz)
它创建出来的Bean都不会交给Spring管理。
参考博文:【小家Spring】为脱离Spring IOC容器管理的Bean赋能【依赖注入】的能力,并分析原理(借助AutowireCapableBeanFactory赋能)
小插曲:在Spring5以下,DispatcherServlet.properties
这个配置文件里写的是这样的:
相当于最底层默认使用的是DefaultAnnotationHandlerMapping
,而在Spring5之后,改成了RequestMappingHandlerMapping
。DefaultAnnotationHandlerMapping
是Spring2.5用来处理@RequestMapping
注解的,自从Spring3.2后已被标记为:@Deprecated
需要注意的是:纯Spring MVC环境下我们都会开启
@EnableWebMvc
,所有我们实际使用的还是RequestMappingHandlerMapping
的。
而在SpringBoot环境下,虽然我们一般不建议标注@EnableWebMvc
,但是Boot它默认也会注册RequestMappingHandlerMapping
它的。现在Spring5/Boot2以后一切都爽了~~~~
DefaultAnnotationHandlerMapping
的一个小坑
在功能上DefaultAnnotationHandlerMapping
和RequestMappingHandlerMapping
绝大多数是等价的。但是因为DefaultAnnotationHandlerMapping
过于古老了,它并不支持像@GetMapping
(Spring4.3后提供)这样的组合注解的。 从源码角度理由如下:
比如Handler这么写的:
@ResponseBody
@GetMapping("/hello/test")
public Object test(String userName) {
System.out.println(userName);
return null;
}
DefaultAnnotationHandlerMapping
处理代码为:
...
RequestMapping mapping = AnnotationUtils.findAnnotation(method, RequestMapping.class);
...
值如下:
发现我们的URL并没有获取到。
但是RequestMappingHandlerMapping
的获取代码为:
...
RequestMapping requestMapping = AnnotatedElementUtils.findMergedAnnotation(element, RequestMapping.class);
...
可以发现使用AnnotatedElementUtils.findMergedAnnotation
是支持这个组合注解的。但是AnnotatedElementUtils
整个工具类才Spring4.0后才有,而DefaultAnnotationHandlerMapping
早在Spring3.2后就被标记为废弃了,因为就无需Spring也就无需继续维护了~~~~
所以若你是纯Spring MVC环境,为确保万无一失,请开启SpringMVC:@EnableWebMvc
备注:若使用非组合注解如
@RequestMapping
,两者大体一样。但既然人家都废弃了,所以非常不建议再继续使用~~~
其实在Spring5.以后,就直接把这个两个类拿掉了,所以也就没有后顾之忧了。(DispatcherServlet.properties这个配置文件也做了对应的修改)
总结
Spring MVC
在启动时会扫描所有的@RequestMapping
并封装成对应的RequestMapingInfo
。
一个请求过来会与RequestMapingInfo
进行逐个比较,找到最适合的那个RequestMapingInfo
。
Spring MVC通过HandlerMapping
建立起了Url Pattern和Handler的对应关系,这样任何一个URL请求过来时,就可以快速定位一个唯一的Handler
,然后交给其进行处理了~
当然这里面还有很多实现细节,其中还有一个非常重要的一块:HandlerAdapter
,会在下文继续源码分析,请保持持续关注~
关于定制
RequestMappingHandlerMapping
,以及自定义RequestCondition
来灵活的使用映射器,我推荐可参阅这篇文章:Spring Mvc之定制RequestMappingHandlerMapping
关注A哥
Author | A哥(YourBatman) |
---|---|
个人站点 | www.yourbatman.cn |
yourbatman@qq.com | |
微 信 | fsx641385712 |
活跃平台 | |
公众号 | BAT的乌托邦(ID:BAT-utopia) |
知识星球 | BAT的乌托邦 |
每日文章推荐 | 每日文章推荐 |
更多推荐
所有评论(0)