A place for study and research

Python 100 Days Day49 RESTful and DRF

|

author: jackfrued

RESTful架構和DRF入門

把軟件(Software)、平台(Platform)、基礎設施(Infrastructure)做成服務(Service)是很多IT企業都一直在做的事情,這就是大家經常聽到的SasS(軟件即服務)、PasS(平台即服務)和IasS(基礎設置即服務)。實現面向服務的架構(SOA)有諸多的方式,包括RPC(遠程過程調用)、Web Service、REST等,在技術層面上,SOA是一種抽象的、松散耦合的粗粒度軟件架構;在業務層面上,SOA的核心概念是“重用”和“互操作”,它將系統資源整合成可操作的、標準的服務,使得這些資源能夠被重新組合和應用。在實現SOA的諸多方案中,REST被認為是最適合互聯網應用的架構,符合REST規範的架構也經常被稱作RESTful架構。

REST概述

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請求的頭信息中用AcceptContent-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的朋友

DRF使用入門

在Django項目中,如果要實現REST架構,即將網站的資源發布成REST風格的API接口,可以使用著名的三方庫djangorestframework ,我們通常將其簡稱為DRF。

安裝和配置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>學科的老師信息</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>姓名:</strong></span>
                    <span>性別:</span>
                    <span>出生日期:</span>
                </div>
                <div class="intro"></div>
                <div class="comment">
                    <a href="" @click.prevent="vote(teacher, true)">好評</a>&nbsp;&nbsp;
                    (<strong></strong>)
                    &nbsp;&nbsp;&nbsp;&nbsp;
                    <a href="" @click.prevent="vote(teacher, false)">差評</a>&nbsp;&nbsp;
                    (<strong></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的用戶跟蹤具體流程如下:

  1. 用戶登錄時,如果登錄成功就按照某種方式為用戶生成一個令牌(token),該令牌中通常包含了用戶標識、過期時間等信息而且需要加密並生成指紋(避免偽造或篡改令牌),服務器將令牌返回給前端;
  2. 前端獲取到服務器返回的token,保存在瀏覽器本地存儲中(可以保存在localStoragesessionStorage中,對於使用Vue.js的前端項目來說,還可以通過Vuex進行狀態管理);
  3. 對於使用了前端路由的項目來說,前端每次路由跳轉,可以先判斷localStroage中有無token,如果沒有則跳轉到登錄頁;
  4. 每次請求後端數據接口,在HTTP請求頭里攜帶token;後端接口判斷請求頭有無token,如果沒有token以及token是無效的或過期的,服務器統一返回401;
  5. 如果前端收到HTTP響應狀態碼401,則重定向到登錄頁面。

通過上面的描述,相信大家已經發現了,基於token的用戶跟蹤最為關鍵是在用戶登錄成功時,要為用戶生成一個token作為用戶的身份標識。生成token的方法很多,其中一種比較成熟的解決方案是使用JSON Web Token。

JWT概述

JSON Web Token通常簡稱為JWT,它是一種開放標準(RFC 7519)。隨著RESTful架構的流行,越來越多的項目使用JWT作為用戶身份認證的方式。JWT相當於是三個JSON對象經過編碼後,用.分隔並組合到一起,這三個JSON對象分別是頭部(header)、載荷(payload)和簽名(signature),如下圖所示。

  1. 頭部

     {
       "alg": "HS256",
       "typ": "JWT"
     }
    

    其中,alg屬性表示簽名的算法,默認是HMAC SHA256(簡寫成HS256);typ屬性表示這個令牌的類型,JWT中都統一書寫為JWT

  2. 載荷

    載荷部分用來存放實際需要傳遞的數據。JWT官方文檔中規定了7個可選的字段:

    • iss :簽發人
    • exp:過期時間
    • sub:主題
    • aud:受眾
    • nbf:生效時間
    • iat:簽發時間
    • jti:編號

    除了官方定義的字典,我們可以根據應用的需要添加自定義的字段,如下所示。

     {
       "sub": "1234567890",
       "nickname": "jackfrued",
       "role": "admin"
     }
    
  3. 簽名

    簽名部分是對前面兩部分生成一個指紋,防止數據偽造和篡改。實現簽名首先需要指定一個密鑰。這個密鑰只有服務器才知道,不能泄露給用戶。然後,使用頭部指定的簽名算法(默認是HS256),按照下面的公式產生簽名。

     HS256(base64Encode(header) + '.' + base64Encode(payload), secret)
    

    算出簽名以後,把頭部、載荷、簽名三個部分拼接成一個字符串,每個部分用.進行分隔,這樣一個JWT就生成好了。

JWT的優缺點

使用JWT的優點非常明顯,包括:

  1. 更容易實現水平擴展,因為令牌保存在瀏覽器中,服務器不需要做狀態管理。
  2. 更容易防範CSRF攻擊,因為在請求頭中添加localStoragesessionStorage中的token必須靠JavaScript代碼完成,而不是自動添加到請求頭中的。
  3. 可以防偽造和篡改,因為JWT有簽名,偽造和篡改的令牌無法通過簽名驗證,會被認定是無效的令牌。

當然,任何技術不可能只有優點沒有缺點,JWT也有諸多缺點,大家需要在使用的時候引起注意,具體包括:

  1. 可能會遭受到XSS攻擊(跨站腳本攻擊),通過注入惡意腳本執行JavaScript代碼獲取到用戶令牌。
  2. 在令牌過期之前,無法作廢已經頒發的令牌,要解決這個問題,還需要額外的中間層和代碼來輔助。
  3. JWT是用戶的身份令牌,一旦泄露,任何人都可以獲得該用戶的所有權限。為了降低令牌被盜用後產生的風險,JWT的有效期應該設置得比較短。對於一些比較重要的權限,使用時應通過其他方式再次對用戶進行認證,例如短信驗證碼等。

使用PyJWT

在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天的內容,里面提供了完整的投票項目代碼的地址。

Comments