GraphQL будущее микросервисов?

Original author: Piotr Mińkowski
  • Translation
  • Tutorial
GraphQL часто представляют как революционный путь дизайна веб API по сравнению с REST. Однако, если вы ближе посмотрите на эти технологии, то вы увидите, что между ними очень много различий. GraphQL относительно новое решение, исходники которого были открыты сообществу Фейсбуком в 2015 году. Сегодня REST все еще самая популярная парадигма, используемая для предоставления API и взаимодействия между микросервисами. Сможет ли GraphQL обогнать REST в будущем? Давайте посмотрим, как происходит микросервисное взаимодействие через GraphQL API с ипользованием Spring Boot и библиотеки GQL.

Начнем с архитектуры примера нашей системы. Предположим, что у нас есть три микросервиса, которые общаются друг с другом через URL полученные из Spring Cloud Eureka приложения.

image

Включаем поддержку GraphQL в Spring Boot


Мы можем легко включить поддержку для GraphQL на серверной стороне приложения Spring Boot с помощью стартеров. После добавления graphql-spring-boot-starter сервлет GraphQL будет автоматически доступен по пути /graphql. Мы можем переопределить этот путь по умолчанию через указание свойства graphql.servlet.mapping в application.yml файле. Мы также включаем GraphiQL – браузерную IDE для написание, проверки и тестирования GraphQL запросов и библиотеку GraphQL Java Tools, которая содержит полезные компоненты для создания запросов и мутаций. Благодаря этой библиотеки все файлы в classpath с расширением .graphqls будут использованы для создания определения схемы.

compile('com.graphql-java:graphql-spring-boot-starter:5.0.2')
compile('com.graphql-java:graphiql-spring-boot-starter:5.0.2')

compile('com.graphql-java:graphql-java-tools:5.2.3')

Описание GrpahQL схемы


Каждое описание схемы содержит декларацию типов, связи между ними и множество операций включающих запросы для поиска объектов и мутаций для создания, обновления или удаления данных. Обычно мы начинаем с определения типа, который отвечает за домен описываемого объекта. Вы можете указать является ли поле обязательным с помощью ! символа или если это массив – […]. Описание должно содержать декларируемы тип или ссылку на другие типы доступные в спецификации.

type Employee {
  id: ID!
  organizationId: Int!
  departmentId: Int!
  name: String!
  age: Int!
  position: String!
  salary: Int!
}

Следующая часть определения схемы содержит декларации запросов и мутаций. Большинство запросов возвращают список объектов, которые помечены в схеме как [Employee]. Внутри типа EmployeeQueries мы объявляем все методы поиска, в то время как в типе EmployeeMutations методы для добавления, обновления и удаления сотрудников. Если вы передаете целый объект в метод, вы должны объявить его как input тип.

schema {
  query: EmployeeQueries
  mutation: EmployeeMutations
}
type EmployeeQueries {
  employees: [Employee]
  employee(id: ID!): Employee!
  employeesByOrganization(organizationId: Int!): [Employee]
  employeesByDepartment(departmentId: Int!): [Employee]
}
type EmployeeMutations {
  newEmployee(employee: EmployeeInput!): Employee
  deleteEmployee(id: ID!) : Boolean
  updateEmployee(id: ID!, employee: EmployeeInput!): Employee
}
input EmployeeInput {
  organizationId: Int
  departmentId: Int
  name: String
  age: Int
  position: String
  salary: Int
}

Реализация запросов и мутаций


Благодаря автоконфигурации GraphQL Java Tools и Spring Boot GraphQL нам не нужно прилагать много усилий для имплементации запросов и мутаций в нашем приложении. Бин EmployeesQuery должен реализовывать интерфейс GraphQLQueryResolver. Основываясь на этом, Spring сможет автоматически найти и вызывать правильный метод как ответ на один из GraphQL запросов, которые были декларированы внутри схемы. Вот класс, содержащий имплементацию ответов на запросы:

@Component
public class EmployeeQueries implements GraphQLQueryResolver {
    private static final Logger LOGGER = LoggerFactory.getLogger(EmployeeQueries.class);
     
    @Autowired
    EmployeeRepository repository;
     
    public List employees() {
        LOGGER.info("Employees find");
        return repository.findAll();
    }
     
    public List employeesByOrganization(Long organizationId) {
        LOGGER.info("Employees find: organizationId={}", organizationId);
        return repository.findByOrganization(organizationId);
    }
    public List employeesByDepartment(Long departmentId) {
        LOGGER.info("Employees find: departmentId={}", departmentId);
        return repository.findByDepartment(departmentId);
    }
     
    public Employee employee(Long id) {
        LOGGER.info("Employee find: id={}", id);
        return repository.findById(id);
    }
     
}

Если вы хотите вызывать, например, метод employee(Long id), напишите следующий запрос. Чтобы протестировать его в вашем приложении используйте GraphiQL, который доступен по адресу /graphiql.

image

Бину ответственному за имплементацию методов мутаций нужно реализовать интерфейс GraphQLMutationResolver. Несмотря на название EmployeeInput мы продолжаем использовать тот же доменный объект Employee, который возвращается запросом.

@Component
public class EmployeeMutations implements GraphQLMutationResolver {
    private static final Logger LOGGER = LoggerFactory.getLogger(EmployeeQueries.class);
     
    @Autowired
    EmployeeRepository repository;
     
    public Employee newEmployee(Employee employee) {
        LOGGER.info("Employee add: employee={}", employee);
        return repository.add(employee);
    }
     
    public boolean deleteEmployee(Long id) {
        LOGGER.info("Employee delete: id={}", id);
        return repository.delete(id);
    }
     
    public Employee updateEmployee(Long id, Employee employee) {
        LOGGER.info("Employee update: id={}, employee={}", id, employee);
        return repository.update(id, employee);
    }
     
}

И здесь мы используем GraphiQL для тестирования мутаций. Вот команда, которая добавляет новый employee и принимает ответ с id и name сотрудника.

image

На этом я приостанавливаю перевод данной статьи и пишу свое «лирическое отступление», а фактически подменяю описание части микросервисного взаимодейстивя через Apollo Client, на взаимодействие через библиотеку GQL и Unirest – библиотеки для выполнения HTTP запросов.


GraphQL клиент на Groovy.


Для создания GraphQL запросов в микросервисе department-servive я буду использовать Query builders:

String queryString = DSL.buildQuery {
    query('employeesByDepartment', [departmentId: departmentId]) {
        returns {
            id
            name
            position
            salary
        }
    }
}

Данная конструкция на DSL GQL создает запрос вида:

{  
  employeesByDepartment (departmentId: 1) { 
    id
    name
    position
    salary
  } 
}

И, далее, я буду выполнять HTTP запрос по переданному в метод адресу.
Как формируется адрес запроса узнаем далее.

(Unirest.post(serverUrl)
        .body(JsonOutput.toJson([query: queryString]))
        .asJson()
        .body.jsonObject['data']['employeesByDepartment'] as List)
        .collect { JsonUtils.jsonToData(it.toString(), Employee.class) }

После получения ответа выполняем его преобразование из JSONObject к виду списка Employee.

GrpahQL клиент для микросервиса сотрудников


Рассмотрим реализацию микросервиса сотрудников. В этом примере я пользовался Eureka клиентом напрямую. eurekaClient получает все запущенные экземпляры сервисов, зарегистрированные как employee-service. Затем он случайно выбирает какой-то экземпляр из зарегистрированных (2). Далее, берет номер его порта и формирует адрес запроса (3) и передает его в объект EmployeeGQL который является GraphQL клиентом на Groovy и, который, описан в предыдущем пункте.

@Component
public class EmployeeClient {

  private static final Logger LOGGER = LoggerFactory.getLogger(EmployeeClient.class);
  private static final String SERVICE_NAME = "EMPLOYEE-SERVICE";
  private static final String SERVER_URL = "http://localhost:%d/graphql";

  Random r = new Random();

  @Autowired
  private EurekaClient discoveryClient; // (1)

  public List<Employee> findByDepartment(Long departmentId) {
    Application app = discoveryClient.getApplication(SERVICE_NAME);
    InstanceInfo ii = app.getInstances().get(r.nextInt(app.size())); // (2)
    String serverUrl = String.format(SERVER_URL, ii.getPort()); // (3)
    EmployeeGQL clientGQL = new EmployeeGQL();
    return clientGQL.getEmployeesByDepartmentQuery(serverUrl, departmentId.intValue()); // (4)
  }

}

Далее, я «передаю» снова слово автору, точнее продолжаю перевод его статьи.

И наконец, EmployeeClient внедряется в класс который отвечает на запросы DepartmentQueries и используется внутри запроса departmentsByOrganizationWithEmployees.

public List<Department> departmentsByOrganizationWithEmployees(Long organizationId) {
  LOGGER.info("Departments find: organizationId={}", organizationId);
  List<Department> departments = repository.findByOrganization(organizationId);
  for (int i = 0; i < departments.size(); i++) {
    departments.get(i).setEmployees(employeeClient.findByDepartment(departments.get(i).getId()));
  }
  return departments;
}

Перед выполнением нужного запросы нам следуем взглянуть на схему созданную для department-service. Каждый объект Department может содержать список назначенных сотрудников, и мы также определили тип Employee на который ссылается тип Department.

schema {
  query: DepartmentQueries
  mutation: DepartmentMutations
}
type DepartmentQueries {
  departments: [Department]
  department(id: ID!): Department!
  departmentsByOrganization(organizationId: Int!): [Department]
  departmentsByOrganizationWithEmployees(organizationId: Int!): [Department]
}
type DepartmentMutations {
  newDepartment(department: DepartmentInput!): Department
  deleteDepartment(id: ID!) : Boolean
  updateDepartment(id: ID!, department: DepartmentInput!): Department
}
input DepartmentInput {
  organizationId: Int!
  name: String!
}
type Department {
  id: ID!
  organizationId: Int!
  name: String!
  employees: [Employee]
}
type Employee {
  id: ID!
  name: String!
  position: String!
  salary: Int!
}

Теперь мы можем вызвать наш тестовый запрос с списком нужных полей используя GraphiQL. Приложение department-service по умолчанию доступно на порте 8091, то есть мы можем увидеть его по адресу http://localhost:8091/graphiql

Заключение


Возможно, GraphQL может быть интересной альтернативой стандартным REST API. Однако, нам не следует рассматривать его как замену REST. Существуют несколько случаев, где GraphQL может быть лучшим выбором, но те, где лучшим выбором будет REST. Если вашим клиентам не нужно иметь все поля, возвращаемые серверной частью, и более того у вас есть много клиентов с разными требованиями для одной точки входа, то GraphQL хороший выбор. Если посмотреть на то, что есть в микросервисном сообществе, то можно увидеть, что сейчас там нет решение основанных на Java, которые позволяют вам использовать GraphQL вместе с обнаружением сервисов, балансировщиком или API gateway из коробки. В этой статье я показал пример использования GQL и Unirest для создания GraphQL клиента вместе с Spring Cloud Eureka для микросервисной коммуникации. Пример кода автора статьи на английском на GitHub github.com/piomin/sample-graphql-microservices.git.

Пример моего когда с библиотекой GQL: github.com/lynx-r/sample-graphql-microservices
Ads
AdBlock has stolen the banner, but banners are not teeth — they will be back

More

Comments 42

    –4
    Я бы говоря о graphql в первую очередь акцентировал внимание на тот момент что
    graphql это фактически стандарт
    graphql это система которая жёстко связывает программный код и документацию. Документация всегда и на 100% актуальна
    graphql позволяет без изменения на стороне бэкэнда делать запросы нескольких разнородных объектов в одном запроса, выбирать только необходимые для работы поля объектов, в том числе выбирать или не выбирать поля связанных объектов произвольной вложенности
      0
      Может я не понимаю, но мне кажется, что graphql, это всего-лишь описание языка запросов. facebook, ещё создал описание того, как рекомендуется реализовывать логику запросов на клиенте и логику выдачи на сервере. То есть, api как было, так и осталось, только теперь оно не на уровне https, а на программном уровне. Ведь один контроллер не может делать все и сразу, придется разбить логику на множество контроллеров. Но доступ к ним будет не через https, а черз один рутовый контроллер. Кроме того описание того, как работать с логикой, придумали уже давно, это клиентское и серверное orm. Разница только в том, что они не в функциональном стиле и их придумали не в facebook.

      Да, удобства от использования действительно есть. Но достигается оно за счет того, что из любого места приложения можно получить любые данные. То есть данные лежат в общей куче. В ооп, такое просто не допустимо. Но лично мне, как лежат данные, если их раскладываю не я лично, безразлично. Поэтомуон действительно делает работу проще и понятнее, при условии что есть готовые библиотеки, которые покрывают все потребности. Иначе придется окунутся в мир того, что только вчера придумали.
        –1
        Вы правильно поняли. Но это как раз то чего не хватает restapi описания то есть стандарта, пусть даже не утвержденного сообществом. Попытка создать стандарт для restapi это jsonapi. Если сравнить с graphql то graphql проще и мощнее. Плюс встроенная документация и даже консоль для тестовых запросов.
          0

          Есть swagger. Не знаю можно ли его рассматриватт как некий стандарт но штука мощная с консолью и документацией.

            +1
            Swagger который теперь openapi это стандарт. Но стандарт в котором они определяются сервисы отдельно от реализации. То есть swagger отчасти созвучен graphql но не гарантирует соответствие сервисов их описанию в то время как graphql описание сервисов неразрывно связано с их реализацией и не может в принципе разойтись. Кроме того будет обеспечена валидация входных и выходных параметров сервиса при их вызове на соответствие описанию.
          +3
          С orm все хорошо пока Вам нужно вернуть плоский объект. Когда нужно вернуть скажем объект со связями то сразу возникает вопрос. А нужно ли возвращать связанные объекты? А все ли связанные объекты нужно возвращать? А нужно ли возвращать связанные объекты со связанными объектами и так далее по рекурсии. Если например нужно вернуть более одного разнородного объекта то это уже для фрнймвкркам задача не тривиальная. Плюс документация. Никто не когда не сталкивался с устаревшей документацией?
            0
            Смешались в кучу, кони, люди…
            То есть, api как было, так и осталось, только теперь оно не на уровне https, а на программном уровне.

            API никогда и нигде не было на уровне https. Потому что https это транспортныый уровень, а не програмный. И graphql и rest и даже soap используют https для безопасного соединения, а не для реализации API.

            Но доступ к ним будет не через https, а черз один рутовый контроллер.

            В нормальном сервисе доступ всегда будет через https. А вот количество ендпоинтов в сервисе будет разным. В rest приложении будет много ендпоинтов, условно «на каждый случай». А в приложении с graphql будет один эндпоинт позволяющий получать различные данные.
              +3
              HTTP, несмотря на парадоксальную расшифровку букв в своей аббревиатуре, является прикладным протоколом, application layer.
                0
                API никогда и нигде не было на уровне https. Потому что https это транспортныый уровень, а не програмный. И graphql и rest и даже soap используют https для безопасного соединения, а не для реализации API.


                К сожалению, rest API строится именно на уровне http (можно без s). Так называемый HTTP REST — это надстройка над http, который строго говоря, именно программный уровень. Можно использовать http исключительно как транспортный уровень, но для этого приложение не должно знать тонкости реализации уровня http, например не обрабатывать http коды ответов, что противоречит «правильному» rest.
                  +1
                  К сожалению, rest API строится именно на уровне http (можно без s). Так называемый HTTP REST — это надстройка над http, который строго говоря, именно программный уровень. Можно использовать http исключительно как транспортный уровень, но для этого приложение не должно знать тонкости реализации уровня http, например не обрабатывать http коды ответов, что противоречит «правильному» rest.

                  Строго говоря, в оригинальном определении REST про правильные коды HTTP ничего нет, даже использование GET для запроса ресурса приводится лишь как пример. Все требования, о которых Вы пишете, придумали позднее.
                    +3
                    В статье идёт скорее всего не о rest а о restapi. Rest это принципы построения приложений которое мы используем даже тогда когда об этом не подозреваем. Restapi это конкретно н где строго не описанная и не стандартизированная фича которая реализует crud сервисы посредством http методов put, get, update, delete и как вишенка в торте patch. Поначалу этот restapi очаровывает своей простотой. Потом когда переходишь от функционала todoapp к реальным приложениям, начинаешь желать чего то более стандартизированного.
                    Кстати restapi не выполняет всех принципов rest ТК как правило полагается на данные сессии клиента которые есть ни что иное как хранение состояния приложения на сервере. Я уже не говорю о смешении транспортного протокола с протоколом уровня приложения. Например сходу разобрать чего нет на сервере получив 404 ответ невозможно. user/joe — 404 это в базе нет пользователя Джо или же нет url user ТК сервис расположено по url users?
                +2
                Может я не понимаю, но мне кажется, что graphql, это всего-лишь описание языка запросов

                а так же описание как эти запросы обрабатывать на стороне сервера. Другими словами это стандарт на API.


                То есть данные лежат в общей куче

                какие данные? чьи данные? в какой общей куче? На сервере у вас точно та же БД, что и была.


                В ооп, такое просто не допустимо.

                причем здесь ООП? Мы же про апи разговариваем.

                  0
                  я про клиент. реализации, которые я видел, на клиенте все данные хранят в общем хранилище и доступны в любой части программы по запросу.
                    0
                    State management никак не связан с REST или GraphQL.
                      0
                      я знаю что нет. и мне безразлично, что запросы к state manager выполняются путем строковых query, что просто распахивает ворота к ним для всех частей программы. главное удобно и быстро. Но не правильно! мне кажется, что эволюцию не должна деградировать. Это я относительно клиентской части говорю.Там так все таким образом эволюционирует, как-будто java и c# их ничему не научили.
                        0
                        Я во многом с Вами согласен, но тема то GraphQL. Понимаю, что фейсбук имеет отношение к появлению и того и другого, но все же.
                      0

                      как уже сказали, с помощью state management строятся современные js-приложения, работающие с api или не работающие с api.

                  +4

                  Ребята, а как разруливать права доступа в graphql?

                    0
                    Этот вопрос graphql не ркшает. Скорее всего его Фейсбук делает это разделение на другом уровне приложения. С разделением доступа насколько мы знаем у них всё в порядке. Друзей друзей не выдают. Кому не следует по api
                      +1
                      Если API у роли полностью отличается, то это делается отдельным инстансом graphql (например, для админки и для пользователя полностью отличаются входные/выходные данные).
                      Если АПИ более-менее похожий, то разруливается на уровне резолверов.

                      Если используете Apollo, то вот есть статья, где расписывается как они работает через директивы: www.apollographql.com/docs/guides/access-control.html
                        0
                        Я сейчас думаю так сделать, на каждую роль сгенерировать свою схему доступа к определенным ресурсам и полям ресурсов. И помере прохождения пользователя по уровням авторизации переключать схемы. Мне не хочется решать на уровне резолверов, тк злоумышленник сможет видеть всю схему. Да можно разделить на 2 части (паблик, админ). Я считаю — что дано ролью то и пользователь должен видеть. + на основе схемы можно сразу строить фронт, отключая определенные элементы ui в зависимости доступности ресурсов. К примеру — нет доступа на создание статьи — нет формы. Я вижу это самым перспективным. Еще можно реализовать crud на клиенте, если дать всем типам вменяемые название. user_create, user_find, user_update, user_remove — первая часть до последнего нижнего подчеркивает складывается и на клиенте можно сделать объект user с 4-мя методами. ИМХО
                          0
                          По поводу того что злоумышленник сможет видеть схему. Фейсбук опубликовал документацию на сервисы а которым доступ может получить и любое приложение и приложение после размещения клиента на доступ и по приложение которое запросило дополнительный доступ на Фейсбуке. Я лично такого никогда не получал думаю это не бесплатно. Так что схему в был все а доступ имеют не все.
                          Конечно есть у них и какие то админские схемы, скорее всего, на и внутри этих админские схемы также доступ разделен по полям и тп. Вообще тема разделения доступа в graphql пока открыта. И разделение схем это не решение ТК даже для самых простых случаев например типа блогов. Я и кю доступ к редактированию своих статей и к чтению чужих и тп не будешь же каждому юсеру свою схему давать
                            0
                            Я и кю доступ к редактированию своих статей и к чтению чужих и тп не будешь же каждому юсеру свою схему давать
                            — это одна схема, только какие именно записи можно решить на resolve. Но если есть различие, к примеру — кто-то может удалять комментарии — это уже 2 схемы. Не надо давать каждому схему, но отделить различные, по мне, лучше сразу на этапе генерации. Это более секьюрно, когда невидно что и как происходит на сервере. Сервер всегда должен быть черной коробкой для клиента.
                        0

                        можно ли воспринимать graphql как современную реинкарнацию soap?

                          +1
                          По функции в приложении да. Но по функциональности нет.
                            +2
                            На soap не особенно и похоже, а вот на OData да.
                              0

                              Похоже! 1-в-1, только на новомодных JSON'ах вместо старых-добрых XML-ей :trollface:
                              SOAP и GraphQL прям как близнецы-братья. Оба заявляют независимость от транспортного протокола, оба описывают формат взаимодействия и требуют описания схемы передаваемых данных (у SOAP — это WSDL), в которой описываются запросы и возвращаемые типы.

                                +3
                                В SOAP жестко задан набор методов, с форматов запроса и ответа, RPC по сути. В GraphQL можно по одному ендпоинту вытянуть самые разлиные данные, так запрос на выборку формируется непосредственно на клиентской стороне. А вот в OData запрос как раз формируется на клиенте, GraphQL оттуда идея видимо и взяли и добавили легковестности и свой синтаксис запросов. Схожесть разе что в том что нужно описывать схему взаимодествия, но по этому признаку 90%+ всех форматов клиент-сервер взаимодействий будут похожи. К тому же SOAP громоздкая штуковина сама по себе.
                              0
                              Скорее как SQL-like интерфейс к удаленным данным работающий по HTTP.
                                0

                                байка, тиражируемая в комментах к каждой статье о graphql

                              +1
                              Нет решение основанных на Java, которые позволяют вам использовать GraphQL вместе с обнаружением сервисов, балансировщиком или API gateway из коробки


                              Балансировка и транспорт между сервисами это больная проблема подхода микросервисов. Сейчас в принципе нет простого подхода для решения проблем взаимодействия между отдельными сервисами.
                              • UFO just landed and posted this here
                                  +2
                                  Пусть меня заминусуют, но до WS-SOAP, где и асинхронность и распределенные транзакции и безопасность и не только http — им всем еще расти и расти, а там может и измениться веб.


                                  Что Вы имеете в виду WS-SOAP не нашел такой аббревиатуры в поисуовой выдаче?
                                  Никто не спорит с тем что SOAP мощный протокол. Но все же graphql имеет преимущество по сравнению с SOAP, хотя имеет и недостатки.
                                  Я эти преимущества уже перечислил.
                                  Graphql позволяет запрашивать произвольное количество разнородных объектов в одном запроса
                                  Graphql позволяет запрашивать только необходимые поля и необходимые вложенные объекты
                                  Graphql не только язык описания сервисов но имеет также принципы построения бэкэнда на основании функций resolver которые обеспечивают вот эту самую указанную выше функциональность.

                                  Ну и в дополнение скажу что soap чрезмерно сложен. Хотя конечно проще чем CORBA. Не даром повсеместно вытесняет restapi. Из технологий которые имеют полноценную поддержку soap кроме к Майкрософт, это java, php, 1с. Node.js имеет две библиотеки и то с ограниченной поддержкой. Graphql все же проще. Он очень прост для понимания.
                                    0
                                    Вроде же уже не один суд принял решение, что API не являются интеллектуальной собственностью.
                                  • UFO just landed and posted this here
                                      0
                                      Ну ws не такая однозначно принятая аббревиатура. В качестве аббревиатуры чаще применяется для веб сокетов ws://
                                      Я именно и подумал что вы на советах предлагаете это сделать в первую очеркдь.
                                      • UFO just landed and posted this here
                                          –1
                                          Да не проблема в веб-сервисах запросить разнородные объекты. Весь вопрос в том что доля этого их предварительно необходимо описать для каждого случая. А в graphql это решает разработчик клиентской части приложения исходя из свои потребностей. Кстати graphql библиотеки не только на nodejs есть.
                                          А кардинальное отличие graphql от swager/openapi в том что swaber/openapi это стандарт описания сервисов который даёт средство для документирования и запросов, но который никакие связан с кодом самих сервисов. Поэтому не гарантируется их соответствие. В graphql определение и документация серыисов является частью исполняемого кода поэтому всегда гарантируется их полная согласованность.
                                        • UFO just landed and posted this here
                                            +2

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


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


                                            Если odata за 10+ лет так и осталась уделом 2.5 энтепрайзеров, то что-то здесь не так.

                                              0
                                              повернуто спиной

                                              лицом конечно) опечатался

                                          • UFO just landed and posted this here

                                            Only users with full accounts can post comments. Log in, please.