1 写在前面 关于spring security的介绍,网上一大堆,这里就不介绍了,这里直接使用springboot开始整合
2 整个流程 spring security授权和认证的流程大致和shiro差不多,其实跟我们自己基于RBAC的思想然后自定义拦截器进行权限拦截是一样的。
2.1 认证 认证的过程就是客户端用户登录,然后服务端将用户登录信息缓存起来,最后服务端将用户信息(基本信息、权限、token等)返回给客户端。
2.2 授权 授权的过程,首先客户端发起请求,携带token,服务端解析token,判断用户是否登录,再从缓存中查询用户的菜单,判断用户是否有权限请求菜单,最后返回数据给客户端
3 准备工作 此次继承基于RBAC思想实现,需要准备准备5张表(用户表、角色表、用户-角色中间表、菜单表、角色-菜单中间表)
DROP TABLE IF EXISTS `sys_menu`;
CREATE TABLE `sys_menu`(
`menu_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`pid` bigint(20) NULL DEFAULT NULL COMMENT '上级菜单ID',
`sub_count` int(5) NULL DEFAULT 0 COMMENT '子菜单数目',
`type` int(11) NULL DEFAULT NULL COMMENT '菜单类型',
`title` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '菜单标题',
`name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '组件名称',
`component` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '组件',
`menu_sort` int(5) NULL DEFAULT NULL COMMENT '排序',
`icon` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '图标',
`path` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '链接地址',
`i_frame` bit(1) NULL DEFAULT NULL COMMENT '是否外链',
`cache` bit(1) NULL DEFAULT b'0' COMMENT '缓存',
`hidden` bit(1) NULL DEFAULT b'0' COMMENT '隐藏',
`permission` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '权限',
`create_by` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '创建者',
`update_by` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '更新者',
`create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建日期',
`update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`menu_id`) USING BTREE,
UNIQUE INDEX `uniq_title`(`title`) USING BTREE,
UNIQUE INDEX `uniq_name`(`name`) USING BTREE,
INDEX `inx_pid`(`pid`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 154 CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '系统菜单' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for sys_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role`(
`role_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '名称',
`level` int(255) NULL DEFAULT NULL COMMENT '角色级别',
`description` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '描述',
`data_scope` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '数据权限',
`create_by` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '创建者',
`update_by` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '更新者',
`create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建日期',
`update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`role_id`) USING BTREE,
UNIQUE INDEX `uniq_name`(`name`) USING BTREE,
INDEX `role_name_index`(`name`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 6 CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '角色表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for sys_roles_menus
-- ----------------------------
DROP TABLE IF EXISTS `sys_roles_menus`;
CREATE TABLE `sys_roles_menus`(
`menu_id` bigint(20) NOT NULL COMMENT '菜单ID',
`role_id` bigint(20) NOT NULL COMMENT '角色ID',
PRIMARY KEY (`menu_id`, `role_id`) USING BTREE,
INDEX `FKcngg2qadojhi3a651a5adkvbq`(`role_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '角色菜单关联' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for sys_user
-- ----------------------------
DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user`(
`user_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`dept_id` bigint(20) NULL DEFAULT NULL COMMENT '部门名称',
`username` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '用户名',
`nick_name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '昵称',
`gender` varchar(2) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '性别',
`phone` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '手机号码',
`email` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '邮箱',
`avatar_name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '头像地址',
`avatar_path` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '头像真实路径',
`password` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '密码',
`is_admin` bit(1) NULL DEFAULT b'0' COMMENT '是否为admin账号',
`enabled` bigint(20) NULL DEFAULT NULL COMMENT '状态:1启用、0禁用',
`create_by` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '创建者',
`update_by` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '更新者',
`pwd_reset_time` datetime(0) NULL DEFAULT NULL COMMENT '修改密码的时间',
`create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建日期',
`update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`user_id`) USING BTREE,
UNIQUE INDEX `UK_kpubos9gc2cvtkb0thktkbkes`(`email`) USING BTREE,
UNIQUE INDEX `username`(`username`) USING BTREE,
UNIQUE INDEX `uniq_username`(`username`) USING BTREE,
UNIQUE INDEX `uniq_email`(`email`) USING BTREE,
INDEX `FK5rwmryny6jthaaxkogownknqp`(`dept_id`) USING BTREE,
INDEX `FKpq2dhypk2qgt68nauh2by22jb`(`avatar_name`) USING BTREE,
INDEX `inx_enabled`(`enabled`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 48 CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '系统用户' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for sys_users_roles
-- ----------------------------
DROP TABLE IF EXISTS `sys_users_roles`;
CREATE TABLE `sys_users_roles`(
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
`role_id` bigint(20) NOT NULL COMMENT '角色ID',
PRIMARY KEY (`user_id`, `role_id`) USING BTREE,
INDEX `FKq4eq273l04bpu4efj0jd0jb98`(`role_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '用户角色关联' ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;
4 开始集成 4.1 依赖pom
org.springframework.boot
spring-boot-starter-security
org.springframework.boot
spring-boot-starter-data-redis
org.apache.commons
commons-pool2
${commons-pool2.version}
org.apache.commons
commons-lang3
com.alibaba
fastjson
${fastjson.version}
com.baomidou
mybatis-plus-boot-starter
${mybatis-plus.version}
com.baomidou
mybatis-plus-generator
${mybatis-plus.version}
org.springframework.boot
spring-boot-starter-freemarker
org.mybatis.spring.boot
mybatis-spring-boot-starter
${mybatis-spring-boot-starter.version}
com.alibaba
druid-spring-boot-starter
${druid-spring-boot-starter.version}
mysql
mysql-connector-java
runtime
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-test
test
org.projectlombok
lombok
${lombok.version}
provided
com.imysh
zmy-common
1.0.0
4.2 定义一个实现了UserDetails的自定义用户类User
package com.imysh.zmy.spring.security.config;
import com.imysh.zmy.common.util.EmptyUtil;
import com.imysh.zmy.spring.security.dto.MenuDto;
import com.imysh.zmy.spring.security.dto.RoleDto;
import lombok.AllArgsConstructor;
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.io.Serializable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
/**
* @author zhangmy
* @date 2021/11/25 14:15
* @description 1、定义spring security专用用户类
*必须实现UserDetails,并重写那几个方法
*这个类属性的获取是通过实现UserDetailsService接口的类中的loadUserByUsername()方法获取
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User implements UserDetails, Serializable {/**
* 用户id
*/
private Long userId;
/**
* 用户名
*/
private String username;
/**
* 密码
*/
private String password;
/**
* token
*/
private String token;
/**
* 包含的角色
*/
private List roles;
/**
* 拥有的菜单权限
*/
private List menus;
/**
* 将用户的角色作为权限
* @return
*/
@Override
public Collection extends GrantedAuthority> getAuthorities() {
List auth = new ArrayList<>();
if (EmptyUtil.isNotEmpty(roles)) {
for (RoleDto role : roles) {
auth.add(new SimpleGrantedAuthority(role.getRoleId().toString()));
}
}
return auth;
}@Override
public String getPassword() {
return password;
}@Override
public String getUsername() {
return username;
}/**
* 用户是否未过期
* @return
*/
@Override
public boolean isAccountNonExpired() {
return true;
}/**
* 用户是否未锁定
* @return
*/
@Override
public boolean isAccountNonLocked() {
return true;
}/**
* 用户凭证是否未过期
* @return
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}/**
* 用户是否启用
* @return
*/
@Override
public boolean isEnabled() {
return true;
}
}
4.3 定义一个实现了UserDetailService的UserService 在这个类中重写loadUserByUsername方法,也就是咱们自定义获取用户的方法实现
package com.imysh.zmy.spring.security.config;
import com.imysh.zmy.common.util.EmptyUtil;
import com.imysh.zmy.spring.security.mapper.SysMenuMapper;
import com.imysh.zmy.spring.security.mapper.SysRoleMapper;
import com.imysh.zmy.spring.security.mapper.SysUserMapper;
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 java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* @author zhangmy
* @date 2021/11/25 14:33
* @description 2、定义实现UserDetailsService接口的类,并重写loadUserByUsername方法,在此方法中根据用户名获取用户信息
*返回的用户信息也是spring security专用的用户类,也就是com.imysh.zmy.spring.security.config.User
*/
@Service
public class UserService implements UserDetailsService {@Autowired
private SysUserMapper userMapper;
@Autowired
private SysRoleMapper roleMapper;
@Autowired
private SysMenuMapper menuMapper;
/**
* 用户信息缓存
*/
static Map userCache = new ConcurrentHashMap<>();
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user;
// 判断缓存中是否包含当前用户
if (userCache.containsKey(username)) {
user = userCache.get(username);
} else {
// 用户基本信息
user = this.userMapper.getSecurityUser(username);
if (EmptyUtil.isNotEmpty(user)) {
// 用户角色列表
user.setRoles(roleMapper.getUserRoles(user.getUserId()));
// 用户菜单列表
user.setMenus(menuMapper.getUserMenus(user.getUserId()));
// token
String token = Base64.getEncoder().encodeToString((user.getUsername() + "_" + System.currentTimeMillis()).getBytes(StandardCharsets.UTF_8));
user.setToken(token);
// 将用户信息保存在缓存中
userCache.put(username, user);
}
}
return user;
}
}
4.4 定义spring security的配置类
package com.imysh.zmy.spring.security.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
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.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.filter.CorsFilter;
/**
* @author zhangmy
* @date 2021/11/25 15:20
* @description 4、定义Spring Security配置类
*
* 其中@EnableWebSecurity 表示是Spring Security的配置类
* 其中@EnableGlobalMethodSecurity(prePostEnabled = true)表示开启@PreAuthorize、@PostAuthorize, @Secured这三个注解支持
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {@Autowired
private CorsFilter corsFilter;
@Autowired
private TokenFilter tokenFilter;
@Bean
public PasswordEncoder passwordEncoder() {
//return new BCryptPasswordEncoder();
return new PasswordEncoder() {/**
*
* @param rawPassword 用户输入的密码
* @return
*/
@Override
public String encode(CharSequence rawPassword) {
return (String) rawPassword;
}/**
*
* @param rawPassword 用户输入的密码
* @param encodedPassword 数据库存的加密后的密码
* @return
*/
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return encodedPassword.contentEquals(rawPassword);
}
};
}/**
* 配置SpringSecurity相关信息
*
* @param http
* @throws Exception
*/
@Override
public void configure(HttpSecurity http) throws Exception {
http.
// 关闭csrf
csrf().disable()
// 跨域处理过滤器
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class)
// 授权异常
.exceptionHandling()
// 防止iframe 造成跨域
.and()
.headers()
.frameOptions()
.disable()
// 不创建会话
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 静态资源等等
.antMatchers(
HttpMethod.GET,
"/*.html",
"/**/*.html",
"/**/*.css",
"/**/*.js",
"/webSocket/**"
).permitAll()
// swagger 文档
.antMatchers("/swagger-ui.html").permitAll()
.antMatchers("/swagger-resources/**").permitAll()
.antMatchers("/webjars/**").permitAll()
.antMatchers("/*/api-docs").permitAll()
// druid
.antMatchers("/druid/**").permitAll()
// 指定接口不进行验证
.antMatchers("/auth/login", "/auth/logout").permitAll()
// 所有请求都需要认证
.anyRequest().authenticated();
}
}
到这一步,已经可以实现用户登录的步骤了
4.5 定义TokenFilter类
package com.imysh.zmy.spring.security.config;
import com.imysh.zmy.common.util.EmptyUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.GenericFilterBean;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
/**
* @author zhangmy
* @date 2021/11/26 13:25
* @description
*/
@Component
public class TokenFilter extends GenericFilterBean {@Autowired
private UserService userService;
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
String token = this.resolveToken(httpServletRequest);
if (EmptyUtil.isNotEmpty(token)) {
// 解析token获取username
String username = new String(Base64.getDecoder().decode(token.getBytes(StandardCharsets.UTF_8)));
// 根据token获取鉴权信息
UserDetails userDetails = this.userService.loadUserByUsername(username.split("_")[0]);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(servletRequest, servletResponse);
}/**
* 获取Token
*
* @param request /
* @return /
*/
private String resolveToken(HttpServletRequest request) {
return request.getHeader("Authorization");
}
}
TokenFilter类用来在客户端发起请求时解析携带的token,得到当前登录的用户信息,然后下一步才能进行授权操作
4.6 权限校验 权限校验这里我们使用spring security自带的注解来实现
package com.imysh.zmy.spring.security.controller;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author zhangmy
* @date 2021/11/26 11:33
* @description
*/
@RestController
public class TestController {@GetMapping("/userList")
@PreAuthorize("@permissionService.hasPermission('user:list')")
public ResponseEntity uerList() {
return ResponseEntity.ok("userList success");
}@PreAuthorize("@permissionService.hasPermission('test:list')")
@GetMapping("/testList")
public ResponseEntity testList() {
return ResponseEntity.ok("testList success");
}
}
代码中的@PreAuthorize注解就是spring security用来做权限校验的,后面可以括号中的内容表示调用哪一个类的哪一个方法进行权限校验,这里就是调用permissionService类的hasPermission方法,参数就是权限标识,下面是PermissionService类
package com.imysh.zmy.spring.security.config;
import com.imysh.zmy.common.util.EmptyUtil;
import com.imysh.zmy.spring.security.dto.MenuDto;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* @author zhangmy
* @date 2021/11/26 11:38
* @description 验证授权
*/
@Service("permissionService")
public class PermissionService {@Autowired
private UserService userService;
public boolean hasPermission(String permission) {
UserDetails userDetails = this.getCurrentUser();
// 获取当前用户的所有权限
//List allPermissions = userDetails.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList());
//return Arrays.stream(permissions).anyMatch(allPermissions::contains);
List allPermissions = this.getUserPermissions(userDetails);
return allPermissions.contains(permission);
}/**
* 内部方法--获取用户权限
* @param userDetails
* @return
*/
private List getUserPermissions(UserDetails userDetails) {
if (userDetails instanceof User) {
User user = (User) userDetails;
List menus = user.getMenus();
if (EmptyUtil.isNotEmpty(menus)) {
Set menuSet = new HashSet<>();
for (MenuDto menu : menus) {
menuSet.add(menu.getPermission());
}
return new ArrayList<>(menuSet);
}
}
return null;
}/**
* 获取当前登录用户
*
* @return
*/
public UserDetails getCurrentUser() {
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
throw new RuntimeException("当前登录状态过期");
}
if (authentication.getPrincipal() instanceof UserDetails) {
return (UserDetails) authentication.getPrincipal();
}
throw new RuntimeException("找不到当前登录的信息");
}
}
到这里基本就整合完成了,当然这是简单版,没有使用JWT,密码也没有加密规则,还可以自定很多处理器完成如登录成功、失败的处理
5 效果验证 5.1 登录成功
文章图片
5.2 登录失败
文章图片
5.3 有权限访问
文章图片
5.4 无权限访问
文章图片
6 写在最后 总的来说Spring securit相对于shiro要更加复杂,更加重量级,但是它是spring家族成员,控制也更加细腻,可能学起来慢点,所以按照此教程,成功集成之后,再回头细看Spring Security的介绍也是可以的
【面试|SpringBoot 整合Spring Security(简单版)】先自我介绍一下,小编13年上师交大毕业,曾经在小公司待过,去过华为OPPO等大厂,18年进入阿里,直到现在。深知大多数初中级java工程师,想要升技能,往往是需要自己摸索成长或是报班学习,但对于培训机构动则近万元的学费,着实压力不小。自己不成体系的自学效率很低又漫长,而且容易碰到天花板技术停止不前。因此我收集了一份《java开发全套学习资料》送给大家,初衷也很简单,就是希望帮助到想自学又不知道该从何学起的朋友,同时减轻大家的负担。添加下方名片,即可获取全套学习资料哦
推荐阅读
- 单片机|app inventor制作手机蓝牙遥控器
- 面试|SpringBoot 整合mybatis,mybatis-plus
- 单点登录|那些利用假期学习的职场人,后来都怎么样了()
- 产品功能|单点登录的三种实现方式
- 面试|3天精通nginx第二天-负载均衡upstream配置
- 面试|centos安装mysql8
- 【走进RDS】之SQL Server性能诊断案例分析
- 程序员开发效率神器汇总!
- 622. 设计循环队列 : 数组模拟循环队列