
Thrift в качестве REST API
При разработке серверной и клиентской частей приложения всегда встаёт вопрос: «Как организовать взаимодействие серверной и клиентской частей?» Каждый раз приходится решать данный вопрос. И на текущий момент я решил остановить свой выбор на использовании Thrift библиотеки.
В 2014 году в рамках разработки мной был опробован подход с использование JSON-RPC. За основу была взята связка серверного struts2 и клиентского dojo. Данный подход позволял прозрачно организовать вызов серверного метода со стороны клиента. Взаимодействие строилось следующим образом: на стороне клиента объявлялся сервис, который привязывался к определённому endpoint. На сервере данный endpoint обрабатывал один Struts2 action
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | public class RpcSearchAction extends ActionSupport { public String execute() { return SUCCESS; } @SMDMethod public PagedListTO find(String beginDateStr, String endDateStr, String placeUrl, int page, int perPage) throws Exception { ... return pagedListTO; } @SMDMethod public long tryComplexInput(List<Long> ids) { ... return count; } @SMDMethod public List loadPlacesTree(String selected) { .... return result; } } |
Для обращения к методам этого класса на клиенте создавался сервис, который привязывался к этому endpoint
1 2 3 | dojo.addOnLoad(function () { searchManager = new SearchManagerPrototype("/rpc/search"); }) |
При создании сервиса, происходил запрос на сервер, который возвращал сигнатуру данного серверного класса
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | { "methods": [ { "name": "find", "parameters": [ { "name": "p0" }, { "name": "p1" }, { "name": "p2" }, { "name": "p3" }, { "name": "p4" } ] }, { "name": "loadPlacesTree", "parameters": [ { "name": "p0" } ] }, { "name": "tryComplexInput", "parameters": [ { "name": "p0" } ] } ], "objectName": null, "serviceType": "JSON-RPC", "serviceUrl": "\/rpc\/search", "version": ".1" } |
После чего клиент мог напрямую вызывать методы данного серверного класса.
1 2 3 | searchManager.findExhibitions(startDateStr, endDateStr, placeId).addCallback(function (response){ ... }); |
Данный вариант удобен, если команда состоит в основном из backend-разработчиков. Но у данного метода есть свои ограничения. Одним из главных недостатков является то, что пока не придёт ответ с сигнатурой сервисов, ты не можешь обращаться к методам этих сервисов из-за того, что методов для вызова ещё нет. Они будут созданы в объекте сервиса только после получения ответа. Так же сложность вызывает разработка «без сервера» (когда работа frontend-разработчика может начаться раньше, чем готова серверная часть), когда ты вынужден делать заглушки не только для обработчиков методов, но и делать функционал, предоставляющий тебе сигнатуру сервиса. Конечно, бешеной собаке 100 миль — не круг, и на новых движках это можно сделать. Но на это убивается много времени.
В 2017 году, в связи с использованием EmberJS попробовали работать с чистым REST. Мы начали использовать Ember Data, работающий поверх REST. В начале всё казалось хорошо — Ember предоставлял нам возможность написать эмуляцию запросов к серверу. Данные для эмуляции серверных моделей клались в отдельные fuxtures-фалы. Если же где-то мы начинали работать не использую Ember Data, то Ember позволяет написать рядом эмулятор обработчика endpoint и вернуть эти данные. У нас было соглашение, что backend-разработчики должны вносить изменения в данные файлы для поддержания актуальности данных для корректной работы frontend разработчиков. Но как всегда бывает, когда всё строится на «соглашениях» настаёт момент, когда «что-то идёт не так»

Новые требования вели не только к появлению новых данных на клиенте, но и к обновлению старой модели данных. Что в конце концов привело к тому, что поддерживать синхронность моделей на сервере и на его эмуляции в исходниках клиента стало просто дорого. Теперь разработка клиентской части начинается после того, как будет готова серверная заглушка.
В рамках принятого решения о миграции с EmberJS на VueJS, я стал искать варианты решения данной проблемы. Я поставил перед собой следующие критерии:
- Совместимость работы со старыми и более новыми версиями протокола
- Максимальное удобство для frontend-разработчиков при работе «без сервера»
- Простота синхронизации сигнатуры вызовов
- понятное описание сигнатуры
- лёгкость в модификации как frontend- так и backend-разработчиками
- максимальная автономность
- Желательно строго типизированное API. Т.е. при изменении API компиляция сервера невозможна
- Простота тестирования серверной логики
- Интеграция со Spring на стороне сервера без танцев с бубнами.
В результате мой выбор упал на Trift. Сейчас описание API выглядит следующим образом
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | namespace java ru.company.api namespace php ru.company.api namespace javascrip ru.company.api const string DIRECTORY_SERVICE= "directoryService" exception ObjectNotFoundException{ } struct AdvBreed { 1: string id, 2: string name, 3: optional string title } service DirectoryService { list<AdvBreed> loadBreeds() AdsBreed getAdvBreedById(1: string id) } |
Для взаимодействия мы используем TMultiplexedProcessor, доступный через TServlet, с использованием TJSONProtocol. Пришлось немного потанцевать, чтобы это бесшовно соединить со Spring. Для этого пришлось создавать и регистрировать Servlet в ServletContainer программным способом. Дальше серверный код написан на Kotlin
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 | @Component class ThriftRegister : ApplicationListener<ContextRefreshedEvent>, ApplicationContextAware, ServletContextAware { companion object { private const val unsecureAreaUrlPattern = "/api/v2/thrift-ns" private const val secureAreaUrlPattern = "/api/v2/thrift" } private var inited = false private lateinit var appContext:ApplicationContext private lateinit var servletContext:ServletContext override fun onApplicationEvent(event: ContextRefreshedEvent) { if (!inited) { initServletsAndFilters() inited = true } } private fun initServletsAndFilters() { registerOpenAreaServletAndFilter() registerSecureAreaServletAndFilter() } private fun registerSecureAreaServletAndFilter() { registerServletAndFilter(SecureAreaServlet::class.java, SecureAreaThriftFilter::class.java, secureAreaUrlPattern) } private fun registerOpenAreaServletAndFilter() { registerServletAndFilter(UnsecureAreaServlet::class.java, UnsecureAreaThriftFilter::class.java, unsecureAreaUrlPattern) } private fun registerServletAndFilter(servletClass:Class<out Servlet>, filterClass:Class<out Filter>, pattern:String) { val servletBean = appContext.getBean(servletClass) val addServlet = servletContext .addServlet(servletClass.simpleName, servletBean) addServlet.setLoadOnStartup(1) addServlet.addMapping(pattern) val filterBean = appContext.getBean(filterClass) val addFilter = servletContext .addFilter(filterClass.simpleName, filterBean) addFilter.addMappingForUrlPatterns(null, true, pattern) } override fun setApplicationContext(applicationContext: ApplicationContext) { appContext = applicationContext } override fun setServletContext(context: ServletContext) { this.servletContext = context } } |
Что здесь надо отметить. В этом коде формируются две области сервисов. Защищённая, которая доступна по адресу «/api/v2/thrift». И открытая, доступная по адресу «/api/v2/thrift-ns». Для данных областей используются разные фильтры. В первом случае при обращении к сервису по кукам формируется объект, определяющий пользователя, который производит вызов. При невозможности сформировать такой объект, выбрасывается 401 ошибка, которая корректно обрабатывается на стороне клиента. Во втором случае, фильтр пропускает все запросы на сервис, и, если определяет, что произошла авторизация, то после выполнения операции наполняет куки необходимой информацией, чтобы можно было делать запросы в защищённую область.
К сожалению приходится писать немного лишнего кода.
1 2 3 | @Component class DirectoryServiceProcessor @Autowired constructor(handler: DirectoryService.Iface): DirectoryService.Processor<DirectoryService.Iface>(handler) |
Но добавление сервиса это редкая процедура, так что это можно пережить.
Немного претерпела изменения работа в режиме «без сервера». Разработчиками frontend-части было сделано предложение, что они будут работать над PHP-сервером-заглушкой. Они сами генерируют для своего сервера классы, реализующие нужную сигнатуру. Возвращают необходимый набор данных. Всё это позволяет им работать до того, как разработчики серверной части приступят к своей работе.
Основной точкой обработки на клиентской стороне является, написанный нами, thrift-plugin.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 | import store from '../../store' import { UNAUTHORIZED } from '../../store/actions/auth' const thrift = require('thrift') export default { install (Vue, options) { const DirectoryService = require('./gen-nodejs/DirectoryService') let _options = { transport: thrift.TBufferedTransport, protocol: thrift.TJSONProtocol, path: '/api/v2/thrift', https: location.protocol === 'https:' } let _optionsOpen = { transport: thrift.TBufferedTransport, protocol: thrift.TJSONProtocol, path: '/api/v2/thrift-ns', https: location.protocol === 'https:' } const XHRConnectionError = (_status) => { if (_status === 0) { console.log('Status 0. Network error') } else if (_status >= 400) { if (_status === 401) { store.dispatch(UNAUTHORIZED) } } } let bufers = {} thrift.XHRConnection.prototype.flush = function () { var self = this if (this.url === undefined || this.url === '') { return this.send_buf } var xreq = this.getXmlHttpRequestObject() if (xreq.overrideMimeType) { xreq.overrideMimeType('application/json') } xreq.onreadystatechange = function () { if (this.readyState === 4) { if (this.status === 200) { self.setRecvBuffer(this.responseText) } else { if (this.status === 404 || this.status >= 500) {... } else {... } } } } xreq.open('POST', this.url, true) Object.keys(this.headers).forEach(function (headerKey) { xreq.setRequestHeader(headerKey, self.headers[headerKey]) }) if (process.env.NODE_ENV === 'development') { let sendBuf = JSON.parse(this.send_buf) bufers[sendBuf[3]] = this.send_buf xreq.seqid = sendBuf[3] } xreq.send(this.send_buf) } const mp = new thrift.Multiplexer() const connectionHostName = process.env.THRIFT_HOST ? process.env.THRIFT_HOST : location.hostname const connectionPort = process.env.THRIFT_PORT ? process.env.THRIFT_PORT : location.port const connection = thrift.createXHRConnection(connectionHostName, connectionPort, _options) const connectionOpen = thrift.createXHRConnection(connectionHostName, connectionPort, _optionsOpen) Vue.prototype.$ThriftPlugin = { DirectoryService: mp.createClient('directoryService', DirectoryService, connectionOpen), } } } |
Для корректно работы данного плагина необходимо подключить сгенерированные классы.
Вызов серверных методов осуществляется следующим образом:
1 2 3 4 5 6 7 8 | thriftPlugin.DirectoryService.loadBreeds() .then(_response => { ... }) .catch(error => { ... }) }) |
В работе с клиентской частью, есть пара ограничений, которые надо учитывать после ментальной миграции с внутренней thrift-интеграции.
- Javascript клиент не распознаёт null значения. По этому для полей, которые могут принимать значение null, необходимо указывать признак optional. В этом случае клиент корректно воспримет это значение
- Javascript не умеет работать с long значениями, по этому все целочисленные идентификаторы надо приводить к string на стороне сервера.
Переход на Thrift позволил решить нам те проблемы, которые были во взаимодействии между серверной и клиентской разработкой. Позволил сделать возможной обработку глобальных ошибок в одном месте.
При этом, дополнительным бонусом, из-за строгой типизации API, а следовательно и жёстких правил сериализации/десериализации данных, мы получили прирост ~30% во времени взаимодействия на клиента и сервера для большинства запросов. При сравнении одинаковых запросов через REST и THRIFT взаимодействие. От времени отправки запроса на сервер, до момента получения ответа.

