文章目录
- 过滤敏感词
- 发布帖子
- 帖子详情
- 添加评论
- 私信列表
- 发送私信
- 统一处理异常
- 统一记录日志
基于Springboot的核心功能实现
包括自定义前缀树过滤敏感词;使用异步请求的方式发布帖子;查看帖子详情;添加评论时需要同时增加评论的数据和修改帖子的评论数量,进行两步数据库操作,出于安全考虑采用事务管理;私信列表部分包括显示私信列表与私信详情两个子功能;采用异步方式发送私信;配置Controller的全局配置类进行统一异常处理;采用AOP实现统一记录日志。
过滤敏感词
- 前缀树
- 名称:Tire、字典树、查找树
- 特点:查找效率高,消耗内存大
- 应用:字符串检索、词频统计、字符串排序等
- 敏感词过滤器
-
定义前缀树
-
根据敏感词,初始化前缀树
-
编写过滤敏感词的方法
-
发布帖子
该功能的实现需要异步请求,使用了AJAX
示例:怎么使用jQuery发送AJAX请求?
1.需要在Controller里添加处理异步请求的方法,一般请求方式为post,因为通常是浏览器通过异步的方式向服务器提交一些数据,然后服务端向浏览器响应一个提示,不需要返回网页而是返回一个字符串,所以需要添加@ResponseBody注解
// ajax示例
@RequestMapping(path = "/ajax", method = RequestMethod.POST)
@ResponseBody
public String testAjax(String name, int age) {
// 请求处理逻辑
System.out.println(name);
System.out.println(age);
// 返回JSON字符串,方法getJSONString()封装在工具类中
return CommunityUtil.getJSONString(0, "操作成功!");
}
2.需要在网页中写一段jQuery代码,访问处理异步请求的方法。
分为两步:引入jQuery,使用jQuery发送异步请求
// ajax示例
<script src="<https://code.jquery.com/jquery-3.3.1.min.js>" crossorigin="anonymous"></script>
<script>
function send() {
$.post(
"/community/alpha/ajax",
{"name":"张三","age":23},
function(data) {
data = $.parseJSON(data);
console.log(typeof(data));
console.log(data.code);
console.log(data.msg);
}
);
}
</script>
采用AJAX请求,实现发布帖子的功能:
具体实现:
首先需要定义一个工具类方法处理Json相关的转换,因为服务端要向客户端返回一些提示信息和数据,使用了fastjson,需要提前导入fastjson的jar包
public class CommunityUtil {
public static String getJSONString(int code, String msg, Map<String,Object> map){
JSONObject json = new JSONObject();
json.put("code",code);
json.put("msg",msg);
if(map != null){
for(String key:map.keySet()){
json.put(key,map.get(key));
}
}
return json.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);
}
}
在DiscussPostService中添加增加帖子的相关业务层代码逻辑:
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类,在里面写发布帖子的方法:
// 发布帖子
@RequestMapping(path = "/add",method = RequestMethod.POST)
@ResponseBody
public String addDiscussPost(String title,String content){
// 判断是否是登录状态
User user = hostHolder.getUser();
if(user == null){
return 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,"发布成功!");
}
浏览器发送异步请求:
// 获取标题和内容
var title = $("#recipient-name").val();
var content = $("#message-text").val();
// 发送异步请求
$.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);
}
)
帖子详情
按照数据层,业务层,表现层逐级来写就行,注意 帖子详情中的内容很多,点赞功能暂未实现,点赞的相关内容后面补充(大概redis部分…)
表现层代码相对复杂(详细如下),主要是套娃套娃套娃🪆🪆🪆…
帖子,评论(帖子的评论),回复(帖子的评论的回评论)…
// 帖子详情
@RequestMapping(path = "/detail/{discussPostId}", method = RequestMethod.GET)
public String getDiscussPost(@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.setLimit(5);
page.setPath("/discuss/detail/" + discussPostId);
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<>();
if (commentList != null) {
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<>();
if (replyList != null) {
for (Comment reply : replyList) {
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);
// 回复数量
int replyCount = commentService.findCommentCount(ENTITY_TYPE_COMMENT, comment.getId());
commentVo.put("replyCount", replyCount);
commentVoList.add(commentVo);
}
}
model.addAttribute("comments", commentVoList);
return "/site/discuss-detail";
}
添加评论
添加评论是在帖子详情页面,给帖子评论,或者是给帖子的评论进行评论(也叫回复)
因为既要增加评论的数据,又要修改帖子的评论数量,所以需要进行两步数据库操作,从安全性考虑,将这两步数据库操作放到一个事务里进行管理。(⚠️:事务管理)
使用声明式事务管理,用@Transactional进行注解,使用isolation属性声明事务的隔离级别,使用propagation属性声明事务的传播机制。
⚠️注意:只有对帖子进行评论的时候才需要更新帖子的评论数量,需要判断一下。业务层逻辑如下:
// 添加评论
@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(comment.getEntityType(),comment.getEntityId());
discussPostService.updateCommentCount(comment.getEntityId(),count);
}
return rows;
}
补充
:为什么在添加评论的时候既需要触发评论事件,又需要触发发帖事件?
- 触发评论事件是因为,系统向被评论用户发送系统通知时是采用消息队列的方式实现的。
- 仅在对帖子进行评论的时候会触发发帖事件,因为当对帖子进行评论时,帖子评论数量发生改变,也就是post.commentCount会有变化,所以要将Elasticsearch中的数据刷新一下。
私信列表
私信列表包括两个子功能,显示会话列表和私信详情信息。
▶️ 对于显示会话列表,需要查询当前用户的会话列表,每个会话只显示一条最新的私信,分页显示该列表,详细步骤主要包括:首先获得当前登录用户,并且设置分页信息,然后查询当前用户的会话列表,遍历会话列表,查询每一条会话所需要显示的其他信息(该会话私信的总数,该会话未读私信的数量,该会话对面用户的用户信息),除此之外,会话列表中还需要显示当前用户所有未读消息的数量。
▶️ 对于私信详情功能,需要查询某个会话所包含的私信列表,分页显示该列表,并且将显示的私信设置为已读状态,详细步骤主要包括:设置分页信息,根据会话Id查询私信列表,遍历私信列表,查询该条私信发送者有关的用户信息,最后将所有的未读私信设置为已读。
发送私信
采用异步方式发送私信,发送成功后刷新私信列表
🔎详细步骤:首先根据私信接收者的用户名获得接收者的用户信息,如果接收者用户不存在,直接返回错误提示;如果接收者用户存在,那么构造message对象,将message存到数据库中。
统一处理异常
服务端的三层架构:表现层 → 业务层 → 数据层。
浏览器发送的请求一律发给表现层,表现层调用业务层,业务层调用数据层。数据层出现异常后会抛出给它的调用者业务层,业务层会把异常抛出给表现层,所以无论是哪个层的异常,最终都会汇集到表现层,所以对表现层的异常进行捕获和处理就可以处理所有的异常。
🔎 Springboot提供的方案:只需要在特定路径src/main/resources/templates/error下,添加对应错误状态的页面,那么在发现相应错误的时候,就会自动的跳转到对应页面。错误状态页面的名字必须是错误状态,比如404。
跳出错误页面是表面上的处理,内在记录日志部分还没有处理。spring提供了@ControllerAdvice注解进行相关处理。
- @ControllerAdvice:用于修饰类,表示该类是Controller的全局配置类,在此类中,可以对Controller进行如下三种全局配置:异常处理方案、绑定数据方案、绑定参数方案
- 异常处理方案:@ExceptionHandler:用于修饰方法,该方法会在Controller出现异常后被调用,用于处理捕获到的异常
- 绑定数据方案:@ModelAttribute:用于修饰方法,该方法会在Controller方法执行前被调用,用于为Model对象绑定参数
- 绑定参数方案:@DataBinder:用于修饰方法,该方法会在Controller方法执行前被调用,用于绑定参数的转换器
具体实现:
- 在Controller层写好处理error请求的方法getErrorPage()。
- 利用@ControllerAdvice注解声明一个Controller全局配置类,对所有Controller的异常做统一的处理。在controller/advice路径下,新建一个类ExceptionAdvice,并用@ControllerAdvice进行注解。
- 定义处理异常的方法handleException()并用@ExceptionHandler进行注解。
- 记录日志,包括详细的日志信息。
- 给浏览器一个响应,重定向到错误页面。注意:这里可能是普通请求也有可能是异步请求,需要区分处理,通过request对象来获取请求方式
request.getHeader("x-requested-with");
如果请求方式为 XMLHttpRequest,说明是异步请求,响应一个字符串。否则重定向到错误页面。
小结:不需要对Controller做任何处理,只需要使用@ControllerAdvice注解声明一个Controller的全局配置类,对添加了@Controller注解的所有类进行一个统一的异常处理。在全局配置类中使用@ExceptionHandler注解定义一个异常处理方法,对于所有类型的异常进行处理,处理过程是:首先输出异常信息(包括异常的详细信息),然后根据请求方式的不同,进行不同的响应,如果是异步请求,则响应给浏览器一个字符串;如果是普通请求,则重定向到错误页面。请求方式是通过request对象来获取的。
// 代码实现
@ControllerAdvice(annotations = Controller.class)
public class ExceptionAdvice {
private static final 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");
}
}
}
统一记录日志
1.AOP的基本实现:
首先定义一个方面组件,该方面组件用@Component @Aspect处理,在方面组件里定义切点和通知。
切点:通过@Pointcut注解实现
// 示例
@Pointcut("execution(* com.community.service.*.*(..))")
public void pointcut() {
}
通知:(共有五种通知方式)
- 在连接点前织入 @Before
// 示例
@Before("pointcut()")
public void before() {
System.out.println("before");
}
- 在连接点后织入@After
// 示例
@After("pointcut()")
public void after() {
System.out.println("after");
}
- 在有了返回值以后再处理逻辑 @AfterReturning
// 示例
@AfterReturning("pointcut()")
public void afterRetuning() {
System.out.println("afterRetuning");
}
- 在抛异常的时候织入代码 @AfterThrowing
// 示例
@AfterThrowing("pointcut()")
public void afterThrowing() {
System.out.println("afterThrowing");
}
- 环绕通知,既想在前面织入逻辑,又想在后面织入逻辑 @Around
要有返回值(Object)和参数(ProceedingJoinPoint joinPoint),抛出异常throws Throwable。除了环绕通知以外的其他通知也可以添加连接点JointPoint的参数。
利用连接点,jointPoint.proceed();就是调用目标对象的方法逻辑,将目标组件的返回值return(return obj)。
// 示例
@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;
}
2.统一记录日志需求:对所有的业务组件记录日志,在业务组件调用的一开始记录日志,采用@Before的方式。日志记录格式:用户xxx,在xxx,访问了xxx。
具体实现:
- 定义一个方面组件:ServiceLogAspect,使用@Component@Aspect进行注解
- 声明切点pointcut(),使用了@Pointcut注解, 切点为所有的业务层方法
@Pointcut("execution (* com.nowcoder.community.service.*.*(..))")
public void pointcut(){
}
-
定义通知,日志格式:用户[1.2.3.4],在[xxx],访问了[service.xxx()].因为是在业务组件调用的一开始就记录日志,也就是在切点前织入代码逻辑,所以使用@Before注解。
问题1:用户IP怎么获取?可以通过request对象,先利用RequestContextHolder工具类中的getRequestAttributes()方法获取attributes;然后attributes.getRequest()获得request对象;最后使用request.getRemoteHost获得用户IP。
问题2:如何获得当前时间?new Date()获取。
问题3:如果获得调用的是哪个类哪个方法?jointPoint是程序织入的目标,也就是目标组件要调用的方法,通过jointPoint就可以得到调用的是哪个类的哪个方法。- joinPoint.getSignature().getDeclaringTypeName():获得类名;
- joinPoint.getSignature().getName():获得方法名。
补充:JoinPoint类,用来获取代理类和被代理类的信息。JoinPoint.getSignature()
@Before("pointcut()")
public void before(JoinPoint joinPoint){
// 用户[1.2.3.4],在[xxx],访问了[com.nowcoder.community.service.xxx()].
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if(attributes == null){
return;
}
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));
}