7.项目进阶,构建安全高效的企业服务

文章目录

  • 项目进阶,构建安全高效的企业服务
    • 1. Spring Security
      • 介绍
      • 使用
    • 2. 权限控制
      • 登录检查
      • 授权配置
      • 认证方案
      • CSRF配置
    • 3. 置顶、加精、删除
      • 功能实现
      • 权限管理
      • 按钮显示
    • 4. Redis高级数据类型
      • HyperLoglog
      • Bitmap
    • 5. 网站数据统计
      • UV(Unique Visitor)
      • DAU(Daily Active User)
    • 6. 任务执行和调度
      • JDK线程池
      • Spring 线程池
      • 分布式定时任务
    • 7. 热帖排行
    • 8. 生成长图
    • 9. 将文件上传至云服务器
    • 10. 优化网站性能

项目源码可以在 https://gitee.com/ShayneC/community获取

项目进阶,构建安全高效的企业服务

1. Spring Security

介绍

  • 简介
    • Spring Security是一个专注与为Java应用程序提供身份认证和授权的框架,它的强大之处在于它可以轻松扩展以满足自定义的需求。
  • 特征
    • 对身份的认证和授权提供全面的、可扩展的支持。
    • 防止各种攻击,如会话固定攻击、点击劫持、csrf攻击等。
    • 支持与Servelt API、Spring MVC等Web技术集成。
  • 原理
    • 底层使用Filter(javaEE标准)进行拦截
    • Filter–>DispatchServlet–>Interceptor–>Controller(后三者属于Spring MVC)
  • 推荐学习网站:www.spring4all.com
    • 看几个核心的Filter源码

使用

  • 导包:spring-boot-starter-security

  • User实体类实现UserDetails接口,实现接口中各方法(账号、凭证是否可用过期,管理权限)

  • UserService实现UserDetailsService接口,实现接口方法(security检查用户是否登录时用到该接口)

  • 新建SecurityConfig类

    • 继承WebSecurityConfigurerAdapter
    • 配置忽略静态资源的访问
    • 实现认证的逻辑,自定义认证规则(AuthenticationManager: 认证的核心接口)
      • 登录相关配置
      • 退出相关配置
    • 委托模式: ProviderManager将认证委托给AuthenticationProvider.
    • 实现授权的逻辑
      • 授权配置
      • 增加Filter,处理验证码
      • 记住我
  • 重定向,浏览器访问A,服务器返回302,建议访问B.一般不能带数据给B(Session和Cookie)

  • 转发,浏览器访问A,A完成部分请求,存入Request,转发给B完成剩下请求。(有耦合)

  • 在HomeController添加认证逻辑

    • 认证成功后,结果会通过SecurityContextHolder存入SecurityContext中.

2. 权限控制

登录检查

  • 之前采用拦截器实现了登录检查,这是简单的权限管理方案,现在将废弃。
    • 修改WebMvcConfig,将loginRequiredInterceptor注释。

授权配置

  • 对当前系统内的所有的请求,分配访问权限(普通用户、板主、管理员)。
    • 新建SecurityConfig类,配置静态资源都可以访问
    • 配置授权操作,以及权限不够时的处理

认证方案

  • 绕过Security认证流程,采用系统原来的认证方案。
    • Security底层默认会拦截/logout请求,进行退出处理。覆盖它默认的逻辑,才能执行我们自己的退出代码.
    • 这里没有用Security进行认证,需要将结果自己存入SecurityContext
    • UserService增加查询用户权限方法
    • 在LoginTicketInterceptor,构建用户认证的结果,并存入SecurityContext,以便于Security进行授权.

首先弃用原来定义的LoginRequiredInterceptor拦截器,

然后再src/main/java/com/nowcoder/community/config包下新建SecurityConfig类,配置spring security功能

@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",
                        "/user/upload",
                        "/discuss/add/**",
                        "/comment/add/**",
                        "/letter/**",
                        "/notice/**",
                        "/like",
                        "/follow",
                        "/unfollow"
                )
                .hasAnyAuthority(
                        AUTHORITY_USER,
                        AUTHORITY_ADMIN,
                        AUTHORITY_MODERATOR
                )
                .anyRequest().permitAll()
                .and().csrf().disable(); //关闭csrf功能

        // 权限不够时的处理
        http.exceptionHandling()
                .authenticationEntryPoint(new AuthenticationEntryPoint() {
                    // 没有登录
                    @Override
                    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) 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 accessDeniedException) 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");
    }
}

然后在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;
    }

其次修改LoginTicketInterceptor中preHandle方法,在获取用户登录凭证信息时将用户认证的结果存入SecurityContext,便于spring 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;
}

并在其afterCompletion中清除SecurityContext 的信息

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    hostHolder.clear();
    SecurityContextHolder.clearContext();
}

同时在LoginController的logout方法也清除SecurityContext 的信息

@GetMapping("/logout")
    public String logout(@CookieValue("ticket") String ticket) {
        userService.logout(ticket);
        SecurityContextHolder.clearContext();
        return "redirect:/login";
    }

最后在HomeController中增加返回错误页面的请求方法,即访问者权限不足时返回的页面

@RequestMapping(path = "/denied", method = RequestMethod.GET)
public String getDeniedPage() {
    return "/error/404";
}

CSRF配置

  • 防止CSRF攻击的基本原理,以及表单、AJAX的相关配置。
    • CSRF攻击:某网站盗取你的Cookie(ticket)凭证,模拟你的身份访问服务器。(发生在提交表单的时候)
    • Security会在表单里增加一个TOCKEN(自动生成)
    • 异步请求Security无法处理,在html文件生成CSRF令牌,(异步不是通过请求体传数据,通过请求头)
    • 发送AJAX请求之前,将CSRF令牌设置到请求的消息头中.

3. 置顶、加精、删除

功能实现

  • 点击“置顶”、“加精”、“删除”,修改帖子的状态
    • 在DiscussPostMapper增加修改方法
    • DiscussPostService、DiscussPostController相应增加方法,注意在Es中同步变化
    • 要在EventConsumer增加消费删帖事件
    • 修改html和js文件

权限管理

  • 版主可以执行“置顶”、“加精”操作。管理员可以执行“删除”操作。
    • 在SecurityConfig类下配置,置顶、加精、删除的访问权限。

按钮显示

  • 版主可以看到“置顶”、“加精”按钮。管理员可以看到“删除“按钮。
    • 导包:thymeleaf-extras-springsecurity5,thymeleaf对security的支持。

在DiscussPostMapper,DiscussPostService中增加修改帖子类型和状态的方法,即置顶、加精、删除三个功能,实现起来较简单,此处省略。

在DiscussPostController中增加置顶、加精、删除的异步请求方法

// 置顶
@RequestMapping(path = "/top", method = RequestMethod.POST)
@ResponseBody
public String setTop(int id) {
    discussPostService.updateType(id, 1);

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

    return CommunityUtil.getJSONString(200);
}

// 加精
@RequestMapping(path = "/wonderful", method = RequestMethod.POST)
@ResponseBody
public String setWonderful(int id) {
    discussPostService.updateStatus(id, 1);

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

    return CommunityUtil.getJSONString(200);
}

// 删除
@RequestMapping(path = "/delete", method = RequestMethod.POST)
@ResponseBody
public String setDelete(int id) {
    discussPostService.updateStatus(id, 2);

    // 触发删帖事件
    Event event = new Event()
            .setTopic(TOPIC_DELETE)
            .setUserId(hostHolder.getUser().getId())
            .setEntityType(ENTITY_TYPE_POST)
            .setEntityId(id);
    eventProducer.fireEvent(event);

    return CommunityUtil.getJSONString(200);
}

由于删帖需要同步es服务器的数据,所以需要在EventConsumer中增加消费删帖的事件

// 消费删帖事件
@KafkaListener(topics = {TOPIC_DELETE})
public void handleDeleteMessage(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;
    }

    elasticsearchService.deleteDiscussPost(event.getEntityId());
}

为使得不同用户拥有不同的权限,在SecurityConfig中配置用户的权限,并处理在未登录和权限不足时的请求

@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",
                        "/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")
                .hasAnyAuthority(AUTHORITY_ADMIN)
                .anyRequest().permitAll()
                .and().csrf().disable(); //关闭csrf功能

        // 权限不够时的处理
        http.exceptionHandling()
                .authenticationEntryPoint(new AuthenticationEntryPoint() {
                    // 没有登录
                    @Override
                    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) 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 accessDeniedException) 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");
    }
}

为了spring security能够识别用户的认证信息,在LoginTicketInterceptor中的preHandle方法里加入SecurityContext

@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;
}

最后修改discuss-detail.html和discuss.js文件(发送ajax请求)

$(function () {
   $("#topBtn").click(setTop);
   $("#wonderfulBtn").click(setWonderful);
   $("#deleteBtn").click(setDelete);
});

function like(btn, entityType, entityId, entityUserId, postId) {
    $.post(
        CONTEXT_PATH + "/like",
        {"entityType":entityType, "entityId":entityId, "entityUserId":entityUserId, "postId":postId},
        function (data) {
            data = $.parseJSON(data);
            if (data.code == 200) {
                $(btn).children("i").text(data.likeCount);
                $(btn).children("b").text(data.likeStatus==1?"已赞":"赞");
            } else {
                alert(data.msg);
            }
        }
    )
}

// 置顶
function setTop() {
    $.post(
        CONTEXT_PATH + "/discuss/top",
        {"id":$("#postId").val()},
        function (data) {
            data = $.parseJSON(data);
            if (data.code == 200) {
                $("#topBtn").attr("disabled", "disabled");
            } else {
                alert(data.msg);
            }
        }
    )
}

// 加精
function setWonderful() {
    $.post(
        CONTEXT_PATH + "/discuss/wonderful",
        {"id":$("#postId").val()},
        function (data) {
            data = $.parseJSON(data);
            if (data.code == 200) {
                $("#wonderfulBtn").attr("disabled", "disabled");
            } else {
                alert(data.msg);
            }
        }
    )
}

// 删除
function setDelete() {
    $.post(
        CONTEXT_PATH + "/discuss/delete",
        {"id":$("#postId").val()},
        function (data) {
            data = $.parseJSON(data);
            if (data.code == 200) {
                location.href = CONTEXT_PATH + "/index";
            } else {
                alert(data.msg);
            }
        }
    )
}

4. Redis高级数据类型

HyperLoglog

  • 采用一种基数算法,用于完成独立总数的统计。
  • 占据空间小,无论统计多少个数据,只占12K的内存空间。
  • 不精确的统计算法,标准误差为0.81%。

Bitmap

  • 不是一种独立的数据结构,实际上就是字符串。
  • 支持按位存取数据,可以将其看成是byte数组。
  • 适合存储大量的连续的数据的布尔值。

5. 网站数据统计

UV(Unique Visitor)

  • 独立访客,需通过用户IP排重新统计数据。
  • 每次访问都要进行统计。
  • HyperLoglog,性能好,且存储空间小。

DAU(Daily Active User)

  • 日活跃用户,需通过用户ID排重新统计数据。
  • 访问过一次,则认为其为活跃。QW
  • Bitmap,性能好、且可以统计精确的结果。

新建DataService类进行统计操作。表现层一分为二,首先是何时记录这个值,其次是查看。记录值在拦截器写比较合适。新建DataInterceptor和DataController。

返回时使用forward转发,表明当前请求仅完成一半,还需另外一个方法继续处理请求。

为了统计网站数据,我们使用redis中的HyperLogLog和BitMap两种数据结果分别统计UV和DAU。

首先在RedisKeyUtil中增加生成UV和DAU数据中键的方法

// 单日UV
public static String getUVKey(String date) {
    return PREFIX_UV + SPLIT + date;
}

// 区间UV
public static String getUVKey(String startDate, String endDate) {
    return PREFIX_UV + SPLIT + startDate + SPLIT + endDate;
}

// 单日活跃用户
public static String getDAUKey(String date) {
    return PREFIX_DAU + SPLIT + date;
}

// 区间活跃用户
public static String getDAUKey(String startDate, String endDate) {
    return PREFIX_DAU + SPLIT + startDate + SPLIT + endDate;
}

在src/main/java/com/nowcoder/community/service包下新建DataService的业务类,生成对应的数据

@Service
public class DataService {

    @Autowired
    private RedisTemplate redisTemplate;

    private SimpleDateFormat df = new SimpleDateFormat("yyyyMMdd");

    // 将指定的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);

    }

    // 将指定用户计入DAU
    public void recordDAU(int userId) {
        String redisKey = RedisKeyUtil.getDAUKey(df.format(new Date()));
        redisTemplate.opsForValue().setBit(redisKey, userId, true);
    }

    // 统计指定日期范围内的UV
    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());
            }
        });
    }
}

为了网站能够记录用户访问量,可以新建一个DataInterceptor拦截器

@Component
public class DataInterceptor implements HandlerInterceptor {

    @Autowired
    private DataService dataService;

    @Autowired
    private HostHolder hostHolder;

    @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;
    }
}

然后在WebMvcConfig中配置拦截器,使其生效

在src/main/java/com/nowcoder/community/controller包下新建DataController发起请求,获得访问数据

@Controller
public class DataController {

    @Autowired
    private DataService dataService;

    // 打开统计页面
    @RequestMapping(path = "/data", method = {RequestMethod.GET, RequestMethod.POST})
    public String getDataPage() {
        return "/site/admin/data";
    }

    // 处理统计网站UV
    @RequestMapping(path = "/data/uv", method = RequestMethod.POST)
    public String getUV(@DateTimeFormat(pattern = "yyyy-MM-dd") Date start,
                        @DateTimeFormat(pattern = "yyyy-MM-dd") Date end, Model model) {
        long uv = dataService.calculateUV(start, end);
        model.addAttribute("uvResult", uv);
        model.addAttribute("uvStartDate", start);
        model.addAttribute("uvEndDate", end);

        return "forward:/data";
    }

    // 统计活跃用户
    @RequestMapping(path = "/data/dau", method = RequestMethod.POST)
    public String getDAU(@DateTimeFormat(pattern = "yyyy-MM-dd") Date start,
                        @DateTimeFormat(pattern = "yyyy-MM-dd") Date end, Model model) {
        long dau = dataService.calculateDAU(start, end);
        model.addAttribute("dauResult", dau);
        model.addAttribute("dauStartDate", start);
        model.addAttribute("dauEndDate", end);

        return "forward:/data";
    }
}

最后修改data.html使数据能够显示,并在SecurityConfig中配置使得只有管理员能够访问此页面

6. 任务执行和调度

JDK线程池

  • ExecutorService
  • ScheduledExecutorService(可以执行定时任务)

Spring 线程池

  • ThreadPoolTaskExecutor
  • ThreadPoolTaskScheduler(分布式环境可能出问题)

分布式定时任务

  • Spring Quartz(将数据存储到数据库,分布式时可以共享数据)

    • 核心调度接口Scheduler
    • 定义任务的接口Job的execute方法
    • Jobdetail接口来配置Job的名字、组等
    • Trigger接口配置Job的什么时候运行、运行频率
    • QuartzConfig:配置 -> 数据库 -> 调用
  • FactoryBean可简化Bean的实例化过程:

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

7. 热帖排行

  • Nowcoder

    • log(精华分 + 评论数 * 10 + 点赞数 * 2)+(发布时间 - 牛客纪元)
    • 在发帖、点赞、加精时计算帖子分数(存入Redis中)
    • 新建PostScoreRefreshJob类进行处理

在RedisKeyUtil中增加生成计算帖子分数所需要的键的方法

private static final String PREFIX_POST = "post";
// 帖子分数
public static String getPostScoreKey() {
    return PREFIX_POST + SPLIT + "score";
}

在发帖、对帖子评论、点赞、加精时将对应的帖子的id加入到redis中

CommentController的addComment方法

@RequestMapping(path = "/add/{discussPostId}", method = RequestMethod.POST)
public String addComment(@PathVariable("discussPostId") int discussPostId, Comment comment) {
    comment.setUserId(hostHolder.getUser().getId());
    comment.setStatus(0);
    comment.setCreateTime(new Date());
    commentService.addComment(comment);

    // 触发评论事件
    Event event = new Event()
            .setTopic(TOPIC_COMMENT)
            .setUserId(hostHolder.getUser().getId())
            .setEntityType(comment.getEntityType())
            .setEntityId(comment.getEntityId())
            .setData("postId", discussPostId);

    if (comment.getEntityType() == ENTITY_TYPE_POST) {
        DiscussPost targe = discussPostService.findDiscussPostById(comment.getEntityId());
        event.setEntityUserId(targe.getUserId());

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

    } else if (comment.getEntityType() == ENTITY_TYPE_COMMENT) {
        Comment target = commentService.findCommentById(comment.getEntityId());
        event.setEntityUserId(target.getUserId());
    }
    eventProducer.fireEvent(event);

    if (comment.getEntityType() == ENTITY_TYPE_POST) {
        // 触发发帖事件
        event = new Event()
                .setTopic(TOPIC_PUBLISH)
                .setUserId(comment.getUserId())
                .setEntityType(ENTITY_TYPE_POST)
                .setEntityId(discussPostId);
        eventProducer.fireEvent(event);
    }

    return "redirect:/discuss/detail/" + discussPostId;
}

DiscussPostController的addDiscussPost方法

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

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

    // 计算帖子分数
    String redisKey = RedisKeyUtil.getPostScoreKey();
    redisTemplate.opsForSet().add(redisKey, post.getId());

    // 报错的情况将来统一处理
    return CommunityUtil.getJSONString(200, "发布成功");
}

DiscussPostController的setWonderful方法

@RequestMapping(path = "/wonderful", method = RequestMethod.POST)
@ResponseBody
public String setWonderful(int id) {
    discussPostService.updateStatus(id, 1);

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

    // 计算帖子分数
    String redisKey = RedisKeyUtil.getPostScoreKey();
    redisTemplate.opsForSet().add(redisKey, id);

    return CommunityUtil.getJSONString(200);
}

LikeController的like方法

@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<String, Object> map = new HashMap<>();
    map.put("likeCount", likeCount);
    map.put("likeStatus", likeStatus);

    // 触发点赞事件
    if (likeStatus == 1) {
        Event event = new Event()
                .setTopic(TOPIC_LIKE)
                .setUserId(user.getId())
                .setEntityType(entityType)
                .setEntityId(entityId)
                .setEntityUserId(entityUserId)
                .setData("postId", postId);
        eventProducer.fireEvent(event);
    }

    if (entityType == ENTITY_TYPE_POST) {
        // 计算帖子分数
        String redisKey = RedisKeyUtil.getPostScoreKey();
        redisTemplate.opsForSet().add(redisKey, postId);
    }

    return CommunityUtil.getJSONString(200, null, map);
}

在src/main/java/com/nowcoder/community下新建quartz包并创建PostScoreRefreshJob类来更新帖子的分数

public class PostScoreRefreshJob implements Job, CommunityConstant {

private static final Logger logger = LoggerFactory.getLogger(PostScoreRefreshJob.class);

@Autowired
private RedisTemplate redisTemplate;

@Autowired
private DiscussPostService discussPostService;

@Autowired
private LikeService likeService;

@Autowired
private ElasticsearchService elasticsearchService;

// 牛客纪元
private static final Date epoch;

static {
    try {
        epoch = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse("2014-08-01 00:00:00");
    } catch (ParseException e) {
        throw new RuntimeException("初始化牛客纪元失败", e);
    }
}

@Override
public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
    String redisKey = RedisKeyUtil.getPostScoreKey();
    BoundSetOperations operations = redisTemplate.boundSetOps(redisKey);
    if (operations.size() == 0) {
        logger.info("[任务取消] 没有需要刷新的帖子");
        return;
    }
    logger.info("[任务开始] 正在刷新帖子分数:" + operations.size());
    while (operations.size() > 0) {
        this.refresh((Integer) operations.pop());
    }
    logger.info("[任务结束] 帖子分数刷新完毕!");
}

public void refresh(int postId) {
    DiscussPost post = discussPostService.findDiscussPostById(postId);
    if (post == null) {
        logger.error("该帖子不存在:" + 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);
}
}

新建QuartzConfig配置类

@Configuration
public class QuartzConfig {
    
    // 刷新帖子分数任务
    @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;
    }
}

为了在主页上能够根据帖子分数显示热帖排行,我们对DiscussPostMapper的selectDiscussPosts加以修改,以及其对应的discusspost-mapper.xml中的查询语句

List<DiscussPost> selectDiscussPosts(int userId, int offset, int limit, int orderMode);
<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>

然后对HomeController的getIndexPage同时修改,让每次请求携带一个orderMode

@GetMapping("/index")
public String getIndexPage(Model model, Page page, @RequestParam(defaultValue = "0", name = "orderMode") int orderMode) {
    //方法调用前,SpringMVC会自动实例化Model和Page,并将Page注入Model
    //所以,在thymeleaf中可以直接访问Page对象中的数据
    page.setRows(discussPostService.findDiscussPostRows(0));
    page.setPath("/index?orderMode=" + orderMode);

    List<DiscussPost> list = discussPostService.findDiscussPosts(0, page.getOffset(), page.getLimit(), orderMode);
    List<Map<String, Object>> discussPosts = new ArrayList<>();
    if (list != null) {
        for (DiscussPost post : list) {
            HashMap<String, Object> map = new HashMap<>();
            map.put("post", post);
            User user = userService.findUserById(post.getUserId());
            map.put("user", user);

            // 获取帖子点赞数量
            map.put("likeCount", likeService.findEntityLikeCount(ENTITY_TYPE_POST, post.getId()));

            discussPosts.add(map);
        }
    }
    model.addAttribute("discussPosts", discussPosts);
    model.addAttribute("orderMode", orderMode);
    return "/index";
}

最后修改index.html,发起请求并展示数据。

8. 生成长图

  • wkhtmltopdf
    • wkhtmltopdf url file
    • wkhtmltoimage url file
  • java
    • Runtime.getRuntime().exec()

9. 将文件上传至云服务器

  • 客户端上传
    • 客户端将数据提交给云服务器,并等待其响应。
    • 用户上传头像时,将表单数据提交给云服务器。
  • 服务器直传
    • 应用服务器将数据直接提交给云服务器,并等待其响应。
    • 分享时,服务端将自动生成的图片,直接提交给云服务器。

10. 优化网站性能

  • 本地缓存
    • 将数据缓存在应用服务器上,性能最好。
    • 常用缓存工具:Ehcache、Cuava、Caffeine等。
  • 分布式缓存
    • 将数据缓存在NoSQL数据库上,跨服务器。
    • 常用缓存工具:MemCache、Redis等。
  • 多级缓存
    • ->一级缓存(本地缓存)->二级缓存(分布式缓存)-> DB
    • 避免缓存雪崩(缓存失效,大量请求直达DB),提高系统的可用性。

使用Caffeine来优化网站性能,减少频繁地查数据

在application.Properties中配置

# caffeine
caffeine.posts.max-size=15
caffeine.posts.expire-seconds=180

使用caffeine主要用来缓存热门的帖子,对DiscussPostService进行修改并初始化缓存

@Service
public class DiscussPostService {

    private static final Logger logger = LoggerFactory.getLogger(DiscussPostService.class);

    @Autowired
    private DiscussPostMapper discussPostMapper;

    @Autowired
    private SensitiveFilter sensitiveFilter;

    @Value("${caffeine.posts.max-size}")
    private int maxSize;

    @Value("${caffeine.posts.expire-seconds}")
    private int expireSeconds;

    // Caffeine核心接口:Cache, LoadingCache, AsyncLoadingCache

    // 帖子列表缓存
    private LoadingCache<String, List<DiscussPost>> postListCache;

    // 帖子总数的缓存
    private LoadingCache<Integer, Integer> postRowsCache;

    @PostConstruct
    public void init() {
        // 初始化帖子列表缓存
        postListCache = Caffeine.newBuilder()
                .maximumSize(maxSize)
                .expireAfterWrite(expireSeconds, TimeUnit.SECONDS)
                .build(new CacheLoader<String, List<DiscussPost>>() {
                    @Override
                    public @Nullable List<DiscussPost> 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>() {
                    @Override
                    public @Nullable Integer load(@NonNull Integer key) throws Exception {

                        logger.debug("load post rows from DB.");
                        return discussPostMapper.selectDiscussPostRows(key);
                    }
                });
    }

    public List<DiscussPost> findDiscussPosts(int userId, int offset, int limit, int orderMode) {
        if (userId == 0 && orderMode == 1) {
            return postListCache.get(offset + ":" + limit);
        }
        logger.debug("load post list from DB.");
        return discussPostMapper.selectDiscussPosts(userId, offset, limit, orderMode);
    }

    public int findDiscussPostRows(int userId) {
        if (userId == 0) {
            return postRowsCache.get(userId);
        }
        logger.debug("load post rows from DB.");
        return discussPostMapper.selectDiscussPostRows(userId);
    }
    ......
    ......
}

最后可以通过Apache JMeter来测试网站性能

affeine.newBuilder()
.maximumSize(maxSize)
.expireAfterWrite(expireSeconds, TimeUnit.SECONDS)
.build(new CacheLoader() {
@Override
public @Nullable Integer load(@NonNull Integer key) throws Exception {

                    logger.debug("load post rows from DB.");
                    return discussPostMapper.selectDiscussPostRows(key);
                }
            });
}

public List findDiscussPosts(int userId, int offset, int limit, int orderMode) {
    if (userId == 0 && orderMode == 1) {
        return postListCache.get(offset + ":" + limit);
    }
    logger.debug("load post list from DB.");
    return discussPostMapper.selectDiscussPosts(userId, offset, limit, orderMode);
}

public int findDiscussPostRows(int userId) {
    if (userId == 0) {
        return postRowsCache.get(userId);
    }
    logger.debug("load post rows from DB.");
    return discussPostMapper.selectDiscussPostRows(userId);
}
......
......

}


最后可以通过Apache JMeter来测试网站性能

你可能感兴趣的:(牛客社区项目笔记,spring,boot,java)