目录
1. 项目实现功能
- 用户注册、登录
- 资讯发布、资讯排序、咨询分类
- 图片上传及管理
- 资讯首页分页展示
- 用户评论、用户点赞、用户点踩
- 邮件通知
- 站内信通知
2. 项目整体架构
2.1 架构及内容
- 项目是B/S(Browser/Server,浏览器/服务器架构),使用SpringBoot
- 数据库:Mysql
- 持久化框架:Mybatis
- 缓存:Redis
- 模版: velocity
- 项目构建:maven
- Controller:解析web请求,调用相应的Service方法处理,并返回ModelAndView或者Json格式结果;
- Service:业务层,包含相应的业务逻辑,调用DAO层相应的方法来操纵数据库;
- DAO(data access object):数据处理层,通过Mybatis框架,处理Mysql中相应的数据操作;
- model:项目中的实体类:News、User、Message、Comment、LoginTicket(起token的作用,注册登录的用户会发一个ticket,ticket过期/失效后,重新登录后,会重新发送一个ticket)、HostHolder(登录用户且ticket有效时,绑定一个hostHolder,将该请求与对应的user绑定,当该请求响应返回ModelAndView时,将user的信息添加到ModelAndView中)
- util:工具类。JedisAdapter:封装了redis一些操作(list、set相关的操作);RedisKeyUtil:返回redis中对应的key(如:like的key、dislike的key);MailSender:封装邮件相关的操作;ToutiaoUtil:封装了一些基本的操作(如:json的操作、文件加密等操作)
- interceptor:拦截器,拦截所有请求,判断用户登录状态,如果已登录且ticket未失效,则绑定hostholder;拦截用户消息列表页、详情页的请求(/msg/*),如果未登录,则重定向到提示登录的页面;
- configuration:配置类,实现了WebMvcConfigurer接口,添加拦截器;
- async:异步操作的包;
2.2 功能:
- 登录、注册、登出;
- 资讯首页:
- 每页展示20条资讯,分页展示;
- 每条资讯显示数据:like数量(用户赞的状态)、图片、标题、链接、评论数、发布者信息(头像、姓名)
-
- 资讯详情页:
- 资讯的详情数据:like数量(用户赞的状态)、图片、标题、链接、评论数、发布者信息(头像、姓名)、评论详情;
- 可以评论该资讯;
-
- 站内信列表页
- 用户发布的资讯收到的相关信息(赞、踩、评论)
- 显示未读信息的数量
-
- 站内信详情页
- 显示具体的站内信
-
数据库中表:
- user
- news
- comment
- message
- login_ticket
2.3 业务逻辑
2.3.1 注册、登录、登出
统一的数据格式:{code:0,msg:”,data:”} :使用fastJson返回Json格式的结果{code:"xx",message:"xxxxxx"}; controller方法使用@ResponseBody注解
注册用户;传参:username、password、remberme
- 用户名合法性检测(长度,敏感词,重复,特殊字符)
- 密码长度要求
- 是否已注册
- 随机生成salt,使用MD5对password+salt进行加密;添加user到数据库中。
- 添加LoginTicket
- 将ticket添加到response的cookie中;如果设置了remberme,则设定一个cookie过期时间;不设定cookie过期时间,则当前窗口关闭,该cookie就会过期。
用户登录;
- 用户名是否存在、是否为空;
- 密码是否正确、是否为空;
- 正确的用户名密码,则添加LoginTicket;
- 将ticket添加到response的cookie中;如果设置了remberme,则设定一个cookie过期时间;不设定cookie过期时间,则当前窗口关闭,该cookie就会过期。
用户登出/logout/
设置ticket状态为1(失效);
2.3.2 拦截器
PassportInterceptor拦截器,它对于所有页面都进行处理
1. 可以知道用户是哪个用户。
注册成功后会进行自动登陆,对于登陆,在登陆操作中,在service层,进行逻辑判断,对上返回状态回到controller,对下dao去和数据库交互。在service登陆代码中,服务器会生成一个string类型的ticket,存入cookie中,key值是ticket,value值是ticket的值。通过response下发到浏览器。
下次在已经登陆的用户,进行其他点击后。在进入controller前,调用preHandle方法处理,它可以检查客户端提交的cookie中是否有服务器之前下发的ticket,如果有证明这个请求是已经登陆的用户了。把登陆的用户放到线程本地变量。在此时才进入controller,可以拿到具体的用户HostHolder类,这是线程本地变量,可以根据登陆的用户进行个性化渲染,比如关注用户的动态,个人收藏等。
二、可以进行权限管理
某些页面需要用户已登录(比如:站内信页面)或者用户是某个等级的。可以现在preHandler中判断,这个可以在设置一个特定范围的拦截器,如下的拦截器LoginRequiredInterceptor,在访问setting页面时才会进入到该拦截器,如果验证没有HostHolder,说明用户没登陆(没有hostholder说明该用户未登录,或ticket已过期/已失效),就跳转到主页或者给出登陆页面。拦截器也先后执行顺序。在配置类中ToutiaoWebConfiguration中先后决定。
三、可以自动登陆
用户请求中cookie包含的sessionId(就是ticket),服务端的拦截器判断该sessionId是否和服务器的ticket关联起来,如果关联,且ticket未过期、未失效时,那么就会知道该请求对应的是哪一个用户,可以在ModelAndView中添加相应的user,可以做到自动登录的功能。
2.3.3 图片上传
MultipartFile接口
一、post方法上传图片到服务器本地,返回访问图片的url
public String saveImage(MultipartFile file) throws IOException {
int dotPos = file.getOriginalFilename().lastIndexOf(".");
if (dotPos < 0) {
return null;
}
String fileExt = file.getOriginalFilename().substring(dotPos + 1).toLowerCase();
if (!ToutiaoUtil.isFileAllowed(fileExt)) {
return null;
}
String fileName = UUID.randomUUID().toString().replaceAll("-", "") + "." + fileExt;
Files.copy(file.getInputStream(), new File(ToutiaoUtil.IMAGE_DIR + fileName).toPath(),
StandardCopyOption.REPLACE_EXISTING);
return ToutiaoUtil.TOUTIAO_DOMAIN + "image?name=" + fileName;
}
图片访问:
通过图片的url,服务器使用StreamUtils.copy()将对应的图片二进制流,copy到response的输出流中。
@RequestMapping(path = {"/image"}, method = {RequestMethod.GET})
@ResponseBody
public void getImage(@RequestParam("name") String imageName,
HttpServletResponse response) {
try {
response.setContentType("image/jpeg");
StreamUtils.copy(new FileInputStream(new
File(ToutiaoUtil.IMAGE_DIR + imageName)), response.getOutputStream());
} catch (Exception e) {
logger.error("读取图片错误" + imageName + e.getMessage());
}
}
二、上传到七牛云,做云存储
优势:图片做单独的服务器
cdn内容分发网络,内容分发到各个节点,能够更快的访问静态文件。
云可以做实时缩图和实时切图。
七牛云上传图片直接写成一个service,如果要更换云服务商,如阿里云,可以直接改service就行。
2.3.4 发布news和comment
/user/addNews/:用户发布news
如果用户为登录用户(hostHolder.getUser()!=null),则该news设定为对应的用户,;
如果用户为未登录用户,则设定为匿名用户(news.setUserId(3);)
/news/{newsId}:newsid对应的news详情页;
1. news内容:获取该newsId对应的news内容;
2. 赞的状态:用户为登录用户,获取用户对该news的赞状态;
3. 评论:获取该news对应的评论;
/addComment:添加newsId对应的一个comment;
1. 添加comment;
2. 更新news相关的评论数目;
2.3.5 Redis实现点赞点踩
使用redis的set数据结构实现;
好处:Redis性能好,快,并发高;在点赞过后要立即刷新显示在页面,所以推荐使用Redis。
RedisUtil类中方法:
- getLikeKey(int entityId, int entityType):获取like对应redis中的key,如:LIKE:1:1234(1代表news业务,1234代表的是指定的newsId);
- getDisLikeKey(int entityId, int entityType):获取disike对应redis中的key,如:DISLIKE:1:1234
思路:
- 点赞:将当前用户userid作为value,存入到对应like集合当中,同时判断点dislike集合中是否有此id值,有的话就移除;
- 点踩:与上面操作相反。
- 查看用户点赞点踩状态:通过like和dislike对应的key,以及userid,查找userid是否在对应的集合中;
2.3.6 异步处理
消息队列MQ主要是用来:
- 解耦应用、
- 异步化消息
- 流量削峰填谷
目前使用的较多的有ActiveMQ、RabbitMQ、ZeroMQ、Kafka、MetaMQ、RocketMQ等。
Redis不仅可作为缓存服务器,还可用作消息队列。它的list类型天生支持用作消息队列。list是使用双向链表实现的,保存了头尾节点,所以在列表头尾两边插取元素都是非常快的。
为什么要用Redis实现轻量级MQ?
在业务的实现过程中,就算没有大量的流量,解耦和异步化几乎也是处处可用,此时MQ就显得尤为重要。但与此同时MQ也是一个蛮重的组件,例如我们如果用RabbitMQ就必须为它搭建一个服务器,同时如果要考虑可用性,就要为服务端建立一个集群,而且在生产如果有问题也需要查找功能。在中小型业务的开发过程中,可能业务的其他整个实现都没这个重。过重的组件服务会成倍增加工作量。
所幸的是,Redis提供的list数据结构非常适合做消息队列。
Fastjson 是一个 Java 库,可以将 Java 对象转换为 JSON 格式,当然它也可以将 JSON 字符串转换为 Java 对象。调用toJSONString方 法即可将对象转换成 JSON 字符串,parseObject 方法则反过来将 JSON 字符串转换成对象
- EventModule类:代表每个事件具体的信息;
- EventProducer类:生产者,生成一个EventModule对象,将该对象存储在Redis list对应的key中,等待Event Consumer消费;
- EventConsumer类:消费者,启动一个线程,从list中读取EventModule对象,然后找到对应的EventHandle对象,处理对应的事件。
- EventHandle接口:处理不同事件,有不同的handle实现;
- LikeHandle:对应处理EventType为LIKE的事件;
- CommentHandle:对应处理EventType为COMMENT的事件;
- MailHandle:对应处理EventType为MAIL的事件;
EventProducer:生产方
@Service
public class EventProducer {
@Autowired
JedisAdapter jedisAdapter;
public boolean fireEvent(EventModel model) {
try {
String json = JSONObject.toJSONString(model);
String key = RedisKeyUtil.getEventQueueKey();
jedisAdapter.lpush(key, json);
return true;
} catch (Exception e) {
return false;
}
}
}
EventConsumer类:消费者
- 获取不同的EventHandler的实现类,如LikeHandler、CommentHandler、MailHandler等;
Map<String, EventHandler> beans = applicationContext.getBeansOfType(EventHandler.class);
2. 根据EventHandler的实现类,得到每个EventType对应的处理器(EventHandle)是哪些;
3. 新建线程,while(true)循环使得后台一直有一个线程在消费队列。 从list中读取对应key下的事件(即EventModule对象),根据该EventModule对象的EventType,调用对应的EventHandle来处理事件。
@Service
public class EventConsumer implements InitializingBean, ApplicationContextAware {
private static final Logger logger = LoggerFactory.getLogger(EventConsumer.class);
private Map<EventType, List<EventHandler>> config = new HashMap<EventType, List<EventHandler>>();
private ApplicationContext applicationContext;
@Autowired
JedisAdapter jedisAdapter;
@Override
public void afterPropertiesSet() throws Exception {
//获取EventHandler类所有的实现类的bean,返回一个map类型的实例,map中的key为bean的名字,key对应的内容未bean的实例。
Map<String, EventHandler> beans = applicationContext.getBeansOfType(EventHandler.class);
if (beans != null) {
//得到每个EventType对应的EventHandle
for (Map.Entry<String, EventHandler> entry : beans.entrySet()) {
List<EventType> eventTypes = entry.getValue().getSupportEventTypes();
for (EventType type : eventTypes) {
if (!config.containsKey(type)) {
config.put(type, new ArrayList<EventHandler>());
}
config.get(type).add(entry.getValue());
}
}
}
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
while (true) {
String key = RedisKeyUtil.getEventQueueKey();
List<String> events = jedisAdapter.brpop(0, key);
for (String message : events) {
if (message.equals(key)) {
continue;
}
EventModel eventModel = JSON.parseObject(message, EventModel.class);
if (!config.containsKey(eventModel.getType())) {
logger.error("不能识别的事件");
continue;
}
for (EventHandler handler : config.get(eventModel.getType())) {
handler.doHandle(eventModel);
}
}
}
}
});
thread.start();
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
}
InitializingBean接口:afterPropertiesSet方法,初始化bean的时候执行,可以针对某个具体的bean进行配置;实现 InitializingBean接口必须实现afterPropertiesSet方法。
ApplicationContextAware接口:某些特殊的情况下,Bean需要实现某个功能,但该功能必须借助于Spring容器才能实现,此时就必须让该Bean先获取Spring容器,然后借助于Spring容器实现该功能。为了让Bean获取它所在的Spring容器,可以让该Bean实现ApplicationContextAware接口。
详见:Bean的生命周期