Статьи

Сервер GraphQL на Java, часть II: Понимание решателей

Узнайте больше о распознавателях в GraphQL и Java.

В первой части мы разработали простой сервер GraphQL. У этого решения был один серьезный недостаток: все поля были загружены на сервер, даже если они не были запрошены интерфейсом. Мы как бы принимаем эту ситуацию с услугами RESTful, не предоставляя клиентам никакого выбора.

RESTful API всегда возвращает все, что означает загрузку всего. С другой стороны, если вы разбиваете RESTful API на несколько меньших ресурсов, вы рискуете получить проблему N + 1 и множество сетевых обращений. GraphQL был специально разработан для решения этих проблем:

  • извлекать только необходимые данные, чтобы избежать дополнительного сетевого трафика, а также ненужной работы на сервере
  • позволяют получать столько данных, сколько необходимо клиенту за один запрос, чтобы уменьшить общую задержку


Вам также может понравиться:
Выход за пределы REST с GraphQL

API RESTful принимают произвольные решения относительно объема возвращаемых данных. Поэтому вряд ли удастся решить вышеупомянутые проблемы. Это либо чрезмерно, либо недостаточно.

Хорошо, это теория, но наша реализация сервера GraphQL не работает таким образом. Он по-прежнему выбирает все данные, независимо от того, были ли они запрошены или нет. Грустный.

Развитие вашего API

Чтобы резюмировать наш API возвращает экземпляр PlayerDTO:

@Value
class Player {
    UUID id;
    String name;
    int points;
    ImmutableList<Item> inventory;
    Billing billing;
}

Это соответствует этой схеме GraphQL:

type Player {
    id: String!
    name: String!
    points: Int!
    inventory: [Item!]!
    billing: Billing!
}

Тщательно профилировав наше приложение, я понял, что очень мало клиентов запрашивают billingв своих запросах, но мы всегда должны спрашивать billingRepository, чтобы создать Playerэкземпляр. Много нетерпеливой, ненужной работы:

private final BillingRepository billingRepository;
private final InventoryClient inventoryClient;
private final PlayerMetadata playerMetadata;
private final PointsCalculator pointsCalculator;

//...

@NotNull
private Player somePlayer() {
    UUID playerId = UUID.randomUUID();
    return new Player(
            playerId,
            playerMetadata.lookupName(playerId),
            pointsCalculator.pointsOf(playerId),
            inventoryClient.loadInventory(playerId),
            billingRepository.forUser(playerId)
    );
}

Поля как billingдолжны быть загружены только по запросу! Чтобы понять, как заставить некоторые части нашего графа объектов ( Graph -QL! Duh!) Загружаться лениво, давайте добавим новое свойство с именем trustworthinessa Player:

type Player {
    id: String!
    name: String!
    points: Int!
    inventory: [Item!]!
    billing: Billing!
    trustworthiness: Float!
}

Это изменение обратно совместимо. На самом деле, GraphQL на самом деле не имеет понятия о версии API. Каков тогда путь миграции? Есть несколько сценариев:

  • Вы по ошибке дали новую схему клиентам, не внедрив сервер. В этом случае клиент терпит неудачу быстро, потому что он запросил trustworthinessполе, которое сервер еще не способен доставить. Хороший. С другой стороны, с помощью RESTful API клиент полагает, что сервер вернет некоторые данные. Это может привести к неожиданным ошибкам или предположениям, которые сервер намеренно возвратил null(пропущенное поле)
  • Вы добавили trustworthinessполе, но не распространили новую схему. Хорошо. Клиенты не знают об этом, trustworthinessпоэтому они не просят об этом.
  • Вы раздали новую схему клиентам, когда сервер был готов. Клиенты могут или не могут использовать новые данные. Ничего страшного.

Но что, если вы допустили ошибку и объявили всем клиентам, что новая версия сервера поддерживает определенную схему, а на самом деле это не так? Другими словами, сервер притворяется, что поддерживает
trustworthiness, но он не знает, как рассчитать его при запросе. Это вообще возможно?
NO :

Caused by: [...]FieldResolverError: No method or field found as defined in schema [...] with any of the following signatures [...], in priority order:

  com.nurkiewicz.graphql.Player.trustworthiness()
  com.nurkiewicz.graphql.Player.getTrustworthiness()
  com.nurkiewicz.graphql.Player.trustworthiness

Это происходит при запуске сервера! Если вы измените схему без реализации базового сервера, она даже не загрузится! Это фантастические новости. Если вы объявите, что поддерживаете определенную схему, невозможно отправить приложение, которое не поддерживает. Это система безопасности при развитии вашего API.

You only deliver schema to clients when it’s supported on the server. And when the server announces certain schema, you can be 100 percent sure it’s working and properly formatted. No more missing fields in the response because you are asking the older version of the server. No more broken servers that pretend to support certain API version, whereas, in reality, you forgot to add a field to a response object.

Replacing Eager Value With Lazy Resolver

Alright, so how do I add trustworthiness to comply with the new schema? The not-so-smart tip is right there in the exception that prevented our application to start. It says it was trying to find a method, getter, or field for trustworthiness. If we blindly add it to the Player class, API would work. What’s the problem then? Remember, when changing the schema, old clients are unaware of trustworthiness.

New clients, even aware of it, may still never or rarely request it. In other words, the value of trustworthiness needs to be calculated for just a fraction of requests. Unfortunately, because trustworthiness is a field on a Player class, we must always calculate it eagerly. Otherwise, it’s impossible to instantiate and return the response object.

Interestingly with RESTful API, this is typically not a problem. Just load and return everything, let clients decide what to ignore. But we can do better.

First, remove trustworthiness field from Player DTO. We have to go deeper, I mean lazier. Instead, create the following component:

import com.coxautodev.graphql.tools.GraphQLResolver;
import org.springframework.stereotype.Component;

@Component
class PlayerResolver implements GraphQLResolver<Player> {

}

Keep it empty, the GraphQL engine will guide us. When trying to run the application one more time, the exception is familiar, but not the same:

FieldResolverError: No method or field found as defined in schema [...] with any of the following signatures [...], in priority order:

  com.nurkiewicz.graphql.PlayerResolver.trustworthiness(com.nurkiewicz.graphql.Player)
  com.nurkiewicz.graphql.PlayerResolver.getTrustworthiness(com.nurkiewicz.graphql.Player)
  com.nurkiewicz.graphql.PlayerResolver.trustworthiness
  com.nurkiewicz.graphql.Player.trustworthiness()
  com.nurkiewicz.graphql.Player.getTrustworthiness()
  com.nurkiewicz.graphql.Player.trustworthiness

trustworthiness is looked for not only on the Player class, but also on PlayerResolver that we just created. Can you spot the difference between these signatures?

  • PlayerResolver.getTrustworthiness(Player)
  • Player.getTrustworthiness()

Первый метод принимает
Playerв качестве аргумента, тогда как последний является методом экземпляра (getter) для
Playerсамого себя. Какова цель
PlayerResolver? По умолчанию каждый тип в вашей схеме GraphQL использует распознаватель по умолчанию. Этот преобразователь в основном берет экземпляр eg
Playerи исследует получатели, методы и поля.

Однако вы можете украсить этот распознаватель по умолчанию более изощренным, который может лениво вычислять поле для заданного имени, особенно когда такое поле отсутствует в Playerклассе. Самое главное, что этот распознаватель вызывается только тогда, когда клиент фактически запросил указанное поле. В противном случае мы возвращаемся к распознавателю по умолчанию, который ожидает, что все поля будут частью самого Playerобъекта.

So how do you implement a custom resolver for trustworthiness? The exception will guide you:

@Component
class PlayerResolver implements GraphQLResolver<Player> {

    float trustworthiness(Player player) {
        //slow and painful business logic here...
        return 42;
    }

}

Of course, in the real world, the implementation would do something clever. Take a Player, apply some business logic, etc. What’s really important is that if the client doesn’t want to know trustworthiness, this method is never called. Lazy! See for yourself by adding some logs or metrics. That’s right, metrics!

This approach also gives you great insight into your API. Clients are very explicit, asking only for necessary fields. Therefore, you can have metrics for each resolver and quickly figure out which fields are used and which are dead and can be deprecated or removed.

Also, you can easily discover which particular field is costly to load. Such fine-grained control is impossible with RESTful APIs, with their all-or-nothing approach. In order to decommission a field with RESTful API, you must create a new version of the resource and encourage all clients to migrate.

Lazy All the Things

If you want to be extra lazy and consume as little resources as possible, every single field of the Player may be delegated to the resolver. The schema remains the same, but the Player class becomes hollow:

@Value
class Player {
    UUID id;
}

So how does GraphQL know how to calculate name, points, inventory, billing, and trustworthiness? Well, there is a method on a resolver for each one of these:

@Component
class PlayerResolver implements GraphQLResolver<Player> {

    String name(Player player) {
        //...
    }

    int points(Player player) {
        //...
    }

    ImmutableList<Item> inventory(Player player) {
        //...
    }

    Billing billing(Player player) {
        //...
    }

    float trustworthiness(Player player) {
        //...
    }

}

The implementation is unimportant. What’s important is laziness: These methods are only invoked when a certain field was requested. Each of these methods can be monitored, optimized, and tested separately. Which is great from a performance perspective.

Performance Problem

Did you notice that inventory and billing fields are unrelated to each other? For example, fetching inventory may require calling some downstream service whereas billing needs an SQL query. Unfortunately, GraphQL engine assembles response in a sequential matter.

We’ll fix that in the next installment. Stay tuned!

Further Reading

GraphQL Java Example for Beginners Using Spring Boot

Moving Beyond REST With GraphQL

[DZone Refcard] An Overview of GraphQL