仿牛客网项目学习笔记(四)

712 阅读5分钟

网站数据统计(Redis:HyperLogLog、BitMap)

1.编写RedisUtil规范Key值

    // UV (网站访问用户数量---根据Ip地址统计(包括没有登录的用户))
    private static final String PREFIX_UV = "uv";
    // DAU (活跃用户数量---根据userId)
    private static final String PREFIX_DAU = "dau";
    
    /**
     * 存储单日ip访问数量(uv)--HyperLogLog ---k:时间 v:ip  (HyperLogLog)
     * 示例:uv:20220526 = ip1,ip2,ip3,...
     */
    public static String getUVKey(String date) {
        return PREFIX_UV + SPLIT + date;
    }

    /**
     * 获取区间ip访问数量(uv)
     * 示例:uv:20220525:20220526 = ip1,ip2,ip3,...
     */
    public static String getUVKey(String startDate, String endDate) {
        return PREFIX_UV + SPLIT + startDate + SPLIT + endDate;
    }

    /**
     * 存储单日活跃用户(dau)--BitMap ---k:date v:userId索引下为true  (BitMap)
     * 示例:dau:20220526 = userId1索引--(true),userId2索引--(true),....
     */
    public static String getDAUKey(String date) {
        return PREFIX_DAU + SPLIT + date;
    }

    /**
     * 获取区间活跃用户
     * 示例:dau:20220526:20220526
     */
    public static String getDAUKey(String startDate, String endDate) {
        return PREFIX_DAU + SPLIT + startDate + SPLIT + endDate;
    }

2.编写DataService业务层

    @Autowired
    private RedisTemplate redisTemplate;

    // 将Date类型转化为String类型
    private SimpleDateFormat df = new SimpleDateFormat("yyyyMMdd");
    
    /*********************** HypeLogLog*************************/
    // 将指定ip计入UV---k:当前时间 v:ip
    public void recordUV(String ip) {
        String redisKey = RedisKeyUtil.getUVKey(df.format(new Date()));
        redisTemplate.opsForHyperLogLog().add(redisKey, ip);
    }

    // 统计指定日期范围内的ip访问数UV
    public long calculateUV(Date start, Date end) {
        if (start == null || end == null) {
            throw new IllegalArgumentException("参数不能为空!");
        }
        if (start.after(end)) {
            throw new IllegalArgumentException("请输入正确的时间段!");
        }
        // 整理该日期范围内的Key
        List<String> keyList = new ArrayList<>();
        Calendar calendar = Calendar.getInstance();
        calendar.setTime(start);
        while (!calendar.getTime().after(end)) {
            // 获取该日期范围内的每一天的Key存入集合
            String key = RedisKeyUtil.getUVKey(df.format(calendar.getTime()));
            keyList.add(key);
            // 日期+1(按照日历格式)
            calendar.add(Calendar.DATE, 1);
        }
        // 合并日期范围内相同的ip
        String redisKey = RedisKeyUtil.getUVKey(df.format(start), df.format(end));
        // 获取keyList中的每一列key进行合并
        redisTemplate.opsForHyperLogLog().union(redisKey, keyList.toArray());

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

    /*********************** BitMap *****************************/
    // 将指定用户计入DAU --k:当前时间 v:userId
    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("参数不能为空!");
        }
        if (start.before(end)) {
            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());
            // 日期+1(按照日历格式)
            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());
            }
        });}

3.在DataInterceptor拦截器中调用Service(每次请求最开始调用)

@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 {
        // 获取请求用户的ip地址,统计UV
        String ip = request.getRemoteHost();
        dataService.recordUV(ip);

        // 统计DAU
        User user = hostHolder.getUser();
        if (user != null) {
            dataService.recordDAU(user.getId());
        }
        return true;
    }
}
/*****************************注册拦截器*********************************/
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Autowired
    private DataInterceptor dataInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(dataInterceptor)
          .excludePathPatterns("/* */*.css", "/ **/ *.js", "/* */*.png", "/**/ *.jpg", "/* */*.jpeg");
    }
}

4.编写DataController用以渲染模板

    /**
     * 统计页面
     */
    @RequestMapping(value = "/data", method = {RequestMethod.GET, RequestMethod.POST})
    public String getDataPage() {
        return "/site/admin/data";
    }
    /**
     * 统计网站UV(ip访问数量)
     * @DateTimeFormat将时间参数转化为字符串
     */
    @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);
        // 转发到 /data请求
        return "forward:/data";
    }
    /**
     * 统计网站DAU(登录用户访问数量)
     */
    @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";
    }

5.编写SecurityConfig进行权限控制

    .antMatchers(
            "/discuss/delete",
            "/data/* *"
    )
    .hasAnyAuthority(
            AUTHORITY_ADMIN
    )

6.编写前端管理员专用页面(核心部分)

  <!-- 网站UV (活跃用户类似)--> 
  <div>
      <h6> 网站 访问人数</h6>
      <form method="post" th:action="@{/data/uv}">
          <input name="start" th:value="${#dates.format(uvStartDate,'yyyy-MM-dd')}" type="date"/>
          <input name="end" th:value="${#dates.format(uvEndDate,'yyyy-MM-dd')}" type="date"/>
          <button type="submit">开始统计</button>
      </form>
      <li>
          统计结果
          <span th:text="${uvResult}">访问人数</span>
      </li> 
  </div>

热帖排行(Quartz线程池、Redis)

1.编写RedisUtil规范Key值

    // 热帖分数 (把需要更新的帖子id存入Redis当作缓存)
    private static final String PREFIX_POST = "post";
    
    /**
     *  帖子分数 (发布、点赞、加精、评论时放入)
     */
    public static String getPostScore() {
        return PREFIX_POST + SPLIT + "score";
    }

2.处理发布、点赞、加精、评论时计算分数,将帖子id存入Key

2.1发布帖子时初始化分数

      /**
       * 计算帖子分数
       * 将新发布的帖子id存入set去重的redis集合------addDiscussPost()
       */
      String redisKey = RedisKeyUtil.getPostScore();
      redisTemplate.opsForSet().add(redisKey, post.getId());

2.2点赞时计算帖子分数

      /**
       * 计算帖子分数
       * 将点赞过的帖子id存入set去重的redis集合------like()
       */
      if (entityType == ENTITY_TYPE_POST) {
          String redisKey = RedisKeyUtil.getPostScore();
          redisTemplate.opsForSet().add(redisKey, postId);
      }

2.3评论时计算帖子分数

      if (comment.getEntityType() == ENTITY_TYPE_POST) {
          /**
          * 计算帖子分数
          * 将评论过的帖子id存入set去重的redis集合------addComment()
          */
          String redisKey = RedisKeyUtil.getPostScore();
          redisTemplate.opsForSet().add(redisKey, discussPostId);
      }

2.4加精时计算帖子分数

      /**
       * 计算帖子分数
       * 将加精的帖子id存入set去重的redis集合-------setWonderful()
       */
      String redisKey = RedisKeyUtil.getPostScore();
      redisTemplate.opsForSet().add(redisKey, id);

3.定义Quartz热帖排行Job

/**热帖排行定时刷新任务**/
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-10-22 00:00:00");
        } catch (ParseException e) {
            throw new RuntimeException("初始化时间失败!", e);
        }
    }

    @Override
    public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
        String redisKey = RedisKeyUtil.getPostScore();
        // 处理每一个key
        BoundSetOperations operations = redisTemplate.boundSetOps(redisKey);

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

        logger.info("[任务开始] 正在刷新帖子分数" + operations.size());
        while (operations.size() > 0) {
            // 刷新每一个从set集合里弹出的postId
            this.refresh((Integer)operations.pop());
        }
        logger.info("[任务结束] 帖子分数刷新完毕!");
    }
    // 从redis中取出每一个value:postId
    private void refresh(int postId) {
        DiscussPost post = discussPostService.findDiscussPostById(postId);
        if (post == null) {
            logger.error("该帖子不存在:id = " + postId);
            return;
        }
        if(post.getStatus() == 2){
            logger.error("帖子已被删除");
            return;
        }

        /**
         * 帖子分数计算公式:[加精(75)+ 评论数*  10 + 点赞数*  2] + 距离天数
         */
        // 是否加精帖子
        boolean wonderful = post.getStatus() == 1;
        // 点赞数量
        long liketCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST, postId);
        // 评论数量
        int commentCount = post.getCommentCount();

        // 计算权重
        double weight = (wonderful ? 75 : 0) + commentCount*  10 + liketCount*  2;
        // 分数 = 取对数(帖子权重) + 距离天数
        double score = Math.log10(Math.max(weight, 1)) + (post.getCreateTime().getTime() - epoch.getTime()) / (1000*  3600* 24);

        // 更新帖子分数
        discussPostService.updateScore(postId, score);
        // 同步搜索数据
        post.setScore(score);
        elasticsearchService.saveDiscussPost(post);
    }
}

4.配置Quartz的PostScoreRefreshJob

    @Bean
    public JobDetailFactoryBean postScoreRefreshJobDetail() {
        JobDetailFactoryBean factoryBean = new JobDetailFactoryBean();
        factoryBean.setJobClass(PostScoreRefreshJob.class);
        factoryBean.setName("postScoreRefreshJob");
        factoryBean.setGroup("communityGroup");
        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(3000);
        factoryBean.setJobDataMap(new JobDataMap());
        return factoryBean;
    }

5.修改主页帖子显示(Mapper、Service、Controller)

5.1 Mapper

    // orderMode=0:最新  orderMode=1:最热
    List<DiscussPost> selectDiscussPosts(@Param("userId") int userId, @Param("offset") int offset, @Param("limit") int limit,@Param("orderMode")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>

5.2 Service

    public List<DiscussPost> findDiscussPosts(int userId, int offset, int limit, int orderMode) {
        return discussPostMapper.selectDiscussPosts(userId, offset, limit, orderMode);
    }

5.3 Controller

    @RequestMapping(value = "/index", method = RequestMethod.GET)
    // @RequestParam(name = "orderMode") 这是从前端传参数方法是:/index?xx 与Controller绑定
    public String getIndexPage(Model model, Page page,@RequestParam(name = "orderMode",defaultValue = "0") int orderMode) {
        
        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>> discussPost = 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);
                long likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST, post.getId());
                map.put("likeCount", likeCount);
                discussPost.add(map);
            }
        }
        model.addAttribute("discussPosts", discussPost);
        model.addAttribute("orderMode", orderMode);
        return "/index";
    }

6编写前端页面实现切换最新/最热帖子显示

  <!-- 切换最新/最热帖子 -->
  <li class="nav-item">
    <a th:class="|nav-link ${orderMode==0?'active':''}|" th:href="@{/index(orderMode=0)}">最新</a>
  </li>
  <li class="nav-item">
    <a th:class="|nav-link ${orderMode==1?'active':''}|" th:href="@{/index(orderMode=1)}">最热</a>
  </li>

文件上传至云服务器(七牛云服务器)

绑定云服务器

1.引入pom.xml

    <!--七牛云服务器-->
    <dependency>
        <groupId>com.qiniu</groupId>
        <artifactId>qiniu-java-sdk</artifactId>
        <version>7.2.28</version>
    </dependency>

2.配置yml文件(服务器参数)

# qiniu
qiniu:
  # 七牛云密钥(个人设置->密钥管理)
  key:
    access: 7Ia7E86E3B9XTQ9TrlA5l_E-_WBnkmXQhxoE3-_n
    secret: 17Ab9TcKnyn_jw4-a0XyH6iD_acl0KaKGEi6_Hqc
  bucket:
    # 头像上传云服务器配置(七牛云对象存储)
    header:
      name: xmyheader
      url: http://rcmsg2hwa.hb-bkt.clouddn.com
    # 分享功能云服务器配置
    share:
      name: xmyshare
      url: http://rcmscfkkw.hb-bkt.clouddn.com

将头像上传至云服务器

客户端上传:

—将客户端数据提交给云服务器,并等待其响应

—用户上传头像时,将表单数据提交给服务器

1.修改文件上传相应的Controller(这里是UserController)

    @LoginRequired//自定义注解
    @RequestMapping(value = "/setting", method = RequestMethod.GET)
    public String getSettingPage(Model model) {
        /**设置页面加载时就开始配置云服务器信息**/
        // 上传随机文件名称
        String fileName = CommunityUtil.generateUUID();
        // 设置返回给云服务器的响应信息(规定用StringMap)
        StringMap policy = new StringMap();
        policy.put("returnBody", CommunityUtil.getJSONString(0));
        // 生成上传云服务器的凭证
        Auth auth = Auth.create(accessKey, secretKey);
        // 上传指定文件名到云服务器指定空间,传入密钥,过期时间
        String uploadToken = auth.uploadToken(headerBucketName, fileName, 3600, policy);
        // 七牛云规定:表单需要携带的参数
        model.addAttribute("uploadToken", uploadToken);
        model.addAttribute("fileName", fileName);

        return "/site/setting";
    }

    /**
     * 异步更新头像路径(云服务器异步返回Json,而不是返回页面,不然乱套)
     */
    @RequestMapping(value = "/header/url", method = RequestMethod.POST)
    @ResponseBody
    public String updateHeaderUrl(String fileName) {
        if (StringUtils.isBlank(fileName)) {
            return CommunityUtil.getJSONString(1, "文件名不能为空!");
        }

        String url = headerBucketUrl + "/" + fileName;
        // 将数据库头像url更换成云服务器图片url
        userService.updateHeader(hostHolder.getUser().getId(), url);

        return CommunityUtil.getJSONString(0);
    }

2.编写更新头像路径时js异步ajax

// 上传到七牛云服务器的异步处理方法
$(function(){
    $("#uploadForm").submit(upload);
});

function upload() {
    // 表单异步提交文件不能用$.post--不能映射文件类型,所以用原生$.ajax
    $.ajax({
        // 七牛云华北地区上传地址
        url: "http://upload-z1.qiniup.com",
        method: "post",
        // 不要把表单内容转为字符串(因为是上传图片文件)
        processData: false,
        // 不让JQuery设置上传类型(使用浏览器默认处理方法将二进制文件随机加边界字符串)
        contentType: false,
        // 传文件时需要这样传data
        data: new FormData($("#uploadForm")[0]),
        success: function(data) {
            if(data && data.code == 0) {
                // 更新头像访问路径
                $.post(
                    CONTEXT_PATH + "/user/header/url",
                    {"fileName":$("input[name='key']").val()},
                    function(data) {
                        data = $.parseJSON(data);
                        if(data.code == 0) {
                            window.location.reload();
                        } else {
                            alert(data.msg);
                        }
                    }
                );
            } else {
                alert("上传失败!");
            }
        }
    });
    // <form>表单没写action,就必须返回false
    return false;
}

将分享图片上传至云服务器

服务器直传:

—本地应用服务器将数据直接提交给云服务器,并等待其响应

—分享时,服务端将自动生成的图片,直接提交给云服务器

1.编写生成长图到本地Controller(使用消息队列处理并发)

/**
 * wkhtmltopdf实现生成分享长图功能
 */
@Controller
public class ShareController implements CommunityConstant {

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

    @Autowired
    private EventProducer eventProducer;

    @Value("${community.path.domain}")
    private String domain;

    @Value("${server.servlet.context-path}")
    private String contextPath;

    @Value("${wk.image.storage}")
    private String wkImageStorage;

    @Value("${qiniu.bucket.share.url}")
    private String shareBucketUrl;

    @RequestMapping(path = "/share", method = RequestMethod.GET)
    @ResponseBody
    public String share(String htmlUrl) {
        // 文件名
        String fileName = CommunityUtil.generateUUID();

        // 异步生成长图
        Event event = new Event()
                .setTopic(TOPIC_SHARE)
                .setData("htmlUrl", htmlUrl)
                .setData("fileName", fileName)
                .setData("suffix", ".png");
        eventProducer.fireMessage(event);

        // 返回访问路径
        Map<String, Object> map = new HashMap<>();
        //map.put("shareUrl", domain + contextPath + "/share/image/" + fileName);
        map.put("shareUrl", shareBucketUrl + "/" + fileName);

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

2.编写Kafka消费者—上传到云服务器

    /**执行wk命令行的位置**/
    @Value("${wk.image.command}")
    private String wkImageCommand;

    /**存储wk图片位置**/
    @Value("${wk.image.storage}")
    private String wkImageStorage;
    /**
     * 使用云服务器获取长图
     */
    @Value("${qiniu.key.access}")
    private String accessKey;

    @Value("${qiniu.key.secret}")
    private String secretKey;

    @Value("${qiniu.bucket.share.name}")
    private String shareBucketName;

    /**定时器避免还没生成图片就上传服务器**/
    @Autowired
    private ThreadPoolTaskScheduler taskScheduler;
    
    /**
     * 消费wkhtmltopdf分享事件
     */
    @KafkaListener(topics = TOPIC_SHARE)
    public void handleShareMessage(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;
        }

        String htmlUrl = (String) event.getData().get("htmlUrl");
        String fileName = (String) event.getData().get("fileName");
        String suffix = (String) event.getData().get("suffix");

        // 执行cmd d:/wkhtmltopdf/bin/wkhtmltoimage --quality 75 https://www.nowcoder.com d:/wkhtmltopdf/wk-images/2.png命令
        String cmd = wkImageCommand + " --quality 75 "
                + htmlUrl + " " + wkImageStorage + "/" + fileName + suffix;
        try {
            Runtime.getRuntime().exec(cmd);
            logger.info("生成长图成功: " + cmd);
        } catch (IOException e) {
            logger.error("生成长图失败: " + e.getMessage());
        }

        // 启用定时器,监视该图片,一旦生成了,则上传至七牛云.
        UploadTask task = new UploadTask(fileName, suffix);
        Future future = taskScheduler.scheduleAtFixedRate(task, 500);
        task.setFuture(future);
    }

    class UploadTask implements Runnable {
        // 文件名称
        private String fileName;
        // 文件后缀
        private String suffix;
        // 启动任务的返回值
        private Future future;
        // 开始时间
        private long startTime;
        // 上传次数
        private int uploadTimes;

        public UploadTask(String fileName, String suffix) {
            this.fileName = fileName;
            this.suffix = suffix;
            this.startTime = System.currentTimeMillis();
        }

        public void setFuture(Future future) {
            this.future = future;
        }

        @Override
        public void run() {
            // 生成失败
            if (System.currentTimeMillis() - startTime > 30000) {
                logger.error("执行时间过长,终止任务:" + fileName);
                future.cancel(true);
                return;
            }
            // 上传失败
            if (uploadTimes >= 3) {
                logger.error("上传次数过多,终止任务:" + fileName);
                future.cancel(true);
                return;
            }

            String path = wkImageStorage + "/" + fileName + suffix;
            File file = new File(path);
            if (file.exists()) {
                logger.info(String.format("开始第%d次上传[%s].", ++uploadTimes, fileName));
                // 设置响应信息
                StringMap policy = new StringMap();
                policy.put("returnBody", CommunityUtil.getJSONString(0));
                // 生成上传凭证
                Auth auth = Auth.create(accessKey, secretKey);
                String uploadToken = auth.uploadToken(shareBucketName, fileName, 3600, policy);
                // 指定上传机房
                UploadManager manager = new UploadManager(new Configuration(Zone.zone1()));
                try {
                    // 开始上传图片
                    Response response = manager.put(
                            path, fileName, uploadToken, null, "image/" + suffix, false);
                    // 处理响应结果
                    JSONObject json = JSONObject.parseObject(response.bodyString());
                    if (json == null || json.get("code") == null || !json.get("code").toString().equals("0")) {
                        logger.info(String.format("第%d次上传失败[%s].", uploadTimes, fileName));
                    } else {
                        logger.info(String.format("第%d次上传成功[%s].", uploadTimes, fileName));
                        future.cancel(true);
                    }
                } catch (QiniuException e) {
                    logger.info(String.format("第%d次上传失败[%s].", uploadTimes, fileName));
                }
            } else {
                logger.info("等待图片生成[" + fileName + "].");
            }
        }
    }

使用Caffine本地缓存优化网站性能(缓存主页热门帖子)

1.缓存概念

2.引入caffine依赖项

        <!--caffeine本地缓存优化热门帖子-->
        <dependency>
            <groupId>com.github.ben-manes.caffeine</groupId>
            <artifactId>caffeine</artifactId>
            <version>2.9.3</version>
        </dependency>

3.编写yml配置caffine全局变量

# caffeine本地缓存优化热门帖子
caffeine:
  posts:
    # 最大缓存15页
    max-size: 15
    expire-seconds: 180

4.修改DiscussPostService业务层分页查询方法

    /**
     * 使用caffine缓存热门帖子(可用Jmeter压力测试)
     * QQ:260602448
     * Caffeine核心接口: Cache, LoadingCache(常用同步), AsyncLoadingCache(异步)
     */
    @Value("${caffeine.posts.max-size}")
    private int maxSize;
    @Value("${caffeine.posts.expire-seconds}")
    private int expireSeconds;

    // 帖子列表缓存
    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
                    // load方法:当没有缓存时,查询数据库
                    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("正在从数据库中加载热门帖子!");
                        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("正在从数据库加载热门帖子总数!");
                        return discussPostMapper.selectDiscussRows(key);
                    }
                });
    }

    /**
     * 主页分页查询帖子(使用缓存查询热门帖子->即userId=0,orderMode=1)
     */
    public List<DiscussPost> findDiscussPosts(int userId, int offset, int limit, int orderMode) {
        if (userId == 0 && orderMode ==1) {
            logger.debug("正在从Caffeine缓存中加载热门帖子!");
            return postListCache.get(offset + ":" + limit);
        }
        logger.debug("正在从数据库中加载热门帖子!");
        return discussPostMapper.selectDiscussPosts(userId, offset, limit, orderMode);
    }

    public int findDiscussPostRows(int userId) {
        // userId=0:查询所有帖子
        if (userId == 0) {
            logger.debug("正在从Caffeine缓存中加载热门帖子!");
            return postRowsCache.get(userId);
        }
        logger.debug("正在从数据库加载热门帖子总数!");
        return discussPostMapper.selectDiscussRows(userId);
    }

统一处理异常

1.将error/404.html或500.html放在templates

注意:springboot默认在templates资源路径下面新建error目录,添加404.html和500.html页面就会自动配置上错误页面自动跳转

2.定义一个控制器通知组件,处理所有Controller所发生的异常

@ControllerAdvice(annotations = Controller.class)
public class ExceptionAdvice {
    private static final Logger logger = LoggerFactory.getLogger(ExceptionAdvice.class);

    public void handleException(Exception e, HttpServletRequest request, HttpServletResponse response) throws IOException {
        logger.error("服务器发生异常: " + e.getMessage());
        // 循环打印异常栈中的每一条错误信息并记录
        for (StackTraceElement element : e.getStackTrace()) {
            logger.error(element.toString());
        }

        // 判断异常返回的是HTML还是Json异步格式字符串
        String xRequestedWith = request.getHeader("x-requested-with");
        // XMLHttpRequest: Json格式字符串
        if ("XMLHttpRequest".equals(xRequestedWith)) {
            // 页面响应普通plain字符串格式
            response.setContentType("application/plain;charset=utf-8");
            PrintWriter writer = response.getWriter();
            writer.write(CommunityUtil.getJSONString(1, "服务器异常!"));
        } else {
            response.sendRedirect(request.getContextPath() + "/error");
        }
    }
}
@RequestMapping(value = "error", method = RequestMethod.GET)
public String getErrorPage(){
    return "/error/500";
}

统一记录日志

1.AOP概念(面向切面编程)

常见的使用场景有:权限检查、记录日志、事务管理

Joinpoint:目标对象上织入代码的位置叫做joinpoint

Pointcut:是用来定义当前的横切逻辑准备织入到哪些连接点上 (如service所有方法)

Advice:用来定义横切逻辑,即在连接点上准备织入什么样的逻辑

Aspect:是一个用来封装切点和通知的组件

织入:就是将方面组件中定义的横切逻辑,织入到目标对象的连接点的过程

2.AOP切面编程Demo示例

2.1导入pom.xml

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
    <version>2.6.6</version>
</dependency>

2.2编写Aspect类

@Component
@Aspect
public class DemoAspect {
    /**
      *第一个* :方法的任何返回值
     * com.xmy.demonowcoder.service.*. *(..)) :service包下的所有类所有方法所有参数(..)
     */
    @Pointcut("execution(* com.xmy.demonowcoder.service. *.*(..))")
    public void pointcut(){}

    /**切点方法之前执行(常用)**/
    @Before("pointcut()")
    public void before(){
        System.out.println("before");
    }
    @After("pointcut()")
    public void after(){
        System.out.println("after");
    }
    /**返回值以后执行**/
    @AfterReturning("pointcut()")
    public void afterRetuning() {
        System.out.println("afterRetuning");
    }

    /**抛出异常以后执行**/
    @AfterThrowing("pointcut()")
    public void afterThrowing() {
        System.out.println("afterThrowing");
    }
    /**切点的前和后都可以执行**/
    @Around("pointcut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable{
        System.out.println("around before");
        Object obj = joinPoint.proceed();
        System.out.println("around after");
        return obj;
    }
}

3.AOP实现统一记录日志

实现需求 :用户ip地址[1.2.3.4],在[xxx],访问了[ com.nowcoder.community.service.xxx ()]业务.\

@Component
@Aspect
public class ServiceLogAspect {

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

    @Pointcut("execution(* com.xmy.demonowcoder.service.*. *(..))")
    public void pointcut(){}

    @Before("pointcut()")
    public void before(JoinPoint joinPoint){
        // 用户ip[1.2.3.4],在[xxx],访问了[com.nowcoder.community.service.xxx()].
        // 通过RequestContextHolder获取request
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        // 通过request.getRemoteHost获取当前用户ip
        String ip = request.getRemoteHost();
        String time = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
        /**
         * joinPoint.getSignature().getDeclaringTypeName()-->com.nowcoder.community.service
         * joinPoint.getSignature().getName() -->方法名
         */
        String target = joinPoint.getSignature().getDeclaringTypeName() + "." +joinPoint.getSignature().getName();
        // String.format()加工字符串
        logger.info(String.format("用户[%s],在[%s],访问了[%s]业务.", ip, time, target));
    }
}

项目监控(Springboot actuator)

1.引入pom.xml依赖

        <!-- actuator项目监控-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
            <version>2.7.0</version>
        </dependency>

2.配置yml文件

# actuator项目监控
management:
  endpoints:
    web:
      exposure:
        include: beans,database,info,health

3.自定义监控id(database数据库监控)

/**
 * QQ:260602448--xumingyu
 * 自定义项目监控类
 */
@Component
@Endpoint(id = "database")
public class DatabaseEndpoint {
    private static final Logger logger = LoggerFactory.getLogger(DatabaseEndpoint.class);

    @Autowired
    private DataSource dataSource;

    // 相当于GET请求
    @ReadOperation
    public String checkConnection() {
        try (
                // 放到try这个位置就不用释放资源,底层自动释放
                Connection conn = dataSource.getConnection();
        ) {
            return CommunityUtil.getJSONString(0, "获取连接成功!");
        } catch (SQLException e) {
            logger.error("获取连接失败:" + e.getMessage());
            return CommunityUtil.getJSONString(1, "获取连接失败!");
        }
    }}

4.使用SpringSecurity设置访问权限

    .antMatchers(
            "/discuss/delete",
            "/data/* *",
            "/actuator/* *"
    )
    .hasAnyAuthority(
            AUTHORITY_ADMIN
    )