Как стать автором
Обновить

Уж+ёж: реактивные компоненты в сервлетном окружении (1/3)

Блог компании Центр Финансовых Технологий (ЦФТ) Java *

Когда рассказывают о прелестях реактивного фреймворка Spring WebFlux и его подкапотном Project Reactor, для примера чаще всего показывают новые, создаваемые с нуля приложения. Однако на практике приходится строить из готовых блоков, в том числе собственных прикладных и инфраструктурных модулей, которые уже написаны в императивном стиле и опираются на сервлетный стек. Как правило, такие модули нельзя/некогда/неохота (нужное подчеркнуть) переписывать, поэтому надо как-то адаптировать их к создаваемому реактивному приложению с минимумом правок (а лучше без них вовсе). О некоторых подходах к такой задаче и пойдёт речь в этой серии из 3 заметок:

  1. Варианты подготовки зависимостей и универсальная поддержка MDC

  2. Унификация получения текущего запроса из любой точки приложения

  3. Обобщение @Around-аспектов и ремонт OpenFeign-клиентов

Вместо disclaimer’а

Всё сказанное здесь опирается лишь на небольшой опыт автора в решении одной практической задачи, поэтому статья не претендует на полноту охвата темы ни вширь, ни вглубь. Её цель – лишь обозначить некоторые проблемы и возможные решения для них. Кроме того, она не призывает скрещивать “ужа с ежом” (сервлеты с реактивщиной), а лишь подсказывает, как быть, если такое скрещивание оказалось необходимым.

Дальнейший материал предполагает хотя бы шапочное знакомство читателя с реактивным стеком технологий в экосистеме Spring, а также наличие практического опыта работы с веб-приложениями на сервлет-контейнере типа Tomcat. Недостающие знания можно почерпнуть, например, в документации на Spring здесь и здесь.

Подготовка зависимостей

Контекст проблемы

Историческая справка

Появившийся в Spring 5 реактивный веб-фреймворк WebFlux был задуман как невытесняющая альтернатива старому доброму WebMVC, т.е. существуют и развиваются они оба, но в качестве серверного стека можно использовать только один. Дело не столько в вездесущих обёртках Mono<> и Flux<> вокруг результатов чуть ли не всех методов реактивного фреймворка, сколько в принципиально иной модели многопоточности под его капотом.

Поскольку большинство сервлетных Spring-приложений построено на WebMVC (в том числе через его Spring Boot стартер), очень многие приключения с реактивщиной начинаются словами: “Заходит как-то раз WebMVC в classpath к реактивному сервису…”

В зависимости от того, какие реактивные библиотеки используются (или будут) в приложении, их прямая или транзитивная встреча с WebMVC может закончиться по-разному. Например, Spring Cloud Gateway, построенный на WebFlux, обозначает свою радикальную позицию сразу при запуске приложения:

11:20:19.437  WARN - [           main] sid: rid: - GatewayClassPathWarningAutoConfiguration :
**********************************************************
Spring MVC found on classpath, which is incompatible with Spring Cloud Gateway at this time.
Please remove spring-boot-starter-web dependency.
**********************************************************

***************************
APPLICATION FAILED TO START
***************************

Это хорошо, потому что fail-fast. Однако так везёт далеко не всегда. В более общем (и распространённом) случае, если просто смешать зависимости от WebMVC и WebFlux в одном classpath’е, то смесь не взорвётся, и ни во время компиляции, ни на запуске, скорее всего, ничего не сломается (по крайней мере, со стороны Spring). Даже предупреждений в логе не будет. И это не диверсия, а сознательное решение разработчиков:

Applications can use one or the other module or, in some cases, both — for example, Spring MVC controllers with the reactive WebClient.

А какой же стек тогда поднимется: сервлетный (по умолчанию на Tomcat) или реактивный (по умолчанию на Netty)? Вероятно, авторы руководствовались упомянутым в цитате выше примером, поэтому в таком случае поднимется именно сервлетный стек. Логика выбора прописана в методе WebApplicationType#deduceFromClasspath, который вызывается на самом старте Spring Boot приложения:

static WebApplicationType deduceFromClasspath() {
	if (ClassUtils.isPresent(WEBFLUX_INDICATOR_CLASS, null)
	&& !ClassUtils.isPresent(WEBMVC_INDICATOR_CLASS, null)
	&& !ClassUtils.isPresent(JERSEY_INDICATOR_CLASS, null)) {
		return WebApplicationType.REACTIVE;
	}
	for (String className : SERVLET_INDICATOR_CLASSES) {
		if (!ClassUtils.isPresent(className, null)) {
			return WebApplicationType.NONE;
		}
	}
	return WebApplicationType.SERVLET;
}

Как видно, эта логика опирается на состав classpath, а значит, чтобы на неё повлиять, придётся п(р)оиграть с зависимостями.

Подготовка зависимостей

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

“В лоб”

Если WebMVC был втянут транзитивно, и с виду это не жизненно важная зависимость, то её можно просто выкинуть (говорили они):

configurations {
  implementation.exclude group: 'org.springframework.boot', module: 'spring-boot-starter-web'
}

Однако тогда надо быть готовым к тому, что у этого “недозвездолёта” уже в воздухе (в runtime) начнут отваливаться части. Например так:

***************************
APPLICATION FAILED TO START
***************************
Description:
Parameter 6 of constructor in springfox.documentation.spring.web.plugins.DocumentationPluginsBootstrapper required a bean of type 'javax.servlet.ServletContext' that could not be found.
Action:
Consider defining a bean of type 'javax.servlet.ServletContext' in your configuration.

Так происходит потому, что вместе с WebMVC исчезает зависимость от Tomcat или иного сервлет-контейнера, а вместе с ним – и зависимость от Servlet API, что совсем плохо для сторонних библиотек и прикладного кода, повязанного на сервлетном стеке.

По этой причине такой вариант годится далеко не во всех случаях.

"Выключатель"

Иногда поломавшаяся зависимость просто не может жить вне сервлетного стека, поэтому вполне адекватный вариант – отказаться от неё. Например, так приходится поступить со встраиваемым мониторингом JavaMelody:

configurations {
  compileClasspath.exclude group: 'net.bull.javamelody', module: 'javamelody-spring-boot-starter'
  runtimeClasspath.exclude group: 'net.bull.javamelody', module: 'javamelody-spring-boot-starter'
}

Однако бывает так, что скандальный компонент нужен в classpath, но не в Spring-контексте. Тогда может пригодиться вариант следующий.

"Условность"

Если поломавшиеся Spring-компоненты не критичны при работе на реактивном стеке, их можно условно отключить путём навешивания вот такой аннотации:

@ConditionalOnWebApplication(type = SERVLET)
public class LogRequestAndResponseAspect {

Она позволит бину регистрироваться в контексте только если приложение сервлетное, не вызывая проблем в реактивном режиме. Понятно, что такой костыль вариант нельзя считать полноценным решением, поэтому можно пойти на…

Компромисс

Как видно, предыдущие варианты сводятся к тому, что мы сознательно отстреливаем себе разные части тела, пытаясь угадать, какие из них не понадобятся в дальнейшем. И хотя совсем без таких решений порой не обойтись, снизить их количество можно за счёт исключения WebMVC, но с оставлением Serlvet API в classpath, например, так:

dependencies {
    // ...
    configurations {
        implementation.exclude group: 'org.springframework.boot', module: 'spring-boot-starter-web'
    }
    implementation group: 'javax.servlet', name: 'javax.servlet-api'
    implementation group: 'org.springframework.boot', name: 'spring-boot-starter-webflux'
    // ...
}

Задумка здесь в том, чтобы устранить WebMVC, но не настолько, чтобы в runtime утонуть в бесчисленных NoClassDefFoundError. Другими словами, мы не хотим поднимать полноценный сервлетный стек, но хотим, чтобы классы от Servlet API остались в classpath.

Разумеется, сам по себе последний вариант не позволит традиционному “сервлетному” коду работать на реактивном стеке. Но зато он наставит на истинный путь упомянутый выше метод WebApplicationType#deduceFromClasspath. Останется только починить всё то, что поотваливается на фиг от такого переключения.

Относительно простой вариант распределения зависимостей по Gradle-модулям реализован в прилагаемом демо-проекте.

Попутное резюме

Эта вступительная часть заметки была отведена под самый неоднозначный этап, который наверняка у каждого читателя проходит как-то по своему. Тем не менее, от его прохождения будет зависеть успех на следующих шагах. А ближайший из них будет посвящён логированию.


Универсальная поддержка MDC

Одна из самых популярных задач, взрывающих мозг честным людям при переходе с сервлетного стека на реактивный, – это обеспечение повсеместного вывода MDC-меток в логах приложения.

Что такое MDC?

Это строковые диагностические метки, которые позволяют легко находить в логах записи, относящиеся к одному этапу/аспекту/модулю приложения. Хрестоматийный пример: идентификатор пользовательского запроса (request id, rid) – позволяет выявить в логе все записи, относящиеся к обработке каждого входящего запроса, из какой бы части приложения эти записи не были сделаны.

Проблема

В классических сервлетных приложениях эта задача решалась относительно просто за счёт того, что каждому входящему запросу, как правило, соответствовал один поток-обработчик в пуле сервлет-контейнера. Благодаря этому оснастка логирования могла переложить значение, например, rid из HTTP-заголовка или параметров в какую-либо поточно-локальную переменную (ThreadLocal), а потом обращаться к ней как к статической переменной из любого места приложения, зная, что в текущем потоке эта переменная теперь будет возвращать одно и то же значение (пока её не очистят после отправки ответа клиенту). Упрощённо это могло бы выглядеть примерно так:

public class ServletMdcFilter implements Filter {    // [1]
  @Override
  public void doFilter(ServletRequest request,
                       ServletResponse response,
                       FilterChain chain) throws ServletException, IOException {
    HttpServletRequest httpServletRequest = (HttpServletRequest) request;
    String rid = httpServletRequest.getParameter("rid");
    MDC.put("rid", rid);                             // [2]
    try {
      chain.doFilter(request, response);
    } finally {
      MDC.remove("rid");
    }
  }
}

1️⃣ Класс Filter импортирован из javax.servlet.Filter.
2️⃣ Здесь под вызовом put скрывается обращение к поточно-локальной map’е. Например, в имплементации org.slf4j.helpers.BasicMDCAdapter она объявлена так:

private InheritableThreadLocal<Map<String, String>> inheritableThreadLocal

Однако с переходом на стек реактивный вся эта прелесть работать перестала. И дело не только в том, что принцип “один запрос – один поток” перестал соблюдаться, но и в том, что фильтры из Servlet API по понятным причинам вообще перестали как-либо учитываться и всё происходящее в них игнорируется.

Вывод решения

Примечание для буквоедов

Эрудированный читатель наверняка знает, что задаче проброса MDC-меток в документации на Project Reactor посвящен отдельный пункт в FAQ. Почему бы просто не последовать ему? Потому что он предполагает написание новой логики в реактивном стиле, в то время как здесь решается задача адаптации существующей (императивной) логики к работе в реактивном окружении.

Короче (TL;DR)

Ради полноты понимания здесь будет изложен весь ход получения финального решения. Однако для страждущих результата “прям ща” предусмотрен короткий путь – готовые классы ReactiveMdcFilter и ServletMdcFilter в прилагаемом демо-проекте.

Переход на WebFilter

На первый взгляд может показаться, что “мигрировать” проброс MDC-меток с сервлетного стека на реактивный очень просто – достаточно поменять приведённый выше фильтр на соответствующий реактивный аналог org.springframework.web.server.WebFilter. Его основное отличие состоит лишь в том, что метод filter возвращает уже не чистый void, а обёртку Mono<Void>. Ну, и пара “запрос/ответ” собрана в один объект exchange. В остальном смысл, вроде как, тот же, так что переписать не сложно:

public class ReactiveMdcFilter implements WebFilter {
  private static final Logger log = LoggerFactory.getLogger(ReactiveMdcFilter.class);
  @Override
  public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
    String rid = exchange.getRequest().getQueryParams().getFirst("rid");
    MDC.put("rid", rid);
    log.trace("Выставлена метка rid={}", rid);    // [1]
    try {
      return chain.filter(exchange);              // [2]
    } finally {
      MDC.remove("rid");
      log.trace("Убрана метка rid={}", rid);      // [1]
    }
  }
}

1️⃣ Добавим чуток логирования для ясности происходящего.
2️⃣ Раньше здесь не было return, и выход из метода был просто в конце тела. Но ведь у нас используется finally, а значит его логика выполнится по-любому, каким бы путём мы не выходили из try {}. Короче, всё должно быть хорошо.

Однако, если применить такой фильтр и отправить приложению запрос с URL-параметром rid=123, поведение окажется, мягко говоря, неожиданным:

17:34:09.710 rid:123 TRACE --- [ctor-http-nio-3] p.t.s.g.gatewaydemo.ReactiveMdcFilter    : Выставлена метка rid=123
17:34:09.710 rid:    TRACE --- [ctor-http-nio-3] p.t.s.g.gatewaydemo.ReactiveMdcFilter    : Убрана метка rid=123
17:34:09.751 rid:     INFO --- [ctor-http-nio-3] p.t.s.g.g.GatewayDemoWebFluxApplication  : Перенаправляю запрос...

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

Дело вот в чём. Во-первых, вызов return chain.filter(exchange) – это пример реактивного конвейера (набора действий в реактивном стиле). Обычно такие конвейеры включают в себя какие-либо действия над пропускаемыми через него данными, и эти действия часто задаются лямбда-выражениями или ссылками на методы. Здесь ни того, ни другого нет, но это не значит, что нет их и дальше в цепочке фильтров, которой мы делегируем управление. Во-вторых, реактивные конвейеры выполняются как бы в два прохода. На первом проходе хоть и выполняется код, но он не запускает обработку данных в конвейере, а лишь декларирует его, т.е. описывает, из каких элементов он состоит, и как эти элементы связаны друг с другом. Сами действия над данными откладываются за счёт упомянутых выше ленивых конструкций типа лямбд и ссылок. И больше ничего не произойдёт до тех пор, пока у конвейера не появится подписчик. Появиться он может только за счёт возвращаемого хвостика Mono<> или Flux<>, у которых есть метод subscribe. И вот когда появится, это и будет второй проход: начнут выполняться декларированные ранее действия.

Так вот в нашем случае получается, что очистка MDC-метки в блоке finally честно отрабатывает ещё на первом проходе, когда конвейер только строится, т.е. до того, как по нему пошли данные HTTP-запроса.

Прививка реактивности

Чтобы это исправить, нужно перенести оба действия с меткой на второй проход. Выставление метки в таком случае будет лучше сделать в момент подписки – для этого есть метод doOnSubscribe. А удаление – после завершения обработки, причём независимо от результата (для этого есть метод с внезапным именем doFinally):

public class ReactiveMdcFilter implements WebFilter {
  private static final Logger log = LoggerFactory.getLogger(ReactiveMdcFilter.class);
  @Override
  public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
    String rid = exchange.getRequest().getQueryParams().getFirst("rid");
    return chain.filter(exchange)        // [1]
      .doOnSubscribe(subscription -> {   // [2]
        MDC.put("rid", rid);
        log.trace("Выставлена метка rid={}", rid);
      })
      .doFinally(whatever -> {           // [3]
        MDC.remove("rid");
        log.trace("Убрана метка rid={}", rid);
      });
  }
}

1️⃣ Даём последующим фильтрам возможность дополнить цепочку своими звеньями. Здесь важно понимать, что это ещё не обработка запроса, как было бы в традиционной сервлетной цепочке, а лишь декларация новых звеньев. Именно поэтому мы можем описывать дополняемые нами действия после того, как этот метод вернёт управление.
2️⃣ Здесь subscription – это экземпляр org.reactivestreams.Subscription, но нам он пока не интересен.
3️⃣ Здесь whatever – это элемент перечисления reactor.core.publisher.SignalType – тип сигнала, с которым завершает свою работу конвейер, но он нам пока тоже не интересен.

Будет ли такая конструкция работать? Конечно!

11:07:12.963 rid:123 TRACE --- [ctor-http-nio-3] p.t.s.g.gatewaydemo.ReactiveMdcFilter    : Выставлена метка rid=123
11:07:13.010 rid:123  INFO --- [ctor-http-nio-3] p.t.s.g.g.GatewayDemoWebFluxApplication  : Перенаправляю запрос...
11:07:13.231 rid:    TRACE --- [ctor-http-nio-3] p.t.s.g.gatewaydemo.ReactiveMdcFilter    : Убрана метка rid=123

… но только до тех пор, пока вся обработка запроса (и построение конвейера, и его выполнение) остаётся в одном потоке. По умолчанию Reactor не будет плодить потоки или как-либо переключать их без ведома разработчика. Однако одна из его сильнейших сторон как раз и состоит в том, чтобы это делать, причём с минимальными усилиями разработчика. А понадобиться это может, например, для того, чтобы вынести какой-либо старый синхронный (ещё пока не реактивный) код в отдельный поток, взятый из специального пула (не конкурирующего с основным). В Project Reactor это делается буквально одним оператором subscribeOn(Schedulers.boundedElastic()), и в рассматриваемом примере приведёт вот к чему:

12:34:08.192 rid:123 TRACE --- [ctor-http-nio-3] p.t.s.g.gatewaydemo.ReactiveMdcFilter    : Выставлена метка rid=123
12:34:08.238 rid:     INFO --- [oundedElastic-1] p.t.s.g.g.GatewayDemoWebFluxApplication  : Синхронно перенаправляю запрос...
12:34:08.332 rid:    TRACE --- [ctor-http-nio-3] p.t.s.g.gatewaydemo.ReactiveMdcFilter    : Убрана метка rid=123

Здесь видно, что прикладная логика выполняется в потоке boundedElastic-1, отличном от исходного reactor-http-nio-3, поэтому поточно-локальная переменная недоступна, и MDC-метка rid не выведена. В силу врождённой асинхронности и неблокируемости, Reactor может жонглировать потоками, нарушая некогда непреложный принцип “один запрос – один поток”. Однако эта же способность является его сильной стороной, и с ней нужно уметь уживаться.

Дополнение

До выхода Project Reactor версии 3.3 у этой задачи было только весьма разлапистое решение. Однако с выходом v3.3 появилась возможность декорировать задачи, раздаваемые планировщикам на выполнение. Это делается при помощи т.н. хуков – методов, принимающих исходный Runnable и возвращающих его декорированный вариант. Благодаря такому подходу они могут назначать действия для выполнения как в декорирующем потоке, так и в целевом. В том же классе ReactiveMdcFilter выглядеть это может примерно так:

  @PostConstruct                          // [1]
  void setupThreadsDecorator() {
    Schedulers.onScheduleHook("mdc", runnable -> {
      String rid = MDC.get("rid");        // [2]
      return () -> {
        MDC.put("rid", rid);              // [3]
        log.trace("Метка 'rid' перенесена в поток");
        try {
          runnable.run();
        } finally {
          MDC.remove("rid");              // [4]
          log.trace("Метка 'rid' отвязана от потока");
        }
      };
    });
  }
  @PreDestroy                             // [5]
  void shutdownThreadsDecorator() {
    Schedulers.resetOnScheduleHook("mdc");
  }

1️⃣ Говорим Spring’у, что хотим позвать этот метод после того, как содержащий его бин будет создан и проинициализирован самим фреймворком. Здесь неявно предполагается, что этот бин будет синглтоном (что по умолчанию в Spring’е уже так).
2️⃣ Извлекаем текущее значение метки. Под текущим понимается то, которое было выставлено описанным ранее фильтром. Такая связность обеспечивается тем, что именно эта часть декоратора выполняется ещё в исходном, а не ответвлённом потоке.
3️⃣ Кладём извлечённое ранее значение метки в то же самое место, но уже в ответвлённом потоке.
4️⃣ Очищаем метку безотносительно к тому, чем закончилось выполнение задачи. Это важно потому, что поток скоро вернётся в пул и может быть снова выдан кому-нибудь в работу. Если не очистить метку и новый “хозяин” не перезатрёт её своей, читатель логов сойдёт с ума, пытаясь понять, почему у него в одну выборку попадают записи от абсолютно не связанных между собой действий.
5️⃣ Удаляем назначенный хук по выбранному ранее условному имени mdc. В большинстве нормальных программ это произойдёт лишь перед самым выключением, т.е. в принципе этого можно и не делать. Но в некоторых случаях (например, при горячей перезагрузке контекста) такой клининг всё же может быть полезным.

Теперь, даже несмотря на оборачивание синхронного кода в отдельный поток, MDC-метка всё равно корректно выводится на каждом шаге:

14:17:07.803 rid:123 TRACE --- [ctor-http-nio-3] p.t.s.g.gatewaydemo.ReactiveMdcFilter    : Выставлена метка rid=123
14:17:07.861 rid:123 TRACE --- [oundedElastic-1] p.t.s.g.gatewaydemo.ReactiveMdcFilter    : Метка 'rid' перенесена в этот поток
14:17:07.861 rid:123  INFO --- [oundedElastic-1] p.t.s.g.g.GatewayDemoWebFluxApplication  : Синхронно перенаправляю запрос...
14:17:07.945 rid:    TRACE --- [oundedElastic-1] p.t.s.g.gatewaydemo.ReactiveMdcFilter    : Метка 'rid' отвязана от этого потока
14:17:07.955 rid:    TRACE --- [ctor-http-nio-3] p.t.s.g.gatewaydemo.ReactiveMdcFilter    : Убрана метка rid=123

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

Двойное гражданство

Написанный нами фильтр теперь будет вполне естественно чувствовать себя в реактивной среде. Но что если этот фильтр окажется/является частью общего модуля (библиотеки), который может быть подключён как реактивному, так и к сервлетному приложению? Очевидно, что с сервлетным он уже не заработает (хотя бы потому, что имплементации WebFilter не будут никем вызваны).

Одно из решений состоит в том, чтобы сделать новый реактивный фильтр не заменой, а альтернативным дополнением к сервлетному (подобно отношениям между WebFlux и WebMVC), то есть чтобы существовали оба, но использовался только один. Этого можно достичь использованием встроенной в Spring Boot аннотации @ConditionalOnWebApplication, которая на примере реактивного фильтра может выглядеть так:

@Component
@ConditionalOnWebApplication(type = REACTIVE)           // [1]
public class ReactiveMdcFilter implements WebFilter {

1️⃣ Здесь REACTIVE – это сокращённое заклинание import static org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type.REACTIVE;

Соответственно, для сервлетного стека та же аннотация может выглядеть так:

@Component
@ConditionalOnWebApplication(type = SERVLET)                // [1]
public class ServletMdcFilter extends GenericFilterBean {   // [2]

1️⃣ Здесь может возникнуть соблазн не указывать эту аннотацию вовсе, ведь мы рассматриваем только два варианта: реактивный и сервлетный. Однако так лучше не делать, потому что (1) такое указание делает намерения разработчика более явными, а значит, и код – более предсказуемым; (2) с точки зрения Spring Boot есть ещё третий тип приложений – условно говоря, “никакой” – это когда никакого веб-сервера нет вообще (например, в CLI-приложении или serverless unit). На такие случаи может реагировать специальная аннотация ConditionalOnNotWebApplication.
2️⃣ GenericFilterBean – это Spring’овая адаптация упоминавшегося ранее интерфейса javax.servlet.Filter, просто она умеет чуть больше (имплементирует всего-то 7 интерфейсов).

В таком виде в приложении появляется “подстилка” под оба режима работы – и сервлетный, и реактивный, но каждая “оживает” только в своём режиме и не мешается в другом. Однако важно отметить, что поскольку MDC в этом подходе выставляется лишь раз, он может некорректно работать в случае множества событий, курсирующих между клиентом и сервером (например, WebSocket или SSE). Спасибо @OlehDokuka за это ценное примечание!

Полные тексты обоих фильтров приведены в пакете shared прилагаемого демо-проекта.

Попутное резюме

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

Теги:
Хабы:
Всего голосов 10: ↑10 и ↓0 +10
Просмотры 5.1K
Комментарии 12
Комментарии Комментарии 12

Публикации

Информация

Сайт
team.cft.ru
Дата регистрации
Дата основания
1991
Численность
1 001–5 000 человек
Местоположение
Россия
Представитель
Roman Annenkov