论坛的总结和改进

[TOC]

image-20210315102121435

数据库

userID, message , login_ticket ,disscuss_post comment

用户表,包括 id、用户名、密码、盐值、邮箱、类型(普通/管理员/版主)、状态(激活/未激活)、激活码(随机字符串)、头像 url、注册时间。

登录凭证表,包括 id、用户 id、登陆凭证(随机字符串)、登录状态(有效/无效)、过期时间。

评论表,包括 id、评论用户 id(索引)、评论实体 id(索引)、评论类型(帖子/回复)、被评论目标 id、评论内容、评论状态(有效/无效)、评论时间。

帖子表,包括 id、发帖用户 id(索引)、标题、帖子内容、类型(普通/置顶)、评论数量、状态(普通/精华/拉黑)、发帖时间。

消息表,包括 id、发消息 id(索引)、收消息 id(索引)、会话 id(由发消息双方 id 拼接,索引)、内容、状态(未读/已读/删除)、发消息时间。


社区论坛

项目描述:该项目是设计一个社区论坛,方便用户发言与讨论

使用技术:本项目使用SpringBoot进行开发,使用的技术主要有 MySQL,Redis , Kafka , Quartz

主要功能:登录注册,发帖评论,点赞关注,消息提醒,热帖排行

项目亮点

  • 通过Kaptcha来生成验证码图片,作登录和注册的验证。
  • 通过自定义注解和拦截器,防止用户在未登录的情况下通过url访问没有权限的页面。
  • 利用数据结构Trie实现前缀树,对发表帖子评论进行简单的敏感词过滤。
  • 对点赞关注等高频功能利用Redis的Set来提升性能,并通过Redis的Hyperloglog统计UV, Bitmap统计DAU。
  • 利用Kafka来实现消息提醒功能,起到异步和解耦的功能。
  • 使用定时任务Quartz来更新帖子的评分,实现帖子排行

注册

判断注册合法性

  • 利用 StringUtils 判断用户名、密码、邮箱是否非空。

  • 分别通过用户名和邮箱查询是否已经注册(为数据库的用户名和邮箱字段添加索引)。

    1
    2
    3
    4
    5
    6
    //Service层
    public Map<String, Object> register(User user)
    {
    //Mapper层
    User u = userMapper.selectByName(user.getUsername());
    }

通过 set 方法为用户设置各项信息,包括 salt,MD5 加密后的密码、用户类型type,用户status状态、创建时间等,然后插入数据库。(keyProperty=”id” 标明主键)

1
2
3
4
5
6
7
8
user.setSalt(CommunityUtil.generateUUID().substring(0, 5));
user.setPassword(CommunityUtil.md5(user.getPassword() + user.getSalt()));
user.setType(0);
user.setStatus(0);
user.setActivationCode(CommunityUtil.generateUUID());
user.setHeaderUrl(String.format("http://images.nowcoder.com/head/%dt.png", new Random().nextInt(1000)));
user.setCreateTime(new Date());
userMapper.insertUser(user);
1
2
3
4
5
**给用户发送激活邮件**

- 在新浪邮箱打开 SMTP 服务,引入 `spring-boot-starter-mail` 依赖。
- 在配置文件配置主机(smtp.sina.com)、端口(465)、邮箱、授权码、协议(smtps),设置 smtp.ssl.enable = true。
- 调用 JavaMailSender 的 API 发送邮件,激活 url 由用户 id 和用户的激活码拼接而成。点击激活 url 后由 controller 中的方法进行处理(成功/重复/失败),调用 Model 对象的 `addAttribute` 方法将结果返回前端。

用户表,包括 id、用户名、密码、盐值、类型(普通/管理员/版主)、状态(激活/未激活)、注册时间。


登录

生成验证码

  • 引入 kaptcha 依赖,将验证码的大小、范围、长度等属性封装到 Properties 对象,作为参数构造 Config 对象,再用 Config 对象作为 DeafultKaptcha 对象 setConfig 方法的参数为验证码设置属性。随后在登录模块就通过kaptchaProducer.createText()生成并封装在BufferedImage里,最后通过ImageIO写到response的输出流里

    1
    2
    String text = kaptchaProducer.createText();
    BufferedImage image = kaptchaProducer.createImage(text);
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    @Bean
    public Producer kaptchaProducer() {
    Properties properties = new Properties();
    properties.setProperty("kaptcha.image.width", "100");
    properties.setProperty("kaptcha.image.height", "40");
    properties.setProperty("kaptcha.textproducer.font.size", "32");
    properties.setProperty("kaptcha.textproducer.font.color", "0,0,0");
    properties.setProperty("kaptcha.textproducer.char.string", "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYAZ");
    properties.setProperty("kaptcha.textproducer.char.length", "4");
    properties.setProperty("kaptcha.noise.impl", "com.google.code.kaptcha.impl.NoNoise");

    DefaultKaptcha kaptcha = new DefaultKaptcha();
    Config config = new Config(properties);
    kaptcha.setConfig(config);
    return kaptcha;
    }
  • 在登录的 controller 处理验证码,设置页面的响应类型为 png,通过 ImageIO 的 write 方法将图片输出到浏览器。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // 生成验证码
    String text = kaptchaProducer.createText();
    BufferedImage image = kaptchaProducer.createImage(text);
    // 验证码的归属
    // 将验证码存入Redis
    // 将突图片输出给浏览器
    response.setContentType("image/png");
    try {
    OutputStream os = response.getOutputStream();
    ImageIO.write(image, "png", os);
    } catch (IOException e) {
    logger.error("响应验证码失败:" + e.getMessage());
    }

验证码我们会生成一个随机ID,写到cookie设置过期时间,并把真正的value值设置到redis里面。在后面的登录模块就根据cookie的值从redis里面取出验证码,与传入的验证码进行验证。

判断验证码正确后,调用业务层处理

  • 利用 StringUtils 判断用户名、密码是否非空,之后判断用户是否存在、用户是否激活、密码是否正确,将错误信息存到 map 集合。(LoginTicketMapper)

  • 如果全部合法,为用户生成一个包含过期时间的登录凭证,将凭证存入 redis 和 map 集合。(redis的key以及cookie存储loginTicket.getTicket(),value是loginTicket)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 验证码的归属
    String kaptchaOwner = CommunityUtil.generateUUID();
    Cookie cookie = new Cookie("kaptchaOwner", kaptchaOwner);
    cookie.setMaxAge(60);
    cookie.setPath(contextPath);
    response.addCookie(cookie);
    // 将验证码存入Redis
    String redisKey = RedisKeyUtil.getKaptchaKey(kaptchaOwner);
    redisTemplate.opsForValue().set(redisKey, text, 60, TimeUnit.SECONDS);
  • 用户ID,ticket,状态status,expired

    1
    2
    3
    4
    5
    LoginTicket loginTicket = new LoginTicket();
    loginTicket.setUserId(user.getId());
    loginTicket.setTicket(CommunityUtil.generateUUID());
    loginTicket.setStatus(0);
    loginTicket.setExpired(new Date(System.currentTimeMillis() + expiredSeconds * 1000));
  • @Insert({
            "insert into login_ticket(user_id,ticket,status,expired) ",
            "values(#{userId},#{ticket},#{status},#{expired})"
    })
    @Options(useGeneratedKeys = true, keyProperty = "id")
    int insertLoginTicket(LoginTicket loginTicket);
    
    1
    2


    /** * 默认状态的登录凭证的超时时间 */ int DEFAULT_EXPIRED_SECONDS = 3600 * 12; /** * 记住状态的登录凭证超时时间 */ int REMEMBER_EXPIRED_SECONDS = 3600 * 24 * 100;
    1
    2
    3
    4

    **根据返回的 map 是否包含登陆凭证判断登陆状态**

    - 如果登录成功,将凭证存入 cookie 并重定向至首页。
    Cookie cookie = new Cookie("ticket", map.get("ticket").toString()); cookie.setPath(contextPath); cookie.setMaxAge(expiredSeconds); response.addCookie(cookie);
    1
    2


    并且ticket也加入redis
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48

    - 如果登陆失败,将 map 中的错误信息添加到 Model 对象,返回登录页。

    ### 登录的改进?

    用户密码是如何保存在数据库中的?如何在用户登录时验证身份信息?如何防止登录请求报文被窃取?









    **Jwt与token的区别**
    1)token对应的内容存放在Redis中
    2)Jwt对应的playload数据存放在客户端

    **Jwt优点**
    1),减轻服务端压力。
    2),查询效率比token高。
    3),不容易被客户端篡改数据。
    **缺点**
    1)如果一旦生成好一个jwt之后,后期是否可以销毁
    2)Jwt playload数据多,占据服务器端带宽资源
    jwt不是很安全,playload中不能存放敏感的信息,必要须加密
    **jwt注销**
    无法做真正意义上的注销
    1)用户注销时,直接将cookie缓存清除。
    2)最好将jwt过期时间定义不要太长。
    3)每个用户对应的盐值不一样,用户注销的时候直接将该盐值发生变化。







    ### 会话管理

    ### 显示登录信息

    (HttpServletResponse)

    创建 CookieUtil 工具类,通过 name 查询对应 cookie 的 value。

    在 UserService 中新增 `findLoginTicket` 方法,根据 ticket 查询 LoginTicket。(redis的key以及cookie存储loginTicket.getTicket(),value是loginTicket)
    // 检查凭证是否有效 if (loginTicket != null && loginTicket.getStatus() == 0 && loginTicket.getExpired().after(new Date())) { // 根据凭证查询用户
    1
    2

    创建 HostHolder 类用来模拟 session 的功能,利用 ThreadLocal 实现,存储用户信息。
    hostHolder.setUser(user);
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14

    创建 LoginTicketInterceptor 拦截器,实现 HandlerInterceptor 接口。

    - 在 `preHandle` 方法中通过 CookieUtil 的 `getValue` 方法查询是否有凭证 cookie,如果有则通过 UserService 的 `findloginTicket` 方法查询用户 ID,再通过用户 ID 查询用户。最后将用户放入 hostHolder 中。
    - 在 `postHandle` 方法中通过 hostHolder 的 `get` 方法获取用户,并将其存入视图中。
    - 在 `afterCompletion` 方法中清除 hostHolder 中存放的用户信息。

    创建 WebMvcConfig 配置类,实现 WebMvcConfigurer接口,配置 LoginTicketInterceptor,拦截除了静态资源之外的所有路径。

    ### 检查登录状态

    **目标**:防止用户通过url直接访问相应的页面。

    只处理带有自定义注解的方法,防止用户在未登录情况下通过 url 访问没有权限的页面。
    @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME)//有效的时长 public @interface LoginRequired { }
    1
    2
    3
    4
    5
    6
    7
    8

    利用 ThreadLocal 创建 HostHolder 类,包括 `set`、`get`、`remove` 方法,模拟 session 存储用户信息。

    通过实现 HandlerInterceptor 接口创建一个拦截器,在 `preHandle` 方法中通过查询是否有登录凭证的 cookie,如果有则通过登录凭证查询用户 ID,再通过用户 ID 查询用户。最后将用户放入 hostHolder 中,在本次请求中持有用户信息。

    创建 `@LoginRequired` 自定义注解,作用范围在方法上,有效期为运行时。为需要在登录状态下调用的方法,例如修改密码、上传头像等方法上等加上自定义注解。

    创建拦截器,在 `preHandle` 中判断方法是否添加了 `@LoginRequired` 注解,如果加了并且从 hostHolder 获取不到用户则拒绝访问。
    @Component 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; } }
    1
    2
    3
    4

    在webMVC中排除掉静态资源

    // 2 2
    public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(alphaInterceptor) .excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg") .addPathPatterns("/register", "/login");
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20

    ---

    ### 发帖、评论、私信

    **敏感词过滤**

    - 创建静态内部类 TrieNode ,通过 boolean 结束符判断是否匹配到关键字尾部。
    - 利用 `@PostConstruct` 注解,在构造方法执行后初始化字典树。
    - 添加 `filter` 方法,利用双指针进行匹配,过滤敏感词。

    **发帖、评论、私信**

    - 对内容进行 HTML 转义以及过滤敏感词。

    - 将信息插入数据库的帖子/评论/消息表。

    发帖

    涉及的数据库的字段:用户id,title,content,createTime
    @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);
    1
    2


    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); }
    1
    2


    <insert id="insertDiscussPost" parameterType="DiscussPost" keyProperty="id"> insert into discuss_post(<include refid="insertFields"></include>) values(#{userId},#{title},#{content},#{type},#{status},#{createTime},#{commentCount},#{score}) </insert>
    1
    2
    3
    4

    帖子详情

    - 通过帖子id,查出帖子的相关信息
    DiscussPost post = discussPostService.findDiscussPostById(discussPostId);
    1
    2

    - 根据帖子实体传来的用户id查出作者
    User user = userService.findUserById(post.getUserId());
    1
    2

    - 根据帖子类型和帖子id找出点赞数量
    long likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST, discussPostId);
    1
    2


    ///点赞状态 int likeStatus = hostHolder.getUser() == null ? 0 : likeService.findEntityLikeStatus(hostHolder.getUser().getId(), ENTITY_TYPE_POST, discussPostId);
    1
    2

    - 获取评论列表
    List<Comment> commentList = commentService.findCommentsByEntity( ENTITY_TYPE_POST, post.getId(), page.getOffset(), page.getLimit());
    1
    2

    - 对每一个评论,也会根据评论进行查询
    List<Comment> replyList = commentService.findCommentsByEntity( ENTITY_TYPE_COMMENT, comment.getId(), 0, Integer.MAX_VALUE);
    1
    2


    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); // 点赞数量 long likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST, discussPostId); model.addAttribute("likeCount", likeCount); // 点赞状态 int likeStatus = hostHolder.getUser() == null ? 0 : likeService.findEntityLikeStatus(hostHolder.getUser().getId(), ENTITY_TYPE_POST, discussPostId); model.addAttribute("likeStatus", likeStatus); // 评论分页信息 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 // 评论 // 作者 // 点赞数量 // 点赞状态
        // 回复列表

        // 回复VO列表
        List<Map<String, Object>> replyVoList = new ArrayList<>();
        if (replyList != null) {
            for (Comment reply : replyList) {

                // 回复

                // 作者

                // 回复目标

                // 点赞数量

                // 点赞状态

            }
        }


        // 回复数量

    }
}

model.addAttribute("comments", commentVoList);

return "/site/discuss-detail";

}

1
2
3
4

帖子列表

添加评论
insert into comment() values(#{userId},#{entityType},#{entityId},#{targetId},#{content},#{status},#{createTime})
1
2

事务管理:
// REQUIRED: 支持当前事务(外部事务),如果不存在则创建新事务. // REQUIRES_NEW: 创建一个新事务,并且暂停当前事务(外部事务). // NESTED: 如果当前存在事务(外部事务),则嵌套在该事务中执行(独立的提交和回滚),否则就会REQUIRED一样. @Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

### 发帖的改进

#### 帖子是怎么存数据库的

用户id,title,content,createTime

Text字段

1、 char长度固定, 即每条数据占用等长字节空间;适合用在身份证号码、手机号码等定。

2、 varchar可变长度,可以设置最大长度;适合用在长度可变的属性。

3、 text不设置长度, 当不知道属性的最大长度时,适合用text。

#### 帖子中有图片怎么进行压缩优化?

网上有免费的图床,但最好自己搭建一个。前端把图片上传到图床,返回URL,再将URL存入数据库。

Graphics

### 点赞

创建 RedisKeyUtil 工具类,通过实体类型和实体 id 生成对应实体获得赞的 key。、

需要四个字段
int userId, int entityType, int entityId, int entityUserId
1
2
3
4
5
6
7

点赞/取消点赞:

- 通过 RedisKeyUtil 获得实体点赞的 key,然后通过 RedisTemplate 的 API 操作,调用集合的 `isMember` 方法查询 userId 是否存在于对应集合中,如果存在则移除出点赞的用户集合,如果不存在则添加到点赞的用户集合。
- 通过 RedisTemplate 的 `execute` 方法实现事务,保证被点赞用户点和点赞用户的数据更新一致。通过 `isMember` 方法查询用户的点赞状态,之后通过 `mutli` 方法开启事务。

点赞数量:通过调用 set 集合的 `size` 方法查询元素个数。
redisTemplate.opsForSet().size(entityLikeKey);
1
2

点赞状态:通过 set 集合的 `isMember` 方法实现。
return redisTemplate.opsForSet().isMember(entityLikeKey, userId) ? 1 : 0;
1
2


public void like(int userId, int entityType, int entityId, int entityUserId) { redisTemplate.execute(new SessionCallback() { @Override public Object execute(RedisOperations operations) throws DataAccessException { String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId); String userLikeKey = RedisKeyUtil.getUserLikeKey(entityUserId);
        boolean isMember = operations.opsForSet().isMember(entityLikeKey, userId);

        operations.multi();

        if (isMember) {
            operations.opsForSet().remove(entityLikeKey, userId);
            operations.opsForValue().decrement(userLikeKey);
        } else {
            operations.opsForSet().add(entityLikeKey, userId);
            operations.opsForValue().increment(userLikeKey);
        }

        return operations.exec();
    }
});
1
2


@Configuration
public class RedisConfig {

@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
    RedisTemplate<String, Object> template = new RedisTemplate<>();
    template.setConnectionFactory(factory);

    // 设置key的序列化方式
    template.setKeySerializer(RedisSerializer.string());
    // 设置value的序列化方式
    template.setValueSerializer(RedisSerializer.json());
    // 设置hash的key的序列化方式
    template.setHashKeySerializer(RedisSerializer.string());
    // 设置hash的value的序列化方式
    template.setHashValueSerializer(RedisSerializer.json());

    template.afterPropertiesSet();
    return template;
}

}

1
2


// 某个实体的赞
// like:entity:entityType:entityId -> set(userId)
public static String getEntityLikeKey(int entityType, int entityId) {
return PREFIX_ENTITY_LIKE + SPLIT + entityType + SPLIT + entityId;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

### 点赞的改进

点赞是如何设计的?
如果用户量很多,你会怎么设计点赞?

点赞作为一个高频率的操作,如果每次操作都读写数据库会增加数据库的压力,所以采用缓存+定时任务来实现。点赞数据是在redis中缓存半小时,同时定时任务是每隔5分钟执行一次,做持久化存储,这里的缓存时间和任务执行时间可根据项目情况而定。

优化一下,如何减少对数据库的访问?(给了个好一点的方案)

考虑一下在MQ后面做处理?

---

### 关注和粉丝

在 RedisUnitl 工具类增加两个方法

- 通过用户 id 和实体类型获得用户关注的实体集合的 key。

- 通过实体类型和实体 id 获得实体拥有的粉丝集合的 key。

当用户关注某实体时,

- 将实体 id 和时间作为 value 和 score 加入用户的关注集合。
- 将用户 id 和时间作为 value 和 score 加入实体的粉丝集合。

当用户取消关注某实体时,将实体从用户的关注集合移除,用户从实体的粉丝集合移除。

**关注列表和粉丝列表**

需要三个变量

int userId, int entityType, int entityId

1
2
3
4
5
6
7
8

某人的关注,就以userId和关注的类型entityType作为key,以entityID作为value进行设置,传入当前时间作为zset的score

某个实体的粉丝,就以entityType, entityId作为key,以userID作为value进行设置,传入当前时间作为zset的score

- 用户的关注列表,通过 zset 的 `reverseRange` 获取 value 即关注用户的 userId,再查询出 user,通过 `score` 获取关注时间。
- 用户的粉丝列表,通过 zset 的 `reverseRange` 获取 value 即粉丝的 userId,再查询出 user,通过 `score` 获取关注时间。
- 列表信息封装在 list 集合中,再将 list 添加到 Model 对象里。

public void follow(int userId, int entityType, int entityId) {
redisTemplate.execute(new SessionCallback() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType);
String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId);

        operations.multi();

        operations.opsForZSet().add(followeeKey, entityId, System.currentTimeMillis());
        operations.opsForZSet().add(followerKey, userId, System.currentTimeMillis());

        return operations.exec();
    }
});

}

public void unfollow(int userId, int entityType, int entityId) {
redisTemplate.execute(new SessionCallback() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType);
String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId);

        operations.multi();

        operations.opsForZSet().remove(followeeKey, entityId);
        operations.opsForZSet().remove(followerKey, userId);

        return operations.exec();
    }
});

}

1
2


// 查询某用户关注的人
public List<Map<String, Object>> findFollowees(int userId, int offset, int limit) {
String followeeKey = RedisKeyUtil.getFolloweeKey(userId, ENTITY_TYPE_USER);
Set targetIds = redisTemplate.opsForZSet().reverseRange(followeeKey, offset, offset + limit - 1);

if (targetIds == null) {
    return null;
}

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;

}

1
2


// 查询某用户的粉丝
public List<Map<String, Object>> findFollowers(int userId, int offset, int limit) {
String followerKey = RedisKeyUtil.getFollowerKey(ENTITY_TYPE_USER, userId);
Set targetIds = redisTemplate.opsForZSet().reverseRange(followerKey, offset, offset + limit - 1);

if (targetIds == null) {
    return null;
}

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(followerKey, targetId);
    map.put("followTime", new Date(score.longValue()));
    list.add(map);
}

return list;

}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

---

### 关注的改进

论坛中的关注操作是怎么做的?并发量高了后怎么优化?











### Kafka

### 发送系统通知

在 CommunityConstant 接口中新增三个常量,代表三个主题:评论、点赞、关注。

创建 Event 类,封装事件对象,包括主题、用户 id、实体类型、实体 id、实体用户 id 以及一个 map 集合存放其它信息。

public class Event {

private String topic;
private int userId;
private int entityType;
private int entityId;
private int entityUserId;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

#### 触发事件

创建 EventProducer 事件生产者,新增 `fireEvent(Event event)` 方法,通过 Event 获取事件类型,并将其封装成 JSON 数据,然后调用注入的 KafkaTemplate 实例的 send 方法发送。

在 CommentController、LikeControler、FollowController 中注入 EventProducer 实例,分别重构 `addComment` 方法、`like` 方法、`follow` 方法,封装 Event 对象,然后调用 EventProducer 的`fireEvent` 方法发布通知。

#### 消费事件

创建 EventConsumer 事件消费者,消费者是被动触发的。

- 注入 MessageService 实例。

- 增加 `handleCommentMessage(ConsumerRecord record)` 方法,通过 `@KafkaListener` 注解,topic 包括了评论、点赞和关注。从 recored 中获取信息,封装成 Message 对象然后调用 `addMessage` 方法插入数据库。

// 发送站内通知
Message message = new Message();
message.setFromId(SYSTEM_USER_ID);
message.setToId(event.getEntityUserId());
message.setConversationId(event.getTopic());
message.setCreateTime(new Date());

【问题】没有向数据库插入系统通知记录,原因是 ServiceLogAspect 类进行日志处理时要获取 ServletRequestAttributes 请求对象,Kafka 的消费事件是自动触发的,没有进行新的请求,产生了请求对象的空指针异常。

---

### 显示系统通知

public class Message {

private int id;
private int fromId;
private int toId;
private String conversationId;
private String content;
private int status;
private Date createTime;
}
1
2

#### 通知列表

// 修改消息的状态
int updateStatus(List ids, int status);

// 查询某个主题下最新的通知
Message selectLatestNotice(int userId, String topic);

// 查询某个主题所包含的通知数量
int selectNoticeCount(int userId, String topic);

// 查询未读的通知的数量
int selectNoticeUnreadCount(int userId, String topic);

// 查询某个主题所包含的通知列表
List<Message> selectNotices(int userId, String topic, int offset, int limit);
1
2

在 MessageMapper 接口中

map.put(“unreadCount”, messageService.findLetterUnreadCount(user.getId(), message.getConversationId()));

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

- 新增 `selectLatestNotice(int userId, String topic)` 方法,查询某主题最新的通知。
- 新增 `selectNoticeCount(int userId, String topic)` 方法,查询某主题通知的数量。

- 新增 `selectNoticeUnreadCount(int userId, String topic)` 方法,查询未读通知的数量。

- 在 `message-mapper.xml` 配置三个方法的 sql 语句,其中查询未读通知时使用 if 动态语句,如果没有传入 topic 就查询未读总量。

在业务层的 MessageService 中

- 新增 `findLatestNotice` 方法,调用 `selectLatestNotice` 方法查询最新通知。

- 新增 `findNoticeCount` 方法,调用 `selectNoticeCount` 方法查询某主题通知的数量。
- 新增 `findNoticeUnreadCount` 方法,调用 `selectNoticeUnreadCount` 方法查询未读通知的数量。

在表现层的 MessageController 中新增 `getNoticeList` 方法,获取通知列表

- 调用业务层 MessageService 的方法查询评论、点赞、关注的通知,将其封装在一个 HashMap 集合中然后添加到 Model 对象里。
- 调用业务层 MessageService 的方法查询私信和通知的总未读数量,添加到 Model 对象里。
- 返回 `notice.html` 页面。

---

#### 显示通知详情

在 MessageMapper 接口新增 `selectNotices` 方法,查询某个主题的通知列表,在 `message-mapper.xml` 配置 SQL。

在业务层的 MessageService 中新增 `findNotices` 方法,调用 `selectNotices` 方法。

在表现层的 MessageController 中新增 `getNoticeDetail` 方法

- 调用 `findNotices` 方法获取通知列表详情,封装到 List 集合并存入 Model 对象。
- 从通知集合中获取 id 集合,调用 `readMessage` 方法将消息设为已读。
- 返回 `notice-detail.html` 页面。

2已读
0未读

1
2
3
4
5
6
7
8
9
10
11

---

#### 显示未读通知总数

创建 MessageInterceptor 拦截器

- 注入 MessageService 实例和 HostHolder 实例。
- 重写 `postHandle` 方法,查询私信和通知的未读数量和,然后添加到 ModelAndView 对象。

在 WebConfig 中注入 MessageInterceptor 实例,并在 `addInterceptors` 方法中添加该拦截器。
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
    User user = hostHolder.getUser();
    if (user != null && modelAndView != null) {
        int letterUnreadCount = messageService.findLetterUnreadCount(user.getId(), null);
        int noticeUnreadCount = messageService.findNoticeUnreadCount(user.getId(), null);
        modelAndView.addObject("allUnreadCount", letterUnreadCount + noticeUnreadCount);
    }
1
2
3
4
5
6

---

**发送系统通知**

创建 Event 类,封装事件对象,包括主题(评论、点赞、关注)、用户 id、实体类型、实体 id,以及一个 map 集合存放其它信息.

public class Event {

private String topic;
private int userId;
private int entityType;
private int entityId;
private int entityUserId;
private Map<String, Object> data = new HashMap<>();
}
1
2
3
4

**触发事件**

通过 Event 获取事件类型,并将其封装成 JSON 数据,然后调用注入的 KafkaTemplate 实例的 send 方法发送。

public class EventProducer {

@Autowired
private KafkaTemplate kafkaTemplate;

// 处理事件
public void fireEvent(Event event) {
    // 将事件发布到指定的主题
    kafkaTemplate.send(event.getTopic(), JSONObject.toJSONString(event));
}

}

1
2


// 触发发帖事件
Event event = new Event()
.setTopic(TOPIC_PUBLISH)
.setUserId(user.getId())
.setEntityType(ENTITY_TYPE_POST)
.setEntityId(post.getId());
eventProducer.fireEvent(event);

1
2
3
4

**消费事件**

通过 `@KafkaListener` 注解,topic 包括了评论、点赞和关注。从 recored 中获取信息,封装成 Message 对象然后调用 `addMessage` 方法插入数据库。
// 消费发帖事件
@KafkaListener(topics = {TOPIC_PUBLISH})
public void handlePublishMessage(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;
    }

    DiscussPost post = discussPostService.findDiscussPostById(event.getEntityId());
    elasticsearchService.saveDiscussPost(post);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

---

### UV

Unique Visitor 独立游客

通过用户IP排重统计数据。

### DAU

Daily Active User 日活跃用户

通过用户ID排重统计数据

![image-20201115211418953](C:\Users\77406\AppData\Roaming\Typora\typora-user-images\image-20201115211418953.png)

首先就在preHandle那里,通过HttpServletRequest的getRemoteHost()来获取相应的IP,随后以日期为key,ip为value进行插入opsForHyperLogLog().add来加入。如果我们要统计一定时间的UV的话,我们会有一个Date类型的start和end,接下来 使用Calendar 来对时间进行遍历,存到一个List里面,然后通过opsForHyperLogLog().union(redisKey, keyList.toArray())来合并,随后通过opsForHyperLogLog().size(redisKey)来返回统计的结果
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    // 统计UV
    String ip = request.getRemoteHost();
    dataService.recordUV(ip);

    // 统计DAU
    User user = hostHolder.getUser();
    if (user != null) {
        dataService.recordDAU(user.getId());
    }

    return true;
}
1
2


// 将指定的IP计入UV
public void recordUV(String ip) {
String redisKey = RedisKeyUtil.getUVKey(df.format(new Date()));
redisTemplate.opsForHyperLogLog().add(redisKey, ip);
}

// 统计指定日期范围内的UV
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(df.format(calendar.getTime()));
    keyList.add(key);
    calendar.add(Calendar.DATE, 1);
}

// 合并这些数据
String redisKey = RedisKeyUtil.getUVKey(df.format(start), df.format(end));
redisTemplate.opsForHyperLogLog().union(redisKey, keyList.toArray());

// 返回统计的结果
return redisTemplate.opsForHyperLogLog().size(redisKey);

}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

DAU的计算

public void recordDAU(int userId) {
String redisKey = RedisKeyUtil.getDAUKey(df.format(new Date()));
redisTemplate.opsForValue().setBit(redisKey, userId, true);
}

// 统计指定日期范围内的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(df.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(df.format(start), df.format(end));
connection.bitOp(RedisStringCommands.BitOperation.OR,
redisKey.getBytes(), keyList.toArray(new byte[0][0]));
return connection.bitCount(redisKey.getBytes());
}
});
}
### Guava

@PostConstruct
public void init() {
// 初始化帖子列表缓存
postListCache = Caffeine.newBuilder()
.maximumSize(maxSize)
.expireAfterWrite(expireSeconds, TimeUnit.SECONDS)
.build(new CacheLoader<String, List>() {
@Nullable
@Override
public List load(@NonNull String key) throws Exception {
if (key == null || key.length() == 0) {
throw new IllegalArgumentException(“参数错误!”);
}

                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]);

                // 二级缓存: Redis -> mysql

                logger.debug("load post list from DB.");
                return discussPostMapper.selectDiscussPosts(0, offset, limit, 1);
            }
        });
// 初始化帖子总数缓存
postRowsCache = Caffeine.newBuilder()
        .maximumSize(maxSize)
        .expireAfterWrite(expireSeconds, TimeUnit.SECONDS)
        .build(new CacheLoader<Integer, Integer>() {
            @Nullable
            @Override
            public Integer load(@NonNull Integer key) throws Exception {
                logger.debug("load post rows from DB.");
                return discussPostMapper.selectDiscussPostRows(key);
            }
        });

}

1
2
3
4
5
6
7
8
9
10

### Quartz

Quartz

- 调度器:Scheduler
- 任务:JobDetail
- 触发器:Trigger,包括SimpleTrigger和CronTrigger

![这里写图片描述](https://img-blog.csdn.net/20180710135431806?watermark/2/text/aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L25vYW1hbl93Z3M=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70)

// FactoryBean可简化Bean的实例化过程:
// 1.通过FactoryBean封装Bean的实例化过程.
// 2.将FactoryBean装配到Spring容器里.
// 3.将FactoryBean注入给其他的Bean.
// 4.该Bean得到的是FactoryBean所管理的对象实例.

// 刷新帖子分数任务
@Bean
public JobDetailFactoryBean postScoreRefreshJobDetail() {
    JobDetailFactoryBean factoryBean = new JobDetailFactoryBean();
    factoryBean.setJobClass(PostScoreRefreshJob.class);
    factoryBean.setName("postScoreRefreshJob");
    factoryBean.setGroup("communityJobGroup");
    factoryBean.setDurability(true);
    factoryBean.setRequestsRecovery(true);
    return factoryBean;
}

@Bean
public SimpleTriggerFactoryBean postScoreRefreshTrigger(JobDetail postScoreRefreshJobDetail) {
    SimpleTriggerFactoryBean factoryBean = new SimpleTriggerFactoryBean();
    factoryBean.setJobDetail(postScoreRefreshJobDetail);
    factoryBean.setName("postScoreRefreshTrigger");
    factoryBean.setGroup("communityTriggerGroup");
    factoryBean.setRepeatInterval(1000 * 60 * 5);
    factoryBean.setJobDataMap(new JobDataMap());
    return factoryBean;
}
1
2
3
4

log(评论数*10+点赞数*2)

新加帖子,先把id作为value加入、

Comment
String redisKey = RedisKeyUtil.getPostScoreKey();
redisTemplate.opsForSet().add(redisKey, discussPostId);
点赞
// 计算帖子分数
String redisKey = RedisKeyUtil.getPostScoreKey();
redisTemplate.opsForSet().add(redisKey, postId);

1
2

不需要刷新
String redisKey = RedisKeyUtil.getPostScoreKey();
BoundSetOperations operations = redisTemplate.boundSetOps(redisKey);

if (operations.size() == 0) {
    logger.info("[任务取消] 没有需要刷新的帖子!");
    return;
}
1
2


epoch = new SimpleDateFormat(“yyyy-MM-dd HH:mm:ss”).parse(“2014-08-01 00:00:00”);

double score = Math.log10(Math.max(w, 1))
+ (post.getCreateTime().getTime() - epoch.getTime()) / (1000 * 3600 * 24);

1
2

页面的排序

@RequestParam(name = “orderMode”, defaultValue = “0”)

1
2


<select id="selectDiscussPosts" resultType="DiscussPost">
    select <include refid="selectFields"></include>
    from discuss_post
    where status != 2
    <if test="userId!=0">
        and user_id = #{userId}
    </if>
    <if test="orderMode==0">
        order by type desc, create_time desc
    </if>
    <if test="orderMode==1">
        order by type desc, score desc, create_time desc
    </if>
    limit #{offset}, #{limit}
</select>
1
2
3
4

### Spring Security的用法

配置

@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",
                    "/user/upload",
                    "/discuss/add",
                    "/comment/add/**",
                    "/letter/**",
                    "/notice/**",
                    "/like",
                    "/follow",
                    "/unfollow"
            )
            .hasAnyAuthority(
                    AUTHORITY_USER,
                    AUTHORITY_ADMIN,
                    AUTHORITY_MODERATOR
            )
            .antMatchers(
                    "/discuss/top",
                    "/discuss/wonderful"
            )
            .hasAnyAuthority(
                    AUTHORITY_MODERATOR
            )
            .antMatchers(
                    "/discuss/delete",
                    "/data/**"
            )
            .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 {
                    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(403, "你还没有登录哦!"));
                    } else {
                        response.sendRedirect(request.getContextPath() + "/login");
                    }
                }
            })
            .accessDeniedHandler(new AccessDeniedHandler() {
                // 权限不足
                @Override
                public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
                    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(403, "你没有访问此功能的权限!"));
                    } else {
                        response.sendRedirect(request.getContextPath() + "/denied");
                    }
                }
            });

    // Security底层默认会拦截/logout请求,进行退出处理.
    // 覆盖它默认的逻辑,才能执行我们自己的退出代码.
    http.logout().logoutUrl("/securitylogout");
1
2

UserService

public Collection<? extends GrantedAuthority> getAuthorities(int userId) {
User user = this.findUserById(userId);

    List<GrantedAuthority> list = new ArrayList<>();
    list.add(new GrantedAuthority() {

        @Override
        public String getAuthority() {
            switch (user.getType()) {
                case 1:
                    return AUTHORITY_ADMIN;
                case 2:
                    return AUTHORITY_MODERATOR;
                default:
                    return AUTHORITY_USER;
            }
        }
    });
    return list;
}
1
2
3
4

LoginTicket

存入SecurityContext,以便于Security进行授权

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 从cookie中获取凭证
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;
}
1
2
3
4
5
6





继承ElasticsearchRepository<DiscussPost, Integer>,第一个参数,要存储的数据的实体类型,第二个参数是ID

@Repository
public interface DiscussPostRepository extends ElasticsearchRepository<DiscussPost, Integer> {

}

1
2
3
4
5
6
7
8
9
10

### ElasticSearch

# 一、Field datatype(字段数据类型)

## 1.1string类型

ELasticsearch 5.X之后的字段类型不再支持string,由text或keyword取代。 如果仍使用string,会给出警告。

测试:

PUT my_index
{
“mappings”: {
“my_type”: {
“properties”: {
“title”: {
“type”: “string”
}
}
}
}
}

1
2

结果:

#! Deprecation: The [string] field is deprecated, please use [text] or [keyword] instead on [title]
{
“acknowledged”: true,
“shards_acknowledged”: true
}

1
2
3
4
5
6

## 1.2 text类型

text取代了string,当一个字段是要被全文搜索的,比如Email内容、产品描述,应该使用text类型。设置text类型以后,字段内容会被分析,在生成倒排索引以前,字符串会被分析器分成一个一个词项。text类型的字段不用于排序,很少用于聚合(termsAggregation除外)。

把full_name字段设为text类型的Mapping如下:

PUT my_index
{
“mappings”: {
“my_type”: {
“properties”: {
“full_name”: {
“type”: “text”
}
}
}
}
}

```

1.3 keyword类型

keyword类型适用于索引结构化的字段,比如email地址、主机名、状态码和标签。如果字段需要进行过滤(比如查找已发布博客中status属性为published的文章)、排序、聚合。keyword类型的字段只能通过精确值搜索到。

其他

编译期和运行时注解的区别

怎么样去实现一个编译型的注解

注解是如何注入的(不是声明,是底层原理!)通用的逻辑注入的原理,是这样完成的

lamda为什么能推导出哪个类型(参数中为什么只写x,y就够了)

mybatis mapper为啥不需要实现类,jdk动态代理原理