仿牛客网社区开发——第3章 Spring Boot进阶,开发社区核心功能
过滤敏感词#
前缀树简介#
前缀树又称单词查找树,Trie 树,是一种树形结构,是一种哈希树的变种。典型应用是用于统计,排序和保存大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。它的优点是:利用字符串的公共前缀来减少查询时间,最大限度地减少无谓的字符串比较,查询效率比哈希树高。
它有3个基本性质:
1、根节点不包含字符,除根节点外每一个节点都只包含一个字符;
2、从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串;
3、每个节点的所有子节点包含的字符都不相同。
导入敏感词#
可以放到数据库,也可以放到 txt 文件里
定义前缀树#
使用内部类
- isWordEnd():看是不是最后一个结点
- Map<Character, TrieNode> subNodes:定义子节点。key 是下级字符,value 是下级结点
- addSubNode(char, TrieNode):添加子结点
- getSubNode(char):通过字符获取子结点
// 前缀树
private class TrieNode {
// 关键词结束标识
private boolean isWordEnd = false;
// 子节点(key是下级字符,value是下级节点)
private Map<Character, TrieNode> subNodes = new HashMap<>();
public boolean isWordEnd() {
return isWordEnd;
}
public void setWordEnd(boolean wordEnd) {
isWordEnd = wordEnd;
}
// 添加子节点
public void addSubNode(char c, TrieNode trieNode) {
subNodes.put(c, trieNode);
}
// 获取子节点
public TrieNode getSubNode(char c) {
return subNodes.get(c);
}
}
根据敏感词初始化前缀树#
@PostConstruct 注解被用来修饰一个非静态的 void() 方法。被 @PostConstruct 修饰的方法会在服务器加载 Servlet 的时候运行,并且只会被服务器执行一次。在构造函数之后自动执行。
Constructor(构造方法) -> @Autowired(依赖注入) -> @PostConstruct(注释的方法)
- 获取编译后的敏感词文件(通过类加载器调用 getResourceAsStream 方法获取,因为文件编译之后直接在 classes目录下,所以参数直接写文件名即可)
测试了一下,最前面不能加 / 。如果加到子目录下则是如下的写法:
- 把字节流转化为字符流再转化为缓冲流
- 一行一行读取 ,把读取到的敏感词添加到前缀树中
// 根节点
private TrieNode rootNode = new TrieNode();
@PostConstruct
private void init() {
try (
InputStream is = this.getClass().getClassLoader().getResourceAsStream("sensitive-words.txt");
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
) {
String keyword;
while ((keyword = reader.readLine()) != null) {
// 添加到前缀树
addKeyWord(keyword);
}
} catch (IOException e) {
logger.error("加载敏感词文件失败:" + e.getMessage());
}
}
将一个敏感词添加到前缀树#
- 创建临时结点先指向根节点
- 以字符为单位,每次读取一个字符,先调用 getSubNode 看是否有相同字符的子节点
- 如果不存在相同字符的子节点则需要添加该字符的子节点
- 把指针指向当前子节点进行下一次循环
- 设置结束标识:如果当前结点为最后一个结点,则把其标识设置为 true
// 将一个敏感词添加到前缀树中
private void addKeyWord(String keyword) {
TrieNode tmpNode = rootNode;
char c;
for (int i = 0; i < keyword.length(); i++) {
c = keyword.charAt(i);
TrieNode subNode = tmpNode.getSubNode(c);
if (subNode == null) {
// 初始化子节点
subNode = new TrieNode();
tmpNode.addSubNode(c, subNode);
}
// 指向子节点,进入下一轮循环
tmpNode = subNode;
}
// 设置结束标识
tmpNode.setWordEnd(true);
}
编写过滤敏感词的方法#
- 定义三个指针。指针 1 遍历前缀树,指针 2 和 3 遍历文本
- StringBuilder 存储过滤结果
- 以指针 2 是否到结尾为循环条件,以指针 3 为条件可能会遗漏(老师课程中的算法不正确)
- 如果字符是符号(不包含东亚文字)并且此时指针 1 是根节点,则追加符号,begin++,end++,继续下次循环
- 如果遇到敏感词中间的符号则需要跳过,end++。例如:※赌※博※;过滤之后显示为:※***※
- 获取根结点的子节点判断。如果为空说明以该字符开头的词不是敏感词,把该字符加入到结果中,end = ++begin;不为空说明可能是敏感词,end++,继续判断
- 如果 isWordEnd() 判断为 true,说明以该字符开头的词是敏感词,将替换文本加到结果中,begin = ++end
- 如果 end 越界,说明以该字符开头的词不是敏感词,把该字符加入到结果中,end = ++begin
/**
* 过滤敏感词
*
* @param text 待过滤的文本
* @return 过滤后的文本
*/
public String filter(String text) {
if (StringUtils.isBlank(text))
return null;
// 结果
StringBuilder sb = new StringBuilder();
// 指针1
TrieNode tmpNode = rootNode;
// 指针2、3
int begin = 0, end = 0;
char c;
while (begin < text.length()) {
// end遍历越界,begin~end-1不是敏感词
if (end >= text.length()) {
// 以begin开头的字符串不是敏感词
sb.append(text.charAt(begin));
// 进入下一个位置
end = ++begin;
// 重新指向根节点
tmpNode = rootNode;
continue;
}
c = text.charAt(end);
// 跳过符号
if (isSymbol(c)) {
// 若指针1处于根节点,将此符号计入结果,让指针2向下走一步
if (tmpNode == rootNode) {
sb.append(c);
begin++;
}
// 无论符号在开头还是中间,指针3都向下走一步
end++;
continue;
}
// 检查下级节点
tmpNode = tmpNode.getSubNode(c);
if (tmpNode == null) {
// 以begin开头的字符串不是敏感词
sb.append(text.charAt(begin));
// 进入下一个位置
end = ++begin;
// 重新指向根节点
tmpNode = rootNode;
continue;
}
if (tmpNode.isWordEnd()) {
// 发现敏感词,将begin~end字符串替换掉
sb.append(REPLACEMENT);
// 进入下一个位置
begin = ++end;
// 重新指向根节点
tmpNode = rootNode;
} else {
// 检查下一个字符
end++;
}
}
return sb.toString();
}
// 判断是否为符号
private boolean isSymbol(char c) {
// 0x2E80~0x9FFF 是东亚文字范围
return !CharUtils.isAsciiAlphanumeric(c) && (c < 0x2E80 || c > 0x9FFF);
}
测试#
//@RunWith(SpringRunner.class)
@SpringBootTest
@ContextConfiguration(classes = CommunityApplication.class)
public class SensitiveFilterTest {
@Autowired
private SensitiveFilter sensitiveFilter;
@Test
public void test1() {
String word = "这里可以赌博,可以吸毒,可以嫖娼和开票。快来啊!";
System.out.println(sensitiveFilter.filter(word));
word = "这里可以※赌※博※,可以※吸※毒※,可以嫖※娼和※开※票※。快来啊!傻逼傻逼!!fab";
System.out.println(sensitiveFilter.filter(word));
}
}
测试结果:
发布帖子#
引入 fastjson 包#
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.80</version>
</dependency>
编写获取 JSON 字符串的方法#
- code 编号;msg 提示信息;map 业务数据
- 首先创建 JSON 对象,然后把 code,msg 传入,map 遍历传入
- 返回得到 JSON 字符串
- 重载两个方法
public static String getJSONString(int code, String msg, Map<String, Object> map) {
JSONObject jsonObject = new JSONObject();
jsonObject.put("code", code);
jsonObject.put("msg", msg);
if(map != null) {
for(String key : map.keySet()) {
jsonObject.put(key, map.get(key));
}
}
return jsonObject.toJSONString();
}
public static String getJSONString(int code, String msg) {
return getJSONString(code, msg, null);
}
public static String getJSONString(int code) {
return getJSONString(code, null, null);
}
增加插入帖子的方法#
编写 DiscussPostMapper#
DiscussPost selectDiscussPostById(int id);
<insert id="insertDiscussPost">
INSERT INTO discuss_post(<include refid="insertFields"/>)
VALUES (#{userId}, #{title}, #{content}, #{type}, #{status}, #{createTime}, #{commentCount}, #{score})
</insert>
编写 DiscussPostService#
- 转义 HTML 标记(如:< >)
- 过滤敏感词
public int addDiscussPost(DiscussPost post) {
if(post == null) {
throw new IllegalArgumentException("参数不能为空!");
}
// 转义HTML标记
post.setTitle(HtmlUtils.htmlEscape(post.getTitle()));
post.setContent(HtmlUtils.htmlEscape(post.getContent()));
// 过滤敏感词
post.setTitle(sensitiveFilter.filter(post.getTitle()));
post.setContent(sensitiveFilter.filter(post.getContent()));
return discussPostMapper.insertDiscussPost(post);
}
编写 DiscussPostController#
- 检查是否登录
- 报错的情况将来统一处理
@PostMapping("/add")
@ResponseBody
public String addDiscussPost(String title, String content) {
User user = hostHolder.getUser();
if(user == null) {
CommunityUtil.getJSONString(403, "你还没有登录哦!");
}
DiscussPost post = new DiscussPost();
post.setUserId(user.getId());
post.setTitle(title);
post.setContent(content);
post.setCreateTime(new Date());
discussPostService.addDiscussPost(post);
// 报错的情况,将来统一处理
return CommunityUtil.getJSONString(0, "发布成功!");
}
处理 js#
- 点击发布的时候调用方法 publish()
- 通过 id 获取标题 title 和内容 content
- 采用 jquery 发送异步请求
- $.post(三个参数:1.访问路径 2.提交数据 3.回调函数(服务器做出响应 把返回的数据传给 data))
- data 保存回调函数处理服务器响应完毕后返回的结果
- 把字符串转为 js 对象
- 在提示框中显示返回信息 data.msg
- 2 秒后自动隐藏提示框
- 判断 data.code 是否等于 0,成功(0)则刷新页面
$(function(){
$("#publishBtn").click(publish);
});
function publish() {
$("#publishModal").modal("hide");
// 获取标题和内容
var title = $("#recipient-name").val();
var content = $("#message-text").val();
// 发送异步请求(POST)
$.post(
CONTEXT_PATH + "/discuss/add",
{"title":title,"content":content},
function (data) {
data = $.parseJSON(data);
// 在提示框中显示返回信息
$("#hintBody").text(data.msg);
// 显示提示框
$("#hintModal").modal("show");
// 2秒后,自动隐藏提示框
setTimeout(function(){
$("#hintModal").modal("hide");
// 刷新页面
if(data.code == 0) {
window.location.reload();
}
}, 2000);
}
);
}
帖子详情#
增加通过 id 查询帖子的方法#
编写 DiscussPostMapper#
<select id="selectDiscussPostById" resultType="com.zwc.community.entity.DiscussPost">
SELECT <include refid="selectFields"/>
FROM discuss_post
WHERE id=#{id}
</select>
编写 DiscussPostService#
public DiscussPost findDiscussPostById(int id) {
return discussPostMapper.selectDiscussPostById(id);
}
编写 DiscussPostController#
- 查询完帖子后,还要根据帖子的 userId 查询用户信息(这里不采用在 Mapper 中写关联查询的方法,虽然其效率高,但是可能会产生耦合,并且如果只查询帖子的话还会冗余的带上用户信息。采用在 Controller 层再调用一次查询用户的方法。虽然访问 2 次数据库效率可能会低,但后面用上 Redis 缓存后效率会很高)
@GetMapping("/detail/{discussPostId}")
public String findDiscussPostById (@PathVariable("discussPostId") int discussPostId, Model model) {
// 帖子
DiscussPost post = discussPostService.findDiscussPostById(discussPostId);
model.addAttribute("post", post);
// 作者
User user = userService.findUserById(post.getUserId());
model.addAttribute("user", user);
return "/site/discuss-detail";
}
目前该方法尚未完善,后续还会添加查询帖子回复的相关功能。
在帖子标题上增加访问详情页面的链接#
<a th:href="@{|/discuss/detail/${map.post.id}|}" th:utext="${map.post.title}">备战春招,面试刷题跟他复习,一个月全搞定!</a>
@{} 自动带上项目名,将来更改了项目名后这里也不需要再改动
注意,@{} 路径中如果包含变量,则要用 | | 将整个路径包含起来。经过测试,不加的话路径就变成了 /discuss/detail/$%7Bmap.post.id%7D。
此外,用 utext 将转义字符转换回原来的符号。text 不会解析 html,utext 会解析 html。
处理 discuss-detail 页面#
老一套,声明 thymeleaf,更改相对路径,替换所有需要显示动态值的地方。注意日期用 #dates.format(post.createTime, 'yyyy-MM-dd HH:mm:ss'),转换成右边的日期格式。(这里就不贴代码了)
注意点:#
- @{} 路径中包含变量的时候用 | | 包含起来
- text 和 utext 的区别。发布帖子,存到数据库时将特定符号转换成转义字符,显示帖子的时候在 html 中再用 utext 将转义字符转换回原符号(虽然不清楚为什么不直接保存原符号,然后 html 中用 text 这种不会解析 html 的方式)
事务管理#
事务简介#
事务是由 N 步数据库操作序列组成的逻辑执行单元,这系列操作要么全执行,要么全放弃执行。
• 事务的特性(ACID)
- 原子性(Atomicity):事务是应用中不可再分的最小执行体
- 一致性(Consistency):事务执行的结果,须使数据从一个一致性状态,变为另一个一致性状态。
- 隔离性(Isolation):各个事务的执行互不干扰,任何事务的内部操作对其他的事务都是隔离的。
- 持久性(Durability):事务一旦提交,对数据所做的任何改变都要记录到永久存储器中。
常见的并发异常#
- 第一类丢失更新、第二类丢失更新
- 脏读、不可重复读、幻读
第一类丢失更新#
第二类丢失更新#
脏读#
不可重复读#
幻读#
事务的隔离级别#
- Read Uncommitted:读取未提交的数据
- Read Committed:读取已提交的数据
- Repeatable Read:可重复读
- Serializable:串行化
事务的传播机制#
解决两个事务交叉在一起的时候以谁的事务为准的问题
–REQUIRED:支持当前事务(外部事务),比如 A 事务调用 B 事务,B 事务以 A 事务的事务为标准,如果 A 不存在事务则创建一个新的事务;
–REQUIRED_NEW:创建一个新事务,按照 B 事务的标准执行,不管 A 是否有事务,如果有事务暂定当前事务(外部事务)即 A 事务;
–NESTED:如果当前存在事务(外部事务),则嵌套在该事务中执行,即如果 A 有事务,B 事务有独立的提交和回滚,如果 A 没有事务则创建一个新的事务,和 REQUIRED 一样。
具体见事务的传播机制,NESTED 和 REQUIRED 的区别。
高危:见我总结的另一篇博客 事务传播机制实验中出现的问题 。
Spring 事务管理#
声明式事务#
- 通过 XML 配置,声明某方法的事务特征
- 通过注解,声明某方法的事务特征
在方法上添加注解,isolation 设置隔离级别;propagation 设置传播机制。
@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
编程式事务#
如果某个业务方法比较复杂,并且只有其中一部分需要用到事务,可以采用编程式事务。如果使用注解的方式则整个业务方法都在事务中运行。
• 通过 TransactionTemplate 管理事务,并通过它执行数据库的操作
- 注入 TransactionTemplate
- 设置隔离级别和传播机制
- 调用 execute 方法,传入回调接口
- 使用匿名内部类,实现 doInTransaction 方法里写业务逻辑
@Autowired
private TransactionTemplate transactionTemplate;
public Object save2() {
transactionTemplate.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
return transactionTemplate.execute(new TransactionCallback<Object>() {
@Override
public Object doInTransaction(TransactionStatus status) {
// 业务方法
……
}
});
}
显示评论#
创建评论对应的实体类#
public class Comment {
private int id;
private int userId;
// 1:帖子的评论 2:评论的回复
private int entityType;
// entityType为1时,帖子id;为2时,评论id
private int entityId;
// entityType为2且是对其他用户的回复的回复时不为0,为目标用户id
private int targetId;
private String content;
private int status;
private Date createTime;
……
}
编写对应的 Mapper#
@Mapper
public interface CommentMapper {
List<Comment> selectCommentsByEntity(@Param("entityType") int entityType, @Param("entityId") int entityId, @Param("offset") int offset, @Param("limit") int limit);
int selectCountByEntity(@Param("entityType") int entityType, @Param("entityId") int entityId);
}
编写 Mapper 对应的 xml 实现方法#
<mapper namespace="com.zwc.community.dao.CommentMapper">
<sql id="selectFields">
id, user_id, entity_type, entity_id, target_id, content, status, create_time
</sql>
<select id="selectCommentsByEntity" resultType="com.zwc.community.entity.Comment">
SELECT <include refid="selectFields"/>
FROM comment
WHERE status != 1
AND entity_type = #{entityType}
AND entity_id = #{entityId}
ORDER BY create_time ASC
LIMIT #{offset}, #{limit}
</select>
<select id="selectCountByEntity" resultType="int">
SELECT count(id)
FROM comment
WHERE status != 1
AND entity_type = #{entityType}
AND entity_id = #{entityId}
</select>
</mapper>
编写 Service 实现 Mapper 的业务#
@Service
public class CommentService {
@Autowired
private CommentMapper commentMapper;
public List<Comment> findCommentsByEntity(int entityType, int entityId, int offset, int limit) {
return commentMapper.selectCommentsByEntity(entityType, entityId, offset, limit);
}
public int findCommentCount(int entityType, int entityId) {
return commentMapper.selectCountByEntity(entityType, entityId);
}
}
在 DiscussPostController 的 findDiscussPostById 方法里增加显示评论的功能(重点)#
- 当点击查看帖子详情的时候,下面会带上帖子的评论,评论需要分页显示,因此需要用到 Page 分页对象,需要对 Page 的属性进行设置;
- 调用 CommentService 的方法,获得该帖子的所有评论信息,需要传入相应的类型和 id,得到一个 Comment 对象的 List 集合;
- 创建 commentVOList 对象,类型为 List<Map<String, Object>>。因为每一条评论还需要同时显示用户信息,所以遍历每一个 Comment 的时候,将评论信息放入 Map,并且要根据 Comment 对象的 userId 查 user 表获取到 User 对象放入 Map 中;
- 每一条评论可能又有回复列表,因此需要再次查询该评论下的所有回复,得到 replyList,再创建 replyVOList,同第3条,将评论(回复)信息和用户信息都放入 Map 中。此外因为有些回复是针对其它回复的回复,还需要判断 target 是否为 0,不为 0 则查询到目标用户信息放入 Map 中;
- 上面两条遍历到每一轮的最后都不要忘了把 Map 加入到 List 集合中。然后在 commentVOList 的每一个 Map 中还需要加上每个评论的回复数量,方便前端进行显示;
- 为了方便类型的可变,常量类中增加了两个常量 ENTITY_TYPE_POST = 1,ENTITY_TYPE_COMMENT = 2;
@GetMapping("/detail/{discussPostId}")
public String findDiscussPostById (@PathVariable("discussPostId") int discussPostId, Model model, Page page) {
// 帖子
DiscussPost post = discussPostService.findDiscussPostById(discussPostId);
model.addAttribute("post", post);
// 作者
User user = userService.findUserById(post.getUserId());
model.addAttribute("user", user);
// 评论分页信息
page.setPath("/discuss/detail/" + discussPostId);
page.setLimit(5);
page.setRows(post.getCommentCount());
// 评论列表
List<Comment> commentList = commentService.findCommentsByEntity(
ENTITY_TYPE_POST, post.getId(), page.getOffset(), page.getLimit());
// 评论VO列表
List<Map<String, Object>> commentVOList = new ArrayList<>();
for(Comment comment : commentList) {
// 评论VO
Map<String, Object> commentVO = new HashMap<>();
// 评论
commentVO.put("comment", comment);
// 用户
commentVO.put("user", userService.findUserById(comment.getUserId()));
// 回复列表
List<Comment> replyList = commentService.findCommentsByEntity(
ENTITY_TYPE_COMMENT, comment.getId(), 0, Integer.MAX_VALUE);
// 回复VO列表
List<Map<String, Object>> replyVOList = new ArrayList<>();
for(Comment reply : replyList) {
// 回复VO
Map<String, Object> replyVO = new HashMap<>();
// 回复
replyVO.put("reply", reply);
// 用户
replyVO.put("user", userService.findUserById(reply.getUserId()));
// 回复目标
User target = reply.getTargetId() == 0 ? null : userService.findUserById(reply.getTargetId());
replyVO.put("target", target);
replyVOList.add(replyVO);
}
commentVO.put("replys", replyVOList);
// 回复数量
commentVO.put("replyCount", commentService.findCommentCount(ENTITY_TYPE_COMMENT, comment.getId()));
commentVOList.add(commentVO);
}
model.addAttribute("comments", commentVOList);
return "/site/discuss-detail";
}
修改相应的页面#
index.html 中各个帖子的回帖数需要动态取值
<li class="d-inline ml-2">
回帖 <span th:text="${map.post.commentCount}">7</span>
</li>
discuss-detail.html 中也需要修改各个需要动态取值的地方
注意:
1、楼层数的显示
这里用到了 xxxStat 对象。Thymeleaf 会提供一个隐含的状态对象,变量名 + Stat,从中可以获取到当前遍历的信息(count、index 等等)
<i th:text="${page.offset + cvoStat.count}">1</i>#
2、对各个回复的回复,输入框动态绑定
……
<li class="d-inline ml-2">
<a th:href="|#huifu-${cvoStat.count+'-'+rvoStat.count}|" data-toggle="collapse" class="text-primary">回复</a>
</li>
</ul>
<div th:id="|huifu-${cvoStat.count+'-'+rvoStat.count}|" class="mt-4 collapse">
……
3、回复是针对其它的回复时,需要显示为“xxx 回复 xxx”(具体见下)
主要代码如下:
<!-- 回帖 -->
<div class="container mt-3">
<!-- 回帖数量 -->
<div class="row">
<div class="col-8">
<h6><b class="square"></b> <i th:text="${post.commentCount}">30</i>条回帖</h6>
</div>
<div class="col-4 text-right">
<a href="#replyform" class="btn btn-primary btn-sm"> 回 帖 </a>
</div>
</div>
<!-- 回帖列表 -->
<ul class="list-unstyled mt-4">
<li class="media pb-3 pt-3 mb-3 border-bottom" th:each="cvo:${comments}">
<a href="profile.html">
<img th:src="${cvo.user.headerUrl}" class="align-self-start mr-4 rounded-circle user-header" alt="用户头像" >
</a>
<div class="media-body">
<div class="mt-0">
<span class="font-size-12 text-success" th:utext="${cvo.user.username}">掉脑袋切切</span>
<span class="badge badge-secondary float-right floor">
<i th:text="${page.offset + cvoStat.count}">1</i>#
</span>
</div>
<div class="mt-2" th:utext="${cvo.comment.content}">
这开课时间是不是有点晚啊。。。
</div>
<div class="mt-4 text-muted font-size-12">
<span>
发布于 <b th:text="${#dates.format(cvo.comment.createTime, 'yyyy-MM-dd HH:mm:ss')}">2019-04-15 15:32:18</b>
</span>
<ul class="d-inline float-right">
<li class="d-inline ml-2"><a href="#" class="text-primary">赞(1)</a></li>
<li class="d-inline ml-2">|</li>
<li class="d-inline ml-2">
<a href="#" class="text-primary">
回复(<span th:text="${cvo.replyCount}">2</span>)
</a>
</li>
</ul>
</div>
<!-- 回复列表 -->
<ul class="list-unstyled mt-4 bg-gray p-3 font-size-12 text-muted">
<li class="pb-3 pt-3 mb-3 border-bottom" th:each="rvo:${cvo.replys}">
<div>
<span th:if="${rvo.target != null}">
<b class="text-info" th:utext="${rvo.user.username}">寒江雪</b>
回复
<b class="text-info" th:utext="${rvo.target.username}">寒江雪</b>:
</span>
<span th:if="${rvo.target == null}">
<b class="text-info" th:utext="${rvo.user.username}">寒江雪</b>:
</span>
<span th:utext="${rvo.reply.content}">这个是直播时间哈,觉得晚的话可以直接看之前的完整录播的~</span>
</div>
<div class="mt-3">
<span th:text="${#dates.format(rvo.reply.createTime, 'yyyy-MM-dd HH:mm:ss')}">2019-04-15 15:32:18</span>
<ul class="d-inline float-right">
<li class="d-inline ml-2"><a href="#" class="text-primary">赞(1)</a></li>
<li class="d-inline ml-2">|</li>
<li class="d-inline ml-2">
<a th:href="|#huifu-${cvoStat.count+'-'+rvoStat.count}|" data-toggle="collapse" class="text-primary">回复</a>
</li>
</ul>
<div th:id="|huifu-${cvoStat.count+'-'+rvoStat.count}|" class="mt-4 collapse">
<div>
<input type="text" class="input-size" th:placeholder="${'回复' + rvo.user.username}"/>
</div>
<div class="text-right mt-2">
<button type="button" class="btn btn-primary btn-sm" onclick="#"> 回 复 </button>
</div>
</div>
</div>
</li>
<!-- 回复输入框 -->
<li class="pb-3 pt-3" ...>
</ul>
</div>
</li>
</ul>
<!-- 分页 -->
<nav class="mt-5" th:replace="index::pagination" ...>
</div>
注意点:#
- 后端部分,尤其 Controller 层的 findDiscussPostById 方法,需要根据页面的结构和需要显示的内容,决定以何种结构存放哪些内容到 Model 中,即评论列表的每一个评论除了本身的评论信息外还要有对应的用户信息以及回复列表和回复数量,回复列表的各个回复中也要有每个回复信息和对应的用户信息以及根据 target 判断是否要存放目标用户信息。
- 前端部分,模板文件中各个需要动态取值的地方,注意传过来的对象的结构,不要取错。(测试途中还发现,如果 Model 中没有存放 target,则判断 ${xxx.target == null} 的时候会报错。因此这个不放的话也不会默认是 null)。其它的就是上面的“注意”中的 3 点。
添加评论#
编写 Mapper 及其 xml#
int insertComment(Comment comment);
<insert id="insertComment" useGeneratedKeys="true" keyProperty="id">
INSERT INTO comment(<include refid="insertFields"/>)
VALUES (#{userId}, #{entityType}, #{entityId}, #{targetId}, #{content}, #{status}, #{createTime})
</insert>
编写 Service 层的方法#
DiscussPostService#
- 修改评论数量
public int updateCommentCount(int id, int commentCount) {
return discussPostMapper.updatecommentCount(id, commentCount);
}
CommentService#
因为在添加评论的同时,需要修改帖子表中的评论数量。涉及到两张表(主要是调用了 2 条 sql 语句),因此这里需要用到之前讲到的事务管理。因为是整个方法都需要事务,因此采用注解的方式声明事务。
- 添加评论时要先转换标签和过滤敏感词
- 如果评论类型是 ENTITY_TYPE_POST(1,对帖子的评论),需要修改帖子表中相应帖子的评论数量
@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
public int addComment(Comment comment) {
if (comment == null) {
throw new IllegalArgumentException("参数不能为空!");
}
// 添加评论
comment.setContent(HtmlUtils.htmlEscape(comment.getContent()));
comment.setContent(sensitiveFilter.filter(comment.getContent()));
int rows = commentMapper.insertComment(comment);
// 更新帖子评论数量
if(comment.getEntityType() == ENTITY_TYPE_POST) {
int count = commentMapper.selectCountByEntity(ENTITY_TYPE_POST, comment.getEntityId());
discussPostService.updateCommentCount(comment.getEntityId(), count);
}
return rows;
}
编写 Controller 层的方法#
(未登录时 user 为空,错误情况后面统一处理)
添加完评论后需重定向到帖子页面,路径需要带上 discussPostId,方便重定向到特定页面。
@PostMapping("/add/{discussPostId}")
public String addComment(@PathVariable("discussPostId") int discussPostId, Comment comment) {
User user = hostHolder.getUser();
comment.setUserId(user.getId());
comment.setCreateTime(new Date());
commentService.addComment(comment);
return "redirect:/discuss/detail/" + discussPostId;
}
修改 discuss-detail.html#
- 路径需要带上帖子的 id,以便重定向回该帖子的页面
- 需要隐式的写上 entityType 和 entityId 的 input 标签,供后端获取参数
- 根据评论类型,决定 entityType 的值
- entityId 的值也由评论类型决定,是 post 的 id 还是 comment 的 id
- 如果是对特定回复的回复,需要带上目标对象的 id,注意是 user 的 id,而不是 target 的 id
(以下代码的顺序和在文件中的实际顺序相反,这里是我修改页面时的顺序)
<!-- 回帖输入 -->
<div class="container mt-3">
<form class="replyform" method="post" th:action="@{|/comment/add/${post.id}|}">
<p class="mt-3">
<a name="replyform"></a>
<textarea placeholder="在这里畅所欲言你的看法吧!" name="content"></textarea>
<input type="hidden" name="entityType" value="1"/>
<input type="hidden" name="entityId" th:value="${post.id}"/>
</p>
<p class="text-right">
<button type="submit" class="btn btn-primary btn-sm"> 回 帖 </button>
</p>
</form>
</div>
<!-- 回复输入框 -->
<li class="pb-3 pt-3">
<form class="replyform" method="post" th:action="@{|/comment/add/${post.id}|}">
<div>
<input type="text" class="input-size" name="content" placeholder="请输入你的观点"/>
<input type="hidden" name="entityType" value="2"/>
<input type="hidden" name="entityId" th:value="${cvo.comment.id}"/>
</div>
<div class="text-right mt-2">
<button type="submit" class="btn btn-primary btn-sm" onclick="#"> 回 复 </button>
</div>
</form>
</li>
<div th:id="|huifu-${cvoStat.count+'-'+rvoStat.count}|" class="mt-4 collapse">
<form class="replyform" method="post" th:action="@{|/comment/add/${post.id}|}">
<div>
<input type="text" class="input-size" name="content" th:placeholder="${'回复' + rvo.user.username}"/>
<input type="hidden" name="entityType" value="2"/>
<input type="hidden" name="entityId" th:value="${cvo.comment.id}"/>
<input type="hidden" name="targetId" th:value="${rvo.user.id}"/>
</div>
<div class="text-right mt-2">
<button type="submit" class="btn btn-primary btn-sm" onclick="#"> 回 复 </button>
</div>
</form>
</div>
注意点:#
- 事务的应用
- 页面中的关系要理清,尤其是如下部分,传的值是 user.id,不是 target.id(“a 回复 b:……”,这时候针对这条回复进行回复,目标对象是 a 而不是 a 的目标对象 b)
<input type="hidden" name="targetId" th:value="${rvo.user.id}"/>
私信列表#
创建 message 表对应的实体类#
public class Message {
private int id;
private int fromId;
private int toId;
// 会话id,小的id在前,111_112
private String conversationId;
private String content;
// 0未读 1已读 2删除
private int status;
private Date createTime;
……
}
编写 MessageMapper#
- 当前用户的会话列表需要分页显示 ,因此需要分页的属性 offset,limit;
- 查询数据库中属于当前用户 id 的所有会话,形成会话列表;
- 查询当前用户的会话数量,为分页做准备
- 每一个会话里又有很多内容信息,因此也需要进行分页,需要相关参数;
- 根据会话 id 查找得到相应的私信列表;
- 查询当前会话包含的私信数量;
- 需要在页面显示未读消息的数量包括总私信未读数量和各会话的私信未读数量,因此需要传入用户 id 和会话 id,根据选择传入相应的值
@Mapper
public interface MessageMapper {
// 查询当前用户的会话列表,针对每个会话只返回一条最新的私信
List<Message> selectConversations(@Param("userId") int userId, @Param("offset") int offset, @Param("limit") int limit);
// 查询当前用户的会话数量
int selectConversationCount(int userId);
// 查询某个会话所包含的私信列表
List<Message> selectLetters(@Param("conversationId") String conversationId, @Param("offset") int offset, @Param("limit") int limit);
// 查询某个会话所包含的私信数量
int selectLetterCount(String conversationId);
// 查询未读私信的数量
int selectUnreadLetterCount(@Param("userId") int userId, @Param("conversationId") String conversationId);
}
编写 MessageMapper.xml 实现 Mapper 中的方法#
- 应当查找 status != 2(非被删除的消息)并且 from_id != 1 的有效信息(非系统消息)
- 查找会话时 userId 可能是 from_id 也可能是 to_id(我发给对方或者对方发给我)
- 根据 conversation_id 分组时,选取同组中 id 最大(与 create_time 最大等价)的一条信息表示最新的消息
- 查找未读消息时根据是否传入 conversationId,选择动态拼接条件(查该用户所有未读消息还是某个会话的未读消息)
- 注意查找会话数量的 sql 写法(表和字段取别名,count(m.maxid) )
<mapper namespace="com.zwc.community.dao.MessageMapper">
<sql id="selectFields">
id, from_id, to_id, conversation_id, content, status, create_time
</sql>
<select id="selectConversations" resultType="com.zwc.community.entity.Message">
SELECT <include refid="selectFields"/>
FROM message
WHERE id IN (
SELECT max(id)
FROM message
WHERE status != 2
AND from_id != 1
AND (from_id = #{userId} OR to_id = #{userId})
GROUP BY conversation_id
)
ORDER BY id DESC
LIMIT #{offset}, #{limit}
</select>
<select id="selectConversationCount" resultType="int">
SELECT count(m.maxid)
FROM (
SELECT max(id) AS maxid
FROM message
WHERE status != 2
AND from_id != 1
AND (from_id = #{userId} OR to_id = #{userId})
GROUP BY conversation_id
) AS m
</select>
<select id="selectLetters" resultType="com.zwc.community.entity.Message">
SELECT <include refid="selectFields"/>
FROM message
WHERE status != 2
AND from_id != 1
AND conversation_id = #{conversationId}
ORDER BY id DESC
LIMIT #{offset}, #{limit}
</select>
<select id="selectLetterCount" resultType="int">
SELECT count(id)
FROM message
WHERE status != 2
AND from_id != 1
AND conversation_id = #{conversationId}
</select>
<select id="selectUnreadLetterCount" resultType="int">
SELECT count(id)
FROM message
WHERE status = 0
AND from_id != 1
AND to_id = #{userId}
<if test="conversationId != null">
AND conversation_id = #{conversationId}
</if>
</select>
</mapper>
编写 MessageService#
@Service
public class MessageService {
@Autowired
private MessageMapper messageMapper;
public List<Message> findConversations(int userId, int offset, int limit) {
return messageMapper.selectConversations(userId, offset, limit);
}
public int findConversationCount(int userId) {
return messageMapper.selectConversationCount(userId);
}
public List<Message> findLetters(String conversationId, int offset, int limit) {
return messageMapper.selectLetters(conversationId, offset, limit);
}
public int findLetterCount(String conversationId) {
return messageMapper.selectLetterCount(conversationId);
}
public int findUnreadLetterCount(int userId, String conversationId) {
return messageMapper.selectUnreadLetterCount(userId, conversationId);
}
}
编写 MessageController#
- 主要形式和之前写过的方法类似,先设置分页信息,然后将对应的值存放到 Map,存放到列表,以及最终添加到 Model 里(注意每轮循环最后不要忘了把 Map 添加到列表里)
- 查找会话列表的时候,除了各个会话的未读消息数量,还需要添加全部的未读消息数量
- 查找具体会话的私信列表时,需要注意存放私信目标用户的信息,这里通过 conversationId 得到。通过将 conversationId 拆分成 2 个 int 类型的 id,与当前 user.id 不同的 id 即为目标用户的 id。
@Controller
@RequestMapping("/letter")
public class MessageController {
@Autowired
private MessageService messageService;
@Autowired
private HostHolder hostHolder;
@Autowired
private UserService userService;
// 私信列表
@GetMapping("/list")
public String findConversations(Model model, Page page) {
User user = hostHolder.getUser();
// 分页信息
page.setPath("/letter/list");
page.setLimit(5);
page.setRows(messageService.findConversationCount(user.getId()));
// 会话列表
List<Message> messages = messageService.findConversations(user.getId(), page.getOffset(), page.getLimit());
List<Map<String, Object>> conversations = new ArrayList<>();
if (messages != null) {
for (Message message : messages) {
Map<String, Object> map = new HashMap<>();
map.put("conversation", message);
map.put("target", userService.findUserById(
user.getId() == message.getFromId() ? message.getToId() : message.getFromId()));
map.put("letterCount", messageService.findLetterCount(message.getConversationId()));
map.put("unreadCount", messageService.findUnreadLetterCount(user.getId(), message.getConversationId()));
conversations.add(map);
}
}
model.addAttribute("conversations", conversations);
// 查询未读消息数量
model.addAttribute("letterUnreadCount", messageService.findUnreadLetterCount(user.getId(), null));
return "/site/letter";
}
@GetMapping("/detail/{conversationId}")
public String findLetters(@PathVariable("conversationId") String conversationId, Model model, Page page) {
// 分页信息
page.setPath("/letter/detail/" + conversationId);
page.setLimit(5);
page.setRows(messageService.findLetterCount(conversationId));
// 私信列表
List<Message> messages = messageService.findLetters(conversationId, page.getOffset(), page.getLimit());
List<Map<String, Object>> letters = new ArrayList<>();
if (messages != null) {
for (Message message : messages) {
Map<String, Object> map = new HashMap<>();
map.put("letter", message);
map.put("fromUser", userService.findUserById(message.getFromId()));
letters.add(map);
}
}
model.addAttribute("letters", letters);
// 私信目标
model.addAttribute("target", getLetterTarget(conversationId));
return "/site/letter-detail";
}
private User getLetterTarget(String conversationId) {
String[] ids = conversationId.split("_");
int id0 = Integer.parseInt(ids[0]);
int id1 = Integer.parseInt(ids[1]);
if(hostHolder.getUser().getId() == id0)
return userService.findUserById(id1);
else
return userService.findUserById(id0);
}
}
修改页面#
- 首先需要修改 index.html 中头部的消息链接为“@{/letter/list}”
- letter.html 和 letter-detail.html 中添加 thymeleaf 模板引擎,修改各相对路径(最前面的 / 需要带上)和标签复用
- 修改各个需要动态取值的地方
- letter.html 中需修改链接到私信详情的链接 @{|/letter/detail/${map.conversation.conversationId}|}
- letter-detail.html 中的返回按钮的链接需要修改(具体见下,用了 js 方法)
letter.html#
<!-- 内容 -->
<div class="main">
<div class="container">
<div class="position-relative">
<!-- 选项 -->
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a class="nav-link position-relative active" th:href="@{/letter/list}">朋友私信
<span class="badge badge-danger" th:text="${letterUnreadCount}" th:if="${letterUnreadCount!=0}">3</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link position-relative" href="notice.html">系统通知<span class="badge badge-danger">27</span></a>
</li>
</ul>
<button type="button" class="btn btn-primary btn-sm position-absolute rt-0" data-toggle="modal" data-target="#sendModal">发私信</button>
</div>
<!-- 弹出框 -->
<div class="modal fade" id="sendModal" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel" aria-hidden="true" ...>
<!-- 提示框 -->
<div class="modal fade" id="hintModal" tabindex="-1" role="dialog" aria-labelledby="hintModalLabel" aria-hidden="true" ...>
<!-- 私信列表 -->
<ul class="list-unstyled">
<li class="media pb-3 pt-3 mb-3 border-bottom position-relative" th:each="map:${conversations}">
<span class="badge badge-danger" th:text="${map.unreadCount}" th:if="${map.unreadCount!=0}">3</span>
<a href="profile.html">
<img th:src="${map.target.headerUrl}" class="mr-4 rounded-circle user-header" alt="用户头像" >
</a>
<div class="media-body">
<h6 class="mt-0 mb-3">
<span class="text-success" th:utext="${map.target.username}">落基山脉下的闲人</span>
<span class="float-right text-muted font-size-12" th:text="${#dates.format(map.conversation.createTime, 'yyyy-MM-dd HH:mm:ss')}">2019-04-28 14:13:25</span>
</h6>
<div>
<a th:href="@{|/letter/detail/${map.conversation.conversationId}|}" th:utext="${map.conversation.content}">米粉车, 你来吧!</a>
<ul class="d-inline font-size-12 float-right">
<li class="d-inline ml-2">
<a href="#" class="text-primary">共<i th:text="${map.letterCount}">5</i>条会话</a>
</li>
</ul>
</div>
</div>
</li>
</ul>
<!-- 分页 -->
<nav class="mt-5" th:replace="index::pagination" ...>
</div>
</div>
letter-detail.html#
<!-- 内容 -->
<div class="main">
<div class="container">
<div class="row">
<div class="col-8">
<h6>
<b class="square"></b>
来自 <i class="text-success" th:utext="${target.username}">落基山脉下的闲人</i> 的私信
</h6>
</div>
<div class="col-4 text-right">
<button type="button" class="btn btn-secondary btn-sm" onclick="back();">返回</button>
<button type="button" class="btn btn-primary btn-sm" data-toggle="modal" data-target="#sendModal">给TA私信</button>
</div>
</div>
<!-- 弹出框 -->
<div class="modal fade" id="sendModal" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel" aria-hidden="true" ...>
<!-- 提示框 -->
<div class="modal fade" id="hintModal" tabindex="-1" role="dialog" aria-labelledby="hintModalLabel" aria-hidden="true" ...>
<!-- 私信列表 -->
<ul class="list-unstyled mt-4">
<li class="media pb-3 pt-3 mb-2" th:each="map:${letters}">
<a href="profile.html">
<img th:src="${map.fromUser.headerUrl}" class="mr-4 rounded-circle user-header" alt="用户头像" >
</a>
<div class="toast show d-lg-block" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<strong class="mr-auto" th:utext="${map.fromUser.username}">落基山脉下的闲人</strong>
<small th:text="${#dates.format(map.letter.createTime, 'yyyy-MM-dd HH:mm:ss')}">2019-04-25 15:49:32</small>
<button type="button" class="ml-2 mb-1 close" data-dismiss="toast" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="toast-body" th:utext="${map.letter.content}">
君不见, 黄河之水天上来, 奔流到海不复回!
</div>
</div>
</li>
</ul>
<!-- 分页 -->
<nav class="mt-5" th:replace="index::pagination" ...>
</div>
</div>
<script>
function back() {
location.href = CONTEXT_PATH + "/letter/list";
}
</script>
注意点:#
- @{} 相对路径最左边需要加上“/”!!!(否则就成了请求路径拼上后面的路径,估计是把浏览器上的路径,即整个请求路径作为当前路径了)
- 列表每轮循环最后一定不要忘了添加 map !!!
- 通过 conversationId 获取私信目标的方法
发送私信#
本节也采用异步的方法
编写 MessageMapper 定义方法及其 xml 实现方法#
MessageMapper#
- 增加消息方法
- 更新状态方法
// 新增消息
int insertMessage(Message message);
// 修改消息的状态
int updateStatus(List<Integer> ids, int status);
注意:这里我对 IDEA 的设置作了一定修改后,不再每个参数写 @Param 注解,详情见关于Mybatis的Mapper中多参数方法不使用@param注解报错的问题
MessageMapper.xml#
当传入 List 类型的参数时,用 <foreach> 标签逐一遍历取值,具体写法如下
<insert id="insertMessage">
INSERT INTO message(<include refid="insertFields" />)
VALUES(#{fromId}, #{toId}, #{conversationId}, #{content}, #{status}, #{createTime})
</insert>
<update id="updateStatus">
UPDATE message
SET status = #{status}
WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</update>
编写 MessageService#
同样,增加消息时,先转换 html 标签,再过滤敏感词,最后存入数据库
public int addMessage(Message message) {
message.setContent(HtmlUtils.htmlEscape(message.getContent()));
message.setContent(sensitiveFilter.filter(message.getContent()));
return messageMapper.insertMessage(message);
}
public int readMessage(List<Integer> ids) {
return messageMapper.updateStatus(ids, 1);
}
编写 MessageController#
发送私信方法#
注意判空,谁是 from 谁是 to 以及拼接 conversationId 时谁先谁后
@PostMapping("/send")
@ResponseBody
public String sendLetter(String toName, String content) {
User target = userService.findUserByName(toName);
if(target == null) {
return CommunityUtil.getJSONString(1, "目标用户不存在!");
}
Message message = new Message();
message.setFromId(hostHolder.getUser().getId());
message.setToId(target.getId());
if(message.getFromId() < message.getToId()) {
message.setConversationId(message.getFromId() + "_" + message.getToId());
} else {
message.setConversationId(message.getToId() + "_" + message.getFromId());
}
message.setContent(content);
message.setCreateTime(new Date());
messageService.addMessage(message);
return CommunityUtil.getJSONString(0);
}
在访问私信详情页面时增加设置已读的方法#
同样注意判空
@GetMapping("/detail/{conversationId}")
public String findLetters(@PathVariable("conversationId") String conversationId, Model model, Page page) {
// 分页信息
……
// 私信列表
……
……
// 私信目标
……
// 设置已读
List<Integer> ids = getLetterIds(messages);
if(!ids.isEmpty()) {
messageService.readMessage(ids);
}
return "/site/letter-detail";
}
private List<Integer> getLetterIds(List<Message> messageList) {
List<Integer> ids = new ArrayList<>();
User user = hostHolder.getUser();
if(messageList != null) {
for(Message message : messageList) {
if(message.getToId() == user.getId() && message.getStatus() == 0)
ids.add(message.getId());
}
}
return ids;
}
修改页面(letter.js)#
对于点击 letter.html 和 letter-detail.html 中的私信按钮,弹出表单的框后再点击发送按钮(sendBtn),调用的都是 letter.js 中的方法(send_letter)
具体流程和前面发布帖子类似
function send_letter() {
$("#sendModal").modal("hide");
var toName = $("#recipient-name").val();
var content = $("#message-text").val();
$.post(
CONTEXT_PATH + "/letter/send",
{"toName":toName, "content":content},
function (data) {
data = $.parseJSON(data);
if(data.code == 0) {
$("#hintBody").text("发送成功!");
} else {
$("#hintBody").text(data.msg);
}
$("#hintModal").modal("show");
setTimeout(function(){
$("#hintModal").modal("hide");
location.reload();
}, 2000);
}
);
}
此外,在 letter-detail.html 中点击“给TA私信”后,表单中的“发给:”要默认带上目标用户的用户名,跟该页面其它地方类似,使用 th:value=${target.username} 即可
注意点:#
- Mapper 中带 List 类型的参数时,foreach 标签的写法要熟悉
- Controller 的方法中注意各处判空的情况
- from 和 to,发送者和接收者要搞清楚,conversationId 的先后次序也要搞清楚
统一处理异常#
SpringBoot 统一处理异常的方法#
只需要把错误页面放在 templates/error 下,注意错误页面的名称要和错误类型码要一致。
当出现对应类型错误的时候,SpringBoot 就会自动跳到相应的页面。
Spring 处理异常#
在加了 @ControllerAdvice 的 Controller 全局配置类中,加了 @ExceptionHandler 注解的方法专门用来处理捕获到的异常。
因为数据层和业务层的异常最终会上抛到表现层,所以在表现层统一处理。
- @ControllerAdvice 注解的参数中需要声明仅扫描加了 @Controller 注解的类
- 记录错误信息日志,将错误堆栈信息也记录到日志里
- 还需要通过 request.getHeader("x-requested-with") 的值来判断是否为异步请求。如果是异步请求,设置返回的类型为普通字符串(这里用了 plain,需要在前端手动将字符串解析成 JSON 对象,也可以直接写成 "application/json",直接传 JSON 对象),返回 JSON 格式的字符串;如果不是异步请求,就重定向到 error 页面(这里手动处理了异常,也需要手动跳转到错误页面)
@ControllerAdvice(annotations = Controller.class)
public class ExceptionAdvice {
private static Logger logger = LoggerFactory.getLogger(ExceptionAdvice.class);
@ExceptionHandler({Exception.class})
public void handleException(Exception e, HttpServletRequest request, HttpServletResponse response) throws IOException {
logger.error("服务器发生异常:" + e.getMessage());
for(StackTraceElement element : e.getStackTrace()) {
logger.error(element.toString());
}
String xRequestedWith = request.getHeader("x-requested-with");
if("XMLHttpRequest".equals(xRequestedWith)) {
response.setContentType("application/plain;charset=utf-8");
PrintWriter writer = response.getWriter();
writer.write(CommunityUtil.getJSONString(1, "服务器异常!"));
} else {
response.sendRedirect(request.getContextPath() + "/error");
}
}
}
在 HomeController 中添加如下方法,用来跳转到错误页面
@GetMapping("/error")
public String getErrorPage() {
return "/error/500";
}
注意点:#
- 需要知道 SpringBoot 默认处理异常的方式,以及 Spring 处理异常的方式(上面的大图)
- 需要根据是否为异步请求来选择不同的返回方式,返回普通字符串 or 重定向到错误页面
- 判断是否为异步请求的方法,参数名不要写错
统一记录日志#
每个业务都需要去记录日志,但是如果写一个记录日志的方法,然后在每个业务方法里都去调用,就使得业务和记录日志产生了耦合。并且如果需要调整记录日志的位置,每个业务方法也需要改动。记录日志本身就不属于业务的一部分,只是系统的需求。所以这里,用到了 AOP 思想,面向切面编程。
AOP 的概念#
AOP 的术语#
- Target:处理需求的目标
- Aspect(方面,切面):封装业务需求的组件(本节即记录日志)
- 利用框架把 Aspect 织入到需要处理的需求目标上
- 编译时织入,程序运行效率高,但可能因为缺少运行时条件导致一些问题;运行时织入效率低
- JoinPoint:织入到需求目标上的位置(属性,方法)
- Pointcut(切点): 表达式声明,声明织入哪些对象
- Advice(通知):具体的逻辑,和执行的位置
AOP 的实现#
· AspectJ
- AspectJ 是语言级的实现,它扩展了 Java 语言,定义了 AOP 语法。
- AspectJ 在编译期织入代码,它有一个专门的编译器,用来生成遵守 Java 字节码规范的 class 文件。
· Spring AOP
- Spring AOP 使用纯 Java 实现,它不需要专门的编译过程,也不需要特殊的类装载器。
- Spring AOP 在运行时通过代理的方式织入代码,只支持方法类型的连接点。
- Spring 支持对 AspectJ 的集成。
Spring AOP#
代理:生成一个代理对象,调用的时候调用代理对象,而不是原始对象,代码织入到代理对象
• JDK 动态代理(目标需要有接口)
- Java 提供的动态代理技术,可以在运行时创建接口的代理实例。
- Spring AOP 默认采用此种方式,在接口的代理实例中织入代码。
• CGLib 动态代理
- 采用底层的字节码技术,在运行时创建子类代理实例。
- 当目标对象不存在接口时,Spring AOP 会采用此种方式,在子类实例中织入代码。
使用 Spring AOP 统一记录日志#
- 加上 @Aspect 注解声明这是一个 Aspect
- @Pointcut,写表达式;该方法是 public void 的方法,不需要写方法体
- @Before,在目标方法前调用,参数需要声明使用上面的切面表达式 pointcut(),来切入哪些类的哪些方法
- 获取 Request 对象,获取用户的 ip 地址,转换时间以及获取目标方法的类和方法名
@Component
@Aspect
public class ServiceLogAspect {
private static Logger logger = LoggerFactory.getLogger(ServiceLogAspect.class);
@Pointcut("execution(* com.zwc.community.service.*.*(..))")
public void pointcut() {}
@Before("pointcut()")
public void before(JoinPoint joinPoint) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
String ip = request.getRemoteHost();
String now = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
String target = joinPoint.getSignature().getDeclaringTypeName() + "." + joinPoint.getSignature().getName();
logger.info(String.format("用户[%s],在[%s],访问了[%s]", ip, now, target));
}
}
此外,还有 @After,@AfterReturning(在返回值之后调用),@AfterThrowing(抛出异常后调用)以及 @Around。
@Around 具体写法如下。其中 joinPoint.proceed() 就是调用目标对象的方法,在这之前和之后分别作相应的一些逻辑处理,就实现了和 @Before 和 @After 一样的效果(不过最终顺序都是 around xxx 在 xxx 之前)
@Around("pointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
System.out.println("around before");
Object obj = joinPoint.proceed();
System.out.println("around after");
return obj;
}
注意点:#
- AOP 的概念、原理、机制等需要理解
- Spring AOP 的代码实现,切面表达式、各个注解的含义
- 用户 ip 的获取方法(涉及到如何在 Aspect 中获取 Request 对象,这里不好直接在方法参数上写 HttpServletRequest),日期时间的转换,目标方法的类与方法名的获取以及 String.format 的使用
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!