[RAILS] GraphQL Ruby and actual development

This article is the 19th day article of GLOBIS Advent Calendar 2020.

We use Ruby on Rails + GraphQL for the back end and React for the client side to develop new services. On the backend side, GraphQL Ruby is used as a library. I will write that I have actually incorporated GraphQL Ruby into development.

Why did you choose GraphQL + React?

The new service we are currently developing uses GraphQL on the server side and React.js on the front end.

At Globis, the service side mainly adopted React.js as the front end, and both knowledge and resources were sufficient to secure the development speed of service launch. Ruby on Rails is used for the server side, and Swagger-based API and Grape gem-based REST Like API have been implemented for the API, but problems due to the REST API discussed in various places and , Separation from the use case in the Ruby on Rails side view that was used from the time of launch, Swaggerfile that is not maintained, etc. were problems.

GraphQL is an all-you-can-learn Globis part, and Globis Unlimited is actively using it, knowledge has been accumulated on the front end side, high affinity with TS when combined with Apollo client, development experience is front There was recognition from the end that it was popular. Variable Front End The ability to manually assemble data can be a factor in accelerating development.

It was adopted with the expectation that GraphQL would be a more efficient API design when compared with the advantages and disadvantages of using Swagger.

disclaimer

It should be noted that this technology selection was made in August 2019. As of December 2020, it will be necessary to carefully consider whether to adopt such a configuration when selecting technology.

About repository configuration

In the past in-house services, the front-end and server-side repositories were separated, but we asked the front-end engineers to make a mono-repo configuration.

There are two reasons for this. The first is that if the front desk and the server are separated as they are now, almost all the contact costs will be paid as the contact costs for the server and front interface, but this cost should be pushed into one repository. The purpose is to minimize it. The second reason is to minimize the error on the front side that can be expected to be caused by the deployment timing of the front and the server being out of sync, and the contact cost of "to keep the breath" of the deployment timing. However, the second reason is that in the case of SPA for existing servers and services, the server side is stable, so it seems that the development speed can be sufficiently maintained even if the front side repository is separated. Monorepo is a problem-solving because it is a new product, so when referring to this article, carefully consider whether the target service is a new service or an existing service (whether it is an unstable interface or a stable interface). Please give me.

Actual repository configuration

The actual repository configuration is as follows.

.
├── CHANGELOG.md
├── Dockerfile
├── Gemfile
├── Gemfile.lock
├── Procfile
├── README.md
├── app/
├── app.json
│   ├── controllers/
│   ├── forms/
│   ├── graphql/
│   │   ├── mutations/
│   │   ├── resolvers/
│   │   └── types/
│   ├── jobs/
│   ├── mailers/
│   ├── models/
│   ├── policies/
│   ├── uploaders/
│   └── views/
├── bin/
├── buildspec.yaml
├── codegen.yml
├── config/
│   ├── environments/
│   ├── initializers/
│   ├── locales/
│   └── settings/
├── config.ru
├── db/
│   ├── migrate/
│   └── seeds/
├── docker-compose.yml
├── lib/
│   ├── assets/
│   └── tasks/
├── node_modules/
├── package.json
├── packages/  ----------------------------  (1)
│   ├── controlpanel/
│   │   ├── index.js
│   │   ├── node_modules/
│   │   ├── package.json
│   │   └── webpack.config.ts
│   └── client/
│       ├── App.tsx
│       ├── api/
│       ├── assets/
│       ├── babel.config.js
│       ├── components/
│       ├── constants/
│       ├── containers/
│       ├── graphql/
│       ├── index.html
│       ├── index.less
│       ├── index.tsx
│       ├── node_modules/
│       ├── package.json
│       ├── routes/
│       ├── test-helpers/
│       ├── types/
│       ├── utils/
│       └── webpack.config.ts
├── prettier.config.js
├── public/
│   ├── packs/
│   ├── static/
│   │   └── images
│   └── uploads/
├── regconfig.json
├── renovate.json
├── schema.graphql
├── schema.json
├── ship.config.js
├── spec/
├── tsconfig.json
├── vendor/
└── yarn.lock

The place marked (1) is the front code. In this project, we have stopped using sprockets, and all front assets are managed by the stack on the front end side. To achieve this, we use yarn workspace, and the controlpanel directory is the asset on the management screen side created by Rails, and the client side is the asset on the service side.

About GraphQL Ruby directory structure

The directory structure of GraphQL is as follows.

app/graphql
├── schema.rb
├── mutations/
├── resolvers/
└── types/

Changes from Getting Started

You can find sample code in the Getting Started Build a Schema section (https://graphql-ruby.org/getting_started#build-a-schema). As far as I can see in this code, I'm trying to define the root type directly in query_type.rb, but sooner or later I can see that this design breaks down. This design may be fine if it's as simple as having a blog and commenting, but in reality the system we maintain is more complex.

QueryType

The definition of QueryType is as simple as the following.

Custom Resolver is not officially recommended, but it is implemented because it has great advantages in terms of readability and prevention of QueyType bloat.

module Types
  class QueryType < Types::BaseObject
    field :user, resolver: Resolvers::UserResolver
    field :hoge, resolver: Resolvers::HogeResolver
    field :huga, resolver: Resolvers::HugaResolver
  end
end

resolvers

resolvers directory

module Resolvers
  class UserResolver < Resolvers::BaseResolver
    description 'Find an User by ID. Require ID'

    argument :id, ID, required: true

    type Types::UserType, null: false

    def resolve(id:)
      _class_name, id = GraphQL::Schema::UniqueWithinType.decode(id)
      User.find!(id)
    end
  end
end

types

User-defined types are placed in types, and the definitions are as follows. The method for facing N + 1 and the settings related to authorization are described later, so please refer to that as well.

module Types
  class UserType < Types::BaseObject
    field :id,           ID, null: false
    field :name,         String, null: false
    field :comments, Types::Comments.connection_type, null: false do
      argument :comment_id, ID, required: false, loads: Types::CommentType, as: :comment
    end

    def comments(comment_id:)
        #Implementation of comments
    end
  end
end

Schema-first development

GraphQL Ruby itself adopts the code-first concept, but in our actual development, we adopt the schema-first concept in which the front end and back end develop based on the schema.

Since it is a new development, basically it is necessary to work on both the back end and front end to add functions. At the beginning of development, schema.graphql is edited in cooperation with the front end and back end to determine the schema to be aimed at. schema.graphql is a text file and easy to read on GitHub, so it is easy to recognize it in common.

Before this method, we had a meeting in the form of adding XX field to XX Type in chat, but by actually writing while writing it in code, it became easier to recognize and share the whole feeling. It was.

In development, schema.graphql is the most abstracted part, and both the front end and the back end are implemented toward this abstraction.

Schema design basic policy

I wrote that the GraphQL schema is an abstract one that depends on both the client and the backend. It is also important to move toward the abstract side when designing the schema. For example, if you have a type definition called UserType, on the backend


# Table name: users
#
#  id                   :bigint(8)        not null, primary key
#  name                 :string(255)      not null
#  role                 :integer(4)       default("member"), not null

# Table name: user_passwords
#
#  id                         :bigint(8)        not null, primary key
#  email                      :string(255)      not null
#  encrypted_password         :string(255)      default(""), not null

Although various tables (models) are combined

When making GraphQL, it should be combined into UserType so that it is intuitive for the client.

module Types
  class UserType < Types::BaseObject
    field :id,           ID, null: false
    field :email,        String, null: true
    field :name,         String, null: false
    field :role,         Types::UserRoleType, null: false

By doing this, the client will be able to issue queries intuitively without being aware of the DB configuration.

Specific countermeasures for the N + 1 problem

Graphql-batch is a well-known solution to the N + 1 problem in GraphQL Ruby, but we use a gem called batch-loader. The reason is that graphql-batch depends on a poorly maintained gem called Promise, and batch-loader is a gem that is easy to understand with less dependence and less actual code base. I will.

The principle is based on the idea of ​​lazy evaluation, and it reads and caches the value while caching the call using Proc # source_location as the key.

For the actual operation, it is recommended that the explanation is easy for the Gem author to understand. https://speakerdeck.com/exaspark/batching-a-powerful-way-to-solve-n-plus-1-queries

Specifically, it is an example of the part where N + 1 is likely to occur and how to deal with it. For example, if there is the following Type

module Types
  class FavoriteType < Types::BaseObject
    field :id,           ID, null: false
    field :user,        Types::UserType, null: false
    field :post,         Types::PostType, null: false

It's easy to see that the inside is a relational table, but a Type that has a field that returns a Type associated with another table like this will easily generate N + 1 when called from somewhere as a collection. .. It's important to keep in mind that the code for a Type doesn't tell you what this Type is called. In GraphQL Ruby type definition, it is easy for the caller as field to notice the N + 1 problem when calling it as a collection, but it is better to place the implementation that actually solves N + 1 in the called Type. Information that connects multiple tables with a type is more knowledge-intensive code if only that type knows. It's a good idea to think about what happens to the DB when the Type is called from the outside as a collection.

The code for the solution when using BatchLoader is as follows.

def user
	BatchLoader.for(object.user_id).batch do |user_ids, loader|
	   User.where(id: user_ids).each { |user| loader.call(user.id, user) }
	end
end

There is a slight quirk in the notation, but it is flexible because you can freely preload in the block. The difficulty is that it depends on the location of the source code, so modifying it to make it easier for users will require another device. Currently, there are many codes like the above under Types.

Way of thinking about approval

Using GraphQL :: Pro

graphql-ruby has a paid pro license, and one of its features is support for integration with Pundit gem. Here are some tips to make good use of this integration,

Serialize ObjectId

GraphQL :: Pro allows you to apply Policy files to ActiveRecord instances as a pundit integration. In the item Authtorizing Loaded Objects of the corresponding document, there is a notation "Mutations can automatically load and authorize objects by ID using the loads: option", and it was specified in the type definition by passing the type definition to the loads option. The Pundit Policy file is automatically applied.

In order to take advantage of this integration, you need to assign an ActiveRecord instance directly from the object's ID, but with the default settings, the ActiveRecord ID is passed to the client side as it is, and this ID is used when querying to the server side. I will end up. You need to have all this information in your ID, because you need to uniquely assign an ActiveRecord instance by ID only.

It is not necessary if the system can issue a unique ID for all models, but this time it is not realistic because MySQL over ActiveRecord is used as the persistence layer.

You can find the sample code in the document object_identification.md in the graphql-ruby repository, and use this as a reference to serialize and deserialize the value that combines the ActiveRecord type name and ID. It has a unique ID.

Also, there was a problem that loads: option works only in the input type field, but when I wanted to use this method of passing from ObjectId to pundit in the argument of a normal field, issue I found it, so I sent a patch. This allows you to use the loads option without having to define the InputType.

module Types
  class UserType < Types::BaseObject
    field :finished, Boolean, null: false do
      argument :courseid, ID, required: false, loads: Types::CourseType
    end

Error notification

First of all, make sure that no error occurs when you hit the QueryType field other than authorization. We will also minimize authorization errors and basically deal with it by narrowing the scope.

The reason is that GraphQL is good because it can be freely assembled by looking at the schema, but if you put it in a state where an error may occur when you hit XX field of XX Type, the side that assembles the query will do it. You need to remember. The automatically generated documents will be useless, and the purpose of comfortable development of the front end will be defeated. So basically, many Mutaions return errors.

Basically, it takes the form of notifying an error from rescue_from using GraphQL :: Execution :: Errors. I referred to this article and used the function added in this article for how to return the error.


class MyProductSchema < GraphQL::Schema
  use GraphQL::Execution::Errors

	rescue_from ActiveRecord::RecordInvalid do |err, _obj, _args, _ctx, _field|
	    raise GraphQL::ExecutionError.new(
	      err,
	      {
	        extensions:
	                    {
	                      code:      'INVALID_INPUT',
	                      exception: { **err.record&.errors&.details, object: err.record.class&.name },
	                    },
	      },
	    )
	 end

Official Error Handling Article also mentions that GraphQL :: Execution :: Errors is the default.

What you want to try

I haven't actually implemented it, but I would like to try it by setting the Mutation return value to QueyType so that the client can freely assemble the return value, as shown in the following article.

https://medium.com/@danielrearden/a-better-refetch-flow-for-apollo-client-7ff06817b052

Add a refetch field to the Payload to map the QueyType.


type CreateIssuePayload {
  clientMutationId: String
  refetch: Query!
}

This can be reproduced in GraphQL-Ruby by writing as follows

module Mutations
    class CreateIssue < BaseMutation
      argument :title, String, required: true
      argument :description, String, required: true
      field :refetch, Types::QueryType, null: false

      def resolve(title:, description:)
        #Mutation implementation

        {refetch: Types::QueryType }
      end
    end
  end
end

Since the client side can freely design the return value of Mutation, it may be possible to reduce the number of times the function is hit.

Summary

GraphQL-Ruby is a large gem and complex to implement. The maintainers are also active and new features are added every day. It seems that there are still many features that we have not fully used. The purpose is "to make it easier to develop products", so I would like to implement it accordingly.

Recommended Posts

GraphQL Ruby and actual development
Ruby and Gem
[Ruby] Classes and instances
Symbols and Destructive Ruby
[Ruby] Big Decimal and DECIMAL
Formal and actual arguments
Ruby classes and instances
Ruby inheritance and delegation
Ruby variables and methods
GraphQL Client starting with Ruby
Ruby syntax errors and countermeasures
About Ruby hashes and symbols
Ruby C extension and volatile
Summarize Ruby and Dependency Injection
About Ruby and object model
[Ruby] Singular methods and singular classes
About Ruby classes and instances
Ruby variables and functions (methods)
Ruby methods and classes (basic)
[Ruby] REPL-driven development with pry
Creating Ruby classes and instances
[Ruby] Singular methods and singular classes
Create a development environment for Ruby 3.0.0 and Rails 6.1.0 on Ubuntu 20.04.1 LTS
[Ruby] Difference between get and post
[Ruby] present/blank method and postfix if.
[Ruby] Difference between is_a? And instance_of?
Ruby standard input and various methods
About Ruby single quotes and double quotes
[Rails 6] API development using GraphQL (Query)
Run GraphQL Ruby resolver in parallel
Ruby Study Memo (Test Driven Development)
[Ruby] then keyword and case in
[Ruby basics] split method and to_s method
About Ruby product operator (&) and sum operator (|)
Write keys and values in Ruby
[Ruby] If and else problems-with operators-
[Ruby] Boolean values ​​and logical operators
Project ruby and rails version upgrade
About object-oriented inheritance and about yield Ruby
Ruby on Rails ✕ Docker ✕ MySQL Introducing Docker and docker-compose to apps under development