1、前言
目前流行的权限校验框架主要Shori和SpringSecury,这篇主要讲解的是在基于前后端分离的基础上对SpringSecurity的使用,前后端使用JWT进行身份鉴定,使用也比较的简单,主要是配置问题,话不多说,上代码。
主要的校验流程:
2、代码展示
2.1 自定义SpringSecurityConfig 配置类
package com.pzg.chat.config;
import com.pzg.chat.filter.JwtAuthenticationTokenFilter;
import com.pzg.chat.handler.AccessDecisionManagerImpl;
import com.pzg.chat.handler.FilterInvocationSecurityMetadataSourceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.AccessDecisionManager;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.ObjectPostProcessor;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource;
import org.springframework.security.web.access.intercept.FilterSecurityInterceptor;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@EnableWebSecurity
@Configuration
@SuppressWarnings("all")
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthenticationFailureHandler authenticationFailureHandler;
@Autowired
private AuthenticationSuccessHandler authenticationSuccessHandler;
@Autowired
private AccessDeniedHandler accessDeniedHandler;
@Autowired
private AuthenticationEntryPoint authenticationEntryPoint;
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Bean
public FilterInvocationSecurityMetadataSource securityMetadataSource(){
return new FilterInvocationSecurityMetadataSourceImpl();
}
@Bean()
public AccessDecisionManager accessDecisionManager(){
return new AccessDecisionManagerImpl();
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginProcessingUrl("/user/login") //登入接口路径
.usernameParameter("account") //账号
.passwordParameter("password")//密码
.failureHandler(authenticationFailureHandler) //自定义登入验证失败
.successHandler(authenticationSuccessHandler); //自定义成功
http.authorizeRequests()
.withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
@Override
public <O extends FilterSecurityInterceptor> O postProcess(O o) {
o.setSecurityMetadataSource(securityMetadataSource());
o.setAccessDecisionManager(accessDecisionManager());
return o;
}
})
.anyRequest().permitAll() //放行所有
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) //禁用session
.and()
.csrf() //防止CSRF攻击
.disable()
.exceptionHandling()
//定义权限不足失败
.accessDeniedHandler(accessDeniedHandler)
//定义用户未登入
.authenticationEntryPoint(authenticationEntryPoint)
//让jwtAuthenticationTokenFilter在UsernamePasswordAuthenticationFilter之前执行
.and().addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
/**
* 暴露认证方法
* @return
* @throws Exception
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
通过去继承WebSecurityConfigurerAdapter类 并重写里面的configure()方法和authenticationManagerBean()方法,重写configure()主要就是去配置自己的规则,而重写authenticationManagerBean()方法则是去暴露自定义校验的方法,源码注释有说明。
属性类说明:
1.AuthenticationFailureHandler:登入失败处理类
2.AuthenticationSuccessHandler:登入成功处理类
3.AccessDeniedHandler:权限不足处理类
4.AuthenticationEntryPoint:用户未登入处理类
5.JwtAuthenticationTokenFilter:JWT过滤器
6.FilterInvocationSecurityMetadataSource:获取访问路径需要的权限
7.accessDecisionManager:判断所访问路径权限与当前登入用户所拥有权限是否匹配
3、SpringSecurityConfig 配置类所依赖的类
3.1 AuthenticationFailureHandler类
package com.pzg.chat.handler;
import com.alibaba.fastjson.JSON;
import com.pzg.chat.constant.CommonConstant;
import com.pzg.chat.exception.BusinessException;
import com.pzg.chat.model.vo.ResultVO;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 登录失败处理
*/
@Component
public class AuthenticationFailHandlerImpl implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException {
//设置响应字符集utf-8
httpServletResponse.setContentType(CommonConstant.APPLICATION_JSON);
if (e.getCause()!=null){
if (e.getCause() instanceof BusinessException){
BusinessException businessException = (BusinessException) e.getCause();
ResultVO<Object> resultVO = ResultVO.fail(businessException.getCode(),businessException.getReason());
httpServletResponse.getWriter().write(JSON.toJSONString(resultVO));
}else{
httpServletResponse.getWriter().write(JSON.toJSONString(ResultVO.fail(e.getMessage())));
}
}else{
//返回错误信息
httpServletResponse.getWriter().write(JSON.toJSONString(ResultVO.fail(e.getMessage())));
}
}
}
3.2 AuthenticationSuccessHandler类
当登入成功后,就会触发这个类里的onAuthenticationSuccess方法,可以自己添加相应的逻辑即可。
package com.pzg.chat.handler;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.pzg.chat.constant.CommonConstant;
import com.pzg.chat.entity.UserAuth;
import com.pzg.chat.mapper.UserAuthMapper;
import com.pzg.chat.model.dto.UserDetailsDTO;
import com.pzg.chat.model.dto.UserInfoDTO;
import com.pzg.chat.model.vo.ResultVO;
import com.pzg.chat.service.TokenService;
import com.pzg.chat.utils.BeanCopy;
import com.pzg.chat.utils.UserUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.security.core.Authentication;;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Objects;
@Slf4j
@Component
public class AuthenticationSuccessHandlerImpl implements AuthenticationSuccessHandler {
@Resource
private UserAuthMapper userAuthMapper;
@Resource
private TokenService tokenService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
//将登入成功封装的用户信息复制到UserInfoDTO
UserInfoDTO userLoginDTO = BeanCopy.singleCopy(UserUtil.getUserDetailsDTO(), UserInfoDTO.class);
//判断
if (Objects.nonNull(authentication)) {
//从authentication.getPrincipal()获取UserDetailsDTO信息,先进行强转
UserDetailsDTO userDetailsDTO = (UserDetailsDTO) authentication.getPrincipal();
//生成token,并刷新了用户信息,存入redis
String token = tokenService.createToken(userDetailsDTO);
//封装token
userLoginDTO.setToken(token);
}
//设置响应的字符utf-8
response.setContentType(CommonConstant.APPLICATION_JSON);
//以流的方式返回
response.getWriter().write(JSON.toJSONString(ResultVO.ok(userLoginDTO), SerializerFeature.PrettyFormat, SerializerFeature.WriteMapNullValue));
//最后并刷新用户信息,存入数据库
updateUserInfo();
}
@Async
public void updateUserInfo() {
UserAuth userAuth = UserAuth.builder()
//获取id,通过security提供的SecurityContextHolder获取本线程的用户id
.id(UserUtil.getUserDetailsDTO().getId())
//获取访问的地址
.ipAddress(UserUtil.getUserDetailsDTO().getIpAddress())
//获取ip来源
.ipSource(UserUtil.getUserDetailsDTO().getIpSource())
//获取登入时的时间
.lastLoginTime(LocalDateTime.now())
.build();
//更新数据
userAuthMapper.updateById(userAuth);
}
}
3.3 AccessDeniedHandlerImpl类
package com.pzg.chat.handler;
import com.alibaba.fastjson.JSON;
import com.pzg.chat.constant.CommonConstant;
import com.pzg.chat.model.vo.ResultVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
@Slf4j
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
log.error("错误:"+accessDeniedException.getClass());
response.setContentType(CommonConstant.APPLICATION_JSON);
response.getWriter().write(JSON.toJSONString(ResultVO.fail("权限不足")));
}
}
3.4 AuthenticationEntryPointImpl类
package com.pzg.chat.handler;
import com.alibaba.fastjson.JSON;
import com.pzg.chat.constant.CommonConstant;
import com.pzg.chat.model.vo.ResultVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Slf4j
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
response.setContentType(CommonConstant.APPLICATION_JSON);
response.getWriter().write(JSON.toJSONString(ResultVO.fail(40001, "用户未登录")));
}
}
3.5 JwtAuthenticationTokenFilter类
package com.pzg.chat.filter;
import com.pzg.chat.model.dto.UserDetailsDTO;
import com.pzg.chat.service.TokenService;
import com.pzg.chat.utils.UserUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Objects;
@Configuration
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private TokenService tokenService;
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
UserDetailsDTO userDetailsDTO=null;
try {
userDetailsDTO = tokenService.getUserDetailsDTO(httpServletRequest);
}catch (Exception ignored){
}
//判断userDetailsDTO是否为null,为null说明用户已过期身份需要重修登入,并提交由springSecurity负责响应
//UserUtil.getAuthentication()判断当前springSecurity上下文应该为null,因为底层使用ThreadLocal,请求一次就会移除
if (Objects.nonNull(userDetailsDTO) && Objects.isNull(UserUtil.getAuthentication())){
//刷新token
tokenService.renewToken(userDetailsDTO);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetailsDTO, null, userDetailsDTO.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
filterChain.doFilter(httpServletRequest,httpServletResponse);
}
}
注意,这里的 tokenService.getUserDetailsDTO(httpServletRequest);是从自己封装的一个类,主要是从每次请求的头部获取token,并通过解析token后,将保存在redis的用户信息获取出来。然后通过SecurityContextHolder.getContext().setAuthentication(authenticationToken);将用户信息保存到SpringSecurity上下文中
3.6 FilterInvocationSecurityMetadataSource类
package com.pzg.chat.handler;
import com.pzg.chat.mapper.RoleMapper;
import com.pzg.chat.model.dto.ResourceListDTO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.access.SecurityConfig;
import org.springframework.security.web.FilterInvocation;
import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.CollectionUtils;
import javax.annotation.PostConstruct;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
@Slf4j
@Component
public class FilterInvocationSecurityMetadataSourceImpl implements FilterInvocationSecurityMetadataSource {
@Autowired
private RoleMapper roleMapper;
private static List<ResourceListDTO> resourceLists;
@PostConstruct
public void init(){
resourceLists = roleMapper.resourceInit();
}
@Override
public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
if (Objects.isNull(resourceLists)){
init();
}
FilterInvocation filterInvocation = (FilterInvocation) object;
//访问的方法
String method = filterInvocation.getRequest().getMethod();
//访问的路径
String url = filterInvocation.getRequest().getRequestURI();
AntPathMatcher antPathMatcher = new AntPathMatcher();
for (ResourceListDTO resourceList : resourceLists) {
if (antPathMatcher.match(url,resourceList.getUrl()) && method.equalsIgnoreCase(resourceList.getRequestMethod())){
List<String> roleList = resourceList.getRoleList();
if (CollectionUtils.isEmpty(roleList)){
return SecurityConfig.createList("disable");
}else{
return SecurityConfig.createList(roleList.toArray(new String[]{}));
}
}
}
//返回null表示匿名访问
return null;
}
@Override
public Collection<ConfigAttribute> getAllConfigAttributes() {
return null;
}
@Override
public boolean supports(Class<?> clazz) {
return FilterInvocation.class.isAssignableFrom(clazz);
}
}
其中 resourceLists = roleMapper.resourceInit();是当容器启动后就将数据库的每个路径对应的权限信息加载进来,方便根据用户请求的路径及请求方法来找到所需要的权限
3.7 accessDecisionManager类
package com.pzg.chat.handler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDecisionManager;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@Component
public class AccessDecisionManagerImpl implements AccessDecisionManager {
@Override
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
//获取用户封装的权限信息
List<String> authentications = authentication
.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
for (ConfigAttribute configAttribute : configAttributes) {
if (authentications.contains(configAttribute.getAttribute())){
return;
}
}
if (SecurityContextHolder.getContext().getAuthentication().getPrincipal().equals("anonymousUser")){
throw new DisabledException("用户未登入");
}else{
throw new AccessDeniedException("权限不足");
}
}
@Override
public boolean supports(ConfigAttribute attribute) {
return false;
}
@Override
public boolean supports(Class<?> clazz) {
return false;
}
}
4、 自定义登入校验规则
这里就是整个SpringSecurity最重要的部分了,在源码中可以发现,SpringSecurity通过将密码层层传递后,最后会到达一个名为loadUserByUsername()的方法里,而这个方法就是UserDetailsService接口下的一个方法,如果实现并重写loadUserByUsername()方法,则SpringSecurity就会从内存中去校验账号密码,也就是为什么在含有springsecurity的项目启动时,控制台会有一串密码,这个密码就是springsecurity生成的一个临时密码,并保存在内存当中,话不多说,看代码实现:
4.1 UserDetailsServiceImpl类
package com.pzg.chat.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.pzg.chat.entity.UserAuth;
import com.pzg.chat.entity.UserInfo;
import com.pzg.chat.exception.BusinessException;
import com.pzg.chat.mapper.RoleMapper;
import com.pzg.chat.mapper.UserAuthMapper;
import com.pzg.chat.mapper.UserInfoMapper;
import com.pzg.chat.model.dto.UserDetailsDTO;
import com.pzg.chat.service.RedisService;
import com.pzg.chat.utils.IpUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.Objects;
import static com.pzg.chat.constant.CommonConstant.*;
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Resource
private RoleMapper roleMapper;
@Resource
private UserInfoMapper userInfoMapper;
@Resource
private UserAuthMapper userAuthMapper;
@Autowired
private RedisService redisService;
@Autowired
private HttpServletRequest request;
@Override
public UserDetails loadUserByUsername(String account) throws UsernameNotFoundException {
UserAuth userAuth = userAuthMapper.selectOne(new LambdaQueryWrapper<UserAuth>()
.eq(UserAuth::getUsername, account).or()
.eq(UserAuth::getEmail,account).or()
.eq(UserAuth::getPhone,account));
if (Objects.isNull(userAuth)){
throw new BusinessException("输入的账号不存在");
}
Object isPass = redisService.get(SLIDER_PASS_ACCOUNTLOGIN + account);
if (Objects.isNull(isPass) || Integer.parseInt(isPass.toString())!=1){
throw new BusinessException("校验未通过,请重新进行校验");
}
redisService.del(SLIDER_PASS_ACCOUNTLOGIN + account);
return convertUserDetail(userAuth,request);
}
public UserDetailsDTO convertUserDetail(UserAuth userAuth,HttpServletRequest request){
//TODO 获取用户信息
UserInfo userInfo = userInfoMapper.selectById(userAuth.getUserInfoId());
//TODO 获取用户权限
List<String> roles = roleMapper.selectRoleList(userAuth.getUserInfoId());
String ipAddress = IpUtil.getIpAddress(request);
String ipSource = IpUtil.getIpSource(ipAddress);
return UserDetailsDTO.builder()
.username(userAuth.getUsername())
.password(userAuth.getPassword())
.email(userAuth.getEmail())
.phone(userAuth.getPhone())
.avatar(userInfo.getAvatar())
.disable(userInfo.getDisable())
.id(userInfo.getId())
.ipAddress(ipAddress)
.ipSource(ipSource)
.lastLoginTime(userAuth.getLastLoginTime())
.nickName(userInfo.getNickName())
.roles(roles)
.gender(userInfo.getGender())
.build();
}
}
这里通过账户去数据库中查找并返回用户信息,因为需要返回UserDetails 类型,所以我们就要去实现这个类,从而自定自己的规则:
4.2 UserDetailsDTO类
package com.pzg.chat.model.dto;
import com.pzg.chat.constant.CommonConstant;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.beans.Transient;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UserDetailsDTO implements UserDetails {
//id
private Integer id;
//昵称
private String nickName;
//账号
private String username;
//邮箱
private String email;
//手机号
private String phone;
//密码
private String password;
//头像
private String avatar;
//性别
private Integer gender;
//ip
private String ipAddress;
//位置
private String ipSource;
//是否禁用(0禁用,1不禁用)
private Integer disable;
//角色
private List<String> roles;
//序列化不然报错
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonSerialize(using = LocalDateTimeSerializer.class)
private LocalDateTime expireTime;
//序列化不然报错
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonSerialize(using = LocalDateTimeSerializer.class)
private LocalDateTime lastLoginTime;
@Override
@Transient
public Collection<? extends GrantedAuthority> getAuthorities() {
return roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
@Transient
public boolean isAccountNonExpired() {
return true;
}
@Override
@Transient
public boolean isAccountNonLocked() {
return disable.equals(CommonConstant.Locked);
}
@Override
@Transient
public boolean isCredentialsNonExpired() {
return true;
}
@Override
@Transient
public boolean isEnabled() {
return true;
}
}
我们只需要重写UserDetails 里的一些方法,并添加自己需要的属性,即可完成整改校验流程
说明:
对密码进行校验的是SpringSecurity本身帮我们去校验密码正确性了,不用我们去考虑,我们要考虑的仅仅是检验用户是否存在,并将用户信息进行封装即可。
5、总结
SpringSecurity难度可以说是比较难上手,但是上手后会发现没这么难,并且可制定的东西也非常多,我什么这种只是其中一种写法,难度属于中上一点,还有比较简单的方法,我就不做演示了,还是那句话,坚持总是有意想不到的结果。
最后说一下,后面我会出分析SpringSecurity源码的文章,讲解SpringSecurity从账号密码发出到校验的整个的过程,共大家相互学习,共同进步。