Serverless 与 Flask 框架结合进行 Blog 开发

随着时间的发展,Serverless 架构越来越火热,其按量付费、弹性伸缩等诸多优质特性,让人眼前一亮,不得不惊叹云计算为我们带来的便利。

本实践通过一个博客系统的开发,和大家简单地体验一下基于 Serverless 架构的博客系统是什么样的。

开发前的思考

  1. 博客系统需要哪些功能?本文仅仅是 demo 性质,所以功能比较少,只有两个页面。具有文章管理、分类管理、标签管理以及留言管理等功能。同时为了方便用户管理,要有前台和后台两部分。

  2. 前台如何做?前台可能是用户流量比较大的(相对后台而言),所以这部分就是用单独的函数。每个功能一个函数,初步判断前台可能需要:获取文章分类,获取文章列表,获取评论列表,增加评论,获取标签列表等接口。

  3. 后台如何做?后台理论上是管理员的专属地盘,所以这一部分流量比较小,可以通过 flask-admin,放入到一个函数中来解决。

  4. 为什么前台要那么多函数,后台用一个框架?整个项目就用一个框架不好么?首先要回答,整个项目用一个框架也是可以的,但是并不好。例如这个项目的后台,使用的是 Flask 框架,用了 flask-admin 来做后台管理,这个开发过程很简单,可能整个后台就一百来行代码就搞定了,但是这涉及到:

  • 网页的返回,需要 APIGW 开启响应集成,响应集成的性能其实很差,所以相对来说,不太适合放在前端;
  • 一个完整项目比较大,可能需要的资源也会更多,那么我们就需要给这个函数更多的资源内存,可能会导致收费的增加,例如我的后台给的资源是 1024,我的前端每个函数给的内存资源是 128/256,在执行同样时间的时候,明显后者的费用降低了 4~8 倍。同样,函数可能涉及大冷启动,冷启动一个函数和冷启动函数中的一个完整的框架/项目,前者的速度和性能可能会更好一下;
  • 函数都有并发上限的,如果所有的资源全都请求到一个函数,那么很可能实际用户并发几个的时候,对用的函数并发就可能是几十几百,这很可能在用户稍微多一点的情况下,就会触及用户实例的上限限制,后台功能是非频繁功能,前台相对来说是更频繁的,所以前台是用单独接口更合理。
  1. 登陆功能怎么做?非常抱歉,函数并不能像传统开发,将客户的一些登录信息缓存到机器上,但是客户端依旧可以使用 cookie,所以利用这个方法,可以做以下流程:
  • 后台登录入口处,拉取 APIGW 传过来的 APIGW Event,看其中 headers/cookie 是否存在,不存在就会返回登录页面;

  • 如果 headers/cookie 存在,取 cookie 中的 token 字段,判断 token 字段是否和服务端的 token 字段吻合,吻合进入系统后台,不吻合返回登录页面

  • 用户登录,请求后台的登陆功能,如果账号密码正确,则返回给用户一个 token,客户端将 token 记录到 cookie 中

  • 问题来了:

    • token 是什么?Token 可以认为是一个登录凭证,生成方法可以按照自己设计升级,本实践比较简单,就直接用账号密码组合,然后 md5。
    • token 存在那里?下次如何获取?Token 可以存在 Mysql 数据库中,也可以存在 Redis 中,甚至可以存在 COS 中,例如 Redis 和 COS,都可以利用其自身的一些特性做一些额外的操作,例如数据有效期(用来做登录过期等)。当然本文不想做的那么麻烦,所以每次用户请求过来,都是单独计算 token,然后进行的对比。
    • 这种 token 登陆方法可以用于其他项目么?还是仅适用于这种博客系统。可以适用其他项目,很多项目都可以通过这种方法来做,例如我自己的 Anycodes,也是通过 Token 进行鉴权,只不过在 Serverless 架构下,Token 如何存储是一个问题,但是我个人推荐有钱就用 redis,没钱就用 cos,不想额外花钱就像我,每次是用单独对比。
    • token 存在 redis 可以理解,但是存在 cos 是为什么?cos 本身是对象存储,用来存储文件的,其实完全可以用来存储 token,例如我们每次生成一个新的 token,都把这个 token 设置为一个文件,文件内容就是这个 token 对应的用户信息或者是权限信息,或者其他的信息,然后存储桶策略设置成文件过期时间,例如文件存入 1 天自动删除,那么 1 天之后,你存储的这个 token 文件就会被删除。等用户带着 token 过来的时候,直接通过内网请求 cos(没有流量费)获取指定文件名,如果获取到了就下载回来(文件一般也就 1K 或者以下),然后进行其他操作,不存在就证明用户已过期,或者 token 错误,让他重新登录就好了。当然,这种方法可能不是最优解,但是确实是在 Serverless 条件下的一个有趣的做法。可以在小项目中尝试使用。
  1. 项目本地开发如何进行调试?众所周知 Serverless 架构的本地调试很难。确实如此,虽然说本地调试很困难,但也不是不能越过去的,可以根据项目自己的需求,来做一些调试策略。

项目开发

项目开发过程主要就是数据库的增删改查,为了更加适应 Serverless 架构下的项目开发,也为了提高项目的开发效率特总结了相关的开发技巧和经验。

数据库设计

由于是做一个简单的博客,所以数据库相对设计比较简单,只有文章表、分类表以及标签表、评论表等,整体的 ER 图如下所示:

ER 图

本地开发与调试

对于开发调试,我在每个函数后面增加了对应触发器的调试方案,例如 APIGW 触发器,我增加了以下代码:

1def test(): 2 event = { 3 "requestContext": { 4 "serviceId": "service-f94sy04v", 5 "path": "/test/{path}", 6 "httpMethod": "POST", 7 "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef", 8 "identity": { 9 "secretId": "abdcdxxxxxxxsdfs" 10 }, 11 "sourceIp": "14.17.22.34", 12 "stage": "release" 13 }, 14 "headers": { 15 "Accept-Language": "en-US,en,cn", 16 "Accept": "text/html,application/xml,application/json", 17 "Host": "service-3ei3tii4-251000691.ap-guangzhou.apigateway.myqloud.com", 18 "User-Agent": "User Agent String" 19 }, 20 "body": json.dumps({"id": 1}), 21 .... .... 22 } 23 print(main_handler(event, None)) 24 25 26if __name__ == "__main__": 27 test()

在实际上,我每次想要看一下运行效果,我都会执行这个文件:

1{'id': 1, 'title': '', 'watched': 1, 'category': '热点新闻', 'publish': '2020-02-13 00:45:52', 'tags': [], 'next': {}, 'pre': {}} 2{'uuid': '749ca9f6-4dfb-11ea-9c5b-acde48001122', 'error': False, 'message': ''}

可以认为,是在通过本地模拟一些线上环境。当然,如果有 redis 等一些需要内网资源的函数,就比较麻烦,但是我这做法,可以用于绝大部分函数。包括后台的 Flaks 框架部分:

1def test(): 2 event = {'body': 'name=sdsadasdsadasd&remark=', 'headerParameters': {}, 'headers': { 3 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', 4 'accept-encoding': 'gzip, deflate', 'accept-language': 'zh-CN,zh;q=0.9', 'cache-control': 'no-cache', 5 'connection': 'keep-alive', 'content-length': '27', 'content-type': 'application/x-www-form-urlencoded', 6 'cookie': 'Hm_lvt_a0c900918361b31d762d9cf4dc81ee5b=1574491278,1575257377', 'endpoint-timeout': '15', 7 'host': 'blog.0duzhan.com', 'origin': 'http://blog.0duzhan.com', 'pragma': 'no-cache', 8 'proxy-connection': 'keep-alive', 'referer': 'http://blog.0duzhan.com/admin/tag/new/?url=%2Fadmin%2Ftag%2F', 9 'upgrade-insecure-requests': '1', 10 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36', 11 'x-anonymous-consumer': 'true', 'x-api-requestid': '656622f3b008a0d406a376809b03b52c', 12 'x-b3-traceid': '656622f3b008a0d406a376809b03b52c', 'x-qualifier': '$LATEST'}, 'httpMethod': 'POST', 13 'path': '/admin/tag/new/', 'pathParameters': {}, 'queryString': {'url': '/admin/tag/'}, 14 'queryStringParameters': {}, 15 'requestContext': {'httpMethod': 'ANY', 'identity': {}, 'path': '/admin', 'serviceId': 'service-23ybmuq7', 16 'sourceIp': '119.123.224.87', 'stage': 'release'}} 17 print(main_handler(event, None)) 18 19 20if __name__ == "__main__": 21 test()

index 执行结果:

1{'body': 'name=sdsadasdsadasd&remark=', 'headerParameters': {}, 'headers': {'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', 'accept-encoding': 'gzip, deflate', 'accept-language': 'zh-CN,zh;q=0.9', 'cache-control': 'no-cache', 'connection': 'keep-alive', 'content-length': '27', 'content-type': 'application/x-www-form-urlencoded', 'cookie': 'Hm_lvt_a0c900918361b31d762d9cf4dc81ee5b=1574491278,1575257377', 'endpoint-timeout': '15', 'host': 'blog.0duzhan.com', 'origin': 'http://blog.0duzhan.com', 'pragma': 'no-cache', 'proxy-connection': 'keep-alive', 'referer': 'http://blog.0duzhan.com/admin/tag/new/?url=%2Fadmin%2Ftag%2F', 'upgrade-insecure-requests': '1', 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36', 'x-anonymous-consumer': 'true', 'x-api-requestid': '656622f3b008a0d406a376809b03b52c', 'x-b3-traceid': '656622f3b008a0d406a376809b03b52c', 'x-qualifier': '$LATEST'}, 'httpMethod': 'POST', 'path': '/admin/tag/new/', 'pathParameters': {}, 'queryString': {'url': '/admin/tag/'}, 'queryStringParameters': {}, 'requestContext': {'httpMethod': 'ANY', 'identity': {}, 'path': '/admin', 'serviceId': 'service-23ybmuq7', 'sourceIp': '119.123.224.87', 'stage': 'release'}} 2{'isBase64Encoded': False, 'statusCode': 200, 'headers': {'Content-Type': 'text/html'}, 'body': '<!DOCTYPE html>n<html lang="en">n<head>n <meta charset="UTF-8">n <title>Title</title>n <script>n var url = window.location.hrefn url = url.split("admin")[0] + "admin"n String.prototype.endWith = function (s) {n var d = this.length - s.length;n return (d >= 0 && this.lastIndexOf(s) == d)n }n if (window.location.href != url) {n if (!window.location.href.endsWith("admin") || !window.location.href.endsWith("admin/"))n window.location = urln }nn function doLogin() {n var xmlhttp = window.XMLHttpRequest ? (new XMLHttpRequest()) : (new ActiveXObject("Microsoft.XMLHTTP"))n xmlhttp.onreadystatechange = function () {n if (xmlhttp.readyState == 4 && xmlhttp.status == 200) {n if (JSON.parse(xmlhttp.responseText)["token"]) {n document.cookie = "token=" + JSON.parse(xmlhttp.responseText)["token"];n window.location = `http://${window.location.host}/admin`n } else {n alert(JSON.parse(xmlhttp.responseText)["message"])n }n }n }n xmlhttp.open("POST", window.location.pathname, true);n xmlhttp.setRequestHeader("Content-type", "application/json");n xmlhttp.send(JSON.stringify({n "username": document.getElementById("username").value,n "password": document.getElementById("password").value,n }));n }n </script>n</head>n<body>nn<center><h1>Serverless Blog 后台管理</h1>n 管理账号:<input type="text" id="username"><br>n 管理密码:<input type="password" id="password"><br>n <input type="reset"><input type="submit" onclick="doLogin()"><br>n</center>n</body>n</html>'}

Flask部署

Flask 部署到 Serverless 架构可以用 @serverless/tencent-flask,但是这里为了更加深入了解传统框架如何部署到 Serverless 架构,所以此处自行「造轮子」实现,先来看一张图:

在通常情况下,我们使用 Flask 等框架实际上要通过 web_server,进入到下一个环节,而我们云函数更多是一个函数,本不需要启动 web server,所以我们就可以直接调用 wsgi_app 这个方法,其中这里的 environ 就是我们刚才的通过对 event/context 等进行处理后的对象,start_response 可以认为是我们的一种特殊的数据结构,例如我们的 response 结构形态等。所以,如果我们自己想要实现这个过程,不使用腾讯云 flask-component,可以这样做:

1# -*- coding: utf-8 -*- 2# Copyright 2016 Matt Martz 3# All Rights Reserved. 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); you may 6# not use this file except in compliance with the License. You may obtain 7# a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 13# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 14# License for the specific language governing permissions and limitations 15# under the License. 16 17import sys 18import json 19 20try: 21 from urllib import urlencode 22except ImportError: 23 from urllib.parse import urlencode 24 25from flask import Flask 26 27try: 28 from cStringIO import StringIO 29except ImportError: 30 try: 31 from StringIO import StringIO 32 except ImportError: 33 from io import StringIO 34 35from werkzeug.wrappers import BaseRequest 36 37__version__ = '0.0.4' 38 39 40def make_environ(event): 41 environ = {} 42 43 for hdr_name, hdr_value in event['headers'].items(): 44 hdr_name = hdr_name.replace('-', '_').upper() 45 if hdr_name in ['CONTENT_TYPE', 'CONTENT_LENGTH']: 46 environ[hdr_name] = hdr_value 47 continue 48 49 http_hdr_name = 'HTTP_%s' % hdr_name 50 environ[http_hdr_name] = hdr_value 51 52 apigateway_qs = event['queryStringParameters'] 53 request_qs = event['queryString'] 54 qs = apigateway_qs.copy() 55 qs.update(request_qs) 56 57 body = '' 58 if 'body' in event: 59 body = event['body'] 60 61 environ['REQUEST_METHOD'] = event['httpMethod'] 62 environ['PATH_INFO'] = event['path'] 63 environ['QUERY_STRING'] = urlencode(qs) if qs else '' 64 environ['REMOTE_ADDR'] = 80 65 environ['HOST'] = event['headers']['host'] 66 environ['SCRIPT_NAME'] = '' 67 68 environ['SERVER_PORT'] = 80 69 environ['SERVER_PROTOCOL'] = 'HTTP/1.1' 70 71 environ['CONTENT_LENGTH'] = str(len(body)) 72 73 environ['wsgi.url_scheme'] = '' 74 environ['wsgi.input'] = StringIO(body) 75 environ['wsgi.version'] = (1, 0) 76 environ['wsgi.errors'] = sys.stderr 77 environ['wsgi.multithread'] = False 78 environ['wsgi.run_once'] = True 79 environ['wsgi.multiprocess'] = False 80 81 BaseRequest(environ) 82 83 return environ 84 85 86class LambdaResponse(object): 87 def __init__(self): 88 self.status = None 89 self.response_headers = None 90 91 def start_response(self, status, response_headers, exc_info=None): 92 self.status = int(status[:3]) 93 self.response_headers = dict(response_headers) 94 95 96class FlaskLambda(Flask): 97 def __call__(self, event, context): 98 if 'httpMethod' not in event: 99 print('httpMethod not in event') 100 # In this "context" `event` is `environ` and 101 # `context` is `start_response`, meaning the request didn't 102 # occur via API Gateway and Lambda 103 return super(FlaskLambda, self).__call__(event, context) 104 105 response = LambdaResponse() 106 # print response.start_response 107 108 body = next(self.wsgi_app( 109 make_environ(event), 110 response.start_response 111 )) 112 113 # return { 114 # "isBase64Encoded": False, 115 # "statusCode": 200, 116 # "headers": {'Content-Type': 'text/html'}, 117 # "body": body 118 # } 119 120 return { 121 'statusCode': response.status, 122 'headers': response.response_headers, 123 'body': body 124 } 125

这个代码,可以将 APIGW 过来的请求,变成请求集成的形式,传送给 Flask 框架,用户可以通过 request.form 来获取 post 内容,通过 request.args 获取 get 内容等。

全局变量

全局变量可能包括用户账号,密码,云的密钥信息,数据库信息等,为了统一配置和修改,可以使用我自己写的全局变量组件:

1# 函数们的整体配置信息 2Conf: 3 component: "serverless-global" 4 inputs: 5 region: ap-shanghai 6 runtime: Python3.6 7 handler: index.main_handler 8 include_common: ./common 9 blog_user: Dfounder 10 blog_email: service@anycodes.cn 11 blog_about_me: 这就是我的博客 12 blog_host: blog.0duzhan.com 13 website_title: Serverless Blog System 14 website_keywords: Serverless, Serverless Framework, Tencent Cloud, SCF 15 website_description: 一款基于腾讯云Serverless架构,并且采用Serverless Framework构建的Serverless博客系统。 16 website_bucket: serverless-blog-1256773370 17 mysql_host: 18 mysql_user: root 19 mysql_password: 20 mysql_port: 60510 21 mysql_db: serverless_blog_system 22 admin_user: mytest 23 admin_password: mytestabc 24 tencent_secret_id: 25 tencent_secret_key: 26 tencent_appid:

在使用的时候,可以直接用,例如函数:

1Blog_Web_addComment: 2 component: "@serverless/tencent-scf" 3 inputs: 4 name: Blog_Web_addComment 5 description: 添加评论 6 codeUri: ./cloudFunctions/addComment 7 handler: ${Conf.handler} 8 runtime: ${Conf.runtime} 9 region: ${Conf.region} 10 include: 11 - ${Conf.include_common} 12 environment: 13 variables: 14 mysql_host: ${Conf.mysql_host} 15 mysql_port: ${Conf.mysql_port} 16 mysql_user: ${Conf.mysql_user} 17 mysql_password: ${Conf.mysql_password} 18 mysql_db: ${Conf.mysql_db}

项目初始化

为了让项目更容易初始化,例如我修改网站的名字,描述,关键词,或者我需要建立数据库等。所以这个时候我单独做了一个 init 文件:

1# -*- coding: utf8 -*- 2import pymysql 3import shutil 4import yaml 5import os 6 7 8def setEnv(): 9 try: 10 file = open("./serverless.yaml", 'r', encoding="utf-8") 11 file_data = file.read() 12 file.close() 13 14 data = yaml.load(file_data) 15 for eveKey, eveValue in data['Conf']['inputs'].items(): 16 os.environ[eveKey] = str(eveValue) 17 return True 18 except Exception as e: 19 raise e 20 21 22def initDb(): 23 try: 24 conn = pymysql.connect(host=os.environ.get('mysql_host'), 25 user=os.environ.get('mysql_user'), 26 password=os.environ.get('mysql_password'), 27 port=int(os.environ.get('mysql_port')), 28 charset='utf8') 29 cursor = conn.cursor() 30 sql = "CREATE DATABASE IF NOT EXISTS {db_name}".format(db_name=os.environ.get('mysql_db')) 31 cursor.execute(sql) 32 cursor.close() 33 conn.close() 34 return True 35 except Exception as e: 36 raise e 37 38 39def initTable(): 40 try: 41 conn = pymysql.connect(host=os.environ.get('mysql_host'), 42 user=os.environ.get('mysql_user'), 43 password=os.environ.get('mysql_password'), 44 port=int(os.environ.get('mysql_port')), 45 db=os.environ.get('mysql_db'), 46 charset='utf8', 47 cursorclass=pymysql.cursors.DictCursor, 48 autocommit=1) 49 cursor = conn.cursor() 50 createTags = "CREATE TABLE `tags` ( `tid` INT NOT NULL AUTO_INCREMENT , `name` VARCHAR(255) NOT NULL , `remark` TEXT NULL , PRIMARY KEY (`tid`), UNIQUE (`name`)) ENGINE = InnoDB;" 51 createCategory = "CREATE TABLE `category` ( `cid` INT NOT NULL AUTO_INCREMENT , `name` VARCHAR(255) NOT NULL , `sorted` INT NOT NULL DEFAULT '1' , `remark` TEXT NULL , PRIMARY KEY (`cid`), UNIQUE (`name`)) ENGINE = InnoDB;" 52 createComments = "CREATE TABLE `comments` ( `cid` INT NOT NULL AUTO_INCREMENT , `content` TEXT NOT NULL , `publish` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP , `user` VARCHAR(255) NOT NULL , `email` VARCHAR(255) NULL , `photo` INT NOT NULL DEFAULT '0' , `article` INT NOT NULL , `remark` TEXT NULL , `uni_mark` VARCHAR(255) NOT NULL , `is_show` INT NOT NULL DEFAULT '0' , PRIMARY KEY (`cid`), UNIQUE (`uni_mark`)) ENGINE = InnoDB;" 53 createArticle = "CREATE TABLE `article` ( `aid` INT NOT NULL AUTO_INCREMENT , `title` VARCHAR(255) NOT NULL , `content` TEXT NOT NULL , `description` TEXT NOT NULL , `publish` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP , `watched` INT NOT NULL DEFAULT '0' , `category` INT NOT NULL , `remark` TEXT NULL , PRIMARY KEY (`aid`)) ENGINE = InnoDB;" 54 createArticleTags = "CREATE TABLE `article_tags` ( `atid` INT NOT NULL AUTO_INCREMENT , `aid` INT NOT NULL , `tid` INT NOT NULL , PRIMARY KEY (`atid`)) ENGINE = InnoDB;" 55 alertArticleTagsArticle = "ALTER TABLE `article_tags` ADD CONSTRAINT `article` FOREIGN KEY (`aid`) REFERENCES `article`(`aid`) ON DELETE CASCADE ON UPDATE CASCADE; " 56 alertArticleTagsTags = "ALTER TABLE `article_tags` ADD CONSTRAINT `tags` FOREIGN KEY (`tid`) REFERENCES `tags`(`tid`) ON DELETE CASCADE ON UPDATE CASCADE;" 57 alertArticleCategory = "ALTER TABLE `article` ADD CONSTRAINT `category` FOREIGN KEY (`category`) REFERENCES `category`(`cid`) ON DELETE CASCADE ON UPDATE CASCADE;" 58 alertCommentsArticle = "ALTER TABLE `comments` ADD CONSTRAINT `article_comments` FOREIGN KEY (`article`) REFERENCES `article`(`aid`) ON DELETE CASCADE ON UPDATE CASCADE;" 59 cursor.execute(createTags) 60 cursor.execute(createCategory) 61 cursor.execute(createComments) 62 cursor.execute(createArticle) 63 cursor.execute(createArticleTags) 64 cursor.execute(alertArticleTagsArticle) 65 cursor.execute(alertArticleTagsTags) 66 cursor.execute(alertArticleCategory) 67 cursor.execute(alertCommentsArticle) 68 cursor.close() 69 conn.close() 70 return True 71 except Exception as e: 72 raise e 73 74 75def initHTML(): 76 try: 77 tempPath = "website" 78 tempDist = os.path.join(tempPath, "dist") 79 if os.path.exists(tempDist): 80 shutil.rmtree(tempDist) 81 tempFileList = [] 82 for eve in os.walk(tempPath): 83 if eve[2]: 84 for eveFile in eve[2]: 85 tempFileList.append(os.path.join(eve[0], eveFile)) 86 os.mkdir(tempDist) 87 for eve in tempFileList: 88 temp = os.path.split(eve.replace(tempPath, tempDist)) 89 if not os.path.exists(temp[0]): 90 os.makedirs(temp[0]) 91 if eve.endswith(".html") or eve.endswith(".htm"): 92 with open(eve) as readData: 93 with open(eve.replace(tempPath, tempDist), "w") as writeData: 94 writeData.write(readData.read(). 95 replace('{{ user }}', os.environ.get('blog_user')). 96 replace('{{ email }}', os.environ.get('blog_email')). 97 replace('{{ title }}', os.environ.get('website_title')). 98 replace('{{ keywords }}', os.environ.get('website_keywords')). 99 replace('{{ about_me }}', os.environ.get('blog_about_me')). 100 replace('{{ host }}', os.environ.get('blog_host')). 101 replace('{{ description }}', os.environ.get('website_description'))) 102 else: 103 shutil.copy(eve, eve.replace(tempPath, tempDist)) 104 return True 105 except Exception as e: 106 raise e 107 108 109if __name__ == "__main__": 110 print("获取Yaml数据: ", setEnv()) 111 print("建立数据库:", initDb()) 112 print("建立数据库:", initTable()) 113 print("初始化HTML:", initHTML()) 114

公共组件的开发

在项目中会有很多公共组件,例如数据库的部分,所以我把数据库的代码,统一放到了一起:common/mysqlCommon.py:

1# -*- coding: utf8 -*- 2 3import os 4import re 5import pymysql 6import hashlib 7from random import choice 8 9 10class mysqlCommon: 11 def __init__(self): 12 self.getConnection({ 13 "host": os.environ.get('mysql_host'), 14 "user": os.environ.get('mysql_user'), 15 "port": int(os.environ.get('mysql_port')), 16 "db": os.environ.get('mysql_db'), 17 "password": os.environ.get('mysql_password') 18 }) 19 20 def getDefaultPic(self): 21 return choice([ 22 'http://t8.baidu.com/it/u=1484500186,1503043093&fm=79&app=86&f=JPEG?w=1280&h=853', 23 'http://t8.baidu.com/it/u=2247852322,986532796&fm=79&app=86&f=JPEG?w=1280&h=853', 24 'http://t7.baidu.com/it/u=3204887199,3790688592&fm=79&app=86&f=JPEG?w=4610&h=2968', 25 'http://t9.baidu.com/it/u=3363001160,1163944807&fm=79&app=86&f=JPEG?w=1280&h=830', 26 'http://t9.baidu.com/it/u=583874135,70653437&fm=79&app=86&f=JPEG?w=3607&h=2408', 27 'http://t9.baidu.com/it/u=583874135,70653437&fm=79&app=86&f=JPEG?w=3607&h=2408', 28 'http://t9.baidu.com/it/u=1307125826,3433407105&fm=79&app=86&f=JPEG?w=5760&h=3240', 29 'http://t9.baidu.com/it/u=2268908537,2815455140&fm=79&app=86&f=JPEG?w=1280&h=719', 30 'http://t7.baidu.com/it/u=1179872664,290201490&fm=79&app=86&f=JPEG?w=1280&h=854', 31 'http://t9.baidu.com/it/u=3949188917,63856583&fm=79&app=86&f=JPEG?w=1280&h=875', 32 'http://t9.baidu.com/it/u=2266751744,4253267866&fm=79&app=86&f=JPEG?w=1280&h=854', 33 'http://t8.baidu.com/it/u=4100756023,1345858297&fm=79&app=86&f=JPEG?w=1280&h=854', 34 'http://t7.baidu.com/it/u=1355385882,1155324943&fm=79&app=86&f=JPEG?w=1280&h=854', 35 'http://t9.baidu.com/it/u=2292037961,3689236171&fm=79&app=86&f=JPEG?w=1280&h=854', 36 'http://t9.baidu.com/it/u=4241966675,2405819829&fm=79&app=86&f=JPEG?w=1280&h=854', 37 'http://t8.baidu.com/it/u=2857883419,1187496708&fm=79&app=86&f=JPEG?w=1280&h=763', 38 'http://t8.baidu.com/it/u=198337120,441348595&fm=79&app=86&f=JPEG?w=1280&h=732' 39 ]) 40 41 def getConnection(self, conf): 42 self.connection = pymysql.connect(host=conf['host'], 43 user=conf['user'], 44 password=conf['password'], 45 port=int(conf['port']), 46 db=conf['db'], 47 charset='utf8', 48 cursorclass=pymysql.cursors.DictCursor, 49 autocommit=1) 50 51 def doAction(self, stmt, data): 52 try: 53 self.connection.ping(reconnect=True) 54 cursor = self.connection.cursor() 55 cursor.execute(stmt, data) 56 result = cursor 57 cursor.close() 58 return result 59 except Exception as e: 60 print(e) 61 try: 62 cursor.close() 63 except: 64 pass 65 return False 66 67 def getCategoryList(self): 68 search_stmt = ( 69 "SELECT * FROM `category` ORDER BY `sorted`" 70 ) 71 result = self.doAction(search_stmt, ()) 72 if result == False: 73 return False 74 return [{"id": eveCategory['cid'], "name": eveCategory['name']} for eveCategory in result.fetchall()] 75 76 def getArticleList(self, category, tag, page=1): 77 if category: 78 search_stmt = ( 79 "SELECT article.*,category.name FROM `article` LEFT JOIN `category` ON article.category=category.cid WHERE article.category=%s ORDER BY -article.aid LIMIT %s,%s;" 80 ) 81 count_stmt = ( 82 "SELECT COUNT(*) FROM `article` LEFT JOIN `category` ON article.category=category.cid WHERE article.category=%s;" 83 ) 84 data = (category, 10 * (int(page) - 1), 10 * int(page)) 85 count_data = (category,) 86 elif tag: 87 search_stmt = ( 88 "SELECT article.* FROM `article` LEFT JOIN `article_tags` ON article.aid=article_tags.aid WHERE article_tags.tid=%s ORDER BY -article.aid LIMIT %s,%s;" 89 ) 90 count_stmt = ( 91 "SELECT COUNT(*) FROM `article`LEFT JOIN `article_tags` ON article.aid=article_tags.aid WHERE article_tags.tid=%s;" 92 ) 93 data = (tag, 10 * (int(page) - 1), 10 * int(page)) 94 count_data = (tag,) 95 else: 96 search_stmt = ( 97 "SELECT article.*,category.name FROM `article` LEFT JOIN `category` ON article.category=category.cid ORDER BY -article.aid LIMIT %s,%s;" 98 ) 99 count_stmt = ( 100 "SELECT COUNT(*) FROM `article` LEFT JOIN `category` ON article.category=category.cid; " 101 ) 102 data = (10 * (int(page) - 1), 10 * int(page)) 103 count_data = () 104 result = self.doAction(search_stmt, data) 105 if result == False: 106 return False 107 108 return {"data": [{"id": eveArticle['aid'], 109 "title": eveArticle['title'], 110 "description": eveArticle['description'], 111 "watched": eveArticle['watched'], 112 "category": eveArticle['category'], 113 "publish": str(eveArticle['publish']), 114 "picture": self.getPicture(eveArticle['content'])} 115 for eveArticle in result.fetchall()], 116 "count": self.doAction(count_stmt, count_data).fetchone()["COUNT(*)"]} 117 118 def getHotArticleList(self): 119 search_stmt = ( 120 "SELECT article.*,category.name FROM `article` LEFT JOIN `category` ON article.category=category.cid ORDER BY article.watched LIMIT 0,5" 121 ) 122 result = self.doAction(search_stmt, ()) 123 if result == False: 124 return False 125 return [{"id": eveArticle['aid'], 126 "title": eveArticle['title'], 127 "description": eveArticle['description'], 128 "watched": eveArticle['watched'], 129 "category": eveArticle['category'], 130 "publish": str(eveArticle['publish']), 131 "picture": self.getPicture(eveArticle['content'])} 132 for 133 eveArticle in result.fetchall()] 134 135 def getTagsArticle(self, aid): 136 search_stmt = ( 137 "SELECT tags.name, tags.tid FROM `article_tags` LEFT JOIN `tags` ON article_tags.tid=tags.tid WHERE article_tags.aid=%s;" 138 ) 139 result = self.doAction(search_stmt, (aid,)) 140 if result == False: 141 return False 142 return [{"id": eveTag["tid"], "name": eveTag["name"]} for eveTag in result.fetchall()] 143 144 def getTagsList(self): 145 search_stmt = ( 146 "SELECT * FROM tags ORDER BY RAND() LIMIT 20; " 147 ) 148 result = self.doAction(search_stmt, ()) 149 if result == False: 150 return False 151 return [{"id": eveTag['tid'], "name": eveTag['name']} for eveTag in result.fetchall()] 152 153 def getArticleContent(self, aid): 154 search_stmt = ( 155 "SELECT article.*, category.name FROM `category` LEFT JOIN `article` ON category.cid=article.category WHERE article.aid=%s;" 156 ) 157 result = self.doAction(search_stmt, (aid)) 158 if result == False: 159 return False 160 article = result.fetchone() 161 return { 162 "id": article["aid"], 163 "title": article["title"], 164 "content": article["content"], 165 "description": article["description"], 166 "watched": article["watched"], 167 "category": article["name"], 168 "publish": str(article["publish"]), 169 "tags": self.getTagsArticle(article["aid"]), 170 "next": self.getOtherArticle(aid, "next"), 171 "pre": self.getOtherArticle(aid, "pre") 172 } if article else {} 173 174 def getOtherArticle(self, aid, articleType): 175 search_stmt = ( 176 "SELECT * FROM `article` WHERE aid=(select max(aid) from `article` where aid>%s)" 177 ) if articleType == "next" else ( 178 "SELECT * FROM `article` WHERE aid=(select max(aid) from `article` where aid<%s)" 179 ) 180 result = self.doAction(search_stmt, (aid)) 181 if result == False: 182 return False 183 article = result.fetchone() 184 return { 185 "id": article["aid"], 186 "title": article["title"] 187 } if article else {} 188 189 def getComments(self, aid): 190 search_stmt = ( 191 "SELECT * FROM `comments` WHERE article=%s AND is_show=1 ORDER BY -cid LIMIT 100;" 192 ) 193 result = self.doAction(search_stmt, (aid)) 194 if result == False: 195 return False 196 return [{"content": eveComment['content'], 197 "publish": str(eveComment['publish']), 198 "user": eveComment['user'], 199 "remark": eveComment['remark']} for eveComment in result.fetchall()] 200 201 def addComment(self, content, user, email, aid): 202 insert_stmt = ( 203 "INSERT INTO `comments` (`cid`, `content`, `publish`, `user`, `email`, `article`, `uni_mark`) " 204 "VALUES (NULL, %s, CURRENT_TIMESTAMP, %s, %s, %s, %s)" 205 ) 206 result = self.doAction(insert_stmt, (content, user, email, aid, hashlib.md5( 207 ("%s----%s----%s----%s" % (str(content), str(user), str(email), str(aid))).encode("utf-8")).hexdigest())) 208 return False if result == False else True 209 210 def updateArticleWatched(self, wid): 211 update_stmt = ( 212 "UPDATE `article` SET `watched`=`watched`+1 WHERE `aid` = %s" 213 ) 214 return False if self.doAction(update_stmt, (wid)) == False else True 215 216 def getPicture(self, content): 217 resultList =[eve[1] for eve in re.findall('<img(.*?)src="(.*?)"(.*?)>', content)] 218 return resultList[0] if resultList else self.getDefaultPic() 219 220 221 def getTag(self, tag): 222 search_stmt = ( 223 "SELECT * FROM `tags` WHERE name=%s;" 224 ) 225 result = self.doAction(search_stmt, (tag,)) 226 return False if not result or result.rowcount == 0 else result.fetchone()['tid'] 227 228 def addTag(self, tag): 229 insert_stmt = ( 230 "INSERT INTO `tags` (`tid`, `name`, `remark`) " 231 "VALUES (NULL, %s, NULL)" 232 ) 233 result = self.doAction(insert_stmt, (tag)) 234 return False if result == False else result.lastrowid 235 236 def addArticleTag(self, article, tag): 237 insert_stmt = ( 238 "INSERT INTO `article_tags` (`atid`, `aid`, `tid`) " 239 "VALUES (NULL, %s, %s)" 240 ) 241 result = self.doAction(insert_stmt, (article, tag)) 242 return False if result == False else True

这里基本上是,这个项目需要的数据库增删改查的全部功能(admin 除外),在使用的时候,分为本地和线上:

1try: 2 import returnCommon 3 from mysqlCommon import mysqlCommon 4except: 5 import common.testCommon 6 7 common.testCommon.setEnv() 8 9 import common.returnCommon as returnCommon 10 from common.mysqlCommon import mysqlCommon 11 12mysql = mysqlCommon()

通过 python 的异常,如果导入没找到,那就说明是本地测试,如果 from mysqlCommon import mysqlCommon 找到了,那就说明是线上环境。除了数据库的公共组件,我还有 returnCommon 等公共文件。当然, 这些文件,在使用的时候也需要打包进入,可以在 yaml 中增加 include,例如:

1Blog_Web_addComment: 2 component: "@serverless/tencent-scf" 3 inputs: 4 name: Blog_Web_addComment 5 description: 添加评论 6 codeUri: ./cloudFunctions/addComment 7 handler: ${Conf.handler} 8 runtime: ${Conf.runtime} 9 region: ${Conf.region} 10 include: 11 - ${Conf.include_common}

功能展示

前台功能

  • 列表页 列表页

  • 内容页 内容页

后台功能

  • 登录功能 登录功能

  • 列表页 列表页

  • 表单页 表单页

项目部署

  • 配置 serverless.yaml

    函数们的整体配置信息

    Conf: component: "serverless-global" inputs: region: ap-shanghai runtime: Python3.6 handler: index.main_handler include_common: ./common blog_user: Dfounder blog_email: service@anycodes.cn website_title: Serverless Blog System website_keywords: Serverless, Serverless Framework, Tencent Cloud, SCF website_description: 一款基于腾讯云Serverless架构,并且采用Serverless Framework构建的Serverless博客系统。 website_bucket: serverless-blog-1256773370 mysql_host: mysql_password: mysql_port: mysql_db: admin_user: mytest admin_password: mytest

除了上面的内容,还要看一下域名问题(例如 CosBucket):

1# 网站 2CosBucket: 3 component: '@serverless/tencent-website' 4 inputs: 5 code: 6 root: website/dist 7 src: ./ 8 index: list.html 9 region: ${Conf.region} 10 bucketName: ${Conf.website_bucket} 11 hosts: 12 - host: 0duzhan.com 13 https: 14 certId: awPsOIHY 15 forceSwitch: -1 16 - host: www.0duzhan.com 17 https: 18 certId: awPsOIHY 19 forceSwitch: -1 20 21 env: 22 apiUrl: ${APIService.subDomain}

以及 API 网关内容:

1# 创建 API 网关 Service 2APIService: 3 component: "@serverless/tencent-apigateway" 4 inputs: 5 region: ${Conf.region} 6 customDomain: 7 - domain: api.0duzhan.com 8 isDefaultMapping: 'FALSE' 9 pathMappingSet: 10 - path: / 11 environment: release 12 protocols: 13 - http 14 protocols: 15 - http 16 - https 17 ........

这两部分域名可以修改成自己的,或者删除掉这两个 key

  • 执行init.py:

这里要注意,我是在 macOS 下开发的,init.py 可以在 macOS/Linux 运行,Windows 用户可能要适当修改一下。还有这里面需要一个依赖:pyyaml,需要自行安装一下。

1获取Yaml数据: True 2建立数据库: True 3建立数据库: True 4初始化HTMLTrue
  • 部署资源,执行 serverless --debug

    (venv) ServerlessBlog:ServerlessBlog dfounderliu$ sls --debug

    DEBUG ─ Resolving the template's static variables. DEBUG ─ Collecting components from the template. DEBUG ─ Downloading any NPM components found in the template. DEBUG ─ Analyzing the template's components dependencies. DEBUG ─ Creating the template's components graph. DEBUG ─ Syncing template state. DEBUG ─ Executing the template's components graph. DEBUG ─ Preparing website Tencent COS bucket serverless-blog-1256773370. DEBUG ─ Starting API-Gateway deployment with name APIService in the ap-shanghai region DEBUG ─ Using last time deploy service id service-23ybmuq7 DEBUG ─ Updating service with serviceId service-23ybmuq7. DEBUG ─ Bucket "serverless-blog-1256773370" in the "ap-shanghai" region alrea

    ………………

    1 - 2 path: /web/article/watched/update 3 method: POST 4 apiId: api-gnvnrbyk 5 - 6 path: /web/sentence/get 7 method: POST 8 apiId: api-msvadsau 9 - 10 path: /web/article/list/hot/get 11 method: POST 12 apiId: api-kfkrjhim 13 - 14 path: /web/tags/list/get 15 method: POST 16 apiId: api-avydagem 17 - 18 path: /admin 19 method: ANY 20 apiId: api-4tnz5tc4

    176s › APIService › done

项目总结

传统博客已经有很多了,无论是基于 PHP 的 zblog 还是 wp 等开源项目,都可以帮助我们快速搭建一个博客系统。除了这些博客系统之外,还有很多静态博客系统。但是就目前而言,基于 Serverless 架构的博客系统还是比较少见的。

本文通过原生的 Serverless 项目开发与 Flask 框架的部署上 Serverless 实现了一个基于 Python 语言的博客系统。通过该博客系统,用户可以发布文章,自动撰写文章的关键词和摘要,还可以进行留言评论的管理。当然,这个博客系统仅作为工程实践使用,实际上还是有一些设计不合理的地方,但是我相信,随着时间的发展,Serverless 架构越来越成熟,基于 Serverless 的开源 Blog 项目或 CMS 项目也会越来越多,期待那一天的到来!

Serverless Framework 30 天试用计划

我们诚邀您来体验最便捷的 Serverless 开发和部署方式。在试用期内,相关联的产品及服务均提供免费资源和专业的技术支持,帮助您的业务快速、便捷地实现 Serverless!

详情可查阅:Serverless Framework 试用计划

One More Thing

3 秒你能做什么?喝一口水,看一封邮件,还是 —— 部署一个完整的 Serverless 应用?

复制链接至 PC 浏览器访问:https://serverless.cloud.tencent.com/deploy/express

3 秒极速部署,立即体验史上最快的 Serverless HTTP 实战开发!

传送门:

欢迎访问:Serverless 中文网,您可以在 最佳实践 里体验更多关于 Serverless 应用的开发!


推荐阅读:《Serverless 架构:从原理、设计到项目实战》

点赞
收藏

评论区

加载中...

相关推荐

MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1

文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s

Oracle 分组与拼接字符串同时使用

SELECTT.,ROWNUMIDFROM(SELECTT.EMPLID,T.NAME,T.BU,T.REALDEPART,T.FORMATDATE,SUM(T.S0)S0,MAX(UPDATETIME)CREATETIME,LISTAGG(TOCHAR(

MySQL部分从库上面因为大量的临时表tmp_table造成慢查询

背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_

皕杰报表之UUID

​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为

手写Java HashMap源码

HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程22

2020年前端实用代码段,为你的工作保驾护航

有空的时候,自己总结了几个代码段,在开发中也经常使用,谢谢。1、使用解构获取json数据let jsonData  id: 1,status: "OK",data: 'a', 'b';let  id, status, data: number   jsonData;console.log(id, status, number )