对象存储URL被刷怕了,看我这样处理

2023年 7月 17日 30.3k 0

个人项目:社交支付项目(小老板)

作者:三哥,j3code.cn

文档系统:admire.j3code.cn/note

社交支付类的项目,怎么能没有图片上传功能呢!

涉及到文件存储我第一时间就想到了 OSS 对象存储服务(腾讯叫 COS),但是接着我又想到了”OSS 被刷 150 T 的流量,1.5 W 瞬间就没了?“。

本来想着是自己搭建一套 MinIO ,但后来一想服务器的开销又要大了,还是作罢了。就在此时,我脑袋突然灵光了一下,既然对象存储的流量是由于资源 url 泄漏导致的外界不停的访问 url 使公网流量剧增从而引起巨额消费,那我能不能不泄露这个 url 呢!

理论上是可以不直接给用户云存储的 url ,那用户如何访问资源?

转换,当用户上传图片时,将云存储的 url 保存入库,而返回用户一个本系统的资源访问接口。当用户访问该接口时,系统从库中获取真实 url 进行资源访问,并返回资源给用户,完成一次转换。

虽然可以解决 url 泄漏问题,但是也是有性能消耗(从直接访问,变为间接访问,而且系统挂了,资源就不可用)。

方案,虽然曲折了点,但为了 money ,牺牲一点是值得的(后来思考了一下,觉得还是有些问题,文章最后会说)。而且即使有人通过刷系统的接口访问资源,也没事,系统有很强的限流和黑名单处理,不会产生过多的公网流量费用的。

那下面我们就先开通相关功能,然后再编码实现。

1、腾讯云对象存储创建

地址:console.cloud.tencent.com/cos

开通对象存储的步骤还是非常简单的,具体步骤如下:

1)开通功能

Snipaste_2023-07-14_15-27-24.png

2)配置存储桶

Snipaste_2023-07-14_15-28-24.png

下一步

Snipaste_2023-07-14_15-30-35.png

下一步

Snipaste_2023-07-14_15-33-10.png

3)创建访问的密钥

腾讯的所有 API 接口都需要这个访问密钥,如果以前创建过就可以直接拿来使用

Snipaste_2023-07-14_15-34-05.png

下一步

Snipaste_2023-07-14_15-35-26.png

基本的功能我们已经开通了,而且以后我们只需向这个存储桶中上传图片即可。

2、SpringBoot 对接对象存储

既然准备工作都已经完成了,那就开始编写上传文件的代码吧!当然,这里我们还是要借助官方文档,便于我们开发,地址如下:

cloud.tencent.com/document/pr…

2.1 配置准备

先来思考一下,对于腾讯 COS 文件上传需要那些配置:

  • 云 API 的 SecretId 和 SecretKey
  • 桶名称
  • 文件上传大小限制
  • 再加一个 cos 上传后的访问域名
  • ok,大致就这些,那咱们就先来写个配置文件:application-cos.yml

    tx.cos:
      # 云 API 的 SecretId
      secret-id: ENC(X7Uu6Y0QD6aCeUmNhyqv1jcr8fSN+fqM/FSP/rqhM+6pkbte2LW5gR3wntsm24n3NAg6sIwBC3pqm1lSNWwElc3iuGe3lE4L/k3zih+EstM=)
      # 云 API 的 SecretKey
      secret-key: ENC(ui3jqYJpyTRtPAizYdtll2Zc1EVzUjK28vjTyD+t3AIydQO6I+JQOVacc5+NJVybsbFptELswKhY55OQLW+BKfujNTOYEM/zb4CMi+AK80w=)
      # 域名访问
      domain: ENC(oRsaRjwRCVLEYfcNB0CjPGyqSMxGM5uzWnSpSifauLF7c5YMt5hZFi7xAthJI4CjmOLVA810Jbgy8lnkKrXUH0g1ee14cr67xSdtPRy1ZaJOXQOMlBgCKNO2wDBg2YW2)
      # 文件上传的桶名称
      bucket-name: ENC(TUsQfDEFx6KSAOpRwG7UYOJbGwnFT0Z9tjS4h+/HeenAE3XbhKsCwn3TTo80n5tUUP9Dzrnu+Ck84FNSYQk5fw==)
    
    spring:
      servlet:
        multipart:
          # 限制文件上传大小
          max-request-size: 5MB
          max-file-size: 5MB
    

    注:这里,我的配置值是加密的,所以你们需要配置自己的值

    再根据这个配置文件,写一个对应的配置类:

    地址:cn.j3code.common.config

    @Slf4j
    @Data
    @Configuration
    @ConfigurationProperties(prefix = "tx.cos")
    public class TxCosConfig {
        /**
         * 访问域名
         */
        private String domain;
    
        /**
         * 桶名称
         */
        private String bucketName;
    
        /**
         * api密钥中的secretId
         */
        private String secretId;
    
        /**
         * api密钥中的应用密钥
         */
        private String secretKey;
    }
    

    2.2 上传文件代码

    这里,我们先实现单个文件的上传,那来思考一下,上传文件应该需要那些步骤:

  • 校验文件名称
  • 重新生成一个新文件名称
  • 腾讯 COS 文件存储路径生成
  • 文件上传
  • 拼接文件访问 url
  • 对应此步骤的流程图,如下:

    Snipaste_2023-07-16_17-31-32.jpg

    1)controller 编写

    位置:cn.j3code.other.api.v1.controller

    @Slf4j
    @AllArgsConstructor
    @ResponseResult
    @RestController
    @RequestMapping(UrlPrefixConstants.WEB_V1 + "/image/upload")
    public class ImageUploadController {
    
        private final FileService fileService;
    
    
        /**
         * 图片上传
         * @param file 文件
         * @return 返回文件 url
         */
        @PostMapping("")
        public String upload(@RequestParam("file") MultipartFile file){
            return fileService.imageUpload(file);
        }
    }
    

    2)service 编写

    位置:cn.j3code.other.service

    public interface FileService {
        String imageUpload(MultipartFile file);
    }
    
    @Slf4j
    @AllArgsConstructor
    @Service
    public class FileServiceImpl implements FileService {
    
        /**
         * 允许上传的图片类型
         */
        public static final Set IMG_TYPE = Set.of("jpg", "jpeg", "png", "gif");
    
        /**
         * 腾讯 cos 配置
         */
        private final TxCosConfig txCosConfig;
        private final UrlKeyService urlKeyService;
    
        /**
         * 图片上传
         *
         * @param file
         * @return
         */
        @Override
        public String imageUpload(MultipartFile file) {
            // 文件名称
            String newFileName = getNewFileName(file);
    
            DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
            String format = formatter.format(LocalDate.now());
            // key = /用户id/年月日/文件
            String key = SecurityUtil.getUserId() + "/" + format + "/" + newFileName;
    
            String prefix = newFileName.substring(0, newFileName.lastIndexOf(".") - 1);
            String suffix = newFileName.substring(newFileName.lastIndexOf(".") + 1);
            File tempFile = null;
            File rename = null;
            try {
                // 生成临时文件
                tempFile = File.createTempFile(prefix, "." + suffix);
                file.transferTo(tempFile);
                // 重命名文件
                rename = FileUtil.rename(tempFile, newFileName, true, true);
                // 上传
                upload(new FileInputStream(rename), key);
            } catch (Exception e) {
                log.error("imageUpload-error:", e);
            } finally {
                if (Objects.nonNull(tempFile)) {
                    FileUtil.del(tempFile);
                }
                if (Objects.nonNull(rename)) {
                    FileUtil.del(rename);
                }
            }
            // 返回访问链接
            return initUrl(key);
        }
    
        /**
         * 初始化图片文件访问 url(本地url和第三方url)
         *
         * @param key 路径
         * @return
         */
        private String initUrl(String key) {
            // 组装第三方 url
            String imageUrl = txCosConfig.getDomain() + "/" + key;
    
            // 保存 url 到 数据库
            UrlKey urlKey = new UrlKey()
                .setUrl(imageUrl)
                .setKey(RandomUtil.randomString(16) + RandomUtil.randomString(16) + RandomUtil.randomString(16))
                .setUserId(SecurityUtil.getUserId());
    
            // 保存成功,返回本地中转的 url 出去
            boolean save = Boolean.FALSE;
            try {
                save = urlKeyService.save(urlKey);
            } catch (Exception e) {
            }
    
            if (save) {
                return CallbackUrlConstants.IMAGE_OPEN_URL + urlKey.getKey();
            }
            // 保存失败,直接把第三方 url 返回给用户
            return imageUrl;
        }
    
        /**
         * 文件上传到第三方
         *
         * @param fileStream 文件流
         * @param path       路径
         */
        private void upload(InputStream fileStream, String path) {
            PutObjectResult putObjectResult = COSClientUtil.getCosClient(txCosConfig)
                .putObject(new PutObjectRequest(txCosConfig.getBucketName(), path, fileStream, null));
            log.info("upload-result:{}", JSON.toJSONString(putObjectResult));
        }
    
        /**
         * 生成一个新文件名称
         * 会校验文件名称和类型
         *
         * @param file 文件
         * @return
         */
        private String getNewFileName(MultipartFile file) {
            String originalFilename = file.getOriginalFilename();
            if (StringUtil.isEmpty(originalFilename)) {
                throw new SysException("文件名称获取失败!");
            }
            String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
    
            if (!IMG_TYPE.contains(suffix.substring(1))) {
                throw new SysException(String.format("仅允许上传这些类型图片:%s", JSON.toJSONString(IMG_TYPE)));
            }
    
            return RandomUtil.randomString(8) + SnowFlakeUtil.getId() + suffix;
        }
    }
    

    代码写的很详细了,应该能看懂,但,有两点我没有提,就是:COSClientUtil 和 UrlKeyService,下面就来结介绍。

    2.2.1 cos 客户端配置提取

    系统中肯定有很多的文件上传,难道是每上传一次,就配置一次 cos 客户端吗?显然不是,这个 cos 客户端肯定是要抽出来的,全局系统中我们只配置一次。也即只有第一次过来是创建 cos 客户端,后续过来的文件上传请求直接返回创建好的 cos 客户端就行。

    COSClientUtil 类就是我抽的公共 cos 客户获取类,具体实现如下:

    位置:cn.j3code.other.util

    public class COSClientUtil {
    
        /**
         * 统一 cos 上传客户端
         */
        private static COSClient cosClient;
    
        public static COSClient getCosClient(TxCosConfig txCosConfig) {
            if (Objects.isNull(cosClient)) {
                synchronized (COSClient.class) {
                    if (Objects.isNull(cosClient)) {
                        // 1 初始化身份
                        COSCredentials cred = new BasicCOSCredentials(txCosConfig.getSecretId(), txCosConfig.getSecretKey());
                        // 2 创建配置,及设置地域
                        ClientConfig clientConfig = new ClientConfig(new Region("ap-guangzhou"));
                        // 3 生成 cos 客户端。
                        cosClient = new COSClient(cred, clientConfig);
                    }
                }
            }
            return cosClient;
        }
    }
    

    私有构造器,且之对外提供 getCosClient 方法获取 COSClient 对象,保证全局只有一个 cos 客户端配置。

    2.2.2 隐藏云存储 URL 处理

    还记得 FileServiceImpl 类中有个 UrlKeyService 属性嘛,这个类就是做 云存储 URL 隐藏及中转功能的。

    具体做法如图:

    Snipaste_2023-07-16_18-14-59.jpg

    文件上传部分我们已经写好了,不过有点超前的意思了,不过没关系,看整体就行。

    从上面我们要开始抓住一个细节了,就是映射关系,即 key 和 url 的映射。这里我用的是 MySQL 保存,也即用表来存,并没有用 Redis。这里我的考虑是,后续可以把表中的数据定时刷到 Redis 中,接着访问的顺序是从 Redis 中找映射,没有再去 MySQL 中找。

    不过,我们首先还是把数据先存表再说,先来看看映射表结构字段:

    id

    user_id

    key

    url

    create_time

    update_time

    ok,就这些字段,把用户 id 加上是为了好回溯看看是谁上传了图片。

    SQL 如下:

    CREATE TABLE `sb_url_key` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT,
      `key` varchar(64) COLLATE utf8_unicode_ci NOT NULL COMMENT 'key',
      `url` varchar(200) COLLATE utf8_unicode_ci NOT NULL COMMENT '资源url',
      `user_id` bigint(20) DEFAULT NULL COMMENT '上传用户',
      `create_time` datetime DEFAULT NULL COMMENT '创建时间',
      `update_time` datetime DEFAULT NULL COMMENT '修改时间',
      PRIMARY KEY (`id`),
      KEY `key` (`key`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci
    

    紧接着就是通过 MyBatisX 插件生成对应的实体、service、mapper 代码了,不过多赘述。那,现在就来开发用户访问图片资源,咱们如何去请求第三方,然后返回用户图片 byte[] 资源数组吧!

    1)controller 编写

    位置:cn.j3code.other.api.v1.controller

    @Slf4j
    @AllArgsConstructor
    @RestController
    @RequestMapping(UrlPrefixConstants.OPEN + "/resource/image")
    public class ImageResourceController {
    
        private final UrlKeyService urlKeyService;
    
        /**
         * 获取图片 base64
         *
         * @param key
         * @return
         * @throws Exception
         */
        @GetMapping("/base64/{key}")
        public String imageBase64(@PathVariable("key") String key) throws Exception {
            UrlKey urlKey = urlKeyService.oneByKey(key);
            return "data:image/jpg;base64," + Base64Encoder.encode(IoUtil.readBytes(new URL(urlKey.getUrl()).openStream()));
        }
    
    
        /**
         * 获取图片 byte 数组
         *
         * @param key
         * @return
         * @throws Exception
         */
        @GetMapping(value = "/io/{key}", produces = MediaType.IMAGE_JPEG_VALUE)
        public byte[] imageIo(@PathVariable("key") String key) throws Exception {
            UrlKey urlKey = urlKeyService.oneByKey(key);
    
            return IoUtil.readBytes(new URL(urlKey.getUrl()).openStream());
        }
    }
    

    注意:这里写了两个方法,目的是返回两种不同形式的图片资源:base64 和 byte[]。且,这种资源访问的接口,我们系统的相关拦截器请放行,如:认证,ip 记录等拦截器。

    2)service 编写

    位置:cn.j3code.other.service

    public interface UrlKeyService extends IService {
        UrlKey oneByKey(String key);
    }
    @Service
    public class UrlKeyServiceImpl extends ServiceImpl
        implements UrlKeyService {
    
        @Override
        public UrlKey oneByKey(String key) {
            UrlKey urlKey = lambdaQuery().eq(UrlKey::getKey, key).one();
            if (Objects.isNull(urlKey)) {
                throw new SysException(SysExceptionEnum.NOT_DATA_ERROR);
            }
            return urlKey;
        }
    }
    

    ok,这样咱们就处理好了,但是仔细想想这种中转的方法有什么问题。

    2.3 思考

    2.2 节我们已经实现了文件上传和防止 cos 访问 url 泄露的操作,但是我留了个问题,就是思考这种方式有什么问题。

    下面是我的思考:

  • 用户上传的图片,访问时每次都会经过本系统,造成了本系统的压力
  • 如果一个页面需要回显的图片过多,那页面响应会不会很慢
  • 如果系统崩溃了或者服务崩溃了,会导致图片不可访问,但其实第三方 url 是没有问题的
  • 好吧,其实上面总结就两个问题,即:性能 和 可用性。

    这里的解决方法是,如果资金充裕而且 COS 做了黑白名单等之类的防御措施可以直接把 COS 的原始 url 返回出去,没必要把图片资源压力给我我们本系统。如果你不是这种情况,那么就给图片访问接口增加部署资源,即升级服务器增加内存和贷款,提高资源访问效率及系统性能。

    以上就是本节内容,如果文章的中转方法有啥不足或者您有什么意见,欢迎一起讨论研究。

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论