一、开发社区首页
代码:community-1.6
1.1、建立数据库
init.schema.sql
文件
1.2、DAO数据访问层
1、编写实体类(Entity)
User.java 用户信息
DiscussPost.java
Page.java 封装分页相关的信息
2、编写对应Mapper文件(注释:@Mapper)
public interface UserMapper {}
public interface DiscussPostMapper {}
3、编写xml
配置文件(放在 resources.mapper
下)
user-mapper.xml
DiscussPost-mapper.xml
4、测试 MapperTests.java
1.3、service业务层
1、编写service包下的 DiscussPostService.java
和UserService.java
(通过userID查询到用户的信息)
1.4、Controller视图层
1.4.1、开发社区首页,显示前10个数据
1、编写HomeController.java
2、index.html
1.4.2、开发分页组件
分页显示
1.5、项目调试技巧
响应状态码的含义
https://developer.mozilla.org/zh-CN/docs/Web/HTTP
常见状态码:
- 200:请求成功
- 302:重定向:请求的资源现在临时从不同的 URI 响应请求。由于这样的重定向是临时的,客户端应当继续向原有地址发送以后的请求。只有在Cache-Control或Expires中进行了指定的情况下,这个响应才是可缓存的
- 404:客户端响应:请求失败,请求所希望得到的资源未被在服务器上发现。路径有问题。
- 500:服务器响应:服务器遇到了不知道如何处理的情况。
服务端断点调试技巧
客户端断点调试技巧
设置日志级别,并将日志输出到不同的终端
logback
配置文件:logback-spring.xml
1.6、版本控制Git
二、SpringBoot实战,开发社区登录模块
2.1、发送邮件
-
邮箱设置:启用客户端SMTP服务
-
Spring Email:
-
在
pom.xml
文件中导入jar包 -
邮箱参数配置
-
使用
JavaMailSender
发送邮件- 在
util
包下创建MailClient.java
工具类,封装发送邮件功能 - 测试
MapperTests.java
- 在
-
-
模板引擎
- 使用
Thymeleaf
发送HTML邮件:resource.templates.mail.demo.html
- 使用
2.2、开发注册功能
共涉及到了三个请求
(1) 点击注册按钮,弹出注册框
(2) 填完表单数据,点击注册,会发送一次请求
(3) 注册成功后,用户接收到激活邮件后,点击激活邮件又会发送一次请求(进行激活服务)
2.2.1、请求一:访问注册页面
controller层,显示注册网站 controller.LoginController.java
2.2.2、请求二:提交注册数据
- 通过表单提交数据
- 服务端验证账号是否已经存在、邮箱是否已经注册
- 服务端发送激活邮件
(1)注册service层模块
导入 commons lang
依赖,完成对字符串的一些基本的判断,如非空等等
配置网站相关信息
#自己定义的属性
community.path.domain=http://localhost:8080
创建一个CommunityUtil.java
工具类
public class CommunityUtil {
//生成随机的字符串,以后将用于验证码的生成以及上传文件时对文件的随机命名
public static String generateUUID(){
//只需要数字和字符串,因此将-替换为空字符
return UUID.randomUUID().toString().replace("-","");
}
//MD5加密,在密码的后面加上一个随机字符串,进而提高密码的安全性-只能加密而不能解密
// hello -> abc123456
// hello + 3e4a8
public static String md5(String key){
if(StringUtils.isBlank(key)){
return null;
}
//利用spring自带的工具类来完成加密操作
return DigestUtils.md5DigestAsHex(key.getBytes());
}
}
开发注册业务层:UserService.java
: 注入邮件客户端,注入模板引擎,注入域名的值和项目名
@Autowired
private UserMapper userMapper;
@Autowired
private MailClient mailClient;//邮件客户端
@Value("${community.path.domain}")//域名
private String domain;
@Value("${server.servlet.context-path}")//项目名
private String contextPath;
@Autowired
private TemplateEngine templateEngine;//模板引擎
public User findUserById(int id) {
return userMapper.selectById(id);
}
编写注册业务层UserService.java
中注册方法的逻辑,将返回的结果封装在一个Map集合中:
- 先对参数值非空进行判断、账号是否存在、邮箱是否已经注册过,
- 开始注册用户,将用户信息保存至数据库中,生成一个随机码(salt:5位)追加在密码后面,对密码进行加密,覆盖user对象中的pass字段,再设置其他的字段(默认普通用户(type),默认未激活(status),默认的头像路径(head_url)),随机生成一个激活码,设置至user对象之中,默认的注册时间。
public Map<String, Object> register(User user) {
Map<String, Object> map = new HashMap<>();//若注册失败则map != null,返回失败原因
// 空值处理
if (user == null) {
throw new IllegalArgumentException("参数不能为空!");
}
if (StringUtils.isBlank(user.getUsername())) {
map.put("usernameMsg", "账号不能为空!");
return map;
}
if (StringUtils.isBlank(user.getPassword())) {
map.put("passwordMsg", "密码不能为空!");
return map;
}
if (StringUtils.isBlank(user.getEmail())) {
map.put("emailMsg", "邮箱不能为空!");
return map;
}
// 验证账号:在数据库中查询是否账号已经被注册!!!!
User u = userMapper.selectByName(user.getUsername());
if (u != null) {
map.put("usernameMsg", "该账号已存在!");
return map;
}
// 验证邮箱:在数据库中查询是否邮箱已经被注册!!!!
u = userMapper.selectByEmail(user.getEmail());
if (u != null) {
map.put("emailMsg", "该邮箱已被注册!");
return map;
}
// 注册用户
//为加密密码生成5位的随机符串:
user.setSalt(CommunityUtil.generateUUID().substring(0, 5));
user.setPassword(CommunityUtil.md5(user.getPassword() + user.getSalt()));
user.setType(0);
user.setStatus(0);
user.setActivationCode(CommunityUtil.generateUUID());//注册的激活码
user.setHeaderUrl(String.format("http://images.nowcoder.com/head/%dt.png", new Random().nextInt(1000)));//随机头像
user.setCreateTime(new Date());
//insert完了以后user里面就有id了,这是数据库自动为我们生成的id
userMapper.insertUser(user);
//激活邮件
Context context = new Context();
context.setVariable("email", user.getEmail());
//url由程序员自己来定,你自己希望服务器用哪个路径去处理激活的请求
//路径中加入了userID和激活码code,为了后面激活账号的页面好获取到用户id和激活码!!!!
// 激活路径:http://localhost:8080/community/activation/101/code
String url = domain + contextPath + "/activation/" + user.getId() + "/" + user.getActivationCode();
context.setVariable("url", url);
String content = templateEngine.process("/mail/activation", context);
mailClient.sendMail(user.getEmail(), "激活账号", content);//发送激活邮件
return map;
}
数据库自动为我们生成的id:
给用户发送HTML链接,模板参数activation.html
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<link rel="icon" href="https://static.nowcoder.com/images/logo_87_87.png"/>
<title>牛客网-激活账号</title>
</head>
<body>
<div>
<p>
<b th:text="${email}">xxx@xxx.com</b>, 您好!
</p>
<p>
您正在注册牛客网, 这是一封激活邮件, 请点击
<a th:href="${url}">此链接</a>,
激活您的牛客账号!
</p>
</div>
</body>
</html>
(2)注册Controller层模块
LoginController.java
:
注入userService
@Autowired
private UserService userService;
编写注册请求处理方法
调用userService
的方法,如果注册成功,则需要跳转至网站的第三方页面上operate-result.html
@RequestMapping(path="/register",method=RequestMethod.POST)
public String register(Model model,User user){
Map<String, Object> map = userService.register(user);
//注册成功的话先跳转到/site/operate-result.html页面上,然后再跳转至首页
if(map == null || map.isEmpty()){
model.addAttribute("msg","注册成功,我们已经向您的邮箱发送了一封激活邮件,请尽快激活!");
model.addAttribute("target","/index");//最终需要跳转至首页
return "/site/operate-result";
}
else{
//错误的时候直接把错误信息发送给页面即可,并且重新跳转至注册页面
model.addAttribute("usernameMsg", map.get("usernameMsg"));
model.addAttribute("passwordMsg", map.get("passwordMsg"));
model.addAttribute("emailMsg", map.get("emailMsg"));
return "/site/register";
}
}
resource.templates.site.operate-result.html
:
<!-- 内容 -->
<div class="main">
<div class="container mt-5">
<div class="jumbotron">
<p class="lead" th:text="${msg}">您的账号已经激活成功,可以正常使用了!</p>
<hr class="my-4">
<p>
系统会在 <span id="seconds" class="text-danger">8</span> 秒后自动跳转,
您也可以点此 <a id="target" th:href="@{${target}}" class="text-primary">链接</a>, 手动跳转!
</p>
</div>
</div>
</div>
2.2.3、请求三:激活注册账号
(1)service层
用户点击激活邮件(携带参数userId
和activateCode
),最终对应到数据库中就是需要改变用户的状态,在service层增加一个业务方法。
激活逻辑判断:(对应三种情况)-把这三种情况放到一个接口的常量声明(util.CommunityConstant.java
)里,为了实现复用, 让UseService
实现这个常量接口。
- (1)激活成功
- (2)重复激活
- (3)伪造的激活码
CommunityConstant.java:
package com.newcoder.community.util;
public interface CommunityConstant {
//激活成功
int ACTIVATION_SUCCESS = 0;
//重复激活
int ACTIVATION_REPEAT = 1;
//激活失败
int ACTIVATION_FAILURE = 2;
}
业务层:UseService.java:
根据用户id和激活码来激活用户
@Service
public class UserService implements CommunityConstant {
public int activation(int userId, String code){
//先根据userId查询出user
User user = userMapper.selectById(userId);
//重复激活的情况
if(user.getStatus() == 1){
return ACTIVATION_REPEAT;
}
//可以激活成功
else if(code.equals(user.getActivationCode())){//输入的激活码=数据库中该user激活码
userMapper.updateStatus(userId, 1);//把用户的激活状态改为1,表示激活成功
clearCache(userId);
return ACTIVATION_SUCCESS;
}
else{
return ACTIVATION_FAILURE;
}
}
}
(2)Controller层
LoginController.java
然该类实现CommunityConstant
接口,再加一个响应登录页面/login
的请求!!
@Controller
public class LoginController implements CommunityConstant {
//....//
//响应登录页面的请求
@RequestMapping(path = "/login", method = RequestMethod.GET)
public String getLoginPage() {
return "/site/login";
}
//http://localhost:8080/community/activation/101/code :用户id和激活码
//@PathVariable("userID"):从路径中取值
@RequestMapping(path = "/activation/{userID}/{code}", method = RequestMethod.GET)
public String activation(Model model, @PathVariable("userID")int userId, @PathVariable("code")String code){
//成功的话直接跳转至login页面,否则直接跳转回首页
int result = userService.activation(userId, code);
if(result == ACTIVATION_SUCCESS){
model.addAttribute("msg","激活成功,你的账号已经可以正常使用了!");
model.addAttribute("target","/login");//激活成果就跳到登录的页面!
}
//激活失败:返回首页!
else if(result == ACTIVATION_REPEAT){
model.addAttribute("msg","无效操作,该账号已经激活过了!");
model.addAttribute("target","/index");
}
else{
model.addAttribute("msg","激活失败,您提供的激活码不正确!");
model.addAttribute("target","/index");
}
return "/site/operate-result";
}
}
2.3、会话管理
HTTP的基本性质:
- -HTTP是简单的
- -HTTP是可扩展的
- -HTTP是无状态的、有会话的
- HTTP是无状态的:在同一个连接中,两个执行成功的请求之间是没有关系的。
Cookie:
- -是服务器发送到浏览器,并保存在浏览器端的一小块数据
- -浏览器下次访问该服务器时,会自动给携带该数据,将其发送给服务器
- -通常,用于告知服务端两个请求是否来自同一浏览器,如保持用户的登录状态。
Session:
- -是
JavaEE
的标准,用于在服务端纪录客户端信息。 - -数据存放在服务端更加安全,但是也会增加服务端的内存压力。
**会话:**用户和服务器之间的对话是连贯的,而非割裂的。由服务器创建发送到用户浏览器并保存在本地的一小块数据。它会在浏览器下次向同一服务器请求时发送这块数据给服务器。
(1)Cookie的使用案例
- **HTTP是无状态的:**在同一个连接中,两个执行成功的请求之间是没有关系的。
- 这就带来了一个问题,用户没有办法在同一个网站中进行连续的交互,比如在一个电商网站里,用户把某个商品加入到购物车,切换一个页面后再次添加了商品,这两次添加商品的请求之间没有关联,浏览器无法知道用户最终选择了哪些商品。
- 而使用HTTP的头部扩展,HTTP Cookie就可以解决这个问题。把Cookies添加到头部中,创建一个会话让每次请求都能共享相同的上下文信息,达成相同的状态。
- 有些网站需要有持续性的交互,HTTP本质是无状态的,使用Cookie可以创建有状态的会话。
- 需要把创建的cookie存放至response中,这样才能返回给客户端。
- 由服务器创建发送到用户浏览器并保存在本地的一小块数据。它会在浏览器下次向同一服务器请求时发送这块数据给服务器。
- **cookie只能存一个key-value,只能存很少的数据。**而且cookie是存储在response的头部中,不是在数据中。
如果不设置生效时间,cookie存储在内存中,等浏览器一关闭,就消失了, 但如果设置了生效时间,就会存储在硬盘中。 - 创建cookie时可以指定key的值(“code”),一个cookie只存一个key value.
代码实例:
@RequestMapping(path = "/cookie/set", method = RequestMethod.GET)
@ResponseBody
public String setCookie(HttpServletResponse response){
// 创建Cookie,key-value
Cookie cookie = new Cookie("code", CommunityUtil.generateUUID());
//设置cookie的生效范围 只针对某个功能下的访问生效,只有在这个路径下才有效
cookie.setPath("/community/alpha");
//设置cookie的生效时间 默认情况下浏览器一关闭cookie就失效,但一旦设置了cookie的生存时间,再次打开浏览器会依旧有效。
cookie.setMaxAge(60 * 10);//设置10分钟
response.addCookie(cookie); //需要把创建好的cookie存放到response之中
return "set cookie";
}
请求相应的url:localhost:8080/community/alpha/cookie/set
//服务器获取cookie
@RequestMapping(path = "/cookie/get", method = RequestMethod.GET)
@ReusponseBody
public String getCookie(@CookieValue("code") String code){
System.out.println(code);
return "get cooke";
}
如何获取浏览器中指定的cookie值?
- 可以从request对象中获得Cookie的数组(包含了所有的cookie)
- @CookieValue表示需要从cookie中获取code的值
(2)Session的使用案例
- cookie的缺点:不安全,因为cookie是存在客户端的;每次请求都要携带cookie数据,会造成性能低的后果。
- 利用一种思路:session:在服务端存放用户数据:session使用依赖cookie
- session不是http的标准,而是JavaEE的标准,用于在服务端记录客户端信息。
- 浏览器第一次访问服务器:服务器先创建一个session对象(每个浏览器访问服务器都会创建一个session)
- 服务器自动创建一个存放sessionId的cookie, 用来维护浏览器和服务器之间的对应关系。
- 浏览器保存cookie,下次访问服务器,携带上cookie。
- 服务器再从cookie中根据sessionId找到响应的session,再找到对应的session里存放的数据。
- 和手动创建cookie不同的是,HttpSession SpringMVC会自动给我们创建session创建并注入进来。
- session的使用和request和response的使用一样。
//在服务器端创建session
@RequestMapping(path = "/session/set", method = RequestMethod.GET)
@ResponseBody
public String setSession(HttpSession session){
session.setAttribute("id",1);
session.setAttribute("name","Test");
return "set session";
}
//服务器获取浏览器请求中的session
@RequestMapping(path = "/session/get", method = RequestMethod.GET)
@ResponseBody
public String getSession(HttpSession session){
System.out.println(session.getAttribute("id"));
System.out.println(session.getAttribute("name"));
return "get session";
}
浏览器发起在服务器端创建session的请求:
浏览器发起让服务器获取session的请求:
sesson存储在服务端,大小和类型不受限制,但cookie需要来回传,大小受限制,而且只能保存字符串类型。
一般情况下,如果数据不是特别隐私,还是使用cookie会比较好。
(3)共享Session
session和cookie都是为了解决会话的技术,各有各自的优缺点,使用应该视具体情况而定,能用cookie尽量用cookie,减少服务端的压力。但是使用session会存在一个session共享(分布式部署的时候)的问题。
解决方案:
- 黏性session,不完美,将请求和IP固定起来了,每个IP发送过来的请求都由固定个服务器来进行处理,会造成负载不均衡。
- 同步session,所有的服务器中的session数据都是相同的,每个服务器都存储了所有的session,服务器的压力会比较大,另外一个,服务器和服务器之间会产生关联,不再像以前那么独立了,产生一定的耦合,对部署有一定的影响, 非理想!
- 共享session: 所有的session都由一台服务器进行专门进行处理,当其他服务器需要利用session时,直接从此服务器中获取。缺点:容易出现单点故障问题。分布式部署的目的:解决性能瓶颈
- 主流的解决方案:
- 数据能存放在cookie就存放在cookie里,而不存放至session中去。
- 将session存放至**数据库中,所有服务器都可以访问数据库集群**来得到客户端的会话的数据。
- 缺点:从数据库硬盘中获取数据显然和从内存中获取数据的性能差别甚大,在并发度大的时候也容易成为一个瓶颈,但现在非关系型数据库(内存) redis能弥补以上问题。

2.4、生成验证码(存于session)
(1)导入Kaptcha的jar包
(2)编写Kaptcha配置类
- 加入至spring的bean容器管理之中。
- 核心是一个Producer接口,创建一个Properties ,用Config装配,然后再传递给DefaultKaptcha创建一个实例,然后再调用这个实例的方法来生成图片。
config.KaptchaConfig.java:
import com.google.code.kaptcha.Producer;
import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.google.code.kaptcha.util.Config;
@Configuration
public class KaptchaConfig {
@Bean
public Producer kaptchaProducer() {//Producer接口
Properties properties = new Properties();
properties.setProperty("kaptcha.image.width", "100");
properties.setProperty("kaptcha.image.height", "40");
properties.setProperty("kaptcha.textproducer.font.size", "32");
properties.setProperty("kaptcha.textproducer.font.color", "0,0,0");
properties.setProperty("kaptcha.textproducer.char.string", "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYAZ");
properties.setProperty("kaptcha.textproducer.char.length", "4");
properties.setProperty("kaptcha.noise.impl", "com.google.code.kaptcha.impl.NoNoise");
DefaultKaptcha kaptcha = new DefaultKaptcha();
Config config = new Config(properties);//config依赖于properties
kaptcha.setConfig(config);
return kaptcha;
}
}
(3)生成随机字符、生成图片(Contoller层)
- 方法返回值为void,因为需要利用response对象向浏览器输出数据。
- 服务端需要记住生成的验证码,因为客户端输入验证码后,服务器需要验证是否正确(是敏感数据,不能存在浏览器中,而存倒服务器端,而且是需要跨请求的,选择**session**).
- 注入生成验证码的配置类
LoginController.java:
private static final Logger logger = LoggerFactory.getLogger(LoginController.class);
@Autowired
private Producer kaptchaProducer;
@RequestMapping(path = "/kaptcha", method = RequestMethod.GET)
public void getKaptcha(HttpServletResponse response, HttpSession session) {
// 生成验证码
String text = kaptchaProducer.createText();
BufferedImage image = kaptchaProducer.createImage(text);
// 将验证码存入session
session.setAttribute("kaptcha", text);
// 将图片输出给浏览器
response.setContentType("image/png");//申明浏览器返回的是什么格式的数据
try {
OutputStream os = response.getOutputStream();//OS输出流
ImageIO.write(image, "png", os);//将image以.png的格式向OS输出流输出
} catch (IOException e) {
logger.error("响应验证码失败:" + e.getMessage());
}
}
login.html:
在前台中通过js来实现每次点击都刷新验证码的功能。
<script>
function refresh_kaptcha() {
var path = CONTEXT_PATH + "/kaptcha?p=" + Math.random();
$("#kaptcha").attr("src", path);
}
</script>
2.5、开发登录和退出功能
点击登录后,转到登录页面,服务器在这里就存了验证码session,用户填完登录信息提交后,
建立登录凭证的数据库:
(1)dao数据访问层
-
Entity:LoginTicket.java
-
dao: LoginTicketMapper.java,通过注解代替Mapper文件
@Mapper public interface LoginTicketMapper { @Insert({//主键不用插,自动生成 "insert into login_ticket(user_id,ticket,status,expired) ", "values(#{userId},#{ticket},#{status},#{expired})" }) @Options(useGeneratedKeys = true, keyProperty = "id")//自动生成主键 int insertLoginTicket(LoginTicket loginTicket); @Select({ "select id,user_id,ticket,status,expired ", "from login_ticket where ticket=#{ticket}" }) LoginTicket selectByTicket(String ticket);//ticket是唯一标识符 @Update({//演示注解的动态sql "<script>", "update login_ticket set status=#{status} where ticket=#{ticket} ", "<if test=\"ticket!=null\"> ", "and 1=1 ", "</if>", "</script>" }) int updateStatus(String ticket, int status); }
-
测试:MapperTest.java
(2)service层
UserService.java:
@Autowired
private LoginTicketMapper loginTicketMapper;
//map:多种情况的返回结果;password需要加密;expiredSeconds过期时限
public Map<String, Object> login(String username, String password, int expiredSeconds) {
Map<String, Object> map = new HashMap<>();
// 空值处理
if (StringUtils.isBlank(username)) {
map.put("usernameMsg", "账号不能为空!");
return map;
}
if (StringUtils.isBlank(password)) {
map.put("passwordMsg", "密码不能为空!");
return map;
}
// 验证账号:根据用户名来查询用户对象user
User user = userMapper.selectByName(username);
if (user == null) {
map.put("usernameMsg", "该账号不存在!");
return map;
}
// 验证状态
if (user.getStatus() == 0) {
map.put("usernameMsg", "该账号未激活!");
return map;
}
// 验证密码
password = CommunityUtil.md5(password + user.getSalt());
if (!user.getPassword().equals(password)) {
map.put("passwordMsg", "密码不正确!");
return map;
}
// 生成登录凭证
LoginTicket loginTicket = new LoginTicket();
loginTicket.setUserId(user.getId());
//凭证是一个随机生成的字符串,所以每次登录同一个用户的登录凭证都是不一样的
loginTicket.setTicket(CommunityUtil.generateUUID());
loginTicket.setStatus(0);//0-有效状态
loginTicket.setExpired(new Date(System.currentTimeMillis() + expiredSeconds * 1000));
loginTicketMapper.insertLoginTicket(loginTicket);
map.put("ticket", loginTicket.getTicket());//ticket放入map中作为返回值
return map;
}
退出:
public void logout(String ticket) {
loginTicketMapper.updateStatus(ticket, 1);
}
(3)Controller层
CommunityConstant.java:
/**
* 默认状态的登录凭证的超时时间
*/
int DEFAULT_EXPIRED_SECONDS = 3600 * 12;
/**
* 记住状态的登录凭证超时时间
*/
int REMEMBER_EXPIRED_SECONDS = 3600 * 24 * 100;
LoginController.java:
response中的cookie:若登录成功,服务端需将凭证放在cookie中发送给客户端
@Value("${server.servlet.context-path}")
private String contextPath;
//和点进等录按钮的请求路径一致,不要紧,因为请求方式不同,这里是post,提交数据
//code:客户端提交的验证码;session中保存了服务器向客户端发送的验证码;
//response中的cookie:若登录成功,服务端需将凭证放在cookie中发送给客户端
@RequestMapping(path = "/login", method = RequestMethod.POST)
public String login(String username, String password, String code, boolean rememberme, Model model, HttpSession session, HttpServletResponse response) {
// 检查验证码,在2.4节中服务器给客户端的响应中存了session(验证码)
String kaptcha = (String) session.getAttribute("kaptcha");
if (StringUtils.isBlank(kaptcha) || StringUtils.isBlank(code) || !kaptcha.equalsIgnoreCase(code)) {
model.addAttribute("codeMsg", "验证码不正确!");
return "/site/login";
}
// 检查账号,密码
int expiredSeconds = rememberme ?
REMEMBER_EXPIRED_SECONDS : DEFAULT_EXPIRED_SECONDS;
Map<String, Object> map = userService.login(username, password, expiredSeconds);
if (map.containsKey("ticket")) {
//cookie中放凭证
Cookie cookie = new Cookie("ticket", map.get("ticket").toString());
cookie.setPath(contextPath);//整个项目的路径都有效
cookie.setMaxAge(expiredSeconds);//cookie的有效时间
response.addCookie(cookie);
return "redirect:/index";
} else {
model.addAttribute("usernameMsg", map.get("usernameMsg"));
model.addAttribute("passwordMsg", map.get("passwordMsg"));
return "/site/login";
}
}
login.html
退出登录:通过ticket退出登录
@RequestMapping(path = "/logout", method = RequestMethod.GET)
public String logout(@CookieValue("ticket") String ticket) {
userService.logout(ticket);
return "redirect:/login";
}
配置退出登录的链接index.html
2.6、显示登录信息
根据登录与否显示相应的内容
(1)拦截器实例
AlphaInterceptor.java :
@Component
public class AlphaInterceptor implements HandlerInterceptor {
private static final Logger logger = LoggerFactory.getLogger(AlphaInterceptor.class);
//在Controller处理请求之前执行,如果返回为false的话,请求就不会被处理
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
logger.debug("preHandle:" + handler.toString());
return true;
}
//在controller之后执行,主要的请求已经处理完了,下一步就是要去调用模板引擎了,所以有ModelAndView
//在模板引擎执行之前执行
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
logger.debug("postHandle:" + handler.toString());
}
//在模板引擎执行之后执行
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
logger.debug("afterCompletion:" + handler.toString());
}
}
为了使得拦截器生效,还需要写一个配置类:WebMvcConfig.java
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private AlphaInterceptor alphaInterceptor;
@Autowired
private LoginTicketInterceptor loginTicketInterceptor;
/*@Autowired
private LoginRequiredInterceptor loginRequiredInterceptor;*/
@Autowired
private DataInterceptor dataInterceptor;
@Autowired
private MessageInterceptor messageInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
//排除所有目录下的所有文件夹,将拦截器注册进去
//排除掉静态资源不进行处理
registry.addInterceptor(alphaInterceptor)
.excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.jpg", "/**/*.jpeg", "/**/*.png").addPathPatterns("/register", "/login");//表示只拦截register下的内容
registry.addInterceptor(loginTicketInterceptor)
.excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.jpg", "/**/*.jpeg", "/**/*.png");
//registry.addInterceptor(loginRequiredInterceptor)
// .excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.jpg", "/**/*.jpeg", "/**/*.png");
registry.addInterceptor(messageInterceptor)
.excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.jpg", "/**/*.jpeg", "/**/*.png");
registry.addInterceptor(dataInterceptor)
.excludePathPatterns("/**/*.css", "/**/*.jpg", "/**/*.jpeg", "/**/*.png");
}
}
需要根据是否登录来调整头部显示的信息。可以利用拦截器来降低模块之间的耦合度。服务器得到了凭证就能得到用户对象。
handler为拦截的目标。
(2)拦截器应用-显示用户登录信息
- 类似这种显示用户信息的在多个页面上都要显示,如果每打开一个页面都重新发送一次请求获取数据的话,耦合度比较高.
- 可以利用统一集中的低耦合度方式实现(拦截器,可以拦截请求,在请求开始和结束的位置插入一些代码,从而批量解决多个请求共有的业务,以低耦度的方式解决通用的问题)

- 浏览器携带cookie访问服务器,服务器获得cookie中的ticket凭证,从数据中查询关联到用户(userid),进而查询到User对象
- 在接下来的每次调用模板引擎之前将用户信息装入Model中。
- 关键在于(用户登陆的时候,存入了ticket,再下次访问的时候可获取到ticket值)。这套逻辑应被复用,利用拦截器来实现。
只在浏览器中存一个ticket,而不存储用户的数据,这避免了敏感数据所造成的不安全问题。这套逻辑不应该实现多次,因为每次请求都需要在页面上显示用户的数据,可以让拦截器来实现。
1)创建LoginInterceptor(涉及多线程!)
1、preHandle方法之中,保存用户数据
- 封装从request中获取cookie的工具类CookieUtil。
- 在请求开始之前就需要保存数据,因为在之后的可能随时随地都需要使用。
CookieUtil.java 从requeset中获取cookie:
public class CookieUtil {
//name:我们想得到的cookie的名字
public static String getValue(HttpServletRequest request, String name){
if(request == null || name == null){
throw new IllegalArgumentException("参数为空");
}
Cookie[] cookies = request.getCookies();
if(cookies != null){
for(Cookie cookie:cookies){
if(cookie.getName().equals(name)){
return cookie.getValue();
}
}
}
return null;
}
}
UserService.java中添加查询ticket方法:
public LoginTicket findLoginTicket(String ticket) {
return loginTicketMapper.selectByTicket(ticket);
}
HostHolder.java
- 服务器在处理请求的时候会创建一个线程来处理用户的请求,请求响应了数据,有了返回值之后,线程才会被销毁。
- 在**多并发的情况下,需要考虑线程的隔离,利用ThreadLocal**工具(以线程为key存取值)来实现。
- ThreadLocal为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本
- 创建一个HostHolder工具类,代替session对象
/**
* 持有用户信息,用于代替session对象.
*/
@Component
public class HostHolder {
private ThreadLocal<User> users = new ThreadLocal<>();
//设置当前线程的user
public void setUser(User user) {
users.set(user);
}
//得到当前线程的user
public User getUser() {
return users.get();
}
//清理当前线程的user
public void clear() {
users.remove();
}
}
LoginTicketInterceptor.java中preHandle方法:
@Component
public class LoginTicketInterceptor implements HandlerInterceptor {
@Autowired
private UserService userService;
@Autowired
private HostHolder hostHolder;
//一开始就拦截请求获得里面的ticket再去查询有没有这个用户
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 从cookie中获取凭证
String ticket = CookieUtil.getValue(request, "ticket");
if (ticket != null) {
// 查询凭证,得到loginTicket对象
LoginTicket loginTicket = userService.findLoginTicket(ticket);
// 检查凭证是否有效
if (loginTicket != null && loginTicket.getStatus() == 0 && loginTicket.getExpired().after(new Date())) {
// 根据凭证查询用户
User user = userService.findUserById(loginTicket.getUserId());
//在本次请求中持有用户
//考虑多线程的情况,要隔离的存当前用户信息
//需要把User进行暂存,表示在本次请求中持有用户,把对象存储在当前线程ThreadLocal之中
hostHolder.setUser(user);
}
}
return true;
}
}
2、 在postHandle方法之中使用保存的User对象信息
@Override //在模板引擎调用之前把数据存储在hostHolder之中的数据放入Model之中
public void postHandle(HttpServletRequest request, HttpServletResponse response,
Object handler, ModelAndView modelAndView) throws Exception {
User user = hostHolder.getUser();
if(user != null && modelAndView != null){
modelAndView.addObject("loginUser",user);
}
}
3、 在afterCompletion(),模板引擎渲染完之后清除掉用户的数据
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,Object handler, Exception ex) throws Exception {
//在模板执行完之后将数据清理掉
hostHolder.clear();
}
2) 配置LoginInterceptor
- 在WebMvcConfig类之中
@Autowired
private LoginTicketInterceptor loginTicketInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginTicketInterceptor)
.excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");//所有路径的请求都要此拦截!!!
}
3) 页面显示用户信息
- 处理情况:未登录不显示用户的信息,登录才显示(根据Model里面是否有loginUser来判断)
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav mr-auto">
<li class="nav-item ml-3 btn-group-vertical">
<a class="nav-link" th:href="@{/index}">首页</a>
</li>
<li class="nav-item ml-3 btn-group-vertical" th:if="${loginUser!=null}">
<a class="nav-link position-relative" th:href="@{/letter/list}">消息
<span class="badge badge-danger" th:text="${allUnreadCount!=0?allUnreadCount:''}">12</span>
</a>
</li>
<li class="nav-item ml-3 btn-group-vertical" th:if="${loginUser==null}">
<a class="nav-link" th:href="@{/register}">注册</a>
</li>
<li class="nav-item ml-3 btn-group-vertical" th:if="${loginUser==null}">
<a class="nav-link" th:href="@{/login}">登录</a>
</li>
<li class="nav-item ml-3 btn-group-vertical dropdown" th:if="${loginUser!=null}">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<img th:src="${loginUser.headerUrl}" class="rounded-circle" style="width:30px;"/>
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item text-center"
th:href="@{|/user/profile/${loginUser.id}|}">个人主页</a>
<a class="dropdown-item text-center" th:href="@{/user/setting}">账号设置</a>
<a class="dropdown-item text-center" th:href="@{/logout}">退出登录</a>
<div class="dropdown-divider"></div>
<span class="dropdown-item text-center text-secondary"
th:utext="${loginUser.username}">nowcoder</span>
</div>
</li>
</ul>
<!-- 搜索 -->
<form class="form-inline my-2 my-lg-0" th:action="@{/search}" method="get">
<input class="form-control mr-sm-2" type="search" aria-label="Search" name="keyword" th:value="${keyword}"/>
<button class="btn btn-outline-light my-2 my-sm-0" type="submit">搜索</button>
</form>
</div>
2.7、账号设置
上传文件-这里不需要数据访问层的代码,上传文件的话只需要在表现层实现就可以
- 请求:必须是POST请求
- 表单:enctype=”multipart/form-data”
- Spring MVC:通过MultipartFile处理上传文件 MultipartFile:表现层的对象,不应该放到业务层来处理,这会带来较高的耦合度。业务层值只负责更新路径即可
配置文件:上传的头像图片的保存路径
community.path.upload=d:/project/upload
(1)service层
UserService.java
public int updateHeader(int userId, String headerUrl) {
return userMapper.updateHeader(userId, headerUrl);
}
(2)Controller层
UserController:
@Controller
@RequestMapping("/user")
public class UserController {
private static final Logger logger = LoggerFactory.getLogger(UserController.class);
@Value("${community.path.upload}")//上传路径
private String uploadPath;
@Value("${community.path.domain}")
private String domain;
@Value("${server.servlet.context-path}")//项目访问路径
private String contextPath;
@Autowired
private UserService userService;
@Autowired
private HostHolder hostHolder;//从hostHolder里取当前用户
@LoginRequired
@RequestMapping(path = "/setting", method = RequestMethod.GET)
public String getSettingPage() {
return "/site/setting";
}
@LoginRequired
@RequestMapping(path = "/upload", method = RequestMethod.POST)
public String uploadHeader(MultipartFile headerImage, Model model) {
if (headerImage == null) {
model.addAttribute("error", "您还没有选择图片!");
return "/site/setting";
}
String fileName = headerImage.getOriginalFilename();
String suffix = fileName.substring(fileName.lastIndexOf("."));//读取文件的后缀
if (StringUtils.isBlank(suffix)) {
model.addAttribute("error", "文件的格式不正确!");
return "/site/setting";
}
// 生成随机文件名
fileName = CommunityUtil.generateUUID() + suffix;
// 确定文件存放的路径
File dest = new File(uploadPath + "/" + fileName);
try {
// 存储文件
headerImage.transferTo(dest);
} catch (IOException e) {
logger.error("上传文件失败: " + e.getMessage());
throw new RuntimeException("上传文件失败,服务器发生异常!", e);
}
// 更新当前用户的头像的路径(web访问路径)
// http://localhost:8080/community/user/header/xxx.png
User user = hostHolder.getUser();
String headerUrl = domain + contextPath + "/user/header/" + fileName;
userService.updateHeader(user.getId(), headerUrl);
return "redirect:/index";
}
//获取头像
@RequestMapping(path = "/header/{fileName}", method = RequestMethod.GET)
public void getHeader(@PathVariable("fileName") String fileName, HttpServletResponse response) {
// 服务器存放路径
fileName = uploadPath + "/" + fileName;
// 文件后缀
String suffix = fileName.substring(fileName.lastIndexOf("."));
// 响应图片
response.setContentType("image/" + suffix);
try (
FileInputStream fis = new FileInputStream(fileName);//输入流
OutputStream os = response.getOutputStream();
) {
byte[] buffer = new byte[1024];
int b = 0;
while ((b = fis.read(buffer)) != -1) {
os.write(buffer, 0, b);
}
} catch (IOException e) {
logger.error("读取头像失败: " + e.getMessage());
}
}
}
hostHolder到底什么时候清除数据???整个网页都关掉的时候?所有路径的请求都要被拦截!!!
2.8、检查登录状态(自定义注解)
自定义注解:LoginRequired.java
package com.nowcoder.community.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginRequired {
}
给UserService.java中需要被拦截的方法加上自定义注释:
LoginRequiredInterceptor.java:
@Component
public class LoginRequiredInterceptor implements HandlerInterceptor {
@Autowired
private HostHolder hostHolder;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//先判断拦截的是不是方法
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;//转型
Method method = handlerMethod.getMethod();//反射:获取拦截到的对象
//获取方法的注解
LoginRequired loginRequired = method.getAnnotation(LoginRequired.class);
//如果不等于空,说明这个方法是需要拦截的,如果没登录就要被拦截
if (loginRequired != null && hostHolder.getUser() == null) {
//重定向
response.sendRedirect(request.getContextPath() + "/login");
return false;
}
}
return true;
}
}
配置拦截器:WebMvcConfigurer