长大后想做什么?做回小孩!

0%

头条项目练习笔记

项目旨在设计开发一个类头条资讯网站集成评论,点赞,站内通知信等功能。采用SpringBoot + MyBatis + Redis的大体框架完成。

并不是完善的项目,总体流程调通,主要业务功能实现。还存在很多细节可以再打磨、实现。

项目已上传github

开发环境

操作系统:win10

JDK:1.8

IDE:IDEA 2019.3.1

项目构建工具:Maven 3.6.3

数据库:MySQL 5.1.49 + Redis 3.2.100

版本控制:git 2.21.0.windows.1

数据库设计

用户(User):id、name、password、salt、head_url

用户实体属性:用户名、密码、头像的连接、数据库中存放的密码采用md5加盐值的方式进行加密,并且每个用户有一个随机的盐值序列。

站内信(Message):id、fromid、toid、content、has_read、created_date、conversation_id

站内信实体属性:发送方id,接受方id,内容,是否已读,发出时间,对话(会话)的id。

资讯(News):id、title、link、image、like_count、comment_count、user_id、created_date

资讯实体属性:标题,资讯的连接,首图链接,赞的数量,评论的数量,发资讯的用户id,资讯创建时间。其中的comment_count是做了冗余,在资讯检索展示的页面上不需要再去检索统计评论表中每条资讯相关联的评论数,只需要检索资讯表就可以得到资讯对应的评论数。

评论(Comment):id、content、user_id、created_date、entity_type、entity_id、status

评论实体属性:内容,评论方id,评论创建时间,评论所属实体的类型(1表示资讯),评论所属实体的id,评论的状态(默认是0可见的,1被删除了)。

登陆凭证(login_ticker):id、user_id、ticket、expired、status

登陆凭证实体属性:凭证关联的用户id、下发给客户端的随机字符串作为ticket、凭证过期时间、ticket的状态0有效 1无效。

功能模块

首页展示

业务分析

主要就是展示资讯列表。

接口实现

查所有资讯接口:作为进入主页默认调用的接口,分页查询所有资讯,每页十条按照时间排序。

根据userId查资讯接口:根据资讯作者id展示资讯。

注册登录

接口实现

注册接口:账号和密码格式采用前后端双校验,接口会生成UUID剪切拼接处理之后座位盐值,和加密后的密码一起存入数据库。

登录接口:发给通过校验的用户一个凭证(ticket),这个ticket中包含了用户的一些基本信息(userId)和登录状态,保存在客户端cookie中。

登出接口:将用户对应的ticket的状态置为1,客户端重定向到首页。

拦截器实现

在登录的部分,引入了ticket或者说是token这一概念。有了这一概念之后的页面访问:
1.客户端的HTTP请求可以携带上Ticket。
2.服务端根据请求携带的ticket去数据库中查找到userId,通过userId得到用户的具体信息,从而进行页面跳转和用户权限管理。
正因如此,在此时引入拦截器再适合不过了!

Grn7kD.md.png

通用拦截器:实现一个HandlerInterceptor。所有的请求都经过这个拦截器,主要任务就是检查请求中是否携带ticket(是否登录)。

preHandler中检查请求中是否携带ticket这个cookie,如果存ticket就去数据库中检查这个ticket是否存在并且有效,如果存在且有效就用一个HostHolder将User信息保存在线程本地。HostHolder以注解的方式生成bean,方便后续service等方法中去使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component//HostHolder以注解的方式生成bean,方便后续service等方法中去使用。
public class HostHolder {
//因为系统不是一个人访问的,所以将user信息存放到请求的线程本地变量中
private static ThreadLocal<User> users = new ThreadLocal<User>();

public User getUser() {
return users.get();
}

public void setUser(User user) {
users.set(user);
}

public void clear() {
users.remove();;
}
}

postHandler中每次渲染前都将HostHolder中的User信息放ModelAndView中方便页面去使用。

afterCompletion中每次一个完整请求结束后回调该方法时,需要将HostHolder中的User信息清除。

登录拦截器:实现一个HandlerInterceptor。指定的请求路径会经过这个拦截器,主要任务就是检查HostHolder中是否有user信息(是否登录)。如果未登录则进行跳转或其他处理。

最后,完成两个拦截器的之后,需要实现一个WebMvcConfigurerAdapter的配置类,自动将我们实现的拦截器注册进去。需要注意拦截器的优先级,先注册的拦截器优先处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Component
public class ToutiaoWebConfiguration extends WebMvcConfigurerAdapter {
@Autowired
PassportInterceptor passportInterceptor;

@Autowired
LoginRequiredInterceptor loginRequiredInterceptor;

@Override
//先注册的拦截器优先处理
public void addInterceptors(InterceptorRegistry registry) {
//拦截所有请求,检查是否有合法的ticket,如有,查出user信息放入HostHolder
registry.addInterceptor(passportInterceptor);
//拦截形如"/setting*"路径的请求,检查HostHolder是否有user信息
registry.addInterceptor(loginRequiredInterceptor).addPathPatterns("/setting*");
super.addInterceptors(registry);
}
}

安全性设计

只是一个分析记录:

  1. 采用HTTPS。
  2. 公钥加密私钥解密,只有服务器拥有这个秘钥。
  3. 前端后端都对账户密码的长度、重复、敏感词进行校验,密码采用随机盐值加密后存入数据库。
  4. token有效期。
  5. 单一平台的单点登录,登录IP异常检验。
  6. 用户状态的权限判断。
  7. 添加验证码机制,防爆破和批量注册。

资讯模块

业务分析

主要是给用户看资讯和发布资讯。

接口实现

根据资讯id获得资讯详情接口:根据咨询的id到数据库中查出资讯的信息,根据资讯id查出咨询下对应的所有评论信息。

更新资讯评论数字段接口:获得资讯的评论的总数。

资讯发布接口:将用户发布的资讯的首图连接、用户id、资讯标题、资讯连接等信息存入数据库。

资讯图片上传接口:post请求到来之后,校验参数中的文件名是否合法,保存到服务器本地。

资讯图片下载接口:设置响应头,将本地图片作为输入流放到响应中返回给用户。

使用第三方云服务实现上传/下载接口

在图片上传的部分,会涉及到大量的图片请求。和图片类似的css、js、或者静态化的页面等等很多静态资源不涉及数据库等服务器的处理,只是进行简单的读取。
正因如此,这些个静态资源最适合部署到CDN节点。
当静态资源部署到CDN服务器之后,当用户请求这些资源的时候,就可以从就近的CDN服务器上去获取需要的资源。
CDN不仅可以非常有效的加速用户的请求,同时可以将这些静态文件和后端的存储分离,还能降低服务器的压力,让服务器"专注"于其他业务逻辑。
当上传图片到一台服务器之后,需要将这张图片同步到其他一些服务节点或者CDN节点上,如果仅仅依靠开发者编码去完成这个事情,是很麻烦且低效的,这时候就可以借助云存储来完成!
将很多静态文件的冗余备份,CDN缓存同步,统一访问等等问题都抛给第三方的云存储服务去完成。

采用七牛云上传文件服务:引入七牛的依赖,注册七牛云,获得ACCESS_KEY、SECRET_KEY,创建bucket。最后就是对照着官方文档进行编码,感觉和上传到本地的步骤差不多。编写过程中,将七牛的业务代码单独封装为一个QiniuService接口,方便之后更换第三方。

使用七牛云的下载文件服务:和使用上传服务的步骤一样。

编写完成,替换掉之前的图片上传和下载的接口即可。

第三方云存储服务除了上传下载文件之外还有很多功能,例如可以使用实时缩图的功能下载到同一张图片的不同大小及质量的图、使用实时鉴图功能过滤敏感违法图片,这些都可以有效提升用户的访问速度和观感。
还有很多的第三方服务可以逐步了解使用进行优化。

评论中心

业务分析

之所以叫做评论中心而不是评论,是因为评论集中式管理、集中式对外提供接口。看下面这个例子:
例如:现在有资讯模块,每条资讯有对应的评论;假如现在又加入了论坛广场模块,论坛里每个人的发帖也有对应的评论;后来又加入了个人主页,个人主页下面也有他人的评论留言;甚至是评论别人的评论;等等。。。如果采用评论中心来管理,就可以更好地复用接口,来实现集中式管理。
这就是为什么评论实体的定义字段不是new_id,而是entity_type和entity_id;当采用这种方式之后,上面的问题可以如下实现:
对应上述例子:无论是哪个模块下的评论,都是一样的字段定义(id、content、user_id、created_date、entity_type、entity_id)具体参考前文数据库设计部分。三个模块的评论之间区别在于entity_type字段不同,资讯模块的评论typecode是1、论坛广场模块的评论typecode是2、个人主页模块的评论typecode是3、评论的评论typecode是4。这样就能实现一定程度的评论统一管理。

接口实现

发布评论接口:将一条评论的信息存入数据库,用户id从HostHolder中获得,并且异步的更新所属实体的总评论数。

获得评论接口:根据entity_type+entity_id获得相关实体的全部评论并根据时间排序。

获得某个实体的评论总数接口:依然是根据entity_type+entity_id两个参数计算出相关实体的评论评论。

删除评论接口:数据库不真正的进行删除,而是根据评论id更新评论的状态为1即不可见。

消息中心/站内信

业务分析

每条消息都有发出方和接收方,同一组双方的往来消息应该归为一个会话中,每个会话有一个唯一的会话id。这个会话id是由发送方id和接收方id拼接而成(小的在前),每条消息实体都有所属的会话id。类似于如下形式:

G7nsUK.png

业务分析完就设计对应的数据模型,见前文数据库设计。

接口实现

发送站内信接口:会话id是由发送方id和接收方id连接而成(较小的id在前)。

展示会话列表接口:根据当前登录用户的userId查出所有相关的消息,根据会话id进行分组,会话列表的展示页面只显示每组会话中最新的一条消息、用户的名称和头像、会话中总消息数量、会话中未读消息数量。

查出每组会话中最新的消息和每组会话中消息的总数

1
2
3
4
5
6
/*从中间表中查出最新的消息和每个会话分组中消息数量,结果按照id降序*/
SELECT *,COUNT(id) as cnt
/*中间表:查出所有和id为12的用户有关的消息并以id降序排序*/
FROM (SELECT * FROM message WHERE from_id='12' OR to_id='12' ORDER BY id DESC) t
GROUP BY conversation_id
ORDER BY id DESC;

在编码中,稍有变化:COUNT(id) as id,将消息总数保存到消息对象实体的id字段中。

查未读消息数量的sql:

1
2
3
SELECT COUNT(id) 
from message
WHERE conversation_id='4_12' and to_id='12' and has_read='0';

展示会话中的全部消息接口:用户点击会话列表中的某条会话后,根据会话id查出会话中所有消息实体,还需要每条消息发送者的用户名和头像链接。

消息中心的操作都要获取当前登录用户id,所以将消息中心所有请求路径”/msg/*”设置为登录拦截器的拦截路径!保证对消息中心的请求都是在登录状态下发起的!

赞/踩功能

业务分析

可以将用户对实体进行踩赞的数据保存,用户可以取消对某个实体的赞踩,可以查询某个实体是否被当前登录用户进行过赞或者踩,如果有则对应的按钮处于点击状态。一个实体可以被多个用户点踩点赞。
从效率来说:
    Redis的数据存放在内存,所以速度快但是会受到内存空间限制。
    MySQL存放在硬盘,在速度上肯定没有Redis快,但是存放的数据量要多的多。
Redis性能好,快,并发高,但不能处理逻辑,而且不支持事务,看具体的场合,主要做数据缓存,减少MySQL数据库的压力。最擅长的是结构化数据的cache,计数器场景,轻量级的消息队列,Top排行榜等互联网应用场景。在点赞过后要立即刷新显示在页面,所以推荐使用Redis。至于并发问题,在此暂不考虑(其实是技术比较渣。。。)
  • List: 双向列表,适用于最新列表,关注列表;
  • Set: 适用于无顺序的集合,点赞点踩,抽奖,已读,共同好友;
  • SortedSet : 具有排序加成功能,适用于排行榜,优先队列的实现;
  • Hash:对象属性,不定长属性数;
  • KV : 单一数值,适用于验证码,缓存等实现。
采用Redis的Set集合数据结构实现。还有一点和评论中心的分析类似,被点赞的实体由entityType和entityId共同组成,好处前文解释过了。

接口实现

因为需要用到Redis,所以编写一个RedisAdapter类注解式声明为Spring Bean,其中封装一些需要用到的Redis操作方法,并实现一个InitializingBean接口来完成对Bean的初始化。

PS:封装方法中一定要记得关闭使用过的Jedis客户端,否则用尽JedisPool中默认的八个Jedis实例,Pool的状态变为exhausted并阻塞。

有多个业务都需要使用Redis,为了防止key冲突,编写一个生成统一规范key的工具类,只要参数不同key就一定不同,并且同一个业务会采用同样的格式和部分相同的标志字段。

获得当前用户对展示实体的赞/踩状态:拿到当前用户的id和实体类型以及实体id,工具类生成key,到缓存中查找用户对实体的赞踩状态,1=赞、-1=踩、0=其他。

点赞:参数是当前用户的id和实体类型以及实体id,工具类生成key和userId值,添加到赞对应的缓存中,最后返回有被点赞实体有多少个赞。

点踩:参数是当前用户的id和实体类型以及实体id,工具类生成key和userId值,添加到踩对应的缓存中,最后依然是返回有被点赞实体有多少个赞。

PS:一个用户对同一个实体只能进行赞或者踩的其中一项,如果对某个实体点赞则需要将对这个实体点过的踩移除,反之点踩亦然。

实现之后,将赞踩的对应接口路径加入登录拦截器的拦截路径,因为赞踩也是登录用户的功能。

异步队列

业务分析

将各个业务之间相互独立化,用户操作一个业务之后立刻告诉用户所操作业务的结果,其他的一些连锁反应则稍后进行操作对用户透明化。
1.对一些实时性要求不高或者比较耗时的操作可以延后处理,将复杂的业务切开,将需要及时反馈的信息响应给用户,可以延后的操作全部异步处理。
例如:用户赞/踩请求之后,将点赞的请求投递到异步队列中,后续的可以有消费者到队列中取出请求交给对应的处理器去发送站内信等等。登录请求之后,立刻响应给用户是否登陆成功,用户账号的异常信息也可以稍后通过异步的方式给用户发送邮件等。
2.在高并发的场景下,很容易发生数据库连接数占满,导致整个网站的响应都变得缓慢。这种情况下除了直接对数据库进行升级优化(增加连接数、读写分离等),还可以利用异步队列转移压力,将数据库的压力分散到缓存中。
例如:还是说本项目的赞/踩,将赞踩的请求放入异步队列,后续的赞踩请求可以将消息合并,即只更新消息中的点赞数,不产生新的任务。再有一个消费者进程或者线程去消费赞踩消息,更新到数据库,因为缓存层将多个数据库更新请求合并成了一个,所以降低了数据库的负载。
使用异步队列的优点:
  • 解耦,业务之间独立性更好,不会因为某个业务的失败而导致所有业务的连锁失败。
  • 异步,更好地用户体验,用户不需要等待所有的后台处理完毕即可得到"最关心的"响应结果。
  • 销峰,数据库不需要直面瞬时的大量的流量,例如大量的赞踩请求到来,将大量的请求放入队列等待数据库消费。
使用异步队列的缺点:
  • 系统可用性降低,虽然实现了一定程度的解耦,增强了主体业务的可用性,但是由于引入了异步队列相关的新业务依赖,就增加了风险点。
  • 系统的复杂性增加,需要想办法保证消费者不会重复消费,处理消息丢失,消息顺序等等难题。
  • 一致性问题,消息到数据落地之间存在距离。

JkDeqH.png

接口实现

  1. 在JedisAdapter中封装对象序列化存储对象反序列化获取方法方便后续缓存中存取对象操作,封装lpushbrpop方法,使用Redis实现异步队列的放消息和取消息操作。
    • lpush向列表头放入消息,brpop从右向左扫描key参数列表并将第一个非空的列表开始进行rpop获取消息。
    • 取消息时如果所有的key都为空或者不存在,则阻塞timeout参数设置的时间,如果timeout为0则表示一直阻塞。
    • 设置为永久阻塞(timeout=0)性能更好节省CPU、并且及时性更好,异步队列一直为空就保持阻塞不需要进行轮询检查。
    • 阻塞期间如果有其他的client对之前任意一个key的列表进行push后,阻塞解除并返回。如果阻塞时间timeout不为0,并且阻塞超时则返回nil(空)。
  2. EventModel类用于封装消息事件的相关信息序列化之后放入队列。
  3. 事件处理器接口EventHandler定义两个方法处理消息处理器所关注的事件类型,实现处理器接口LikeHandler给被点赞用户发送站内信LoginExceptionHandler给异常用户发送邮件并注册为bean,分别用来处理点赞和登录异常消息。
  4. 消息生产者EventProducer用于各种业务中将消息投递到异步队列。
  5. 消息消费者EventConsumer用于从队列中获取消息,并依据一个映射表将消息路由给对应的处理器。需要实现一个bean初始化接口,在初始化的时候得到所有处理器的映射,并启动一个线程去消费队列中的消息;实现一个ApplicationContextAware接口可以获取当前应用上下文已经注册的所有bean对象。

完结

最后完成Junit单元测试,就可以打包部署。

本地部署:application继承SpringBootServletInitializer,打包方式修改成war,mvn package打包后,将war包放到本地安装好的WebServer(我安装的Tomcat)上,运行Tomcat去可以去访问对应的端口。

服务器部署:我选择部署到本地的虚拟机上。服务器安装nginx、mysql-server、libmysqlclient-dev、maven、redis。把war包放到tomcat目录,需要给tomcat目录增加执行权限。配置JDK版本为开发版本和tomcat适合的版本。修改Redis连接参数。简单的配置一下nginx。启动WebServer。

总结一下项目所用设计的技术和工具,SpringBoot、Mybatis、拦截器模式、MVC模式、适配器模式、Git、Maven、IntilliJ、七牛云SDK、Redis、实现异步队列、邮件、单元测试/部署等等。