SpringSecurity ,oAuth2.0 从入门到源码精通 之 (二)spring security 自定义登录界面、登录验证、登录成功处理、登录失败处理

在上一篇文章中(SpringSecurity ,oAuth2.0 从入门到源码精通 之 (一)spring security 简单入门,包你学会)我们已经详细的讨论了 Spring Security 的基本使用,相信大家已经对 Spring Security 有了一个基本的了解了。本篇文章我们接着往下,来讨论一下在 Spring Security 中如何自定义我们的登录界面、自定义登录验证、自定义登录成功后的处理等。

本文使用的代码是在上一篇文章(SpringSecurity ,oAuth2.0 从入门到源码精通 之 (一)spring security 简单入门,包你学会)的代码基础之上的。上一篇的代码地址:spring-security-oauth-demo-one

在上一篇文章中,我们使用了 Spring Security 的默认登录界面,在实际开发中,我们肯定还是希望能够自定义我们的登录界面。下面我们就介绍一下如何自定义登录界面。

1 分析默认界面

在自定义登录界面之前,我们先来看一看 Spring Security 为我们提供的默认登录界面,我们在浏览器中查看界面源代码:

<body>
     <div class="container">
      <form class="form-signin" method="post" action="/login">
        <h2 class="form-signin-heading">Please sign in</h2>
        <p>
          <label for="username" class="sr-only">Username</label>
          <input type="text" id="username" name="username" class="form-control" placeholder="Username" required autofocus>
        </p>
        <p>
          <label for="password" class="sr-only">Password</label>
          <input type="password" id="password" name="password" class="form-control" placeholder="Password" required>
        </p>
<input name="_csrf" type="hidden" value="55960b17-4244-4632-8efa-be6f1767b6dc" />
        <button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button>
      </form>
</div>
</body>

这段代码很简单,其中有两个 input 输入框,他们的 name 属性分别为 username 和 password 对应我们的用户名密码。还有一个隐藏的 input 输入框:

<input name="_csrf" type="hidden" value="55960b17-4244-4632-8efa-be6f1767b6dc" />

这个的目的是帮助我们的服务器不受到 csrf 跨站攻击,其大致流程为:浏览器访问登录界面时,服务器会在返回的 html 中加上上面这个 csrf 令牌:55960b17-4244-4632-8efa-be6f1767b6dc,并在服务器上也存储一份,当用户输入用户名和密码后点击登录后,服务器会从请求中获取这个 csrf 令牌并与服务器中保存的对比,如果一致则认为此次请求是正常请求;否则就是恶意攻击,服务器就对本次请求进行拦截。通过这种隐藏表单域的方式也不是很安全,应该考虑更安全的其他方式,https 请求等,所以以后的示例代码中,我们先暂时关闭 csrf 攻击防护,后面的文章再说如何解决。

http.csrf().disable()//关闭 csrf 防护

2 编写自己的登录界面

经过前面的分析,这个登录界面其实很简单,我们也写一个差不多的:
我们需要先添加 thymeleaf 的依赖

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

myLoginPage.html:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<head>
    <meta charset="UTF-8">
    <title>我的登录页面</title>
</head>
<body>
<h2>自定义的登录页面</h2>
<form id="loginForm" action="/login" method="post">
    用户名:<input type="text" id="username" name="username"><br/><br/>&nbsp;&nbsp;&nbsp;码:<input type="password" id="password" name="password"><br/><br/>
    <button id="loginBtn" type="submit">登录</button>
</form>
</body>
</html>

这个界面很简单,具体的美化就由大家自由发挥了,我们这里只做个例子。其中就是一个普通的表单,并有两个必须的 input 输入框:name 属性分别为 username 和 password

如果我们关闭了 csrf 防护的话,就不需要写一个隐藏表单域来记录 csrf 令牌值了。如果没有关闭 csrf 的话需要添加如下代码:

<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>

但是只是编写一个登陆界面是还不够的,我们需要在配置文件中将我们的登录界面配置给 Spring
Security:
在我们之前的 WebSecurityConfiguration 代码基础上添加如下代码:

WebSecurityConfiguration.java:

@Override
    public void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable()//关闭 csrf 防护
                .formLogin()
                .loginPage("/toLoginPage")//跳转到登录界面的接口
                .loginProcessingUrl("/login")//如果不设置的话,就与 .loginPage() 设置的值相同
        ;
    }

.loginPage("/loginPage") :loginPage 是我们的自定义的 LoginController 控制器的请求接口,该接口将返回我们的 myLoginPage.html 给浏览器。

.loginProcessingUrl("/login")处理用户的登录请求的地址。即使我们要用默认的(/login),也必须配置,否则的话,loginProcessingUrl 的值将使用 loginPage 的值。也可以自己随便定义,/user/login;/test/login 等等都可以,只要保证一点就可以:必须与 myLoginPage.html 中表单的 action 值保持一致。

LoginController.java:

@Controller
public class LoginController {
    @RequestMapping("/toLoginPage")
    public String toLoginPage(){
        return "myLoginPage";//返回 我们自定义的登录界面
    }
}

配置完成后,我们重启项目,在浏览器输入地址:http://localhost:8080/toLoginPage 访问我们的登录界面:
在这里插入图片描述
输入正确的用户名和密码,然后我们发现会出现如下 404 错误:
在这里插入图片描述
大家不要怕,这是正常的,报 404 的原因是因为我们没有对登录成功做出任何的处理,Spring Security 就会将请求重定向到项目的根目录下 :/ 。从上图中我们可以看出,浏览器中的地址已经变成了 http://localhost:8080/ ,即项目的根目录,但是我们在根目录下没有 index.html 默认的主页面,所以就会出现 404 错误。但是我们的登录是成功的,我们可以在浏览器中输入我们之前访问书籍信息的地址:http://localhost:8080/book/get,如果成功的显示我们的书籍信息,则说明我们登录成功了。

myLoginPage.html 中的两个 input 输入框的 name 属性分别为 username 和 password,这是 Spring Security 默认设置的,我们也可以做出修改。
第一步,我们需要将 这两个属性改成我们想要的值:

<form id="loginForm" action="/login" method="post">
    用户名:<input type="text" id="username" name="user"><br/><br/>&nbsp;&nbsp;&nbsp;码:<input type="password" id="password" name="passwd"><br/><br/>
    <button id="loginBtn" type="submit">登录</button>
</form>

第二步,在 WebSecurityConfiguration 中做如下配置:

http
                .csrf().disable()
                .formLogin()
                    .usernameParameter("user")//与 myLoginPage.html 中的 name 属性为 user 的输入框对相应
                    .passwordParameter("passwd")
                   .loginPage("/toLoginPage")//跳转到登录界面的接口
                    .loginProcessingUrl("/login")//如果不设置的话,就与 .loginPage() 设置的值相同

配置完成后,我们重启项目,进行测试。

3 自定义登录成功后的处理

此小节我们就来讨论一下如何在登录成功后作出一些处理。

3.1 使用 index.html 默认界面(第一种方式)

通过前面的学习,我们已经知道,在登录成功后 Spring Security 会将我们的请求重定向到 http://localhost:8080/,项目的根目录,所以一种方式就是为项目添加一个默认的 index.html 。
在 templates 文件夹下添加 index.html :
index.html:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<head>
    <meta charset="UTF-8">
    <title>首页</title>
</head>
<body>
<h2>这里是首页</h2>
</body>
</html>

添加完成后,我们再重启项目并访问我们的登录界面,输入正确的用户名和密码,就会在浏览器中显示我们刚刚添加的首页,而不是报 404 错误了。
在这里插入图片描述

3.2 通过配置 defaultSuccessUrl 的方式 (第二种)

为 Spring Security 配置我们自定义的 URL ,来完成登录成功后的处理。
说是 URL 其实我们配置的是 URI,我们下面直接来看如何配置。
WebSecurityConfiguration 类中完成如下配置:

public void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .formLogin()
                   .loginPage("/toLoginPage")//跳转到登录界面的接口
                    .loginProcessingUrl("/login")//如果不设置的话,就与 .loginPage() 设置的值相同
                    .defaultSuccessUrl("/login/success")//登录成功后将执行 /login/success 
                .permitAll()
        ;
    }

所以我们还需要编写一个 处理 /login/success 请求的接口:
LoginController.java 中加入如下代码:

	@ResponseBody
    @RequestMapping("/login/success")
    public String loginSuccess(){
        System.out.println("loginSuccess执行了");
        //在这里我们就可以做很多别的事情
        return "登录成功";
    }

当用户登录成功,就会执行我们这个 loginSuccess() 方法,在该方法中,我们就可以做一些与我们业务逻辑紧密相关的任务了,比如在登录成功后给用户发送一个消息以确认是否本人登录等功能。
我们重启项目,在浏览器中输入地址 http://localhost:8080/toLoginPage 进行测试:
在这里插入图片描述
但是,我们有另外一种情况:有的应用可能在一些界面上有一些超链接供用户点击,但是用户此时没有登录,点击该超链接后就会跳转到登录界面,待用户登录成功后,就跳转到该超链接对应的页面。

我们来演示一下这个案例:我们在浏览器中输入我们访问书籍信息的地址:http://localhost:8080/book/get 来模拟用户直接在其他页面点击了该超链接来获取书籍信息,但是该接口需要用户登录才可以访问,由于用户目前还没有登录,所以会跳转到登录界面,待用户输入正确的用户名与密码之后,此时问题出现了,在浏览器中并没有显示我们前面的 “登录成功”,我们的 loginSuccess() 方法 也没有执行,而是直接执行了我们的 /book/get 接口,在浏览器中显示了我们的书籍信息。对于这个结果大家应该不会感到惊讶,因为这样处理类似问题的应用我们见的太多了。我们的主要问题是我们配置的

.defaultSuccessUrl("/login/success")

没有起作用。

那么,要想找到问题的答案,我们来分析一下 defaultSuccessUrl() 的源码

在分析源码前,我们先将出现问题的流程列一下:

(1)我们在浏览器中输入访问地址:http://localhost:8080/book/get

(2)请求发送给服务器,Spring Security 判断我们是否请求了受保护的资源。/book/get 是受保护的资源,必须要我们登录才可以访问。

(3)由于我们没有登录,服务器重定向到登录页面

(4)我们填写表单,点击登录

(5)浏览器将用户名和密码以表单形式发送给服务器

(6)服务器验证用户名密码。成功,进入到下一步。否则要求用户重新认证(第 3 步)

(7)服务器将请求重定向到 /book/get。

那么在第 7 步,Spring Security 是如何将请求重定向到 /book/get 的呢?接下来我们通过分析 defaultSuccessUrl() 源码来解决前面的这两个问题。

首先我们 ctrl + 左键点进 defaultSuccessUrl() 方法:

	public final T defaultSuccessUrl(String defaultSuccessUrl) {
		return defaultSuccessUrl(defaultSuccessUrl, false);
	}

我们发现该方法还有一个重载方法

public final T defaultSuccessUrl(String defaultSuccessUrl, boolean alwaysUse) {
		SavedRequestAwareAuthenticationSuccessHandler handler = new SavedRequestAwareAuthenticationSuccessHandler();
		handler.setDefaultTargetUrl(defaultSuccessUrl);
		handler.setAlwaysUseDefaultTargetUrl(alwaysUse);
		this.defaultSuccessHandler = handler;
		return successHandler(handler);
	}

SavedRequestAwareAuthenticationSuccessHandler 就是来完成登录成功后的处理的类。

SavedRequestAwareAuthenticationSuccessHandlerSimpleUrlAuthenticationSuccessHandler 的子类,并且 SimpleUrlAuthenticationSuccessHandler 实现了 AuthenticationSuccessHandler 接口。

AuthenticationSuccessHandler 接口中的 onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response, Authentication authentication)
方法就是在用户登录成功后要执行的方法。

后面的第四种方式我们就采用实现这个接口的方式来完成用户登录成功后的处理。

这里我们主要分析 SavedRequestAwareAuthenticationSuccessHandler 的源码
SavedRequestAwareAuthenticationSuccessHandler.java:
这里我们主要看 onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response, Authentication authentication) 方法

@Override
	public void onAuthenticationSuccess(HttpServletRequest request,
			HttpServletResponse response, Authentication authentication)
			throws ServletException, IOException {
		SavedRequest savedRequest = requestCache.getRequest(request, response);

		if (savedRequest == null) {
			super.onAuthenticationSuccess(request, response, authentication);
			return;
		}
		String targetUrlParameter = getTargetUrlParameter();
		if (isAlwaysUseDefaultTargetUrl()
				|| (targetUrlParameter != null && StringUtils.hasText(request
						.getParameter(targetUrlParameter)))) {
			requestCache.removeRequest(request, response);
			super.onAuthenticationSuccess(request, response, authentication);

			return;
		}

		clearAuthenticationAttributes(request);

		// Use the DefaultSavedRequest URL
		String targetUrl = savedRequest.getRedirectUrl();
		logger.debug("Redirecting to DefaultSavedRequest Url: " + targetUrl);
		getRedirectStrategy().sendRedirect(request, response, targetUrl);
	}

这段代码涉及了 savedRequest这个是用来缓存用户请求的对象它是 HttpServletRequest 实例的一个副本,然后将该对象缓存在 session 中,具体的代码大家可以查看 DefaultSavedRequest 类的源码。

何时对请求进行缓存的呢,在前面我们分析的流程中的第 3 步,用户访问了受保护的资源,但是没有登录会抛出 AuthenticationException 异常,在处理该异常的代码中将用户的 /book/get 请求进行缓存
ExceptionTranslationFilter.java:

protected void sendStartAuthentication(HttpServletRequest request,
			HttpServletResponse response, FilterChain chain,
			AuthenticationException reason) throws ServletException, IOException {
		// SEC-112: Clear the SecurityContextHolder's Authentication, as the
		// existing Authentication is no longer considered valid
		SecurityContextHolder.getContext().setAuthentication(null);
		requestCache.saveRequest(request, response);
		logger.debug("Calling Authentication entry point.");
		authenticationEntryPoint.commence(request, response, reason);
	}

言归正传,所以在执行 requestCache.getRequest(request, response); 后 savedRequest 不会为空(如果用户是直接在浏览器中输入 /toLoginPage 访问的登录界面,savedRequest 就为空,登录成功后就会执行我们的 loginSuccess() 方法。),所以接着往下执行,对于 targetUrlParameter 我们不需要关注太多,这个在现在的版本已经不使用了。

接着就是执行 clearAuthenticationAttributes(request); 这个方法的作用就是将用户登录过程中出现的异常给清除 。然后是最后两句代码:

		String targetUrl = savedRequest.getRedirectUrl();
		getRedirectStrategy().sendRedirect(request, response, targetUrl);

这里的 savedRequest.getRedirectUrl(); 其实就是我们的 /book/get 。我们通过查看它的源码可以发现:
DefaultSavedRequest.java:

@Override
	public String getRedirectUrl() {
		return UrlUtils.buildFullRequestUrl(scheme, serverName, serverPort, requestURI,
				queryString);
	}

DefaultSavedRequest 的实例有很多属性,这些属性就是将我们的 http://localhost:8080/book/get 拆开成各个部分。scheme:协议类型;serverName:服务器地址;serverPort:端口号等。getRedirectUrl() 方法就是再将这些数据组成一个 URL 地址:
在这里插入图片描述
最后重定向到 targetUrl 地址。

所以这就解释了我们一开始的问题,用户未登录的情况下直接访问 /book/get ,跳转到登录界面,输入正确的用户名和密码之后并没有执行我们的 LoginController 中的 loginSuccess() 方法,而是直接执行了 /book/get 接口。

在前面这种情况下,我们如何让我们的 loginSuccess() 方法一定执行呢?

在前面我们已经发现了 defaultSuccessUrl() 方法有一个重载方法:

defaultSuccessUrl(String defaultSuccessUrl, boolean alwaysUse) 

当第二个参数为 true 时,表示无论是什么情况,都会执行 defaultSuccessUrl 对应的接口。我们通过前面分析的 SavedRequestAwareAuthenticationSuccessHandler 类源码可以印证这一点。

3.3 通过设置 successForwardUrl 的方式(第三种)

我们直接开始配置:
第一步,先在 WebSecurityConfiguration 完成如下配置:

@Override
    public void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .formLogin()
                   .loginPage("/toLoginPage")//跳转到登录界面的接口
                    .loginProcessingUrl("/login")//如果不设置的话,就与 .loginPage() 设置的值相同
                    .successForwardUrl("/login/success2") 

第二步,在我们的 LoginController.java 中新增一个处理 /login/success2 请求的接口

@ResponseBody
    @RequestMapping("/login/success2")
    public String loginSuccess2(){
        System.out.println("loginSuccess2执行了");
        return "登录成功2";
    }

这样我们就配置完成了,重启项目,进行测试。这种方式无论用户在浏览器中输入的是 /toLoginPage 还是 /book/get ,最后登录成功后,都会执行我们的 /login/success2 接口

successForwardUrl() 使用的是请求转发,defaultSuccessUrl() 使用的是请求重定向。

查看 successForwardUrl() 源码:

public FormLoginConfigurer<H> successForwardUrl(String forwardUrl) {
		successHandler(new ForwardAuthenticationSuccessHandler(forwardUrl));
		return this;
	}

设置的 forwardUrl 实际上是交给了 ForwardAuthenticationSuccessHandler 类,该类也是 AuthenticationSuccessHandler 的实现类

3.4 通过实现 AuthenticationSuccessHandler 接口的方式(第四种)

AuthenticationSuccessHandler 顾名思义,当用户登录认证成功后,执行该 handler

第一步,我们需要编写一个类实现该接口:
LoginSuccessHandler.java:

@Component
public class LoginSuccessHandler implements AuthenticationSuccessHandler {

	/**
	当用户登录成功后,就执行该方法
	*/
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
     HttpServletResponse response, 
     Authentication authentication//用户的认证信息,第一篇文章中,我们已经介绍过
     ) throws IOException, ServletException {

        System.out.println("登录成功");
        System.out.println(authentication);
        response.setContentType("application/json; charset=utf-8");
        response.getWriter().write("LoginSuccessHandler 登录成功");

    }
}

第二步,将 LoginSuccessHandler 实例注入到 WebSecurityConfiguration 中:

	@Autowired
    private LoginSuccessHandler loginSuccessHandler;

第三步,配置 successHandler()

public void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .formLogin()
                   .loginPage("/toLoginPage")//跳转到登录界面的接口
                    .loginProcessingUrl("/login")//如果不设置的话,就与 .loginPage() 设置的值相同
                    .successHandler(loginSuccessHandler)

完成前面的配置后,我们重启项目,进行测试。最后会出现如下界面:
在这里插入图片描述
【小总结】如果 defaultSuccessUrl() 、successForwardUrl() 和 successHandler() 都配置了的话,Spring Security 将采用后配置的。如果都没有配置则使用我们介绍的第一种方式。

我们在前面分析 defaultSuccessUrl() 和 successForwardUrl() 源码时,大家一定都注意到了,在这两个方法中最后都调用了 successHandler(AuthenticationSuccessHandler successHandler) 方法,即我们的第四种方式。

public final T successHandler(AuthenticationSuccessHandler successHandler) {
		this.successHandler = successHandler;
		return getSelf();
	}

该方法给成员变量 successHandler 进行了赋值。所以后设置的会把先设置的覆盖,后配置的才生效。

然后我们又看:

private SavedRequestAwareAuthenticationSuccessHandler defaultSuccessHandler = new SavedRequestAwareAuthenticationSuccessHandler();
	private AuthenticationSuccessHandler successHandler = this.defaultSuccessHandler;

发现如果 successHandler 没有被赋值的话,就用默认的 defaultSuccessHandler,该实例就是我们前面提到的 SavedRequestAwareAuthenticationSuccessHandler 类的实例。

SavedRequestAwareAuthenticationSuccessHandler 类的父类的父类中有一个字段 defaultTargetUrl,即我们通过 defaultSuccessUrl() 方法设置的 url 。如果我们不通过第二、三、四种方式设置的话,就会使用默认的 defaultTargetUrl 的值。
在这里插入图片描述

4 对登录失败进行处理

配置方式与配置登录成功后的处理基本一致,这里我只列出配置的方法,如何配置大家可以参照文末示例代码,也可以自己发挥,这部分的源码分析与前面的基本差不多。

1failureUrl(String authenticationFailureUrl)2failureForwardUrl(String forwardUrl)3failureHandler(AuthenticationFailureHandler authenticationFailureHandler)

前两种方式,我们不能获取到用户为什么登陆失败,而第三种方式我们可以获取到登录失败的原因:AuthenticationException

5 自定义登录验证器

这部分内容,我们只讨论一下如何自定义 AuthenticationProvider 来实现验证用户名密码的正确性,对于其中的原理我们在后面的文章中再讨论。

接下来我们就直接开始走代码了。

第一步,我们需要将之前的用户配置信息做出一点改变,稍微换一种写法,但还是基于内存的方式。

WebSecurityConfiguration.java 类中将之前配置的用户信息注释掉,改成下面的配置:

	@Bean
    public UserDetailsService userDetailsService() {
    	//内存用户管理器,该类最终也实现自 UserDetailsService
        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(User.withUsername("llk").password(passwordEncoder().encode("123")).authorities("USER").build());
        manager.createUser(User.withUsername("zhangsan").password(passwordEncoder().encode("123456")).authorities("USER","ADMIN").build());
        return manager;
    }

这样做的目的是,方便我们一会儿在我们自定义的 AuthenticationProvider 中获取到用户信息。

第二步,编写自己的 AuthenticationProvider
MyAuthenticationProvider.java:

@Component//注册到容器中
public class MyAuthenticationProvider implements AuthenticationProvider {

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        //authentication 其实也是一个 UsernamePasswordAuthenticationToken 对象
        System.out.println(authentication.getClass());
        //获取用户在登录界面输入的信息
        String userName = (String) authentication.getPrincipal(); //拿到username
        String password = (String) authentication.getCredentials(); //拿到password

        //在 WebSecurityConfiguration 中配置的用户信息
        UserDetails userDetails = userDetailsService.loadUserByUsername(userName);
        if(userDetails == null){
            throw new UsernameNotFoundException("not found this username");
        }

        if ( passwordEncoder.matches(password,userDetails.getPassword()) ) {//验证密码
            //验证成功将结果返回给 PrivoderManager
            return new UsernamePasswordAuthenticationToken(userDetails, null,userDetails.getAuthorities());
        }else{
            throw new BadCredentialsException("the password and the username are not matches");
        }
        //如果返回了null,AuthenticationManager会交给下一个支持 authentication类型的AuthenticationProvider处理。
        //return null;
    }

    @Override
    public boolean supports(Class<?> authentication) {
        //该 provider 支持的认证方式
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }
}

**第三步,在 WebSecurityConfiguration 中注入我们的 MyAuthenticationProvider 实例,并配置给 Spring Security 以替换默认的 DaoAuthenticationProvider **:

	@Autowired
    private MyAuthenticationProvider myAuthenticationProvider;
    
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    	//让 Spring Security 使用我们自己的 AuthenticationProvider
        auth.authenticationProvider(myAuthenticationProvider);
    }

上述步骤完成后,重启项目,进行测试即可。

6 示例代码

示例代码地址:spring-security-oauth-demo-two

本文示例代码的项目结构:
在这里插入图片描述

好了,关于自定义登录相关的功能,我们就讨论到这里了,如果文中有什么错误,或者大家认为重点的地方而遗漏了的,还往大家指出,我们共同进步。下面文章我们一起讨论如何将用户信息保存在数据库中。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值