iT邦幫忙

2023 iThome 鐵人賽

DAY 22
0

Relay 是一種使用 GraphQL 的規範,其名稱來自於由 Facebook 開發的 JavaScript 框架 Relay。
這種規範規定了一些特定的慣用格式,是讓客戶端能更加高效、易於使用與 GraphQL 伺服器連結。
Relay 也是 GraphQL 官方教學中最佳實踐的內容,它對於伺服器端開發,主要有三個重點:

  1. 全域唯一 ID:所有的物件都會有一個唯一的全域 ID(Global ID),有助於客戶端建立快取 [1]。
  2. 節點介面:所有的物件都實作節點介面(Node Interface),即保證每個物件都會有id欄位,通常id欄位會使用全域唯一 ID 型態,這種全域物件識別(Global Object Identification)的作法,統一物件查詢的方式,也有助於客戶端快取查詢結果與物件 [2]。
  3. 連接模式的分頁:連接模式(Connections pattern)是一種基於指標(Cursor)的分頁實作規範,對於巨量資料的分頁提供更好的效能,但是缺點是沒辦法像傳統的基於位移的分頁一樣能夠跳到特定頁數 [3]。

接下來我們先將物件改成 Relay 風格的格式,下面先將我們定義型態改成繼承節點介面,id欄位使用全域唯一 ID:

# server/app/blog/graph/types.py
from django.db.models import QuerySet
from strawberry.types import Info
+from strawberry import relay

from server.app.authentication.graph import types as auth_types
# ... 省略

@strawberry_django.type(blog_models.Post)
-class Post:
-    id: uuid.UUID  # noqa: A003
+class Post(relay.Node):
+    id: relay.NodeID[uuid.UUID]  # noqa: A003
		# ... 省略

# ... 省略

@strawberry_django.type(blog_models.Comment)
-class Comment:
-    id: uuid.UUID  # noqa: A003
+class Comment(relay.Node):
+    id: relay.NodeID[uuid.UUID] # noqa: A003
		# ... 省略

# ... 省略

-class Tag:
+class Tag(relay.Node):
+    id: relay.NodeID[uuid.UUID]  # noqa: A003
		# ... 省略

# ... 省略

@strawberry_django.type(blog_models.Category)
-class Category:
-    id: uuid.UUID  # noqa: A003
+class Category(relay.Node):
+    id: relay.NodeID[uuid.UUID]  # noqa: A003
		# ... 省略

# ... 省略
@strawberry_django.input(blog_models.Post)
class PostInput:
    slug: str
    title: str
    content: str
		tags: list[relay.GlobalID]
    categories: list[relay.GlobalID]
    author: str = strawberry.field(description="Username of the author")
-    tags: list[str] = strawberry.field(description="List of tag names")
-    categories: list[str] = strawberry.field(description="List of category slugs")

# ... 省略
@strawberry_django.partial(blog_models.Post)
class PostInputPartial:
-    id: strawberry.auto  # noqa: A003
+    id: relay.GlobalID  # noqa: A003
    slug: strawberry.auto
    title: strawberry.auto
    content: strawberry.auto
-    tags: strawberry.auto
-    categories: strawberry.auto
+    tags: list[relay.GlobalID] | None
+    categories: list[relay.GlobalID] | None
    published_at: datetime.datetime | None
    published: bool | None

-@strawberry_django.input(blog_models.Post)
-class PostIdInput:
-    id: strawberry.auto  # noqa: A003

繼承relay.Node的型態必須定義ㄧ個id欄位,或是將某個欄位的型態設為relay.NodeID[T]
而定義型態為relay.NodeID[T]的欄位會在 Schema 被轉成relay.GlobalID型態。
修改完型態後,就可以試著做查詢:
https://ithelp.ithome.com.tw/upload/images/20231007/20161957rNFtjECqzn.png

query MyQuery {
  posts {
    id
    title
    slug
    publishedAt
    published
    content
    tags {
      id
    }
    categories {
      id
    }
  }
}

relay.GlobalID型態的值會是 base64 編碼的字串,解開會是Type:ID的形式。

$ echo "VGFnOjI3YzE4ZGQwLTg5MDUtNGU5Yy1iMzY4LTZmMTg1NmNkNzA0NA==" | base64 -d
Tag:27c18dd0-8905-4e9c-b368-6f1856cd7044

接著修改變更:

# server/app/blog/graph/mutations.py
# ... 省略
from django.utils import timezone
from strawberry.file_uploads import Upload
+from strawberry.types import Info
from strawberry.utils.str_converters import to_camel_case
from strawberry_django import mutations
# ... 省略

@strawberry.type
class Mutation:
-    update_post: blog_types.Post = mutations.update(blog_types.PostInputPartial)
-    delete_post: blog_types.Post = mutations.delete(blog_types.PostIdInput)
+    @strawberry_django.mutation(handle_django_errors=True)
+    def update_post(
+        self,
+        data: blog_types.PostInputPartial,
+        info: Info,
+    ) -> blog_types.Post:
+        post = data.id.resolve_node_sync(info, ensure_type=blog_models.Post)
+        input_data = vars(data)
+        for field, value in input_data.items():
+            if field in ("id", "tags", "categories"):
+                continue
+            
+            if value and hasattr(post, field):
+                setattr(post, field, value)
+                
+        post.save()
+        if data.tags is not None:
+            tags = [
+                tag_id.resolve_node_sync(info, ensure_type=blog_models.Tag) 
+                for tag_id in data.tags
+            ] 
+            post.tags.set(tags)
+            
+        if data.categories is not None:
+            categories = [
+                category_id.resolve_node_sync(info, ensure_type=blog_models.Category)
+                for category_id in data.categories
+            ]
+            post.categories.set(categories)
+
+					return typing.cast(blog_types.Post, post)

# ... 省略

這邊將使用 strawberry_django 內建變更功能移除,重新實作一個更新文章的變更功能,前面查詢的地方所有的id欄位都被定義成relay.GlobalID型態,所以在變更輸入資料也是使用relay.GlobalID型態的id欄位,relay.GlobalID會是一個 base64 編碼的字串,relay.GlobalID裡面有個resolve_node的方法能夠將它轉成 Django 的模型物件,然後resolve_node是非同步的方法,同步的方法是使用resolve_node_sync,有個ensure_type參數是帶入要轉成 Django 的模型類別。

修改完後,就可以試著執行更新文章:
https://ithelp.ithome.com.tw/upload/images/20231007/20161957bUf4CiYdmo.png

mutation MyMutation($data: PostInputPartial!) {
  updatePost(data: $data) {
    ... on Post {
      content
      id
      published
      publishedAt
      slug
      title
      categories {
        id
        slug
      }
      tags {
        id
        name
      }
    }
  }
}

再我們將原本的查詢做修改,分頁改成連接模式的分頁:

# server/app/blog/graph/queries.py
import strawberry
import strawberry_django
+from strawberry import relay
# ... 省略

@strawberry.type
class Query:
-    posts: list[blog_types.Post] = strawberry_django.field(
+    posts: relay.ListConnection[blog_types.Post] = strawberry_django.connection(
        order=blog_orders.PostOrder,
-       pagination=True,
        filters=blog_filters.PostFilter,
    )

https://ithelp.ithome.com.tw/upload/images/20231007/201619573kjqKktSsX.png

query MyQuery {
  posts(
    after: "YXJyYXljb25uZWN0aW9uOjA=",
    before: "",
    first: 1,
    last: null
  ) {
    pageInfo {
      endCursor
      hasNextPage
      hasPreviousPage
      startCursor
    }
    edges {
      cursor
      node {
        content
        id
        title
      }
    }
  }
}
  1. afterbefore:這些參數用於查詢的分頁。after指定了查詢結果的起始點,系統將回傳此指標之後的資料,before則是用於設置查詢結果的結束點,系統將返回此指標之前的資料。
  2. firstlast:這些參數用於設定限制查詢的筆數。如果只想取前 n 個結果,就使用first,如果想取最後 n 個結果,使用last
  3. pageInfo:這是連接模式的查詢重要部分,它包含了有關查詢結果的有用資訊。
    • endCursor:這是最後一筆資料的指標。如果希望獲得更多的後續資料,可以使用after參數並提供此endCursor值。
    • hasNextPage:這是一個 bool 型態,表示是否還有更多的資料可以查詢。如果為真,表示可以使用afterendCursor繼續查詢更多資料。
    • hasPreviousPage:這是一個 bool 型態,表示是否還有前面的資料可以查詢。如果為真,表示可以使用beforestartCursor繼續查詢更多資料。
    • startCursor:這是第一筆資料的指標。如果你想獲得更多的前面的資料,可以使用before參數並提供此startCursor值。
  4. edges:這是連接查詢結果的一部分,包括了指標和節點(node)。
    • cursor:每個邊(edge)都有一個指標,表示其在連接的位置。在執行分頁查詢時,cursor是非常有用的。
    • node:這是一個包含了實際資料的物件。

strawberry_django 提供另一個連接模式的分頁型態,它多了顯示總筆數的欄位:

# server/app/blog/graph/queries.py
import strawberry
import strawberry_django
-from strawberry import relay
+from strawberry_django.relay import ListConnectionWithTotalCount

from server.app.blog.graph import filters as blog_filters
from server.app.blog.graph import orders as blog_orders
# ... 省略

@strawberry.type
class Query:
-   posts: relay.ListConnection[blog_types.Post] = strawberry_django.connection(
+   posts: ListConnectionWithTotalCount[blog_types.Post] = strawberry_django.connection(
        order=blog_orders.PostOrder,
        filters=blog_filters.PostFilter,
    )

https://ithelp.ithome.com.tw/upload/images/20231007/20161957boYPg74jqI.png

query MyQuery {
  posts(
    after: "YXJyYXljb25uZWN0aW9uOjA=",
    before: "",
    first: 1,
    last: null
  ) {
    pageInfo {
      endCursor
      hasNextPage
      hasPreviousPage
      startCursor
    }
    edges {
      cursor
      node {
        content
        id
        title
      }
    }
    totalCount
  }
}

這次修改內容可以參考 Git commit:

參考資料


上一篇
Day 21:Strawberry Django 檔案上傳
下一篇
Day 23:Strawberry Django 認證
系列文
Django 與 Strawberry GraphQL:探索現代 API 開發之路30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言