[Elixir] Dataloader를 가져와서 GraphiQL의 N+1 문제를 해결합니다

Elixir Phoenix에 GraphiQL 서버를 설치할 때Absinthe의 표준은그러나 다른 언어의 시행 방식과 마찬가지로 어떤 대책도 취하지 않으면 곧 N+1 문제에 부닥칠 수 있다.
이 기사는 N+1 문제를 해결하기 위해 데이터loader를 가져오는 절차를 소개한다.
!
데이터로드러가 뭔지 기사 검색하면 이 기사가 많이 나오니까.자세한 내용은 참조로 구현된 JavaScript의 웨어하우스 등에 관한 기사를 참조하십시오.
  • https://github.com/graphql/dataloader
  • https://dev.classmethod.jp/articles/graphql-dataloader-sample/
  • 샘플 항목


    여기에는 잠시 이쪽 기사에 실린 코드가 놓여 있습니다.
    https://github.com/koga1020/phx-dataloader-sample
    샘플 생성 명령을 입력하고 있습니다.
    $ mix phx.new phx-dataloader-sample --app dataloader_sample --no-html --database sqlite3
    $ mix phx.gen.context Blog Post posts title:string body:text
    $ mix phx.gen.context Blog Comment comments body:text post_id:references:posts
    

    테이블 정의


    투고에 여러 논평을 덧붙이는 단순한 구성으로 추진된다.
    image
    seed로 여러 개의commeent와post가 연관된 상황을 신속하게 제작합니다.faker 사용하면 편리해요.
    alias DataloaderSample.Blog.Comment
    alias DataloaderSample.Blog.Post
    
    Enum.each(1..5, fn _ ->
      post =
        %Post{title: Faker.Lorem.sentence(), body: Faker.Lorem.paragraph()}
        |> DataloaderSample.Repo.insert!()
    
      Enum.each(1..5, fn _ ->
        %Comment{body: Faker.Lorem.paragraph(), post_id: post.id} |> DataloaderSample.Repo.insert()
      end)
    end)
    

    문제가 발생하려고 시도하다


    우선 N+1에서 발생한 실복을 살펴보자.schema의 실현은 다음과 같다.해석기는 phx.gen.context 생성된 함수만 두드린다.
    schema.ex
    defmodule DataloaderSampleWeb.Schema do
      use Absinthe.Schema
    
      alias DataloaderSampleWeb.BlogResolver
    
      object :post do
        field :id, non_null(:id)
        field :title, non_null(:string)
        field :body, non_null(:string)
    
        field :comments, non_null(list_of(:comment)) do
          resolve(fn post, _, _ ->
            comments = Ecto.assoc(post, :comments) |> DataloaderSample.Repo.all()
            {:ok, comments}
          end)
        end
      end
    
      object :comment do
        field :id, non_null(:id)
        field :body, non_null(:string)
      end
    
      query do
        field :posts, non_null(list_of(non_null(:post))) do
          resolve(&BlogResolver.list_posts/3)
        end
      end
    end
    
    blog_resolver.ex
    defmodule DataloaderSampleWeb.BlogResolver do
      alias DataloaderSample.Blog
    
      def list_posts(_, _, _) do
        {:ok, Blog.list_posts()}
      end
    end
    
    모든 평론을 얻기 위해query를 발행합니다.
    {
      posts {
        title
        body
        comments {
          id
          body
        }
      }
    }
    
    로그에서 보듯이comments표에posts가 발행된 기록에 대해 여러 차례 조회했다.
    [debug] QUERY OK source="posts" db=0.0ms idle=673.2ms
    SELECT p0."id", p0."body", p0."title", p0."inserted_at", p0."updated_at" FROM "posts" AS p0 []
    ↳ DataloaderSampleWeb.BlogResolver.list_posts/3, at: lib/dataloader_sample_web/resolvers/blog_resolver.ex#5
    [debug] QUERY OK source="comments" db=0.1ms queue=0.1ms idle=707.0ms
    SELECT c0."id", c0."body", c0."post_id", c0."inserted_at", c0."updated_at" FROM "comments" AS c0 WHERE (c0."post_id" = ?) [1]
    ↳ anonymous fn/3 in DataloaderSampleWeb.Schema.__absinthe_function__/2, at: lib/dataloader_sample_web/schema.ex#13
    [debug] QUERY OK source="comments" db=0.1ms idle=709.2ms
    SELECT c0."id", c0."body", c0."post_id", c0."inserted_at", c0."updated_at" FROM "comments" AS c0 WHERE (c0."post_id" = ?) [2]
    ↳ anonymous fn/3 in DataloaderSampleWeb.Schema.__absinthe_function__/2, at: lib/dataloader_sample_web/schema.ex#13
    [debug] QUERY OK source="comments" db=0.1ms idle=299.9ms
    SELECT c0."id", c0."body", c0."post_id", c0."inserted_at", c0."updated_at" FROM "comments" AS c0 WHERE (c0."post_id" = ?) [3]
    ↳ anonymous fn/3 in DataloaderSampleWeb.Schema.__absinthe_function__/2, at: lib/dataloader_sample_web/schema.ex#13
    [debug] QUERY OK source="comments" db=0.1ms idle=280.4ms
    SELECT c0."id", c0."body", c0."post_id", c0."inserted_at", c0."updated_at" FROM "comments" AS c0 WHERE (c0."post_id" = ?) [4]
    ↳ anonymous fn/3 in DataloaderSampleWeb.Schema.__absinthe_function__/2, at: lib/dataloader_sample_web/schema.ex#13
    [debug] QUERY OK source="comments" db=0.1ms idle=37.5ms
    SELECT c0."id", c0."body", c0."post_id", c0."inserted_at", c0."updated_at" FROM "comments" AS c0 WHERE (c0."post_id" = ?) [5]
    ↳ anonymous fn/3 in DataloaderSampleWeb.Schema.__absinthe_function__/2, at: lib/dataloader_sample_web/schema.ex#13
    
    이 상태에서 투고 수, 평론 수가 증가하면 성능이 악화된다.

    데이터 가져오기 도우미


    일부러 N+1을 깨워도 문제 해결을 위해 Dataloader 라이브러리를 가져왔습니다.
    https://github.com/absinthe-graphql/dataloader
    mix.exs
      defp deps do
        [
          # ... 略
          {:dataloader, "~> 1.0.0"}
        ]
      end
    
    $ mix deps.get
    
    deps에 추가한 후 schema 파일을 수정합니다.해야 할 일은 대략 다음과 같은 세 가지가 있다.
  • :loader의 키로 context에 데이터 로더 구조체 추가
  • Absinthe.Middleware.Dataloaderplugens
  • 에 추가
  • context.middleware에서 loader
  • 사용
  • N+1이 발생한 Resolver 부분을 Absinthe.Resolution.Helpers.dataloader/1로 다시 쓰기
  • schema.ex
    defmodule DataloaderSampleWeb.Schema do
      use Absinthe.Schema
    
      # Helpersをimport。dataloader/1 を呼べるようにする
      import Absinthe.Resolution.Helpers
    
      alias DataloaderSample.Blog
      alias DataloaderSampleWeb.BlogResolver
    
      object :post do
        field :id, non_null(:id)
        field :title, non_null(:string)
        field :body, non_null(:string)
    
        field :comments, non_null(list_of(:comment)) do
          # dataloader/1を実行。Dataloader.add_source/3の第2引数に指定したものと同じ値(source)を指定
          resolve(dataloader(Blog))
        end
      end
    
      # ... 略
    
      # context/1を追加(Absinthe.Schemaマクロでdefoverridableに指定されている)
      def context(ctx) do
        loader =
          Dataloader.new()
          |> Dataloader.add_source(Blog, Blog.data())
    
        Map.put(ctx, :loader, loader)
      end
    
      # plugins/0を追加(Absinthe.Schemaマクロでdefoverridableに指定されている)
      def plugins() do
        [Absinthe.Middleware.Dataloader] ++ Absinthe.Plugin.defaults()
      end
    end
    
    blog.ex
    defmodule DataloaderSample.Blog do
      @moduledoc """
      The Blog context.
      """
    
      import Ecto.Query, warn: false
      alias DataloaderSample.Repo
    
      alias DataloaderSample.Blog.Post
    
      # Dataloader.add_source/3 で利用するdata/0を実装
      def data() do
        Dataloader.Ecto.new(DataloaderSample.Repo, query: &query/2)
      end
    
      def query(queryable, _params) do
        queryable
      end
    
     # ...略
    end
    
    Dataloader.new/0loader를 제작하는 부분에서absinthehexdocs의 예법에 따라 다음과 같은 함수로 나뉜다.
  • Blog 상하문의 모듈을 데이터 원본으로 지정
  • Phoenix 응용 프로그램에서 상하문에 따라 원본을 만들 수 있음참고 자료

  • In a Phoenix application you'll generally have one source per context, so that each context can control how its data is loaded.
  • data/0, 그리고 반환Dataloader.Ecto.new/2의 결과.Dataloader에서 사용할 쿼리 지정
  • 데이터 원본을 상하문에 따라 분류함으로써load에서 실행하는 조회를 더욱 쉽게 확장할 수 있기 때문에 이러한 분류 방법이 비교적 좋다고 생각합니다.
      def query(Post, %{has_admin_rights: true}), do: Post
    
      def query(Post, _), do: from p in Post, where: is_nil(p.deleted_at)
    
      def query(queryable, _), do: queryable
    
    resolve(dataloader(Blog))만 쓰면field명:comments에서 포스트구조체에서 자란has_many: :comments, Comment까지 연결해 해결할 수 있다.이거 영리하네.
    https://hexdocs.pm/absinthe/dataloader.html#usage
    In this example, the query returned by query/2 is used as a starting point by Dataloader to build the final query, which it does by traversing schema associations. In other words, Dataloader can determine that an author has many posts, and that to retrieve posts it needs to get those with the relevant author_id. If that's sufficient for your needs, query/2 need not modify the query it's given. But if you only want to load published posts, query/2 can narrow the query accordingly.
      schema "posts" do
       # ... 略
        has_many :comments, Comment
      end
    
    실제 행동을 봐라.같은query를 던져봐, 1~5의post 발견나는 id가 하나의 select로 정리되었다는 것을 안다.이렇게 N+1 문제가 해결되었습니다!
    [debug] QUERY OK source="posts" db=0.1ms queue=0.1ms idle=874.9ms
    SELECT p0."id", p0."body", p0."title", p0."inserted_at", p0."updated_at" FROM "posts" AS p0 []
    ↳ DataloaderSampleWeb.BlogResolver.list_posts/3, at: lib/dataloader_sample_web/resolvers/blog_resolver.ex#5
    [debug] QUERY OK source="comments" db=0.3ms idle=923.1ms
    SELECT c0."id", c0."body", c0."post_id", c0."inserted_at", c0."updated_at", c0."post_id" FROM "comments" AS c0 WHERE (c0."post_id" IN (?,?,?,?,?)) ORDER BY c0."post_id" [5, 4, 3, 2, 1]
    ↳ Dataloader.Source.Dataloader.Ecto.run_batch/2, at: lib/dataloader/ecto.ex#680
    

    총결산


    Absinthe를 사용하는 GraphiQL 서버의 설치에서 데이터loader를 가져와 N+1을 제거하는 절차에 대한 간단한 샘플을 소개했다.뇌사로 이뤄지면 N+1 벽에 부딪히기 쉬우므로 연기가 악화하지 않도록 함께 지내면서 시행했으면 좋겠다.
    영어책이지만 다음 책의 chapter9에서 데이터로드러의 기술을 배울 수 있다.관심 있는 사람은 꼭 봐야 한다.
    https://pragprog.com/titles/wwgraphql/craft-graphql-apis-in-elixir-with-absinthe/

    좋은 웹페이지 즐겨찾기