18.将文件上传至云服务器 + 优化网站的性能

目录

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

1.1 处理上传头像逻辑

1.1.1 客户端上传

1.1.2 服务器直传

2.优化网站的性能

2.1 本地缓存优化查询方法

2.2 压力测试


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

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

使用七牛云服务器

导入依赖:



    com.qiniu
    qiniu-java-sdk
    7.12.1

在 application.properties 中配置:

# qiniu
qiniu.key.access=NHzlA2MRle10vB0wNeO54aGS-tMjEpO5BiIrflz9
qiniu.key.secret=qicgWpPOslm5_dFu_j_94r5gUcKm_UekhT2MMLPf
qiniu.bucket.header.name=communityheader123321
quniu.bucket.header.url=http://s6sc3za8f.hb-bkt.clouddn.com
qiniu.bucket.share.name=communityshare123321
qiniu.bucket.share.url=http://s6scgzhqs.hb-bkt.clouddn.com

1.1 处理上传头像逻辑

1.1.1 客户端上传

打开 UserController 类:

  • 注入上述的两个 key
  • 注入有关头像的空间内容
  • 废弃原来上传头像方法:上传头像因为有表单,所以在客户端上传,表单直接提交给七牛云,废弃此方法
  • 上传文件同样废弃
  • 在打开用户设置的页面时需要生成凭证(设置上传文件名称,响应信息),将凭证写到表单中,当打开表单时,表单中应该有凭证
  • 最后传给模板
  • 还需要添加一个方法:在表单中将数据提交给七牛云,七牛云返回一个消息,然后将 User 表中的 headurl 做一个更新,更新为七牛云的路径

    @Value("${qiniu.key.access}")
    private String accessKey;

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

    @Value("${qiniu.bucket.header.name}")
    private String headerBucketName;

    @Value("${quniu.bucket.header.url}")
    private String headerBucketUrl;

    @LoginRequired
    @RequestMapping(path = "/setting", method = RequestMethod.GET)
    //添加方法使得浏览器通过方法访问到设置的页面
    public String getSettingPage(Model model) {
        // 上传文件名称
        String fileName = CommunityUtil.generateUUID();
        // 设置响应信息
        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";
    }

    // 更新头像路径
    @RequestMapping(path = "/header/url", method = RequestMethod.POST)
    @ResponseBody
    public String updateHeaderUrl(String fileName) {
        if (StringUtils.isBlank(fileName)) {
            return CommunityUtil.getJSONString(1, "文件名不能为空!");
        }

        String url = headerBucketUrl + "/" + fileName;
        userService.updateHeader(hostHolder.getUser().getId(), url);

        return CommunityUtil.getJSONString(0);
    }

再处理表单 setting.html:

                
				
上传头像
该账号不存在!

在 static 包下 js 包新建 setting.js: 

$(function(){
    $("#uploadForm").submit(upload);
});

function upload() {
    $.ajax({
        url: "http://upload-z1.qiniup.com",
        method: "post",
        processData: false,
        contentType: false,
        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("上传失败!");
            }
        }
    });
    return false;
}

18.将文件上传至云服务器 + 优化网站的性能_第1张图片

18.将文件上传至云服务器 + 优化网站的性能_第2张图片

1.1.2 服务器直传

需要重构分享相关的功能,打开 ShareController

  • 注入分享空间的 url
  • 修改返回浏览器的路径:七牛云空间路径 = 空间 url + / + 图片名字
  • 废弃从本地获取图片传给客户端的方法:传给七牛云之后,通过七牛云获取图片
    @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.fireEvent(event);

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

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

     // 废弃
    // 获取长图
    @RequestMapping(path = "/share/image/{fileName}", method = RequestMethod.GET)
    public void getShareImage(@PathVariable("fileName") String fileName, HttpServletResponse response) {
        if (StringUtils.isBlank(fileName)) {
            throw new IllegalArgumentException("文件名不能为空!");
        }

        response.setContentType("image/png");
        File file = new File(wkImageStorage + "/" + fileName + ".png");
        try {
            OutputStream os = response.getOutputStream();
            FileInputStream fis = new FileInputStream(file);
            byte[] buffer = new byte[1024];
            int b = 0;
            while ((b = fis.read(buffer)) != -1) {
                os.write(buffer, 0, b);
            }
        } catch (IOException e) {
            logger.error("获取长图失败: " + e.getMessage());
        }
    }

修改 EventConsumer,逻辑是在消费者中体现

  • 在消费者中消费事件,传入两个 key 和上传空间的 name
  • 注入 ThreadPoolTaskScheduler
  • 在消费分享事件中,在执行生成图片时,启用定时器,监视该图片,一旦生成,则上传至七牛云.
    // 消费分享事件
    @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;
        }

        //获取 html、文件名、后缀
        String htmlUrl = (String) event.getData().get("htmlUrl");
        String fileName = (String) event.getData().get("fileName");
        String suffix = (String) event.getData().get("suffix");

        //拼接参数
        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();
                // 成功返回0
                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 + "].");
            }
        }
    }

2.优化网站的性能

本地缓存:将数据缓存在应用服务器上,性能最好;常用的缓存工具:Ehcache、Guava、Caffeine等

分布式缓存:将数据缓存在 NoSQL 数据库上,跨服务器;常用缓存工具:MemCache、Redis等

多级缓存:一级缓存 > 二级缓存 > DB;避免缓存雪崩(缓存失效,大量请求直达DB),提高系统的可用性

2.1 本地缓存优化查询方法

本地缓存主要使用 Caffeine 工具,导入依赖:

        
			com.github.ben-manes.caffeine
			caffeine
			2.7.0
		

设置自定义参数:

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

优化查询方法(discussPostService):

  • 初始化 Logger,记录日志
  • 注入上述申明参数
  • Caffeine核心接口: Cache, LoadingCache(同步缓存:多个线程同时访问缓存中的数据,但是缓存中没有数据,让多个线程排队等待,然后缓存去数据库中取), AsyncLoadingCache(异步缓存:支持并发同时取数据)
  • 声明帖子列表缓存,使用 LoadingCache>(使用 key 缓存 value)
  • 声明帖子总数缓存,使用 LoadingCache
  • 在服务启动或者首次调用 DiscussPostService,初始化一次上述两个缓存即可
  • 给当前类新增初始化方法,添加注解 @PostConstruct,初始化帖子列表缓存和帖子总数缓存
  • 在查询某一页的方法中需要启动缓存的条件:缓存热门帖子(orderMode == 1)并且缓存首页(访问首页的时候,userId 是不传为0),缓存的是一页的数据(key 为 offset 和 limit 组合);否则访问数据库,在访问之前记录一下日志(load post list from DB.)
  • 在查询总数的方法中:用户查看自己帖子的时候不需要缓存,但是当 userId == 0 为首页查询,需要缓存帖子总数
    private static final Logger logger = LoggerFactory.getLogger(DiscussPostService.class);

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

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

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

    // 帖子列表缓存
    private LoadingCache> postListCache;

    // 帖子总数缓存
    private LoadingCache postRowsCache;

    @PostConstruct
    public void init() {
        // 初始化帖子列表缓存
        postListCache = Caffeine.newBuilder()
                .maximumSize(maxSize)//缓存最大数据量
                .expireAfterWrite(expireSeconds, TimeUnit.SECONDS)//把缓存写入缓存空间多长时间自动过期
                .build(new CacheLoader>() {
                    //当尝试从缓存中取数据,caffeine会看缓存是否有数据:
                    //没有,需要提供一个查询的方法,load就是实现这个查询方法
                    @Nullable
                    @Override
                    public List load(@NonNull String key) throws Exception {
                        //实现访问数据库查数据
                        if (key == null || key.length() == 0) {
                            throw new IllegalArgumentException("参数错误!");
                        }

                        //不为空,解析 key(offset + ":" + limit),:进行切割
                        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.");
                        //调用discussPostMapper查询数据(userId = 0   orderMode = 1)
                        return discussPostMapper.selectDiscussPosts(0, offset, limit, 1);
                    }
                });
        // 初始化帖子总数缓存
        postRowsCache = Caffeine.newBuilder()
                .maximumSize(maxSize)
                .expireAfterWrite(expireSeconds, TimeUnit.SECONDS)
                .build(new CacheLoader() {
                    @Nullable
                    @Override
                    public 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) {
        //缓存热门帖子(orderMode == 1)并且缓存首页(访问首页的时候,userId 是不传为0)
        // 缓存的是一页的数据(key 为 offset 和 limit 组合)
        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);
    }

测试类:

package com.example.demo;

import com.example.demo.entity.DiscussPost;
import com.example.demo.service.DiscussPostService;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.Date;

@RunWith(SpringRunner.class)
@SpringBootTest
@ContextConfiguration(classes = DemoApplication.class)
public class CaffeineTests {

    @Autowired
    private DiscussPostService postService;

    @Test
    public void initDataForTest() {
        for (int i = 0; i < 300000; i++) {
            DiscussPost post = new DiscussPost();
            post.setUserId(111);
            post.setTitle("互联网求职暖春计划");
            post.setContent("今年的就业形势,确实不容乐观");
            post.setCreateTime(new Date());
            post.setScore(Math.random() * 2000);
            postService.addDiscussPost(post);
        }
    }

    @Test
    public void testCache() {
        System.out.println(postService.findDiscussPosts(0, 0, 10, 1));
        System.out.println(postService.findDiscussPosts(0, 0, 10, 1));
        System.out.println(postService.findDiscussPosts(0, 0, 10, 1));
        System.out.println(postService.findDiscussPosts(0, 0, 10, 0));
    }

}

2.2 压力测试

使用 jmeter 工具测试:

未添加缓存:

18.将文件上传至云服务器 + 优化网站的性能_第3张图片

18.将文件上传至云服务器 + 优化网站的性能_第4张图片

添加缓存:

18.将文件上传至云服务器 + 优化网站的性能_第5张图片

18.将文件上传至云服务器 + 优化网站的性能_第6张图片

你可能感兴趣的:(论坛系统,个人论坛系统,spring,boot,spring,mvc,mybatis,redis,kafka,elasticsearch)