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