3.6 添加评论
数据访问层(dao)
首先是在 CommentMapper 接口中增加两个方法添加评论:
int insertComment(Comment comment);
/**
* 插入一条评论
*/
<sql id="insertFields">
user_id, entity_type, entity_id, target_id, content, status, create_time
</sql>
<insert id="insertComment" parameterType="com.nowcoder.community.entity.Comment">
insert into comment(<include refid="insertFields"></include>)
values(#{userId}, #{entityType}, #{entityId}, #{targetId}, #{content}, #{status}, #{createTime})
</insert>
然后我们需要在 DiscussPostMapper 接口中增加一个更新帖子数量的方法:
int updateCommentCount(int id, int commentCount);
/**
* 根据帖子id更新评论数量
*/
然后是对应的mapper配置文件
<update id="updateCommentCount">
update discuss_post set comment_count = #{commentCount}
where id = #{id}
</update>
业务层(Service)
我们先在 DiscussPostService 中加一个更新评论数量的方法,然后在 CommentService 中开发时可以依赖于 DiscussPostService 组件
DiscussPostService :
public int updateCommentCount(int id, int commentCount){
return discussPostMapper.updateCommentCount(id, commentCount);
}
CommentService:
为了方便使用常量,CommentService要实现常量接口 CommunityConstant
@Autowired
private SensitiveFilter sensitiveFilter;
@Autowired
private DiscussPostService discussPostService;
// 增加评论数量,另外,因为是两个DML操作:增加评论、更新评论数量,所以交给事务管理
@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED) // 事务级别、传播机制
public int addComment(Comment comment){
if(comment == null){
throw new IllegalArgumentException("参数不能为空!");
}
// 添加评论 转义html标记 过滤敏感词
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(comment.getEntityType(), comment.getEntityId());
discussPostService.updateCommentCount(comment.getEntityId(), count);
}
return rows;
}
表现层(controller 和 thymeleaf 模板)
新建一个 CommentController:
@Controller
@RequestMapping("/comment")
public class CommentController {
@Autowired
private CommentService commentService;
@Autowired
private HostHolder hostHolder;
@RequestMapping(path = "/add/{discussPostId}", method = RequestMethod.POST)
public String addComment(@PathVariable("discussPostId") int discussPostId, Comment comment) {
comment.setUserId(hostHolder.getUser().getId());
comment.setStatus(0);
comment.setCreateTime(new Date());
commentService.addComment(comment);
return "redirect:/discuss/detail/" + discussPostId;
}
}
然后就是处理 discuss-detail.html 模板了
回复有三种类型:
- 一种是直接在帖子最下面回复
- 一种是回复别人对于帖子的评论的评论
- 最后一种是回复别人评论别人的评论
测试
经过自己测试,三种方式的评论都测试成功!
3.7 私信列表 和 私信详情
这次我们开发的是私信列表和私信详情两个功能,这里我们一起开发。
首先是私信对应的实体类 Message
public class Message {
private int id; // 主键
private int fromId; // 发信方id
private int toId; // 首先方id
private String conversationId; // 会话id,有fromId和toId组成
private String content; // 绘画内容
private int status; // 状态 0-未读 1-已读 2-删除
private Date createTime; // 日期
// 以免影响阅读体验,get。set。toString方法 没有粘,但开发时这些方法是需要的
}
- 数据访问层(dao)
因为是一个新业务,所以我们创建一个新的dao接口
@Mapper
public interface MessageMapper {
// 开发私信列表页面:查询当前用户的会话列表(支持分页),针对每个会话只返回一条最新的消息
List<Message> selectConversations(int userId, int offset, int limit);
// 开发私信列表页面:查询当前用户的会话数量
int selectConversationCount(int userId);
// 开发私信详情页面:查询某个会话包含的私信列表
List<Message> selectLetters(String conversationId, int offset, int limit);
// 开发私信详情页面:查询某个会话所包含的私信数量
int selectLetterCount(String conversationId);
// 查询未读私信的数量, 这里conversationId作为动态的条件去拼,不是一定会有,传了就拼上去,这样这个方法能够实现两种业务
int selectLetterUnreadCount(int userId, String conversationId);
}
然后是对应的mapper配置文件message-mapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.nowcoder.community.dao.MessageMapper">
<sql id="selectFields">
id, from_id, to_id, conversation_id, content, status, create_time
</sql>
<!--查询所有会话,每个会话查询和某个人的最后一个对话,from_id=1是系统用户是通知不是私信-->
<select id="selectConversations" resultType="com.nowcoder.community.entity.Message">
select <include refid="selectFields"></include>
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="Integer">
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="Message">
select <include refid="selectFields"></include>
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="Integer">
select count(id)
from message
where status != 2
and from_id != 1
and conversation_id = #{conversationId}
</select>
<!--查询未读私信的数量-->
<select id="selectLetterUnreadCount" resultType="Integer">
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>
- 业务层
新建一个Service: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 findLetterUnreadCount(int userId, String conversationId) {
return messageMapper.selectLetterUnreadCount(userId, conversationId);
}
}
- 表现层
表现层我们需要两个功能,一个是私信列表,一个是私信详情。这两个功能写在一个controller里就可以。
先处理私信列表
新建一个Controller叫 MessageController
@Controller
public class MessageController {
@Autowired
private MessageService messageService;
@Autowired
private HostHolder hostHolder;
@Autowired
private UserService userService;
// 私信列表
@RequestMapping(path = "/letter/list", method = RequestMethod.GET)
public String getLetterList(Model model, Page page){
User user = hostHolder.getUser();
// 分页信息
page.setLimit(5); //设置一页显示5条
page.setPath("/letter/llist"); //设置路径
page.setRows(messageService.findConversationCount(user.getId())); // 设置总条数
// 会话列表
List<Message> conversationList = messageService.findConversations(user.getId(),
page.getOffset(), page.getLimit());
List<Map<String, Object>> conversations = new ArrayList<>();
if(conversationList != null){
for (Message message : conversationList) {
Map<String, Object> map = new HashMap<>();
// 将 message 封装进去
map.put("conversation", message);
// 私信数量
map.put("letterCount", messageService.findLetterCount(message.getConversationId()));
// 将 未读消息数量 封装进去
map.put("unreadCount", messageService.findLetterUnreadCount(user.getId(), message.getConversationId()));
// 找到谁给“自己”传了消息的id,然后传入,因为我们在页面上还要显示这个人的用户名和头像
int targetId = user.getId() == message.getFromId() ? message.getToId() : message.getFromId();
map.put("target", userService.findUserById(targetId));
conversations.add(map);
}
}
model.addAttribute("conversations", conversations);
// 查询未读消息数量
int letterUnreadCount = messageService.findLetterUnreadCount(user.getId(), null);
model.addAttribute("letterUnreadCount", letterUnreadCount);
return "/site/letter";
}
}
首先要将首页 index.html 的 “消息” 链到 controller 上
然后是处理私信列表的模板 letter.html
到这里私信列表功能就已经实现了,到这里可以启动一下项目测试一下
接下来开发 私信详情 功能
在 MessageController 中添加下面方法
// 私信详情
@RequestMapping(path = "/letter/detail/{conversationId}", method = RequestMethod.GET)
public String getLetterDetail(@PathVariable("conversationId") String conversationId, Page page, Model model) {
// 分页信息
page.setLimit(5);
page.setPath("/letter/detail/" + conversationId);
page.setRows(messageService.findLetterCount(conversationId));
// 私信列表
List<Message> letterList = messageService.findLetters(conversationId, page.getOffset(), page.getLimit());
List<Map<String, Object>> letters = new ArrayList<>();
if (letterList != null) {
for (Message message : letterList) {
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";
}
// 根据conversationId查询目标用户
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);
}
}
然后我们就可以处理模板了
首先我们在会话列表里 letter.html 做一个处理,把那个链接(可以链到详情页面)做好
然后处理私信详情页面 letter-detail.html
到这里私信详情就开发完了
测试
经测试,功能开发成功!
3.8 发送私信
这次开发发送私信和设置已读功能
首先开发发送私信的功能
- 数据访问层(dao)
在之前创建好的dao组件MessageMapper中增加 发送私信和设置已读 方法
// 新增消息(私信)
int insertMessage(Message message);
// 修改私信的状态(未读-->已读或添加私信后首先直接设置为未读货值设置为删除)
int updateStatus(List<Integer> ids, int status);
然后是对应的mapper配置文件
- 业务层(Service)
在之前创建的MessageService中添加 发送私信和设置已读 方法
@Autowired
private SensitiveFilter sensitiveFilter;
// 新增私信
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); // 1 表示已读
}
- 表现层
在UserService中增加一个根据用户名查询用户的方法(dao接口之前已经写过这个方法),方便MessageController使用这个方法
public User findUserByName(String username) {
return userMapper.selectByName(username);
}
然后在MessageController中增加添加私信方法
// 发送私信
@RequestMapping(path = "/letter/send", method = RequestMethod.POST)
@ResponseBody // 因为是异步方式,所以要加上这个注解
public String sendLetter(String toName, String content) {
User target = userService.findUserByName(toName);
if (target == null) {
return CommunityUtil.getJSONString(1, "目标用户不存在!"); // 返回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);
// 异步方式返回JSON字符串
return CommunityUtil.getJSONString(0); // 新增私信默认状态为0,表示未读
}
然后我们需要修改 私信列表 letter.html 处的发私信按钮,这个功能是在 letter.js 中实现的,所以我们需要修改一下letter.js
然后我们需要处理一下私信详情页面 letter-detail.html的私信时会默认带上对方的用户名,我们要处理一下
到这里发送私信的功能就开发完了。
然后我们接下来需要开发当用户读私信时设置已读功能
我们只需要在 MessageController 的 getLetterDetail 访问私信详情方法里,把私信列表里面的未读的消息提取出来,然后自动的设置为已读就可以了
这样,设置已读功能就开发完成了,开发完成之后可以测试一下。
3.9 统一异常处理
SpringBoot中异常的自动处理
浏览器发送请求的时候一律发送给表现层,然后 表现层 调用 业务层,业务层 调用 数据层,假如 数据层 出异常了,它会抛给 业务层,然后 业务层 会抛给 表现层。所以整个三层架构,无论 数据层还是业务层哪个层次的异常,最终都会汇集到表现层,因此只要我们统一的对表现层捕获异常,就能处理系统的所有异常。
SpringBoot中异常处理
SpringBoot会自动处理异常,跳转到相应的 404.html 、500.html 对应的错误状态页面。
我们先将 404.html、500.html页面处理一下:
文件夹名字一定要叫 error,且在 templates 目录下,错误文件的名字一定要叫错误状态。
运行项目测试:
SpringBoot会自动给异常进行一个处理,但是这个处理还不是一定符合我们的预期,
比如 404 这个就ok,找不到资源没有更好的办法,500 就不一样,500 意味着服务端报错了,报错的时候我们最好记个日志,好以后分析,所以直接跳转页面这只是表面上的一个处理,我们内在需要记个日志这个还没有处理。
SpringBoot中异常的详细处理
接下来我们来看一下如何统一记录日志:
- @ModelAttribute
绑定数据的意思是:
比如 controller 有很多请求,这些请求当中要用到同一个数据给模板,我们需要往 model
里装同一个数据,我们可以利用 @ModelAttribute 加一个方法给 model 统一绑定参数,给所有的 controller 用。
-
@DataBinder
这个注解的作用是,页面向服务器传参,会被自动的做转换,是因为内部调了很多的参数转换器,万一程序当中默认的参数转换器不够用,有一个特殊的类型需要处理,可以自定义一个转换器,然后用 @DataBinder 把它注册上。
上面的 @ModelAttribute 和 @DataBinder 目前没有需求要用,所以就不演示了。接下来我们演示利用 @ExceptionHandler 统一处理所有 controller 可能发生的异常。
演示
首先在 HomeController 里加上一个请求,为什么呢?因为controller发生异常之后,我们统一处理,记了日志,处理完以后我们得去到 500那个页面,这个时候因为是我们人为处理的,我们需要手动的重定向过去,所以我们需要提前把 500 页面这个请求的访问给它配一下,增加一下这个请求的处理。
@RequestMapping(path = "/error", method = RequestMethod.GET)
public String getErrorPage() {
return "/error/500";
}
接下来我们需要用@ControllerAdvice 声明一个 controller 全局配置类,然后对所有 controller 的异常做统一的处理
@ControllerAdvice 不加参数的话会扫描所有的bean,范围太大了,我们通常需要一个限制,让它范围小一点,
@ControllerAdvice(annotations = Controller.class) 的意思是 这个注解只扫描带有 @Controller 注解的那些组件,
我们需要在这个类里面加一个方法处理所有的错误的情况,方法之前加一个注解@ExceptionHandler 表示这个方法是处理所有异常的方法
@ControllerAdvice(annotations = Controller.class)
public class ExceptionAdvice {
// 为了记日志
private static Logger logger = LoggerFactory.getLogger(ExceptionAdvice.class);
@ExceptionHandler({Exception.class}) // 里面的参数表示处理所有异常
public void handlerException(Exception e, HttpServletRequest request, HttpServletResponse response) throws IOException { // 方法名无所谓
// 记录一下异常的概括
logger.error("服务器发生异常: " + e.getMessage());
// 详细记录异常的栈的信息
for (StackTraceElement element : e.getStackTrace()) {
logger.error(element.toString());
}
/*
普通的请求重定向到刚刚500的controller
异步请求的话返回json,json字符串里面写错误信息
*/
// 判断请求是普通请求还是异步请求
String xRequestedWith = request.getHeader("x-requested-with");
if ("XMLHttpRequest".equals(xRequestedWith)) {
// 异步请求
response.setContentType("application/plain;charset=utf-8"); // plain表示向浏览器返回的是一个普通的字符串
PrintWriter writer = response.getWriter();
writer.write(CommunityUtil.getJSONString(1, "服务器异常!"));
} else {
// 普通请求重定向到错误页面
response.sendRedirect(request.getContextPath() + "/error");
}
}
}
上面的处理的话如果是 500 错误,表示服务器错误,会记个日志,然后跳到 500 那个controller,然后这个 controller会跳转到 500.html 界面
如果是 404 错误,也就是找不到资源,会直接跳转到 404.html 页面
测试
普通请求的 500 异常
异步发私信的 500 异常
到这里,统一异常处理就完成了。
3.10 统一记录日志
这里日志的意思是平时不发生异常的话也记录日志。
切入点表达式:
# 切入点表达式
切入点表达式
execution(返回值 包.类名.方法名(参数类型))
execution(返回值 包.类名.*(..))
*代表类中所有方法 ..代表参数任意
aop.*.*(..) 代表aop包下的所有类的所有方法在执行时都要加前置通知
execution:
第一个代表方法的返回值,*代表不关心方法的返回值
之后空格写包名(包.类.方法名),切类中所有方法且不关心方法的参数写*(..)
以前写的关于AOP的博客:
Spring中AOP编程本质(动态代理机制)以及前置通知的开发
SpringAOP使用示例
Spring AOP 的示例
下面写一个示例体会一下 Spring Aop 的语法
写完这个示例之后将类的两个注解注释掉,防止影响记录日志时的数据观看
@Component
@Aspect
public class AlphaAspect {
@Pointcut("execution(* com.nowcoder.community.service.*.*(..))")
public void pointcut() {
}
// 前置通知
@Before("pointcut()")
public void before() {
System.out.println("before");
}
// 后置通知
@After("pointcut()")
public void after() {
System.out.println("after");
}
// 如果想在有了返回值以后处理一下逻辑可以使用这个注解(相当于后置通知的增强版)
@AfterReturning("pointcut()")
public void afterRetuning() {
System.out.println("afterRetuning");
}
// 在抛异常之后置入代码
@AfterThrowing("pointcut()")
public void afterThrowing() {
System.out.println("afterThrowing");
}
// 环绕通知
@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; // 目标组件方法的返回值
}
}
/*
around before
before
2022-07-19 17:00:43,555 DEBUG [http-nio-8080-exec-1] c.n.c.d.L.selectByTicket [BaseJdbcLogger.java:143] ==> Preparing: select id, user_id, ticket, status, expired from login_ticket where ticket = ?
2022-07-19 17:00:43,555 DEBUG [http-nio-8080-exec-1] c.n.c.d.L.selectByTicket [BaseJdbcLogger.java:143] ==> Parameters: e626e90074e84641993380de204f177e(String)
2022-07-19 17:00:43,557 DEBUG [http-nio-8080-exec-1] c.n.c.d.L.selectByTicket [BaseJdbcLogger.java:143] <== Total: 1
afterRetuning
after
around after
*/
对所有业务组件记录日志
@Component
@Aspect
public class ServiceLogAspect {
// 日志
private static final Logger logger = LoggerFactory.getLogger(ServiceLogAspect.class);
// 切点
@Pointcut("execution(* com.nowcoder.community.service.*.*(..))")
public void pointcut() {
}
// 使用前置通知记录日志
@Before("pointcut()")
public void before(JoinPoint joinPoint) { // 这个参数是为了知道访问了哪个类的哪个方法
// 用户[1.2.3.4,ip地址],在[xx时间],访问了[com.nowcoder.community.service.xxx()].
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
// 得到request对象
HttpServletRequest request = attributes.getRequest();
String ip = request.getRemoteHost(); // 利用request得到ip地址
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));
}
}
访问首页:
经测试,统一记录日志功能完成