본문 바로가기

배움의 즐거움/프로그래밍

(31) Graphql-ruby - 필드(fields) 의 모든 것

반응형




Introduction


객체 필드는 해당 객체에 대한 데이터나 다른 객체와의 연결에 대한 데이터를 보여준다. field(...) 클래스 메서드를 사용해서 객체에 필드를 추가할 수 있다.

field :name, String, "The unique name of this list", null: false

Objects 와 Interfaces 는 필드를 가진다.

필드 정의는 아래와 같은 요소들을 갖는다.


Field Return Type

field(...) 의 두번째 argument 는 리턴 타입이다. 이 것은 다음과 같은 것이 될 수있다.:

  • 내장된 GraphQL 타입 (IntegerFloatStringID, 또는 Boolean)
  • 어플리케이션 내의 GraphQL 타입
  • 위의 두 가지 것들의 배열 형태, 이는 list type 에 자세히 명시되어 있다.

Nullability 는 null: 키워드를 사용해서 반드시 표시되어야 한다.

  • null: true 는 해당 필드가 nil 을 리턴할 수도 있음을 뜻한다.
  • null: false 는 해당 필드가 nil 이 될 수 없음을 뜻한다. 만약 nil을 리턴한다면 GraphQL-Ruby 는 클라이턴트에게 에러를 리턴할 것이다.

추가적으로, 리스트 타입은 [..., null: true] 을 추가함으로써 nil이 될 수도 있다.([..., null: false]  이 디폴트 값임)

예시는 다음과 같다.(더 자세히 보려면 여기)

field :name, String, null: true # `name`은 `String` 또는 `nil` 를 리턴할 수 있다.
field :id, ID, null: false # `ID!`는 항상`ID` 를 리턴하고 `nil`이 될 수 없다
field :teammates, [Types::User], null: false # `[User!]!` 은 항상` User` 의 리스트를 리턴한다.
field :scores, [Integer, null: true], null: true # `[Int]`, 리스트 또는 `nil`을 리턴할 수 있다. 리스트는 `Integer` 또는`nil` 을 섞어서 가질 수 있다.


Field Documentation

필드는 필드에 대한 설명과 해당 필드가 더 이상 쓰이지 않음을 명시할 수 있다.

설명 부분은 field(...) 메서드에 positional argument 로써 인라인으로 추가될 수 있으며, 또는 블럭 안에서 keyword argument 로 추가될 수 있다.

# 3rd positional argument
field :name, String, "The name of this thing", null: false

# `description:` keyword
field :name, String, null: false,
  description: "The name of this thing"

# inside the block
field :name, String, null: false do
  description "The name of this thing"
end


더 이상 쓰이지 않는 필드 deprecation_reason:  라는 keyword argument를 추가함으로써 명시해 줄 수 있다.

field :email, String, null: true,
  deprecation_reason: "Users may have multiple emails, use `User.emails` instead."

deprecation_reason: 를 가진 필드는 GraphQL 에서 “deprecated” 라고 표시될 것이다.


Field Resolution

일반적으로 GraphQL 리턴 타입에 따라 필드는 Ruby 값을 리턴한다. 예를 들어 리턴 타입으로 String 을 갖는 필드는 Ruby String 을 리턴해야 하고 [User!]! 리턴 타입 를 갖는 필드는 반드시 User 객체 또는 비어있는 Ruby 배열을 리턴해야 한다.


기본적으로, 필드는 다음과 같은 방법으로 값을 반환한다. 

  • 해당 객체에 메서드를 호출하려 시도함으로써 또는,
  • 만약에 기본을 이루는 객체가 Hash일 경우, 그 hash 안에서 키 값을 검색함으로써


메서드 이름 또는 hash 키가 필드 이름과 일치한다면, 다음과 예시를 보면

field :top_score, Integer, null: false

기본적으로 이 것은 #top_score 메서드를 찾으려 할 것이다. 또는 :top_score (심볼) 또는 "top_score" (문자열)과 같은  Hash 키를 찾으려 할 것이다.

메서드 이름은 method: 키워드를 사용해서 오버라이드 할 수 있다. 또는 hash 키는 hash_key: 키워드를 사용해서 오버라이드 할 수 있다.

# `#best_score` 메서드를 사용해서 해당 필드를 resolve 할 수 있다
field :top_score, Integer, null: false,
  method: :best_score

# 해당 필드를 resolve 하기 위해 `hash["allPlayers"]` 를 찾을 것이다
field :players, [User], null: false,
  hash_key: "allPlayers"


만약 기본 객체를 위임하고 싶지 않다면, 각 필드에 대해 메서드를 정의하면 된다.

# 해당 필드를 resolve 하기 위해 하위에 커스텀 메서드 사용
field :total_games_played, Integer, null: false

def total_games_played
  object.games.count
end

이 메서드 안에서 몇가지 헬퍼 메서드를 사용할 수 있다.

  • object 는 현재 접근하려하는 객체이다.(이전의 obj)
  • context 는 쿼리 context 이다( 쿼리가 실행될 때 context: 로써 전달됨, 이전의 ctx)


추가적으로, 아래와 같이 argument 를 정의할 때에는 다음과 같이 메서드에 전달된다.

# 전달되는 arguments 와 함께 커스텀 메서드를 호출
field :current_winning_streak, Integer, null: false do
  argument :include_ties, Boolean, required: false, default_value: false
end

def current_winning_streak(include_ties:)
  # 필요한 로직 작성
end


Field Arguments

Arguments 는 필드가 인풋을 받아 resolve 하는데 사용할 수 있도록 한다. 예를 들어

  • search()  필드는 term: argument를 받을 수 있다. 이는 search(term: "GraphQL") 처럼 검색을 하는데 사용될 수 있다.
  • user() 필드는 id: argument 를 받아 해당 id 에 일치하는 사용자를 찾을 수 있다. 예) user(id: 1)
  • attachments() 필드는type: argument 를 받아 결과를 파일 타입에 따라 필터링 할 수 있다. 예) attachments(type: PHOTO)

더 자세한건 아래 Arguments 에서 다뤄진다.


Extra Field Metadata

필드 메서드 안에서는 GraphQL-Ruby 런타임에서 low-level 객체에 접근하는 것이 가능하다. 주의해야 할 점은 이 API 들은 변경될 수 있으므로 업데이트를 할 때 로그를 체크하길 바란다.

다음과 같은 것들이 가능하다

  • irep_node
  • ast_node
  • parent, 부모 field context
  • execution_errors, 에러 메시지를 추가하기 위해서  #add(err_or_msg) 메서드를 반드시 사용해야 함


필드 메서드에 삽입하기 위해 먼저 extras: 옵션을 필드 정의에 추가한다.

field :my_field, String, null: false, extras: [:ast_node]


그리고 ast_node: 키워드를 메서드 시그니처에 추가한다

def my_field(ast_node:)
  # ...
end

런타임 시에 요청된 객체는 필드로 전해질 것이다.

커스텀 extras 또한 가능하다. extras: [...] 에 어떠한 필드 클래스의 메서드든 전달 할 수 있으며, 값은 메서드로 전해질 것이다. 


Field Parameter Default Values

필드 메서드는 해당 필드가 null인지 아닌지를 결정하기 위해서 null: 키워드를 전달할 것을 요구한다. 오버라이드 할 만한 또 다른 camelize 필드는 이고, 이는 기본적으로 true 값을 가진다. 이는 커스텀 필드를 추가함으로써 오버라이드 할 수 있다.

class CustomField < GraphQL::Schema::Field
  # argument 에 아무 것도 전해지지 않을 것을 대비하여
  # `null: false` 와 `camelize: false` 를 추가한다.
  # **kwargs 는 그 외의 것들을 캐치하게 된다.
  def initialize(*args, null: false, camelize: false, **kwargs, &block)
    # Then, call super _without_ any args, where Ruby will take
    # _all_ the args originally passed to this method and pass it to the super method.
    super
  end
end



Instrumentation


필드 instrumentation 은 스키마 정의에 추가될 수 있다.

MySchema = GraphQL::Schema.define do
  instrument(:field, FieldTimerInstrumentation.new)
end

instrumenter 은 #instrument(type, field)에 응답하는 객체이다. #instrument 는 반드시 GraphQL::Field인스턴스를 리턴해야 한다. #instrumen 는 스키마의 모든 객체 타입과 인터페이스 타입에 대한 각 타입-필드 쌍과 함께 호출된다.

여기 필드 instrumenter에 대한 예시가 있다.

class FieldTimerInstrumentation
  # If a field was flagged to be timed,
  # wrap its resolve proc with a timer.
  def instrument(type, field)
    if field.metadata[:timed]
      old_resolve_proc = field.resolve_proc
      new_resolve_proc = ->(obj, args, ctx) {
        Rails.logger.info("#{type.name}.#{field.name} START: #{Time.now.to_i}")
        resolved = old_resolve_proc.call(obj, args, ctx)
        Rails.logger.info("#{type.name}.#{field.name} END: #{Time.now.to_i}")
        resolved
      }

      # Return a copy of `field`, with a new resolve proc
      field.redefine do
        resolve(new_resolve_proc)
      end
    else
      field
    end
  end
end

이 것은 위와 같이 추가될 수 있다. redefine { ... } 를 사용해서 GraphQL::Field 의 카피본을 만들어 이 정의를 extend 할 수 있다.

GraphQL::Field#lazy_resolve_proc 또한 instrument 될 수 있다. 이 것은 lazy execution에 등록된 객체에 대해 호출된다.

(이 부분은 무슨말인지 모르겠다)


Limits

List Fields

항상 리스트 필드에서 반환되는 아이템의 개수를 제한하라. 예를 들어 limit: argument 를 사용하고 이 값이 너무 크지 않도록 해야 한다. prepare: 함수는 아이템의 개수를 제한하는데 유용하다.

field :items, Types::ItemType do
  # Cap the number of items at 30
  argument :limit, Integer, default_value: 20, prepare: ->(limit, ctx) {[limit, 30].min}
end

def items(limit:)
  object.items.limit(limit)
end

이렇게 한다면 1000개의 아이템을 가져오기 위해 데이터베이스에 접근 하는 일은 없을 것이다.


Relay Connections

Relay connection 은 max_page_size 옵션을 받는데 이는 노드의 개수를 제한한다.


Resolvers

GraphQL::Schema::Resolver 은 필드 resolve 에 대한 로직을 담고 있다. 이 것은 resolver:키워드를 사용해서 필드에 연결할 수 있다.

# 해당 필드를 실행하기 위해서 resolver 클래스 사용
field :pending_orders, resolver: PendingOrders

내부적으로는 GraphQL::Schema::Mutation 은  Resolver 의 특화된 서브클래스 이다.


First, ask yourself …

정말 Resolver이 필요한가? Resolver 를 추가하는 것은 몇 가지 단점이 있다.

  • GraphQL과 결합되어 있기 때문에 단순한 Ruby 객체보다 테스트 하는게 어렵다.
  • GraphQL-Ruby 의 베이스 클래스 이기 때문에 만약 업데이트가 있다면 너의 코드도 업데이트 해야할 수도 있다.


여기 몇가지 대안이 있다.

  • 보여주는 로직(예를 들어 분류나 정렬)을 앱의 Ruby 클래스 내에 넣고 해당 클래스를 테스트 한다.
  • 해당 객체와 메서드를 연결한다. 예를 들어
field :recommended_items, [Types::Item], null: false
def recommended_items
  ItemRecommendation.new(user: context[:viewer]).items
end
  •  공유해야 할 argument 가 많다면, 필드를 생성하기 위해서 클래스 메서드를 사용하라. 예를 들면
# 필터되거나 정렬된 아이템의 리스트를 리턴하는 필드를 생성한다
def self.items_field(name, override_options)
  # Prepare options
  default_field_options = { type: [Types::Item], null: false }
  field_options = default_field_options.merge(override_options)
  # 필드 생성
  field(name, field_options) do
    argument :order_by, Types::ItemOrder, required: false
    argument :category, Types::ItemCategory, required: false
    # Allow an override block to add more arguments
    yield if block_given?
  end
end

# 필드를 생성하기 위해 generator 사용
items_field(:recommended_items) do
  argument :similar_to_product_id, ID, required: false
end
# 필드 구현
def recommended_items
  # ...
end

해당 클래스 메서드는 모듈에 넣은 뒤 다른 클래스들 사이에서 공유될 수 있다.

  • 만약 여러 객체들 사이에서 공유되는 같은 로직이 필요하다면, Ruby 모둘과 self.included  후크를 사용하는 것을 고려해 볼 수 있다. 예를 들어
module HasRecommendedItems
  def self.included(child_class)
    # attach the field here
    child_class.field(:recommended_items, [Types::Item], null: false)
  end

  # then implement the field
  def recommended_items
    # ...
  end
end

# Add the field to some objects:
class Types::User < BaseObject
  include HasRecommendedItems # adds the field
end
  •  만약 모듈을 사용하는 방법이 괜찮다고 생각된다면 Interfaces 를 고려해 보는것도 좋다. Interfaces 역시 객체간에 행동을 공유하고 introspection 을 통해서 공통된 점을 클라이언트에게 보여준다.


언제 정말로 resolver 가 필요한가?

만약 더 나은 옵션이 있다면 왜 Resolver 가 존재하는가? 여기 몇개의 장점들이 있다.

  • 고립시킴. Resolver 은 필드의 각 호출을 인스턴스화 한다. 그러므로 이것의 인스턴스 변수는 해당 객체에 대해 private 하다. 만약 어떠한 이유에서 인스턴스 변수들를 사용해야 한다면 이것은 도움이 될 것이다. 해당 작업이 끝나면 이 값들은 더이상 존재하지 않게 된다.
  • 복잡한 스키마 생성 RelayClassicMutation (Resolver 의 서브클래스) 은 각 muation 마다 인풋 타입과 리턴 타입을 생성한다. Resolver 클래스를 사용하는 것은 이것의 구현을 쉽게 할 수 있도록 하며 이 코드 생성 로직을 공유하거나 확장할 수 있게끔 한다.


Using resolver

resolver 을 추가하기 위해서 먼저 베이스 클래스를 생성해라.

# app/graphql/resolvers/base.rb
module Resolvers
  class Base < GraphQL::Schema::Resolver
    # if you have a custom argument class, you can attach it:
    argument_class Arguments::Base
  end
end


그리고 필요 시 이를 확장하라

module Resolvers
  class RecommendedItems < Resolvers::Base
    type [Types::Item], null: false

    argument :order_by, Types::ItemOrder, required: false
    argument :category, Types::ItemCategory, required: false

    def resolve(order_by: nil, category: nil)
      # call your application logic here:
      recommendations = ItemRecommendation.new(
        viewer: context[:viewer],
        recommended_for: object,
        order_by: order_by,
        category: category,
      )
      # return the list of items
      recommendations.items
    end
  end
end


이를 필드에 추가

class Types::User < Types::BaseObject
  field :recommended_items,
    resolver: Resolvers::RecommendedItems,
    description: "Items this user might like"
end

Resolver lifecycle 이 GraphQL 런타임에 따라 관리되기 때문에 테스트 하는 가장 좋은 방법은 GraphQL 쿼리를 실행해서 결과를 검사하는 것이다..


Arguments

필드는 arguments 를 인풋으로 받을 수 있다. argument 는 리턴되는 값을 결정하거나(예를 들어 필터링) 어플리케이션 상태를 수정하는데(예를 들어 mutation 을 이용하여 데이터베이스 업데이트) 사용될 수 있다.

Arguments 는argument 헬퍼를 이용하여 정의된다. 이러한 argment 는 keyword arguments 로써 resolver 메서드에 전달된다.

field :search_posts, [PostType], null: false do
  argument :category, String, required: true
end

def search_posts(category:)
  Post.where(category: category).limit(10)
end

 이러한 argument 를 optional 으로 변경하기 위해서 required: false 를 사용하면 된다. 

field :search_posts, [PostType], null: false do
  argument :category, String, required: false
end

def search_posts(category: nil)
  if category
    Post.where(category: category).limit(10)
  else
    Post.all.limit(10)
  end
end

 

만약 모든 argument 가 optional 이고 쿼리가 어떠한 argument 도 제공하지 않을 때, resolver 메서드는 argument 없이 호출된다. 이러한 경우에 를 ArgumentError 방지하기 위해서 모든 키워드 argument 에 대해서 기본 값을 제공하거나(위의 예시처럼) double splat 기호(**) 를 사용해야 한다.

def search_posts(**args)
  if args[:category]
    Post.where(category: args[:category]).limit(10)
  else
    Post.all.limit(10)
  end
end


또 다른 방법은 쿼리에서 값이 제공되지 않았을 경우를 대비해서 default_value: value 를 사용하여 기본 값 제공하는 것이다.

field :search_posts, [PostType], null: false do
  argument :category, String, required: false, default_value: "Programming"
end

def search_posts(category:)
  Post.where(category: category).limit(10)
end


as: :alternate_name 를 사용하여 클라이언트에게 보여주는 키와는 다른 이름으로 변경하여 사용할 수 있다.

field :post, PostType, null: false do
  argument :post_id, ID, required: true, as: :id
end

def post(id:)
  Post.find(id)
end


prepare 함수를 사용하여 필드의 resolver 메서드를 실행하기 전에 argument 값을 수정하거나 유효성 검사를 할 수 있다.

field :posts, [PostType], null: false do
  argument :start_date, String, required: true, prepare: ->(startDate, ctx) {
    # return the prepared argument or GraphQL::ExecutionError.new("msg")
    # to halt the execution of the field and add "msg" to the `errors` key.
  }
end

def posts(start_date:)
  # use prepared start_date
end


snake_cased 로 작성된 Arguments 는 스키마에서 camelCase 로 변경된다.

field :posts, [PostType], null: false do
  argument :start_year, Int, required: true
end


이에 상응하는 GraphQL 쿼리는 다음과 같을 것이다.

{
  posts(startYear: 2018) {
    id
  }
}


이러한 auto-camelization 을 끄기 위해서는 camelize: false 를 argument 메서드에 전달하면 된다.

field :posts, [PostType], null: false do
  argument :start_year, Int, required: true, camelize: false
end


추가적으로 만약 너의 argument 가 이미 camelCase 일 때에는 GraphQL 스키마에서 그대로 camelCase로 남아있을 것이다. 그러나 그 argument는 resolver 메서드로 전달될 때에는 다시 snake_case로 전환될 것이다.

field :posts, [PostType], null: false do
  argument :startYear, Int, required: true
end

def posts(start_year:)
  # ...
end

 다음과 같은 타입만이 argument 로 사용될 수 있다.

* 해당 글은 번역기 돌리다가 크롬 번역기 말도 안되는 해석에 지친 본인이 나중에 참고할 의도로 대충대충 발로 해석한 것이니 참고용으로만 사용하시길 바랍니다.

* 출처: http://graphql-ruby.org/fields/introduction.html

* 출처: http://graphql-ruby.org/fields/introduction.html

* 출처: http://graphql-ruby.org/fields/limits.html

* 출처: http://graphql-ruby.org/fields/resolvers.html

* 출처: http://graphql-ruby.org/fields/arguments.html



반응형