隨着Serverless架構越來越火熱,其按量付費,彈性伸縮等很多優質特性都讓人眼前一亮,不得驚歎雲計算爲我們帶來的便利。但是就目前而言,Serverless架構相關的業務框架還是比較少的。雖然騰訊雲Serverless與Serverless Framework聯手,支持了Express、Koa、Egg以及Flask等衆多項目的輕鬆上雲,但是在實際使用過程中,尤其是遷移過程中還是很痛苦的。而且,從這些框架在Serverless組件上的表現來看,POST/GET的參數傳輸方法就比較難原生獲取,這可能導致原有項目上雲要經歷很大的演進過程。
不僅是原生框架直接部署在Serverless架構上會有困難,直接在Serverless架構上開發的框架也很少。所以,本文希望通過一個簡單的博客系統開發實踐,讓大家體驗一個基於Serverless架構的系統應該是什麼樣子。
開發前的思考
思考
- 博客系統需要哪些功能?
這個博客系統僅作爲拋磚引玉項目,所以功能比較少,只有兩個頁面,具有文章管理、分類管理、標籤管理以及留言管理等功能,但是爲了方便用戶管理,要有前臺和後臺兩部分。
- 前臺如何做?
前臺的用戶流量可能會比較大(相對後臺而言),所以這部分使用單獨的函數,每個功能一個函數,初步判斷前臺需要的函數包括:獲取文章分類、獲取文章列表、獲取評論列表、增加評論、獲取標籤列表等接口。
- 後臺如何做?
理論上來說,後臺是管理員的專屬地盤,所以這部分流量比較小,可以通過flask-admin放入到一個函數中來解決。
- 爲什麼前臺要那麼多函數,後臺用一個框架?
整個項目就用一個框架不好嗎?首先要申明,整個項目用一個框架是可以的,但是並不好,例如這個項目的後臺使用的是Flask框架,用了Flask-admin來做後臺管理,開發過程可能很簡單,一百行代碼就可以搞定,但是這樣會引發其中問題。
網頁的返回,需要APIGW開啓響應集成,響應集成的性能其實很差,所以相對來說,不太適合放在前端。
一個完整的項目比較大,需要的資源也會更多,那麼就需要分配給這個函數更多的資源內存,會導致收費的增加。例如後臺給的資源是1024,前端每個函數給的內存資源是128/256,在執行同樣時間的時候,明顯後者的費用降低了4-8倍。同樣,函數會涉及到冷啓動,冷啓動一個函數和冷啓動函數中的一個完整的框架/項目,前者的速度和性能會更好。
函數都有併發上限,如果所有資源全都請求到一個函數,那麼就會出現實際用戶併發幾個的時候,對應的函數併發就已經是達到幾十上百。在用戶稍微多一點的情況下,就會觸及用戶實例的上限限制。後臺功能是非頻繁功能,前臺相對來說是更頻繁的,所以前臺使用單獨接口更合理。
- 登陸功能怎麼做?
函數並不像傳統開發可以將客戶的登錄信息緩存到機器上,但是客戶端依舊可以使用cookie,所以利用這個方法,可以做以下流程:
1、後臺登錄入口處,拉取APIGW傳過來的APIGW Event,看其中headers/cookie是否存在,不存在就會返回登錄頁面
2、如果headers/cookie存在,取cookie中的Token字段,判斷Token字段是否和服務端的Token字段吻合,吻合進入系統後臺,不吻合返回登錄頁面
3、用戶登錄,請求後臺的登陸功能,如果賬號密碼正確,則返回給用戶一個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天自動刪除。當用戶Token到達時,直接通過內網請求COS(沒有流量費)獲取指定文件名,如果獲取到了就下載回來,然後進行其他操作,不存在就證明用戶已過期或者Token錯誤,提示用戶重新登錄。當然,這種方法可能不是最優解,但是在Serverless條件下的一個有趣做法,可以在小項目中嘗試使用。
- 項目本地開發如何進行調試?
衆所周知,Serverless架構的本地調試很難。確實如此,雖說本地調試很困難,但也不是完全不能做的,可以根據項目的需求來做調試策略。
項目開發
數據庫設計
由於這是一個簡單的博客,所以數據庫的設計也相對比較簡單,只有文章表、分類表、標籤表、評論表等,整體的ER圖如下所示:
項目開發
項目開發過程主要是數據庫的增刪改查,爲了適應Serverless架構下的項目開發、提高項目的開發效率,這裏總結了一些相關的開發技巧和經驗:
本地開發與調試
針對開發調試,我們在每個函數後都增加了對應觸發器的調試方案,例如APIGW觸發器。
增加代碼:
def test():
event = {
"requestContext": {
"serviceId": "service-f94sy04v",
"path": "/test/{path}",
"httpMethod": "POST",
"requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef",
"identity": {
"secretId": "abdcdxxxxxxxsdfs"
},
"sourceIp": "14.17.22.34",
"stage": "release"
},
"headers": {
"Accept-Language": "en-US,en,cn",
"Accept": "text/html,application/xml,application/json",
"Host": "service-3ei3tii4-251000691.ap-guangzhou.apigateway.myqloud.com",
"User-Agent": "User Agent String"
},
"body": json.dumps({"id": 1}),
.... ....
}
print(main_handler(event, None))
if __name__ == "__main__":
test()
如果想看運行效果,可以執行以下文件:
{'id': 1, 'title': '', 'watched': 1, 'category': '熱點新聞', 'publish': '2020-02-13 00:45:52', 'tags': [], 'next': {}, 'pre': {}}
{'uuid': '749ca9f6-4dfb-11ea-9c5b-acde48001122', 'error': False, 'message': ''}
接下來,我們可以在本地模擬線上環境。下面的這個做法可以用於絕大部分函數,但如果遇到需要內網資源的函數,這個做法可能就比較麻煩。
後臺的Flaks框架部分:
def test():
event = {'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'}}
print(main_handler(event, None))
if __name__ == "__main__":
test()
index執行結果:
{'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'}}
{'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.href\n 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 = url\n }\n\n 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>\n\n<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,我們可以這樣實現:
# -*- coding: utf-8 -*-
# Copyright 2016 Matt Martz
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import sys
import json
try:
from urllib import urlencode
except ImportError:
from urllib.parse import urlencode
from flask import Flask
try:
from cStringIO import StringIO
except ImportError:
try:
from StringIO import StringIO
except ImportError:
from io import StringIO
from werkzeug.wrappers import BaseRequest
__version__ = '0.0.4'
def make_environ(event):
environ = {}
for hdr_name, hdr_value in event['headers'].items():
hdr_name = hdr_name.replace('-', '_').upper()
if hdr_name in ['CONTENT_TYPE', 'CONTENT_LENGTH']:
environ[hdr_name] = hdr_value
continue
http_hdr_name = 'HTTP_%s' % hdr_name
environ[http_hdr_name] = hdr_value
apigateway_qs = event['queryStringParameters']
request_qs = event['queryString']
qs = apigateway_qs.copy()
qs.update(request_qs)
body = ''
if 'body' in event:
body = event['body']
environ['REQUEST_METHOD'] = event['httpMethod']
environ['PATH_INFO'] = event['path']
environ['QUERY_STRING'] = urlencode(qs) if qs else ''
environ['REMOTE_ADDR'] = 80
environ['HOST'] = event['headers']['host']
environ['SCRIPT_NAME'] = ''
environ['SERVER_PORT'] = 80
environ['SERVER_PROTOCOL'] = 'HTTP/1.1'
environ['CONTENT_LENGTH'] = str(len(body))
environ['wsgi.url_scheme'] = ''
environ['wsgi.input'] = StringIO(body)
environ['wsgi.version'] = (1, 0)
environ['wsgi.errors'] = sys.stderr
environ['wsgi.multithread'] = False
environ['wsgi.run_once'] = True
environ['wsgi.multiprocess'] = False
BaseRequest(environ)
return environ
class LambdaResponse(object):
def __init__(self):
self.status = None
self.response_headers = None
def start_response(self, status, response_headers, exc_info=None):
self.status = int(status[:3])
self.response_headers = dict(response_headers)
class FlaskLambda(Flask):
def __call__(self, event, context):
if 'httpMethod' not in event:
print('httpMethod not in event')
# In this "context" `event` is `environ` and
# `context` is `start_response`, meaning the request didn't
# occur via API Gateway and Lambda
return super(FlaskLambda, self).__call__(event, context)
response = LambdaResponse()
# print response.start_response
body = next(self.wsgi_app(
make_environ(event),
response.start_response
))
# return {
# "isBase64Encoded": False,
# "statusCode": 200,
# "headers": {'Content-Type': 'text/html'},
# "body": body
# }
return {
'statusCode': response.status,
'headers': response.response_headers,
'body': body
}
以上代碼實現的是將APIGW請求轉變成請求集成的形式,傳送給Flask框架,用戶可以通過request.form來獲取post內容,通過request.args獲取get內容等。
全局變量
全局變量可能包括用戶賬號、密碼、雲的密鑰信息、數據庫信息等。爲了統一配置和修改,這裏使用了自己寫的全局變量組件:
# 函數們的整體配置信息
Conf:
component: "serverless-global"
inputs:
region: ap-shanghai
runtime: Python3.6
handler: index.main_handler
include_common: ./common
blog_user: Dfounder
blog_email: [email protected]
blog_about_me: 這就是我的博客
blog_host: blog.0duzhan.com
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_user: root
mysql_password:
mysql_port: 60510
mysql_db: serverless_blog_system
admin_user: mytest
admin_password: mytestabc
tencent_secret_id:
tencent_secret_key:
tencent_appid:
有些函數可以直接使用:
Blog_Web_addComment:
component: "@serverless/tencent-scf"
inputs:
name: Blog_Web_addComment
description: 添加評論
codeUri: ./cloudFunctions/addComment
handler: ${Conf.handler}
runtime: ${Conf.runtime}
region: ${Conf.region}
include:
- ${Conf.include_common}
environment:
variables:
mysql_host: ${Conf.mysql_host}
mysql_port: ${Conf.mysql_port}
mysql_user: ${Conf.mysql_user}
mysql_password: ${Conf.mysql_password}
mysql_db: ${Conf.mysql_db}
讓項目初始化更容易
爲了讓項目更容易初始化,例如修改網站名字、描述、關鍵詞、建立數據庫等,我單獨做了一個init文件:
# -*- coding: utf8 -*-
import pymysql
import shutil
import yaml
import os
def setEnv():
try:
file = open("./serverless.yaml", 'r', encoding="utf-8")
file_data = file.read()
file.close()
data = yaml.load(file_data)
for eveKey, eveValue in data['Conf']['inputs'].items():
os.environ[eveKey] = str(eveValue)
return True
except Exception as e:
raise e
def initDb():
try:
conn = pymysql.connect(host=os.environ.get('mysql_host'),
user=os.environ.get('mysql_user'),
password=os.environ.get('mysql_password'),
port=int(os.environ.get('mysql_port')),
charset='utf8')
cursor = conn.cursor()
sql = "CREATE DATABASE IF NOT EXISTS {db_name}".format(db_name=os.environ.get('mysql_db'))
cursor.execute(sql)
cursor.close()
conn.close()
return True
except Exception as e:
raise e
def initTable():
try:
conn = pymysql.connect(host=os.environ.get('mysql_host'),
user=os.environ.get('mysql_user'),
password=os.environ.get('mysql_password'),
port=int(os.environ.get('mysql_port')),
db=os.environ.get('mysql_db'),
charset='utf8',
cursorclass=pymysql.cursors.DictCursor,
autocommit=1)
cursor = conn.cursor()
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;"
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;"
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;"
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;"
createArticleTags = "CREATE TABLE `article_tags` ( `atid` INT NOT NULL AUTO_INCREMENT , `aid` INT NOT NULL , `tid` INT NOT NULL , PRIMARY KEY (`atid`)) ENGINE = InnoDB;"
alertArticleTagsArticle = "ALTER TABLE `article_tags` ADD CONSTRAINT `article` FOREIGN KEY (`aid`) REFERENCES `article`(`aid`) ON DELETE CASCADE ON UPDATE CASCADE; "
alertArticleTagsTags = "ALTER TABLE `article_tags` ADD CONSTRAINT `tags` FOREIGN KEY (`tid`) REFERENCES `tags`(`tid`) ON DELETE CASCADE ON UPDATE CASCADE;"
alertArticleCategory = "ALTER TABLE `article` ADD CONSTRAINT `category` FOREIGN KEY (`category`) REFERENCES `category`(`cid`) ON DELETE CASCADE ON UPDATE CASCADE;"
alertCommentsArticle = "ALTER TABLE `comments` ADD CONSTRAINT `article_comments` FOREIGN KEY (`article`) REFERENCES `article`(`aid`) ON DELETE CASCADE ON UPDATE CASCADE;"
cursor.execute(createTags)
cursor.execute(createCategory)
cursor.execute(createComments)
cursor.execute(createArticle)
cursor.execute(createArticleTags)
cursor.execute(alertArticleTagsArticle)
cursor.execute(alertArticleTagsTags)
cursor.execute(alertArticleCategory)
cursor.execute(alertCommentsArticle)
cursor.close()
conn.close()
return True
except Exception as e:
raise e
def initHTML():
try:
tempPath = "website"
tempDist = os.path.join(tempPath, "dist")
if os.path.exists(tempDist):
shutil.rmtree(tempDist)
tempFileList = []
for eve in os.walk(tempPath):
if eve[2]:
for eveFile in eve[2]:
tempFileList.append(os.path.join(eve[0], eveFile))
os.mkdir(tempDist)
for eve in tempFileList:
temp = os.path.split(eve.replace(tempPath, tempDist))
if not os.path.exists(temp[0]):
os.makedirs(temp[0])
if eve.endswith(".html") or eve.endswith(".htm"):
with open(eve) as readData:
with open(eve.replace(tempPath, tempDist), "w") as writeData:
writeData.write(readData.read().
replace('{{ user }}', os.environ.get('blog_user')).
replace('{{ email }}', os.environ.get('blog_email')).
replace('{{ title }}', os.environ.get('website_title')).
replace('{{ keywords }}', os.environ.get('website_keywords')).
replace('{{ about_me }}', os.environ.get('blog_about_me')).
replace('{{ host }}', os.environ.get('blog_host')).
replace('{{ description }}', os.environ.get('website_description')))
else:
shutil.copy(eve, eve.replace(tempPath, tempDist))
return True
except Exception as e:
raise e
if __name__ == "__main__":
print("獲取Yaml數據: ", setEnv())
print("建立數據庫:", initDb())
print("建立數據庫:", initTable())
print("初始化HTML:", initHTML())
公共組件的開發
項目中會有很多公共組件,例如數據庫,所以我把數據庫的代碼統一放在了一起:common/mysqlCommon.py
:
# -*- coding: utf8 -*-
import os
import re
import pymysql
import hashlib
from random import choice
class mysqlCommon:
def __init__(self):
self.getConnection({
"host": os.environ.get('mysql_host'),
"user": os.environ.get('mysql_user'),
"port": int(os.environ.get('mysql_port')),
"db": os.environ.get('mysql_db'),
"password": os.environ.get('mysql_password')
})
def getDefaultPic(self):
return choice([
'http://t8.baidu.com/it/u=1484500186,1503043093&fm=79&app=86&f=JPEG?w=1280&h=853',
'http://t8.baidu.com/it/u=2247852322,986532796&fm=79&app=86&f=JPEG?w=1280&h=853',
'http://t7.baidu.com/it/u=3204887199,3790688592&fm=79&app=86&f=JPEG?w=4610&h=2968',
'http://t9.baidu.com/it/u=3363001160,1163944807&fm=79&app=86&f=JPEG?w=1280&h=830',
'http://t9.baidu.com/it/u=583874135,70653437&fm=79&app=86&f=JPEG?w=3607&h=2408',
'http://t9.baidu.com/it/u=583874135,70653437&fm=79&app=86&f=JPEG?w=3607&h=2408',
'http://t9.baidu.com/it/u=1307125826,3433407105&fm=79&app=86&f=JPEG?w=5760&h=3240',
'http://t9.baidu.com/it/u=2268908537,2815455140&fm=79&app=86&f=JPEG?w=1280&h=719',
'http://t7.baidu.com/it/u=1179872664,290201490&fm=79&app=86&f=JPEG?w=1280&h=854',
'http://t9.baidu.com/it/u=3949188917,63856583&fm=79&app=86&f=JPEG?w=1280&h=875',
'http://t9.baidu.com/it/u=2266751744,4253267866&fm=79&app=86&f=JPEG?w=1280&h=854',
'http://t8.baidu.com/it/u=4100756023,1345858297&fm=79&app=86&f=JPEG?w=1280&h=854',
'http://t7.baidu.com/it/u=1355385882,1155324943&fm=79&app=86&f=JPEG?w=1280&h=854',
'http://t9.baidu.com/it/u=2292037961,3689236171&fm=79&app=86&f=JPEG?w=1280&h=854',
'http://t9.baidu.com/it/u=4241966675,2405819829&fm=79&app=86&f=JPEG?w=1280&h=854',
'http://t8.baidu.com/it/u=2857883419,1187496708&fm=79&app=86&f=JPEG?w=1280&h=763',
'http://t8.baidu.com/it/u=198337120,441348595&fm=79&app=86&f=JPEG?w=1280&h=732'
])
def getConnection(self, conf):
self.connection = pymysql.connect(host=conf['host'],
user=conf['user'],
password=conf['password'],
port=int(conf['port']),
db=conf['db'],
charset='utf8',
cursorclass=pymysql.cursors.DictCursor,
autocommit=1)
def doAction(self, stmt, data):
try:
self.connection.ping(reconnect=True)
cursor = self.connection.cursor()
cursor.execute(stmt, data)
result = cursor
cursor.close()
return result
except Exception as e:
print(e)
try:
cursor.close()
except:
pass
return False
def getCategoryList(self):
search_stmt = (
"SELECT * FROM `category` ORDER BY `sorted`"
)
result = self.doAction(search_stmt, ())
if result == False:
return False
return [{"id": eveCategory['cid'], "name": eveCategory['name']} for eveCategory in result.fetchall()]
def getArticleList(self, category, tag, page=1):
if category:
search_stmt = (
"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;"
)
count_stmt = (
"SELECT COUNT(*) FROM `article` LEFT JOIN `category` ON article.category=category.cid WHERE article.category=%s;"
)
data = (category, 10 * (int(page) - 1), 10 * int(page))
count_data = (category,)
elif tag:
search_stmt = (
"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;"
)
count_stmt = (
"SELECT COUNT(*) FROM `article`LEFT JOIN `article_tags` ON article.aid=article_tags.aid WHERE article_tags.tid=%s;"
)
data = (tag, 10 * (int(page) - 1), 10 * int(page))
count_data = (tag,)
else:
search_stmt = (
"SELECT article.*,category.name FROM `article` LEFT JOIN `category` ON article.category=category.cid ORDER BY -article.aid LIMIT %s,%s;"
)
count_stmt = (
"SELECT COUNT(*) FROM `article` LEFT JOIN `category` ON article.category=category.cid; "
)
data = (10 * (int(page) - 1), 10 * int(page))
count_data = ()
result = self.doAction(search_stmt, data)
if result == False:
return False
return {"data": [{"id": eveArticle['aid'],
"title": eveArticle['title'],
"description": eveArticle['description'],
"watched": eveArticle['watched'],
"category": eveArticle['category'],
"publish": str(eveArticle['publish']),
"picture": self.getPicture(eveArticle['content'])}
for eveArticle in result.fetchall()],
"count": self.doAction(count_stmt, count_data).fetchone()["COUNT(*)"]}
def getHotArticleList(self):
search_stmt = (
"SELECT article.*,category.name FROM `article` LEFT JOIN `category` ON article.category=category.cid ORDER BY article.watched LIMIT 0,5"
)
result = self.doAction(search_stmt, ())
if result == False:
return False
return [{"id": eveArticle['aid'],
"title": eveArticle['title'],
"description": eveArticle['description'],
"watched": eveArticle['watched'],
"category": eveArticle['category'],
"publish": str(eveArticle['publish']),
"picture": self.getPicture(eveArticle['content'])}
for
eveArticle in result.fetchall()]
def getTagsArticle(self, aid):
search_stmt = (
"SELECT tags.name, tags.tid FROM `article_tags` LEFT JOIN `tags` ON article_tags.tid=tags.tid WHERE article_tags.aid=%s;"
)
result = self.doAction(search_stmt, (aid,))
if result == False:
return False
return [{"id": eveTag["tid"], "name": eveTag["name"]} for eveTag in result.fetchall()]
def getTagsList(self):
search_stmt = (
"SELECT * FROM tags ORDER BY RAND() LIMIT 20; "
)
result = self.doAction(search_stmt, ())
if result == False:
return False
return [{"id": eveTag['tid'], "name": eveTag['name']} for eveTag in result.fetchall()]
def getArticleContent(self, aid):
search_stmt = (
"SELECT article.*, category.name FROM `category` LEFT JOIN `article` ON category.cid=article.category WHERE article.aid=%s;"
)
result = self.doAction(search_stmt, (aid))
if result == False:
return False
article = result.fetchone()
return {
"id": article["aid"],
"title": article["title"],
"content": article["content"],
"description": article["description"],
"watched": article["watched"],
"category": article["name"],
"publish": str(article["publish"]),
"tags": self.getTagsArticle(article["aid"]),
"next": self.getOtherArticle(aid, "next"),
"pre": self.getOtherArticle(aid, "pre")
} if article else {}
def getOtherArticle(self, aid, articleType):
search_stmt = (
"SELECT * FROM `article` WHERE aid=(select max(aid) from `article` where aid>%s)"
) if articleType == "next" else (
"SELECT * FROM `article` WHERE aid=(select max(aid) from `article` where aid<%s)"
)
result = self.doAction(search_stmt, (aid))
if result == False:
return False
article = result.fetchone()
return {
"id": article["aid"],
"title": article["title"]
} if article else {}
def getComments(self, aid):
search_stmt = (
"SELECT * FROM `comments` WHERE article=%s AND is_show=1 ORDER BY -cid LIMIT 100;"
)
result = self.doAction(search_stmt, (aid))
if result == False:
return False
return [{"content": eveComment['content'],
"publish": str(eveComment['publish']),
"user": eveComment['user'],
"remark": eveComment['remark']} for eveComment in result.fetchall()]
def addComment(self, content, user, email, aid):
insert_stmt = (
"INSERT INTO `comments` (`cid`, `content`, `publish`, `user`, `email`, `article`, `uni_mark`) "
"VALUES (NULL, %s, CURRENT_TIMESTAMP, %s, %s, %s, %s)"
)
result = self.doAction(insert_stmt, (content, user, email, aid, hashlib.md5(
("%s----%s----%s----%s" % (str(content), str(user), str(email), str(aid))).encode("utf-8")).hexdigest()))
return False if result == False else True
def updateArticleWatched(self, wid):
update_stmt = (
"UPDATE `article` SET `watched`=`watched`+1 WHERE `aid` = %s"
)
return False if self.doAction(update_stmt, (wid)) == False else True
def getPicture(self, content):
resultList =[eve[1] for eve in re.findall('<img(.*?)src="(.*?)"(.*?)>', content)]
return resultList[0] if resultList else self.getDefaultPic()
def getTag(self, tag):
search_stmt = (
"SELECT * FROM `tags` WHERE name=%s;"
)
result = self.doAction(search_stmt, (tag,))
return False if not result or result.rowcount == 0 else result.fetchone()['tid']
def addTag(self, tag):
insert_stmt = (
"INSERT INTO `tags` (`tid`, `name`, `remark`) "
"VALUES (NULL, %s, NULL)"
)
result = self.doAction(insert_stmt, (tag))
return False if result == False else result.lastrowid
def addArticleTag(self, article, tag):
insert_stmt = (
"INSERT INTO `article_tags` (`atid`, `aid`, `tid`) "
"VALUES (NULL, %s, %s)"
)
result = self.doAction(insert_stmt, (article, tag))
return False if result == False else True
這裏實現的是數據庫增刪改查的全部功能(admin除外),在使用的時候分爲本地和線上:
try:
import returnCommon
from mysqlCommon import mysqlCommon
except:
import common.testCommon
common.testCommon.setEnv()
import common.returnCommon as returnCommon
from common.mysqlCommon import mysqlCommon
mysql = mysqlCommon()
利用Python異常,如果導入沒找到,那就說明是本地測試,如果from mysqlCommon import mysqlCommon
找到了,那就說明是線上環境。除了數據庫的公共組件,還會有returnCommon
等公共文件。 這些文件在使用的時候也需要打包進入,可以在yaml中增加include,例如:
Blog_Web_addComment:
component: "@serverless/tencent-scf"
inputs:
name: Blog_Web_addComment
description: 添加評論
codeUri: ./cloudFunctions/addComment
handler: ${Conf.handler}
runtime: ${Conf.runtime}
region: ${Conf.region}
include:
- ${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: [email protected]
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:
component: '@serverless/tencent-website'
inputs:
code:
root: website/dist
src: ./
index: list.html
region: ${Conf.region}
bucketName: ${Conf.website_bucket}
hosts:
- host: 0duzhan.com
https:
certId: awPsOIHY
forceSwitch: -1
- host: www.0duzhan.com
https:
certId: awPsOIHY
forceSwitch: -1
env:
apiUrl: ${APIService.subDomain}
API網關內容:
# 創建API網關Service
APIService:
component: "@serverless/tencent-apigateway"
inputs:
region: ${Conf.region}
customDomain:
- domain: api.0duzhan.com
isDefaultMapping: 'FALSE'
pathMappingSet:
- path: /
environment: release
protocols:
- http
protocols:
- http
- https
........
這裏可以將這兩部分域名可以修改成自己的。
- 執行
init.py
:
這裏需要注意的是,該示例是在MacOS下開發的,所以init.py應該可在mac/linux運行,windows用戶需要做適當修改。
另外,還需要安裝一個依賴:pyyaml:
獲取Yaml數據: True
建立數據庫: True
建立數據庫: True
初始化HTML: True
- 部署資源,執行
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
………………
-
path: /web/article/watched/update
method: POST
apiId: api-gnvnrbyk
-
path: /web/sentence/get
method: POST
apiId: api-msvadsau
-
path: /web/article/list/hot/get
method: POST
apiId: api-kfkrjhim
-
path: /web/tags/list/get
method: POST
apiId: api-avydagem
-
path: /admin
method: ANY
apiId: api-4tnz5tc4
176s › APIService › done
項目總結
目前,傳統博客已經有很多了,基於PHP的zblog、wp等開源項目都可以幫助我們快速搭建一個博客系統,但是基於Serverless架構的博客系統是比較少見的。
本文通過原生Serverless項目開發和Flask框架部署實現了一個基於Python語言的博客系統,在這個系統實現了文章發佈、自動撰寫文章的關鍵詞和摘要,還可以進行留言評論的管理。不過,這個博客系統僅作爲工程實踐使用,所以仍然會存在BUG或者設計不合理的地方,如果要是實際應用,還需接着完善。