第 16 篇:別再手動管理介面文件了

削微寒發表於2020-09-04

作者:HelloGitHub-追夢人物

大多數情況下,開發的介面都不是給開發這個介面的人用的,所以如果沒有介面文件,別人就無法有哪些介面可以呼叫,即使知道了介面的 URL,也很難知道介面需要哪些引數,即使知道了這些引數,也可能無法理解這些引數的含義。因此介面文件應該是專案必不可少的配置。

編寫介面文件有很多種方式,最為簡單直接的方式就是開啟一個記事本或者 word 文件,將介面的詳細資訊和用法寫下來,別人就可以參考這個文件來呼叫介面。這樣做雖然簡單,但弊端也很明顯:一是需要寫大量的描述文字,非常枯燥,但其實這些資訊在程式碼中已有體現,有點像是使用自然語言又把程式碼寫了一遍;二是一旦介面有了更新,就必須手動同步更新介面文件,開發人員很容易搞忘這件事,導致介面文件的內容和介面的實際功能不一致。

因為很多介面的資訊其實在程式碼中已有體現,人們自然而然就想到能否直接從寫好的程式碼中自動提取相關資訊來生成文件,這樣改了程式碼,介面文件也會自動更新,上面說的兩個問題就都可以解決了。

當然寫介面文件不是搞文學創作,為了直接從寫好的程式碼中自動提取資訊來生成文件,就必須要有一套標準的文件格式,否則工具無法知道要從程式碼中提取出哪些資訊,資訊提取之後,也不知道該如何組織這些資訊。

經過大家的努力,現在已經有了很多成熟的介面文件標準和生成工具,其中 OpenAPI Specification 就是一個被廣泛接收和使用的標準,我們部落格介面使用的文件自動化工具,也會基於 OpenAPI 標準從程式碼中提取文件資訊,然後組織為 OpenAPI 的標準格式。

小貼士:

大家更為熟悉的,和 OpenAPI 相關的一個名詞是 swagger。Swagger 提供一系列免費開源的 OpenAPI 相關的工具,他們背後的公司是 SMARTBEAR,號稱 code quality tools 開發行業的領導者。

OpenAPI 介紹

介面文件不是文學作品,它所需要的內容基本都是固定的。例如對一個 RESTful 風格的介面來說,只需要知道以下這些關鍵的資訊就足夠完成對它的呼叫了。反過來,這些資訊也就可以定義一個完整的 RESTful 風格的介面:

  • 請求的 HTTP 方法和 URL。
  • 接收的引數(包括 URL 中的路徑引數、查詢引數;HTTP 請求頭的引數;HTTP 請求體等引數)。
  • 介面返回的內容。

OpenAPI 對以上資訊進行了標準化,從而提出了 OpenAPI specification,只要文件內容符合這個標準,OpenAPI 工具就可以對它進行處理,例如視覺化文件工具就可以讀取文件內容生成 HTML 格式的文件。

注意:

OpenAPI specification 目前最新版本是 3,但目前大部分工具對 2 的支援最好,教程中使用的庫僅支援 2。

drf-yasg

drf-yasg 是一個 django 的第三方應用,它可以從 django-rest-framework 框架編寫的程式碼中自動提取介面資訊來生成符合 OpenAPI 標準的文件。我們將使用它來生成部落格應用的介面文件。

第一步當然是安裝 drf-yasg,進入專案根目錄,執行命令 :

Command Tab

Linux/macOS
$ pipenv install drf-yasg
Windows
...\> pipenv install drf-yasg

然後將 drf-yasg 新增到 INSTALLED_APPS 配置項中:

# filename="blogproject/settings/common.py"
INSTALLED_APPS = [
    # 其它已新增的應用...
		"pure_pagination",  # 分頁
    "haystack",  # 搜尋
    "drf_yasg", # 文件
]

接著使用 drf_yasg 提供的函式來建立一個 django 檢視,這個檢視將返回 HTML 格式的文件內容,這樣我們就可以直接在瀏覽器檢視到部落格的介面文件:

# filename="blogproject/urls.py"
from django.urls import include, path, re_path
from drf_yasg import openapi
from drf_yasg.views import get_schema_view
from rest_framework import permissions, routers


schema_view = get_schema_view(
    openapi.Info(
        title="HelloDjango REST framework tutorial API",
        default_version="v1",
        description="HelloDjango REST framework tutorial AP",
        terms_of_service="",
        contact=openapi.Contact(email="zmrenwu@163.com"),
        license=openapi.License(name="GPLv3 License"),
    ),
    public=True,
    permission_classes=(permissions.AllowAny,),
)

urlpatterns = [
  	# 其它已註冊的 URL 模式...
  
    # 文件
    re_path(
        r"swagger(?P<format>\.json|\.yaml)",
        schema_view.without_ui(cache_timeout=0),
        name="schema-json",
    ),
    path(
        "swagger/",
        schema_view.with_ui("swagger", cache_timeout=0),
        name="schema-swagger-ui",
    ),
    path("redoc/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"),
]

只需要使用 get_schema_view 就可以生成一個文件檢視,然後我們將這個檢視函式對映到了 4 個 URL。

現在進入專案根目錄,啟動開發伺服器:

Command Tab

Linux/macOS
$ pipenv run python manage.py runserver
Windows
...\> pipenv run python manage.py runserver

然後訪問 http://127.0.0.1:8000/swagger/ 或者 http://127.0.0.1:8000/redoc/,你就可以看到 drf-yasg 自動生成的 HTML 格式的介面文件了。如果訪問 http://127.0.0.1:8000/swagger.json 或者 http://127.0.0.1:8000/swagger.yaml 就可以看到原始的 OpenAPI 標準文件,swagger 和 redoc 都是基於這個標準文件來生成視覺化的 UI 介面的。

完善文件

drf-yasg 畢竟不是使用人工智慧開發的,即使是使用人工智慧,也很難做到 100% 的正確,畢竟由人類寫的程式碼可能是千變萬化的,工具無法預料到所有可能的情況,一旦它遇到無法處理的地方,自動生成的文件就可能出錯,或者生成的內容不符合我們的預期。

我們不妨訪問 http://127.0.0.1:8000/swagger/ 先來看看沒做任何定製化之前生成的效果。可以看到內容大體上是正確的,介面基本上都羅列了出來,但是仔細檢查各個介面的內容,就會發現一些問題:

  1. GET /api-version/test/ 這個介面是我們用來測試的,不希望它顯示在文件裡。
  2. 基本上沒有任何描述資訊來說明這個介面的功能。
  3. 介面的部分引數也沒有描述資訊,可能會讓介面的使用者無法知道其準確含義。
  4. GET /posts/archive/dates/ 這個介面顯示的引數是錯誤的,它不應該接受任何查詢引數,介面響應引數也是錯誤的。
  5. GET /posts/{id}/comments/ 這個介面應該還支援分頁查詢的引數,但生成的文件中沒有列出,介面響應引數也是錯誤的,正確的應該是一個分頁後的評論列表,但文件中是單個評論物件。
  6. GET /search/ 沒有列出搜尋引數 text。
  7. 多出一個 GET /search/{id}/ 介面,這個介面我們並不需要其被使用,因此也無需在文件列出。

接下來我們就一個個地來解決上面的問題,只需要稍加改變一下 drf-yasg 的預設行為,就能夠生成我們預期的文件內容。

隱藏不需要的介面

首先將第 1 點和第 7 點提到的不需要的介面從自動生成的文件中隱藏。

對於 GET /api-version/test/ 這個介面,它對應的檢視集是 ApiVersionTestViewSet,給這個檢視集新增一個 swagger_schema 類屬性,將值設為 None,這樣 drf-yasg 就知道忽略這個檢視集對應的介面了。

# filename="blog/views.py"
class ApiVersionTestViewSet(viewsets.ViewSet):  # pragma: no cover
    swagger_schema = None

隱藏 GET /search/{id}/ 介面的方式稍微有點不同,因為對應的檢視集 PostSearchView 不只這一個介面,上面的處理方式會把整個檢視集的介面都隱藏,我們需要想辦法隱藏指定 action 對應的介面。

drf-yasg 提供了一個 swagger_auto_schema 裝飾器來裝飾檢視,只需要為裝飾器設定 auto_shema=None 就可以讓 drf-yasg 忽略掉被裝飾的檢視,具體用法如下:

# filename="blog/views.py"
from django.utils.decorators import method_decorator
from drf_yasg.utils import swagger_auto_schema


@method_decorator(
    name="retrieve",
    decorator=swagger_auto_schema(
        auto_schema=None,
    ),
)
class PostSearchView(HaystackViewSet):
    index_models = [Post]
    serializer_class = PostHaystackSerializer
    throttle_classes = [PostSearchAnonRateThrottle]

需要隱藏的介面對應 retrieve 這個 action,因此我們裝飾的是這個方法。因為 PostSearchView 繼承自 HaystackViewSet,在程式碼中並沒有顯示地定義 retrieve 這個方法,而是從父類繼承而來,所以我們藉助 django 提供的輔助函式 method_decorator 非侵入式地為類的某個方法新增裝飾器。

現在訪問介面文件地址,可以看到不需要的介面已經從文件中隱藏了。

新增介面功能描述資訊

接下來解決第 2 個問題,為介面新增必要的功能描述。drf-yasg 支援從檢視的 docstring 解析介面對應的描述資訊,只要符合指定的格式即可。

先來一個簡單例子,為 GET /categories/ 這個介面新增描述資訊,找到 CategoryViewSet 檢視集,新增格式化的 docstring:

# filename="blog/views.py"
class CategoryViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
    """
    部落格文章分類檢視集

    list:
    返回部落格文章分類列表
    """

CategoryViewSet 檢視集就一個介面,對應的 action 是 list,因此 docstring 的格式就像上面那樣,文件中的效果如下:

可以看到介面請求 URL 下方多出了我們寫的描述內容。其它一些簡單的介面都可以用這種方式來新增功能描述資訊,留作練習的內容交給你自己了。

tip 描述的內容還支援 Markdown 格式,這樣我們可以根據需要寫出格式豐富的內容。

對於稍微複雜一點檢視集,例如 PostViewSet,這個檢視集含有多個 action 對應多個介面,功能描述資訊的格式差不多是一樣的,關鍵點是指明每個 action 對應的內容:

# filename="blog/views.py"
class PostViewSet(
    mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
    """
    部落格文章檢視集

    list:
    返回部落格文章列表

    retrieve:
    返回部落格文章詳情

    list_comments:
    返回部落格文章下的評論列表

    list_archive_dates:
    返回部落格文章歸檔日期列表
    """

新增引數說明

接著我們來完善介面的引數說明文件。通過檢視自動生成的文件中各個介面的引數,發現主要有這麼幾個問題:

  • 有些引數沒有說明,無法準確知道其含義。
  • 有些介面該有的引數,文件中沒有列出。
  • 有些介面不該有的引數,文件中卻列出來了。

例如我們可以看到 GET /posts/{id}/ 這個介面的響應引數,其中大部分有中文資訊的描述,我們可以推斷,這些說明都是 drf-yasg 自動從定義在 Post 模型各欄位的 verbose_name 引數的值提取的。其中 toc 和 body_html 因為不是 Post 中定義的欄位,所以 drf-yasg 無法知道關於這兩個欄位的說明。

drf-yasg 是如何知道這個介面會返回哪些響應引數的呢?原理是 drf-yasg 會嘗試去解析介面對應的序列化器(Serializer),從序列化器中提取出對應的請求和響應欄位(如果序列化器中找不到,它會進一步去序列化器關聯的模型中找),因此我們就可以給序列化器中定義的欄位新增說明資訊。例如我們來給 toc 和 body_html 新增 label 引數:

# filename="blog/views.py"
class PostRetrieveSerializer(serializers.ModelSerializer):
  	toc = serializers.CharField(label="文章目錄")
    body_html = serializers.CharField(label="文章內容")

訪問介面文件地址,找到對應的介面,可以看到文件中這兩個欄位新增了對應的說明資訊,還可以通過 help_text(Model 中的欄位也支援這個引數)來新增更為詳細的描述,例如:

# filename="blog/serializers.py"
class PostRetrieveSerializer(serializers.ModelSerializer):
  	toc = serializers.CharField(label="文章目錄", help_text="HTML 格式,每個目錄條目均由 li 標籤包裹。")
    body_html = serializers.CharField(
        label="文章內容", help_text="HTML 格式,從 `body` 欄位解析而來。"
    )

這樣兩個欄位的含義就非常清晰了,效果如下:

其它一些沒有說明資訊的欄位都可以根據這種方式來新增,只需要找到文件中的引數在程式碼中對應的來源欄位就可以了。除了在序列化器(Serializer)、模型(Model)裡面新增。查詢過濾引數也是可以這樣設定的,例如先來看一下 GET /posts/ 的引數:

可以看到用來過濾文章列表的引數都沒有說明,這些欄位都定義在 PostFilter 中,我們來改一下程式碼,新增必要的說明資訊後再去文件中看看效果吧!

# filename="blog/filters.py"
from .models import Category, Post, Tag

class PostFilter(drf_filters.FilterSet):
    created_year = drf_filters.NumberFilter(
        field_name="created_time", lookup_expr="year", help_text="根據文章發表年份過濾文章列表"
    )
    created_month = drf_filters.NumberFilter(
        field_name="created_time", lookup_expr="month", help_text="根據文章發表月份過濾文章列表"
    )
    category = drf_filters.ModelChoiceFilter(
        queryset=Category.objects.all(),
        help_text="根據分類過濾文章列表",
    )
    tags = drf_filters.ModelMultipleChoiceFilter(
        queryset=Tag.objects.all(),
        help_text="根據標籤過濾文章列表",
    )

    class Meta:
        model = Post
        fields = ["category", "tags", "created_year", "created_month"]

接著我們來看 GET /posts/archive/dates/ 和 GET /posts/{id}/comments/ 這兩個介面。前者文件中顯示了一些錯誤的引數,後者本應該有分頁引數,但是文件卻沒有列出。

先來看 GET /posts/archive/dates/,它對應的 action 是 list_archive_dates,由於 action 預設會從它所在的檢視集中繼承一些屬性,而 drf-yasg 會從這些屬性去解析介面支援的引數,例如檢視集設定了 filterset_class = PostFilterpagination_class=PageNumberPagination(雖然不在檢視集中顯示定義,但在全域性進行了配置),在解析 list_archive_dates 的引數時,drf-yasg 錯誤地解析到了從檢視集繼承來的 PostFilterPageNumberPagination,所以就把這兩個類中定義的引數也包含進文件了。

知道了原因,解決方法也就有了,在 list_archive_dates action 中把這兩個屬性設為 None,覆蓋掉檢視集中的預設設定:

# filename="blog/views.py"
class PostViewSet(
    mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
  @action(
        # ...
        filter_backends=None, # 將 filter_backends 設為 None,filterset_class 也就不起作用了。
        pagination_class=None,
    )
    def list_archive_dates(self, request, *args, **kwargs):
        # ...

再來看看這個介面,就沒有那些錯誤的引數了。

接著處理 GET /posts/{id}/comments/ 介面,我們需要文件列出分頁引數。這個介面對應的 action 是 list_comment。從上面的分析來看,這個 action 明明已經指定了 pagination_class=LimitOffsetPagination,為什麼 drf-yasg 無法自動檢測到分頁引數呢?原因是這個 action 設定了 detail=True。當 detial=True 時,drf-yasg 會將這個 action 對應的介面看做獲取單個資源的介面,因此它認為分頁是不需要的。但實際上我們對這個介面進行了定製,它返回的其實是評論列表。解決辦法是應該告訴 drf-yasg,這個介面返回的是列表結果,請去解析列表介面相關的一些引數:

# filename="blog/views.py"
class PostViewSet(
    mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
    @action(
        methods=["GET"],
        detail=True,
        # ...
        suffix="List",  # 將這個 action 返回的結果標記為列表,否則 drf-yasg 會根據 detail=True 誤判為這是返回單個資源的介面
        pagination_class=LimitOffsetPagination,
        serializer_class=CommentSerializer,
    )
    def list_comments(self, request, *args, **kwargs):
        # ...

但是 drf-yasg 還是不夠聰明,當它去解析列表介面可能的引數時,順便又把 PostFilter 中的欄位也一併解析了,這是用來過濾部落格文章的,顯然不能用於過濾評論列表,我們需要將這些無關引數移除,解決方法在處理 GET /posts/archive/dates/ 介面時就講過了,把 filter_backends 設定成 None 就可以了。

更正錯誤的響應引數

仔細看生成的介面文件,發現有 2 個介面的返回內容是錯誤的。

一是 GET /posts/{id}/comments/,最初我們發現這個介面文件的響應是一個單一的評論物件,原因我們上面也分析了,drf-yasg 根據 detail=True 誤地將其作為返回單一資源的介面處理了。隨著為其新增更多資訊,告訴 drf-yasg 這是一個返回資源列表的介面,問題也就順便解決了。

二是 GET /posts/archive/dates/,這個介面的返回內容應該是一個日期列表,但是文件中顯示的竟然是部落格文章列表。drf-yasg 推斷的響應型別是正確的,但內容不對。原因也很明顯,這個介面對應的 action 是 list_archive_dates,drf-yasg 在這個 action 中沒有找到解析響應結果的序列化器(Serializer),所以它跑去檢視集 PostViewSet 中去找了,結果找到了 PostListSerializer,然後把這個當成了介面返回的內容進行解析了。

由於這個介面返回的僅僅是一個簡單的日期列表,並不涉及到序列化器,因此這裡我們不使用指定 serializer_class 屬性值的方式,而是使用 swagger_auto_schema 裝飾器,直接告訴 drf-yasg 介面返回的響應:

# filename="blog/views.py"
class PostViewSet(
    mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
		@swagger_auto_schema(responses={200: "歸檔日期列表,時間倒序排列。例如:['2020-08', '2020-06']"})
    @action(
        methods=["GET"],
        detail=False,
        url_path="archive/dates",
        url_name="archive-date",
        filter_backends=None,
        pagination_class=None,
    )
    def list_archive_dates(self, request, *args, **kwargs):
        # ...

responses 引數的值是一個字典,字典的鍵是 HTTP 響應碼,值可以是一個序列化器,這樣 drf-yasg 會拿這個序列化器去解析介面響應的引數;也可以是一個字串,drf-yasg 會把字串直接當做介面響應結果寫入文件中。看看修改後的效果:

至此,我們就有了一套比較完善的部落格介面文件了,而且大部分內容均由 drf-yasg 為我們自動生成,省去了不少手寫文件的麻煩。

參考資料

以下是教程中用到的一些參考:

小貼士:

drf-yasg 的官方文件對於這個庫的使用方法寫的不是很清晰,這篇文章中列出的一些用法都是從原始碼中看出來的。如果你在使用過程中遇到了問題,首先嚐試分析問題的原因,然後順藤摸瓜去找到相關的原始碼,看看庫的內部是如何處理你所遇到的問題的,這樣就可以針對性地給出解決方案了,這篇教程中列出的很多問題以及最後給出的解決方案,都是使用的這種方式。


關注公眾號加入我們

相關文章