我们在篇(一)中已经谈到了默认的登录页面以及默认的登录账号和密码。

在这一篇中我们将自己定义登录页面及账号密码。

我们先从简单的开始吧:设置自定义的账号和密码(并非从数据库读取),虽然意义不大。

上一篇中,我们仅仅重写了 configure(HttpSecurity http) 方法,该方法是用于完成用户授权的。

为了完成自定义的认证,我们需要重写 configure(AuthenticationManagerBuilder auth) 方法。

public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// TODO Auto-generated method stub
auth.inMemoryAuthentication().withUser("Hello").password("{noop}World").roles("USER");
} @Override
protected void configure(HttpSecurity http) throws Exception {
// TODO Auto-generated method stub
http.authorizeRequests()
.antMatchers("/login").permitAll()
.antMatchers("/user").hasRole("USER")
.anyRequest().authenticated()
.and()
.formLogin().defaultSuccessUrl("/hello");
}
}

这个就是新的 WebSecurityConfig 类,控制器里面的方法我就不写了,仿照(一)很容易写出来,运行结果你们自己测试吧。

configure(AuthenticationManagerBuilder auth) 方法中,AuthenticationManagerBuilder 的 inMemoryAuthentication() 方法

可以添加用户,并给用户指定权限,它还有其他的方法,我们以后用到再讲。

在 Password 的地方我们需要注意了:

Spring 5.0 之后为了更加安全,修改了密码存储格式,密码存储格式为{id}encodedPassword。

id 是一个标识符,用于查找是哪个 PasswordEncoder,也就是密码加密的格式所对应的 PasswordEncoder。

encodedPassword 是指原始密码经过加密之后的密码。id 必须在密码的开始,id前后必须加 {}。

如果 id 找不到,id 则会为空,会抛出异常:There is no PasswordEncoder mapped for id "null"。

好啦,重点来啦,我们现在开始设置自定义登录页面,并从数据库读取账号密码。

一般来讲,我们先讲认证原理及流程比较好,不过这个地方我也说不太清楚。那我们还是从例子说起吧。

我用的是 MyBaits 框架操作 Mysql 数据库。为了支持它们,我们需要在原来的 pom.xml 中添加依赖。

<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.0</version>
</dependency> <dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>

好啦,现在我们首先定义一个用户对象以及一个角色对象。

package security.pojo;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails; public class SimpleUser implements UserDetails { private static final long serialVersionUID = 1L;
private String username;
private String password;
private String name;
private String telephone;
private String email;
private String headImg;
private boolean status = true;
private Set<Role> roles; public SimpleUser() {
super();
} public SimpleUser(String username, String password, String telephone) {
super();
this.username = username;
this.password = password;
this.telephone = telephone;
} public Set<Role> getRoles() {
return roles;
} public void setRoles(Set<Role> roles) {
this.roles = roles;
} @Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// TODO Auto-generated method stub
if(!roles.isEmpty()) {
List<GrantedAuthority> authorities = new ArrayList<>();
for (Role role : roles) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getName()));
}
return authorities;
}
return null;
} @Override
public String getPassword() {
// TODO Auto-generated method stub
return password;
} @Override
public String getUsername() {
// TODO Auto-generated method stub
return username;
} @Override
public boolean isAccountNonExpired() {
// TODO Auto-generated method stub
return true;
} @Override
public boolean isAccountNonLocked() {
// TODO Auto-generated method stub
return true;
} @Override
public boolean isCredentialsNonExpired() {
// TODO Auto-generated method stub
return true;
} @Override
public boolean isEnabled() {
// TODO Auto-generated method stub
return status;
} public String getTelephone() {
return telephone;
} public void setTelephone(String telephone) {
this.telephone = telephone;
} public String getEmail() {
return email;
} public void setEmail(String email) {
this.email = email;
} public boolean getStatus() {
return status;
} public void setStatus(boolean status) {
this.status = status;
} public void setUsername(String username) {
this.username = username;
} public void setPassword(String password) {
this.password = password;
} public String getName() {
return name;
} public void setName(String name) {
this.name = name;
} public String getHeadImg() {
return headImg;
} public void setHeadImg(String headImg) {
this.headImg = headImg;
}
}

package security.pojo;

public class Role {

    private String username;
private String name; public Role() {
super();
}
public Role(String username, String name) {
super();
this.username = username;
this.name = name;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
} }

然后,为了根据用户名找到用户,我们定义 Mapper:

package security.mapper;

import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Insert;
import security.pojo.SimpleUser; @Mapper
public interface SimpleUserMapper { @Select("select * from users where username = #{username}")
public SimpleUser findUserByUsername(String username); @Insert("insert into users(username,password,telephone) values(#{username},#{password},#{telephone})")
public int addSimpleUser(SimpleUser user); }

package security.mapper;

import java.util.Set;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import security.pojo.Role; @Mapper
public interface RoleMapper { @Select("select * from roles where username = #{username}")
public Set<Role> findRolesByUsername(String username);
}

而这样的一个 Mapper 是不会加载到 Bean 中去的,我们需要对这个类进行扫描:

package security;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication
@MapperScan("security.mapper")
public class SecurityApplication { public static void main(String[] args) {
SpringApplication.run(SecurityApplication.class, args);
} }

好啦,这个 Mapper 已经成为一个 Bean 了,下面的将是重点:来自 《Spring Boot 2 企业应用实战》

1、UserDetails

UserDetails 是 Spring Security 的一个核心接口。其中定义了一些可以获取用户名、密码、权限等与认证相关信息的方法。

   Spring Security 内部使用的 UserDetails 实现类大都是内置的 User 类,要使用 UserDetails,也可以直接使用该类。

在 Spring Security 内部,很多需要使用用户信息的时候,基本上都是使用 UserDetails,比如在登录认证的时候。

UserDetails 是通过 UserDetailsService 的 loadUserByUsername() 方法进行加载的。

我们也需要实现自己的 UserDetailsService 来加载自定义的 UserDetails 信息。

2、UserDetailsService

Authentication.getPrincipal() 的返回类型是 Object,但很多情况下返回的其实是一个 UserDetails 的实例。

   登录认证的时候 Spring Security 会通过 UserDetailsService 的 loadByUsername() 方法获取相对应的 UserDetails

进行认证,认证通过后会将改 UserDetails 赋给认证通过的 Authentication 的 principal,

   然后再把该 Authentication 存入 SecurityContext。之后如果需要使用用户信息,

可以通过 SecurityContextHolder 获取存放在 SecurityContext 中的 Authentication 的 principal。

3、Authentication

Authentication 用来表示用户认证信息,在用户登录认证之前,

Spring Security 会将相关信息封装为一个 Authentication

具体实现类的对象,在登录认证成功之后又会生成一个信息更全面、包含用户权限等信息的 Authentication 对象,

然后把它保存在 SpringContextHolder 所持有的 SecurityContext 中,供后续的程序进行调用,如访问权限的鉴定等。

4、SecurityContextHolder

SecurityContextHolder 是用来保存 SecurityContext 的。SecurityContext 中含有当前所访问系统的用户的详细信息。

默认情况下,SecurityContextHolder 将使用 ThreadLocal 来保存 SecurityContext。

这也就意味着在处于同一线程的方法中,可以从 ThreadLocal 获取到当前 SecurityContext。

好啦,这个地方就到这儿啦,没弄懂也不要紧,我们能看懂例子就行了:

package security.service;

import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import security.mapper.RoleMapper;
import security.mapper.SimpleUserMapper;
import security.pojo.Role;
import security.pojo.SimpleUser; @Service
public class SimpleUserService implements UserDetailsService { @Autowired
private SimpleUserMapper userMapper;
@Autowired
private RoleMapper roleMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// TODO Auto-generated method stub
SimpleUser user = userMapper.findUserByUsername(username);
Set<Role> roles = roleMapper.findRolesByUsername(username);
if(user == null) {
throw new UsernameNotFoundException("Username or Password is not correct");
}
user.setRoles(roles);
return new User(user.getUsername(),user.getPassword(),user.getAuthorities());
} public int addSimpleUser(SimpleUser user) {
user.setPassword(new BCryptPasswordEncoder().encode(user.getPassword()));
return userMapper.addSimpleUser(user);
} }

在这个类中,我们实现了 UserDetailsService 接口,然后重写了 loadUserByUsername(String username) 方法。

之后自动注入了一个根据用户名查找用户的 Mapper,再将查找的用户对象复制给 user。

当存在这个用户的时候,我们获取它的权限添加到权限列表中,然后把这个列表以及用户名,密码存入到 UserDetails 对象中。

因为一个用户的权限可能不止一个,所以是一个权限列表。

最后我们到了配置环节了:

package security.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
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.crypto.bcrypt.BCryptPasswordEncoder;
import security.service.SimpleUserService;
// 重写DaoAuthenticationProvider,authentication 携带username,password信息
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired
private SimpleUserService userService;
@Autowired
private AuthenticationProvider authenticationProvider;
private MessageSource messageSource;
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// TODO Auto-generated method stub
auth.authenticationProvider(authenticationProvider);
} @Override
protected void configure(HttpSecurity http) throws Exception {
// TODO Auto-generated method stub
http
.authorizeRequests()
.anyRequest().permitAll()
.and()
.formLogin().loginPage("/signin")
.usernameParameter("username")
.passwordParameter("password")
.loginProcessingUrl("/signin")
.and()
.csrf().disable();
} @Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new CustomAuthenticationProvider();
provider.setMessageSource(messageSource);
provider.setUserDetailsService(userService);
provider.setPasswordEncoder(new BCryptPasswordEncoder());
return provider;
}
}

package security.config;

import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.util.Assert; public class CustomAuthenticationProvider extends DaoAuthenticationProvider { @Override
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
// TODO Auto-generated method stub String presentedPassword = authentication.getCredentials().toString();
if (!getPasswordEncoder().matches(presentedPassword, userDetails.getPassword())) {
logger.debug("Authentication failed: password does not match stored value"); throw new BadCredentialsException(messages.getMessage(
"UNameOrPwdIsError","Username or Password is not correct"));
}
} @Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
// TODO Auto-generated method stub
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
() -> messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.onlySupports",
"Only UsernamePasswordAuthenticationToken is supported")); if("".equals(authentication.getPrincipal())) {
throw new BadCredentialsException(messages.getMessage(
"UsernameIsNull","Username cannot be empty"));
}
if("".equals(authentication.getCredentials())) {
throw new BadCredentialsException(messages.getMessage(
"PasswordIsNull","Password cannot be empty"));
} String username = (String) authentication.getPrincipal();
boolean cacheWasUsed = true;
UserDetails user = this.getUserCache().getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
try {
user = retrieveUser(username,
(UsernamePasswordAuthenticationToken) authentication);
}
catch (UsernameNotFoundException notFound) {
logger.debug("User '" + username + "' not found"); if (hideUserNotFoundExceptions) {
throw new BadCredentialsException(messages.getMessage(
"UNameOrPwdIsError","Username or Password is not correct"));
}
else {
throw notFound;
}
}
Assert.notNull(user,
"retrieveUser returned null - a violation of the interface contract");
}
try {
getPreAuthenticationChecks().check(user);
additionalAuthenticationChecks(user,
(UsernamePasswordAuthenticationToken) authentication);
}
catch (AuthenticationException exception) {
if (cacheWasUsed) {
cacheWasUsed = false;
user = retrieveUser(username,
(UsernamePasswordAuthenticationToken) authentication);
getPreAuthenticationChecks().check(user);
additionalAuthenticationChecks(user,
(UsernamePasswordAuthenticationToken) authentication);
}
else {
throw exception;
}
} getPostAuthenticationChecks().check(user); if (!cacheWasUsed) {
this.getUserCache().putUserInCache(user);
} Object principalToReturn = user; if (isForcePrincipalAsString()) {
principalToReturn = user.getUsername();
} return createSuccessAuthentication(principalToReturn, authentication, user);
} }

package security.config;

import org.springframework.context.support.MessageSourceAccessor;
import org.springframework.context.support.ResourceBundleMessageSource; public class MessageSource extends ResourceBundleMessageSource { public MessageSource() {
setBasename("messages");
} public static MessageSourceAccessor getAccessor() {
return new MessageSourceAccessor(new MessageSource());
} }

在第一个类中我们重写了两个 configure() 方法。其中一个我们之前谈过,不过并没有讲全,现在补充一下:

在 formLogin() 下还有 .usernameParameter() 和 .passwordParameter() 以及 .loginProcessingUrl("/login") 这三个函数。

前两个函数是用于指定登录页面用户名及密码的标识的,后面的一个是用于表单请求的 action 参数。

defaultSuccessUrl 是指定登录成功显示的页面,failureUrl 是指定登录失败显示的页面。

还有其他的一些我们以后用到再讲。

另一个 configure() 方法是用于认证的。我们这里仅仅只写了一行代码。

我们把之前的 @Service 的那个类注入到了 userService 中,再把 @Bean 的那个 Bean 注入到了 authenticationProvider 中。

在这个 Bean 里面有个 DaoAuthenticationProvider 类:

Spring Security 默认会使用 DaoAuthenticationProvider 实现 AuthenticationProvider 接口,专门进行用户认证处理。

DaoAuthenticationProvider 在进行认证处理的时候需要一个 UserDetailsService 来获取用户的信息 UserDetails,

其中包括用户名,密码和所拥有的权限等。

看到这些代码,可以知道我们写的代码都有联系了。我们还差一个控制器的代码:

package security.controller;

import java.util.Random;
import security.pojo.SimpleUser;
import security.service.SimpleUserService;
import com.zhenzi.sms.ZhenziSmsClient;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam; @Controller
public class SecurityController { @Autowired
private SimpleUserService userService; @GetMapping("/signin")
public String signIn() {
return "signin";
} @GetMapping("/signup")
public String signUp() {
return "signup";
} @PostMapping("/sign_up")
public String regist(@RequestParam(value="verifycode") String code,HttpServletRequest request, SimpleUser user) {
String verifycode = (String) request.getSession().getAttribute("verifyCode");
if(!code.equals(verifycode)){
return "failure";
}
userService.addSimpleUser(user);
return "signin";
} @PostMapping("/sendsms")// 若不要 response 参数,则会发出 /sendsms 请求。
public void sendsms(HttpServletRequest request, HttpServletResponse response, String telephone) {
try {
String verifyCode = String.valueOf(new Random().nextInt(899999) + 100000);
ZhenziSmsClient client = new ZhenziSmsClient("******", "******", "******");
client.send(telephone, "您的验证码为 " + verifyCode + ",有效期为 3 分钟,如非本人操作,可不予理会!");
request.getSession().setAttribute("verifyCode", verifyCode);
} catch (Exception e) {
e.printStackTrace();
}
}
}

好啦,到此 java 代码就结束了。

前面我们设置了 usernameParameter("username"),passwordParameter("password"),

另外由于默认的登录页面表单请求的 action="/login",用户名参数和密码分别为 "username","password"。

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
private String usernameParameter = "username";
private String passwordParameter = "password";
private boolean postOnly = true; public UsernamePasswordAuthenticationFilter() {
super(new AntPathRequestMatcher("/login", "POST"));
}
// ... ... }

如果用 thymeleaf 模板的话,这三个参数就分别用 th:action="{/login}" ,th:name="username",th:name="password"。

若是我们想自定义的话,比如登录页面为 signin.html,登录请求的 action 为 "/signin",

用户名参数为 uname,密码参数为 pwd。

    @Override
protected void configure(HttpSecurity http) throws Exception {
// TODO Auto-generated method stub
http.authorizeRequests()
.antMatchers("/css/**","/images/*","/js/**","/login").permitAll()
.antMatchers("/index").hasRole("USER")
.anyRequest().authenticated()
.and()
.formLogin().loginPage("/login")
.usernameParameter("uname")
.passwordParameter("pwd")
.loginProcessingUrl("/sign")
.defaultSuccessUrl("/success")
.failureUrl("/failure");
}

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<form th:action="@{/signin}" method="post">
<input th:name="uname" type="text">
<input th:name="pwd" type="password">
<input type="submit" value="login">
</form>
</body>
</html>

熬,对啦,连接数据库的地方需要写在 application.properties 文件里:

注意了,那个 url 数据库(security)后面一定要写上 ?serverTimezone=UTC&characterEncoding=utf-8 这样的,不然会出错的。

至此,入门项目就结束了,所有的源码都在上面啦,觉得可以的话点个赞啦!

链接:https://pan.baidu.com/s/13fc6P9NV49aRRBctr3MjNQ
提取码:4qgu

Spring Security 入门 (二)的更多相关文章

  1. SpringBoot集成Spring Security入门体验

    一.前言 Spring Security 和 Apache Shiro 都是安全框架,为Java应用程序提供身份认证和授权. 二者区别 Spring Security:重量级安全框架 Apache S ...

  2. Spring Security(二)

    Spring Security(二) 注:凡是源码部分,我已经把英文注释去掉了,有兴趣的同学可以在自己项目里进去看看.:-) 定义用户认证逻辑 用户登录成功后,用户的信息会被 Security 封装在 ...

  3. Spring Security 解析(二) —— 认证过程

    Spring Security 解析(二) -- 认证过程   在学习Spring Cloud 时,遇到了授权服务oauth 相关内容时,总是一知半解,因此决定先把Spring Security .S ...

  4. Spring Security教程(二):自定义数据库查询

    Spring Security教程(二):自定义数据库查询   Spring Security自带的默认数据库存储用户和权限的数据,但是Spring Security默认提供的表结构太过简单了,其实就 ...

  5. Spring Security 入门(基本使用)

    Spring Security 入门(基本使用) 这几天看了下b站关于 spring security 的学习视频,不得不说 spring security 有点复杂,脑袋有点懵懵的,在此整理下学习内 ...

  6. Spring Security 入门

    一.Spring Security简介 Spring Security是一个能够为基于Spring的企业应用系统提供声明式的安全访问控制解决方案的安全框架.它提供了一组可以在Spring应用上下文中配 ...

  7. Spring Security 入门(一)

    当你看到这篇文章时,我猜你肯定是碰到令人苦恼的问题了,我希望本文能让你有所收获. 本人几个月前还是 Spring 小白,几个月走来,看了 Spring,Spring boot,到这次的 Spring ...

  8. Spring Security入门(2-3)Spring Security 的运行原理 4 - 自定义登录方法和页面

    参考链接,多谢作者: http://blog.csdn.net/lee353086/article/details/52586916 http元素下的form-login元素是用来定义表单登录信息的. ...

  9. Spring Security 入门(1-1)Spring Security是什么?

    1.Spring Security是什么? Spring Security 是一个安全框架,前身是 Acegi Security , 能够为 Spring企业应用系统提供声明式的安全访问控制. Spr ...

随机推荐

  1. Google工程师亲授 Tensorflow2.0-入门到进阶

    第1章 Tensorfow简介与环境搭建 本门课程的入门章节,简要介绍了tensorflow是什么,详细介绍了Tensorflow历史版本变迁以及tensorflow的架构和强大特性.并在Tensor ...

  2. jquery ajax到servlet出现中文乱码(utf-8编码下)

    个人遇到的该问题有两大类: 第一类很普遍,就是jsp页面编码没有规定,servlet中接收参数没有转码,response没有使用setContentType()和setCharacterEncodin ...

  3. Winform中实现ZedGraph中曲线右键显示为中文

    场景 Winforn中设置ZedGraph曲线图的属性.坐标轴属性.刻度属性: https://blog.csdn.net/BADAO_LIUMANG_QIZHI/article/details/10 ...

  4. 三、SpringBoot 整合mybatis 多数据源以及分库分表

    前言 说实话,这章本来不打算讲的,因为配置多数据源的网上有很多类似的教程.但是最近因为项目要用到分库分表,所以让我研究一下看怎么实现.我想着上一篇博客讲了多环境的配置,不同的环境调用不同的数据库,那接 ...

  5. C++解决最基本的迷宫问题

    问题描述:给定一个最基本的迷宫图,用一个数组表示,值0表示有路,1表示有障碍物,找一条,从矩阵的左上角,到右下角的最短路.求最短路,大家最先想到的可能是用BFS求,本文也是BFS求最短路的. 源代码如 ...

  6. mysql-connector-java-5.-bin.jar 下载方法

    访问https://downloads.mysql.com/archives/c-j/,选择相应版本,如图 加油zip即可得到

  7. 品Spring:帝国的基石

    序 生活是一杯酒,有时需要麻醉自己,才能够暂时忘却痛苦与不快.生活是一杯茶,有时需要细细品味,才发现苦涩背后也会有甘甜. Spring是一杯酒,一眼望不到边的官方文档,着实让人难以下咽.Spring是 ...

  8. iOS 开发中一些 tips

    tableView 的 tableHeaderView 高度不正确的问题: func forceRefreshHeader() { let size = headerView.systemLayout ...

  9. 软件测试的分类&软件测试生命周期

    软件测试的分类: 按测试执行阶段:单元测试.集成测试.系统测试.验收测试.(正式验收测试,Alpha 测试-内侧,Beta 测试-公测) 按测试技术分类:黑盒测试.白盒测试.灰盒测试 按测试对象是否运 ...

  10. vscode中如何自动保存

    是的,vscode是个不错的编辑器,它的扩展功能能支持很多的语言,然后在实践过程中,我们发现每写好一次就得手动按CTRL+S,未免有点手酸,这时候我们就可以开启我们的自动保存功能,方式也很简单,在 文 ...