Django整合騰訊COS物件儲存

程序设计实验室發表於2024-08-23

前言

最近遇到一個場景需要把大量的資原始檔儲存到 OSS 裡,這裡選的是騰訊的 COS 物件儲存

(話說我接下來想搞的 SnapMix 專案也是需要大量儲存的,我打算搭個 MinIO 把 24T 的伺服器利用起來~)

為啥騰訊不搞個相容 Amazon S3 協議的啊…… 官方的 SDK 和文件都奇奇怪怪的,感覺國內的廠商都不怎麼重視文件、SDK這些,開發體驗很差(特別點名微信小程式)

因為騰訊的 COS 不在 django-storages 的支援中,所以本文就沒有使用這個庫了,而是自己封裝了一個 Storage,其實 Django 裡要自定義一個 Storage 是很簡單的。

OK,我在參考了一些網際網路資源(以及官方文件、Github)之後,把騰訊的這個 COS 整合到 DjangoStarter 裡了,不得不說 Django 這套東西還是好用,只要把 DEFAULT_FILE_STORAGE 儲存後端切換到 COS ,就能實現 FileField, ImageField 這些全都自動透過 OSS 去儲存和使用。

為了方便管理檔案,我還用上了 django-filer 這個也算是方便,開箱即用,不過中文的 locale 有點問題,預設安裝之後只能顯示英文,如果需要中文得自己 fork 之後改一下(重新命名 locale 目錄)

PS:另外說一下,為了使用簡單,我使用 django-filer 實現了在 admin 裡管理靜態資源,但這樣流量會經過伺服器,更好的做法是在前端直接上傳檔案到 OSS 裡

本文的程式碼都是在 DjangoStarter 框架的基礎上進行修改,在普通的 Django 專案中使用也沒有問題,只是需要根據實際情況做一些修改(檔案路徑不同)

配置

編輯 src/config/settings/components/tencent_cos.py 檔案

DEFAULT_FILE_STORAGE = "django_starter.contrib.storages.backends.TencentCOSStorage"

TENCENTCOS_STORAGE = {
    # 儲存桶名稱,必填
    "BUCKET": "",

    # 儲存桶檔案根路徑,選填,預設 '/'
    "ROOT_PATH": "/",
    # 上傳檔案時最大緩衝區大小(單位 MB),選填,預設 100
    "UPLOAD_MAX_BUFFER_SIZE": 100,
    # 上傳檔案時分塊大小(單位 MB),選填,預設 10
    "UPLOAD_PART_SIZE": 10,
    # 上傳併發上傳時最大執行緒數,選填,預設 5
    "UPLOAD_MAX_THREAD": 5,

    # 騰訊雲端儲存 Python SDK 的配置引數,詳細說明請參考騰訊雲官方文件。
    # 注意:CONFIG中欄位的大小寫請與python-sdk中CosConfig的構造引數保持一致
    "CONFIG": {
        "Region": "ap-guangzhou",
        "SecretId": "",
        "SecretKey": "",
    }
}

這個配置裡註釋都很清楚了,根據實際情況填寫 bucket、id、key 等配置即可。

Storage 實現

前面有說到我把 COS 整合到 DjangoStarter 裡了,所以放到了 src/django_starter/contrib 下面

安裝依賴

這裡需要用到騰訊提供的 Python SDK,請先安裝

pdm add cos-python-sdk-v5

編寫程式碼

編輯 src/django_starter/contrib/storages/backends/cos.py 檔案。

from io import BytesIO
from shutil import copyfileobj
from tempfile import SpooledTemporaryFile

from datetime import datetime, timezone
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.core.files.storage import Storage
from django.utils.deconstruct import deconstructible
from qcloud_cos import CosConfig, CosS3Client
from qcloud_cos.cos_exception import CosServiceError
from importlib import metadata
import os.path

from django.core.files.base import File


class TencentCOSFile(File):
    def __init__(self, name, storage, file=None):
        super().__init__(file, name)
        self.name = name
        self._storage = storage
        self._file = None

    @property
    def file(self):
        if self._file is None:
            self._file = SpooledTemporaryFile()
            response = self._storage.client.get_object(
                Bucket=self._storage.bucket,
                Key=self.name,
            )
            raw_stream = response["Body"].get_raw_stream()
            with BytesIO(raw_stream.data) as file_content:
                copyfileobj(file_content, self._file)
            self._file.seek(0)
        return self._file

    @file.setter
    def file(self, value):
        self._file = value


@deconstructible
class TencentCOSStorage(Storage):
    """Tencent Cloud Object Storage class for Django pluggable storage system."""

    def path(self, name):
        return super(TencentCOSStorage, self).path(name)

    def __init__(self, bucket=None, root_path=None, config=None):
        setting = getattr(settings, "TENCENTCOS_STORAGE", {})
        self.bucket = bucket or setting.get("BUCKET", None)
        if self.bucket is None:
            raise ImproperlyConfigured("Must configure bucket.")

        self.root_path = root_path or setting.get("ROOT_PATH", "/")
        if not self.root_path.endswith("/"):
            self.root_path += "/"

        self.upload_max_buffer_size = setting.get("UPLOAD_MAX_BUFFER_SIZE", None)
        self.upload_part_size = setting.get("UPLOAD_PART_SIZE", None)
        self.upload_max_thread = setting.get("UPLOAD_MAX_THREAD", None)

        config_kwargs = config or setting.get("CONFIG", {})
        package_name = "cos-python-sdk-v5"  # 替換為您要查詢的包的名稱
        version = metadata.version(package_name)
        config_kwargs["UA"] = "tencentcloud-django-plugin-cos/0.0.1;cos-python-sdk-v5/" + version
        required = ["Region", "SecretId", "SecretKey"]
        for key in required:
            if key not in config_kwargs:
                raise ImproperlyConfigured("{key} is required.".format(key=key))

        config = CosConfig(**config_kwargs)
        self.client = CosS3Client(config)

    def _full_path(self, name):
        if name == "/":
            name = ""
        # p = safe_join(self.root_path, name).replace("\\", "/")
        # 亂起名的問題(自動在路徑前加上 D:\ 之類的)終於解決了
        # 騰訊哪個人才想到用 Django 內部的 safe_join 方法代替 os.path.join 的?告訴我,我絕對不打死他!!!
        p = os.path.join(self.root_path, name).replace("\\", "/")
        return p

    def delete(self, name):
        self.client.delete_object(Bucket=self.bucket, Key=self._full_path(name))

    def exists(self, name):
        try:
            return bool(
                self.client.head_object(Bucket=self.bucket, Key=self._full_path(name))
            )
        except CosServiceError as e:
            if e.get_status_code() == 404 and e.get_error_code() == "NoSuchResource":
                return False
            raise

    def listdir(self, path):
        directories, files = [], []
        full_path = self._full_path(path)

        if full_path == "/":
            full_path = ""

        contents = []
        marker = ""
        while True:
            # return max 1000 objects every call
            response = self.client.list_objects(
                Bucket=self.bucket, Prefix=full_path.lstrip("/"), Marker=marker
            )
            contents.extend(response["Contents"])
            if response["IsTruncated"] == "false":
                break
            marker = response["NextMarker"]

        for entry in contents:
            if entry["Key"].endswith("/"):
                directories.append(entry["Key"])
            else:
                files.append(entry["Key"])
        # directories includes path itself
        return directories, files

    def size(self, name):
        head = self.client.head_object(Bucket=self.bucket, Key=self._full_path(name))
        return head["Content-Length"]

    def get_modified_time(self, name):
        head = self.client.head_object(Bucket=self.bucket, Key=self._full_path(name))
        last_modified = head["Last-Modified"]
        dt = datetime.strptime(last_modified, "%a, %d %b %Y %H:%M:%S %Z")
        dt = dt.replace(tzinfo=timezone.utc)
        if settings.USE_TZ:
            return dt
        # convert to local time
        return datetime.fromtimestamp(dt.timestamp())

    def get_accessed_time(self, name):
        # Not implemented
        return super().get_accessed_time(name)

    def get_created_time(self, name):
        # Not implemented
        return super().get_accessed_time(name)

    def url(self, name):
        return self.client.get_conf().uri(
            bucket=self.bucket, path=self._full_path(name)
        )

    def _open(self, name, mode="rb"):
        tencent_cos_file = TencentCOSFile(self._full_path(name), self)
        return tencent_cos_file.file

    def _save(self, name, content):
        upload_kwargs = {}
        if self.upload_max_buffer_size is not None:
            upload_kwargs["MaxBufferSize"] = self.upload_max_buffer_size
        if self.upload_part_size is not None:
            upload_kwargs["PartSize"] = self.upload_part_size
        if self.upload_max_thread is not None:
            upload_kwargs["MAXThread"] = self.upload_max_thread

        self.client.upload_file_from_buffer(
            self.bucket, self._full_path(name), content, **upload_kwargs
        )
        return os.path.relpath(name, self.root_path)

    def get_available_name(self, name, max_length=None):
        name = self._full_path(name)
        return super().get_available_name(name, max_length)

一些絮絮叨叨:

  • 這個程式碼是根據騰訊github上的程式碼修改來的,實話說寫的亂七八糟,不堪入目,不過想到這也都是騰訊打工人應付工作寫出來的東西,也就能理解了……
  • Class 前面的 @deconstructible 裝飾器是 Django 內建的,用於確保在遷移時類可以被正確序列化
  • 原版的程式碼執行起來有很多奇奇怪怪的問題,後面仔細分析了一下程式碼才發現,騰訊的人才好端端的 os.path.join 不用,非要去用 Django 內部的 safe_join 方法,這個還是私有的,不然隨便呼叫的… 真的逆天

參考資料

  • https://www.cnblogs.com/shijieli/p/16478153.html
  • https://docs.djangoproject.com/zh-hans/4.2/ref/files/storage/
  • https://cloud.tencent.com/document/product/436/12269

相關文章