基于 SSM + Spring Boot + Thymeleaf 开发的论坛社区网站
项目概述
本项目是依据 2019 年牛客项目,基于 SSM + Spring Boot + Thymeleaf 开发的论坛社区网站,网站实现了如下功能:
- 使用 Spring Email + Interceptor + Spring Security 等实现网站权限模块开发,完成注册、登录、退出、状态、设置、授权等功能。
- 实现网站核心功能,包括首页、帖子、评论、私信、敏感词过滤、全局异常处理、统一日志记录。
- 使用 Redis 实现其他功能模块,包括点赞、关注、网站数据统计、缓存优化,其中缓存主要为:验证码、登录凭证、会话信息。
- 引入 Kafka 的目的主要是为了异步生产消费事件,包括评论、点赞、关注时的系统通知,以及 Elasticsearch 服务器的更新。
- 使用 Elasticsearch 实现全文搜索。
- 基于 Quartz 定时任务实现热帖排行;使用 Caffeine 做热帖服务器缓存,提升性能。
后面我会罗列一些我认为的重点,梳理项目的后台实现步骤。
总结图如下:
项目难度
本人是把这个项目作为学校工程实践前的热身项目。整个项目约需 1 个月,可作为 Java 练手项目,快速了解热门框架和组件的基本使用。项目可改进的地方有很多,最后会提到。
实现步骤
权限模块
首页
首页进行帖子的展示,依据一般后台开发业务流程进行实现:数据库建表->Java 对应 entity 实体类->mapper 层接口-> 接口对应的 XML 文件实现 crud 逻辑(Mybatis)->service 层->controller 层-> 页面。
分页
通过 Page 类封装分页逻辑:
```java public class Page {
//当前页码,默认1
private int current = 1;
//每页显示上限,默认10
private int limit = 10;
//数据总数
private int rows;
//查询路径,复用分页链接
private String path;
public int getCurrent() {
return current;
}
public void setCurrent(int current) {
if(current >= 1) {
this.current = current;
}
}
public int getLimit() {
return limit;
}
public void setLimit(int limit) {
if(limit >= 1 && limit <= 100) {
this.limit = limit;
}
}
public int getRows() {
return rows;
}
public void setRows(int rows) {
if(rows >= 0) {
this.rows = rows;
}
}
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
/**
* 获取当前页的起始行
* @return
*/
public int getOffset() {
return (current - 1) * limit;
}
/**
* 获取总页数
* @return
*/
public int getTotal() {
if(rows % limit == 0) {
return rows / limit;
} else {
return rows /limit + 1;
}
}
/**
* 获取起始页码
* @return
*/
public int getFrom() {
int from = current - 2;
return Math.max(from, 1);
}
/**
* 获取终止页码
* @return
*/
public int getTo() {
int to = current + 2;
return Math.min(to, getTotal());
}
} ```
前台传来的 current 等参数通过 controller 的 Page 类参数进行封装,从而实现页面跳转。该模块可用于其他地方大量复用。
HomeController 层对于 Page 类的使用:
```java @RequestMapping(value = "/index", method = RequestMethod.GET) public String getIndexPage(Model model, Page page) { page.setRows(discussPostService.findDiscussPostRows(0)); page.setPath("/index");
List<DiscussPost> list = discussPostService.findDiscussPosts(0, page.getOffset(), page.getLimit());
List<Map<String,Object>> discussPosts = new ArrayList<>();
if(list != null) {
for(DiscussPost discussPost : list) {
Map<String,Object> map = new HashMap<>();
map.put("post", discussPost);
map.put("user", userService.findUserById(discussPost.getUserId())); //这里也可以在map层做级联查询调出user数据
discussPosts.add(map);
}
}
model.addAttribute("discussPosts", discussPosts);
//model加page可以省略
return "/index";
}
```
邮箱注册
对前台传来的注册表单数据进行判重判空和数据库匹配后,如果能注册,将用户数据插入数据库:
java
//注册
user.setSalt(NcCommunityUtil.generateUUID().substring(0,5));
user.setPassword(NcCommunityUtil.md5(user.getPassword() + user.getSalt()));
user.setType(0); //普通用户
user.setStatus(0); //还未激活
user.setActivationCode(NcCommunityUtil.generateUUID());
user.setHeaderUrl(String.format("http://images.nowcoder.com/head/%dt.png", new Random().nextInt(1000)));
user.setCreateTime(new Date());
userMapper.insertUser(user);
注意:密码的存储是经过加盐和 md5 加密的,防止密码泄露。数据库存储了该用户的盐和加密的密码。
然后发送激活邮件:
java
//发送激活邮件
Context context = new Context();
context.setVariable("email", user.getEmail());
context.setVariable("url", domain + contextPath + "/activation/" + user.getId() + "/" + user.getActivationCode()); //激活链接
String content = templateEngine.process("/mail/activation", context);
new Thread(new Runnable() {
@Override
public void run() {
mailClient.sendMail(user.getEmail(), "激活账号", content);
}
}).start(); //由于发送邮件太慢,直接交给多线程去处理
mailClient 工具类封装了 JavaMailSender 进行邮箱激活,需要在配置文件中进行邮箱 SMTP 服务的配置。这里用子线程去发送邮件,防止卡顿时间过长。
邮箱里的激活链接即是通过"/activation/{userId}/{code}"访问路径修改用户的 status 字段,使其可用。
登录
登录时检查信息正确性的逻辑和注册时基本一致,需要对账号进行非空、存在和激活的判断,对验证码进行判断,对密码进行非空和正确的判断,以及是否有 rememberMe。登陆成功后生成 LoginTicket 存入数据库,记录了用户 ID、ticket、过期时间等,ticket 字段会被放入 cookie 中。
LoginTicket 登录凭证之所以存入数据库,是考虑到了 session 在分布式环境下请求分发导致的会话状态无法保持的问题。
验证码
使用 Google 提供的 Kaptcha 实现验证码,登录时要检查验证码,逻辑如下:
- 生成图片时存入 session(后面用 Redis 优化):
```java @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);
//图片输出给浏览器,不用关闭流,springmvc会自动做
response.setContentType("image/png");
try {
OutputStream os = response.getOutputStream();
//javax的用于图片输出的工具
ImageIO.write(image,"png",os);
} catch (IOException e) {
logger.error("响应验证码失败:" + e.getMessage());
}
}
``` - 登录时从 session 中取值和表单值进行比对即可。
状态保持
登录后需要进行状态保持,可以用前面提到的登录凭证 +Interceptor+ThreadLocal 实现:
```java public class LoginTicketInterceptor implements HandlerInterceptor {
@Autowired
private UserService userService;
@Autowired
private HostHolder hostHolder;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String ticket = CookieUtil.getValue(request, "ticket");
if(ticket != null) {
LoginTicket loginTicket = userService.findLoginTicket(ticket);
//查询凭证是否有效
if(loginTicket != null && loginTicket.getStatus() == 0 && loginTicket.getExpired().after(new Date())) {
//本次请求中持有该用户
User user = userService.findUserById(loginTicket.getUserId());
hostHolder.setUser(user);
}
}
return true;
}
@Override
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);
}
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
hostHolder.clear();
}
} ```
拦截器在 preHandle 时检查 cookies 中是否有有效 ticket,有的话就在当前请求中持有用户信息。HostHolder 类封装了 ThreadLocal< User >,ThreadLocal 的目的:Tomcat 服务器会使用独立线程去处理每个请求,因此需要隔离多请求多用户,防止信息混乱。
拦截器在 postHandle 时若发现该次请求中有用户信息,需要在 modelAndView 中添加用户信息以保持状态。
在 afterCompletion 清空信息即可。
设置头像
File 文件上传,修改用户的头像链接使其可以通过 url 访问头像图片。图片存放位置可暂存本地,后改为云服务器。
```java @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(".")); //.png等后缀
if(StringUtils.isBlank(suffix)) {
model.addAttribute("error", "文件格式不正确!");
return "/site/setting";
}
//生成随机访问url
filename = NcCommunityUtil.generateUUID() + suffix;
//上传头像
File file = new File(uploadPath + "/" + filename);
try {
headerimage.transferTo(file);
} catch (IOException e) {
logger.error("上传文件失败:" + e.getMessage());
throw new RuntimeException("上传文件失败,服务器异常!", e);
}
//更新headerUrl(web访问路径)
//http://localhost:8080/nccommunity/user/header/xxx.png
User user = hostHolder.getUser();
String headerUrl = domain + contextPath + "/user/header/" + filename;
userService.updateHeader(user.getId(), headerUrl);
return "redirect:/index";
}
```
```java @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(".") + 1);
System.out.println(suffix);
//注意这里只能用传统的文件io方法而不能用验证码的ImageIO.write(image,"png",os);因为第一个参数类型不满足
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());
}
}
```
简单的权限管理
设置页面和修改头像请求显然必须登录才能使用,可以通过注解进行简单的权限管理:
自定义@LoginRequired 注解类,并添加到需要权限的方法上,然后通过拦截器进行判定。在访问当前方法时若有该注解则必须是已登录状态:
```java 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;
}
} ```
发帖评论私信
发帖和评论为论坛核心功能,此外要能支持用户的私信。
敏感词过滤
基于 Trie 树数据结构,该模块可用于帖子、评论、私信等。
```java public class SensitiveFilter {
private static final Logger logger = LoggerFactory.getLogger(SensitiveFilter.class);
//替换符号
private static final String REPLACEMENT = "**";
//根节点
private TrieNode rootNode = new TrieNode();
@PostConstruct
public void init() {
try(
InputStream is = this.getClass().getClassLoader().getResourceAsStream("sensitive-words.txt");//classes类路径
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
) {
String keyword;
while((keyword = reader.readLine()) != null) {
this.addKeyword(keyword);
}
}catch(IOException e) {
logger.error("加载敏感词文件失败:" + e.getMessage());
}
}
/**
* 用于过滤文本的敏感词,被外界所调用
* @param text 待过滤文本
* @return 过滤后的文本
*/
public String filter(String text) {
if(StringUtils.isBlank(text)) {
return null;
}
//指针1,指向树
TrieNode tempNode = rootNode;
//指针2
int begin = 0;
//指针3
int position = 0;
StringBuilder sb = new StringBuilder();
while(position < text.length()) {
char c = text.charAt(position);
//跳过符号
if(isSymbol(c)) {
//若指针1位于根节点(即position == ),说明过滤判断还没开始,因此该符号可以计入结果
if(tempNode == rootNode) {
sb.append(c);
begin++;
}
//position始终跳过该符号
position++;
continue;
}
tempNode = tempNode.getSubnode(c);
//以begin开头的子字符串不是敏感词
if(tempNode == null) {
sb.append(c);
position = ++begin;
tempNode = rootNode;
} else if(tempNode.isKeywordEnd()) {
//发现敏感词
sb.append(REPLACEMENT);
begin = ++position;
tempNode = rootNode;
} else {
//继续检查
position++;
}
}
//最后的几个字符计入
sb.append(text.substring(begin));
return sb.toString();
}
private class TrieNode {
//关键词结束标识
private boolean isKeywordEnd = false;
//子节点
private Map<Character, TrieNode> subnodes = new HashMap<>();
public boolean isKeywordEnd() {
return isKeywordEnd;
}
public void setKeywordEnd(boolean keywordEnd) {
isKeywordEnd = keywordEnd;
}
//添加子节点操作
public void addSubnode(Character c, TrieNode node) {
subnodes.put(c, node);
}
//获取子节点操作
public TrieNode getSubnode(Character c) {
return subnodes.get(c);
}
}
private void addKeyword(String keyword) {
TrieNode trieNode = rootNode;
for(int i = 0; i < keyword.length(); i++){
char c = keyword.charAt(i);
TrieNode subnode = trieNode.getSubnode(c);
if(subnode == null) {
subnode = new TrieNode();
trieNode.addSubnode(c, subnode);
}
trieNode = subnode;
}
//最后别忘了设置结束标志
trieNode.setKeywordEnd(true);
}
private boolean isSymbol(Character c) {
return !CharUtils.isAsciiAlphanumeric(c) && (c < 0x2E80 || c > 0x9FFF); //0x2E80到0x9FFF为东亚文字
}
} ```
发帖
异步发送请求,以通过提示框展示提示信息,发帖后台就是普通的 crud。
```java function publish() { $("#publishModal").modal("hide");
//获取标题和内容
var title = $("#recipient-name").val();
var content = $("#message-text").val();
//发送异步请求
$.post(
CONTEXT_PATH + "/discuss/add",
{"title":title,"content":content},
function (data) {
data = $.parseJSON(data);
console.log(data);
//提示框内展示返回消息
$("#hintBody").text(data.msg);
//显示提示框
$("#hintModal").modal("show");
//2s后,隐藏
setTimeout(function(){
$("#hintModal").modal("hide");
//刷新页面
if(data.code == 0) {
window.location.reload();
}
}, 2000);
}
)
} ```
评论
评论分为对帖子的评论(简称评论)和对评论的评论(简称回复)。
评论的 entity 如下:
```java public class Comment {
private int id;
private int userId;
private int entityType;
private int entityId;
private int targetId;
private String content;
private int status;
private Date createTime;
...
} ```
entityType+entityId 指示评论的对象(是帖子还是评论,然后具体 Id 为多少),当 entityType 为评论,且该条评论为回复时有效,为 0 表示该条回复评论的是 对帖子的评论 ,非 0 则是代表该条回复评论的是 回复 ,具体如图:
另外注意,帖子 entity 中有个关于评论的冗余数据,因此有新评论产生时需要通过事务进行更新:
```java @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());
discussPostMapper.updateCommentCount(comment.getEntityId(), count);
}
return rows;
}
```
私信
私信 entity 如下:
```java public class Message {
private int id;
private int fromId;
private int toId;
private String conversationId;
private String content;
private int status;
private Date createTime;
...
} ```
其中 conversationId 由 fromId 和 toId 拼接而成,小 Id 在前,如 111_112,表示 111 和 112 之间的私信。status 记录私信是否已读,当用户进入私信详情页面时,会更新未读私信状态为已读。
```java
@RequestMapping(path = "/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
//设置已读
List<Integer> ids = getLetterIds(letterList);
if(!ids.isEmpty()) {
messageService.readMessage(ids);
}
return "/site/letter-detail";
}
private User getLetterTarget(String conversationId) {
String[] ids = conversationId.split("_");
int d0 = Integer.parseInt(ids[0]);
int d1 = Integer.parseInt(ids[1]);
if(hostHolder.getUser().getId() == d0) {
return userService.findUserById(d1);
} else return userService.findUserById(d0);
}
/**
* 获取当前用户当前会话未读消息的id
* @param letterList
* @return
*/
private List<Integer> getLetterIds(List<Message> letterList) {
List<Integer> ids = new ArrayList<>();
if(letterList != null) {
for(Message message : letterList) {
if(hostHolder.getUser().getId() == message.getToId() && message.getStatus() == 0) {
ids.add(message.getId());
}
}
}
return ids;
}
```
统一异常处理
JavaWeb 的思想是异常尽量不处理,而是往上层抛给 controller 去处理。Spring 对此提供了简单支持,若有 4xx 或 5xx 异常,则返回的页面为/error 包下对应的 4xx.html 或 5xx.html。但我们有如下需求:
- 记录错误日志
- 对于异步/非异步提供友好提示
因此我们可以用@ControllerAdvice 注解实现异常处理:
```java //统一异常处理,1.记录日志 2.对于异步/非异步请求给予友好提示 //如果无上述需求,可以用spring自带的异常处理(error包和404.html/500.html) @ControllerAdvice(annotations = Controller.class) public class ExceptionAdvice {
private static final 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());
}
String xRequestedWith = request.getHeader("x-requested-with");
//如果是异步请求,返回普通字符串
if("XMLHttpRequest".equals(xRequestedWith)) {
response.setContentType("application/plain;charset=utf-8");
PrintWriter writer = response.getWriter();
writer.write(NcCommunityUtil.getJSONString(1, "服务器异常"));
} else {
//非异步请求
response.sendRedirect(request.getContextPath() + "/error");
}
}
} ```
统一记录日志
我们需要知道哪些用户什么时候对什么方法进行了访问,因此最好能提供日志记录,一个解决思路是对于每个方法都进行硬编码记录日志,但这种思路显然违背了开闭原则,不利于扩展和维护。因此我们可以用 AOP 的思想解决问题,Spring 对此提供了友好支持:
```java @Component @Aspect public class ServiceLogAspect {
private static final Logger logger = LoggerFactory.getLogger(ServiceLogAspect.class);
//任意返回值,service包的任意xxx类的任意方法(任意参数)
@Pointcut("execution(* cn.codingcrea.nccommunity.service.*.*(..))")
public void pointcut() {
}
@Before("pointcut()")
public void before(JoinPoint joinPoint) {
//用户[1.2.3.4],在[xxx],访问了[xxx.service.xxx.xxx()].
//由于不能用request,只能用下面的方法
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = requestAttributes.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));
}
} ```
切入时机可以是 pointcut 调用前(@Before),pointcut 调用后(@After),环绕 pointcut(@Around),return 后(@AfterReturning),异常后(@AfterThrowing)。这里只需@Before 即可。
点赞关注
点赞关注使用较频繁,是通过 Redis 实现的。我们在配置类中将 RedisTemplate
点赞
点赞用 AJAX 实现,可对帖子也可对评论点赞。根据返回的点赞状态和点赞数量进行正确的局部更新。Service 方法如下:
```java @Autowired private RedisTemplate redisTemplate;
//点赞
//entityUserId即被点赞的实体的作者,本来可以通过entityId找,但点赞操作本来是redis操作,这样查会调数据库拉低性能,因此干脆直接作为参数传进来
public void like(int userId, int entityType, int entityId, int entityUserId) {
// String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId); // Boolean isMember = redisTemplate.opsForSet().isMember(entityLikeKey, userId); // if(isMember) { // redisTemplate.opsForSet().remove(entityLikeKey, userId); // } else { // redisTemplate.opsForSet().add(entityLikeKey, userId); // }
redisTemplate.execute(new SessionCallback() {
@Override
public Object execute(RedisOperations redisOperations) throws DataAccessException {
String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);
String userLikeKey = RedisKeyUtil.getUserLikeKey(entityUserId);
Boolean isMember = redisOperations.opsForSet().isMember(entityLikeKey, userId);
redisOperations.multi();
if(isMember) {
redisOperations.opsForSet().remove(entityLikeKey, userId);
redisOperations.opsForValue().decrement(userLikeKey);
} else {
redisOperations.opsForSet().add(entityLikeKey, userId);
redisOperations.opsForValue().increment(userLikeKey);
}
return redisOperations.exec();
}
});
}
```
其中 RedisKeyUtil 是一个构造 Redis key 的工具类,生成的 key 是由各个字段用冒号隔开的(Redis 的惯用 key 命名方式)。
我们通过 set 数据结构记录某个实体(如帖子)的点赞用户 Id,此外用一个 string 数据结构记录某个用户获得的赞总数:
这两个数据结构的操作需要用到 Redis 的事务一并实现。
关注
关注的对象可以是用户、帖子、评论。和点赞的区别在于:key 中需要包含关注者和被关注者这两个变量。
Service 层定义 follow 和 unfollow 等方法,试举一例:
```java public void follow(int userId, int entityType, int entityId) { redisTemplate.execute(new SessionCallback() { @Override public Object execute(RedisOperations redisOperations) throws DataAccessException { String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType); String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId);
redisOperations.multi();
redisOperations.opsForZSet().add(followeeKey, entityId, System.currentTimeMillis());
redisOperations.opsForZSet().add(followerKey, userId, System.currentTimeMillis());
return redisOperations.exec();
}
});
}
```
这里要用 zset 数据结构,在进行关注的人/粉丝列表显示的时候,可以根据关注时间进行排序显示。
你可以查看别人的关注列表,并对列表中的用户进行关注。
```java //查询某用户关注的人 public List
List<Map<String,Object>> list = new ArrayList<>();
for(Integer targetId : targetIds) {
Map<String, Object> map = new HashMap<>();
User user = userService.findUserById(targetId);
map.put("user", user);
Double score = redisTemplate.opsForZSet().score(followeeKey, targetId);
map.put("followTime", new Date(score.longValue()));
list.add(map);
}
return list;
}
```
缓存优化
下面用 Redis 缓存进行了三处地方的性能优化。
代替 session 存储验证码
理由如下:
- 验证码可能会频繁访问和刷新
- 验证码只需要暂存,不需要长期保存
- 如未来涉及到分布式部署,能避免 session 共享的问题。
存验证码("/kaptcha"请求):
```java //验证码存入session // session.setAttribute("kaptcha", text);
//验证码存入redis
String kaptchaOwner = NcCommunityUtil.generateUUID();
Cookie cookie = new Cookie("kaptchaOwner", kaptchaOwner);
cookie.setMaxAge(60);
cookie.setPath(contextPath);
response.addCookie(cookie);
String redisKey = RedisKeyUtil.getKaptchaKey(kaptchaOwner);
redisTemplate.opsForValue().set(redisKey, text, 60, TimeUnit.SECONDS);
```
登录表单提交时取验证码:
```java @RequestMapping(path = "/login",method = RequestMethod.POST) public String login(String username, String password, String code, boolean rememberme, Model model, / HttpSession session, / HttpServletResponse response, @CookieValue("kaptchaOwner") String kaptchaOwner) {
//验证码
// String kaptcha = (String) session.getAttribute("kaptcha"); String kaptcha = null; if(StringUtils.isNotBlank(kaptchaOwner)) { String redisKey = RedisKeyUtil.getKaptchaKey(kaptchaOwner); kaptcha = (String)redisTemplate.opsForValue().get(redisKey); } ... } ```
存储登录凭证
每次对网站的请求都会通过拦截器获取登录凭证,因此考虑到凭证的时效性和经常性,可以改为用 Redis 存储凭证而不是用数据库存储。
存储用户信息
同上,每次对网站的请求都会通过拦截器获取登录凭证,然后再获取用户信息以保持登录状态,因此对于 findUserById 这个方法,有必要做 Redis 缓存:
```java @Component public class LoginTicketInterceptor implements HandlerInterceptor {
@Autowired
private UserService userService;
@Autowired
private HostHolder hostHolder;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String ticket = CookieUtil.getValue(request, "ticket");
if(ticket != null) {
LoginTicket loginTicket = userService. findLoginTicket(ticket);
//查询凭证是否有效
if(loginTicket != null && loginTicket.getStatus() == 0 && loginTicket.getExpired().after(new Date())) {
//本次请求中持有该用户
User user = userService.findUserById(loginTicket.getUserId());
hostHolder.setUser(user);
}
}
return true;
}
...
} ```
这样,整个拦截器所调用的方法就不会涉及到数据库了。
但用户信息的存储会涉及到 Redis 和 MySQL 的缓存不一致问题,需要解决:
```java //1.优先从缓存中取值 private User getCache(int userId) { String redisKey = RedisKeyUtil.getUserKey(userId); return (User)redisTemplate.opsForValue().get(redisKey); }
//2.取不到时初始化缓存数据
private User initCache(int userId) {
User user = userMapper.selectById(userId);
String redisKey = RedisKeyUtil.getUserKey(userId);
redisTemplate.opsForValue().set(redisKey, user, 3600, TimeUnit.SECONDS);
return user;
}
//3.数据变更时清除缓存信息
private void clearCache(int userId) {
String redisKey = RedisKeyUtil.getUserKey(userId);
redisTemplate.delete(redisKey);
}
//redis、数据库不一致问题https://developer.aliyun.com/article/712285
//由于该项目涉及到的主要是user信息,所以很难涉及到对同一行的并发访问,可以不采用延时双删等策略
//重试机制?
//这篇比较完善,以后再看:https://www.cnblogs.com/dingpeng9055/p/11562261.html
```
关于缓存不一致问题,有很多解决方法,这里采用的是:当数据变更时,先更新数据库,再删除缓存。
当然无论是先更新数据库还是先删除缓存,都会有并发访问情况下的不一致问题和第二步操作失败的问题。
前一个问题可以采用延迟双删策略来解决。后一个问题可以用重试机制来解决。详细可以参考博文。
```java
public User findUserById(int id) { // return userMapper.selectById(id); User user = getCache(id); if(user == null) { user = initCache(id); } return user; } ... //用户信息变更时 public int updateHeader(int userId, String headerUrl) { int i = userMapper.updateHeaderUrl(userId, headerUrl); clearCache(userId); return i; } ```
系统通知
用 Kafka 做消息队列也能对系统进行优化。
原先 Controller 层的一些实现逻辑,可以转移到 EventConsumer 类中实现,享有消息队列异步削峰解耦的优势。例如系统通知的实现,本身和点赞、关注、评论的逻辑关联不强,且这些动作频繁发生,因此可以通过异步实现,提高性能。在例如后面用到的 ES 数据库的更新也可以用消息队列来实现。
比如点赞时:
```java
@RequestMapping(path = "/like",method = RequestMethod.POST)
@ResponseBody
public String like(int entityType, int entityId, int entityUserId, int postId) {
User user = hostHolder.getUser();
likeService.like(user.getId(), entityType, entityId, entityUserId);
//数量
long likeCount = likeService.findEntityLikeCount(entityType, entityId);
//状态
int likeStatus = likeService.findEntityLikeStatus(user.getId(), entityType, entityId);
//返回结果
Map
//触发点赞事件
if(likeStatus == 1) {
Event event = new Event()
.setTopic(TOPIC_LIKE)
.setUserId(hostHolder.getUser().getId())
.setEntityType(entityType)
.setEntityId(entityId)
.setEntityUserId(entityUserId)
.setData("postId", postId);
eventProducer.fireEvent(event);
}
return NcCommunityUtil.getJSONString(0, null, map);
}
```
生产者:
```java @Component public class EventProducer {
@Autowired
private KafkaTemplate kafkaTemplate;
//处理事件
public void fireEvent(Event event) {
//发布消息到指定topic
kafkaTemplate.send(event.getTopic(), JSONObject.toJSONString(event));
}
} ```
消费者:
```java @Component public class EventConsumer implements CommunityConstant {
private static final Logger logger = LoggerFactory.getLogger(EventConsumer.class);
@Autowired
private MessageService messageService;
@KafkaListener(topics = {TOPIC_COMMENT,TOPIC_LIKE,TOPIC_FOLLOW})
public void handleCommentMessage(ConsumerRecord record) {
if(record == null || record.value() == null) {
logger.error("消息为空!");
return;
}
Event event = JSONObject.parseObject(record.value().toString(), Event.class);
if(event == null) {
logger.error("消息的格式不对!");
return;
}
Message message = new Message();
message.setFromId(SYSTEM_USER_ID);
message.setToId(event.getEntityUserId());
message.setConversationId(event.getTopic());
message.setCreateTime(new Date());
Map<String, Object> content = new HashMap<>();
content.put("userId", event.getUserId());
content.put("entityType", event.getEntityType());
content.put("entityId",event.getEntityId());
if(!event.getData().isEmpty()) {
for(Map.Entry<String, Object> entry : event.getData().entrySet()) {
content.put(entry.getKey(), entry.getValue());
}
}
message.setContent(JSONObject.toJSONString(content));
messageService.addMessage(message);
}
} ```
至于系统消息的查看、列表等实现,则基本与私信的实现差不多。(数据库中 from_id 为 1 表示这不是普通私信而是系统通知)
搜索
Elasticsearch 相当于特殊的数据库,搜索就是对这个数据库的搜索。
ElasticsearchService 需要完成三个方法:save、delete 和 search,重点是 search:
```java
public Map
NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
.withQuery(QueryBuilders.multiMatchQuery(keyword, "title", "content"))
.withSort(SortBuilders.fieldSort("type").order(SortOrder.DESC))
.withSort(SortBuilders.fieldSort("score").order(SortOrder.DESC))
.withSort(SortBuilders.fieldSort("createTime").order(SortOrder.DESC))
.withPageable(PageRequest.of(current, limit))
.withHighlightFields(
new HighlightBuilder.Field("title").preTags("<em>").postTags("</em>"),
new HighlightBuilder.Field("content").preTags("<em>").postTags("</em>")
).build();
SearchHits<DiscussPost> search = elasticsearchRestTemplate.search(searchQuery, DiscussPost.class);
// 得到查询结果返回的内容
List<SearchHit<DiscussPost>> searchHits = search.getSearchHits();
// 设置一个需要返回的实体类集合
List<DiscussPost> discussPosts = new ArrayList<>();
for(SearchHit<DiscussPost> searchHit : searchHits){
// 高亮的内容
Map<String, List<String>> highLightFields = searchHit.getHighlightFields();
// 将高亮的内容填充到content中
searchHit.getContent().setTitle(highLightFields.get("title") == null ? searchHit.getContent().getTitle() : highLightFields.get("title").get(0));
searchHit.getContent().setContent(highLightFields.get("content") == null ? searchHit.getContent().getContent() : highLightFields.get("content").get(0));
// 放到实体类中
discussPosts.add(searchHit.getContent());
}
long totalCount = elasticsearchRestTemplate.count(searchQuery, DiscussPost.class);
result.put("discussPosts", discussPosts);
result.put("totalCount", totalCount);
return result;
}
```
其他
认证授权
废弃了之前采用拦截器实现的登录检查,使用 Spring Security 框架来进行统一认证授权管理。Security 底层原理蛮复杂的,我们这里对其进行简单的使用。
授权方面,见 Security 配置类:
```java @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter implements CommunityConstant {
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/resources/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
//授权
http.authorizeRequests()
.antMatchers(
"/user/setting",
...
)
.hasAnyAuthority(
AUTHORITY_USER,
AUTHORITY_ADMIN,
AUTHORITY_MODERATOR)
.antMatchers(
"/discuss/top",
"/discuss/wonderful"
)
.hasAnyAuthority(
AUTHORITY_MODERATOR
)
.antMatchers(
"/discuss/delete"
)
.hasAnyAuthority(
AUTHORITY_ADMIN
)
.anyRequest().permitAll()
.and().csrf().disable();
//权限不够时的处理
http.exceptionHandling()
//未登录时
.authenticationEntryPoint(new AuthenticationEntryPoint() {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
...
}
})
//权限不足
.accessDeniedHandler(new AccessDeniedHandler() {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
...
}
});
//跳过(覆盖默认)logout功能,让自己的logout逻辑能执行
http.logout().logoutUrl("/xxxxxx");
}
//另外,用户认证逻辑采用我自己的,绕过spring security,但是认证信息我们仍要想办法存到SecurityContext里
} ```
而用户认证方面,由于我们采用自定义的认证方式,因此无需采用 Security 提供的方式,但我们认证信息仍需要存到 SecurityContext 里,拦截器需要改进:
```java @Component public class LoginTicketInterceptor implements HandlerInterceptor {
@Autowired
private UserService userService;
@Autowired
private HostHolder hostHolder;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String ticket = CookieUtil.getValue(request, "ticket");
if(ticket != null) {
LoginTicket loginTicket = userService. findLoginTicket(ticket);
//查询凭证是否有效
if(loginTicket != null && loginTicket.getStatus() == 0 && loginTicket.getExpired().after(new Date())) {
//本次请求中持有该用户
User user = userService.findUserById(loginTicket.getUserId());
hostHolder.setUser(user);
//构建用户认证结果,并存入SecurityContext,以便于Security获取
Authentication authentication = new UsernamePasswordAuthenticationToken(user, user.getPassword(),
userService.getAuthorities(user.getId()));//自定义方法
SecurityContextHolder.setContext(new SecurityContextImpl(authentication));
}
}
return true;
}
@Override
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);
}
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
if(hostHolder.getUser()==null) {
SecurityContextHolder.clearContext();
}
hostHolder.clear();
}
} ```
认证信息会在 preHandle 加入,afterafterCompletion 处删除,logout 时也会删除。
关于认证信息何时删除的思考:
- 每次在 afterCompletion 时删除:
Security 是基于 Filter 的,如果每次在 afterCompletion 时删除,那么下次请求时首先到达 Filter,由于没有认证信息,会被判定权限不够,直接跳转到登录页面,即使已经登录。 - 在 afterCompletion 时不删除,只在 logout 时删除:
如果这样虽然能保证权限与请求匹配,但是由于登录凭证会过期,用户信息会被清除,但认证信息却不会被清除,用户即使没登录也能访问,显然不合常理。 - 因此正确做法为 afterafterCompletion 处如果没有用户信息就删除认证信息,logout 时也删除。
置顶加精删除
Thymeleaf 有对 Security 的支持,可以从 SecurityContext 从获得权限信息。从而赋予用户不同的权限(置顶、加精、删除)。
网站统计
UV 独立访客统计
使用 Redis 的 HyperLogLog 数据结构去实现,该数据结构的特点是占用内存很小,但会损失一定的统计精度。
使用拦截器,游客每次访问时 Redis 计入该 ip。
统计区间 UV:
```java public long calculateUV(Date start, Date end) { if(start == null || end == null) { throw new IllegalArgumentException("参数不能为空!"); }
//整理日期范围内的key
List<String> keyList = new ArrayList<>();
Calendar calendar = Calendar.getInstance();
calendar.setTime(start);
while(!calendar.getTime().after(end)) {
String key = RedisKeyUtil.getUVKey(sdf.format(calendar.getTime()));
keyList.add(key);
calendar.add(Calendar.DATE, 1);
}
//合并数据(union去重生成新数据)
String redisKey = RedisKeyUtil.getUVKey(sdf.format(start), sdf.format(end));
redisTemplate.opsForHyperLogLog().union(redisKey, keyList.toArray());
//返回统计结果
return redisTemplate.opsForHyperLogLog().size(redisKey);
}
```
DAU 日活统计
类似 UV 统计,只是 DAU 统计操作的是 bitmap:
```java //统计区间内的DAU public long calculateDAU(Date start, Date end) { if(start == null || end == null) { throw new IllegalArgumentException("参数不能为空!"); }
//整理日期范围内的key
List<byte[]> keyList = new ArrayList<>();
Calendar calendar = Calendar.getInstance();
calendar.setTime(start);
while(!calendar.getTime().after(end)) {
String key = RedisKeyUtil.getDAUKey(sdf.format(calendar.getTime()));
keyList.add(key.getBytes());
calendar.add(Calendar.DATE, 1);
}
//or运算
return(long) redisTemplate.execute(new RedisCallback() {
@Override
public Object doInRedis(RedisConnection connection) throws DataAccessException {
String redisKey = RedisKeyUtil.getDAUKey(sdf.format(start), sdf.format(end));
connection.bitOp(RedisStringCommands.BitOperation.OR, redisKey.getBytes(),
keyList.toArray(new byte[0][0]));
return connection.bitCount(redisKey.getBytes());
}
});
}
```
热帖排行
使用 Quartz 实现热帖排行和更新,相比 JDK 的 ScheduledExecutorService 和 Spring 的 ThreadPoolTaskScheduler 的优势:
- Quartz 实现定时任务所依赖的参数是保存在数据库中,数据库只有一份,所以不会冲突。
- 而 ScheduledExecutorService 和 ThreadPoolTaskScheduler 是基于内存的,在分布式环境中,多台服务器会重复执行定时任务,产生冲突。
热帖排行和更新的实现逻辑如下:
- 帖子的 score 字段计算方法自定义为 log(精华分 + 评论数 * 10+ 点赞数 * 2)+(发布时间-纪元)。
- 一旦涉及到上述 score 会变化的操作,如帖子被设为精华,或帖子有新的评论等,帖子 id 会被放入 Redis 的 set 中。
- 每隔一段时间执行定时任务,会从 set 中 pop 帖子 id 出来进行分数的刷新。
```java private void refresh(int postId) { DiscussPost post = discussPostService.findDiscussPostById(postId);
//算分的时候发现被管理员删了
if(post == null) {
logger.error("待刷新帖子不存在: id = " + postId);
return;
}
//是否精华
boolean wonderful = post.getStatus() == 1;
//评论数量
int commentCount = post.getCommentCount();
//点赞数量
long likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST, postId);
//计算权重
double w = (wonderful ? 75 : 0) + commentCount * 10 + likeCount * 2;
//分数 = 权重 + 距离天数
double score =
Math.log10(Math.max(w, 1)) + (post.getCreateTime().getTime() - epoch.getTime()) / (1000 * 3600 * 24);
//更新帖子分数
discussPostService.updateScore(postId, score);
//同步搜索数据
post.setScore(score);
elasticSearchService.saveDiscussPost(post);
}
```
热帖页面根据帖子的 score 进行排序。
头像上传云服务器
头像上传到阿里云 OSS 进行存储,Spring 对此提供了支持。
本地缓存
在实际的部署中,服务器缓存由于就在服务器本机内,因此对性能的提升相比 Redis 更高。
对于热帖等与用户状态无关的内容可以存到本地缓存中。本项目用 Caffeine 实现,主要缓存帖子列表和帖子总数。
需要定义两个缓存管理器,一个针对热帖列表,一个针对帖子总数,可以通过@PostConstruct 进行初始化。
```java
//帖子列表缓存
private LoadingCache
String[] params = key.split(":");
if(params == null || params.length != 2) {
throw new IllegalArgumentException("参数错误!");
}
int offset = Integer.valueOf(params[0]);
int limit = Integer.valueOf(params[1]);
//这里可以进行二次缓存,后实现
logger.debug("从数据库中查找热帖列表数据。");
return discussPostMapper.selectDiscussPosts(0, offset, limit, 1);
}
});
}
...
public List
logger.debug("从数据库中查找热帖列表数据。");
return discussPostMapper.selectDiscussPosts(userId, offset, limit, orderMode);
}
```
用 JMeter 做下小测试,30w 数据的情况下,性能有数倍到数十倍的提升。
服务器部署
需要修改一些路径以适配 Linux 环境。为方便切换,可以使用两套配置文件。
整个项目部署到阿里云 ECS 上(2cpu/4g/CentOS),部署步骤为:
- Java 运行环境安装。
- Maven 安装,把项目发到云服务器再进行 mvn package,可以节省传输流量,因为 package 后的包特别大。
- Tomcat 安装。
- 安装所需组件,包括 MySQL、Redis、Kafka、Elasticsearch。
- 将本地项目 clean 后压缩发送到服务器。
- 项目解压后使用命令 mvn package -Dmaven.test.skip=true 打包,将生成的 war 包放在 Tomcat 的 webapps 文件夹中,部署成功。
参考文献
- 基于.NET架构的商业网站设计与实现(山东大学·张超)
- 基于SOA架构的社区管理系统的设计与实现(电子科技大学·任利群)
- 基于JavaEE的ITPUB软件开发技术论坛的设计与实现(吉林大学·孙睿)
- 基于互联网应用服务的SNS设计与开发(电子科技大学·徐小茹)
- 基于Web技术的校园论坛设计与实现(内蒙古科技大学·李鹏飞)
- 基于JavaEE的ITPUB软件开发技术论坛的设计与实现(吉林大学·孙睿)
- 基于SOA架构的社区管理系统的设计与实现(电子科技大学·任利群)
- 印刷公司内容管理平台的设计与实现(吉林大学·郎彩虹)
- 一个通用论坛系统的设计与实现(山东大学·张正)
- 印刷公司内容管理平台的设计与实现(吉林大学·郎彩虹)
- 基于SSH架构的个人空间交友网站的设计与实现(北京邮电大学·隋昕航)
- 基于SSH框架的跑步社区网站的设计与实现(东北大学·艾宇雷)
- 基于Web技术的校园论坛设计与实现(内蒙古科技大学·李鹏飞)
- 基于SSH框架的跑步社区网站的设计与实现(东北大学·艾宇雷)
- 公司管理中BBS信息系统的设计与实现(电子科技大学·赵正刚)
本文内容包括但不限于文字、数据、图表及超链接等)均来源于该信息及资料的相关主题。发布者:源码港湾 ,原文地址:https://bishedaima.com/yuanma/35890.html