Небольшая статья о том, как мы столкнулись с проблемами синхронизации работы между командами клиентской и серверной разработки. Как мы подключили 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 взаимодействие, от времени отправки запроса на сервер, до момента получения ответа)

Комментарии (7)


  1. vladqa
    10.11.2018 18:46

    А почему остановились на Thrift, а не на gRPC? В свое время нас смутило слабое развитие проекта.


    1. creker
      11.11.2018 00:33

      Ладно бы слабое развитие. Для меня основная проблема трифта это полный хаос в реализациях для разных языков. Большинство сделаны кое как без какого-то единого направления. Некоторые фичи отсутствуют, другие реализованы совершенно неидиоматично для конкретного языка. Тесты порой отсутствуют. Собственно, одна из причин, почему трифт для меня это скорее генератор классов из IDL с сериализаторами, а весь транспорт и обвязка пишется своя. Весь их RPC стэк для меня бесполезен. Получается прямой конкурент протобуферам.

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


    1. gubber Автор
      11.11.2018 14:52

      У нас уже был использован Thrift для межмодульного взаимодействия. Не хотелось вводить ещё один язык.
      Плюс не всегда «слабое развитие» это критический минус. Если продукт решает свои проблемы, и ты не планируешь использовать неподдерживаемые фичи. То это не является критическим местом.
      Выбирая из пары развивающихся продуктов, которые только выходят на рынок, тут я соглашусь, что надо смотреть на динамику развития.

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

      Не знаю, что уважаемый creker подразумевал, что весь транспорт и обвязка пишется руками. Если то, что приходится писать свой сервлет, для работы через http протокол. Или поднимать ручками сервер для работы на сокетах

      @Component
      class ThriftAdsServer @Autowired constructor(
      		@Value("\${thrift.server.port}")
      		private val serverPort: Int,
      		handler: AdsService.Iface) {
      
      	companion object {
      		private val logger = LoggerFactory.getLogger("THRIFT-SERVER")
      	}
      
      
      	private val server: TServer
      
      	@PostConstruct
      	fun startServer() {
      		logger.info("Thrift server starting at port $serverPort")
      		thread(start = true) {
      			try {
      				val serverTransport = TNonblockingServerSocket(serverPort)
      				server = THsHaServer(THsHaServer.Args(serverTransport).processor(AdsService.Processor(handler)))
      				server.serve()
      			} catch (e: Throwable) {
      				logger.error("Server was crashed.", e)
      			}
      		}
      	}
      
      	@PreDestroy
      	fun stopServer() {
      		logger.info("Try to stop thrift server serving at port $serverPort")
      		if (server.isServing) {
      			server.stop()
      			logger.info("thrift server was stopped")
      		} else {
      			logger.info("Server wasn't started")
      		}
      	}
      
      }

      То по мне это не большая проблема: написать 40-50 строчек кода для одного сервиса.


      1. creker
        11.11.2018 14:56

        Трифт это не просто IDL с генераторами, а еще RPC стэк — т.е. сервисы с методами в IDL, сервера и клиенты в библиотеке. Вот это вот все довольно сломано у них и криво написано в разных языках. Поэтому для меня трифт заканчивается на TBinaryProtocol и TCompactProtocol, когда я получаю сериализованный массив байтов. Все остальное пишется самостоятельно на голых сокетах.


  1. Kaer_Morchen
    11.11.2018 14:12
    +1

    Поправьте меня если я не все понял, но думаю у вас было бы значительно меньше проблем, если бы:
    1. В начале 2017, начиная новый проект, вы вызяли бы не архаичный REST, a JSONAPI, который более строгий и при этом имеет больше возможностей.
    2. За место fuxtures использовали mirage, поддержкой которого занималась фронтенд сторона.
    3. Имели бы четкую документацию API, изменения в которую разрешалось только после ревью и согласования всех кто с ней работает.
    4. Писали бы тесты которые выявляют все ошибки API как на бэкенде так и на фронтенде.

    И вообще судя по описанию проблемы у вас скорее беда с культурой разработки в команде, а не трудностью согласовывать API, так как это скорее следствие, а не причина.


    1. gubber Автор
      11.11.2018 14:26

      Проблема не столько в том, какой набор адаптеров мы выбрали, а в том, что возникают проблемы с синхронизацией серверного и клиентского представления API. Чтобы поддерживать возможность работы команд бекенда и фронтенда независимо друг от друга.
      Мы mirage и используем. Дело в том, что при таком подходе для изменения API ты должен вносить несвязанные изменения и на стороне клиента и на стороне сервера. В итоге конфигурация для mirage разрослась. Плюс, как я писал некоторые разработчики меняя модель данных на сервере не всегда меняли модель данных на мираже. Т.е. у нас в mirage есть представление списка сущностей. Список из 20 — 30 записей. И добавление одного поля в данный список приносило много боли.
      Когда модель состоит из десятка сущностей — это не очень проблемно, когда количество сущностей кратно возрастает, то возрастает и стоимость «ревью».
      Если можно убрать момент «ревью»API, то лучше его убрать.

      Писать тесты для выявления ошибок API, это опять вопрос синхронизации. Как можно защититься от того, что разработчик напишет тест только для бекенда, а для «фронтенда» забудет?

      Сейчас же с выносом API в единое место, у нас есть гарантия на уровне компиляции, что модели для сервера и клиента будут согласованы. Т.е. во время сборки проекта будет подложена последняя версия API. До этого гарантией служили «соглашения».


      1. Kaer_Morchen
        11.11.2018 16:48

        Дело в том, что при таком подходе для изменения API ты должен вносить не связанные изменения и на стороне клиента и на стороне сервера.

        Да это плохо, этого нельзя было допускать с самого начала. Фронтенд и бэкенд должны жить независимо друг от друга. У них есть связующее звено — спецификация протокола и документация — API. Они не должны залазить во внутреннюю кухню другой стороны, а лишь соответствовать текущей документации. Вносить правки в документацию API можно только по согласованию. Все. Если вам нужна защита от дурака или случайно ошибки можно написать везде тестов. Тесты фронтенда должны проходить проверку соответствия внутренней структуры моделей и текущей документации, а тесты бэкенда аналогично проходят по своей внутренней структуре.

        Мне всегда казалось что это какие-то капитанские вещи.

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

        Ну вот это и есть рука лицо и отсутствие культуры. Как один разработчик может внести изменения в что-то что затрагивает другие части системы без элементарного уведомления, не говоря уже об согласовании. Ну вот поменял бэкендер api и структуру в mirage, а правки в бизнес логику кто вносить будет?