从零开始实现一个文件托管服务
基于之前所讲的知识,本节我们来实现一个真实的应用。这是一个文件托管服务,主要解决以下问题:
- 上传后的文件可以被永久存放。
- 上传后的文件有一个功能完备的预览页。预览页显示文件大小、文件类型、上传时间、下载地址和短链接等信息。
- 可以通过传参数对图片进行缩放和剪切。
- 不错的页面展示效果。
- 为节省空间,相同文件不重复上传,如果文件已经上传过,则直接返回之前上传的文件。
我们先安装一些之前没有安装的依赖:
> 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/ )。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论