天天看點

看我的奇思妙想,解決對象存儲被刷錢的情況

作者:IT知識分享官

社交支付類的項目,怎麼能沒有圖檔上傳功能呢!

涉及到檔案存儲我第一時間就想到了 OSS 對象存儲服務(騰訊叫 COS),但是接着我又想到了”OSS 被刷 150 T 的流量,1.5 W 瞬間就沒了?“。

本來想着是自己搭建一套 MinIO ,但後來一想伺服器的開銷又要大了,還是作罷了。就在此時,我腦袋突然靈光了一下,既然對象存儲的流量是由于資源 url 洩漏導緻的外界不停的通路 url 使公網流量劇增進而引起巨額消費,那我能不能不洩露這個 url 呢!

理論上是可以不直接給使用者雲存儲的 url ,那使用者如何通路資源?

轉換,當使用者上傳圖檔時,将雲存儲的 url 儲存入庫,而傳回使用者一個本系統的資源通路接口。當使用者通路該接口時,系統從庫中擷取真實 url 進行資源通路,并傳回資源給使用者,完成一次轉換。

雖然可以解決 url 洩漏問題,但是也是有性能消耗(從直接通路,變為間接通路,而且系統挂了,資源就不可用)。

方案,雖然曲折了點,但為了 money ,犧牲一點是值得的(後來思考了一下,覺得還是有些問題,文章最後會說)。而且即使有人通過刷系統的接口通路資源,也沒事,系統有很強的限流和黑名單處理,不會産生過多的公網流量費用的。

那下面我們就先開通相關功能,然後再編碼實作。

1、騰訊雲對象存儲建立

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

開通對象存儲的步驟還是非常簡單的,具體步驟如下:

1)開通功能

看我的奇思妙想,解決對象存儲被刷錢的情況

2)配置存儲桶

看我的奇思妙想,解決對象存儲被刷錢的情況

下一步

看我的奇思妙想,解決對象存儲被刷錢的情況

下一步

看我的奇思妙想,解決對象存儲被刷錢的情況

3)建立通路的密鑰

騰訊的所有 API 接口都需要這個通路密鑰,如果以前建立過就可以直接拿來使用

看我的奇思妙想,解決對象存儲被刷錢的情況

下一步

看我的奇思妙想,解決對象存儲被刷錢的情況

基本的功能我們已經開通了,而且以後我們隻需向這個存儲桶中上傳圖檔即可。

2、SpringBoot 對接對象存儲

既然準備工作都已經完成了,那就開始編寫上傳檔案的代碼吧!當然,這裡我們還是要借助官方文檔,便于我們開發,位址如下:

cloud.tencent.com/document/pr…

2.1 配置準備

先來思考一下,對于騰訊 COS 檔案上傳需要那些配置:

  1. 雲 API 的 SecretId 和 SecretKey
  2. 桶名稱
  3. 檔案上傳大小限制
  4. 再加一個 cos 上傳後的通路域名

ok,大緻就這些,那咱們就先來寫個配置檔案:application-cos.yml

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

java複制代碼@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 上傳檔案代碼

這裡,我們先實作單個檔案的上傳,那來思考一下,上傳檔案應該需要那些步驟:

  1. 校驗檔案名稱
  2. 重新生成一個新檔案名稱
  3. 騰訊 COS 檔案存儲路徑生成
  4. 檔案上傳
  5. 拼接檔案通路 url

對應此步驟的流程圖,如下:

看我的奇思妙想,解決對象存儲被刷錢的情況

1)controller 編寫

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

java複制代碼@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

java複制代碼public interface FileService {
    String imageUpload(MultipartFile file);
}

@Slf4j
@AllArgsConstructor
@Service
public class FileServiceImpl implements FileService {

    /**
     * 允許上傳的圖檔類型
     */
    public static final Set<String> 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

java複制代碼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 隐藏及中轉功能的。

具體做法如圖:

看我的奇思妙想,解決對象存儲被刷錢的情況

檔案上傳部分我們已經寫好了,不過有點超前的意思了,不過沒關系,看整體就行。

從上面我們要開始抓住一個細節了,就是映射關系,即 key 和 url 的映射。這裡我用的是 MySQL 儲存,也即用表來存,并沒有用 Redis。這裡我的考慮是,後續可以把表中的資料定時刷到 Redis 中,接着通路的順序是從 Redis 中找映射,沒有再去 MySQL 中找。

不過,我們首先還是把資料先存表再說,先來看看映射表結構字段:

id

user_id

key

url

create_time

update_time

ok,就這些字段,把使用者 id 加上是為了好回溯看看是誰上傳了圖檔。

SQL 如下:

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

java複制代碼@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

java複制代碼public interface UrlKeyService extends IService<UrlKey> {
    UrlKey oneByKey(String key);
}
@Service
public class UrlKeyServiceImpl extends ServiceImpl<UrlKeyMapper, UrlKey>
    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 洩露的操作,但是我留了個問題,就是思考這種方式有什麼問題。

下面是我的思考:

  1. 使用者上傳的圖檔,通路時每次都會經過本系統,造成了本系統的壓力
  2. 如果一個頁面需要回顯的圖檔過多,那頁面響應會不會很慢
  3. 如果系統崩潰了或者服務崩潰了,會導緻圖檔不可通路,但其實第三方 url 是沒有問題的

好吧,其實上面總結就兩個問題,即:性能 和 可用性。

這裡的解決方法是,如果資金充裕而且 COS 做了黑白名單等之類的防禦措施可以直接把 COS 的原始 url 傳回出去,沒必要把圖檔資源壓力給我我們本系統。如果你不是這種情況,那麼就給圖檔通路接口增加部署資源,即更新伺服器增加記憶體和貸款,提高資源通路效率及系統性能。

以上就是本節内容,如果文章的中轉方法有啥不足或者您有什麼意見,歡迎一起讨論研究。

繼續閱讀