# wenda **Repository Path**: bendi114/wenda ## Basic Information - **Project Name**: wenda - **Description**: SpringBoot问答系统 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 1 - **Created**: 2022-01-17 - **Last Updated**: 2022-01-17 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # SpringBoot构建的问答项目 ## 一、功能简介 仿照知乎做的一个Java Web项目. 基于SpringBoot + Redis + MySQL搭建的wenda网站. 大概分为以下几个模块网站首页问题的显示, 用户个人的注册,登录,退出登录和自动登录, 用户发布问题, 用户评论, 站内信(私信), 用户赞/踩模块, 异步消息功能, 用户互相关注/取消关注模块. 先是网站首页的显示, 首页最上面一栏有首页, 消息, 提问, 还有用户个人信息的显示, 你鼠标放在头像上会出现退出登录, 私信等功能. ## 二、数据库设计 首先设计了四张表. User, Question, Comment, Message. 用户表User中的字段: id, name, password, salt, header_url. 问题表Question中的字段: id, title, content, user_id, create_date, comment_count(评论数). 评论Comment表中的字段: id, content, user_id entity_id 标识了所评论实体的唯一id entity_type评论既可以评论问题, 也可以评论评论, entity_type就标识了所评论的实体是一个问题还是一个评论. entity_type和entity_id结合起来就可以唯一标识所评论的一个实体. create_date, status. 信息表Message: id, from_id, to_id, content, created_date, has_read, conversation_id(对话id). ## 三、模块介绍 ### 1、首页模块 使用HomeController来实现对首页的访问, 当访问/index路径时, 会从数据库中查询出最新的十条问题显示在首页上. 这块不仅仅要查询出提问, 还需要查询出提问的用户, 将用户和对应的提问一起显示在首页上, 就需要创建一个ViewObject对象, 一个ViewObject对象包含了提问和用户的全部信息, 将VO对象的集合添加到Model对象中, html页面就将Model中的对象提取出来, 显示在页面上. ### 2、用户模块 #### 1)、用户注册 首先判断用户名, 密码是否为空, 并且判断用户名是否已经被注册过了, 如果符合条件, 用户进行注册, 用户密码是通过password+salt 然后进行md5加密存储在数据库中的, salt是使用UUID生成的, 然后截取了部分字符. #### 2)、用户登录 用户首次登陆成功的话, 判断用户密码是否正确也是先根据用户名从数据库中查询出salt值, 然后用户输入的password+salt再进行md5加密, 判断与数据库中所存储的值是否相等. 用户首次登陆成功, 向数据库中记录一个login_ticket. 在数据库中创建了一个login_ticket表, 用来记录这个用户的login_ticket. login_ticket表中字段: id, user_id, ticket(varchar类型的字段), expired(有效期,设置为100天), status(登录状态, 0表示有效, 1表示无效). 然后创建一个Cookie, 以login_ticket中的ticket为值, 使用HttpServletResponse向浏览器发送Cookie. 这个Cookie没有使用setMaxAge()设置有效期, 默认就是-1, 当浏览器关闭的时候, 这个Cookie也会失效. #### 3)、使用了两个拦截器 自定义拦截器实现了HandlerInterceptor接口, 实现了三个方法, preHandle, postHandle, afterCompletion三个方法. 自定义了一个Configuration, 继承了WebMvcConfigurerAdapter类, 重写addInterceptoers()方法, 注册拦截器. (1)第一个拦截器是验证用户身份, 也就是验证login_ticket的合法性, 从Cookie中读取出ticket, 然后验证ticket的合法性, 就是从数据库中查询出Ticket对象, 判断ticket的有效期和状态statue. 如果这个ticket是合法的, 就从Ticket对象中取出user_id, 然后根据user_id去数据库中查询对应的User对象, 这块我使用了一个HostHolder, HostHolder类里面封装了一个ThreadLocal, 将查询出来的User放在ThreadLocal中, 保证后台可以方法. 上面是preHandle()方法中的代码, 在postHandle()方法中, 就是在渲染模板之前, 从HostHolder中取出User对象, 将User对象放置在Model中, 前端页面会判断user是否为空, 来显示不同的页面. 通过这个拦截器就可以实现用户的自动登录, 只要用户不关闭当前浏览器, 不主动退出登录, 当访问网站首页的时候, 通过这个拦截器就可以实现直接登录. 最后在afterCompletion()方法中, 调用HostHolder的clear()方法, 就是调用ThreadLocal的remove()方法进行清理, 防止发生内存泄漏. (2)第二个拦截器 如果用户退出登录或者关闭浏览器, 导致ticket失效或者Cookie失效, 则用户不会自动登录, 这时候当用户点击发表的问题的用户用户名时, 用户名是个超链接, 可以查看用户信息, 当前如果没有用户登录时, 点击会自动跳转到登录页面, 完成登录时, 会跳回点击的页面, 也就是会直接显示出登录前访问的页面, 而不是跳转回首页. 这块这个拦截器实现了对/user路径下请求的拦截, 如果当前用户未登录, 直接跳转到登录页面, 并携带上此次访问的uri, 这个uri中保存着要访问用户信息的user_id, 当登录成功后, redirect重定向到/user/user_id路径下, 就可以显示出要访问用户的信息. #### 4)、用户退出登录 将ticket的statue置为1即可, 就是使ticket失效. ### 3、Question模块, 用户发布问题模块 创建Question对象, 设置title, content, 从HostHolder中获取当前用户信息, 将用户id添加到Question对象中, 将Question添加到数据库中. 在添加问题这块使用字典树实现了敏感词过滤. 使用字典树实现了对标题和内容中敏感词和html标签的过滤. 先使用HtmlUtils过滤用户输入, 防止XSS攻击, 跨站脚本攻击, 用户的输入中可能会包含一些恶意的js脚本, 如果后台不对用户输入进行处理, 直接返回的话, 浏览器会将这些恶意的js代码解析, 从而对网站造成影响, 严重的甚至可能导致信息的泄漏. HtmlUtils会将html代码的一些标签进行转义, 然后存储到数据库中. 还要对标题和内容进行敏感词的过滤. 项目中使用了字典树 字典树的建立: 写了一个类, 里面封装了一个TrieNode节点, TrieNode节点里面使用HashMap来存储敏感词. 当过滤字符串中的敏感词时, 使用两个指针, 一个指针在字符串当前的位置保持不动, 另外一个指针向后移动, 同时字典树中的一个指针遍历, 如果找到一个敏感词, 就将其替换为 ***, 然后第一个指针移到和第二个指针一样的位置, 再继续匹配字典树中的敏感词, 将敏感词都替换为***, 遇到空格就跳过去, 继续匹配下一个词. 用一个StringBuilder对象来拼接最终返回的字符. 初始化字典树: 这个Service实现了InitializingBean接口, 重写了afterPropertiesSet()方法, 会对Bean进行初始化. 初始化时将创建字典树, 将敏感词文件读取进来, 然后添加到字典树中. **为什么使用字典树来进行敏感词的过滤???** 可以有效降低字符串匹配的时间复杂度, 提高效率. 在首页上的问题标题title, 其实上是一个超链接, 当点击这个title时会将问题的id传过来, 根据问题的id查询出对应的问题和用户, 然后返回另外一个html页面, 显示出详细的问题和 ### 4、用户评论模块 当点进一个问题时, 问题下面会显示评论的数量, 每条评论的具体信息, 评论人信息等. 当用户在问题下面添加评论时, 会创建一个Comment对象, 设置user_id, content, entity_type, entity_id属性等, 然后将评论添加到数据库中, 根据entity_id和entity_type查询出这个问题评论的数量, 然后更新Question中的count字段. 评论也要进行敏感词的过滤. 然后redirect重定向到/question路径下, 显示Question的详细信息. 找出此问题评论的所有用户, 然后根据每条评论中的user_id查找出对应的用户, 返回给前端一个VO对象. 前端根据返回的VOs对象的不同显示不同的信息. **站内信模块** 在首页上面一栏, 有一个消息的按钮, 点击之后就是要发送私信的人的姓名, 发送私信的内容. 发送私信要先判断用户是否登录, 还要判断发送给私信的用户是否存在, 如果都符合条件, 则发送私信, 将当前HostHolder中的User的id设置为from_id, 将要发送给的用户的id设置为to_id, 然后向数据库中添加相应Message信息. Message表中的Conversation_id是当前fromId_toId组合成的varchar字段. 用户可以获取所有发送给自己的私信的列表 每个列表中显示的私信只显示最近的信息, 这块写了一个比较复杂的SQL语句. 用户将某一个私信列表的消息完全取出来 取出一个列表的Message是根据conversation_id取出来的. 不光要取出两个用户之间的Message, 而且还要取出用户的信息, 因为显示出来的信息有用户和Message两部分. 还是使用ViewObject来存储用户和Message信息, 向VO对象中添加一条Message信息, 就根据from_id找出对应的用户, 然后将用户User对象也添加进VO对象, 再返回给前端页面进行展示. 返回的Message可以使用分页, 只查询10条信息, 然后按照时间降序排列. **用户赞踩模块,使用Redis实现** 为什么使用Redis实现点赞功能??? 因为Redis效率高, 速度快, Mysql数据存放在磁盘上, 速度肯定没有redis快, 而且点赞, 点踩是一个频繁的, 要求速度高效的事件, redis在这块可以作为一个缓存来实现, 点赞之后要立即进行页面的显示, 所以使用Redis来实现. 点赞功能可以给问题点赞, 也可以给评论点赞. 所以需要前端传入entity_type和entity_id, 这两个可以共同确定一个点赞的实体. (1)实现对Comment的点赞功能 前端传入Comment评论的Id, 然后调用Service实现点赞功能, 从HostHolder中获取当前用户的id, 然后使用一个封装好的工具类来生成唯一的key, 这个key 可以标识对一个评论或者问题的点赞或者踩的用户, 可以使用 LIKE:COMMENT:comment_id 这样的形式来生成一个key, 然后使用的是Redis的set集合对象来将点赞用户的id存储起来. Set集合对象的底层实现使用 intset来实现, 当存储元素的个数超过512个时, 会转换成hashtable编码. 在用户点赞完成之后, 还需要将用户从这个question/comment的点踩集合中删除, 点踩的key也是封装了一个工具类来生成key, 采用的是 DISLIKE:COMMENT:comment_id 这样的形式来生成key的. 同样可以保证key的唯一性, 用来区分对不同实体的like或者dislike. 点踩功能也是一样的, 点踩功能也是将用户加入点踩的列表中, 然后再从点赞的列表中将用户id删除. 这块是在用户点赞完成之后, 用户点赞完成之后, 调用redis中的一个scard命令返回当前key中的元素个数, 就是返回这个实体的赞/踩的人数. 点赞或者点踩完毕之后, 返回当前key中的元素个数, 然后Controller返回给前端json格式的是数据, 将当前赞/踩的人数返回给前端, 前端使用ajax实现异步更新. 在显示/question/id页面的时候, 也要加入用户对评论的赞踩功能. 在显示问题的页面, 查询出具体的问题和对应的而用户, 再查询出这个问题的所有评论, 在查询出评论所关联的用户, 然后每遍历一条评论, 查询当前用户对每条评论的状态, 是喜欢还不是不喜欢, 最后查询出总的评论数量. 存放在Model对象中, 然后返回给视图层. 为什么使用Redis的set对象来实现用户的赞踩功能呢???为什么不使用list 因为Redis的set数据结构底层使用的是inset和hashtable来实现的, 而list使用的是ziplist和双端链表实现, 当数据量上升时, list集合使用双端链表来存储数据, 而set使用hashtable来存储数据, 在删除元素的效率上肯定要优于list. 而且赞/踩功能也是需要频繁的移除元素的. **异步消息框架, 使用Redis实现异步消息队列** 增加异步消息处理的功能, 有时候有些不需要实时执行的操作或者任务, 可以将它们改造成异步消息来发送. 就可以将用户给某个用户的评论或者问题点了赞, 然后异步的给这位用户发出一个消息通知. 在项目中搭建了一个异步消息处理框架. 异步消息处理架构: 一个EvnetModel事件由EventProducer提交到单向队列中, 然后由EventConsumer将任务事件取出来, 分发给对应的Handler进行处理. 这块的EventConsumer是另起的一条线程, 不讲它设置为同步是为了不阻塞主线程的执行, 从而提高响应速度, 而且然后异步执行抛出异常, 也并不影响主线程业务的执行. ![img](https://note.youdao.com/yws/public/resource/12fcc0a27563b0fce273498200bf4336/xmlnote/2EB91AB960E243E882353D6C338AFC6C/20911) 创建一个EventModel来表示事件模型, EventModel封装了Event的类型, 触发者, 所触发的事物, 被触发者(这个所触发的对象是与被触发者相关的), 还封装了一个Map, 用来记录触发事物的信息. EventProducer的作用就是将EventModel事件保存到任务队列中, 将EventModel对象序列化之后, 将它保存到队列之中. 这块的任务队列并没有使用BlockingQueue或者集合类来实现, 而是使用Redis中的list数据结构来存放EventModel事件, 因为单机系统的话可以这样使用, 但是如果是分布式系统的话, 就应该独立出来一个组件来存放消息, 有利于系统的解耦, 更好的实现异步通信. EventConsumer是用来向不同的EventHandler分发对应的EventModel. EventConsumer是开启了一个循环的单线程去获取EventModel. EventHandler是一个接口, 不同的Handler实现EventHeandler接口, 实现自己的逻辑. EventHandler中有两个方法, doHandl(),处理EventModel的方法, 还有一个List getSupportEventTypes()方法来获取这个Handler所支持的所有事件类型. EventConsumer类就是来分发Event事件的. 实现IniaializingBean接口, 重写方法, 来进行EventConsumer的初始化, 将Spring上下文注入到类中(实现ApplicationContextAware接口), 初始化时通过ApplicationContext的getBeansOfType()方法来获取所有实现EventHandler的类, 遍历这些EventHandler, 找出每个Handler所支持的EventType, 然后将这些EventType加入到一个HashMap中, map元素的value是一个List集合, 这个List集合用来保存处理这个EventType的Handler. 然后开启一条循环的线程, 从Redis中循环获取EventModel来分发. 使用Redis的brpop命令获取key中的元素, 如果key列表中没有元素, 则线程会一直阻塞, 直到获取到元素返回. 获取到的EventModel是JSON格式的字符串, 通过JSONObject来将它反序列化为EventModel对象, 根据这个EventModel对象的事件类型来从Map中获取对应的List来执行, 整个异步消息处理框架整体上就是搭建起来. 异步消息框架在项目中的应用: 在LikeController中点赞完成之后, 会使用EventProducer将创建的EventModel事件放入消息队列中, 然后EventConsumer会不断从消息队列中取出EventModel分发给相应的Handler去执行, 每个Handler都实现了自己的doHandle()方法. 对于点赞这块, 就实现了一个LikeHandler, 用来异步处理点赞之后向用户发送系统通知的功能. 这块如果做成同步的话, 效率会大大降低, 因为这块向用户提示的功能不需要实时进行, 采用异步发送的方式会比较好. 然后调用MessageService向用户发送私信. ## 三、用户关注/取消关注模块 用户可以关注(取消关注)其他用户或者问题, 取消关注其他人. 获取自己的粉丝列表, 或者自己所关注的用户(列表). 在实现用户粉丝列表和关注列表使用的是Redis的zset数据结构 为什么使用zset数据结构不适用list数据结构呢??? 关注不止只能关注人, 也可以关注问题, 使用List数据结构也可以进行存储, 但是使用zset更好, 因为要显示用户的粉丝列表是按照关注时间的显示的, zset数据结构中有一个score分值, 元素按照各自的分值从小到大进行排序的, 可以用这个分值来保存时间, 当元素达到128个时, 会将编码转换成跳跃表, 在查询删除的时间复杂度上要比list要好. 用户关注/取消关注模块也是使用Redis来实现的, 当一个用户关注另外 一个用户时, 将这个用户添加到被关注用户的粉丝列表中, 将被关注的用户添加到这个用户的关注对象列表中. 将当前用户添加到被关注用户的粉丝列表中, 是使用FOLLOWER:USER:user_id来标识唯一的key. 然后将当前用户的id添加到zset中, 分值是当前时间, date.getTime()获取当前时间, 返回的是一个long值. 然后将被关注用户的id添加到当前用户的关注对象的zset中. 这两个业务的执行添加了Redis事务来实现. 当关注成功之后, 返回被关注对象有多少人关注他, 通过Redis的 用户取消关注的功能也是开启了一个事务来实现的. 如果当前用户取消对一个用户的关注, 将当前用户从被取消关注的用户粉丝列表中移除, 然后再将被关注对象从当前用户的关注对象列表中移除. 在对一个用户进行关注之后, 采用异步消息对象向被关注的对象发送一条Message, 通知被关注对象. 前端页面还有两个功能: 获取粉丝列表和获取所关注的对象. 获取粉丝列表就是传入当前用户的id, 根据这个用户id生成唯一的key值, 然后调用Redis的zrevrange命令, 按照分值从大到小的顺序查询出在value值, 也就是粉丝key中的用户id, 然后根据查询出来的粉丝的id集合, 将每个用户的详细信息(关注了多少人, 粉丝数等等)查询出来, 封装到VO对象中, 然后返回给前端页面展示. 再可以将当前用户所关注的人数查询出来.