• SpringBoot + SpringSecurity + redis 整合优化版(2)


    版本:SprintBoost2.7.0、  SpringSecurity5.4.x以上、Redis

    跟上一篇文章不一样,这次我们采取Redis来存储用户Token的方法来实现

    老规矩,下面就让我们按流程来吧。

    首先,还是一样,先让我们实现一个登陆的用户实体对象,这里添加@JsonIgnore注解是因为通过Redis将这个实体序列化 -> 反序列化,会因为没有属性下面几个方法会报错。具体注解还得根据Redis的序列化策略还添加,我这边Redis序列化策略用的是jackson2, 所以我就用jackson的注解。具体这个类内的用户属性,是采用继承还是直接创建个User属性,都可以按自己喜好实现,我这里选择了直接实现数据库的用户实体类。

    1. package com.mrlv.rua.auth.entity;
    2. import com.fasterxml.jackson.annotation.JsonIgnore;
    3. import com.google.common.collect.Lists;
    4. import com.mrlv.rua.admin.entity.SysPerm;
    5. import com.mrlv.rua.admin.entity.SysRole;
    6. import com.mrlv.rua.admin.entity.SysUser;
    7. import lombok.Data;
    8. import org.springframework.security.core.GrantedAuthority;
    9. import org.springframework.security.core.authority.SimpleGrantedAuthority;
    10. import org.springframework.security.core.userdetails.UserDetails;
    11. import java.util.ArrayList;
    12. import java.util.Collection;
    13. import java.util.List;
    14. import java.util.stream.Collectors;
    15. /**
    16. * @author lvshiyu
    17. * @description: 登陆用户信息体
    18. * @date 2022年07月06日 15:33
    19. */
    20. public class LoginUser extends SysUser implements UserDetails {
    21. /**
    22. * 权限列表,为什么是字符串,是因为我这里只需要保存用户所拥有的权限唯一标识即可,可自行改动。
    23. */
    24. private List permissionList;
    25. public List getPermissionList() {
    26. return permissionList;
    27. }
    28. public void setPermissionList(List permissionList) {
    29. this.permissionList = permissionList;
    30. }
    31. /**
    32. * 返回授予用户的权限。 不能返回null。
    33. * 返回:权限,按自然键排序(从不为空)
    34. * @return
    35. */
    36. @JsonIgnore
    37. @Override
    38. public Collectionextends GrantedAuthority> getAuthorities() {
    39. //如果用户的权限为null,则返回空数组
    40. if (permissionList == null){
    41. return new ArrayList<>(0);
    42. }
    43. List authorities = permissionList.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
    44. return authorities;
    45. }
    46. /**
    47. * 指示用户的帐户是否已过期。过期的帐户无法进行身份验证。
    48. * 返回:如果用户的帐户有效(即未过期),返回true,如果不再有效(即过期),返回false。
    49. * @return
    50. */
    51. @JsonIgnore
    52. @Override
    53. public boolean isAccountNonExpired() {
    54. return true;
    55. }
    56. /**
    57. * 用户被锁定或解锁状态。被锁定的用户无法进行认证。
    58. * 如果用户没有被锁定,返回true,否则返回false
    59. * @return
    60. */
    61. @JsonIgnore
    62. @Override
    63. public boolean isAccountNonLocked() {
    64. return true;
    65. }
    66. /**
    67. * 指示用户的凭据(密码)是否已过期。过期的凭据将阻止身份验证。
    68. * 返回:如果用户的凭证有效(即未过期),返回true,如果不再有效(即过期),返回false。
    69. * @return
    70. */
    71. @JsonIgnore
    72. @Override
    73. public boolean isCredentialsNonExpired() {
    74. return true;
    75. }
    76. /**
    77. * 表示该用户是启用还是禁用。 被禁用的用户无法进行认证。
    78. * 返回:如果用户已启用,则为true,否则为false
    79. * @return
    80. */
    81. @JsonIgnore
    82. @Override
    83. public boolean isEnabled() {
    84. return true;
    85. }
    86. @Override
    87. public String toString() {
    88. return "LoginUser{" +
    89. "permissionList=" + permissionList +
    90. '}';
    91. }
    92. }

    接下来实现Securtiy内置的UserDetailsService接口,这个接口的作用就是后期配Security配置中将其配置进去,是Security内置的登陆校验器,但具体登陆账户信息获取这一块得我们来实现。我这里表关联已经把用户信息和权限查询了出来。

    1. package com.mrlv.rua.auth.service.impl;
    2. import com.mrlv.rua.auth.mapper.SysUserMapper;
    3. import org.springframework.security.core.userdetails.UserDetails;
    4. import org.springframework.security.core.userdetails.UserDetailsService;
    5. import org.springframework.security.core.userdetails.UsernameNotFoundException;
    6. import org.springframework.stereotype.Service;
    7. import javax.annotation.Resource;
    8. /**
    9. * @author lvshiyu
    10. * @description: TODO
    11. * @date 2022年07月06日 16:41
    12. */
    13. @Service("userDetailsService")
    14. public class UserDetailsServiceImpl implements UserDetailsService {
    15. @Resource
    16. private SysUserMapper sysUserMapper;
    17. /**
    18. * 根据用户名查询用户信息
    19. * @param username
    20. * @return
    21. * @throws UsernameNotFoundException
    22. */
    23. @Override
    24. public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    25. //根据用户账户查询用户的信息进行校验
    26. UserDetails userDetails = sysUserMapper.getUserDetails(username);
    27. //完成校验,赋予授权
    28. return userDetails;
    29. }
    30. }

    然后是需要实现Security内置的接口FilterInvocationSecurityMetadataSource,这个接口的作用是用来获取全局权限元数据。我们这里采用静态的Map来存储。这里的Map,Key存的是权限的接口路径,value存储的是权限的唯一标识,后面我需要通过路径来获取该接口所需要的权限标识。

    DynamicSecurityMetadataSource中,获取我们的每个请求,然后通过Object我们获取请求的URL,再根据请求接口的URL获取访问该接口所需要的权限标识,并返回标识集合

    DynamicSecurityMetadataSource中还加了个clearDataSource方法,用来后期清除缓存用。

    1. package com.mrlv.rua.auth.security;
    2. import cn.hutool.core.util.URLUtil;
    3. import com.google.common.collect.Lists;
    4. import com.mrlv.rua.auth.service.IDynamicSecurityService;
    5. import org.springframework.security.access.ConfigAttribute;
    6. import org.springframework.security.access.SecurityConfig;
    7. import org.springframework.security.web.FilterInvocation;
    8. import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource;
    9. import org.springframework.stereotype.Component;
    10. import org.springframework.util.AntPathMatcher;
    11. import org.springframework.util.PathMatcher;
    12. import javax.annotation.PostConstruct;
    13. import javax.annotation.Resource;
    14. import java.util.ArrayList;
    15. import java.util.Collection;
    16. import java.util.List;
    17. import java.util.Map;
    18. /**
    19. * @author lvshiyu
    20. * @description: 动态权限数据源,用于获取动态权限
    21. * @date 2022年07月05日 17:51
    22. */
    23. @Component
    24. public class DynamicSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
    25. /**
    26. * 权限集合
    27. */
    28. private static Map configAttributeMap = null;
    29. /**
    30. * 动态权限服务
    31. */
    32. @Resource
    33. private IDynamicSecurityService dynamicSecurityService;
    34. /**
    35. * 初始化所有的对应权限集合
    36. */
    37. @PostConstruct
    38. public void loadDataSource() {
    39. //加载所有的URL和资源map
    40. configAttributeMap = dynamicSecurityService.loadDataSource();
    41. }
    42. /**
    43. * 获取访问路径所需要的权限
    44. * @param object
    45. * @return
    46. * @throws IllegalArgumentException
    47. */
    48. @Override
    49. public Collection getAttributes(Object object) throws IllegalArgumentException {
    50. //如果内存中的缓存数据为空,则加载所有的URL和资源map
    51. if (configAttributeMap == null) {
    52. this.loadDataSource();
    53. }
    54. List configAttributes = new ArrayList<>();
    55. //获取当前访问的路径
    56. String url = ((FilterInvocation) object).getRequestUrl();
    57. String path = URLUtil.getPath(url);
    58. //获取访问该路径所需资源
    59. PathMatcher pathMatcher = new AntPathMatcher();
    60. configAttributeMap.forEach((key, value) -> {
    61. if (pathMatcher.match(key, path)){
    62. configAttributes.add(value);
    63. }
    64. });
    65. //未设置操作请求权限,返回空集合
    66. return configAttributes;
    67. }
    68. /**
    69. * 清除权限集合
    70. */
    71. public static void clearDataSource() {
    72. configAttributeMap = null;
    73. }
    74. @Override
    75. public Collection getAllConfigAttributes() {
    76. return null;
    77. }
    78. @Override
    79. public boolean supports(Class clazz) {
    80. return true;
    81. }
    82. }

    dynamicSecurityService就是个普通的service,对权限表进行了列表查询,区别是这里的返回做了下处理。

    1. public Map loadDataSource() {
    2. //查询所有的动态权限
    3. List sysPerms = sysPermMapper.selectList(new QueryWrapper<>());
    4. Map collect = sysPerms.stream().collect(Collectors.toMap(
    5. SysPerm::getPath,
    6. e -> new org.springframework.security.access.SecurityConfig(e.getPermission())));
    7. return collect;
    8. }

    接下来,我们还要实现Security内置的接口AcessDecisionManager。这个接口的作用是用来比较登陆用户的权限的,将上面那个接口获取到访问接口所需要的权限标识和登陆用户所拥有的权限进行比对。

    1. package com.mrlv.rua.auth.security;
    2. import cn.hutool.core.collection.CollUtil;
    3. import lombok.extern.slf4j.Slf4j;
    4. import org.springframework.security.access.AccessDecisionManager;
    5. import org.springframework.security.access.AccessDeniedException;
    6. import org.springframework.security.access.ConfigAttribute;
    7. import org.springframework.security.authentication.InsufficientAuthenticationException;
    8. import org.springframework.security.core.Authentication;
    9. import org.springframework.security.core.authority.SimpleGrantedAuthority;
    10. import org.springframework.stereotype.Component;
    11. import java.util.Collection;
    12. /**
    13. * @author lvshiyu
    14. * @description: 自定义访问权限决策管理器,用于判断用户是否有访问权限
    15. * @date 2022年07月05日 17:39
    16. */
    17. @Component
    18. @Slf4j
    19. public class DynamicAccessDecisionManager implements AccessDecisionManager {
    20. /**
    21. * 访问权限决策
    22. * @param authentication 用户拥有的权限
    23. * @param object
    24. * @param configAttributes 资源所需要的权限
    25. * @throws AccessDeniedException
    26. * @throws InsufficientAuthenticationException
    27. */
    28. @Override
    29. public void decide(Authentication authentication, Object object, Collection configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
    30. //当接口未被配置资源时直接放行
    31. if (CollUtil.isEmpty(configAttributes)) {
    32. //未配置资源访问限制
    33. log.info("未配置资源访问限制");
    34. return;
    35. }
    36. for (ConfigAttribute attribute : configAttributes) {
    37. SimpleGrantedAuthority needAuthority = new SimpleGrantedAuthority(attribute.getAttribute());
    38. //将访问所需资源或用户拥有资源进行比对
    39. if (authentication.getAuthorities().contains(needAuthority)) {
    40. return;
    41. }
    42. }
    43. throw new AccessDeniedException("抱歉,您没有访问权限");
    44. }
    45. @Override
    46. public boolean supports(ConfigAttribute attribute) {
    47. return true;
    48. }
    49. @Override
    50. public boolean supports(Class clazz) {
    51. return true;
    52. }
    53. }

    基本的实现都差不多后我们开始实现登陆接口,Controller我就不发出来了,直接上Service。

    首先构建一个Security的登陆实体类,走内置的登陆认证。会根据我们UserDetailsServiceImpl所返回的查询结果进行认证,如果通过了,我们将用户信息写入Redis中,将key构建Token返回给前台;如果不通过则抛出对应的异常,我们这里对异常进行捕捉并返回想要返回的错误信息。当然,也可以再全局异常中进行捕捉。至于AuthenticationManager ,我们且看后面。

    1. package com.mrlv.rua.auth.service.impl;
    2. import com.mrlv.rua.auth.consts.RedisPreConst;
    3. import com.mrlv.rua.auth.dto.LoginDTO;
    4. import com.mrlv.rua.auth.entity.LoginUser;
    5. import com.mrlv.rua.auth.mapper.SysUserMapper;
    6. import com.mrlv.rua.auth.service.ILoginService;
    7. import com.mrlv.rua.auth.utils.JwtUtil;
    8. import com.mrlv.rua.common.exception.MasterException;
    9. import com.mrlv.rua.common.redis.utils.RedisUtil;
    10. import com.mrlv.rua.common.wrapper.Result;
    11. import org.springframework.security.authentication.*;
    12. import org.springframework.security.core.Authentication;
    13. import org.springframework.security.core.AuthenticationException;
    14. import org.springframework.security.core.userdetails.UserDetails;
    15. import org.springframework.stereotype.Service;
    16. import javax.annotation.Resource;
    17. /**
    18. * @author lvshiyu
    19. * @description: 用户登陆服务
    20. * @date 2022年07月06日 15:01
    21. */
    22. @Service
    23. public class LoginServiceImpl implements ILoginService {
    24. @Resource
    25. private AuthenticationManager authenticationManager;
    26. /**
    27. * 登陆
    28. * @param dto
    29. * @return
    30. */
    31. @Override
    32. public Result login(LoginDTO dto) {
    33. //进行用户认证 获取AuthenticationManager authenticate
    34. //构建认证对象
    35. UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(dto.getUsername(),
    36. dto.getPassword());
    37. //登陆认证...
    38. try {
    39. Authentication authenticate = authenticationManager.authenticate(authenticationToken);
    40. if (authenticate == null) {
    41. throw new MasterException("登陆失败");
    42. }
    43. LoginUser loginUser = (LoginUser)authenticate.getPrincipal();
    44. //写Redis
    45. boolean result = RedisUtil.hset(RedisPreConst.AUTH_ONLINE_USER + loginUser.getId(), "PC", loginUser);
    46. if (result) {
    47. //认证成功,生成token
    48. return Result.createBySuccess(JwtUtil.createToken(loginUser.getId() , "PC"));
    49. }
    50. return Result.createByErrorMessage("登陆失败");
    51. } catch (AccountExpiredException e) {
    52. //账号过期
    53. return Result.createByErrorMessage("账号过期");
    54. } catch (BadCredentialsException e) {
    55. //密码错误
    56. return Result.createByErrorMessage("密码错误");
    57. } catch (CredentialsExpiredException e) {
    58. //密码过期
    59. return Result.createByErrorMessage("密码过期");
    60. } catch (DisabledException e) {
    61. //账号不可用
    62. return Result.createByErrorMessage("账号不可用");
    63. } catch (LockedException e) {
    64. //账号锁定
    65. return Result.createByErrorMessage("账号锁定");
    66. } catch (InternalAuthenticationServiceException e) {
    67. //用户不存在
    68. return Result.createByErrorMessage("用户不存在");
    69. } catch (AuthenticationException e) {
    70. //其他错误
    71. return Result.createByErrorMessage("其他错误");
    72. } catch (Exception e) {
    73. throw new RuntimeException("登陆失败");
    74. }
    75. }
    76. }

    AuthenticationManager 需要注入依赖,这个依赖我们在配置类里面注册,在此之前,我们先创建一个过滤器。这里过滤器拦截请求,并验证其Token是否正常,然后从Redis中取出用户信息,并写入全局上下文。 如果没有Token或Token异常,则直接往下走,让Security自行处理(因为上下文取不到用户信息,所有会跳到未登录页面)。

    1. package com.mrlv.rua.auth.security;
    2. import cn.hutool.core.util.StrUtil;
    3. import com.auth0.jwt.exceptions.JWTVerificationException;
    4. import com.auth0.jwt.exceptions.SignatureVerificationException;
    5. import com.mrlv.rua.auth.consts.RedisPreConst;
    6. import com.mrlv.rua.auth.entity.LoginUser;
    7. import com.mrlv.rua.auth.utils.JwtUtil;
    8. import com.mrlv.rua.common.redis.utils.RedisUtil;
    9. import lombok.extern.slf4j.Slf4j;
    10. import org.springframework.security.access.SecurityMetadataSource;
    11. import org.springframework.security.access.intercept.AbstractSecurityInterceptor;
    12. import org.springframework.security.access.intercept.InterceptorStatusToken;
    13. import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
    14. import org.springframework.security.core.context.SecurityContextHolder;
    15. import org.springframework.security.core.userdetails.UserDetails;
    16. import org.springframework.security.web.FilterInvocation;
    17. import org.springframework.stereotype.Component;
    18. import org.springframework.web.filter.OncePerRequestFilter;
    19. import javax.annotation.Resource;
    20. import javax.servlet.*;
    21. import javax.servlet.http.HttpServletRequest;
    22. import javax.servlet.http.HttpServletResponse;
    23. import java.io.IOException;
    24. /**
    25. * @author lvshiyu
    26. * @description: 动态权限过滤器
    27. * @date 2022年07月05日 17:32
    28. */
    29. @Component
    30. @Slf4j
    31. public class DynamicSecurityFilter extends OncePerRequestFilter {
    32. /**
    33. * 过滤
    34. * @param request
    35. * @param response
    36. * @param filterChain
    37. */
    38. @Override
    39. protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    40. //获取请求头,判断是否已经登陆
    41. String token = request.getHeader(JwtUtil.HEADER_STRING);
    42. if (StrUtil.isNotBlank(token)) {
    43. String userId = null;
    44. try {
    45. userId = JwtUtil.getUserId(token);
    46. String key = RedisPreConst.AUTH_ONLINE_USER + userId;
    47. if (RedisUtil.hHasKey(key, "PC")) {
    48. LoginUser userDetails = (LoginUser)RedisUtil.hget(key, "PC");
    49. UsernamePasswordAuthenticationToken user = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
    50. SecurityContextHolder.getContext().setAuthentication(user);
    51. }
    52. } catch (JWTVerificationException e) {
    53. log.info("token异常 error:{}", e.getMessage(), e);
    54. } catch (Exception e) {
    55. e.printStackTrace();
    56. }
    57. }
    58. filterChain.doFilter(request, response);
    59. }
    60. }

    最后是通过配置类SpringSecurityConfig把所有的东西拼装起来。

    首先把过滤器DynamicSecurityFilter、用户信息获取UserDetailsService、权限决策器DynamicAccessDecisionManager、权限数据源加载DynamicSecurityMetadataSource 依赖注入,

    然后逐个配置上。

    这里有两个异常处理:无权访问、无登录。分别返回对应的JSON。 同时放开登陆接口 /login。

    最后把我们的过滤器DynamicSecurityFilter配置在内置过滤器FilterSecurityInterceptor之前,同时配置UserDetailsService

    注入依赖  AuthenticationManager ,用于 loginService 注入调用登陆认证。

    我这里的密码加密没有配置,如有需要可以自行配置。

    1. package com.mrlv.rua.auth.security;
    2. import cn.hutool.json.JSONUtil;
    3. import com.mrlv.rua.common.wrapper.Result;
    4. import org.springframework.context.annotation.Bean;
    5. import org.springframework.context.annotation.Configuration;
    6. import org.springframework.security.authentication.*;
    7. import org.springframework.security.config.annotation.ObjectPostProcessor;
    8. import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
    9. import org.springframework.security.config.annotation.web.builders.HttpSecurity;
    10. import org.springframework.security.config.http.SessionCreationPolicy;
    11. import org.springframework.security.core.userdetails.UserDetailsService;
    12. import org.springframework.security.crypto.password.PasswordEncoder;
    13. import org.springframework.security.web.SecurityFilterChain;
    14. import org.springframework.security.web.access.intercept.FilterSecurityInterceptor;
    15. import javax.annotation.Resource;
    16. /**
    17. * @author lvshiyu
    18. * @description: SpringSecurity 5.4.x以上新用法配置 为避免循环依赖,仅用于配置HttpSecurity
    19. * @date 2022年07月05日 15:43
    20. */
    21. @Configuration
    22. public class SpringSecurityConfig {
    23. @Resource
    24. private DynamicSecurityFilter dynamicSecurityFilter;
    25. @Resource
    26. private UserDetailsService userDetailsService;
    27. @Resource
    28. private DynamicAccessDecisionManager accessDecisionManager;
    29. @Resource
    30. private DynamicSecurityMetadataSource securityMetadataSource;
    31. /**
    32. * 配置过滤
    33. * @param security
    34. * @return
    35. * @throws Exception
    36. */
    37. @Bean
    38. SecurityFilterChain filterChain(HttpSecurity security) throws Exception {
    39. security.cors().and().csrf().disable()
    40. .authorizeRequests()
    41. .withObjectPostProcessor(new ObjectPostProcessor() {
    42. @Override
    43. public extends FilterSecurityInterceptor> O postProcess(O o) {
    44. //决策管理器
    45. o.setAccessDecisionManager(accessDecisionManager);
    46. //安全元数据源
    47. o.setSecurityMetadataSource(securityMetadataSource);
    48. return o;
    49. }
    50. }).and()
    51. //关闭session,不通过Session获取SecurityContext
    52. .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    53. //异常处理(权限拒绝、登录失效等)
    54. .and().exceptionHandling()
    55. .authenticationEntryPoint((request, response, accessDeniedException) -> {
    56. //处理匿名用户访问无权限资源时的异常(即未登录,或者登录状态过期失效)
    57. response.setContentType("application/json;charset=utf-8");
    58. response.getWriter().write(JSONUtil.toJsonStr(Result.createByErrorMessage("请登录")));
    59. })
    60. .accessDeniedHandler((request, response, accessDeniedException) -> {
    61. //返回json形式的错误信息
    62. response.setContentType("application/json;charset=utf-8");
    63. response.getWriter().write(JSONUtil.toJsonStr(Result.createByErrorMessage("没有权限访问")));
    64. response.getWriter().flush();
    65. })
    66. //对于登录接口 允许匿名访问
    67. .and().authorizeRequests()
    68. .antMatchers("/login").anonymous()
    69. //特定化权限的写法
    70. //.antMatchers("/textCors").hasAuthority("system:ddd:aaa")
    71. //除上面外的所有请求全部需要鉴权认证
    72. .anyRequest().authenticated();
    73. //在security原生过滤器之前添加过滤器
    74. return security.addFilterBefore(dynamicSecurityFilter, FilterSecurityInterceptor.class)
    75. .userDetailsService(userDetailsService)
    76. .build();
    77. }
    78. /**
    79. * 获取AuthenticationManager(认证管理器),登录时认证使用
    80. * @param authenticationConfiguration
    81. * @return
    82. * @throws Exception
    83. */
    84. @Bean
    85. public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
    86. return authenticationConfiguration.getAuthenticationManager();
    87. }
    88. /**
    89. * 添加加密配置
    90. * @return
    91. */
    92. @Bean
    93. public PasswordEncoder passwordEncoder() {
    94. return new PasswordEncoder() {
    95. @Override
    96. public String encode(CharSequence rawPassword) {
    97. return rawPassword.toString();
    98. }
    99. @Override
    100. public boolean matches(CharSequence rawPassword, String encodedPassword) {
    101. if (rawPassword == null) {
    102. throw new IllegalArgumentException("rawPassword cannot be null");
    103. }
    104. if (encodedPassword == null || encodedPassword.length() == 0) {
    105. return false;
    106. }
    107. if (!rawPassword.equals(encodedPassword)) {
    108. return false;
    109. }
    110. return true;
    111. }
    112. };
    113. }
    114. }

    以上整合SpringBoot、SpringSecurity完成,实现了前后端分离,用户状态保存到Redis(支持分布式认证、系统重启也不会导致用户离线、可以手动让用户离线等优势)。

    可能讲的不够细,原理什么的也没有细讲,这里只说实现功能,如果遇到什么问题可以留言。

  • 相关阅读:
    前端进击笔记第三节 CSS:页面布局的基本规则和方式
    快速入门安装及使用&git与svn的区别&常用命令
    Feign远程调用
    FPGA工程师面试试题集锦61~70
    天津工业大学计算机考研资料汇总
    C++之策略(Strategy)模式
    《精品生活》万方普刊投稿发表简介
    nrrd转bmp格式代码
    Vue中如何进行音视频录制与视频剪辑
    虚函数 纯虚函数 抽象类
  • 原文地址:https://blog.csdn.net/qq826303461/article/details/126036738