Elixir, Phoenix, Absinthe, GraphQL, React, Apollo, ExUnit, Jest mới nhất

Trước khi bắt đầu bài viết này bạn nên biết ít nhất 3 từ khóa ở trên. Một vài năm trước tôi có làm dự án về Elixir, nó là một ngôn ngữ lập trình rất ngắn gọn. Và sau đó tôi có dùng GraphQL trên một ứng dụng di động với React. Và bây giờ khi mọi thứ đã thay đổi rất nhiều, tôi thử kết hợp mọi thứ mới nhất với nhau. Trong bài viết này tôi sẽ giới thiệu và cách kết hợp các keyword trên với nhau.

Định nghĩa các từ khóa

  • Elixir là ngôn ngữ lập trình phía Server.
  • Phoenix là framework phổ biến nhất cho Elixir. (Ruby có Rails :: Elixir có Phonix).
  • GraphQL là ngôn ngữ truy vấn cho API.
  • Absinthe là thư viện Elixir phổ biến nhất để triển khai GraphQL Server.
  • Apollo là một thư viện JavaScript phổ biến để sử dụng API GraphQL. (Apollo cũng có gói phía Server, được sử dụng để triển khai GraphQL Server trong Node.js, nhưng tôi chỉ sử dụng ở client)
  • React là một framework hay thư viện JavaScript phổ biến để xây dựng giao diện người dùng frontend. (Có lẽ bạn đã biết cái này.)
  • ExUnit là thư viện viết unit test cho elixir
  • Jest là thư viện viết test cho React

Tôi đang xây dựng cái gì?

Tôi quyết định xây dựng một mạng xã hội nho nhỏ. Nó dường như đủ đơn giản để hoàn thành một cách khả thi trong một khoảng thời gian hợp lý, nhưng đủ phức tạp để tôi gặp phải những thách thức khi làm mọi thứ hoạt động trong một ứng dụng thực tế. Người dùng có thể tạo Post và comment về bài đăng của người dùng khác. Và để thực hành luôn về socket với Phoenix tôi tôi làm cả tính năng trò chuyện; Người dùng có thể bắt đầu cuộc trò chuyện riêng tư với những người dùng khác và mỗi cuộc hội thoại có thể có bất kỳ số lượng người dùng nào (tức là trò chuyện nhóm).

Để có thể chạy được ứng dụng này tôi đã chuẩn bị sãn môi trường đã được cài đặt đầy đủ.

https://github.com/dongpv91/elixir-infra

Tại sao dùng Elixir?

Elixir đã dần dần trở nên phổ biến trong vài năm qua. Nó chạy trên máy ảo Erlang và bạn có thể viết cú pháp Erlang trực tiếp trong tệp Elixir, nhưng nó được thiết kế để cung cấp cú pháp thân thiện hơn cho các nhà phát triển trong khi vẫn giữ tốc độ và khả năng chịu lỗi của Erlang. Elixir được gõ động, và cú pháp cảm thấy tương tự như ruby. Tuy nhiên, nó có nhiều chức năng hơn ruby, và có nhiều thành ngữ và parttern khác nhau.

Ít nhất là đối với bản thân tôi, điểm thu hút chính đối với Elixir là hiệu năng của Erlang VM. Nhóm tại WhatsApp đã có thể thiết lập hai triệu kết nối đến một máy chủ bằng Erlang. Máy chủ Elixir / Phoenix thường có thể phục vụ các yêu cầu đơn giản trong vòng chưa đến 1 mili giây.

Elixir có những lợi ích tốt. Nó được thiết kế để có khả năng chịu lỗi; bạn có thể tưởng tượng Erlang VM như một cụm các nút, trong đó bất kỳ một nút nào chết cũng không làm gián đoạn các nút khác. Điều này cũng giúp cho việc thực hiện "hot code swapping", triển khai mã mới mà không phải dừng và khởi động lại ứng dụng. Tôi còn thấy rất tiện lợi và ngắn gọn khi sử dụng pattern matchingpipe operator .

Tại sao dùng GraphQL?

Với API RESTful truyền thống, máy chủ xác định tài nguyên và route nào được cung cấp (thông qua tài liệu API hoặc có thể thông qua một số tool tự động như Swagger) và client phải thực hiện đúng request để có được dữ liệu họ muốn. Server sẽ trả về bài post, hay comment để nhận nhận xét về bài đăng hay thông tin user để nhận tên và ảnh của tác giả, client có thể phải thực hiện ba request riêng biệt để nhận thông tin nó cần cho một nội dung cần thiết (API có thể sẽ cho phép bạn truy xuất dữ liệu bản ghi liên quan, nhưng Server phải thay đổi theo yêu cầu của client khác nhau) . GraphQL đảo ngược nguyên tắc này - khách hàng gửi một tài liệu truy vấn mô tả dữ liệu cần thiết (có thể kéo dài các mối quan hệ bảng) và máy chủ trả về tất cả trong một yêu cầu. Đối với ví dụ blog, một truy vấn bài viết có thể trông giống như thế này:

query {
  post(id: 123) {
    id
    body
    createdAt
    user {
      id
      name
      avatarUrl
    }
    comments {
      id
      body
      createdAt
      user {
        id
        name
        avatarUrl
      }
    }
  }
}

Truy vấn này mô tả tất cả thông tin mà người tiêu dùng có thể cần để hiển thị một trang bằng một bài đăng trên blog: ID, nội dung và thời gian của bài đăng đó; ID, tên và URL hình đại diện của người dùng đã xuất bản bài đăng; ID, nội dung và thời gian của các bình luận trên bài đăng; và ID, tên và URL hình đại diện của người dùng đã gửi từng nhận xét. Cấu trúc trực quan và linh hoạt; thật tuyệt vời khi xây dựng giao diện vì bạn chỉ có thể mô tả dữ liệu bạn muốn, thay vì xoay quanh cấu trúc được cung cấp bởi API.

Có hai khái niệm quan trọng khác trong GraphQL: mutation và subscription. Mutations  là một truy vấn tạo ra sự thay đổi dữ liệu trên máy chủ; nó tương đương với POST / PATCH / PUT trong API RESTful. Đây là một mutation để tạo ra một bài viết có thể trông như thế nào:

mutation {
  createPost(body: $body) {
    id
    body
    createdAt
  }
}

Các thuộc tính của bản ghi được cung cấp dưới dạng đối số và khối mô tả dữ liệu bạn cần lấy lại sau khi mutation hoàn tất (trong trường hợp này là ID, nội dung và thời gian của bài đăng mới).

Một subscription khá độc đáo đối với GraphQL; API RESTful hoàn toàn không có tính năng này. Nó cho phép khách hàng nói rằng họ muốn nhận được cập nhật trực tiếp từ máy chủ mỗi khi có một sự kiện cụ thể xảy ra. Ví dụ: nếu tôi muốn trang chủ cập nhật trực tiếp mỗi khi bài viết mới được tạo, tôi có thể viết subscription như thế này:

subscription {
  postCreated {
    id
    body
    createdAt
    user {
      id
      name
      avatarUrl
    }
  }
}

Như bạn có thể có thể chắc chắn rằng request máy chủ gửi cho tôi bản cập nhật trực tiếp bất cứ khi nào bài đăng mới được tạo và bao gồm ID, nội dung và thời gian của bài đăng, ID, tên và URL hình đại diện của tác giả. Subscription thường được hỗ trợ bởi websockets; máy khách giữ một socket mở cho máy chủ và máy chủ sẽ gửi một thông điệp xuống máy khách bất cứ khi nào sự kiện xảy ra.

Một điều cuối cùng - GraphQL có một công cụ phát triển khá tuyệt vời có tên là GraphiQL. Đó là giao diện web với trình chỉnh sửa trực tiếp, nơi bạn có thể viết và thực hiện các truy vấn và xem kết quả. Nó bao gồm tự động hoàn thành và đường khác giúp dễ dàng tìm thấy các truy vấn và trường có sẵn; nó đặc biệt tuyệt vời khi bạn lặp lại trên cấu trúc của một truy vấn. Bạn có thể dùng thử giao diện GraphiQL trên github tại đây. Hãy thử gửi cho nó truy vấn sau đây để lấy thông tin account github:

Xem Document trực tiếp trên GraphiQL

Tại sao dùng Apollo?

Apollo đã trở thành một trong những thư viện GraphQL phổ biến nhất, cả trên server và client. Trải nghiệm trước đây của tôi với GraphQL là vào năm 2017 với Relay, đó là một thư viện JavaScript phía client khác. Thành thật mà nói, tôi ghét nó. Tôi bị thu hút bởi nó được do chính Facebook làm ra cùng với GraphQL, nhưng Relay cảm thấy phức tạp và khó khăn để nắm bắt; tài liệu có rất nhiều từ ngữ đặc biệt và tôi cảm thấy khó khăn khi thiết lập một nền tảng kiến ​​thức để hiểu nó. Lúc đó, Relay vẫn là phiên bản 1.0; họ đã thực hiện những thay đổi đáng kể để đơn giản hóa thư viện (mà họ gọi là Relay Modern) và tài liệu ngày nay cũng tốt hơn rất nhiều. Nhưng tôi muốn thử một cái gì đó mới và Apollo đã trở nên phổ biến một phần vì nó mang lại trải nghiệm phát triển đơn giản hơn để xây dựng ứng dụng client GraphQL.

Phía Server

Tôi bắt đầu bằng cách xây dựng phía server của ứng dụng.

Cụ thể, tôi bắt đầu bằng cách xác định cấu trúc mô hình cơ bản của ứng dụng. Ở cấp độ cao, nó trông như thế này:

User
- Name
- Email
- Password hash

Post
- User ID
- Body

Comment
- User ID
- Post ID
- Body

Conversation
- Title (just the names of the participants denormalized to a string)

ConversationUser (each conversation can have any number of users)
- Conversation ID
- User ID

Message
- Conversation ID
- User ID
- Body

Phoenix cho phép bạn viết migration cơ sở dữ liệu khá giống với migration  trong Rails. Đây là migration  để tạo bảng người dùng, ví dụ:

# socializer/priv/repo/migrations/20190414185306_create_users.exs
defmodule Socializer.Repo.Migrations.CreateUsers do
  use Ecto.Migration

  def change do
    create table(:users) do
      add :name, :string
      add :email, :string
      add :password_hash, :string

      timestamps()
    end

    create unique_index(:users, [:email])
  end
end

Tiếp theo, tôi đã thực hiện các module theo các table đã tạo (Giống như Model trong các framework khác, Elixir không có class). Phoenix sử dụng một thư viện có tên Ecto cho các Model của nó; bạn có thể nghĩ Ecto tương tự như ActiveRecord nhưng ít liên kết chặt chẽ hơn với framework. Một điểm khác biệt chính là các model của Ecto không có bất kỳ phương thức khởi tạo nào (vì không phải là class). Một thể hiện mô hình chỉ là một cấu trúc (như hàm băm với các khóa được xác định trước); các phương thức mà bạn định nghĩa trên một model là các phương thức nhận vào một cấu trúc, thay đổi nó và trả về kết quả. Cách tiếp cận này thường là thành ngữ trong Elixir; nó ưu tiên lập trình chức năng và các biến là bất biến.

Đây là ví dụ về Model trong bài viết:

# socializer/lib/socializer/post.ex
defmodule Socializer.Post do
  use Ecto.Schema
  import Ecto.Changeset
  import Ecto.Query

  alias Socializer.{Repo, Comment, User}

  # ...
end

Đầu tiên, phải gọi một số module cần thiết. Trong Elixir, importmang đến các chức năng của một module  (tương tự như `include trong ruby); usegọi __using__macro trên module được chỉ định. Macro là cơ chế của Elixir để lập trình siêu dữ liệu. aliaschỉ đơn giản là làm cho các mô-đun được đặt tên có sẵn làm tên cơ sở của chúng (vì vậy tôi có thể tham chiếu Userthay vì phải gõ Socializer.Userở mọi nơi).

# socializer/lib/socializer/post.ex
defmodule Socializer.Post do
  # ...

  schema "posts" do
    field :body, :string

    belongs_to :user, User
    has_many :comments, Comment

    timestamps()
  end

  # ...
end

Tiếp theo, chúng ta có schema. Các model Ecto phải mô tả rõ ràng từng thuộc tính trong schema. Các schemamacro được tạo sẵn bởi use Ecto.Schematrong phần trước.

# socializer/lib/socializer/post.ex
defmodule Socializer.Post do
  # ...

  def all do
    Repo.all(from p in __MODULE__, order_by: [desc: p.id])
  end

  def find(id) do
    Repo.get(__MODULE__, id)
  end

  # ...
end

Sau schema, tôi đã viết một vài hàm để tìm lấy các bài đăng từ cơ sở dữ liệu. Với Ecto, Repomodule được sử dụng cho tất cả các truy vấn cơ sở dữ liệu; ví dụ, Repo.get(Post, 123)sẽ tra cứu bài đăng với ID 123. Cú pháp truy vấn cơ sở dữ liệu trong searchphương thức được cung cấp bởi import Ecto.Queryở đầu lớp. Cuối cùng, __MODULE__chỉ là một tốc ký cho module hiện tại (tức là Socializer.Post).

# socializer/lib/socializer/post.ex
defmodule Socializer.Post do
  # ...

  def create(attrs) do
    attrs
    |> changeset()
    |> Repo.insert()
  end

  def changeset(attrs) do
    %__MODULE__{}
    |> changeset(attrs)
  end

  def changeset(post, attrs) do
    post
    |> cast(attrs, [:body, :user_id])
    |> validate_required([:body, :user_id])
    |> foreign_key_constraint(:user_id)
  end
end

Các phương thức update data

GraqhQL Schema

Tiếp theo, tôi kết nối các thành phần GraphQL của máy chủ. Chúng thường có thể được nhóm thành hai loại: type và resolver. Trong các file type, bạn sử dụng cú pháp giống như DSL để khai báo các đối tượng, trường và quan hệ có sẵn để được truy vấn. Các Resolver giải quyết cách trả lời bất kỳ truy vấn nào.

Đây là type bài đăng của tôi trông như thế này:

# socializer/lib/socializer_web/schema/post_types.ex
defmodule SocializerWeb.Schema.PostTypes do
  use Absinthe.Schema.Notation
  use Absinthe.Ecto, repo: Socializer.Repo

  alias SocializerWeb.Resolvers

  @desc "A post on the site"
  object :post do
    field :id, :id
    field :body, :string
    field :inserted_at, :naive_datetime

    field :user, :user, resolve: assoc(:user)

    field :comments, list_of(:comment) do
      resolve(
        assoc(:comments, fn comments_query, _args, _context ->
          comments_query |> order_by(desc: :id)
        end)
      )
    end
  end

  # ...
end

Sau useimport  chỉ cần xác định:postđối tượng cho GraphQL. ID, body và insert_at sẽ sử dụng các giá trị trực tiếp từ Poststruct. Tiếp theo, chúng tôi khai báo một vài đôi tượng có thể liên quan đến bài đăng (user, comments).

# socializer/lib/socializer_web/schema/post_types.ex
defmodule SocializerWeb.Schema.PostTypes do
  # ...

  object :post_queries do
    @desc "Get all posts"
    field :posts, list_of(:post) do
      resolve(&Resolvers.PostResolver.list/3)
    end

    @desc "Get a specific post"
    field :post, :post do
      arg(:id, non_null(:id))
      resolve(&Resolvers.PostResolver.show/3)
    end
  end

  # ...
end

Tiếp theo, chúng tôi sẽ khai báo một vài truy vấn cấp gốc liên quan đến bài viết.postscho phép truy vấn tất cả các bài đăng trên trang web trong khi posttìm nạp một bài đăng bằng ID. File type chỉ đơn giản khai báo các truy vấn cùng với các đối số và kiểu trả về; việc thực hiện được giao cho resolve giải quyết.

# socializer/lib/socializer_web/schema/post_types.ex
defmodule SocializerWeb.Schema.PostTypes do
  # ...

  object :post_mutations do
    @desc "Create post"
    field :create_post, :post do
      arg(:body, non_null(:string))

      resolve(&Resolvers.PostResolver.create/3)
    end
  end

  # ...
end

Sau các truy vấn, chúng tôi tuyên bố một mutation cho phép tạo một bài đăng mới trên trang web. Như với các truy vấn, tệp loại chỉ đơn giản khai báo siêu dữ liệu về các mutation, phần xử lý sẽ do resolve.

# socializer/lib/socializer_web/schema/post_types.ex
defmodule SocializerWeb.Schema.PostTypes do
  # ...

  object :post_subscriptions do
    field :post_created, :post do
      config(fn _, _ ->
        {:ok, topic: "posts"}
      end)

      trigger(:create_post,
        topic: fn _ ->
          "posts"
        end
      )
    end
  end
end

Cuối cùng, một vài subcruption liên quan đến bài viết ,:post_created. Điều này cho phép khách hàng đăng ký và nhận được cập nhật bất cứ khi nào một bài đăng mới được tạo. configđược sử dụng để thiết lập đăng ký, đồng thời triggercho absinthe biết mutation nào sẽ gọi đăng ký. topiccho phép bạn phân đoạn subscription responses - trong trường hợp này, cập nhật ứng dụng khách về bất kỳ bài đăng nào, nhưng trong các trường hợp khác, chỉ muốn thông báo về một số thay đổi nhất định. Ví dụ: đây là đăng ký nhận xét - khách hàng chỉ muốn biết về nhận xét mới về một bài đăng cụ thể (không phải mỗi bài đăng) để nó cung cấp một post_idđối số là chủ đề.

defmodule SocializerWeb.Schema.CommentTypes do
  # ...

  object :comment_subscriptions do
    field :comment_created, :comment do
      arg(:post_id, non_null(:id))

      config(fn args, _ ->
        {:ok, topic: args.post_id}
      end)

      trigger(:create_comment,
        topic: fn comment ->
          comment.post_id
        end
      )
    end
  end
end

Mặc dù tôi đã chia các type thành các tệp cho từng model, nhưng đáng chú ý là absinthe yêu cầu bạn phải lắp ráp tất cả các loại trong mộtSchemamô-đun. Nó trông như thế này:

defmodule SocializerWeb.Schema do
  use Absinthe.Schema
  import_types(Absinthe.Type.Custom)

  import_types(SocializerWeb.Schema.PostTypes)
  # ...other models' types

  query do
    import_fields(:post_queries)
    # ...other models' queries
  end

  mutation do
    import_fields(:post_mutations)
    # ...other models' mutations
  end

  subscription do
    import_fields(:post_subscriptions)
    # ...other models' subscriptions
  end
end

Như tôi đã đề cập ở trên, resolvers chúng chứa logic để cung cấp dữ liệu cho truy vấn hoặc áp dụng đột biến.Chúng ta hãy đi qua resolvers post:

# lib/socializer_web/resolvers/post_resolver.ex
defmodule SocializerWeb.Resolvers.PostResolver do
  alias Socializer.Post

  def list(_parent, _args, _resolutions) do
    {:ok, Post.all()}
  end

  def show(_parent, args, _resolutions) do
    case Post.find(args[:id]) do
      nil -> {:error, "Not found"}
      post -> {:ok, post}
    end
  end

  # ...
end

Hai phương thức đầu tiên xử lý hai truy vấn được xác định ở trên - để lấy tất cả các bài đăng và lấy một bài đăng cụ thể. Absinthe hy vọng mọi phương thức trình phân giải sẽ trả về một tuple - {:ok, requested_data}hoặc {:error, some_error}(đây là một pattern chung cho các phương thức Elixir nói chung). Câucaselệnh trong show là một ví dụ hay về pattern matching trong Elixir - nếuPost.findtrả về nil, chúng ta trả về bộ lỗi; nếu có gì khác, chúng tôi trả lại bài được tìm thấy.

# lib/socializer_web/resolvers/post_resolver.ex
defmodule SocializerWeb.Resolvers.PostResolver do
  # ...

  def create(_parent, args, %{
        context: %{current_user: current_user}
      }) do
    args
    |> Map.put(:user_id, current_user.id)
    |> Post.create()
    |> case do
      {:ok, post} ->
        {:ok, post}

      {:error, changeset} ->
        {:error, extract_error_msg(changeset)}
    end
  end

  def create(_parent, _args, _resolutions) do
    {:error, "Unauthenticated"}
  end

  # ...
end

Tiếp theo, chúng ta có resolver của create, chứa logic để tạo một bài đăng mới. Đây cũng là một ví dụ điển hình về pattern matching thông qua các tham số phương thức - Elixir cho phép bạn nạp chồng tên phương thức và sẽ chọn triển khai đầu tiên khớp với mẫu đã khai báo. Trong trường hợp này, nếu tham số thứ ba là bản đồ có context, chứa bản đồ có current_user, thì phương thức đầu tiên được sử dụng; nếu truy vấn không đi kèm với authentication token, nó sẽ chuyển sang phương thức thứ hai và trả về lỗi.

# lib/socializer_web/resolvers/post_resolver.ex
defmodule SocializerWeb.Resolvers.PostResolver do
  # ...

  defp extract_error_msg(changeset) do
    changeset.errors
    |> Enum.map(fn {field, {error, _details}} ->
      [
        field: field,
        message: String.capitalize(error)
      ]
    end)
  end
end

Cuối cùng, chúng ta có một phương thức trợ giúp đơn giản để trả về phản hồi lỗi nếu thuộc tính bài viết không hợp lệ (ví dụ: nếu phần thân trống). Absinthe muốn các thông báo lỗi là một chuỗi, một chuỗi các chuỗi hoặc một chuỗi các danh sách từ khóa có fieldmessagecác khóa

Context/authentication

Trong phần cuối cùng, tôi đã đề cập đến khái niệm truy vấn được xác thực - trong trường hợp của tôi, được biểu thị đơn giản bằng một Bearer: tokentrong authorizationHeader. Làm thế nào để chúng ta nhận được từ mã thông báo đó đến current_userbối cảnh trong trình phân giải? Với một plug tùy đọc tiêu đề và tìm kiếm người dùng hiện tại. Ở Phoenix, Plug là một phần của đường dẫn cho một yêu cầu - bạn có thể có các plugin giải mã JSON, thêm các tiêu đề CORS hoặc thực sự là bất kỳ phần có thể kết hợp nào khác để xử lý yêu cầu. Plug trông như thế này:

# lib/socializer_web/context.ex
defmodule SocializerWeb.Context do
  @behaviour Plug

  import Plug.Conn

  alias Socializer.{Guardian, User}

  def init(opts), do: opts

  def call(conn, _) do
    context = build_context(conn)
    Absinthe.Plug.put_options(conn, context: context)
  end

  def build_context(conn) do
    with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
         {:ok, claim} <- Guardian.decode_and_verify(token),
         user when not is_nil(user) <- User.find(claim["sub"]) do
      %{current_user: user}
    else
      _ -> %{}
    end
  end
end

Testing — server side

Elixir có một thư viện thử nghiệm tích hợp cơ bản có tên là ExUnit. Nó chứa đơn giản assert/ refutetrợ giúp và xử lý chạy test của bạn. Chúng được bao gồm trong các thử nghiệm để chạy các tác vụ thiết lập chung như kết nối với cơ sở dữ liệu. Ngoài các mặc định, có hai thư viện trợ giúp mà tôi thấy hữu ích trong các test của mình - ex_specex_machina . ex_spec thêm đơn giản describeitmacro làm cho cú pháp kiểm tra cảm thấy thân thiện hơn một chút, ít nhất là từ nền tảng ruby ​. ex_machina cung cấp cho các nhà máy giúp dễ dàng chèn động dữ liệu thử nghiệm.

# test/support/factories.ex
defmodule Socializer.Factory do
  use ExMachina.Ecto, repo: Socializer.Repo

  def user_factory do
    %Socializer.User{
      name: Faker.Name.name(),
      email: Faker.Internet.email(),
      password: "password",
      password_hash: Bcrypt.hash_pwd_salt("password")
    }
  end

  def post_factory do
    %Socializer.Post{
      body: Faker.Lorem.paragraph(),
      user: build(:user)
    }
  end

  # ...factories for other models
end

Và sau khi nhập factory vào thiết lập trường hợp, nó có thể được sử dụng trong các test với cú pháp rất trực quan:

# Insert a user
user = insert(:user)

# Insert a user with a specific name
user_named = insert(:user, name: "John Smith")

# Insert a post for the user
post = insert(:post, user: user)

Với cách thiết lập đã sẵn sàng, đây là Postmodel test trông như thế này:

# test/socializer/post_test.exs
defmodule Socializer.PostTest do
  use SocializerWeb.ConnCase

  alias Socializer.Post

  describe "#all" do
    it "finds all posts" do
      post_a = insert(:post)
      post_b = insert(:post)
      results = Post.all()
      assert length(results) == 2
      assert List.first(results).id == post_b.id
      assert List.last(results).id == post_a.id
    end
  end

  describe "#find" do
    it "finds post" do
      post = insert(:post)
      found = Post.find(post.id)
      assert found.id == post.id
    end
  end

  describe "#create" do
    it "creates post" do
      user = insert(:user)
      valid_attrs = %{user_id: user.id, body: "New discussion"}
      {:ok, post} = Post.create(valid_attrs)
      assert post.body == "New discussion"
    end
  end

  describe "#changeset" do
    it "validates with correct attributes" do
      user = insert(:user)
      valid_attrs = %{user_id: user.id, body: "New discussion"}
      changeset = Post.changeset(%Post{}, valid_attrs)
      assert changeset.valid?
    end

    it "does not validate with missing attrs" do
      changeset =
        Post.changeset(
          %Post{},
          %{}
        )

      refute changeset.valid?
    end
  end
end

Tiếp theo, test resolver:

# test/socializer_web/resolvers/post_resolver_test.exs
defmodule SocializerWeb.PostResolverTest do
  use SocializerWeb.ConnCase

  alias SocializerWeb.Resolvers.PostResolver

  describe "#list" do
    it "returns posts" do
      post_a = insert(:post)
      post_b = insert(:post)
      {:ok, results} = PostResolver.list(nil, nil, nil)
      assert length(results) == 2
      assert List.first(results).id == post_b.id
      assert List.last(results).id == post_a.id
    end
  end

  describe "#show" do
    it "returns specific post" do
      post = insert(:post)
      {:ok, found} = PostResolver.show(nil, %{id: post.id}, nil)
      assert found.id == post.id
    end

    it "returns not found when post does not exist" do
      {:error, error} = PostResolver.show(nil, %{id: 1}, nil)
      assert error == "Not found"
    end
  end

  describe "#create" do
    it "creates valid post with authenticated user" do
      user = insert(:user)

      {:ok, post} =
        PostResolver.create(nil, %{body: "Hello"}, %{
          context: %{current_user: user}
        })

      assert post.body == "Hello"
      assert post.user_id == user.id
    end

    it "returns error for missing params" do
      user = insert(:user)

      {:error, error} =
        PostResolver.create(nil, %{}, %{
          context: %{current_user: user}
        })

      assert error == [[field: :body, message: "Can't be blank"]]
    end

    it "returns error for unauthenticated user" do
      {:error, error} = PostResolver.create(nil, %{body: "Hello"}, nil)

      assert error == "Unauthenticated"
    end
  end
end

Tạo một tệp trợ giúp với một số chức năng phổ biến mà các test tích hợp sẽ cần:

# test/support/absinthe_helpers.ex
defmodule Socializer.AbsintheHelpers do
  alias Socializer.Guardian

  def authenticate_conn(conn, user) do
    {:ok, token, _claims} = Guardian.encode_and_sign(user)
    Plug.Conn.put_req_header(conn, "authorization", "Bearer #{token}")
  end

  def query_skeleton(query, query_name) do
    %{
      "operationName" => "#{query_name}",
      "query" => "query #{query_name} #{query}",
      "variables" => "{}"
    }
  end

  def mutation_skeleton(query) do
    %{
      "operationName" => "",
      "query" => "mutation #{query}",
      "variables" => ""
    }
  end
end
# test/socializer_web/integration/post_resolver_test.exs
defmodule SocializerWeb.Integration.PostResolverTest do
  use SocializerWeb.ConnCase
  alias Socializer.AbsintheHelpers

  describe "#list" do
    it "returns posts" do
      post_a = insert(:post)
      post_b = insert(:post)

      query = """
      {
        posts {
          id
          body
        }
      }
      """

      res =
        build_conn()
        |> post("/graphiql", AbsintheHelpers.query_skeleton(query, "posts"))

      posts = json_response(res, 200)["data"]["posts"]
      assert List.first(posts)["id"] == to_string(post_b.id)
      assert List.last(posts)["id"] == to_string(post_a.id)
    end
  end

  # ...
end

Test này thực hiện điểm cuối để truy vấn danh sách bài viết.

Test tích hợp cho resolver tạo post:

# test/socializer_web/integration/post_resolver_test.exs
defmodule SocializerWeb.Integration.PostResolverTest do
  # ...

  describe "#create" do
    it "creates post" do
      user = insert(:user)

      mutation = """
      {
        createPost(body: "A few thoughts") {
          body
          user {
            id
          }
        }
      }
      """

      res =
        build_conn()
        |> AbsintheHelpers.authenticate_conn(user)
        |> post("/graphiql", AbsintheHelpers.mutation_skeleton(mutation))

      post = json_response(res, 200)["data"]["createPost"]
      assert post["body"] == "A few thoughts"
      assert post["user"]["id"] == to_string(user.id)
    end
  end
end

Làm thế nào về test subscription? Những điều này cũng liên quan đến một chút thiết lập để tạo kết nối plug nơi chúng ta có thể thiết lập và thực hiện subscription.Tôi thấy bài viết này cực kỳ hữu ích để hiểu quá trình thiết lập cho test subscription.

Đầu tiên, chúng tôi tạo ra một case mới của Wap để thực hiện thiết lập cho các  test subscription. Nó trông như thế này:

# test/support/subscription_case.ex
defmodule SocializerWeb.SubscriptionCase do
  use ExUnit.CaseTemplate

  alias Socializer.Guardian

  using do
    quote do
      use SocializerWeb.ChannelCase
      use Absinthe.Phoenix.SubscriptionTest, schema: SocializerWeb.Schema
      use ExSpec
      import Socializer.Factory

      setup do
        user = insert(:user)

        # When connecting to a socket, if you pass a token we will set the context's `current_user`
        params = %{
          "token" => sign_auth_token(user)
        }

        {:ok, socket} = Phoenix.ChannelTest.connect(SocializerWeb.AbsintheSocket, params)
        {:ok, socket} = Absinthe.Phoenix.SubscriptionTest.join_absinthe(socket)

        {:ok, socket: socket, user: user}
      end

      defp sign_auth_token(user) do
        {:ok, token, _claims} = Guardian.encode_and_sign(user)
        token
      end
    end
  end
end

Sau khi nhập chung, cần xác định setupbước chèn user mới và thiết lập websocket được xác thực bằng mã thông báo của user. Tôi trả lại Plug và user cho các test của tôi để sử dụng.

Tiếp theo, chúng ta hãy xem test sau:

defmodule SocializerWeb.PostSubscriptionsTest do
  use SocializerWeb.SubscriptionCase

  describe "Post subscription" do
    it "updates on new post", %{socket: socket} do
      # Query to establish the subscription.
      subscription_query = """
        subscription {
          postCreated {
            id
            body
          }
        }
      """

      # Push the query onto the socket.
      ref = push_doc(socket, subscription_query)

      # Assert that the subscription was successfully created.
      assert_reply(ref, :ok, %{subscriptionId: _subscription_id})

      # Query to create a new post to invoke the subscription.
      create_post_mutation = """
        mutation CreatePost {
          createPost(body: "Big discussion") {
            id
            body
          }
        }
      """

      # Push the mutation onto the socket.
      ref =
        push_doc(
          socket,
          create_post_mutation
        )

      # Assert that the mutation successfully created the post.
      assert_reply(ref, :ok, reply)
      data = reply.data["createPost"]
      assert data["body"] == "Big discussion"

      # Assert that the subscription notified us of the new post.
      assert_push("subscription:data", push)
      data = push.result.data["postCreated"]
      assert data["body"] == "Big discussion"
    end
  end
end

Đầu tiên, chúng tôi viết subscription query và đẩy nó vào Plug mà chúng tôi đã xây dựng trong quá trình test setup. Tiếp theo, chúng tôi viết một mutation dự kiến ​​sẽ kích hoạt subscription (tức là tạo một bài đăng mới) và đẩy nó lên Plug. Cuối cùng, chúng tôi kiểm tra pushphản hồi để khẳng định rằng chúng tôi đã được cập nhật về bài đăng mới được tạo. Thêm một chút thiết lập liên quan.

Client

Tiếp theo, hãy xem cách client được xây dựng.

Tôi đã bắt đầu với create-react-app , rất phù hợp cho khởi tạo dự án React

Tôi đang sử dụng React Router để routing trong ứng dụng của mình; điều này sẽ cho phép người dùng điều hướng giữa một danh sách các bài đăng, một bài đăng, chat, v.v. Component root của ứng dụng của tôi trông giống như thế này:

// client/src/App.js
import React, { useRef } from "react";
import { ApolloProvider } from "react-apollo";
import { BrowserRouter, Switch, Route } from "react-router-dom";
import { createClient } from "util/apollo";
import { Meta, Nav } from "components";
import { Chat, Home, Login, Post, Signup } from "pages";

const App = () => {
  const client = useRef(createClient());

  return (
    <ApolloProvider client={client.current}>
      <BrowserRouter>
        <Meta />
        <Nav />

        <Switch>
          <Route path="/login" component={Login} />
          <Route path="/signup" component={Signup} />
          <Route path="/posts/:id" component={Post} />
          <Route path="/chat/:id?" component={Chat} />
          <Route component={Home} />
        </Switch>
      </BrowserRouter>
    </ApolloProvider>
  );
};

Một vài phần ở đây - util/apollohiển thị một createClienthàm tạo và trả về một cá thể client Apollo (sẽ nói ở phần tiếp theo). Việc gói nó trong một useRef(React hook tính năng mới của React) client giống nhau có sẵn trong suốt vòng đời của ứng dụng (tức là trên các rerenders). Các ApolloProviderHOC làm cho client có sẵn trong component con hay query. Việc BrowserRoutersử dụng HTML5 history API để giữ trạng thái URL đồng bộ hóa khi chúng tôi điều hướng xung quanh ứng dụng.

Routing động cho phép tôi thể hiện như vậy:

const App = () => {
  return (
    // ...
    <Route path="/chat/:id?" component={Chat} />
    // ...
  );
};

const Chat = () => {
  return (
    <div>
      <ChatSidebar />

      <Switch>
        <Route path="/chat/:id" component={Conversation} />
        <Route component={EmptyState} />
      </Switch>
    </div>
  );
};

Apollo client

Hãy đi sâu hơn một chút vào Apollo client - cụ thể là createClientchức năng được tham chiếu ở trên. Các util/apollo.jstập tin trông như thế này:

// client/src/util.apollo.js
import ApolloClient from "apollo-client";
import { InMemoryCache } from "apollo-cache-inmemory";
import * as AbsintheSocket from "@absinthe/socket";
import { createAbsintheSocketLink } from "@absinthe/socket-apollo-link";
import { Socket as PhoenixSocket } from "phoenix";
import { createHttpLink } from "apollo-link-http";
import { hasSubscription } from "@jumpn/utils-graphql";
import { split } from "apollo-link";
import { setContext } from "apollo-link-context";
import Cookies from "js-cookie";

const HTTP_URI =
  process.env.NODE_ENV === "production"
    ? "https://brisk-hospitable-indianelephant.gigalixirapp.com"
    : "http://localhost:4000";

const WS_URI =
  process.env.NODE_ENV === "production"
    ? "wss://brisk-hospitable-indianelephant.gigalixirapp.com/socket"
    : "ws://localhost:4000/socket";


export const createClient = () => {
  // Create the basic HTTP link.
  const httpLink = createHttpLink({ uri: HTTP_URI });

  // Create an Absinthe socket wrapped around a standard
  // Phoenix websocket connection.
  const absintheSocket = AbsintheSocket.create(
    new PhoenixSocket(WS_URI, {
      params: () => {
        if (Cookies.get("token")) {
          return { token: Cookies.get("token") };
        } else {
          return {};
        }
      },
    }),
  );

  // Use the Absinthe helper to create a websocket link around
  // the socket.
  const socketLink = createAbsintheSocketLink(absintheSocket);

// ...

Apollo client yêu cầu cung cấp cho nó một liên kết - về cơ bản, một kết nối đến máy chủ GraphQL của bạn mà Apollo client có thể sử dụng để thực hiện các yêu cầu. Có hai loại liên kết phổ biến - liên kết HTTP, yêu cầu máy chủ GraphQL qua HTTP tiêu chuẩn và liên kết websocket, mở kết nối websocket đến máy chủ và gửi truy vấn qua Plug. Trong trường hợp của chúng tôi, chúng tôi thực sự muốn cả hai . Đối với các truy vấn và đột biến thông thường, chúng tôi sẽ sử dụng liên kết HTTP và để đăng ký, chúng tôi sẽ sử dụng liên kết websocket.

// client/src/util.apollo.js
export const createClient = () => {
  //...

  // Split traffic based on type -- queries and mutations go
  // through the HTTP link, subscriptions go through the
  // websocket link.
  const splitLink = split(
    (operation) => hasSubscription(operation.query),
    socketLink,
    httpLink,
  );

  // Add a wrapper to set the auth token (if any) to the
  // authorization header on HTTP requests.
  const authLink = setContext((_, { headers }) => {
    // Get the authentication token from the cookie if it exists.
    const token = Cookies.get("token");

    // Return the headers to the context so httpLink can read them.
    return {
      headers: {
        ...headers,
        authorization: token ? `Bearer ${token}` : "",
      },
    };
  });

  const link = authLink.concat(splitLink);

  // ...
};

Apollo cung cấp phương thức split cho phép bạn định tuyến các truy vấn đến các liên kết khác nhau dựa trên các tiêu chí bạn chọn. Nếu query là subscription thì trả về link có Socket, nếu không thì sẽ trả về link http bình thường.

Có thể cần cung cấp xác thực cho cả hai liên kết, nếu người dùng hiện đang đăng nhập. Khi họ đăng nhập, sẽ đặt mã thông báo xác thực của họ thành tokencookie. Sử dụng tokenlàm tham số khi thiết lập kết nối websocket Phoenix trong phần trước và ở đây sử dụng setContexttrình bao bọc để đặt tokentiêu đề ủy quyền của các yêu cầu qua liên kết HTTP.

// client/src/util.apollo.js
export const createClient = () => {
  // ...

  return new ApolloClient({
    cache: new InMemoryCache(),
    link,
  });
});

Ngoài liên kết, Apollo client cũng cần một cá thể bộ đệm; GraphQL tự động lưu trữ kết quả của các truy vấn để ngăn các yêu cầu trùng lặp cho cùng một dữ liệu. Cơ bản InMemoryCachelà lý tưởng cho hầu hết các trường hợp sử dụng - nó chỉ giữ dữ liệu truy vấn được lưu trong bộ nhớ cache ở trạng thái trình duyệt cục bộ.

Sử dụng client với query đầu tiên

Bây giờ hãy xem cách nó được sử dụng để chạy các query và mutation. Chúng ta sẽ bắt đầu với Component Posts, được sử dụng để hiển thị nguồn cấp dữ liệu của bài đăng trên trang chủ của ứng dụng.

// client/src/components/Posts.js
import React, { Fragment } from "react";
import { Query } from "react-apollo";
import gql from "graphql-tag";
import produce from "immer";
import { ErrorMessage, Feed, Loading } from "components";

export const GET_POSTS = gql`
  {
    posts {
      id
      body
      insertedAt
      user {
        id
        name
        gravatarMd5
      }
    }
  }
`;

export const POSTS_SUBSCRIPTION = gql`
  subscription onPostCreated {
    postCreated {
      id
      body
      insertedAt
      user {
        id
        name
        gravatarMd5
      }
    }
  }
`;

// ...

Đầu tiên là query cơ bản để tìm nạp danh sách bài đăng (cùng với thông tin về người dùng đã viết mỗi bài) và thứ hai là subscription để thông báo khi có bất kỳ bài đăng mới nào, vì vậy chúng tôi có thể cập nhật trực tiếp màn hình và giữ cho nguồn cấp dữ liệu cập nhật.

// client/src/components/Posts.js
// ...

const Posts = () => {
  return (
    <Fragment>
      <h4>Feed</h4>
      <Query query={GET_POSTS}>
        {({ loading, error, data, subscribeToMore }) => {
          if (loading) return <Loading />;
          if (error) return <ErrorMessage message={error.message} />;
          return (
            <Feed
              feedType="post"
              items={data.posts}
              subscribeToNew={() =>
                subscribeToMore({
                  document: POSTS_SUBSCRIPTION,
                  updateQuery: (prev, { subscriptionData }) => {
                    if (!subscriptionData.data) return prev;
                    const newPost = subscriptionData.data.postCreated;

                    return produce(prev, (next) => {
                      next.posts.unshift(newPost);
                    });
                  },
                })
              }
            />
          );
        }}
      </Query>
    </Fragment>
  );
};

Để chạy truy vấn, cần phải render <Query query={GET_POSTS}> của Apollo . Nó cung cấp một số param - loading, error, data, và subscribeToMore. Nếu truy vấn đang tải, chúng tôi chỉ hiển thị tải đơn giản. Nếu có lỗi, chúng tôi sẽ hiển thị chung ErrorMessagecho người dùng. Mặt khác, truy vấn đã thành công, vì vậy chúng ta có thể kết xuất một component Feed( data.postschứa các bài đăng sẽ được render, khớp với cấu trúc của truy vấn).

subscribeToMorethực hiện subcribe chỉ chịu trách nhiệm tìm nạp các mục mới trong bộ sưu tập mà người dùng hiện đang xem. Nó được cho là được gọi ở componentDidMount của component con, đó là lý do tại sao nó được chuyển qua như một chỗ dựa Feed- Feedchịu trách nhiệm gọi subscribeToNewmột khi Feedđã kết xuất. Chúng tôi cung cấp subscribeToMoretruy vấn đăng ký của chúng tôi và một updateQuerycuộc gọi lại, mà Apollo sẽ gọi khi nhận được thông báo rằng một bài đăng mới đã được tạo. Khi điều đó xảy ra, chỉ cần đẩy bài đăng mới lên mảng bài đăng hiện có, sử dụng bộ nhúng để trả về một đối tượng mới để thành phần này được đăng ký lại một cách chính xác.

Authentication (và mutations)

Bây giờ chúng tôi đã có một trang chủ có thể hiển thị danh sách các bài đăng và có thể phản hồi trong thời gian thực với các bài đăng mới được tạo - làm thế nào để bài viết mới được tạo? Để bắt đầu, chúng tôi sẽ muốn cho phép người dùng đăng nhập vào tài khoản, vì vậy tôi có thể liên kết họ với bài đăng của họ. Điều này sẽ yêu cầu phải viết một mutation - chúng tôi cần gửi email và mật khẩu đến máy chủ và lấy lại mã thông báo xác thực mới cho người dùng. Hãy bắt đầu với màn hình đăng nhập:

// client/src/pages/Login.js
import React, { Fragment, useContext, useState } from "react";
import { Mutation } from "react-apollo";
import { Button, Col, Container, Form, Row } from "react-bootstrap";
import Helmet from "react-helmet";
import gql from "graphql-tag";
import { Redirect } from "react-router-dom";
import renderIf from "render-if";
import { AuthContext } from "util/context";

export const LOGIN = gql`
  mutation Login($email: String!, $password: String!) {
    authenticate(email: $email, password: $password) {
      id
      token
    }
  }
`;

Phần đầu tiên tương tự như query component - Nó chấp nhận email và mật khẩu và chúng tôi muốn lấy lại ID của người dùng được xác thực và mã thông báo xác thực của họ.

// client/src/pages/Login.js
// ...

const Login = () => {
  const { token, setAuth } = useContext(AuthContext);
  const [isInvalid, setIsInvalid] = useState(false);
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  if (token) {
    return <Redirect to="/" />;
  }

  // ...
};

Trong component body, trước tiên chúng ta tìm tokensetAuthhàm từ ngữ cảnh (AuthContext). Tôi cũng đặt một số trạng thái cục bộ bằng cách sử dụng useState, vì vậy chúng tôi có thể lưu trữ các giá trị tạm thời cho email, mật khẩu của người dùng và liệu thông tin đăng nhập của họ không hợp lệ (để chúng tôi có thể hiển thị trạng thái lỗi trên form). Cuối cùng, nếu người dùng đã có mã thông báo xác thực, họ đã đăng nhập để chúng tôi có thể chuyển hướng họ đến trang chủ.

// client/src/pages/Login.js
// ...

const Login = () => {
  // ...

  return (
    <Fragment>
      <Helmet>
        <title>Socializer | Log in</title>
        <meta property="og:title" content="Socializer | Log in" />
      </Helmet>
      <Mutation mutation={LOGIN} onError={() => setIsInvalid(true)}>
        {(login, { data, loading, error }) => {
          if (data) {
            const {
              authenticate: { id, token },
            } = data;
            setAuth({ id, token });
          }

          return (
            <Container>
              <Row>
                <Col md={6} xs={12}>
                  <Form
                    data-testid="login-form"
                    onSubmit={(e) => {
                      e.preventDefault();
                      login({ variables: { email, password } });
                    }}
                  >
                    <Form.Group controlId="formEmail">
                      <Form.Label>Email address</Form.Label>
                      <Form.Control
                        type="email"
                        placeholder="you@gmail.com"
                        value={email}
                        onChange={(e) => {
                          setEmail(e.target.value);
                          setIsInvalid(false);
                        }}
                        isInvalid={isInvalid}
                      />
                      {renderIf(error)(
                        <Form.Control.Feedback type="invalid">
                          Email or password is invalid
                        </Form.Control.Feedback>,
                      )}
                    </Form.Group>

                    <Form.Group controlId="formPassword">
                      <Form.Label>Password</Form.Label>
                      <Form.Control
                        type="password"
                        placeholder="Password"
                        value={password}
                        onChange={(e) => {
                          setPassword(e.target.value);
                          setIsInvalid(false);
                        }}
                        isInvalid={isInvalid}
                      />
                    </Form.Group>

                    <Button variant="primary" type="submit" disabled={loading}>
                      {loading ? "Logging in..." : "Log in"}
                    </Button>
                  </Form>
                </Col>
              </Row>
            </Container>
          );
        }}
      </Mutation>
    </Fragment>
  );
};

export default Login;

Có một chút tốt ở đây, nhưng đừng quá tải - hầu hết chỉ là hiển thị các thành phần Bootstrap cho biểu mẫu. Chúng tôi bắt đầu với một Helmettừ react-helmet - component này là một trang cấp cao nhất (so với Postsđược trả lại như một con của trang Home) vì vậy tôi muốn cung cấp cho nó một tiêu đề trình duyệt và một số siêu dữ liệu. Tiếp theo chúng tôi render Mutation, chuyển mutation query  từ phía trên vào nó. Nếu muation trả về lỗi, chúng tôi sử dụng hàm onErrorgọi lại để đặt trạng thái thành không hợp lệ, vì vậy chúng tôi có thể hiển thị lỗi trong biểu mẫu. Mutation truyền một hàm cho con của nó (được đặt tên loginở đây) sẽ gọi mutation và đối số thứ hai là cùng một mảng các giá trị mà chúng ta sẽ nhận được từ Query. Nếudatađược điền, điều đó có nghĩa là mutation đã được thực hiện thành công, vì vậy chúng tôi có thể lưu auth token và ID người dùng của mình với setAuth. Phần còn lại của biểu mẫu là React Bootstrap khá chuẩn - render các input và cập nhật các giá trị trạng thái khi thay đổi, hiển thị thông báo lỗi nếu người dùng cố đăng nhập nhưng thông tin đăng nhập của họ không hợp lệ.

Thế còn cái đó AuthContext? Khi người dùng đã xác thực, chúng tôi cần lưu trữ mã thông báo xác thực của họ ở phía client. GraphQL sẽ không thực sự giúp ích ở đây - tôi cần phải có auth token để xác thực request tiếp theo. Có thể kết nối Redux để lưu trữ mã thông báo ở trạng thái cục bộ, nhưng cảm giác đó là quá mức khi chúng tôi chỉ cần lưu trữ một giá trị. Thay vào đó, chúng tôi chỉ có thể sử dụng React context API để lưu trữ mã thông báo ở trạng thái gốc của ứng dụng và làm cho nó có sẵn khi cần.

Tiếp theo, hãy tạo một tệp tạo AuthContext:

// client/src/util/context.js
import { createContext } from "react";

export const AuthContext = createContext(null);

Và sau đó chúng tôi sẽ tạo một StateProviderHOC sẽ render ở root của ứng dụng - nó sẽ chịu trách nhiệm giữ và cập nhật trạng thái xác thực.

// client/src/containers/StateProvider.js
import React, { useEffect, useState } from "react";
import { withApollo } from "react-apollo";
import Cookies from "js-cookie";
import { refreshSocket } from "util/apollo";
import { AuthContext } from "util/context";

const StateProvider = ({ client, socket, children }) => {
  const [token, setToken] = useState(Cookies.get("token"));
  const [userId, setUserId] = useState(Cookies.get("userId"));

  // If the token changed (i.e. the user logged in
  // or out), clear the Apollo store and refresh the
  // websocket connection.
  useEffect(() => {
    if (!token) client.clearStore();
    if (socket) refreshSocket(socket);
  }, [token]);

  const setAuth = (data) => {
    if (data) {
      const { id, token } = data;
      Cookies.set("token", token);
      Cookies.set("userId", id);
      setToken(token);
      setUserId(id);
    } else {
      Cookies.remove("token");
      Cookies.remove("userId");
      setToken(null);
      setUserId(null);
    }
  };

  return (
    <AuthContext.Provider value={{ token, userId, setAuth }}>
      {children}
    </AuthContext.Provider>
  );
};

export default withApollo(StateProvider);

Có rất nhiều thứ đang diễn ra ở đây. Đầu tiên, chúng ta tạo ra trạng thái cho cả tokenuserIdcủa người sử dụng chứng thực. Chúng tôi khởi tạo trạng thái đó bằng cách đọc cookie, vì vậy chúng tôi có thể giữ cho người dùng đăng nhập trên các trang mới. Sau đó, chúng tôi thực hiện setAuthchức năng của chúng tôi . Nếu nó được gọi với nullthì nó sẽ đăng xuất người dùng; nếu không, nó đăng nhập người dùng với được cung cấp tokenuserId. Dù bằng cách nào, nó cập nhật cả trạng thái cục bộ và cookie.

Có một thách thức lớn với xác thực và liên kết websocket Apollo. Tôi đã khởi tạo websocket bằng cách sử dụng tham số mã thông báo nếu được xác thực hoặc không có mã thông báo nếu người dùng đã đăng xuất. Nhưng khi trạng thái xác thực thay đổi, chúng ta cần đặt lại kết nối websocket để khớp. Nếu người dùng bắt đầu đăng xuất và sau đó đăng nhập, Tôi cần đặt lại websocket để được xác thực bằng mã thông báo mới của họ, để họ có thể nhận được cập nhật trực tiếp cho các hoạt động đăng nhập như các cuộc trò chuyện. Nếu họ bắt đầu đăng nhập và sau đó đăng xuất, chúng tôi cần đặt lại websocket để không được xác thực, vì vậy họ không tiếp tục nhận được cập nhật websocket cho tài khoản mà họ không đăng nhập. Điều này hiện thực hóa ra rất khó - không có giải pháp nào được ghi chép rõ ràng và tôi phải mất vài giờ để tìm ra thứ gì đó hiệu quả.

// client/src/util.apollo.js
export const refreshSocket = (socket) => {
  socket.phoenixSocket.disconnect();
  socket.phoenixSocket.channels[0].leave();
  socket.channel = socket.phoenixSocket.channel("__absinthe__:control");
  socket.channelJoinCreated = false;
  socket.phoenixSocket.connect();
};

Nó ngắt kết nối Plug Phoenix, rời khỏi kênh Phoenix hiện tại để cập nhật GraphQL, tạo một kênh Phoenix mới (có cùng tên với kênh mặc định mà Abisnthe tạo khi thiết lập), đánh dấu rằng kênh này chưa được tham gia (vì vậy, absinthe tham gia lại kênh trên kết nối), và sau đó kết nối lại Plug. Plug Phoenix được cấu hình để tự động tìm kiếm mã thông báo trong cookie trước mỗi kết nối, do đó, khi kết nối lại, nó sẽ sử dụng trạng thái xác thực mới. Tôi thấy bực bội vì không có giải pháp tốt cho những gì có vẻ như là một vấn đề phổ biến, nhưng với một số nỗ lực thủ công, tôi đã làm cho nó hoạt động tốt.

Cuối cùng, useEffecttrong StateProvidersẽ giúp refreshSocket. Đối số thứ hai [token], bảo React đánh giá lại hàm mỗi khi tokengiá trị thay đổi. Nếu người dùng vừa đăng xuất, chúng tôi cũng gọi client.clearStore()để đảm bảo rằng máy khách Apollo không tiếp tục truy vấn bộ đệm ẩn chứa dữ liệu quyền, như các cuộc hội thoại hoặc tin nhắn của người dùng.

Và đó là khá nhiều tất cả là dành cho client.

Testing — client side

Với jest (bao gồm mặc định trong create-react-app); jest là một trình chạy test khá đơn giản và trực quan cho JavaScript. Nó cũng bao gồm một số tính năng nâng cao như intuitive test runner. Nó cung cấp một API đơn giản khuyến khích bạn render và thực hiện các Component theo quan điểm của người dùng (mà không đi sâu vào chi tiết triển khai của các Component). Ngoài ra, các phần trợ giúp của nó khéo léo thúc đẩy bạn để đảm bảo các Component của bạn có thể truy cập được, vì thật khó để có thể xử lý một nút DOM để tương tác với nó nếu nút thường không thể truy cập theo cách nào đó.

Chúng ta sẽ bắt đầu với một test đơn giản về Component Loading . Thành phần này chỉ hiển thị một số HTML tải tĩnh nên thực sự không có logic nào để kiểm tra; Tôi chỉ muốn đảm bảo rằng HTML hiển thị như mong đợi.

// client/src/components/Loading.test.js
import React from "react";
import { render } from "react-testing-library";
import Loading from "./Loading";

describe("Loading", () => {
  it("renders correctly", () => {
    const { container } = render(<Loading />);
    expect(container.firstChild).toMatchSnapshot();
  });
});

Khi bạn gọi .toMatchSnapshot(), jest sẽ tạo một tệp __snapshots__/Loading.test.js.snapđể ghi lại trạng thái hiện tại. Các lần chạy thử tiếp theo sẽ so sánh đầu ra với snapshot được ghi lại và không test nếu snapshot không khớp. Tệp snapshot trông như thế này:

// client/src/components/__snapshots__/Loading.test.js.snap
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Loading renders correctly 1`] = `
<div
  class="d-flex justify-content-center"
>
  <div
    class="spinner-border"
    role="status"
  >
    <span
      class="sr-only"
    >
      Loading...
    </span>
  </div>
</div>
`;

Trong trường hợp này, kiểm tra ảnh chụp nhanh không thực sự hữu ích, vì HTML không bao giờ thay đổi - mặc dù nó phục vụ để xác nhận rằng thành phần hiển thị không có lỗi. Trong các trường hợp nâng cao hơn, kiểm tra snapshot có thể rất hữu ích để đảm bảo rằng bạn chỉ thay đổi đầu ra component khi bạn định làm như vậy - ví dụ: nếu bạn đang cấu trúc lại logic bên trong thành phần của mình nhưng hy vọng đầu ra không thay đổi, snapshot test sẽ cho bạn biết nếu bạn đã phạm sai lầm.

Tiếp theo, chúng ta hãy xem xét một test cho một thành phần được kết nối Apollo. Đây là nơi mọi thứ trở nên phức tạp hơn một chút; thành phần này dự kiến ​​sẽ có một Apollo client trong ngữ cảnh của nó và chúng ta cần mô phỏng các truy vấn để đảm bảo thành phần xử lý chính xác các phản hồi.

// client/src/components/Posts.test.js
import React from "react";
import { render, wait } from "react-testing-library";
import { MockedProvider } from "react-apollo/test-utils";
import { MemoryRouter } from "react-router-dom";
import tk from "timekeeper";
import { Subscriber } from "containers";
import { AuthContext } from "util/context";
import Posts, { GET_POSTS, POSTS_SUBSCRIPTION } from "./Posts";

jest.mock("containers/Subscriber", () =>
  jest.fn().mockImplementation(({ children }) => children),
);

describe("Posts", () => {
  beforeEach(() => {
    tk.freeze("2019-04-20");
  });

  afterEach(() => {
    tk.reset();
  });

  // ...
});

Bắt đầu với một số import và mock. Mock là để ngăn Component  Posts được subscription  trừ khi tôi muốn. Đây là một lĩnh vực mà tôi đã có rất nhiều thất vọng - Apollo có tài liệu để mocking các query và mutation, nhưng không nhiều bằng cách mocking subscription và tôi thường gặp phải các lỗi nội bộ khó hiểu, khó theo dõi. Tôi chưa bao giờ có thể tìm ra cách giả mạo các truy vấn một cách đáng tin cậy khi tôi chỉ muốn thành phần thực hiện truy vấn ban đầu của nó (và không mock nó để nhận được cập nhật từ đăng ký của nó).

Mặc dù vậy, chúng cực kỳ hữu ích cho các trường hợp như thế này. Tôi có một Subscriberthành phần thường gọi subscribeToNewprop sau khi nó gắn kết và sau đó trả về các con của nó:

// client/src/containers/Subscriber.js
import { useEffect } from "react";

const Subscriber = ({ subscribeToNew, children }) => {
  useEffect(() => {
    subscribeToNew();
  }, []);

  return children;
};

export default Subscriber;

Vì vậy, trong test của mình, tôi chỉ mock việc thực hiện thành phần này để trả lại cho children mà không cần gọi subscribeToNew.

Cuối cùng, tôi đang sử dụng timekeeperđể đóng băng thời gian xung quanh mỗi bài kiểm tra - Component Posts hiển thị một số văn bản dựa trên mối quan hệ của thời gian đăng bài với thời gian hiện tại (ví dụ: hai ngày trước, trước đó), vì vậy tôi cần đảm bảo bài kiểm tra luôn chạy thời gian trên cùng một thời gian, hoặc các ảnh chụp nhanh sẽ thường xuyên fail khi thời gian trôi qua.

// client/src/components/Posts.test.js
// ...

describe("Posts", () => {
  // ...

  it("renders correctly when loading", () => {
    const { container } = render(
      <MemoryRouter>
        <AuthContext.Provider value={{}}>
          <MockedProvider mocks={[]} addTypename={false}>
            <Posts />
          </MockedProvider>
        </AuthContext.Provider>
      </MemoryRouter>,
    );
    expect(container).toMatchSnapshot();
  });

  // ...
});

Test đầu tiên của tôi kiểm tra loading state. Chúng ta phải gói nó trong một vài HOC - MemoryRoutercung cấp bộ định tuyến mô phỏng cho bất kỳ Link nào và Route React Router nào ; AuthContext.Providertrong đó cung cấp trạng thái xác thực; và MockedProvidertừ Apollo. Vì chúng tôi đang snapshot và quay lại, chúng tôi thực sự không cần phải mock bất cứ điều gì; snapshot sẽ chỉ chụp loading state trước khi Apollo có cơ hội thực hiện truy vấn.

// client/src/components/Posts.test.js
// ...

describe("Posts", () => {
  // ...

  it("renders correctly when loaded", async () => {
    const mocks = [
      {
        request: {
          query: GET_POSTS,
        },
        result: {
          data: {
            posts: [
              {
                id: 1,
                body: "Thoughts",
                insertedAt: "2019-04-18T00:00:00",
                user: {
                  id: 1,
                  name: "John Smith",
                  gravatarMd5: "abc",
                },
              },
            ],
          },
        },
      },
    ];
    const { container, getByText } = render(
      <MemoryRouter>
        <AuthContext.Provider value={{}}>
          <MockedProvider mocks={mocks} addTypename={false}>
            <Posts />
          </MockedProvider>
        </AuthContext.Provider>
      </MemoryRouter>,
    );
    await wait(() => getByText("Thoughts"));
    expect(container).toMatchSnapshot();
  });

  // ...
});

Đối với thử nghiệm này, tôi snapshot màn hình sau khi tải xong và bài viết đang được hiển thị. Đối với điều này, tôi phải thực hiện thử nghiệm của mình asyncvà sau đó sử dụng react-testing-library waitđể chờ tải kết thúc. wait(() => ...)sẽ đơn giản thử lại chức năng cho đến khi nó không bị lỗi. Khi văn bản đã xuất hiện, chúng tôi snapshot toàn bộ thành phần để đảm bảo đó là những gì mong đợi.

// client/src/components/Posts.test.js
// ...

describe("Posts", () => {
  // ...

  it("renders correctly after created post", async () => {
    Subscriber.mockImplementation((props) => {
      const { default: ActualSubscriber } = jest.requireActual(
        "containers/Subscriber",
      );
      return <ActualSubscriber {...props} />;
    });

    const mocks = [
      {
        request: {
          query: GET_POSTS,
        },
        result: {
          data: {
            posts: [
              {
                id: 1,
                body: "Thoughts",
                insertedAt: "2019-04-18T00:00:00",
                user: {
                  id: 1,
                  name: "John Smith",
                  gravatarMd5: "abc",
                },
              },
            ],
          },
        },
      },
      {
        request: {
          query: POSTS_SUBSCRIPTION,
        },
        result: {
          data: {
            postCreated: {
              id: 2,
              body: "Opinions",
              insertedAt: "2019-04-19T00:00:00",
              user: {
                id: 2,
                name: "Jane Thompson",
                gravatarMd5: "def",
              },
            },
          },
        },
      },
    ];
    const { container, getByText } = render(
      <MemoryRouter>
        <AuthContext.Provider value={{}}>
          <MockedProvider mocks={mocks} addTypename={false}>
            <Posts />
          </MockedProvider>
        </AuthContext.Provider>
      </MemoryRouter>,
    );
    await wait(() => getByText("Opinions"));
    expect(container).toMatchSnapshot();
  });
});

Cuối cùng, test subcription, để đảm bảo các subcruption của các Component như mong đợi khi nhận được bài đăng mới. Trong trường hợp này, chúng ta cần cập nhật bản Subscriptiongiả để nó thực sự trả về việc thực hiện ban đầu và subcruptin Component để cập nhật. Chúng tôi cũng giả định một POSTS_SUBSCRIPTIONtruy vấn để mô phỏng đăng ký nhận được một bài đăng mới. Cuối cùng, tương tự như bài kiểm tra cuối cùng, chúng tôi chờ các truy vấn giải quyết (và văn bản từ bài đăng mới xuất hiện) và sau đó chụp nhanh HTML.

jest và Reac-tests-library rất mạnh mẽ và giúp bạn dễ dàng thực hiện các thành phần.  Apollo test có một chút rắc rối, nhưng với một số cách sử dụng khôn ngoan, tôi đã có thể viết một bài kiểm tra khá chắc chắn, thực hiện tất cả các trường hợp thành phần chính.

Server-side rendering

Có thêm một vấn đề với client app. Tất cả HTML được hiển thị ở phía client. HTML được trả về từ máy chủ chỉ là một index.htmltệp trống có <script>thẻ để tải JavaScript thực sự kết xuất mọi thứ. Điều này rất tốt trong development, nhưng không tốt cho production - ví dụ, nhiều công cụ tìm kiếm không tuyệt vời trong việc chạy JavaScript và lập chỉ mục nội dung do khách hàng kết xuất. Những gì chúng tôi thực sự muốn là server trả về HTML được hiển thị đầy đủ cho trang và sau đó React có thể đảm nhận phía client để xử lý các tương tác và routing của người dùng tiếp theo.

Đây là nơi xuất hiện khái niệm Server-side rendering (hoặc SSR). Về cơ bản, thay vì phục vụ tệp chỉ mục HTML tĩnh, chúng tôi route các yêu cầu đến máy chủ Node.js. Máy chủ render các Component (giải quyết bất kỳ truy vấn nào đến điểm cuối GraphQL) và trả về HTML đầu ra, cùng với <script>thẻ để tải JavaScript. Khi JavaScript tải trên máy khách, nó sẽ hydrat hóa thay vì hiển thị hoàn toàn từ đầu - có nghĩa là nó giữ HTML hiện có do máy chủ cung cấp và kết nối nó với cây React phù hợp. Cách tiếp cận này cho phép các công cụ tìm kiếm dễ dàng lập chỉ mục HTML kết xuất của máy chủ và cũng cung cấp trải nghiệm nhanh hơn cho người dùng vì họ không phải chờ JavaScript tải xuống, thực thi và chạy truy vấn trước khi hiển thị nội dung trang.

Tôi thấy cấu hình SSR là một thứ gì đó vẫn khó khăn - không có gì được chuẩn hóa tốt. Tôi đã gỡ bỏ hầu hết cấu hình của ứng dụng của mình từ cra-ssr , nó cung cấp triển khai SSR khá toàn diện cho các ứng dụng được khởi động với create-react-app. Tôi sẽ không đào sâu quá ở đây. Chỉ cần nói rằng SSR là tuyệt vời và làm cho ứng dụng cảm thấy cực kỳ nhanh để tải, nhưng làm cho nó hoạt động vẫn còn một chút khó khăn.

Kết luận và đánh giá

Cảm ơn vì đã đọc đến đây! Có rất nhiều nội dung ở đây. Nếu bạn có thắc mắc, vui lòng để lại trong phần bình luận và tôi sẽ làm tốt nhất để trả lời.

Tôi đã gặp rất nhiều khó khăn khi tìm cách lấy websocket để xác định lại kết nối của nó khi thay đổi trạng thái xác thực; cảm giác này giống như một trường hợp khá phổ biến nên được ghi lại ở đâu đó, nhưng tôi không thể tìm thấy gì. Tương tự, tôi gặp nhiều rắc rối xung quanh việc test subscription, và cuối cùng đã từ bỏ và sử dụng mock. Các tài liệu xung quanh việc test là tuyệt vời, nhưng tôi thấy nó còn nông cạn khi tôi bắt đầu làm việc thông qua các trường hợp sử dụng nâng cao hơn. Đôi khi tôi cũng bị bối rối vì thiếu tài liệu API cơ bản, một phần rơi vào Apollo và một phần trên thư viện phía client của absinthe. Khi nghiên cứu cách đặt lại kết nối websocket, tôi không thể tìm thấy bất kỳ tài liệu API nào cho các trường hợp Plug absinthe hoặc các trường hợp liên kết Apollo chẳng hạn; Tôi về cơ bản chỉ cần đọc qua tất cả các mã nguồn trên GitHub. Trải nghiệm của tôi với Apollo tốt hơn nhiều so với trải nghiệm của tôi với Relay vài năm trước - nhưng lần sau khi tôi sử dụng nó, tôi vẫn sẽ tự chuẩn bị một chút cho thực tế rằng tôi sẽ cần dành thời gian

Với tất cả những gì đã nói, trên toàn bộ tôi cho điểm này rất cao, và tôi thực sự thích làm việc trong dự án này. Elixir và Phoenix được làm mới để sử dụng, nhưng tôi thực sự thích một số tính năng ngôn ngữ của Elixir như pattern matching và pipe operator. Elixir có rất nhiều ý tưởng mới (và nhiều khái niệm từ function programing) giúp bạn dễ dàng viết mã đẹp. Absinthe được triển khai rất tốt với tài liệu âm thanh và nó bao gồm tất cả các trường hợp sử dụng hợp lý xung quanh việc triển khai GraphQL server. Nhìn chung, tôi thấy rằng GraphQL mang đến rất nhiều hứa hẹn. Thật dễ dàng để truy vấn dữ liệu tôi cần cho mỗi trang và cũng dễ dàng kết nối các bản cập nhật trực tiếp thông qua subcription. Tôi luôn thích làm việc với React và React Router, và lần này cũng không khác - chúng giúp dễ dàng xây dựng các giao diện người dùng phức tạp. Cuối cùng, tôi rất hài lòng với kết quả chung - với tư cách là người dùng, ứng dụng của tôi rất nhanh để tải và điều hướng và mọi thứ cập nhật trực tiếp nên tôi không bao giờ không đồng bộ. Nếu thước đo cuối cùng của ngăn xếp công nghệ là trải nghiệm người dùng mang lại kết quả, thì tôi muốn nói rằng sự kết hợp này là một thành công lớn.

Tài liệu tham khảo