Thumbnail Server
最近有个任务,是独自开发一个缩略图服务器,用于专门处理:图片、PDF、PSD、HEIC、XMIND…的文件缩略图
框架 Uvicorn
实现方式:运用任务队列 Queue,前面的文章有讲过,这里不再过多讲解
特点:运用必会的知识点异步操作(协程),async、await
注:详细的缩略图生成功能就不讲解了,主要讲解大体的实现思路和难点
话不多说,开始!
Uvicorn
An ASGI web server, for Python.
详细请见:Uvicorn
Thumbnail Server 设计
准备任务
1. HTTPRequest创建
class HTTPRequest(object):
def __init__(self, **scope):
self.__dict__.update(scope)
self.parse()
def parse(self):
self.parse_headers()
self.parse_cookies()
self.parse_url()
self.parse_query_dict()
def parse_headers(self):
raw_headers = self.headers
headers = {}
for item in raw_headers:
k = item[0].decode().lower()
v = item[1].decode()
if k in headers:
headers[k].append(v)
else:
headers[k] = [v]
self.headers = headers
def parse_cookies(self):
cookies = ()
if self.headers.get('cookie'):
cookie_string = self.headers.get('cookie')[0]
s = SimpleCookie(cookie_string)
cookies = {v.key: v.value for k, v in s.items()}
self.cookies = cookies
def parse_url(self):
self.url = self.path[len(settings.URL_PREFIX):]
def parse_query_dict(self):
query_string = self.query_string.decode()
self.query_dict = urllib.parse.parse_qs(query_string)
创建一个HTTPRequest类,用来解析其他服务端传来的请求(请求头、Cookie、Url和请求参数)
2. 创建响应头、响应体(Response)
def gen_response_header(status, content_type):
return {
'type': 'http.response.start',
'status': status,
'headers': [
[b'Content-Type', content_type],
[b'Cache-Control', b'max-age=604800, public']
]
}
def gen_response_body(body):
return {
'type': 'http.response.body',
'body': body
}
def gen_error_response(status, error_msg):
response_header = gen_response_header(status, TEXT_CONTENT_TYPE)
response_body = gen_response_body(error_msg.encode('utf-8'))
return response_header, response_body
def gen_text_response(text):
response_header = gen_response_header(200, TEXT_CONTENT_TYPE)
response_body = gen_response_body(text.encode('utf-8'))
return response_header, response_body
def gen_cache_response():
response_start = gen_response_start(304, THUMBNAIL_CONTENT_TYPE)
response_body = gen_response_body(EMPTY_BYTES)
return response_start, response_body
async def gen_thumbnail_response(request, thumbnail_info):
"""缩略图创建"""
content_type = 'application/json; charset=utf-8'
result = {}
etag = thumbnail_info['etag']
.....
task_id, status = generate_thumbnail(request, thumbnail_info)
if status == 400:
err_msg = 'Failed to create thumbnail.'
return gen_error_response(status, err_msg)
if not isinstance(task_id, bool):
while True:
if thumbnail_task_manager.query_status(task_id)[0]:
break
src = get_thumbnail_src(repo_id, size, path)
result['encoded_thumbnail_src'] = quote(src)
result = json.dumps(result)
result_b = str(result).encode('utf-8')
response_header = gen_response_header(200, content_type)
response_header['headers'].append([b'Last-Modified', last_modified.encode('utf-8')])
response_header['headers'].append([b'ETag', etag.encode('utf-8')])
response_body = gen_response_body(result_b)
return response_header, response_body
async def thumbnail_get(request, thumbnail_info):
"""
缩略图获取
"""
thumbnail_file = thumbnail_info['thumbnail_path']
last_modified = thumbnail_info['last_modified']
etag = thumbnail_info['etag']
if not os.path.exists(thumbnail_file):
task_id, status = generate_thumbnail(request, thumbnail_info)
if status == 400:
err_msg = 'Failed to create thumbnail.'
return gen_error_response(status, err_msg)
while True:
if thumbnail_task_manager.query_status(task_id)[0]:
break
try:
with open(thumbnail_file, 'rb') as f:
thumbnail = f.read()
response_header = gen_response_header(200, 'image/' + THUMBNAIL_EXTENSION)
response_body = gen_response_body(thumbnail)
if thumbnail:
response_start['headers'].append([b'Last-Modified', last_modified.encode('utf-8')])
response_start['headers'].append([b'ETag', etag.encode('utf-8')])
return response_header, response_body
except:
err_msg = 'Failed to create thumbnail.'
return gen_error_response(400, err_msg)
创建响应头和响应体,返回给前端,前端获取到对应的信息进行渲染。
代码逻辑:
- 前端发起请求
- 服务端根据请求的URL 去找到指定的函数
- 当发起创建请求时,将执行 gen_thumbnail_response 函数,去返回请求头和请求体,函数逻辑如下:
- 根据函数返回的任务ID和状态去判断该缩略图文件是否被创建
- 若未被创建,则循环等待任务创建完成后将缩略图文件位置进行返回
- 若已经被创建,则直接返回其位置(前端要求,也可返回二进制数据)
- 当发起获取请求时,将执行thumbnail_get函数,返回相应的请求头和请求体,函数逻辑如下:
- 前端发起对应的请求URL
- 后端判断其缩略图文件是否存在
- 未创建,则将创建任务添加至任务队列,返回任务ID和状态
- 循环等待该任务的完成,完成后直接读取并返回二进制
- 若创建直接打开对应的文件返回其二进制
返回头中包含ETag和Last-Modified和Cache-Control,其中ETag和Last-Modified用来做304处理,Cache-Control用来做缓存处理
ETag和Last-Modified详解
ETag和Last-Modified都是HTTP头部字段,用于缓存控制,它们提供了服务器上资源的状态信息,以便浏览器可以决定是否需要从服务器重新加载资源。
我们用大白话解释:
- 客户端向服务器请求资源 thumbnail
- 服务端返回数据并带上ETag或Last-Modified(他们有优先级)
- 客户端再次请求资源 thumbnail
- 由于上次服务器给他返回了一个 Etag或Last-Modified,这次请求的时候他会带上这个 If-None-Match(对应ETag的内容)或 If-Modified-Since(对应Last-Modified)
- 服务器发现请求中包含 Etag,判断是否过期,没过期则返回 304 Not Modified
- 客户端强制刷新(如chrome中ctrl+shift+R刷新页面),请求中剔除 ETag
- 服务器未发现请求中包含 Etag,返回资源 thumbnial,并带上一个 ETag
Cache相关代码如下:
def cache_check(request, info):
etag = info.get('etag')
if_none_match_headers = request.headers.get('if-none-match')
if_none_match = if_none_match_headers[0] if if_none_match_headers else ''
last_modified = info.get('last_modified')
if_modified_since_headers = request.headers.get('if-modified-since')
if_modified_since = if_modified_since_headers[0] if if_modified_since_headers else ''
if (if_none_match and if_none_match == etag) \
or (if_modified_since and if_modified_since == last_modified):
return True
else:
return False
在返回响应头和响应体之前,我们需要先对缓存进行检查,增效:
try:
if cache_check(request, thumbnail_info):
response_header, response_body = gen_cache_response()
await send(response_header)
await send(response_body)
return
except Exception as e:
logger.exception(e)
注意⚠️:Cache-Control:服务器的响应包含Cache-Control头的话,指示浏览器可以缓存资源多长时间。如果缓存时间足够长,浏览器可能会在缓存未过期时直接使用缓存,而不发送If-None-Match头。
3. 任务管理设计(TaskManager)
大体的请求和响应我们都已经设计好了,接下来设计一个任务队列,用来对大量的 缩略图创建任务进行管理,该任务管理设置了两个任务队列,一个图片处理队列,一个视频处理队列,分别用两个线程去维护
任务管理的设计前面文章已经讲过,这次就不做过多的解释,直接上伪代码:
class ThumbnailManager(object):
def __init__(self):
self.tasks_map = {}
self.task_results_map = {}
self.image_queue = queue.Queue()
self.video_queue = queue.Queue()
self.current_task_info = {}
self.threads = []
def add_image_creat_task(self, func, bb, file_id, path, size, thumbnail_file):
task_id = str(uuid.uuid4())
task = (func, (bb, file_id, path, size, thumbnail_file))
self.image_queue.put(task_id)
self.tasks_map[task_id] = task
return task_id
def add_video_task(self, func, aa, path, size, thumbnail_file):
task_id = str(uuid.uuid4())
task = (func, (aa, path, size, thumbnail_file))
self.video_queue.put(task_id)
self.tasks_map[task_id] = task
return task_id
.........
def query_status(self, task_id):
task_result = self.task_results_map.pop(task_id, None)
if task_result == 'success':
return True, None
if isinstance(task_result, str) and task_result.startswith('error_'):
return True, task_result[6:]
return False, None
def handle_image_task(self):
while True:
try:
image_id = self.image_queue.get(timeout=2)
except queue.Empty:
continue
except Exception as e:
logger.error(e)
continue
task = self.tasks_map.get(image_id)
if type(task) != tuple or len(task) < 1:
continue
task_info = image_id + ' ' + str(task[0])
try:
self.current_task_info[image_id] = task_info
start_time = time.time()
# run
task[0](*task[1])
self.task_results_map[image_id] = 'success'
finish_time = time.time()
self.current_task_info.pop(image_id, None)
except Exception as e:
self.task_results_map[image_id] = 'error_' + str(e.args[0])
self.current_task_info.pop(image_id, None)
finally:
self.tasks_map.pop(image_id, None)
def handle_video_task(self):
while True:
try:
video_id = self.video_queue.get(timeout=2)
except queue.Empty:
continue
except Exception as e:
continue
task = self.tasks_map.get(video_id)
if type(task) != tuple or len(task) < 1:
continue
task_info = video_id + ' ' + str(task[0])
try:
self.current_task_info[video_id] = task_info
start_time = time.time()
# run
task[0](*task[1])
self.task_results_map[video_id] = 'success'
self.current_task_info.pop(video_id, None)
except Exception as e:
self.task_results_map[video_id] = 'error_' + str(e.args[0])
self.current_task_info.pop(video_id, None)
finally:
self.tasks_map.pop(video_id, None)
def run(self):
image_name = 'ImageManager Thread'
video_name = 'VideoManager Thread'
image_t = threading.Thread(target=self.handle_image_task, name=image_name)
video_t = threading.Thread(target=self.handle_video_task, name=video_name)
self.threads.append(image_t)
self.threads.append(video_t)
image_t.start()
video_t.start()
thumbnail_task_manager = ThumbnailManager()
该服务器启动时,即启动该任务管理线程
- 前端发起请求时,走对应处理,调用对应的任务添加函数即add_image_creat_task和add_video_task
- 将对应的创建缩略图任务添加到该任务队列中(imgae_queue和video_queue)
- 俩线程通过一直循环的方式查看任务队列中是否存在任务,存在则进行执行,不存在则一直监听
- 可以通过query_status查询任务的完成状态
4. 前期准备工作(序列化设计ThumbnailSeralizer)
在这个模块中,我们将对其params(请求参数)、session及resource(资源) 进行检查,主要为之后的 thumbnail 生成做准备
代码如下:
class ThumbnailSerializer(object):
def __init__(self, request):
self.request = request
self.check()
self.gen_thumbnail_info()
def check(self):
self.params_check()
self.session_check()
self.resource_check()
def gen_thumbnail_info(self):
thumbnail_info = {}
thumbnail_info.update(self.params)
thumbnail_info.update(self.resource)
self.thumbnail_info = thumbnail_info
def resource_check(self):
size = self.params['size']
file_path = self.params['file_path']
file_name = os.path.basename(file_path)
filetype, fileext = get_file_type_and_ext(file_name)
......
thumbnail_dir = os.path.join(settings.THUMBNAIL_DIR, str(size))
thumbnail_file = os.path.join(thumbnail_dir, file_id)
if not os.path.exists(thumbnail_dir):
os.makedirs(thumbnail_dir)
file_obj = seafile_api.get_dirent_by_path(repo_id, file_path)
last_modified_time = file_obj.mtime
last_modified = formatdate(int(last_modified_time), usegmt=True)
etag = '"' + file_id + '"'
self.resource = {
'file_size': file_size,
'file_id': file_id,
'file_ext': fileext,
'file_type': filetype,
'file_name': file_name,
'thumbnail_dir': thumbnail_dir,
'thumbnail_path': thumbnail_file,
'last_modified': last_modified,
'etag': etag
}
def get_enable_file_type(self):
enable_file_type = [IMAGE]
if settings.ENABLE_VIDEO_THUMBNAIL:
enable_file_type.append(VIDEO)
if settings.ENABLE_XMIND_THUMBNAIL:
enable_file_type.append(XMIND)
if settings.ENABLE_PDF_THUMBNAIL:
enable_file_type.append(PDF)
self.enable_file_type = enable_file_type
def params_check(self):
token = None
repo_id = None
match = re.match('^thumbnail/(?P<repo_id>[-0-9a-f]{36})/create/$', self.request.url)
query_dict = self.request.query_dict
path = query_dict['path'][0]
size = query_dict['size'][0]
repo_id = match.group('repo_id')
if not size:
size = settings.THUMBNAIL_DEFAULT_SIZE
if not path:
err_msg = "Invalid arguments."
raise AssertionError(400, err_msg)
...... # 其他URL对应的检查
self.params = {
'size': size,
'token': token,
'file_path': path,
}
def session_check(self):
try:
session_key = self.request.cookies[settings.SESSION_KEY]
except:
session_key = ''
self.session_key = session_key
self.permission_check()
def permission_check(self):
permission = jwt_permission_check(self.session_key, self.params['repo_id'], self.params['file_path'])
if not permission:
err_msg = "Permission denied."
raise AssertionError(400, err_msg)
Params检查
主要针对前端传来的参数是否合规进行检查,并将其参数加入 self,params字典中,为之后的操作做准备
Resource检查
该项目中需要对指定文件夹是否存在等资源进行检查,主要保存其对应的缩略图路径及last-modified和etag,将这些参数都整合到一起,方便为之后的操作做准备
Session检查
对其权限进行检查,该项目主要从Cookies中获取session_key,将session_key返回给另一个服务器做jwt验证,判断是否有权,jwt验证代码如下
def jwt_permission_check(session_key, repo_id, path):
jwt_url = get_jwt_url(repo_id)
payload = {
'is_internal': True
}
jwt_token = jwt.encode(payload, JWT_PRIVATE_KEY, algorithm='HS256')
headers = {
'Authorization': f'token {jwt_token}',
'Cookie': "sessionid=%s" % session_key
}
try:
response = requests.post(jwt_url, data={'path': path}, headers=headers)
if response.status_code != 200:
error_msg = 'Internal Server Error'
logger.error(error_msg)
return False
res = json.loads(response.text)
if res["success"]:
return True
else:
return False
except Exception as e:
logger.error(e)
return False
请求对应的路径,将其jwt_token进行加密,发送到另一服务端,另一服务端解密该token进行比对,并且判断其权限,并将需要的参数传回到该服务器中,为之后的操作做其准备
这里有一点需要注意:因jwt请求的服务器是Django框架,所以将其sessionid发送到Django所建立的服务器时,其会通过中间件处理session信息,在Django服务端将能够直接根据sessionid获取其主要的信息
总结
做完以上认证的同时,在后面数据处理过程中的参数也已经被保存,为后面的数据处理做准备
4. App设计(管理请求路径)
在这个模块中,我们将规整其大体的流程
class App:
async def __call__(self, scope, receive, send):
# request
request = HTTPRequest(**scope)
if request.method != 'GET':
response_stat, response_body = gen_error_response(
405, 'Method %s not allowed' % request.method
)
await send(response_stat)
await send(response_body)
return
# serialize check
try:
serializer = ThumbnailSerializer(request)
thumbnail_info = serializer.thumbnail_info
except Exception as e:
logger.warning(e)
thumbnail_info = None
# ========= router=======
# ------ping
if request.url in ('ping', 'ping/'):
response_stat, response_body = gen_text_response('pong')
await send(response_stat)
await send(response_body)
return
# ------thumbnail
elif re.match('^thumbnail/(?P<token>[a-f0-9]+)/create/$', request.url):
# cache
try:
if cache_check(request, thumbnail_info):
response_start, response_body = gen_cache_response()
await send(response_start)
await send(response_body)
return
except Exception as e:
logger.exception(e)
response_start, response_body = await share_link_thumbnail_create(request, thumbnail_info)
await send(response_start)
await send(response_body)
return
elif re.match('^thumbnail/(?P<token>[a-f0-9]+)/(?P<size>[0-9]+)/(?P<path>.*)$', request.url):
# cache
try:
if cache_check(request, thumbnail_info):
response_start, response_body = gen_cache_response()
await send(response_start)
await send(response_body)
return
except Exception as e:
logger.exception(e)
response_start, response_body = await share_link_thumbnail_get(request, thumbnail_info)
await send(response_start)
await send(response_body)
return
else:
response_stat, response_body = gen_error_response(
404, 'Not Found'
)
await send(response_stat)
await send(response_body)
return
app = App()
代码流程详解:
- 前端发起请求,服务端创建HTTPRequest对象
- 判断其请求方法是否属于GET请求,不是则返回错误
- 对该请求做序列化处理,返回序列化对象(ThumbnailSeralizer)
- 在序列化中上面已经讲过,对其参数、session、资源进行检查并准备后面数据处理所需的参数
- 用正则表达式判断URL的路径,不同的路径走不同的逻辑
- 判断其cache,若比对成功,则直接返回304,避免资源的浪费
- 若无cache,则走对应的函数返回其对应的响应头和响应体返回给前端
async、await:
这里是异步协程部分,大白话说就是 await 要等待其函数内部的执行完成才能执行以下的函数,否则不等待其 thumbnail 生成就返回给前端将会出现错误,若需要详解的话可以在评论区打出,小白根据情况专门出一篇对应的async和await的见解
以上便是这次开发的整个过程,有啥见解可以评论区交流
声明:我是小白,大神勿喷,可以交流