返回介绍

从零开始实现一个文件托管服务

发布于 2025-04-20 18:52:14 字数 9576 浏览 0 评论 0 收藏

基于之前所讲的知识,本节我们来实现一个真实的应用。这是一个文件托管服务,主要解决以下问题:

  • 上传后的文件可以被永久存放。
  • 上传后的文件有一个功能完备的预览页。预览页显示文件大小、文件类型、上传时间、下载地址和短链接等信息。
  • 可以通过传参数对图片进行缩放和剪切。
  • 不错的页面展示效果。
  • 为节省空间,相同文件不重复上传,如果文件已经上传过,则直接返回之前上传的文件。

我们先安装一些之前没有安装的依赖:

> sudo apt-get install libjpeg8-dev-yq
> pip install-r chapter3/section5/requirements.txt

requirements.txt 中包含以下内容。

  • python-magic:libmagic 的 Python 绑定,用于确定文件类型。
  • Pillow:PIL(Python Imaging Library)的分支,用来替代 PIL。
  • cropresize2:用来剪切和调整图片大小。
  • short_url:创建短链接。

文件托管服务的建表语句如下(databases/schema.sql):

CREATE TABLE`PasteFile`(
    `id`int(11) NOT NULL AUTO_INCREMENT,
    `filename`varchar(5000) NOT NULL,
    `filehash`varchar(128) NOT NULL,
    `filemd5`varchar(128) NOT NULL,
    `uploadtime`datetime NOT NULL,
    `mimetype`varchar(256) NOT NULL,
    `size`int(11) unsigned NOT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY`filehash`(`filehash`),
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

其中指定了“ENGINE=InnoDB”,这个表会使用 InnoDB 引擎。直接在命令行把它导入到数据库:

> (echo"use r";cat databases/schema.sql)|mysql--user='web'--password='web'

这个项目有多个文件。

1.config.py:用于存放配置。

SQLALCHEMY_DATABASE_URI='mysql://web:web@localhost:3306/r'
UPLOAD_FOLDER='/tmp/permdir'
SQLALCHEMY_TRACK_MODIFICATIONS=False

UPLOAD_FOLDER 指定了存放上传文件的目录。我们先创建这个目录:

> mkdir/tmp/permdir

2.utils.py:用于存放功能函数。

  • get_file_md5:获得文件的 md5 值。
  • humanize_bytes:返回可读的文件大小。
In:humanize_bytes(100)
Out:'100.00 bytes'
In:humanize_bytes(34500)
Out:'33.00 kB'
In:humanize_bytes(34500000)
Out:'32.00 MB'
  • get_file_path:根据上传文件的目录获得文件路径。

3.mimes.py:只接受文件中定义了的媒体类型。

AUDIO_MIMES=[
    'audio/ogg',
    'audio/mp3'
    ...
]

IMAGE_MIMES=[
    'image/jpeg',
    'image/png',
    ...
]

VIDEO_MIMES=[
    'video/mp4',
    'video/ogg',
    ...
]

4.ext.py:存放扩展的封装。

from flask_mako import MakoTemplates, render_template
from flask_sqlalchemy import SQLAlchemy

mako=MakoTemplates()
db=SQLAlchemy()

5.models.py:存放模型。

6.app.py:应用主程序。

models.py 文件中只包含了 PasteFile 模型。它的字段定义和初始化方法如下:

from ext import db


class PasteFile(db.Model):
    __tablename__='PasteFile'
    id=db.Column(db.Integer, primary_key=True)
    filename=db.Column(db.String(5000), nullable=False)
    filehash=db.Column(db.String(128), nullable=False, unique=True)
    filemd5=db.Column(db.String(128), nullable=False, unique=True)
    uploadtime=db.Column(db.DateTime, nullable=False)
    mimetype=db.Column(db.String(256), nullable=False)
    size=db.Column(db.Integer, nullable=False)

    def__init__(self, filename='', mimetype='application/octet-stream',
                size=0, filehash=None, filemd5=None):
    self.uploadtime=datetime.now()
    self.mimetype=mimetype
    self.size=int(size)
    self.filehash=filehash if filehash else self._hash_filename(filename)
    self.filename=filename if filename else self.filehash
    self.filemd5=filemd5

@staticmethod
def_hash_filename(filename):
    _,_, suffix=filename.rpartition('.')
    return '%s.%s'%(uuid.uuid4().hex, suffix)

现在看一下 app 的初始化:

from werkzeug import SharedDataMiddleware

from ext import db, mako
from utils import get_file_path

app=Flask(__name__, template_folder='../../templates/r',
         static_folder='../../static')
app.config.from_object('config')

app.wsgi_app=SharedDataMiddleware(app.wsgi_app,{
    '/i/':get_file_path()
})

mako.init_app(app)
db.init_app(app)

上述例子有如下细节需要注意:

  • 使用 SharedDataMiddleware 是实现在页面读取源文件的最简单的方法。
  • 只是把第三方扩展初始化放在了 app.py 中,而没有使用“db=SQLAlchemy(app)”这样的方式。这是因为在大型应用中如果 db 被多个模型文件引用的话,会造成“from app import db”这样的方式,但是往往也在 app.py 中也会引用模型文件定义的类,这就造成了循环引用。所以最好的做法是把它放在不依赖其他模块的独立文件中。

我们来分别看不同的视图及其实现逻辑。

首页

首页就是上传图片页,通过这个页面可以上传图片,并生成预览页:

@app.route('/', methods=['GET', 'POST'])
def index():
    if request.method=='POST':
        uploaded_file=request.files['file']
        w=request.form.get('w')
        h=request.form.get('h')
        if not uploaded_file:
            return abort(400)

        if w and h:
            paste_file=PasteFile.rsize(uploaded_file, w, h)
        else:
            paste_file=PasteFile.create_by_upload_file(uploaded_file)
        db.session.add(paste_file)
        db.session.commit()
        
        return jsonify({
            'url_d':paste_file.url_d,
            'url_i':paste_file.url_i,
            'url_s':paste_file.url_s,
            'url_p':paste_file.url_p,
            'filename':paste_file.filename,
            'size':humanize_bytes(paste_file.size),
            'time':str(paste_file.uploadtime),
            'type':paste_file.type,
            'quoteurl':paste_file.quoteurl
    })
return render_template('index.html',**locals())

如果是 GET 请求,直接渲染 index.html。如果是 POST 方法,通过 PasteFile.create_by_upload_file 创建一个 PasteFile 实例:

@classmethod
def get_by_md5(cls, filemd5):
    return cls.query.filter_by(filemd5=filemd5).first()

@property
def path(self):
    return get_file_path(self.filehash)

@classmethod
def create_by_upload_file(cls, uploaded_file):
    rst=cls(uploaded_file.filename, uploaded_file.mimetype, 0)
    uploaded_file.save(rst.path)
    with open(rst.path) as f:
        filemd5=get_file_md5(f)
        uploaded_file=cls.get_by_md5(filemd5)
        if uploaded_file:
            os.remove(rst.path)
            return uploaded_file
    filestat=os.stat(rst.path)
    rst.size=filestat.st_size
    rst.filemd5=filemd5
    return rst

创建 PasteFile 实例前会先保存文件,保存的文件名是 rst.path。如果通过被上传文件的 md5 值判断的文件之前已经上传过,则直接删掉这个文件,并返回之前创建的文件。

rst.path 使用了 filehash,filehash 是通过_hash_filename 方法生成的随机名字,这是为了防止不同的用户上传的同名文件造成的替换。

如果上传请求是一个 POST 请求,并且指定了长和宽,会先裁剪图片再保存:

import cropresize2
from PIL import Image


@classmethod
def rsize(cls, old_paste, weight, height):
    assert old_paste.is_image, TypeError('Unsupported Image Type.')

    img=cropresize2.crop_resize(
        Image.open(old_paste.path), (int(weight), int(height)))
    
    rst=cls(old_paste.filename, old_paste.mimetype, 0)
    img.save(rst.path)
filestat=os.stat(rst.path)
rst.size=filestat.st_size
return rst

重新设置图片页

支持对现有的图片重新设置大小,返回新的图片地址:

@app.route('/r/<img_hash>')
def rsize(img_hash):
    w=request.args['w']
    h=request.args['h']

    old_paste=PasteFile.get_by_filehash(img_hash)
    new_paste=PasteFile.rsize(old_paste, w, h)

    return new_paste.url_i

其中 get_by_filehash 方法就是从数据库中找到匹配 filehash 的条目:

from flask import abort


@classmethod
def get_by_filehash(cls, filehash, code=404):
    return cls.query.filter_by(filehash=filehash).first() or abort(code)

url_i 属性获取的是源文件的地址。其他文件属性如下:

def get_url(self, subtype, is_symlink=False):
    hash_or_link=self.symlink if is_symlink else self.filehash
    return 'http://{host}/{subtype}/{hash_or_link}'.format(
        subtype=subtype, host=request.host, hash_or_link=hash_or_link)

@property
def url_i(self):
    return self.get_url('i')

@property
def url_p(self):
    return self.get_url('p')

@property
def url_s(self):
    return self.get_url('s', is_symlink=True)

@property
def url_d(self):
    return self.get_url('d')

通过 get_url 可以拼不同类型的请求地址,如表 3.1 所示。

表 3.1 不同类型的请求地址

方法作用
url_p文件预览地址
url_d文件下载地址
url_s文件短链接地址

下载页

下载文件时使用“/d/img_hash.jpg”这样的地址,可以用 Flask 提供的 send_file 实现:

from flask import send_file

ONE_MONTH=60*60*24*30


@app.route('/d/<filehash>', methods=['GET'])
def download(filehash):
    paste_file=PasteFile.get_by_filehash(filehash)

    return send_file(open(paste_file.path, 'rb'),
        mimetype='application/octet-stream',
        cache_timeout=ONE_MONTH,
        as_attachment=True,
        attachment_filename=paste_file.filename.encode('utf-8'))

预览页

预览文件使用“/p/img_hash.jpg”这样的地址:

@app.route('/p/<filehash>')
def preview(filehash):
    paste_file=PasteFile.get_by_filehash(filehash)

    if not paste_file:
        filepath=get_file_path(filehash)
    if not(os.path.exists(filepath) and (not os.path.islink(filepath))):
        return abort(404)

    paste_file=PasteFile.create_by_old_paste(filehash) 
    db.session.add(paste_file)
    db.session.commit()

return render_template('success.html', p=paste_file)

在首页上传完毕时也会在地址栏显示这样的地址,但事实上并没有发生跳转,只是用 JavaScript 修改了地址。由于它们使用了同一个文件卡片组件,所以看起来一模一样。

短链接页

由于文件 hash 值太长,支持使用短连接的方式访问,使用“/s/short_url”这样的地址:

@app.route('/s/<symlink>')
def s(symlink):
    paste_file=PasteFile.get_by_symlink(symlink)

    return redirect(paste_file.url_p)

但是并不需要把短链接存放进数据库,正确的做法是用 id 这个唯一标识生成短链接地址:

import short_url
from werkzeug.utils import cached_property


@cached_property
def symlink(self):
    return short_url.encode_url(self.id)

通过短链接获得对应数据库条目的方法如下:

@classmethod
def get_by_symlink(cls, symlink, code=404):
    id=short_url.decode_url(symlink)
    return cls.query.filter_by(id=id).first() or abort(code)

现在启动服务就可以看到效果了:

> mkdir/tmp/permdir
> python chapter3/section7/app.py

在线的效果可以访问搭建在 Heroku 上的 DEMO(https://vast-brushlands-4477.herokuapp.com/ )。

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。