把軟體(Software)、平臺(Platform)、基礎設施(Infrastructure)做成服務(Service)是很多IT企業都一直在做的事情,這就是大家經常聽到的SasS(軟體即服務)、PasS(平臺即服務)和IasS(基礎設定即服務)。實現面向服務的架構(SOA)有諸多的方式,包括RPC(遠端過程呼叫)、Web Service、REST等,在技術層面上,SOA是一種抽象的、鬆散耦合的粗粒度軟體架構;在業務層面上,SOA的核心概念是“重用”和“互操作”,它將系統資源整合成可操作的、標準的服務,使得這些資源能夠被重新組合和應用。在實現SOA的諸多方案中,REST被認為是最適合網際網路應用的架構,符合REST規範的架構也經常被稱作RESTful架構。
REST這個詞,是Roy Thomas Fielding在他2000年的博士論文中提出的,Roy是HTTP協議(1.0和1.1版)的主要設計者、Apache伺服器軟體主要作者、Apache基金會第一任主席。在他的博士論文中,Roy把他對網際網路軟體的架構原則定名為REST,即REpresentational State Transfer的縮寫,中文通常翻譯為“表現層狀態轉移”或“表述狀態轉移”。
這裡的“表現層”其實指的是“資源”的“表現層”。所謂資源,就是網路上的一個實體,或者說是網路上的一個具體資訊。它可以是一段文字、一張圖片、一首歌曲或一種服務。我們可以用一個URI(統一資源定位符)指向資源,要獲取到這個資源,訪問它的URI即可,URI就是資源在網際網路上的唯一標識。資源可以有多種外在表現形式。我們把資源具體呈現出來的形式,叫做它的“表現層”。比如,文字可以用text/plain
格式表現,也可以用text/html
格式、text/xml
格式、application/json
格式表現,甚至可以採用二進位制格式;圖片可以用image/jpeg
格式表現,也可以用image/png
格式表現。URI只代表資源的實體,不代表它的表現形式。嚴格地說,有些網址最後的.html
字尾名是不必要的,因為這個字尾名錶示格式,屬於“表現層”範疇,而URI應該只代表“資源”的位置,它的具體表現形式,應該在HTTP請求的頭資訊中用Accept
和Content-Type
欄位指定,這兩個欄位才是對“表現層”的描述。
訪問一個網站,就代表了客戶端和伺服器的一個互動過程。在這個過程中,勢必涉及到資料和狀態的變化。Web應用通常使用HTTP作為其通訊協議,客戶端想要操作伺服器,必須透過HTTP請求,讓伺服器端發生“狀態轉移”,而這種轉移是建立在表現層之上的,所以就是“表現層狀態轉移”。客戶端透過HTTP的動詞GET、POST、PUT(或PATCH)、DELETE,分別對應對資源的四種基本操作,其中GET用來獲取資源,POST用來新建資源(也可以用於更新資源),PUT(或PATCH)用來更新資源,DELETE用來刪除資源。
簡單的說RESTful架構就是:“每一個URI代表一種資源,客戶端透過四個HTTP動詞,對伺服器端資源進行操作,實現資源的表現層狀態轉移”。
我們在設計Web應用時,如果需要向客戶端提供資源,就可以使用REST風格的URI,這是實現RESTful架構的第一步。當然,真正的RESTful架構並不只是URI符合REST風格,更為重要的是“無狀態”和“冪等性”兩個詞,我們在後面的課程中會為大家闡述這兩點。下面的例子給出了一些符合REST風格的URI,供大家在設計URI時參考。
請求方法(HTTP動詞) | URI | 解釋 |
---|---|---|
GET | /students/ |
獲取所有學生 |
POST | /students/ |
新建一個學生 |
GET | /students/ID/ |
獲取指定ID的學生資訊 |
PUT | /students/ID/ |
更新指定ID的學生資訊(提供該學生的全部資訊) |
PATCH | /students/ID/ |
更新指定ID的學生資訊(提供該學生的部分資訊) |
DELETE | /students/ID/ |
刪除指定ID的學生資訊 |
GET | /students/ID/friends/ |
列出指定ID的學生的所有朋友 |
DELETE | /students/ID/friends/ID/ |
刪除指定ID的學生的指定ID的朋友 |
在Django專案中,如果要實現REST架構,即將網站的資源釋出成REST風格的API介面,可以使用著名的三方庫djangorestframework
,我們通常將其簡稱為DRF。
安裝DRF。
pip install djangorestframework
配置DRF。
INSTALLED_APPS = [
'rest_framework',
]
# 下面的配置根據專案需要進行設定
REST_FRAMEWORK = {
# 配置預設頁面大小
# 'PAGE_SIZE': 10,
# 配置預設的分頁類
# 'DEFAULT_PAGINATION_CLASS': '...',
# 配置異常處理器
# 'EXCEPTION_HANDLER': '...',
# 配置預設解析器
# 'DEFAULT_PARSER_CLASSES': (
# 'rest_framework.parsers.JSONParser',
# 'rest_framework.parsers.FormParser',
# 'rest_framework.parsers.MultiPartParser',
# ),
# 配置預設限流類
# 'DEFAULT_THROTTLE_CLASSES': (
# '...'
# ),
# 配置預設授權類
# 'DEFAULT_PERMISSION_CLASSES': (
# '...',
# ),
# 配置預設認證類
# 'DEFAULT_AUTHENTICATION_CLASSES': (
# '...',
# ),
}
前後端分離的開發需要後端為前端、移動端提供API資料介面,而API介面通常情況下都是返回JSON格式的資料,這就需要對模型物件進行序列化處理。DRF中封裝了Serializer
類和ModelSerializer
類用於實現序列化操作,透過繼承Serializer
類或ModelSerializer
類,我們可以自定義序列化器,用於將物件處理成字典,程式碼如下所示。
from rest_framework import serializers
class SubjectSerializer(serializers.ModelSerializer):
class Meta:
model = Subject
fields = '__all__'
上面的程式碼直接繼承了ModelSerializer
,透過Meta
類的model
屬性指定要序列化的模型以及fields
屬性指定需要序列化的模型欄位,稍後我們就可以在檢視函式中使用該類來實現對Subject
模型的序列化。
DRF框架支援兩種實現資料介面的方式,一種是FBV(基於函式的檢視),另一種是CBV(基於類的檢視)。我們先看看FBV的方式如何實現資料介面,程式碼如下所示。
from rest_framework.decorators import api_view
from rest_framework.response import Response
@api_view(('GET', ))
def show_subjects(request: HttpRequest) -> HttpResponse:
subjects = Subject.objects.all().order_by('no')
# 建立序列化器物件並指定要序列化的模型
serializer = SubjectSerializer(subjects, many=True)
# 透過序列化器的data屬性獲得模型對應的字典並透過建立Response物件返回JSON格式的資料
return Response(serializer.data)
對比上一個章節的使用bpmapper
實現模型序列化的程式碼,使用DRF的程式碼更加簡單明瞭,而且DRF本身自帶了一套頁面,可以方便我們檢視我們使用DRF定製的資料介面,如下圖所示。
直接使用上一節寫好的頁面,就可以透過Vue.js把上面介面提供的學科資料渲染並展示出來,此處不再進行贅述。
編寫序列化器。
class SubjectSimpleSerializer(serializers.ModelSerializer):
class Meta:
model = Subject
fields = ('no', 'name')
class TeacherSerializer(serializers.ModelSerializer):
class Meta:
model = Teacher
exclude = ('subject', )
編寫檢視函式。
@api_view(('GET', ))
def show_teachers(request: HttpRequest) -> HttpResponse:
try:
sno = int(request.GET.get('sno'))
subject = Subject.objects.only('name').get(no=sno)
teachers = Teacher.objects.filter(subject=subject).defer('subject').order_by('no')
subject_seri = SubjectSimpleSerializer(subject)
teacher_seri = TeacherSerializer(teachers, many=True)
return Response({'subject': subject_seri.data, 'teachers': teacher_seri.data})
except (TypeError, ValueError, Subject.DoesNotExist):
return Response(status=404)
配置URL對映。
urlpatterns = [
path('api/teachers/', show_teachers),
]
透過Vue.js渲染頁面。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>老師資訊</title>
<style>
/* 此處省略掉層疊樣式表 */
</style>
</head>
<body>
<div id="container">
<h1>{{ subject.name }}學科的老師資訊</h1>
<hr>
<h2 v-if="loaded && teachers.length == 0">暫無該學科老師資訊</h2>
<div class="teacher" v-for="teacher in teachers">
<div class="photo">
<img :src="'/static/images/' + teacher.photo" height="140" alt="">
</div>
<div class="info">
<div>
<span><strong>姓名:{{ teacher.name }}</strong></span>
<span>性別:{{ teacher.sex | maleOrFemale }}</span>
<span>出生日期:{{ teacher.birth }}</span>
</div>
<div class="intro">{{ teacher.intro }}</div>
<div class="comment">
<a href="" @click.prevent="vote(teacher, true)">好評</a>
(<strong>{{ teacher.good_count }}</strong>)
<a href="" @click.prevent="vote(teacher, false)">差評</a>
(<strong>{{ teacher.bad_count }}</strong>)
</div>
</div>
</div>
<a href="/static/html/subjects.html">返回首頁</a>
</div>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/2.6.11/vue.min.js"></script>
<script>
let app = new Vue({
el: '#container',
data: {
subject: {},
teachers: [],
loaded: false
},
created() {
fetch('/api/teachers/' + location.search)
.then(resp => resp.json())
.then(json => {
this.subject = json.subject
this.teachers = json.teachers
})
},
filters: {
maleOrFemale(sex) {
return sex? '男': '女'
}
},
methods: {
vote(teacher, flag) {
let url = flag? '/praise/' : '/criticize/'
url += '?tno=' + teacher.no
fetch(url).then(resp => resp.json()).then(json => {
if (json.code === 10000) {
if (flag) {
teacher.good_count = json.count
} else {
teacher.bad_count = json.count
}
}
})
}
}
})
</script>
</body>
</html>
之前我們提到過, HTTP是無狀態的,一次請求結束連線斷開,下次伺服器再收到請求,它就不知道這個請求是哪個使用者發過來的。但是對於一個Web應用而言,它是需要有狀態管理的,這樣才能讓伺服器知道HTTP請求來自哪個使用者,從而判斷是否允許該使用者請求以及為使用者提供更好的服務,這個過程就是常說的會話管理。
之前我們做會話管理(使用者跟蹤)的方法是:使用者登入成功後,在伺服器端透過一個session物件儲存使用者相關資料,然後把session物件的ID寫入瀏覽器的cookie中;下一次請求時,HTTP請求頭中攜帶cookie的資料,伺服器從HTTP請求頭讀取cookie中的sessionid,根據這個識別符號找到對應的session物件,這樣就能夠獲取到之前儲存在session中的使用者資料。我們剛才說過,REST架構是最適合網際網路應用的架構,它強調了HTTP的無狀態性,這樣才能保證應用的水平擴充套件能力(當併發訪問量增加時,可以透過增加新的伺服器節點來為系統擴容)。顯然,基於session實現使用者跟蹤的方式需要伺服器儲存session物件,在做水平擴充套件增加新的伺服器節點時,需要複製和同步session物件,這顯然是非常麻煩的。解決這個問題有兩種方案,一種是架設快取伺服器(如Redis),讓多個伺服器節點共享快取服務並將session物件直接置於快取伺服器中;另一種方式放棄基於session的使用者跟蹤,使用基於token的使用者跟蹤。
基於token的使用者跟蹤是在使用者登入成功後,為使用者生成身份標識並儲存在瀏覽器本地儲存(localStorage、sessionStorage、cookie等)中,這樣的話伺服器不需要儲存使用者狀態,從而可以很容易的做到水平擴充套件。基於token的使用者跟蹤具體流程如下:
- 使用者登入時,如果登入成功就按照某種方式為使用者生成一個令牌(token),該令牌中通常包含了使用者標識、過期時間等資訊而且需要加密並生成指紋(避免偽造或篡改令牌),伺服器將令牌返回給前端;
- 前端獲取到伺服器返回的token,儲存在瀏覽器本地儲存中(可以儲存在
localStorage
或sessionStorage
中,對於使用Vue.js的前端專案來說,還可以透過Vuex進行狀態管理); - 對於使用了前端路由的專案來說,前端每次路由跳轉,可以先判斷
localStroage
中有無token,如果沒有則跳轉到登入頁; - 每次請求後端資料介面,在HTTP請求頭裡攜帶token;後端介面判斷請求頭有無token,如果沒有token以及token是無效的或過期的,伺服器統一返回401;
- 如果前端收到HTTP響應狀態碼401,則重定向到登入頁面。
透過上面的描述,相信大家已經發現了,基於token的使用者跟蹤最為關鍵是在使用者登入成功時,要為使用者生成一個token作為使用者的身份標識。生成token的方法很多,其中一種比較成熟的解決方案是使用JSON Web Token。
JSON Web Token通常簡稱為JWT,它是一種開放標準(RFC 7519)。隨著RESTful架構的流行,越來越多的專案使用JWT作為使用者身份認證的方式。JWT相當於是三個JSON物件經過編碼後,用.
分隔並組合到一起,這三個JSON物件分別是頭部(header)、載荷(payload)和簽名(signature),如下圖所示。
-
頭部
{ "alg": "HS256", "typ": "JWT" }
其中,
alg
屬性表示簽名的演算法,預設是HMAC SHA256(簡寫成HS256
);typ
屬性表示這個令牌的型別,JWT中都統一書寫為JWT
。 -
載荷
載荷部分用來存放實際需要傳遞的資料。JWT官方文件中規定了7個可選的欄位:
- iss :簽發人
- exp:過期時間
- sub:主題
- aud:受眾
- nbf:生效時間
- iat:簽發時間
- jti:編號
除了官方定義的字典,我們可以根據應用的需要新增自定義的欄位,如下所示。
{ "sub": "1234567890", "nickname": "jackfrued", "role": "admin" }
-
簽名
簽名部分是對前面兩部分生成一個指紋,防止資料偽造和篡改。實現簽名首先需要指定一個金鑰。這個金鑰只有伺服器才知道,不能洩露給使用者。然後,使用頭部指定的簽名演算法(預設是
HS256
),按照下面的公式產生簽名。HS256(base64Encode(header) + '.' + base64Encode(payload), secret)
算出簽名以後,把頭部、載荷、簽名三個部分拼接成一個字串,每個部分用
.
進行分隔,這樣一個JWT就生成好了。
使用JWT的優點非常明顯,包括:
- 更容易實現水平擴充套件,因為令牌儲存在瀏覽器中,伺服器不需要做狀態管理。
- 更容易防範CSRF攻擊,因為在請求頭中新增
localStorage
或sessionStorage
中的token必須靠JavaScript程式碼完成,而不是自動新增到請求頭中的。 - 可以防偽造和篡改,因為JWT有簽名,偽造和篡改的令牌無法透過簽名驗證,會被認定是無效的令牌。
當然,任何技術不可能只有優點沒有缺點,JWT也有諸多缺點,大家需要在使用的時候引起注意,具體包括:
- 可能會遭受到XSS攻擊(跨站指令碼攻擊),透過注入惡意指令碼執行JavaScript程式碼獲取到使用者令牌。
- 在令牌過期之前,無法作廢已經頒發的令牌,要解決這個問題,還需要額外的中間層和程式碼來輔助。
- JWT是使用者的身份令牌,一旦洩露,任何人都可以獲得該使用者的所有許可權。為了降低令牌被盜用後產生的風險,JWT的有效期應該設定得比較短。對於一些比較重要的許可權,使用時應透過其他方式再次對使用者進行認證,例如簡訊驗證碼等。
在Python程式碼中,可以使用三方庫PyJWT
生成和驗證JWT,下面是安裝PyJWT
的命令。
pip install pyjwt
生成令牌。
payload = {
'exp': datetime.datetime.utcnow() + datetime.timedelta(days=1),
'userid': 10001
}
token = jwt.encode(payload, settings.SECRET_KEY).decode()
驗證令牌。
try:
token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1OTQ4NzIzOTEsInVzZXJpZCI6MTAwMDF9.FM-bNxemWLqQQBIsRVvc4gq71y42I9m2zt5nlFxNHUo'
payload = jwt.decode(token, settings.SECRET_KEY)
except InvalidTokenError:
raise AuthenticationFailed('無效的令牌或令牌已經過期')
如果不清楚JWT具體的使用方式,可以先看看第55天的內容,裡面提供了完整的投票專案程式碼的地址。