Небольшая статья о том, как мы столкнулись с проблемами синхронизации работы между командами клиентской и серверной разработки. Как мы подключили Thrift для того, чтобы упростить взаимодействие между нашими командами.
Кому интересно, как мы это сделали, и какие «побочные» эффекты мы словили, прошу заглянуть под кат.
Предыстория
В начале 2017 года, когда мы начинали новый проект, то в качестве фронтенда выбрали EmberJS. Что, почти автоматически привело нас к работе по REST схеме при организации взаимодействия клиентской и серверной части приложения. Т.к. EmberData предоставляет удобный инструмент для разделения работы команд бекенда и фронтенда, а использование Adapter позволяет выбрать «протокол» взаимодействия.
По началу всё хорошо — Ember предоставлял нам возможность реализовать эмуляцию запросов к серверу. Данные для эмуляции серверных моделей клались в отдельные fuxtures-фалы. Если же где-то мы начинали работать не использую Ember Data, то Ember позволяет написать рядом эмулятор обработчика endpoint и вернуть эти данные. У нас было соглашение, что backend-разработчики должны вносить изменения в данные файлы для поддержания актуальности данных для корректной работы frontend разработчиков. Но как всегда бывает, когда всё строится на «соглашениях» (и нет инструмента их проверки) настаёт момент, когда «что-то идёт не так».
Новые требования вели не только к появлению новых данных на клиенте, но и к обновлению старой модели данных. Что в конце концов привело к тому, что поддерживать синхронность моделей на сервере и на его эмуляции в исходниках клиента стало просто дорого. Теперь разработка клиентской части, как правило, начинается после того, как будет готова серверная заглушка. И разработка ведётся поверх рабочего сервера, а это усложняет командную работу и увеличивает время выхода нового функционала.
Развитие проекта
Сейчас же мы отказываемся от EmberJS в пользу VueJS. и в рамках принятого решения о миграции мы стали искать варианты решения данной проблемы. Были выработаны следующие критерии:
- Совместимость работы со старыми и более новыми версиями протокола
- Максимальное удобство для frontend-разработчиков при работе «без сервера»
- Разделение описания API от тестовых данных
- Простота синхронизации сигнатуры вызовов
- понятное описание сигнатуры
- лёгкость в модификации как frontend- так и backend-разработчиками
- максимальная автономность
- Желательно строго типизированное API. Т.е. максимально быстрое выявление факта изменения протокола
- Простота тестирования серверной логики
- Интеграция со Spring на стороне сервера без танцев с бубнами.
Реализация
Подумав, было принято решение остановиться на Thrift. Это дало нам простой и понятный язык описания API
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. Пришлось немного потанцевать, чтобы это Thrift бесшовно интегрировать со Spring. Для этого пришлось создавать и регистрировать Servlet в ServletContainer программным способом.
@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 ошибка, которая корректно обрабатывается на стороне клиента. Во втором случае, фильтр пропускает все запросы на сервис, и, если определяет, что произошла авторизация, то после выполнения операции, наполняет куки необходимой информацией, чтобы можно было делать запросы в защищённую область.
Для подключения нового сервиса приходится писать немного лишнего кода.
@Component
class DirectoryServiceProcessor @Autowired constructor(handler: DirectoryService.Iface):
DirectoryService.Processor<DirectoryService.Iface>(handler)
И регистрировать процессор
@Component
class SecureMultiplexingProcessor @Autowired constructor(dsProcessor: DirectoryServiceProcessor) : TMultiplexedProcessor() {
init {
this.registerProcessor(DIRECTORY_SERVICE, dsProcessor)
...
}
}
Последнюю часть кода можно упростить, навесив на все процессоры дополнительный интерфейс, что позволит получать сразу список процессоров одним параметром конструктора, и отдав ответственность за значение ключа доступа к процессору самому процессору.
Немного претерпела изменения работа в режиме «без сервера». Разработчиками frontend-части было сделано предложение, что они будут работать над PHP-сервером-заглушкой. Они сами генерируют для своего сервера классы, реализующие сигнатуру для нужной версии протокола. И реализуют сервер с необходимым набором данных. Всё это позволяет им работать до того, как разработчики серверной части закончат свою работу.
Основной точкой обработки на клиентской стороне является, написанный нами, thrift-plugin.
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 = {
...
}
const XHRConnectionError = (_status) => {
if (_status === 0) {
....
} 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),
}
}
}
Для корректно работы данного плагина необходимо подключить сгенерированные классы.
Вызов серверных методов на клиенте выглядит следующим образом:
thriftPlugin.DirectoryService.loadBreeds()
.then(_response => {
...
})
.catch(error => {
...
})
})
Здесь я не углубляюсь в особенности самого VueJS, где правильно держать код, вызывающий сервер. Этот код можно использовать и внутри компонента, и внутри route и внутри Vuex-action.
При работе с клиентской частью, есть пара ограничений, которые надо учитывать после ментальной миграции с внутренней thrift-интеграции.
- Javascript клиент не распознаёт null значения. По этому для полей, которые могут принимать значение null, необходимо указывать признак optional. В этом случае клиент корректно воспримет это значение
- Javascript не умеет работать с long значениями, по этому все целочисленные идентификаторы надо приводить к string на стороне сервера
Выводы
Переход на Thrift позволил решить нам те проблемы, которые присутствуют во взаимодействии между серверной и клиентской разработкой при работе над старой версией интерфейса. Позволил сделать возможной обработку глобальных ошибок в одном месте.
При этом, дополнительным бонусом, из-за строгой типизации API, а следовательно и жёстких правил сериализации/десериализации данных, мы получили прирост ~30% во времени взаимодействия на клиента и сервера для большинства запросов (при сравнении одинаковых запросов через REST и THRIFT взаимодействие, от времени отправки запроса на сервер, до момента получения ответа)
Автор: gubber