开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

针对 SpringSecurity 鉴权流程做了一个详细分析

发表于 2021-10-18

本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。

纸上得来终觉浅,绝知此事要躬行。

阅读本文:

如需简单使用👉:SpringBoot集成SpringSecurity做安全框架、附源码

你能收获:🛴

  1. 你能大致明白 SpringSecurity 鉴权流程。
  2. 能够 Debug 一步一步能够画出 SpringSecurity 鉴权流程图。
  3. 对于 SpringSecurity 框架会有更深一步的理解,能够在使用时做到更高程度的定制化。
  4. 以及对 SpringSecurity 更深一步的思考

一、前言:

xdm,我还是没有学会写小故事😭,我只可以在这里请你们喝可乐🥤,请 xdm 赏个一键三连😁。

image-20211015195832816

xdm,不知道你们在使用SpringSecurity安全框架的时候,有没有想过 debug 一步一步看它是如何实现判断是否可以访问的?

如下:

1
2
3
4
5
java复制代码@PreAuthorize("hasRole('ROLE_ADMIN')")
@RequestMapping("/role/admin1")
String admin() {
return "role: ROLE_ADMIN";
}

为什么我们写上这个注解可以了呢?如何进行判断的呢?

前面写过一次👨‍💻 SpringSecurity 登录流程分析,写那篇文章是为了写👩‍💻 SpringSecurity 实现多种登录方式做铺垫。

那么这次写这个文章的原因呢?

在掘金看到了掘友的 和耳朵 写的 SpringSecurity 动态鉴权流程分析,才发觉用注解其实也不是个非常好的事情,直接固定在项目,无法做到动态的更改,是个要不得的事情(捂脸),之前只考虑到这么写蛮好的,看完文章才恍然大悟。这两天也准备实现一下Security的动态鉴权的小demo。

xdm,一定要记得,纸上得来终觉浅,绝知此事要躬行,尤其是一路 debug 的文章,亲身踩坑。

对于一门技术,会使用是说明我们对它已经有了一个简单了解,把脉络、细节都掌握清楚,我们才能更好的使用。

接下来就让👨‍🏫来带大家一起看看吧。

二、流程图:

下图是在百度找的一张关于 Security 原理图

image-20211015202632522

我接下来画的流程图是基于用户已经登录的状态下的画的。

整个认证的过程其实一直在围绕图中过滤链的绿色部分,而我们今天要说的鉴权主要是围绕其橙色部分,也就是图上标的:FilterSecurityInterceptor。

这也就是我流程图的开始,如下图:

image-20211016152246436

上图如有不妥之处,请大家批正,在此郑重感谢。

关于上图的粗略解释,后文再一一道来:

1、登录后,用户访问一个需要权限的接口,经过一连串过滤器,到达 FilterSecurityInterceptor, FilterSecurityInterceptor 的invoke()方法执行具体拦截行为,具体是 beforeInvocation、finallyInvocation、afterInvocation 这三个方法,这三个方法是定义在父类 AbstractSecurityInterceptor 中。

2、调用 AbstractSecurityInterceptor 的 beforeInvocation 方法。AbstractSecurityInterceptor将确保安全拦截器的正确启动配置。它还将实现对安全对象调用的正确处理,即:

  1. 获取访问当前资源所需要的权限SecurityMetadataSource..getAttributes(object);返回个 Collection< ConfigAttribute > attributes
  2. 从SecurityContextHolder获取Authentication对象。 `Authentication authenticated = authenticateIfRequired();
  3. 尝试授权 attemptAuthorization(object, attributes, authenticated); 调用 AccessDecisionManager 接口 decide 方法,执行鉴权,鉴权不成功,会直接抛异常。
  4. 返回一个InterceptorStatusToken。

3、经过千辛万苦后,到达MethodSecurityInterceptor,由它再次重新调用起 AbstractSecurityInterceptor.beforeInvocation(mi) 方法,来进行权限的验证

  • 鉴权的时候,投票者会换成 PreInvocationAuthorizationAdviceVoter

进入正题前先放张图片缓一缓:

123456.jpg

当乌云和白云相遇


👨‍💻

image-20211015201424942

三、前半部分

前半部分作用是在检测用户的状态,并非就是执行鉴权,不过两次都十分相近。关于方法上注解的检测是在后半部分。

1)入口:FilterSecurityInterceptor

第一步:FilterSecurityInterceptor void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)

1
2
3
4
5
6
java复制代码//过滤器链实际调用的方法。 简单地委托给invoke(FilterInvocation)方法。
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
invoke(new FilterInvocation(request, response, chain));
}

接着看 void invoke(FilterInvocation filterInvocation)

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
java复制代码public void invoke(FilterInvocation filterInvocation) throws IOException, ServletException {
if (isApplied(filterInvocation) && this.observeOncePerRequest) {
//过滤器已应用于此请求,用户希望我们观察每个请求处理一次,因此不要重新进行安全检查
filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
return;
}
// 第一次调用这个请求,所以执行安全检查
if (filterInvocation.getRequest() != null && this.observeOncePerRequest) {
filterInvocation.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
}

//调用 beforeInvocation(filterInvocation) 方法 跟着这个方法往下看
InterceptorStatusToken token = super.beforeInvocation(filterInvocation) ;
try {
//每个过滤器都有这么一步
filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
}
finally {
//在安全对象调用完成后清理AbstractSecurityInterceptor的工作。
//无论安全对象调用是否成功返回,都应该在安全对象调用之后和 afterInvocation 之前调用此方法(即它应该在 finally 块中完成)。
super.finallyInvocation(token);
}
//当调用afterInvocation(InterceptorStatusToken,Object)时,AbstractSecurityInterceptor不会采取进一步的操作。
super.afterInvocation(token, null);
}

2)进入:AbstractSecurityInterceptor

授权检查 beforeInvocation() 方法

第二步:super.beforeInvocation(filterInvocation); 一些打印信息被精简了,太长不适合阅读

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
28
29
30
31
32
33
34
35
36
37
38
java复制代码protected InterceptorStatusToken beforeInvocation(Object object) {
//检查操作
Assert.notNull(object, "Object was null");
if (!getSecureObjectClass().isAssignableFrom(object.getClass())) {
//....
}
//这里获取的信息看下图示1 :
//object 就是调用处传过来的参数 FilterInvocation filterInvocation,它本身其实就是 HttpServletRequest 和 HttpServletResponse 的增强
//object :filter invocation [GET /role/admin1] "
//然后我们获取到的就是受保护调用的列表
Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource().getAttributes(object);
if (CollectionUtils.isEmpty(attributes)) {
//...
return null; // no further work post-invocation
}
//在 SecurityContext 中未找到身份验证对象,会发事件抛异常
if (SecurityContextHolder.getContext().getAuthentication() == null) {
credentialsNotFound(this.messages.getMessage("AbstractSecurityInterceptor.authenticationNotFound",
"An Authentication object was not found in the SecurityContext"), object, attributes);
}

//在这里拿到了 Authentication 对象登录的信息 ,后文会简单说是如何拿到的
Authentication authenticated = authenticateIfRequired();
if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.format("Authorizing %s with attributes %s", object, attributes));
}
// Attempt authorization : 尝试授权 这步本文重点,用我的话来说,这就是鉴权的入口 重点关注,下文继续
attemptAuthorization(object, attributes, authenticated);
//...
// Attempt to run as a different user
Authentication runAs = this.runAsManager.buildRunAs(authenticated, object, attributes);
if (runAs != null) {
//...
}
// 无后续动作
return new InterceptorStatusToken(SecurityContextHolder.getContext(), false, attributes, object);

}

关于 Collection< ConfigAttribute > attributes = this.obtainSecurityMetadataSource().getAttributes(object);这段代码。

第一次访问这里的时候,FilterSecurityInterceptor是从 SecurityMetadataSource 的子类 DefaultFilterInvocationSecurityMetadataSource 获取到当前的是这样的数据。它和我们第二次来执行这里有很大的区别。这里的表达式是 authenticated,翻译过来就是认证过的。

image-20211016160929332

在后文会进行比较的。

我们接着往下看:Authentication authenticateIfRequired() 获取身份信息

1
2
3
4
5
6
7
8
9
10
11
java复制代码//如果Authentication.isAuthenticated()返回 false 或属性alwaysReauthenticate已设置为 true,
//则检查当前的身份验证令牌并将其传递给 AuthenticationManager进行身份验证
private Authentication authenticateIfRequired() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication.isAuthenticated() && !this.alwaysReauthenticate) {
return authentication;
}
authentication = this.authenticationManager.authenticate(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
return authentication;
}

3)尝试授权: attemptAuthorization()

第三步:尝试授权: attemptAuthorization()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码private void attemptAuthorization(Object object, Collection<ConfigAttribute> attributes,
Authentication authenticated) {
try {
//接着套娃 我们去看 AccessDecisionManager 下的 decide() 方法
this.accessDecisionManager.decide(authenticated, object, attributes);
}
catch (AccessDeniedException ex) {
if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.format("Failed to authorize %s with attributes %s using %s", object,
attributes, this.accessDecisionManager));
}
else if (this.logger.isDebugEnabled()) {
this.logger.debug(LogMessage.format("Failed to authorize %s with attributes %s", object, attributes));
}
publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated, ex));
throw ex;
}
}

AccessDecisionManager 决策器说明:

this.accessDecisionManager 其实是个接口。我们一起看看它的源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码public interface AccessDecisionManager {

/**
为传递的参数解析访问控制决策。
参数:
身份验证 - 调用方法的调用者(非空)
object – 被调用的安全对象
configAttributes – 与被调用的安全对象关联的配置属性
*/
void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes)
throws AccessDeniedException, InsufficientAuthenticationException;


// 下面这两个方法主要起辅助作用的。大都执行检查操作
boolean supports(ConfigAttribute attribute);

boolean supports(Class<?> clazz);

}

我们先看看这个接口结构,之后再看它的实现类内部鉴权机制是如何执行的,需要获取那些信息,又是如何判断它是否可以通过的。

我们可以看到这个 AccessDecisionManager 接口,接口下有一个抽象类,然后再有了三个实现类。

image-20211015132038199

他们分别代表不同的机制。

  1. AffirmativeBased:如果任何AccessDecisionVoter返回肯定响应,则授予访问权限。即有一票同意,就可以通过,默认是它。
  2. ConsensusBased:少数服从于多数。多数票同意通过,即可以通过。如民主选举制一样。
  3. UnanimousBased:要求所有选民弃权或授予访问权限。简称一票反对。只要有一票反对就不能通过。

一起看看默认用的 AffirmativeBased:

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
28
29
30
31
32
33
34
35
java复制代码public class AffirmativeBased extends AbstractAccessDecisionManager {

public AffirmativeBased(List<AccessDecisionVoter<?>> decisionVoters) {
super(decisionVoters);
}

/**
这个具体的实现只是轮询所有配置的AccessDecisionVoter并在任何AccessDecisionVoter投赞成票时授予访问权限。 仅当存在拒绝投票且没有赞成票时才拒绝访问。
如果每个AccessDecisionVoter放弃投票,则决策将基于isAllowIfAllAbstainDecisions()属性(默认为 false)。
*/
@Override
@SuppressWarnings({ "rawtypes", "unchecked" })
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes)
throws AccessDeniedException {
int deny = 0;
for (AccessDecisionVoter voter : getDecisionVoters()) {
int result = voter.vote(authentication, object, configAttributes);
switch (result) {
case AccessDecisionVoter.ACCESS_GRANTED:
return;
case AccessDecisionVoter.ACCESS_DENIED:
deny++;
break;
default:
break;
}
}
if (deny > 0) {
throw new AccessDeniedException(
this.messages.getMessage("AbstractAccessDecisionManager.accessDenied", "Access is denied"));
}
// To get this far, every AccessDecisionVoter abstained
checkAllowIfAllAbstainDecisions();
}
}

到这里又会牵扯到 AccessDecisionVoter 出来,也就是能够投票的选民们。

AccessDecisionVoter 投票观众接口

我们先一起来看它的源码,再看看它的实现类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码//表示一个类负责对授权决定进行投票。
//投票的协调(即轮询AccessDecisionVoter ,统计他们的响应,并做出最终授权决定)由AccessDecisionManager执行。
public interface AccessDecisionVoter<S> {

int ACCESS_GRANTED = 1;

int ACCESS_ABSTAIN = 0;

int ACCESS_DENIED = -1;

//这两个用来执行check操作,判断参数是否合法等等
boolean supports(ConfigAttribute attribute);
boolean supports(Class<?> clazz);

/**
指示是否授予访问权限。
决定必须是肯定的 ( ACCESS_GRANTED )、否定的 ( ACCESS_DENIED ) 或者 AccessDecisionVoter可以弃权 ( ACCESS_ABSTAIN ) 投票。
在任何情况下,实现类都不应返回任何其他值。 如果需要对结果进行加权,则应改为在自定义AccessDecisionManager处理。
除非AccessDecisionVoter由于传递的方法调用或配置属性参数而专门用于对访问控制决策进行投票,否则它必须返回ACCESS_ABSTAIN 。
这可以防止协调AccessDecisionManager计算来自那些AccessDecisionVoter的选票,而这些AccessDecisionVoter对访问控制决策没有合法利益。
虽然安全对象(例如MethodInvocation )作为参数传递以最大限度地提高访问控制决策的灵活性,但实现类不应修改它或导致所表示的调用发生(例如,通过调用MethodInvocation.proceed() ) .
*/
int vote(Authentication authentication, S object, Collection<ConfigAttribute> attributes);
}

我们看看它的结构:

image-20211015133632166

  1. RoleVoter 主要用来判断当前请求是否具备该接口所需要的角色
  2. RoleHierarchyVoter 是 RoleVoter 的一个子类,在 RoleVoter 角色判断的基础上,引入了角色分层管理,也就是角色继承
  3. WebExpressionVoter 这是一个基于表达式权限控制的投票器
  4. Jsr250Voter 处理 Jsr-250 权限注解的投票器,如 @PermitAll,@DenyAll 等。
  5. AuthenticatedVoter 用于判断 ConfigAttribute 上是否拥有 IS_AUTHENTICATED_FULLY、IS_AUTHENTICATED_REMEMBERED、IS_AUTHENTICATED_ANONYMOUSLY 三种角色。
  6. AbstractAclVoter 提供编写域对象 ACL 选项的帮助方法,没有绑定到任何特定的 ACL 系统。
  7. PreInvocationAuthorizationAdviceVoter 使用 @PreFilter 和 @PreAuthorize 注解处理的权限,通过 PreInvocationAuthorizationAdvice 来授权。

AffirmativeBased默认传入的构造器只有一个 WebExpressionVoter,这个构造器会根据你在配置文件中的配置进行逻辑处理得出投票结果。

所以我们在执行第一次循环时,也是在这里处理的。

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
28
29
30
31
32
33
34
35
36
java复制代码public class WebExpressionVoter implements AccessDecisionVoter<FilterInvocation> {
private SecurityExpressionHandler<FilterInvocation> expressionHandler = new DefaultWebSecurityExpressionHandler();

@Override
public int vote(Authentication authentication, FilterInvocation filterInvocation,
Collection<ConfigAttribute> attributes) {
//...执行的一些检查
//
WebExpressionConfigAttribute webExpressionConfigAttribute = findConfigAttribute(attributes);
if (webExpressionConfigAttribute == null) {
return ACCESS_ABSTAIN;
}
//允许对EvaluationContext进行后处理。 实现可能会返回一个新的EvaluationContext实例或修改传入的EvaluationContext 。
EvaluationContext ctx = webExpressionConfigAttribute.postProcess(
//调用内部模板方法来创建StandardEvaluationContext和SecurityExpressionRoot对象。
this.expressionHandler.createEvaluationContext(authentication, filterInvocation), filterInvocation);
//针对指定的根对象评估默认上下文中的表达式。 如果评估结果与预期结果类型不匹配(并且无法转换为),则将返回异常。
boolean granted = ExpressionUtils.evaluateAsBoolean(webExpressionConfigAttribute.getAuthorizeExpression(), ctx);
// 投赞同票,返回
if (granted) {
return ACCESS_GRANTED;
}
return ACCESS_DENIED;
}

//循环判断
private WebExpressionConfigAttribute findConfigAttribute(Collection<ConfigAttribute> attributes) {
for (ConfigAttribute attribute : attributes) {
if (attribute instanceof WebExpressionConfigAttribute) {
return (WebExpressionConfigAttribute) attribute;
}
}
return null;
}
//...
}

image-20211016163159648

在这里的数据也是如此,和我们上文就互相对应上了。

4)返回过程

4.1、先返回至AffirmativeBased.decide()方法处,投票通过,继续 retrun

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码for (AccessDecisionVoter voter : getDecisionVoters()) {
int result = voter.vote(authentication, object, configAttributes);
switch (result) {
case AccessDecisionVoter.ACCESS_GRANTED:
return;
case AccessDecisionVoter.ACCESS_DENIED:
deny++;
break;
default:
break;
}
}

4.2、返回至 AbstractSecurityInterceptor 方法调用处,这里是无返回值,直接回到 beforeInvocation 方法中。

1
2
3
4
5
6
java复制代码private void attemptAuthorization(Object object, Collection<ConfigAttribute> attributes,
Authentication authenticated) {
try {
this.accessDecisionManager.decide(authenticated, object, attributes);
}
}

4.3、再返回至beforeInvocation 方法中,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码protected InterceptorStatusToken beforeInvocation(Object object) {
// 返回到这里,我们再顺着往下看,看如何执行
attemptAuthorization(object, attributes, authenticated);
// Attempt to run as a different user :尝试以其他用户身份运行
Authentication runAs = this.runAsManager.buildRunAs(authenticated, object, attributes);
if (runAs != null) {
SecurityContext origCtx = SecurityContextHolder.getContext();
SecurityContextHolder.setContext(SecurityContextHolder.createEmptyContext());
SecurityContextHolder.getContext().setAuthentication(runAs);
// 需要恢复到 token.Authenticated 调用后 true的意思是:如果能以其他用户运行 就执行刷新
return new InterceptorStatusToken(origCtx, true, attributes, object);
}
return new InterceptorStatusToken(SecurityContextHolder.getContext(), false, attributes, object);
}

4.4、回到了我们梦开始的地方了:FilterSecurityInterceptor.invoke() 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码	public void invoke(FilterInvocation filterInvocation) throws IOException, ServletException {
if (isApplied(filterInvocation) && this.observeOncePerRequest) {
// 过滤器已应用于此请求,用户希望我们观察每个请求处理一次,因此不要重新进行安全检查
filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
return;
}
// 第一次调用这个请求,所以执行安全检查
if (filterInvocation.getRequest() != null && this.observeOncePerRequest) {
filterInvocation.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
}
//返回至此处
//InterceptorStatusToken类上的doc注释说:
//AbstractSecurityInterceptor子类接收的返回对象。
//这个类反映了安全拦截的状态,以便最终调用AbstractSecurityInterceptor.afterInvocation(InterceptorStatusToken, Object)可以正确整理。
InterceptorStatusToken token = super.beforeInvocation(filterInvocation);
try {
//每个过滤器的必备代码
filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
}
finally {
super.finallyInvocation(token);
}
super.afterInvocation(token, null);
}

四、后半部分

对方法注解的鉴权,是真的一步一步看它如何执行的,一直扒,真的是历经千辛万苦。

image-20211016140515085

默认大家都能看的懂这个图了,我们直接转到 MethodSecurityInterceptor 里来看看它做了什么吧

4.1、入口:MethodSecurityInterceptor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码//提供对基于 AOP 联盟的方法调用的安全拦截。
//此安全拦截器所需的SecurityMetadataSource是MethodSecurityMetadataSource类型。 这与基于 AspectJ 的安全拦截器 ( AspectJSecurityInterceptor ) 共享,因为两者都与 Java Method 。
public class MethodSecurityInterceptor extends AbstractSecurityInterceptor implements MethodInterceptor {

private MethodSecurityMetadataSource securityMetadataSource;

//此方法应用于对MethodInvocation强制实施安全性。
@Override
public Object invoke(MethodInvocation mi) throws Throwable {
//beforeInvocation 这个有没有似曾相识 ,莫错哈 就是我们之前在 FilterSecurityInterceptor 看到的那个
//需要注意到的是 之前我们传的参是一个 FilterInvocation ,这里则是一个 MethodInvocation 。
InterceptorStatusToken token = super.beforeInvocation(mi);
Object result;
try {
result = mi.proceed();
}
finally {
super.finallyInvocation(token);
}
return super.afterInvocation(token, result);
}
//...
}

MethodInvocation :doc注释是”方法调用的描述,在方法调用时提供给拦截器。方法调用是一个连接点,可以被方法拦截器拦截”.

4.2、进入 AbstractSecurityInterceptor

授权检查 beforeInvocation() 方法

image-20211016171816409

另外在这里debug获取到的值也是不一样的,这点上文我刚刚也说过了。

获取资源访问策略:FilterSecurityInterceptor 会从 SecurityMetadataSource 的子类 DefaultFilterInvocationSecurityMetadataSource 获取要访问当前资源所需要的权限 Collection< ConfigAttribute >。 SecurityMetadataSource 其实就是读取访问策略的抽象,而读取的内容,其实就是我们配置的访问规则, 读取访问策略如:

1
2
3
4
5
6
7
java复制代码protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/r/r1").hasAuthority("r1")
.antMatchers("/r/r2").hasAuthority("r2")
....
}

image-20211016182852123

中间的过程同上半部分差不多,就不多说了。我们直接看 AffirmativeBased 情况如何。

4.3、转战:AffirmativeBasedl;

1
2
java复制代码attemptAuthorization(object, attributes, authenticated);
this.accessDecisionManager.decide(authenticated, object, attributes);

接着往下,到此处就同之前稍有不同了,我们之前用到的是 WebExpressionVoter,在这里我们使用的是: PreInvocationAuthorizationAdviceVoter

image-20211015160437849

我们接着进入:PreInvocationAuthorizationAdviceVoter,它的类上的doc注释如下:

Voter 使用从 @PreFilter 和 @PreAuthorize 注释生成的 PreInvocationAuthorizationAdvice 实现来执行操作。
在实践中,如果使用这些注解,它们通常会包含所有必要的访问控制逻辑,因此基于投票者的系统并不是真正必要的,包含相同逻辑的单个AccessDecisionManager就足够了。 然而,这个类很容易与 Spring Security 使用的传统的基于投票者的AccessDecisionManager实现相适应。

我们可以很容易的看出,这个就是处理方法上注解的那个类。接着看下它的源码。

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
28
29
java复制代码public class PreInvocationAuthorizationAdviceVoter implements AccessDecisionVoter<MethodInvocation> {

private final PreInvocationAuthorizationAdvice preAdvice;

public PreInvocationAuthorizationAdviceVoter(PreInvocationAuthorizationAdvice pre) {
this.preAdvice = pre;
}
//...一些检查方法
@Override
public int vote(Authentication authentication, MethodInvocation method, Collection<ConfigAttribute> attributes) {
// 查找 prefilter 和 preauth(或组合)属性,如果两者都为 null,则弃权使用它们调用建议
PreInvocationAttribute preAttr = findPreInvocationAttribute(attributes);
if (preAttr == null) {
// 没有基于表达式的元数据,所以弃权
return ACCESS_ABSTAIN;
}
//在这里又委托给 PreInvocationAuthorizationAdvice接口的before方法来做判断
return this.preAdvice.before(authentication, method, preAttr) ? ACCESS_GRANTED : ACCESS_DENIED;
}

private PreInvocationAttribute findPreInvocationAttribute(Collection<ConfigAttribute> config) {
for (ConfigAttribute attribute : config) {
if (attribute instanceof PreInvocationAttribute) {
return (PreInvocationAttribute) attribute;
}
}
return null;
}
}

简单看一下PreInvocationAuthorizationAdvice接口的before方法的默认实现:

before方法的说明是:应该执行的“before”建议以执行任何必要的过滤并决定方法调用是否被授权。

image-20211015161226479

我们先说说它的参数:(Authentication authentication,MethodInvocation mi,PreInvocationAttribute attr),第一个就是当前登录的用户,二就是要执行的方法,三就是方法上的注解信息。
我们可以很简单的看出这段代码的含义,就是在比较已经登录的用户,是否拥有这个方法上所需要的权限。

另外简单说明一下:

  1. createEvaluationContext 的dco注释:提供评估上下文,在其中评估调用类型的安全表达式(即 SpEL 表达式)。我个人对这块没有特别深入过,没法说清楚,大家可以查一查。
  2. 另外我们看一下debug的详细信息,大家应该就差不多能懂啦。

image-20211016182251432

接下来就是一步一步返回啦

最后就是:

image-20211015162556081

这里的 result 就是方法执行的返回结果。紧接着就是一步一步返回过滤器链啦。

对于这里 proceed 方法就不再深入了。这个点拉出来说,怕是直接可以写上一篇完整的文章啦。

内部很多动态代理啊、反射啊这些相关的,一层套一层的,不是咱研究重点。溜啦溜啦。

五、小结

这张图是在百度上搜到的,大致流程其实就是如此。

20200503214152166

其实内部还有很多很多值得推敲的东西,不是在这一篇简单的文章中能够写出来的。

六、自我感受

还记得我第一次说要看源码是在准备研究 Mybatis 的时候,那时候上头看了大概几天吧,看着看着就看不下去了,找不到一个合适的方法,什么都想看,没有一个非常具体的目标,导致连续受挫,结果就是不了了之了。

第二次真正意义看源码就是看 Security 。原因是当时在写项目的时,我的前端小伙伴说,现在大部分网站都有多种登录方式,你能实现不?

男人肯定是不能说不行,然后我就一口答应下来了。结果就是疯狂百度、google,到处看博客。互联网这么庞大,当然也有找到非常多的例子,也有源码解析。但是找到的文章,要么只贴出了核心代码,要么就是不合适(庞大,难以抽取),总之一句话没法运行。就很烦操。

不过文章中都提到了要理解 Security 的登录过程,然后进行仿写,俗称抄作业。最后,真就是一步一步 debug 去看 Security 的登录过程,写出了 第一篇 Security登录认证流程分析,紧接着又去用 SpringSecurity实现多种登录方式,如邮件验证码、电话号码登录。这次即是机缘巧合,也是心有所念,耗费不少时间写出了这篇文章。感觉还是非常不错的。

希望大家能够喜欢,如果 xdm 对此也感兴趣,希望大家在有时间的情况,debug 几次,记忆会深刻很多。并竟 纸上得来终觉浅,绝知此事要躬行。

相关文章:

  1. SpringBoot集成SpringSecurity做安全框架
  2. Security的登录流程详解
  3. Security实现多种登录方式、邮件验证码、手机验证码登录。
  4. SpringSecurity权限命名ROLE_问题

今天的文章就到这里了。

你好,我是博主宁在春:主页

如若在文章中遇到疑惑,请留言或私信,或者加主页联系方式,都会尽快回复。

如若发现文章中存在问题,望你能够指正,不胜感谢。

如果觉得对你有所帮助的话,请点个赞再走吧!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【奇技淫巧】Linux 统计网络-netstat

发表于 2021-10-18

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

在构建生产服务器时,我们有的时候需要统计网络接口状况,比如TCP、UDP端口开放的情况,这时我们需要用到netstat。

一、命令介绍

netstat命令最主要的功能是对网络信息进行统计,其实这个命令的拼写本身就能看出不少东西,netstat=network+statistics。network代表“网络”,statistics代表“统计”,所以两者的结合就代表其能提供的功能。说的更加通俗易懂一点,其实这个命令能让用户了解你的电脑正在网络上做什么。

二、用法介绍

netstat 可以显示很多信息,但是我们可以用参数来控制显示信息的种类和样式。
netstat -i
我们常用的可选项参数就是-i,输出会显示一张统计列表,列出你电脑的所有网络接口的一些统计信息。

1
bash复制代码$netstat -i

图片.png

可以清晰的看出列出了四条信息,docker0和veth171093d都是docker相关的网络接口信息。eth0是以太网接口信息,lo表示 Local Loopback(本地回环)。

后面几列的信息,RX 是 receive(表示“接收”)的缩写,TX 是 transmit(表示“发送”)的缩写,这种缩写形式在通信方面最为常见。

  • RX-OK : 在此接口接收的包中正确的包数。OK 表示“没问题,好的”;
  • RX-ERR : 在此接口接收的包中错误的包数。ERR 是 error 的缩写,表示“错误”;
  • RX-DRP : 在此接口接收的包中丢弃的包数。DRP 是 drop 的缩写,表示“丢掉”;
  • RX-OVR : 在此接口接收的包中没能接收的包数。OVR 是 over 的缩写,表示“结束”。

类似的,TX-OK、TX-ERR、TX-DR 和 TX-OVR 则表示在此接口放送的包中对应的包数。

MTU是 Maximum Transmission Unit 的缩写,表示“最大传输单元”,是指一种通信协议的某一层上面所能通过的最大数据包大小(以字节为单位)。

netstat -uta
这个命令是列出所有开启的网络连接。

1
bash复制代码$netstat -uta

图片.png

参数 uta分别表示:

  • -u : 显示 UDP 连接(u 是 udp 的首字母)
  • -t : 显示 TCP 连接(t 是 tcp 的首字母)
  • -a : 不论连接的状态如何,都显示(a 是 all 的首字母)

如果只显示 TCP 连接的信息:

1
bash复制代码$netstat -ta

或者只显示 UDP 连接的信息(不常用):

1
bash复制代码$netstat -ua

state(“状态”)那一列的信息,有但不仅限于以下的状态:

  • ESTABLISHED:与远程电脑的连接已建立,establish 是英语“建立”的意思;
  • TIME_WAIT : 连接正在等待网络上封包的处理,一旦处理完毕就开始关闭连接。time 是英语“时间”的意思,wait 是英语“等待”的意思;
  • CLOSE_WAIT :远程服务器中止了连接(也许你太久没什么动作,处在不活跃状态)。close 是英语“关闭”的意思;
  • CLOSED :连接没有被使用,关闭了;
  • CLOSING :连接正在关闭,但有些数据还没有发送完毕;
  • LISTEN :监听着可能进入的连接。此时连接还没有被使用。listen 是英语“听”的意思。

上面就大概是是netstat主要的内容,另外,假如你想让端口信息以数字的形式显示,可以使用-n可选项参数。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

聊一聊Jmeter多用户token使用问题

发表于 2021-10-18

背景

在测试的时候,经常会有模拟用户登录,拿到用户 token 后再去请求接口的场景。

这个模拟用户登录就会分为两种,一种是单用户,另一种是多用户。

日常自动化测试的时候可能一个用户对应 n 个用例就可以满足大多数场景;

如果是在压力测试的场景下面,可能就会略显单调,也无法满足一些真实业务场景。

对于单用户的情况下,和我们常规的多接口有依赖的测试其实没什么太大的差别。

所以这里主要讲的是多用户产生多个 token 的情况。

下面来看一个具体的例子来了解一下。

场景接口

在这里的话,只有两个接口,一个是登录拿 token,一个是有 token 才能请求的。

下面是各接口定义

登录接口

请求:

1
2
3
4
5
6
7
HTTP复制代码POST http://localhost:8532/MultiToken/Login
Content-Type: application/json

{
"UserName":"catcherwong-1",
"Password":"123"
}

响应:

1
JSON复制代码{"code":0,"msg":"ok","data":"catcherwong-1-token"}

业务接口

请求:

1
2
3
HTTP复制代码GET http://localhost:8532//MultiToken/do?account=xxx
Content-Type: application/json
token: catcherwong-1-token

响应:

1
JSON复制代码{"code":0,"msg":"ok","data":"catcherwong-1-token"}

登录接口处理

登录接口属于预请求,所以我们一般会选择把它放在 setUp 线程组里面。

我们需要准备一个 csv 文件,里面用来存放需要登录的用户名和密码。

接下来就是把这个 csv 配置好,定义了两个变量 account 和 pwd

然后是把登录的 HTTP 请求配置好:

由于后面要用到 token,所以要先把 token 提取出来,这里用的是 JSON Extractor。

到这里就要开始注意了!!!!

由于我们会有多个用户进行登录,但是这一个提取操作每次都会把 token 赋值到 access_token 这个变量上面,是覆盖的操作。

换句话就是说,每登录一个用户,这个 access_token 的值就会是最后一个登录的用户的 token,。

换个思路,每次它会覆盖,那么把这些 token 存到一个地方,然后业务接口去这个地方取就可以了。

如果没有用户登录这一步,给的直接是 token,那么我们也是直接把这个 token 放到 csv 文件里面,然后让 jmeter 去循环使用里面的 token。

那么要做的东西其实就很确定了,就是在提取到 token 后,把这个 token 写到一个 csv 文件里面。

要想做到这一步,需要在登录接口后面加一个后置的处理。

1
2
3
4
5
6
7
8
9
10
java复制代码String p1 = System.getProperty("user.dir");
String p2 = System.getProperty("file.separator");
String p3 = "user_token.csv";
String path = p1 + p2 + p3;

FileWriter fileWriter = new FileWriter(new File(path), true);
BufferedWriter writer = new BufferedWriter(fileWriter);
writer.append(vars.get("accout")+","+vars.get("access_token")+"\n");
writer.close();
fileWriter.close();

这段代码的意思是,把用户名和提取到的 access_token 写进到 csv 文件里面,这个文件在的位置是 jmeter 的目录。

这里是对文件路径做了处理,可以适配所有操作系统的。不会出现说指定了一个 windows 系统的路径,然后放到 linux 系统下面就跑不了了。

还有最重要的一个是,要修改 setUp 线程组的属性,把循环次数改成 3 。因为前面的 csv 文件里面有 3 个用户,这样它才会触发三次登录。

业务接口处理

业务接口要放到正常的线程组里面,独立于 setUp 线程组。

前面提到,登录后会有一个 csv 文件,所以这里第一个要做的是把 csv 配置好。

上面的截图用的是 ${__P(user.dir,)}${__P(file.separator,)}user_token.csv 这个文件路径,这个在本地环境的 Jmeter 是可以通过的,不过在一些云服务上面是不行的,如阿里云 PTS 。

这里可以忽略前面的路径,直接填写 user_token.csv 即可,填这两个,得到的文件路径是一样的,一个是绝对路径一个是相对路径。

然后就是配置 HTTP 请求了

PS:不要忘记把请求头也配置了,这里就不截图了。

这里试跑两次,可以发现业务请求的接口,它的 token 请求头每次都是不一样的,在交替变化,这个是符合预期的。

但是会发现生成 csv 文件里面的数据会重复,没有自动清理掉上一次产生的数据。如果上一次产生的 token 过期了,那么用了这些过期的 token === 凉凉。

所以这里还有必要加一步 tearDown 线程组,每次跑完脚本把这个文件删除掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码String p1 = System.getProperty("user.dir");
String p2 = System.getProperty("file.separator");
String p3 = "user_token.csv";
String path = p1 + p2 + p3;

log.info("准备删除文件:" + path);

File file = new File(path);
if (!file.exists()) {
log.info("删除文件失败:" + path + "不存在!");
} else {
file.delete();
}

这个时候跑脚本就基本没什么问题了。

写在最后

多用户获取多 token 再使用的场景其实挺多的,这篇内容简单讲解了老黄正在用的一个方案,如果您有更好的建议,也欢迎一起沟通交流。

老黄把 JMeter 系列的内容都放在 github 了,方便大家查阅和测试。

github.com/catcherwong…

关注我的公众号「不才老黄」,第一时间和您分享老黄的所见所闻。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

go并发之路(五)——runner

发表于 2021-10-18

本文同时参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。

runner总体设计

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
28
29
30
31
32
33
34
35
36
37
38
39
go复制代码// Package runner 管理进程的运行和生命周期。
package runner

import (
"errors"
"os"
"os/signal"
"time"
)

//Runner 在给定的超时时间内运行一组任务,并且可以在操作系统中断时关闭。
type Runner struct {
// interrupt通道会传送操作系统发送来的信号
interrupt chan os.Signal

// complete 通道会发来协程完成的信号
complete chan error

// timeout 会发来超时信号
timeout <-chan time.Time

// 任务包含一组按索引顺序同步执行的函数。
tasks []func(int)
}

// 当在超时通道上接收到一个值时,返回 ErrTimeout。
var ErrTimeout = errors.New("received timeout")

// 当接收到来自操作系统的事件时,会返回 ErrInterrupt。
var ErrInterrupt = errors.New("received interrupt")

// New 返回一个新的准备被使用的Runner。
func New(d time.Duration) *Runner {
return &Runner{
interrupt: make(chan os.Signal, 1),
complete: make(chan error),
timeout: time.After(d),
}
}

从总体设计上,我们可以看出runner具备以下功能:

  • 程序如果在分配的超时时间内完成工作,可以正常终止;
  • 程序没有及时完成工作,就会选择“自杀”;
  • 程序如果接收到操作系统发送的中断事件,程序立刻试图清理状态并停止工作。

runner细节设计

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
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
go复制代码// 添加任务到 Runner。 任务是一个接受 int整数型变量的函数。
func (r *Runner) Add(tasks ...func(int)) {
r.tasks = append(r.tasks, tasks...)
}

// Start函数运行所有任务并监听通道事件。
func (r *Runner) Start() error {
// 我们希望接收所有的中断信号。
signal.Notify(r.interrupt, os.Interrupt)

//在不同的协程上运行不同的任务。
go func() {
r.complete <- r.run()
}()

select {
// 处理完成时发出信号。
case err := <-r.complete:
return err

// 超时以后发出信号
case <-r.timeout:
return ErrTimeout
}
}

// run函数执行每个注册的任务。
func (r *Runner) run() error {
for id, task := range r.tasks {
// 检查来自操作系统的中断信号。
if r.gotInterrupt() {
return ErrInterrupt
}

// 执行注册的任务。
task(id)
}

return nil
}

// gotInterrupt函数验证是否已发出中断信号。
func (r *Runner) gotInterrupt() bool {
select {
// 中断事件被发送时发出信号。
case <-r.interrupt:
// 停止接收任何进一步的信号。
signal.Stop(r.interrupt)
return true

// 像往常一样正常运行
default:
return false
}
}

以上代码分别是任务的添加,以及任务开始,执行,中断步骤,共同构成了一个完整的生命周期。

举个例子

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
28
29
30
31
32
33
34
35
36
37
38
go复制代码package main
import (
"log"
"os"
"time"

"github.com/goinaction/code/chapter7/patterns/runner"
)

// timeout 规定了必须在多少秒内处理完成
const timeout = 3 * time.Second

// main 是程序的入口
func main() {
log.Println("Starting work.")

// 为本次执行分配超时时间
r := runner.New(timeout)

// 加入要执行的任务
r.Add(createTask(), createTask(), createTask(), createTask(), createTask(),createTask())

// 执行任务并处理结果
if err := r.Start(); err != nil {
log.Println(err.Error())
}
log.Println("Process ended.")
}

// createTask 返回一个根据 id
// 休眠指定秒数的示例任务
// 用来模拟正在进行工作
func createTask() func(int) {
return func(id int) {
log.Printf("Processor - Task #%d.", id)
time.Sleep(time.Duration(id) * time.Second)
}
}

模拟代码简单的设置了任务并展示了任务执行成功以及超时会出现的现象

1
2
3
4
5
6
yaml复制代码2021/10/18 01:09:39 Starting work.
2021/10/18 01:09:39 Processor - Task #0.
2021/10/18 01:09:39 Processor - Task #1.
2021/10/18 01:09:40 Processor - Task #2.
2021/10/18 01:09:42 received timeout
2021/10/18 01:09:42 Process ended.

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

实战 Cloud Nacos Client 端主流程

发表于 2021-10-17

首先分享之前的所有文章 , 欢迎点赞收藏转发三连下次一定 >>>> 😜😜😜

文章合集 : 🎁 juejin.cn/post/694164…

Github : 👉 github.com/black-ant

CASE 备份 : 👉 gitee.com/antblack/ca…

一 . 前言

Nacos 请求时部分操作异常会直接宕掉 ,这个时候了解整个 Nacos Client 端的流程就至关重要.

Nacos 主流程主要分为以下几个部分 :

  • Nacos Client 端的启动和初始化
  • Nacos Client 端服务注册
  • Nacos Client 端服务发现

二 . Nacos Client 端的启动和初始化

Nacos 的自动装配类主要分为2个部分 :

  • NacosConfigAutoConfiguration
  • NacosDiscoveryAutoConfiguration

上一篇 Nacos 配置加载流程和优先级 已经看了配置的处理流程 , 这一篇主要关注 NacosDiscoveryAutoConfiguration

2.1 Nacos 的 Discovery 自动装配类

在 Nacos 的启动过程中 , 在配置类中初始化了如下几个对象 :

1
2
3
java复制代码- new NacosServiceManager() 
- new NacosServiceDiscovery(discoveryProperties, nacosServiceManager) : 从缓存中获取列表
- new NacosDiscoveryClient(nacosServiceDiscovery)

2.1.1 NacosWatch 的处理

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
28
29
30
31
32
33
34
35
36
37
38
39
40
java复制代码// C- NacosWatch
public void start() {

if (this.running.compareAndSet(false, true)) {

// Step 1 : 构建 EventListener 对象
EventListener eventListener = listenerMap.computeIfAbsent(buildKey(),
event -> new EventListener() {
@Override
public void onEvent(Event event) {
if (event instanceof NamingEvent) {

List<Instance> instances = ((NamingEvent) event).getInstances();

Optional<Instance> instanceOptional = selectCurrentInstance(instances);

instanceOptional.ifPresent(currentInstance -> {
resetIfNeeded(currentInstance);
});
}
}
});


// Step 2 : 通过 NacosProperties 准备 NamingService 对象 -> 2.1.2 Step 2
NamingService namingService = nacosServiceManager
.getNamingService(properties.getNacosProperties());
try {

namingService.subscribe(properties.getService(), properties.getGroup(),
Arrays.asList(properties.getClusterName()), eventListener);
}
catch (Exception e) {
log.error("namingService subscribe failed, properties:{}", properties, e);
}

this.watchFuture = this.taskScheduler.scheduleWithFixedDelay(
this::nacosServicesWatch, this.properties.getWatchDelay());
}
}

2.1.2 初始化流程

Step1 : NacosWatch 发起监听

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码// NamingService init 流程
private void init(Properties properties) throws NacosException {
ValidatorUtils.checkInitParam(properties);
this.namespace = InitUtils.initNamespaceForNaming(properties);
InitUtils.initSerialization();
initServerAddr(properties);
// 初始化web容器
InitUtils.initWebRootContext();
// 初始化缓存目录
initCacheDir();
// 初始化 log 路径
initLogName(properties);

this.eventDispatcher = new EventDispatcher();
// 代理对象用于调用远程 Server
this.serverProxy = new NamingProxy(this.namespace, this.endpoint, this.serverList, properties);
// 用于向Nacos服务端发送已注册服务的心跳
this.beatReactor = new BeatReactor(this.serverProxy, initClientBeatThreadCount(properties));
// HostReactor用于获取、保存、更新各Service实例信息
this.hostReactor = new HostReactor(this.eventDispatcher, this.serverProxy, beatReactor, this.cacheDir,
isLoadCacheAtStart(properties), initPollingThreadCount(properties));
}

Step 2 : NamingFactory 构建 NamingService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码// 实际上是通过反射生产最终的实例对象
Class<?> driverImplClass = Class.forName("com.alibaba.nacos.client.naming.NacosNamingService");
Constructor constructor = driverImplClass.getConstructor(Properties.class);
NamingService vendorImpl = (NamingService) constructor.newInstance(properties);

// 工作空间
private String namespace;
//
private String endpoint;
// 服务列表对应的 Server地址 : localhost:8848
private String serverList;
// 本地缓存地址
private String cacheDir;
// log 名称 : 通常是 naming.log
private String logName;

private HostReactor hostReactor;
private BeatReactor beatReactor;

private EventDispatcher eventDispatcher;
private NamingProxy serverProxy;

Step 3 : EventDispatcher 添加监听器

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
java复制代码public void subscribe(String serviceName, String groupName, List<String> clusters, EventListener listener)
throws NacosException {

// 通过监听器来实现更新Service
eventDispatcher.addListener(hostReactor
.getServiceInfo(NamingUtils.getGroupedName(serviceName, groupName), StringUtils.join(clusters, ",")),
StringUtils.join(clusters, ","), listener);
}


public void addListener(ServiceInfo serviceInfo, String clusters, EventListener listener) {

// 构建一个 EventListener 集合
List<EventListener> observers = Collections.synchronizedList(new ArrayList<EventListener>());
observers.add(listener);

// ConcurrentMap<String, List<EventListener>> observerMap
// 其中有个 Notifier 的线程 , 通过 while 循环持续的处理实例
// /nacos/v1/ns/instance
observers = observerMap.putIfAbsent(ServiceInfo.getKey(serviceInfo.getName(), clusters), observers);
if (observers != null) {
observers.add(listener);
}

// change 时刷新 Service
serviceChanged(serviceInfo);
}

hostReactor.getServiceInfo 结果 :
image.png

三 . Nacos Client 端服务注册

服务注册涉及到如下流程 :

  • NacosRegistration:保存服务的基本数据信息
  • NacosServiceRegistry:实现服务注册
  • NacosServiceRegistryAutoConfiguration:Nacos自动配置类

Nacos 服务注册的起点是 @EnableDiscoveryClient , 其最终会调用 NacosAutoServiceRegistration :

  • AbstractAutoServiceRegistration
1
2
3
4
5
java复制代码@Import(EnableDiscoveryClientImportSelector.class)
public @interface EnableDiscoveryClient {
// 默认为 true , 开启后就会调用对应的 AutoServiceRegistration
boolean autoRegister() default true;
}

3.1 start

不同的注册中心 , 会有不同的实现类 ,此处是对应 Nacos 的 NacosAutoServiceRegistration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码// C- NacosAutoServiceRegistration
public void start() {
// 如果未开启 , 则直接return


// only initialize if nonSecurePort is greater than 0 and it isn't already running
// because of containerPortInitializer below
if (!this.running.get()) {
this.context.publishEvent(new InstancePreRegisteredEvent(this, getRegistration()));

// 调用 Registry 发起注册 : this.serviceRegistry.register(getRegistration());
register();

if (shouldRegisterManagement()) {
registerManagement();
}
this.context.publishEvent(new InstanceRegisteredEvent<>(this, getConfiguration()));
this.running.compareAndSet(false, true);
}

}

3.2 NacosServiceRegistry 发起注册

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
java复制代码public void register(Registration registration) {

if (StringUtils.isEmpty(registration.getServiceId())) {
return;
}

// 构建当前的 ServiceId 和 Group
NamingService namingService = namingService();
String serviceId = registration.getServiceId();
String group = nacosDiscoveryProperties.getGroup();

// 包括 IP , 端口 , 分配名 , 元数据
Instance instance = getNacosInstanceFromRegistration(registration);

try {
// 注册当前实例 , 同时会添加心跳
// serverProxy.registerService(groupedServiceName, groupName, instance);
namingService.registerInstance(serviceId, group, instance);
}
catch (Exception e) {
log.error("nacos registry, {} register failed...{},", serviceId,
registration.toString(), e);
// rethrow a RuntimeException if the registration is failed.
// issue : https://github.com/alibaba/spring-cloud-alibaba/issues/1132
rethrowRuntimeException(e);
}
}

四. 服务的发现

之前在Nacos 基础 中 , 我们大概看过一点 , 这里无非是再看一下

Nacos 的不同版本获取的方式是有很大区别的 , 这里主要针对 spring-cloud-starter-alibaba-nacos-discovery : 2.2.5 版本来看一下 .

4.1 Nacos Server 的发现

Step 1 : 发起的起点

1
2
3
4
5
6
java复制代码private List<NacosServer> getServers() {
String group = discoveryProperties.getGroup();
List<Instance> instances = discoveryProperties.namingServiceInstance()
.selectInstances(serviceId, group, true);
return instancesToServerList(instances);
}

Step 2 : 发现的主流程

到这里就和之前的逻辑串起来了 , 后面就是反向调用 Nacos Server 中提供的接口即可

1
2
3
4
5
6
7
8
java复制代码// C- NacosNamingService
public List<Instance> selectInstances(String serviceName, String groupName, List<String> clusters, boolean healthy,
boolean subscribe) throws NacosException {
ServiceInfo serviceInfo = hostReactor
.getServiceInfoDirectlyFromServer(NamingUtils.getGroupedName(serviceName, groupName),
StringUtils.join(clusters, ","));
return selectInstances(serviceInfo, healthy);
}

这里获取中会从 Map<String, ServiceInfo> serviceInfoMap 中获取数据 , 下面来看一下 ServiceInfo 是如何获取的

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
28
29
30
31
32
33
34
35
36
java复制代码public void updateServiceNow(String serviceName, String clusters) {
ServiceInfo oldService = getServiceInfo0(serviceName, clusters);
try {
// 同样通过代理类发起调用
String result = serverProxy.queryList(serviceName, clusters, pushReceiver.getUdpPort(), false);

if (StringUtils.isNotEmpty(result)) {
processServiceJson(result);
}
} catch (Exception e) {
NAMING_LOGGER.error("[NA] failed to update serviceName: " + serviceName, e);
} finally {
if (oldService != null) {
synchronized (oldService) {
oldService.notifyAll();
}
}
}
}


// 发起远程调用
public String queryList(String serviceName, String clusters, int udpPort, boolean healthyOnly)
throws NacosException {

final Map<String, String> params = new HashMap<String, String>(8);
params.put(CommonParams.NAMESPACE_ID, namespaceId);
params.put(CommonParams.SERVICE_NAME, serviceName);
params.put("clusters", clusters);
params.put("udpPort", String.valueOf(udpPort));
params.put("clientIP", NetUtils.localIP());
params.put("healthyOnly", String.valueOf(healthyOnly));

// 这里可以看到具体的 API :
return reqApi(UtilAndComs.nacosUrlBase + "/instance/list", params, HttpMethod.GET);
}

总结

这篇文章有点简单 , 了解的不深 , 但是应该是很有用 , 出现问题在核心的地方打个断点 ,能节省很多时间

TODO : 流程图今天就不画了 , 后面有时间补一个

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

JVM?看这一篇还不够吗 JVM?看这一篇还不够吗

发表于 2021-10-17

JVM?看这一篇还不够吗

写在前面

随着我们程序员‘入坑’的时间越来越长,不可避免地遇到JVM方面的问题,比如各种OOM异常、GC次数过多、每次GC时间很长等等问题。这时候,不掌握JVM知识,遇到GC问题,一脸懵逼啊,还怎么去调优!同时,JVM知识在出去找工作面试时,几乎也是被面试官必问的知识点,所以,不掌握这玩意,被面试官问到,又是一脸懵逼啊。本章就是专门用来详细介绍JVM的,尽力简单高效的分享知识。

内容简介

1
2
3
4
5
6
复制代码一 JAVA内存模型的介绍
二 JVM 内部组成
三 JAVA对象内存布局介绍和创建过程
四 JVM 参数调优
五 垃圾回收机制详解
六 JVM性能分析命令

一 Java内存模型

1 定义:

1
erlang复制代码 描述的是一组规范,定义了变量的访问方式.

2 特性

  • 1
    复制代码 可见性:  当一个线程对共享变量做了修改后其他线程可以立即感知到该共享变量的改变
  • 1
    复制代码 原子性:  是不可分割,当某个线程在做某个业务时,要么同时成功,要么失败,中间不能被加塞或分割。
  • 1
    复制代码 有序性:  计算机在执行程序时,为了提高性能,编译器和处理器常常会做指令重排。

3 Volatile关键字

3.1 特性:

  1. 保证可见性
  2. 不保证原子性
  3. 禁止指令重排序优化。(保证了JMM中的有序性)

若用volatile修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

在虚拟机层面,为了尽可能减少内存操作速度远慢于CPU运行速度所带来的CPU空置的影响,会修改代码的执行顺序。

3.2 原理

  • 当写一个volatile变量时,把该线程对应的本地内存中的共享变量刷新到主内存。
  • 当读一个volatile变量时,线程接下来将从主内存中读取共享变量。

3.3 如何解决原子性问题?

  • 使用JUC包下原子类,比如AtomicInteger
  • 加锁synchronized

二 JVM内部组成

1 类加载器子系统

类加载器主要分类有:

1
2
3
4
5
6
7
8
9
10
11
bash复制代码引导类加载器:使用C/C++编写的,加载Java的核心类库,并不继承ClassLoader,没有父加载器
拓展类加载器:加载 jre/lib/ext 目录下的类库
应用程序加载器:加载用户自定义类的默认加载器
用户自定义类加载器:
为何要自定义类加载器?
隔离加载类,避免类冲突
扩展加载源
防止源码泄漏
自定义步骤:
继承ClassLoader
也可以继承URLClassLoader

类加载过程:

1 加载

1
2
复制代码 通过一个类的全限定名来获取其定义的二进制字节流
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构

2 链接

1
2
3
4
5
6
7
8
9
10
11
vbnet复制代码 验证:确保被加载类的正确性,但不是必须的,它对程序运行期没有影响
分为如下几个阶段:
文件格式验证:验证字节流是否符合Class文件格式的规范,例如:是否以0xCAFEBABE开头
元数据验证:对字节码描述的信息进行语义分析,证其描述的信息符合Java语言规范的要求
字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
符号引用验证:确保解析动作能正确执行。
准备:为类变量分配内存并且设置该类变量的默认初始值,即零值
注意:这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中
解析:
将常量池中的符号引用替换为直接引用(内存地址)的过程
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。

符号引用:就是一组符号来描述目标,可以是任何字面量。
直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

3 初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
markdown复制代码只有对类的主动使用才会导致类的初始化,就是执行类构造器方法<clinit> 的过程
类的主动使用包括以下六种:
– 创建类的实例,也就是new的方式

– 访问某个类或接口的静态变量,或者对该静态变量赋值

– 调用类的静态方法

– 反射(如Class.forName(“com.shengsiyuan.Test”))

– 初始化某个类的子类,则其父类也会被初始化

– Java虚拟机启动时被标明为启动类的类(Java Test),直接使用java.exe命令来运行某个主类

2 运行时数据区

栈:

1
2
3
4
5
6
css复制代码生命周期:主管程序运行,在线程创建是创建,跟随线程的生命周期,线程结束栈内存释放。
不存在垃圾回收,并且线程私有。

运行原理:
栈中的数据都是以栈帧(Stack Frame)的格式存在,栈帧是一个内存区块,是一个数据集,是一个有关方法和运行期数据的数据集。
当一个方法A被调用时就产生了一个栈帧F1,并被压入到栈中,A方法又调用了B方法,于是产生栈帧F2也被压入栈,B方法又调用了C方法,于是产生栈帧F3也被压入栈…… 依次执行完毕后,先弹出后进......F3栈帧,再弹出F2栈帧,再弹出F1栈帧。

栈帧内部结构:

1
2
3
4
5
6
7
8
9
markdown复制代码    局部变量表
保存方法的局部变量(8种基本数据类型/对象的引用地址)
动态链接
指向运行时常量池的方法引用
操作数栈
主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
底层是数组实现
方法返回地址
一些附加信息

堆:

内部结构

1
2
3
4
5
6
7
8
9
10
11
12
13
markdown复制代码新生代:占整个堆内存的1/3
伊甸园区:占整个新生代8/10,当该区域满了的时候,会发生YGC/MinorGC

幸存者区:该区域满时,不会发生GC,当伊甸园区满了才会在该区域发生GC
幸存0区:占整个新生代1/10

幸存者1区:占整个新生代1/10


老年代:占整个堆内存的2/3
当对象经历过15次GC后仍然可用,就会进入该区域
主要存放长生命周期的对象
发生在该区域的GC叫做MajorGC

方法区(jdk1.8叫元空间)

内部存储这些数据:

1
2
3
4
5
6
7
8
markdown复制代码运行时常量池
方法信息
Class类信息
域信息(类属性)
注意
使用本地内存
虚拟机规范中没有明说是否要回收,也可能会存在类卸载
线程共享

本地方法栈

1
复制代码管理本地方法的调用

程序计数器

1
2
3
erlang复制代码每个线程都有一个程序计算器,就是一个指针,指向方法区中的方法字节码(下一个将要执行的指令代码),由执行引擎读取下一条指令.
不会GC,不会OOM
因为CPU需要不停的切换各个线程,切换回来后需要知道从哪开始执行

3 执行引擎

1
复制代码任务:将字节码指令解释/编译为对应平台上的本地机器指令

4 本地方法接口

1
java复制代码native 修饰的方法,由C/C++ 实现

三 JAVA对象内存布局和创建过程

1 内存布局

1
2
3
4
5
6
7
8
9
10
11
12
markdown复制代码一 对象头
包含两部分:
1 运行时数据
Hash值
GC分代年龄
锁状态标志
线程持有的锁
偏向线程ID
2 类型指针:指向类元数据

二 实例数据
包括从父类继承下来的字段和自身的

2 创建过程:

1
2
3
4
5
6
7
8
9
10
11
12
markdown复制代码1 加载类元信息
判断对应的类是否加载/链接/初始化
2 为对象分配内存
若内存连续
采用指针碰撞的方式分配
若内存不连续
使用空闲列表记录那些空闲的内存
3 处理并发问题
采用CAS失败重试/区域加锁保证原子性
4 属性的默认初始化(零值初始化)
5 设置对象头信息
6 属性的显示初始化/代码块初始化/

四 JVM 参数调优

1 参数分类

1
2
3
4
5
6
7
8
9
ruby复制代码1 标配参数
比如:java -version
2 XX参数
boolean类型
公式: -XX: + 或者 - 某个属性 。+表示开启,-表示关闭。
比如:-XX:+PrintGCDetails
KV类型
公式:-XX:key=value
比如:-XX:MetaspaceSize=21m

2 常用参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
diff复制代码-Xms :初始内存大小,默认物理内存的1/64
等价于 -XX:InititalHeapSize

-Xmx : 最大分配内存,默认为物理内存1/4
等价于 -XX:MaxHeapSize

-Xss : 设置单个线程栈的大小,默认值和平台有关,linux一般为1024k
等价于 -XX:ThreadStackSize

-Xmn: 设置年轻代大小

-XX:MetaspaceSize :
设置元空间大小,元空间使用的本地内存。

-XX:+PrintGCDetails :打印垃圾回收信息:

-XX:SurvivorRatio : 设置新生代eden和s0/s1空间的比例,默认是8:1:1
该值设置多少就是设置eden区的比例是多少,s0/s1相同。

-XX:NewRatio: 设置年轻代和老年代的比例。默认是2,即新生代:老年代=1:2

-XX:MaxTenuringThreshold :
设置进入老年代最大年龄,默认为15,即对象需要经过15次GC才能进入老年代。

结合SpringBoot:Java -server jvm各种参数 -jar jar/war包名称

五 垃圾回收

1 如何判断对象是否被回收?

1
2
3
4
5
6
7
8
9
10
11
12
less复制代码引用计数算法:
给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1;当引用失效时,计数器值就减 1;
Java 语言中没有选用引用计数算法,原因是它很难解决对象之间的相互循环引用的问题。解释:当A、B两个对象没有其他对象引用时,但A、B相互引用着对方,计数器无法为0,于是无法回收A、B两个对象。

根搜索算法:
通过一系列的名为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。
哪些可以作为GC Roots 对象?
1、栈中引用的对象。
2、方法区中类静态属性引用的对象。
3、方法区中常量引用的对象。 比如,使用static final 修饰的对象。
4、本地方法栈中引用的对象。
5.同步锁sync持有的对象

2 引用关系

1
2
3
4
5
6
7
8
9
markdown复制代码强引用:只要强引用关系还存在,永远不会被JVM回收

软引用:只有当内存不够的时候,JVM会对软引用的对象回收

弱引用:JVM只要有GC都会回收该引用对象

虚引用:PhantomReference 任何时候都可能会被GC回收。必须和引用队列联合使用,不能单独使用。
该引用的对象在GC后会放入ReferenceQueue引用队列中。可以在该对象销毁后做些业务。
唯一目的:就是在这个对象被收集器回收时收到一个系统通知

3 GC算法

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
28
29
30
31
32
33
34
35
36
37
38
erlang复制代码1 复制算法:
将内存空间分为两块,每次只使用其中一块,
将正在使用的内存中的存活对象复制到未被使用的内存块中,
之后清除正在使用的内存块中所有对象,交换两个内存的角色,最后完成垃圾回收.

优点:
运行高效
可以保证空间的连续性,不会出现内存碎片
缺点:需要2倍的内存空间
场景:
用在新生代。
复制的存活对象越少,性能越高


2 标记/清除
标记阶段:
从引用根节点(GC Roots)开始遍历,标记所有被引用的对象,一般在对象头中标记为可达对象.
清除阶段:
对堆中所有对象进行线性遍历,如果发现某个对象在其Header中没有标记为可达对象,则回收。

缺点:
容易导致内存碎片,需要维护一个空闲列表
效率不高
进行GC的时候,需要停止整个应用程序

3 标记/压缩
标记阶段:
从引用根节点(GC Roots)开始遍历,标记所有被引用的对象,一般在对象头中标记为可达对象.
压缩阶段:
将所有存活对象压缩到内存的一端,按顺序排放,然后清理边界外的空间.

总结:相当于在 标记/清除算法执行完成后,再进行一次的内存整理
缺点
效率上要低于复制算法
移动过程中需要暂停用户线程,STW
场景:老年代
4 分区算法
把整个堆空间划分成连续不同的小区间,每个小区间独立使用,独立回收

JVM采用的分代收集算法
原因:因为不同对象的生命周期是不一样的

新生代:采用了GC的复制算法,因为新生代一般是新对象,对象存活率低。

老年代:采用标清或标整。因为老年代中因为对象存活率高。

4 GC时可能发生的错误:

有两大类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ruby复制代码内存溢出:
1、StackOverflowError:
2、OutOfMemoryError:java heap space
3、OutOfMemoryError:GC overhead limit exceeded
服务器资源大量时间被用来GC,且GC效果很差。
4、OutOfMemoryError:Direct buffer memory
分配堆外内存不够用,即超过-XX:MaxDirectMemorySize的值。
一般在NIO程序中会报这种异常。
5、OutOfMemoryError:unable to create new native thread
原因是一个应用进程中创建了过多的线程。可以设置linux允许单个进程可以运行的最大线程数。
6、OutOfMemoryError:Metaspace:

解决办法:Java虚拟机的堆内存设置不够,可以通过参数-Xms、-Xmx来调整。

内存泄漏:对象不会再被程序使用,但是GC又不能回收。
有可能在单例模式,数据库连接,网络连接中,对象没法被回收。

STW: (Stop-The-World)
是在执行垃圾收集算法时,应用程序的其他所有线程都被挂起(除了垃圾收集帮助器之外)。Java中一种全局暂停现象,全局停顿,所有Java代码停止,native代码可以执行,但不能与JVM交互;这些现象多半是由于gc引起。
所有GC都会存在这个事件.
JVM在后台会自动发起和自动完成的.

Minor GC和Full GC的区别

1
2
3
4
5
markdown复制代码Minor GC:又称新生代GC,指发生在新生代的垃圾收集动作;
因为Java对象大多是朝生夕灭,所以Minor GC非常频繁,一般回收速度也比较快;

Full GC:又称Major GC或老年代GC,指发生在老年代的GC;
Major GC速度一般比Minor GC慢10倍以上;

5 垃圾回收器

分类:

1
2
3
4
5
6
7
8
9
10
arduino复制代码1 Serial :串行垃圾回收器,只有一个线程进行垃圾回收,会暂停用户线程,直到完成。
开启命令:-XX:+UseSerialGC
应用场景:
主要用于Client模式;
而在Server模式有两大用途:
1 在JDK1.5及之前,与Parallel Scavenge收集器搭配使用(JDK1.6有Parallel Old收集器可搭配);
2 作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用
具体分类:
Serial GC:发生在新生代,采用复制算法。
Serial Old GC:用于老年代,采用标记/压缩算法

s.jpg

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
objectivec复制代码2 Parallel: 并行垃圾回收器,多个垃圾收集器线程并行工作,java8默认
开启命令:-XX:+UseParrallelGC
具体分类:
1 ParNew:用于新生代,老年代可以使用CMS
开启命令:
"-XX:+UseConcMarkSweepGC":指定使用CMS后,会默认使用ParNew作为新生代收集器;
"-XX:+UseParNewGC":强制指定使用ParNew;
特点:除了多线程外,其余的行为、特点和Serial收集器一样;
2 Parallel Scavenge:因为与吞吐量关系密切,也称为吞吐量收集器
特点:有一些特点与ParNew收集器相似
新生代收集器;
采用复制算法;
多线程收集;
应用场景:
高吞吐量为目标,即减少垃圾收集时间,让用户代码获得更长的运行时间;
当应用程序运行在具有多个CPU上,对暂停时间没有特别高的要求时,
即程序主要在后台进行计算,而不需要与用户进行太多交互;
例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序;

p.jpg

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
objectivec复制代码3 CMS:并发垃圾回收器
目的:减少STW的时间
开启命令:-XX:+UseConMarkSweepGC
特点:
针对老年代
基于"标记-清除"算法(不进行压缩操作,产生内存碎片);            
以获取最短回收停顿时间为目标;
并发收集、低停顿;
应用场景:
与用户交互较多的场景;        
希望系统停顿时间最短,注重服务的响应速度;
以给用户带来较好的体验;
回收步骤:
1 初始标记(CMS-initial-mark)
会导致STW; 初始标记阶段就是标记老年代中的GC ROOT对象和与GC ROOT对象关联的对象给标记出来。
2 并发标记(CMS-concurrent-mark)
从GC Roots的直接关联对象开始遍历,这个过程耗时较长,但是不需要暂停用户线程
3 重新标记(CMS-remark)
为了修正并发标记期间,因为用户线程继续运作和导致标记产生变动的那一部分对象的标记记录,也会导致STW
4 并发清除(CMS-concurrent-sweep)
清理标记阶段被判断为垃圾的对象,可以与用户线程同时并发执行

20170102225017372.jpg

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
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
objectivec复制代码    为何不使用标记压缩算法?
因为当并发清除时,在整理内存过程中,用户线程还在运行,对象的内存地址不能修改。

优点:并发收集
低延迟
缺点:
1 会产生内存碎片
2 无法处理浮动垃圾,可能出现"Concurrent Mode Failure"失败
浮动垃圾(Floating Garbage):在并发清除时,用户线程新产生的垃圾,称为浮动垃圾;
这使得并发清除时需要预留一定的内存空间,不能像其他收集器在老年代几乎填满再进行收集;
如果CMS预留内存空间无法满足程序需要,就会出现一次"Concurrent Mode Failure"失败;
3 对CPU资源非常敏感,CMS的默认收集线程数量是=(CPU数量+3)/4;当cpu数量不足4个时,对系统影响较大。



4 G1:Garbage-First收集器:
是JDK7-u4才推出商用的收集器;
宏观上它不在区分年轻代和老年代,把内存划分为2048个独立的子区域。
小范围内区分年轻代和老年代。最大的好处是避免了全内存扫描。
设计目标取代CMS收集器。
特点:
1 并行与并发
能充分利用多CPU、多核环境下的硬件优势;
可以并行来缩短"Stop The World"停顿时间;
也可以并发让垃圾收集与用户程序同时进行;

2 分代收集,收集范围包括新生代和老年代    
能独立管理整个GC堆(新生代和老年代),而不需要与其他收集器搭配;
能够采用不同方式处理不同时期的对象;
3 结合多种垃圾收集算法,空间整合,不产生碎片
从整体看,是基于标记-整理算法;
从局部(两个Region间)看,是基于复制算法;
4 可预测的停顿:低停顿的同时实现高吞吐量
G1除了追求低停顿处,还能建立可预测的停顿时间模型;
可以明确指定M毫秒时间片内,垃圾收集消耗的时间不超过N毫秒;
采用的算法:Region之间是复制算法
命令:
-XX:+UseG1GC:开启
"-XX:MaxGCPauseMillis":为G1设置暂停时间目标,默认值为200毫秒;
优点:
用户可以指定停顿时间:
使用-XX:MaxGCPauseMills 最大GC停顿时间。JVM尽可能做到。
并行与并发兼具
不会产生内存碎片
使用场景:
面向服务端应用,针对具有大内存、多处理器的机器;
最主要的应用是为需要低GC延迟,并具有大堆的应用程序提供解决方案;
大内存,多处理器的机器上
在下面的情况时,使用G1可能比CMS好:
超过50%的Java堆被活动数据占用;
对象分配频率或年代提升频率变化很大;
GC停顿时间过长(长于0.5至1秒)。
是否一定采用G1呢?也未必:
如果现在采用的收集器没有出现问题,不用急着去选择G1;
如果应用程序追求低停顿,可以尝试选择G1;
是否代替CMS需要实际场景测试才知道。

20170102225017799.jpg

吞吐量与收集器关注点说明

吞吐量(Throughput)

CPU用于运行用户代码的时间与CPU总消耗时间的比值;


即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间);    


高吞吐量即减少垃圾收集时间,让用户代码获得更长的运行时间;

垃圾收集器期望的目标(关注点)

1 停顿时间

停顿时间越短就适合需要与用户交互的程序;


良好的响应速度能提升用户体验;

2 吞吐量

高吞吐量则可以高效率地利用CPU时间,尽快完成运算的任务;


主要适合在后台计算而不需要太多交互的任务;

3 覆盖区(Footprint)

在达到前面两个目标的情况下,尽量减少堆的内存空间;


可以获得更好的空间局部性;

六 性能分析

常用命令:

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
28
29
30
31
32
33
34
35
36
37
lua复制代码1 Jps: 列出系S统中所有java应用程序。
Jps -l :输出主函数的完整路径。

2 Jinfo:查看或修改虚拟机参数
语法:jinfo <option> <pid>
例如:jinfo -flag PrintGCDetails pid
查看打印GC日志是否开启。
也可以动态修改参数:
Jinfo -flag +PrintGCDetails pid

3 Jmap:内存映像工具
它可以生成Java 程序的堆dump文件,还可以查看堆内对象实例的统计信息。

生成当前java程序的堆快照:
语法:
Jmap -dump:format=b,file=dump文件地址 <javaPid>
生成dump文件后,可以使用jhat、Visual VM、MAT等工具分析。
Jmap -histo pid > s.txt
统计java程序的对象信息:
将pid的java程序的对象统计信息输出到s.txt中。

4 Jstat:用于观察Java应用程序运行时信息,查看堆的详细信息。
例如:输出GC相关堆信息:jstat -gc或 -gcutil pid
5 Jstack :用于导出Java应用程序的线程堆栈。
语法:Jstack [-l] <pid>
可以帮助开发人员找到死锁问题。

6 Jvisualvm:Java Visual VM 可视化工具

7 Jconsole:监控堆信息、类加载情况,线程监控,还可以检测死锁。

8 Jcmd: JDK1.7之后新增的命令行工具。它可以导出堆,查看java进程、导出线程信息、执行GC等。
语法:
Jcmd :查看java进程
Jcmd <pid> Thread.print:打印线程栈信息
Jcmd <pid> GC.heap_dump dump文件地址:导出堆信息,供Mat等分析
Jcmd <pid> help : 查看支持哪些命令

未完待续。。。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Hands-on Rust 学习之旅(1)——Rust 环境

发表于 2021-10-17

本系列笔记开始记录学习《Hands-on Rust: Effective Learning through 2D Game Development and Play》的过程。

首先,还是万年不变的第一章内容:

安装 Rust 和对应的工具

具体如何安装 Rust,我觉得没必要多此一举去介绍,这里我主要罗列下基于 Mac 下注意的几个知识内容。

关键几个知识点和命令

  1. Rust 更新时间

Rust releases minor updates every six weeks.

  1. Clippy

Finding Common Mistakes with Clippy
Type cargo clippy into your terminal, and you’ll receive a list of suggestions.

  1. 格式化 Formatting Your Code
1
css复制代码cargo fmt to transform the terse code back into the recommended format.
  1. 检查更新:
1
sql复制代码rustup check

VSCode 下安装两个插件:

1
复制代码Rust Analyzer, CodeLLDB plugins

配置加速

如果出现这个问题:

1
arduino复制代码error: no override and no default toolchain set

可以执行:

1
2
arduino复制代码rustup install stable
rustup default stable

如果下载过程缓慢可以先设置:

1
2
3
arduino复制代码export RUSTUP_DIST_SERVER=https://mirrors.ustc.edu.cn/rust-static

export RUSTUP_UPDATE_ROOT=https://mirrors.ustc.edu.cn/rust-static/rustup

这样就下载快速了:

最后,可以在 ~/.cargo/config 配置国内镜像:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
ini复制代码[source.crates-io]
registry = "https://github.com/rust-lang/crates.io-index"
# 指定镜像
replace-with = 'ustc' # 如:tuna、sjtu、ustc,或者 rustcc

# 注:以下源配置一个即可,无需全部

# 中国科学技术大学
[source.ustc]
registry = "https://mirrors.ustc.edu.cn/crates.io-index"
# >>> 或者 <<<
# registry = "git://mirrors.ustc.edu.cn/crates.io-index"

# 上海交通大学
[source.sjtu]
registry = "https://mirrors.sjtug.sjtu.edu.cn/git/crates.io-index/"

# 清华大学
[source.tuna]
registry = "https://mirrors.tuna.tsinghua.edu.cn/git/crates.io-index.git"

# rustcc社区
[source.rustcc]
registry = "https://code.aliyun.com/rustcc/crates.io-index.git"

好了,基本环境配置完成了。

测试下,执行命令 cargo run:

完美!开启 Rust学习之旅!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【Quarkus技术系列】打造基于Quarkus的云原生微服

发表于 2021-10-17

小知识,大挑战!本文正在参与「程序员必备小知识」创作活动

本文已参与 「掘力星计划」 ,赢取创作大礼包,挑战创作激励金。

前提介绍

本系列文章主要讲解如何基于Quarkus技术搭建和开发“专为Kubernetes而优化的Java微服务框架”的入门和实践,你将会学习到如何搭建Quarkus微服务脚环境及脚手架,开发Quarkus的端点服务,系统和应用层级的配置介绍与Quarkus的编程模型分析,创建Quarkus的应用Uber-jar文件以及集成到Kubernetes的环境中。

  1. 学习Quarkus的云原生微服务的零基础搭建和开发实践
  2. 分析Quarkus的编程模型以及与Kubernetes环境进行集成

面向的人群

Java软件开发人员、系统架构师、微服务开发爱好者、运维部署人员等。

目前的状况

近几年由于云原生技术的普及,越来越多的用户开始使用容器来运行微服务应用,随着微服务的快速发展,spring全家桶已然成为了java框架的事实标准,包括单体应用使用的spring Framework和springboot,微服务间服务治理框架spring cloud,生态系统完善,各种组件层出不穷。

Java云原生化痛点

  • 轻量化容器技术的诞生促使JVM服务变得更加臃肿
+ 微服务架构的引入,使我们的服务颗粒度变得越来越小,轻量且能快速启动的应用能够更好的适应容器化环境。 以我们目前常规的Spring Boot应用来说,一般Restful服务的jar包大概是30M左右,如果我们将JDK以及相关应用打包成docker镜像文件大概是140M左右。
+ 常规的Go语言的可执行程序生成镜像包一般不会超过50M。如何让臃肿的Java应用瘦身使他易于容器化,成为Java应用云原生化需要解决的问题。
  • 轻量化容器技术的诞生促使JVM服务内容使用量变得过大
+ JVM对于内存的使用量变的越来越大,会促使FullGC的过多甚至OOM。
  • SpringBoot的微服务应用启动的速度越来越慢(JVM启动速度)
+ 从JVM启动到真的应用程序执行需要经历VM加载,字节码文件加载,以及JVM为了提升效率,借助JIT(just in time)及时编译技术对解释执行的字节码进行局部优化,通过编译器生成本地执行代码的过程,同时还需要加上了JVM内部垃圾回收所耗费的时间。
![](https://gitee.com/songjianzaina/juejin_p16/raw/master/img/c60485d62c325678d19220a803a5560b6000c22e2d02bc2b5e76c61226120d58)

典型的Java应用加载时间一般都是秒级起步,如果遇到比较大的应用初始花费几分钟都是正常的。 以往由于我们很少重新启动Java应用,Java应用启动时间长的问题一般很少暴露出来。

  • 但是在云原生应用场景下,随着粒度变的非常细,所以导致部署频率过于频繁
+ 我们会经常不断重启应用来实现滚动升级或者无服务应用场景。 Java应用启动时间长的问题就变成了Java应用云原生化亟待解决的问题。

Quarkus的介绍

  • Quarkus定位为GraalVM和OpenJDK HotSpot量身定制的Kubernetes Native Java框架。
  • Quarkus是红帽开源的项目,借助开源社区的力量,通过对业界广泛使用的框架进行了适配,并结合云原生应用的特点,提供了一套端到端的Java云原生应用解决方案。
  • 虽然开源时间较短,但是生态方面也已经达到可用的状态,自身包含扩展框架,已经支持像Netty、Undertow、Hibernate、JWT等框架,足以用于开发企业级应用,用户也可以基于扩展框架自行扩展。

向原生迈进

对需要长时间运行的应用来说,由于经过充分预热,热点代码会被HotSpot的探测机制准确定位捕获,并将其编译为物理硬件可直接执行的机器码,在这类应用中Java的运行效率很大程度上是取决于即时编译器所输出的代码质量。

HotSpot虚拟机中包含有两个即时编译器,分别是编译时间较短但输出代码优化程度较低的客户端编译器(简称为C1)以及编译耗时长但输出代码优化质量也更高的服务端编译器(简称为C2),通常它们会在分层编译机制下与解释器互相配合来共同构成HotSpot虚拟机的执行子系统的。

新一代即时编译器(Graal VM)

自JDK 10起,HotSpot中又加入了一个全新的即时编译器:Graal编译器,看名字就可以联想到它是来自于前一节提到的Graal VM,Graal编译器是作为C2编译器替代者的角色登场的。

C2编译器的问题

C2的历史已经非常长了,可以追溯到Cliff Click大神读博士期间的作品,这个由C++写成的编译器尽管目前依然效果拔群,但已经复杂到连Cliff Click本人都不愿意继续维护的程度。

Graal编译器本身就是由Java语言写成,实现时又刻意与C2采用了同一种名为“Sea-of-Nodes”的高级中间表示(High IR)形式,使其能够更容易借鉴C2的优点。

Graal编译器比C2编译器晚了足足二十年面世,有着极其充沛的后发优势,在保持能输出相近质量的编译代码的同时,开发效率和扩展性上都要显著优于C2编译器,这决定了C2编译器中优秀的代码优化技术可以轻易地移植到Graal编译器上,但是反过来Graal编译器中行之有效的优化在C2编译器里实现起来则异常艰难。

Graal编译器

Graal的编译效果短短几年间迅速追平了C2,甚至某些测试项中开始逐渐反超C2编译器。

Graal能够做比C2更加复杂的优化,如“部分逃逸分析”(Partial Escape Analysis),也拥有比C2更容易使用“激进预测性优化”(Aggressive Speculative Optimization)的策略,支持自定义的预测性假设等等。

Graal编译器尚且年幼,还未经过足够多的实践验证,所以仍然带着“实验状态”的标签,需要用开关参数去激活,这让笔者不禁联想起JDK 1.3时代,HotSpot虚拟机刚刚横空出世时的场景,同样也是需要用开关激活,也是作为Classic虚拟机的替代品的一段历史。

Graal编译器未来的前途可期,作为Java虚拟机执行代码的最新引擎,它的持续改进,会同时为HotSpot与Graal VM注入更快更强的驱动力。

GraalVM的总结分析

GraalVM:JVM为了提升效率,借助JIT及时编译技术对解释执行的字节码进行局部优化,通过编译器生成本地执行代码提升应用执行效率。

GraalVM是Oracle实验室开发的新一代的面向多种语言的JVM即时编译器,在性能以及多语言互操作性上有比较好的表现。与Java HotSpot VM相比,Graal借助内联,逃逸分析以及推出优化技术可以提升2至5倍的性能提升。

GraalVM提供的静态编译功能,只能针对其编译时能够看得的封闭世界进行优化,对于那些使用了反射、动态加载、以及动态代理的代码是无能为力的。

  • 为了能让我们日常的Java应用能够正常运行起来,需要我们对应用所使用到的框架和类库进行相关修改适配。
  • 由于Java代码所使用的类库很多,这部分的工作量还是相当巨大的,虽然GraalVM已经推出超过一年多的时间,但是还是很少见到大规模Java应用转移到这个平台之上。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

美团联盟怎么实现用户订单跟单功能

发表于 2021-10-17

不管是电商cps,还是外卖cps,对接过这么多第三方cps接口,只有美团联盟提供了订单数据回推接口,而且只要订单状态改变,就会回推数据,这为我们自身系统实现用户跟单继而实现分销裂变的功能提供了极大的友好帮助。

登录美团联盟后台,在联盟API接口列表找到一个名称为【订单回推接口】的栏目。

image.png

对,这个就是我们需要的接口,点进去查看详情。

image.png

和大部分回调接口一样,这个接口不需要接入方主动调用,而是接入方提供一个接口给美团联盟平台调用的,平台会将订单数据post到这个接口上,从而我们就可以从数据中获取到下单时预先传入的sid,这个sid是能够唯一识别我们系统的用户。这样就可以实现订单跟踪的效果,继而做一些业务逻辑的处理。

为了安全,同样的需要对联盟平台post过来的数据进行验签操作,以确保是平台发送过来的数据。注意,验签用到的密钥和调用联盟平台其他接口用到的密钥不是同一个哦,这个密钥在如下位置,签名方式和其他接口一致。

image.png

看下service层的代码

注意,接收到推送的订单之后,需要按照下面的固定的格式进行返回,数据正常,返回:{"errcode":"0","errmsg":"ok"},数据错误,返回: {"errcode":"1","errmsg":"err"}

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
ini复制代码@Override
public Map<String, String> mtOrderCallback(TreeMap<String, String> params) {
String data = JSON.toJSONString(params);
logger.info("美团回调参数:{}", data);
MtOrderModel mtOrder = JSONObject.parseObject(data, MtOrderModel.class);
String getSign = mtOrder.getSign();
String sign = MtSignUtils.genSign(params, model.getMtCallbackSecret());
Map<String, String> result = new HashMap<>(3);
if (sign.equals(getSign)) {
String userUuid = mtOrder.getSid();
UserMember member = userMemberService.getOne(Wrappers.<UserMember>lambdaQuery().eq(UserMember::getUuid, userUuid));
if (member != null) {
String orderId = mtOrder.getOrderid();
String status = mtOrder.getStatus();
String type = mtOrder.getType();
CpsOrder order = cpsOrderService.getOne(Wrappers.<CpsOrder>lambdaQuery()
.eq(CpsOrder::getOrderSn, orderId));
if (order == null) {
order = new CpsOrder();
// TODO
// 保存订单
// ......
cpsOrderService.save(order);
} else {
if (StringUtils.equals("8", status)) {
// 美团订单已完成
// ......
} else if (StringUtils.equals("9", status)) {
// 美团订单已退款或风控
// ......
}
cpsOrderService.updateById(order);
}
}
result.put("errcode", "0");
result.put("errmsg", "ok");
} else {
result.put("errcode", "1");
result.put("errmsg", "err");
}
logger.info("美团回调返回给美团的参数:{}", JSON.toJSONString(result));
return result;
}

看下controller层的代码

1
2
3
4
less复制代码@PostMapping("mtOrderCallback")
public Map<String, String> mtOrderCallback(@RequestBody TreeMap<String, String> params){
return mtApiService.mtOrderCallback(params);
}

最后一定要记得把接口地址配置在联盟平台上,这样就大功告成了。

image.png

美团联盟怎么实现用户订单跟单功能

山水有相逢,来日皆可期,谢谢阅读,我们再会

我手中的金箍棒,上能通天,下能探海

上一篇:从短视频中筛选你的精准客户-抖音版

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

SourceTree使用教程图文详解 1、 前言 2、正文

发表于 2021-10-17

小知识,大挑战!本文正在参与“ 程序员必备小知识 ”创作活动

作者的其他平台:

| CSDN:blog.csdn.net/qq_4115394…

| 掘金:juejin.cn/user/651387…

| 知乎:www.zhihu.com/people/1024…

| GitHub:github.com/JiangXia-10…

| 公众号:1024笔记

本文大概3751字,读完共需10分钟

1、 前言

Git分布式版本控制系统是我们日常开发中不可或缺的一部分,能够大大提高我们协同工作的效率。前面的一篇文章如何玩转Git介绍过Git的相关知识。在工作中往往我们需要使用Git的可视化管理工具进行版本控制。目前市面上比较流行的Git可视化管理工具有SourceTree、Github Desktop、TortoiseGit等等,我们公司主要使用的是SourceTree。该篇文章主要结合日常开发工作的对于sourctree的一些常用操作进行讲解和总结,帮助没有使用过的同学进行快速入门,希望能对大家有所帮助!

2、正文

首先当然是要说明Sourcetree的下载安装。

关于sourcetree各版本的下载:可以访问网址:

www.sourcetreeapp.com/download-ar…

这里建议不要下载最新的版本,因为新版本有时候不太稳定,建议下载最新版本的前1-2个版本,我这里使用的是3.4.4版本。

下载完成之后是一个.exe的可执行文件,直接双击进行安装即可。

这里在安装SourceTree的过程中,需要通过账户登录,但注册或登录界面可能根本无法打开,导致软件无法正常安装。这时可以通过以下办法进行解决:

在目录C:\Users{username}\AppData\Local\Atlassian\SourceTree 下创建文件accounts.json ,注意:{username}需要替换为登录系统用户名。如我的电脑路径为:

C:\Users\Administrator\AppData\Local\Atlassian\SourceTree。accounts.json文件的内容如下:

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
28
29
30
31
32
33
34
35
36
37
38
39
kotlin复制代码[
{
"$id": "1",
"$type": "SourceTree.Api.Host.Identity.Model.IdentityAccount, SourceTree.Api.Host.Identity",
"IsDefault": false,
"Authenticate": true,
"HostInstance": {
"$id": "2",
"$type": "SourceTree.Host.Atlassianaccount.AtlassianAccountInstance, SourceTree.Host.AtlassianAccount",
"Host": {
"$id": "3",
"$type": "SourceTree.Host.Atlassianaccount.AtlassianAccountHost, SourceTree.Host.AtlassianAccount",
"Id": "atlassian account"
},
"BaseUrl": "https://id.atlassian.com/"
},
"Credentials": {
"$id": "4",
"$type": "SourceTree.Api.Account.Basic.BasicAuthCredentials, SourceTree.Api.Account.Basic",
"Username": "",
"Email": null,
"AvatarURL": null,
"AuthenticationScheme": {
"$type": "SourceTree.Api.Account.Basic.BasicAuthAuthenticationScheme, SourceTree.Api.Account.Basic",
"Value": "用户名/密码",
"Name": "Basic",
"Description": "密码",
"HeaderValuePrefix": "Basic",
"UsernameIsRequired": true
},
"Id": "",
"EmailHash": null,
"DisplayName": null
}
},
{
"$ref": "1"
}
]

然后重新启动软件,顺利进入界面。

这里假设前提是已经在本地电脑安装好了git,如果没有关于git的安装使用可以参考之前的文章:如何玩转Git。

前面的工作准备好了之后接下来就是关于SourceTree的配置以及使用了!

首先我们需要在SourceTree中添加SSH密钥。在菜单栏的工具栏选择选项菜单,SSH客户端配置,SSH秘钥选择:C:\Users\Administrator.ssh\id_rsa.pub,然后SSH客户端选项选择OpenSSH,如下图所示:

‍‍‍‍‍‍‍

接下来就可以Clone远程的代码了,这里以自己的GitHub为例,其他的比如gitee也类似:

打开github,找到自己需要clone的源码,code选择ssh,复制地址,这里需要在github秘钥也进行配置,具体参考之前的文章即可:

然后打开sourcetree,clone项目到本地:

由上面我们可以发现每次Clone项目的时候,克隆下来的项目默认存储位置都是在C盘,如果一直放在C盘,则系统盘肯定会越来越小,电脑越来越卡,每次更改项目路径也挺麻烦,因此我们可以设置一个默认的项目存储位置:

点击工具—>选项—>一般—>找到项目目录设置Clone项目默认存储的位置:

在进行协同开发的时候往往需要新建不同的分支,用于不同的版本或者功能开发,最后再合并版本。

1、新建分支:

在新建分支时,我们需要在哪个主分支的基础上新建分支必须先要切换到对应的主分支才能到该主分支上创建分支,如下我们要在main分支上创建一个dev-1017新建分支:

因为前面选择了默认检出,所以这里自动检出了分支:

如果需要切换到其他分支,可以直接双击对应的分支切换到该分支,前提是需要先从远程检出该分支:

多个人在不同的分支进行开发完成后,需要合并分支版本然后进行送测,比如在main分支上点击右键,选择合并刚刚创建的dev-1017新建分支至当前分支即可进行合并,在合并代码之前我们都需要将需要合并的分支拉取到最新状态避免覆盖别人的代码,或者代码丢失:

代码完成之后需要提交代码:

将修改的代码提交到暂存区:

如果我们发现一个文件修改错了,那么可以右键这个文件,选择丢弃,将该文件的所有修改丢弃,回滚到你修改之前的状态:

如果你不想要这个文件了,发现这个文件新增错了,那么可以选择这个文件然后右键选择移除这个文件:

然后修改的文件就出现在了暂存区:

sourecetree还有个储藏功能:

这个主要是储藏你的文件,好吧这么解释等于没说,简单点理解就是比如我们开发的时候有很多的配置文件,但是一般我们开发的时候配置文件的部分数据比如数据库地址等等和线上的是不一样的,所以我们提交的时候有些时候是需要改成线上的地址,然后开发的时候拉取最新的线上地址再修改成开发的环境,这样就很麻烦,那么这时候我们就可以使用储藏功能,把我们修改的本地的开发环境的代码储藏起来,下次拉取了最新的线上代码之后我们直接选择存储的文件然后应用它就行了,避免了每次拉取最新代码之后再一次次的修改:

这里需要明确几个概念之间的区别:

1、提交和推送:有些人可能有疑问为啥我已经提交代码了,但是远程却没有发现我修改的代码呢?因为提交只是将暂存区的文件上传到我们本地的代码库,而推送则是将本地仓库同步至远程仓库,这样操作之后别人才能从远程拉取你修改的最新代码。

2、拉取和获取:这两个名词仅有一字只差,但是却有不同的功能。拉取(pull)是从远程仓库获取信息并同步至本地仓库,并且自动执行合并(merge)操作(git pull=git fetch+git merge)。而获取(fetch)则只是从远程仓库获取信息并同步至本地仓库。所以一般推送之前需要先拉取一次,确保代码一致。

3、丢弃和移除:丢弃指的是丢弃更改,恢复文件改动/重置所有改动,即将已暂存的文件丢回未暂存的文件。移除则是移除文件至缓存区。

3、总结

这篇文章主要结合我日常开发工作的对于sourctree的一些常用操作进行讲解和总结,帮助没有使用过的同学进行快速入门,希望能对大家有所帮助!

如果你觉得本文不错就点赞分享给更多的人吧!

你也可以关注公众号(1024笔记)免费获取海量学习资源(涵盖了C、python、Java、大数据、人工智能)以及笔面试题!

相关推荐:

  • Spring注解(三):@scope设置组件作用域
  • Spring常用注解大全,值得你的收藏!!!
  • Spring注解(七):使用@Value对Bean进行属性赋值
  • SpringBoot开发Restful风格的接口实现CRUD功能
  • Spring注解(六):Bean的生命周期中自定义初始化和销毁方法的四种方式

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…486487488…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%