Разумеется, никто нам не мешает заняться смешиванием и подгонкой. К тому или иному стилю некоторые технологии могут подходить более естественно. Тем не менее нужно оценить особенности ряда различных технических реализаций, что в дальнейшем поможет сделать правильный выбор.
Для начала обратимся к двум технологиям, которые хорошо подходят для рассмотрения стиля «запрос — ответ»: удаленному вызову процедуры (RPC) и передаче репрезентативного состояния (REST).
Удаленный вызов процедуры является технологией локального вызова, который выполняется где-то на удаленном сервисе. У технологии RPC имеется множество разновидностей. Некоторые из них основаны на применении определенного интерфейса (SOAP, Thrift, Protocol Buffers). Использование отдельного определения интерфейса может облегчить создание клиентских и серверных заглушек для различных технологических стеков, таким образом, к примеру, у меня может быть Java-сервер, выставляющий SOAP-интерфейс, и. NET-клиент, созданный из определения интерфейса на языке описания веб-сервисов (WSDL). Другие технологии вроде Java RMI предусматривают более тесную связанность между клиентом и сервером, требующую, чтобы оба они использовали одинаковую исходную технологию, но при этом исключается необходимость в определении совместно используемого интерфейса. Но все эти технологии имеют одинаковые основные характеристики в том смысле, что они делают локальный вызов, похожий на удаленный вызов.
Многие из этих технологий, такие как Java RMI, Thrift или Protocol Buffers, являются двоичными по своей природе, в то время как в SOAP для форматов сообщений используется язык XML. Некоторые реализации привязаны к конкретному сетевому протоколу (например, SOAP, который номинально использует HTTP), в то время как другие могут допускать использование различных типов сетевых протоколов, которые сами по себе могут обеспечить дополнительные возможности. Например, TCP предоставляет гарантии доставки, а UDP их не дает, но имеет намного меньше издержек. Это может позволить применять различные сетевые технологии для различных вариантов использования.
Те RPC-реализации, которые позволяют создавать клиентские и серверные заглушки, содействуют весьма быстрому старту. Я могу в два счета отправлять контекст через сетевую границу. Зачастую это служит одним из основных аргументов в пользу RPC — эта технология проста в использовании. Тот факт, что я могу просто сделать обычный вызов метода и теоретически проигнорировать все остальное, является существенным подспорьем.
Но некоторые RPC-реализации не лишены недостатков, которые могут вызвать проблемы. Изначально эти проблемы могут быть не столь очевидными, но тем не менее могут оказаться довольно серьезными, чтобы это перевесило преимущества от простоты получения реализации и ее быстрой работы.
Некоторые RPC-механизмы, такие как Java RMI, сильно привязаны к конкретной платформе, что может ограничить выбор технологий для применения на клиенте и сервере. У Thrift и Protocol Buffers имеется впечатляющий диапазон поддержки альтернативных языков, что может некоторым образом сгладить данный недостаток, но при этом следует иметь в виду, что у некоторых RPC-технологий существуют ограничения по возможностям взаимодействия.
В известном смысле эта технологическая связанность может быть одной из форм обнажения деталей внутренней технической реализации. Например, при использовании RMI осуществляется привязка к JVM не только клиента, но и сервера.
Основной замысел RPC заключается в скрытии сложности удаленного вызова. Но многие реализации RPC скрывают их слишком сильно. Управление в некоторых разновидностях RPC с целью сделать удаленный вызов метода похожим на локальный вызов скрывает тот факт, что эти два вызова очень не похожи друг на друга. Можно сделать огромное количество локальных вызовов в рамках одного и того же процесса, практически не переживая о потере производительности. Но при использовании RPC затраты на маршализацию и обратный ей процесс могут быть весьма существенными, даже если не обращать внимания на пересылку по сети. Это означает, что конструкцию удаленного API нужно продумывать иначе, чем конструкцию локальных интерфейсов. Нельзя просто взять локальный API и попытаться без дополнительных размышлений сделать из него границу сервиса, поскольку, скорее всего, ничего, кроме проблем, вы не получите. Если абстракция слишком непрозрачна, то в самых худших примерах разработчики могут использовать удаленные вызовы, даже не зная об этом.
Нужно подумать и о самой сети. Когда речь идет о распределенном вычислении, самым распространенным первым заблуждением является уверенность в надежности сети. Сети не могут быть абсолютно надежными. Они могут и будут отказывать, даже если речь идет о вполне благополучных клиентах и серверах. Они могут сбоить часто или редко, а могут и вовсе портить ваши пакеты. Всегда нужно предполагать, что сети могут подвергнуться воздействию недоброжелателей, готовых в любой момент излить на вас свою злость. Поэтому можно ожидать всяческих отказов. Сбой может быть вызван тем, что удаленный сервер возвратил ошибку, или тем, что вы составили неверный вызов. Можете вы отличить одно от другого, и если да, то можете ли что-то с этим сделать? А что делать, когда удаленный сервер просто начинает медленно реагировать на ваши вызовы? К этой теме мы еще вернемся, когда в главе 11 будем рассматривать эластичность системы.
Некоторые из наиболее популярных реализаций RPC могут стать причиной опасных форм хрупкости, и хорошим примером этого может послужить Java RMI. Рассмотрим весьма простой Java-интерфейс, который мы решили сделать удаленным API для нашего сервиса клиентов. В примере 4.1 объявляются методы, которые мы собираемся удаленно представить на всеобщее обозрение. Затем Java RMI сгенерирует клиентскую и серверную заглушки для нашего метода.
Пример 4.1. Определение конечной точки сервиса с помощью Java RMI
import java.rmi.Remote;
import java.rmi.RemoteException;
public interface CustomerRemote extends Remote {
public Customer findCustomer(String id) throws RemoteException;
public Customer createCustomer(String firstname, String surname, String emailAddress)
throws RemoteException;
}
В данном интерфейсе findCustomer получает имя, фамилию и адрес электронной почты. А что произойдет, если будет принято решение разрешить объекту Customer также быть созданным лишь с адресом электронной почты? В данном случае мы без особого труда можем добавить следующий новый метод:
…
public Customer createCustomer(String emailAddress) throws RemoteException;
…
Проблема в том, что теперь необходимо заново создать также клиентские заглушки. Клиенты, желающие использовать новый метод, нуждаются в новых заглушках, и в зависимости от характера изменений в спецификации тем потребителям, которые не нуждаются в новом методе, также может потребоваться обновление их заглушек. Конечно, с этим можно справиться, но до определенного момента. Дело в том, что подобные изменения встречаются довольно часто. На поверку конечные точки RPC зачастую имеют множество методов для различных способов создания объектов или взаимодействия с ними. Отчасти это связано с тем, что мы до сих пор думаем об этих удаленных вызовах как о локальных.
Но есть еще одна разновидность хрупкости. Посмотрим, на что похож наш объект Customer:
public class Customer implements Serializable {
private String firstName;
private String surname;
private String emailAddress;
private String age;
}
Что, если теперь выяснится, что, несмотря на выставление на всеобщее обозрение в наших объектах Customer поля возраста age, никто из потребителей им не пользуется? Мы решим, что поле нужно удалить. Но если серверная реализация удаляет поле age из своего определения данного типа, а мы не сделаем того же самого для всех потребителей, даже если они никогда не воспользуются этим полем, код, связанный с десериализацией объекта Customer на стороне потребителя, будет поврежден. Чтобы отменить это изменение, я буду вынужден одновременно развернуть как новый сервер, так и новых клиентов. В этом и состоит основная проблема с использованием любого RPC-механизма, продвигающего создание двоичной заглушки: вы не получаете возможности отдельных развертываний клиента и сервера. При использовании данной технологии в будущем вас ожидают такие вот одновременные выпуски с блокировкой всей работы.
Такая же проблема возникнет при желании реструктурировать объект Customer, даже если я не стану удалять какие-либо поля, например, если мне захочется для упрощения управления заключить firstName и surname в новый поименованный тип. Разумеется, я могу справиться с этим путем повсеместной передачи словарных типов в качестве параметров своего вызова, но тогда мне придется расстаться со многими преимуществами создания заглушек, поскольку мне все равно придется вручную искать соответствия и извлекать нужные поля.
На практике объекты, используемые как часть двоичной сериализации отправляемых по сети данных, могут рассматриваться как типы, предназначенные только для раскрытия. Такая хрупкость приводит к типам, раскрываемым для всех, кто находится в сети, и превращаемым во множество полей, часть из которых больше не используются, но не могут быть безопасно удалены.
Несмотря на все недостатки RPC, я не стану сгущать краски и называть его страшным. Некоторые из наиболее распространенных реализаций, с которыми мне приходилось сталкиваться, могли приводить к возникновению тех проблем, которые здесь уже были очерчены. Из-за сложностей использования RMI я, конечно же, постарался бы обойтись без этой технологии. В модель на основе RPC неплохо вписываются многие операции, а более современные механизмы, такие как Protocol Buffers или Thrift, сглаживают некоторые из прошлых грехов, исключая необходимость одновременных выпусков кода клиента и сервера с блокировкой всей работы.