讓你的Django應用變DRY的幾個最佳實踐

  • 2019 年 12 月 1 日
  • 筆記

目前在Python的Web框架中被應用最廣泛的就是Django和Django REST Framework. 這兩種框架都提供了非常健壯的功能,能滿足Web開發的各個方面。DRY是Don't-Repeat-Yourself的縮寫,是一種程式碼編寫的原則,即不要重複自己的工作。我個人有些程式碼潔癖,凡是發現我需要複製粘貼程式碼的地方,就想著能怎樣去除重複的工作。在日常的開發中也總結出了一些個人的實踐,分享給大家。

總的來說,要使得你的應用很DRY,要遵循以下兩個原則:

  • 全局都應用的變更,收攏到一個地方配置
  • 有少數與其他不一樣的行為,將多數行為定義為全局行為,將少數行為分別配置,並儘可能簡化配置方法。

Django和Django REST framework(後簡稱DRF)提供了海量的全局配置、局部配置,來實現上述思想,但配置項太多了,有時人們往往不知道該如何利用。

一、用戶鑒權

1. Django的配置AUTHENTICATION_BACKENDS

AUTHENTICATION_BACKENDS控制了應用根據傳入的參數校驗用戶是否屬於合法用戶(用戶名是否存在?密碼是否正確?)。使用時通過django.contrib.auth.authenticate函數,傳入想要的參數,該函數會自動選擇對應的後端進行用戶校驗,常用的校驗方式有資料庫校驗、配置文件校驗、LDAP校驗等等。如果你想接入第三方登錄,OAuth登錄,都應該自定義一個Backend,無需繼承任何基類,只需實現一個authenticate方法,該方法參數與django.contrib.auth.authenticate的傳入參數相同,返回一個用戶對象,然後將這個Backend添加到AUTHENTICATION_BACKENDS就可以了。

注意:在使用到用戶模型的時候,要使用django.contrib.auth.get_user_model()而不是導入具體的model類,這樣可以方便用AUTH_USER_MODEL配置去改變用戶模型。

Python

class PowerOAuthBackend:      """請求Power單點登錄後跳轉的驗證"""        def authenticate(self, request, user=None, password=None):          if check_user_password(user, password):              # 返回用戶對象              return get_user_model().get(username=user)          else:              # 用戶名密碼錯誤 403              raise PermissionDenied()    	def get_user(self, user_id):          # 若通過瀏覽器訪問則需要定義次方法,獲取已登錄的用戶對象          # 若只有RESTful調用則跳過          return get_user_model().objects.get(staff_id=user_id)    # 登錄  def login_view(request):      username = request.POST.get('user')      password = request.POST.get('password')      user = authenticate(user=username, password=password)      # 將用戶存入會話      login(request, user)      return redirect('/')

2. DRF的配置 DEFAULT_AUTHENTICATION_CLASSES

DEFAULT_AUTHENTICATION_CLASSES,以及針對每個APIView配置的authentication_classes,是對RESTful請求的身份驗證,通過分析請求帶的身份資訊判斷來源方的身份,一般有以下幾種方式:

  • 會話鑒權(登錄態)
  • BasicAuth鑒權
  • Token鑒權

這些類都包含在rest_framework.authentication模組中。如果你要通過智慧網關轉發後端請求,則需要寫一個Authentication類,繼承自rest_framework.authentication.BaseAuthentication類,其中有兩個比較重要的方法,函數簽名及說明如下:

Python

class MyAuthentication(BaseAuthentication):      def authenticate(self, request):          # 若鑒權成功,則返回一個(user, auth)的元組          return user, auth          # 否則,若想交給後面的authentication處理,則返回None          return None          # 否則拋出401錯誤          raise rest_framework.exceptions.AuthenticationFailed()        def authenticate_header(self, request):          # DRF會選擇第一順位的Authentication的此方法返回的結果作為WWW-Authentication頭          # 如果返回為空則會將401錯誤轉換成403錯誤          return 'OMS'

3. DRF的DEFAULT_PERMISSION_CLASSES

如果說Authentication是判斷「你是誰」,那麼Authorization就是判斷「你能做什麼」,就好比你進入公司大樓需要用工卡(Authentication),但你有了工卡也不能隨便去總裁辦公室(Authorization)。在DRF中完成Authorization工作的就是DEFAULT_PERMISSION_CLASSES配置項,以及針對每個APIView配置的permission_classes,他是用來精確控制請求放對某一資源有無許可權。在RESTful規範中,無鑒權資訊是401錯誤而無許可權是403錯誤。在DRF的官方文檔中有詳細例子這裡就不再贅述。

二、自定義響應體

很多時候(如前端框架、開發SDK)對響應體的格式是有要求的,我看到大多數的實現只是用一個格式化的類去填充響應資訊,但這種方法有兩個缺點:

  1. 每次需要人為構造響應
  2. 無法適用於DRF的ModelViewSet,因為它自帶的方法的響應是默認的,如果要挨個重載就無法利用到ModelViewSet的懶人特性

所以我們需要將這種格式自定義收攏到一處,做到使用時無感知,響應自動形成期望的格式。要達成這種效果,大致有兩種途徑:

  1. 寫自定義中間件,修改響應格式
  2. 寫自定義renderer

這裡第一種途徑有幾處劣勢:

  1. 在中間件處理時rest_framework.response.Response已完成渲染,修改內部數據不起作用
  2. 若重新構造一個rest_framework.response.Response則會報未渲染錯誤,而渲染過程比較複雜
  3. 若選擇用django.http.response.JSONResponse重新構造響應則放棄了DRF的自動渲染特性

我對這些缺陷不能忍,於是想到了第二種途徑,也就是自定義renderer,它有以下好處:

  1. 即可全局生效(DEFAULT_RENDERER_CLASSES),又可針對單個APIView生效,非常靈活
  2. 保留了DRF的智慧渲染特性,即瀏覽器請求渲染HTML頁面,後端請求渲染JSON響應

DRF的默認renderer有兩個:rest_framework.renderers.JSONRendererrest_framework.renderers.BrowsableAPIRenderer。這裡可以按需重載,如果瀏覽器和後端響應都需要,則都重載,如果只需要JSON響應,則重載第一個就可以了,這裡兩個類的重載點不一樣:

Python

class JSONRenderer(renderers.JSONRenderer):        def render(self, data, accepted_media_type=None, renderer_context=None):          request = renderer_context['request']          # 在此處修改data          return super().render(data, accepted_media_type=accepted_media_type, renderer_context=renderer_context)      class BrowsableAPIRenderer(renderers.BrowsableAPIRenderer):        def get_content(self, renderer, data,                      accepted_media_type, renderer_context):          request = renderer_context['request']          # 在此處修改data          return super().get_content(renderer, data,                                     accepted_media_type, renderer_context)

三、異常處理

我們經常會需要拋出異常,有些是主動拋出、有些是未捕獲的異常,在這些情況下,我們都希望日誌記錄異常的堆棧資訊,然後返回一個規範的響應(格式與上一節中一致),這樣我們就需要更改異常處理。在Django+DRF中異常處理有兩個重載點:

  1. 中間件中的process_exception函數
  2. DRF的EXCEPTION_HANDLER配置

而其中EXCEPTION_HANDLER的作用時間早於中間件,這就導致了有些DRF內置的異常,在到達中間件之前已經渲染為正常的響應了,這明顯不是我們期望的效果,所以我們選擇第二個重載點。

Python

def exception_handler(exc, context):      # copy自DRF默認exception_handler      if isinstance(exc, Http404):          exc = exceptions.NotFound()      elif isinstance(exc, PermissionDenied):          exc = exceptions.PermissionDenied()        if isinstance(exc, exceptions.APIException):          # DRF內置異常          headers = {}          if getattr(exc, 'auth_header', None):              headers['WWW-Authenticate'] = exc.auth_header          if getattr(exc, 'wait', None):              headers['Retry-After'] = '%d' % exc.wait            if isinstance(exc.detail, (list, dict)):              body = exc.detail              message = str(exc)          else:              body = {}              message = str(exc.detail)  		# copy結束          # 組裝響應體          return Response({...})        else:          # 其他未捕獲異常          logger.error(traceback.format_exc())          if not isinstance(exc, ApiError):              exc = ApiError(str(exc))          # 組裝響應體          return exc.as_response()

美中不足的是有一大段的程式碼是從DRF默認的異常處理函數copy過來的,這是DRF為數不多的不合理設計,留了一個配置項供你改變默認行為,但卻沒有留出一個好的重載點。

總結

DRY原則能使你的程式碼結構好、易維護、易擴展。在日常的開發中,要時刻反思自己的程式碼是否過於重複,可以精簡。在Python中,可以說只要你想,一定能把多處一樣的程式碼給抽取出來。只是有時候為了抽出這些程式碼,又產生了很多額外的程式碼,這是需要取捨的。相信本文中提到的三個大方向,能對你有所啟發。