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.
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.
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.
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.
The directory structure of GraphQL is as follows.
app/graphql
├── schema.rb
├── mutations/
├── resolvers/
└── types/
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
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.
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.
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.
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,
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
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.
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.
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