Доброго времени суток, друзья!
В процессе работы над одним сервисом, фронтенд которого построен на AngularJS, возникла необходимость общаться с сервером socket.io по разным логическим каналам. При этом было желание обойтись всего одним реальным физическим соединением с сервером и иметь в коде приложения отдельные объекты подключений для каждого канала. Что из этого получилось, можно посмотреть под катом.
socket.io namespaces
Как оказалось, библиотека socket.io предоставляет возможность создания так называемых namespaces, которые помогают мультиплексировать сообщения от различных подсистем в рамках одного физического соединения. Клиентский код при этом выглядит примерно следующим образом:
var channelMessages = io.connect('http://localhost:3000/messages'),
channelMessages.on('message received', function() { /* notify about new message */ });
// ...
channelCommands = io.connect('http://localhost:3000/commands');
channelCommands.emit('init');
channelCommands.on('command received', function() { /* process new command */ });
Т.е. каждый вызов io.connect
возвращает новый объект соединения, обладающий методами on
и emit
. Вот именно для таких объектов соединений и захотелось иметь по AngularJS-сервису на каждый для удобного общения с сервером.
AngularJS-сервис socket v.1
На момент возникновения желания получить несколько именованных сокетов, сервис для работы с socket.io выглядел примерно так, как описано в этой статье.
app.factory('socket', function ($rootScope) {
var socket = io.connect();
return {
on: function (eventName, callback) {
socket.on(eventName, function () {
var args = arguments;
$rootScope.$apply(function () { apply(socket, args); });
});
},
emit: function (eventName, data, callback) {
socket.emit(eventName, data, function () {
var args = arguments;
$rootScope.$apply(function () { if (callback) { callback.apply(socket, args); } });
})
}
};
});
Фактически это примитивная обертка вокруг on
и emit
, приводящая к обновлению всех scopes при получении сообщения/подтверждения отправки сообщения. Соединение с сервером происходит единожды при инициализации сервиса (так как AngularJS вызывает метод factory
один раз для обеспечения «синглтоновости» сервисов).
$apply()
. Но для данной статьи это является оффтопиком.С появлением необходимости в namespace'ах, подход с созданием соединения с сервером в момент инициализации сервиса socket перестал работать. Плюс возникла необходимость в нескольких экземплярах сервиса, каждый из которых подключен к собственному каналу. И еще одно очевидное требование — избежать дублирования кода при создании именованных подключений в будущем.
AngularJS-сервис Socket v.2
Примерно к этому моменту разработки наступил некоторый когнитивный диссонанс. Именованных соединений должно быть много, а сервис в AngularJS — синглтон. Первой попыткой разрешить эту проблему была идея, что AngularJS уже умеет делать нечто подобное из коробки. Как оказалось, существует как минимум 3 способа создания сервисов. Простейший — это module.service
, который принимает конструктор, с помощью которого будет создан объект сервиса по требованию. Чуть более гибкий способ — это module.factory
, позволяющий более удобно, чем непосредственно в конструкторе, произвести некоторые дополнительные действия, прежде чем вернуть экземпляр сервиса. И самый гибкий способ — это module.provider
. Судя по названию, можно предположить, что возможно указать зависимость клиентских модулей от провайдера, и в клиентском коде писать что-то вроде socketsProvider.get('foo')
, чтобы получить именованное соединение /foo
. Однако же, module.provider
позволяет лишь единожды сконфигурировать экземпляр сервиса, а клиентский код должен быть зависим не от провайдера, а, непосредственно, от самого сервиса.
После обсуждения проблемы с коллегами, возникла идея расширить сигнатуры on
и emit
сервиса socket, добавив в них первым параметром namespace
, а внутри сервиса держать пул lazy-соединений. На каждый вызов on
или emit
необходимо было бы делать проверку, существует ли уже соединение с заданным namespace, и если нет — создавать новое. А для реализации объектов именованных соединений, пришлось бы создавать легковесные сервисы socketFoo
, socketBar
и т.п., обладающие собственными реализациями on
и emit
, каррирующими socket.on
и socket.emit
, фиксирую параметр namespace константными значениями 'foo' и 'bar'. Рабочее решение, но обладающее весомым недостатком — при расширении набора методов socket, клиенты сервисов socketFoo
и socketBar
не получили бы возможности вызывать новые методы socket'а без изменения существующего кода сервисов socketFoo
и socketBar
.
Поломав голову еще немного удалось вспомнить, что в качестве сервисов в AngularJS может выступать любой объект, в том числе функция! Классический паттерн использования сервсисов, как экземпляров, может быть изменен на следующий подход:
var module = angular.module('myApp.services', []);
app.factory('MyService', function() {
function MyService(options) { /* код инициализации сервиса */ }
MyService.prototype.baz = function() { /* ... */ };
MyService.prototype.qux = function() { /* ... */ };
return MyService;
});
// ...
module.factory('clientService', function(MyService) {
var myService = new MyService({foo: 1, bar: 42});
myService.qux();
// return ...
});
Изменился не только способ создания сервиса, но и способ именования. Вместо традиционного camelCase
(означающего, что мы имеем дело с экземпляром) используется CamelCase
, чтобы показать, что сервис на самом деле является конструктором. Используя этот подход был реализован сервис Socket
:
var services = angular.module('myApp.services', []);
services.factory('Socket', ['$rootScope', function($rootScope) {
var connections = {}; // пул соединений, каждое из которых создается по требованию
function getConnection(channel) {
if (!connections[channel]) {
connections[channel] = io.connect('http://localhost:3000/' + channel);
}
return connections[channel];
}
// При создании нового сокета, он инициализируется namespace-частью строки подключения.
function Socket(namespace) {
this.namespace = namespace;
}
Socket.prototype.on = function(eventName, callback) {
var con = getConnection(this.namespace), self = this; // получение или создание нового соединения
con.on(eventName, function() {
var args = arguments;
$rootScope.$apply(function() { callback.apply(con, args); });
});
};
Socket.prototype.emit = function(eventName, data, callback) {
var con = getConnection(this.namespace); // получение или создание нового соединения.
con.emit(eventName, data, function() {
var args = arguments;
$rootScope.$apply(function() { if (callback) { callback.apply(con, args); } });
})
};
return Socket;
}]);
Конкретные реализации именованных подключений, от которых будут зависеть клиентские модули, могут выглядеть примерно таким образом:
var services = angular.module('myApp.services.channels', []);
// Тривиальная реализация.
services.factory('channelFoo', function(Socket) { return new Socket('foo'); });
// Реализация с расширением функциональности.
services.factory('channelBar', function(Socket) {
function ChannelBar() { this.namespace = 'bar'; }
ChannelBar.prototype = angular.extend(Socket.prototype, {});
ChannelBar.prototype.start = function() { this.emit('start'); };
ChannelBar.prototype.exit = function() { this.emit('exit'); };
return new ChannelBar();
});
Созданные таким образом каналы обладают как преимуществом автоматического наследования всей функциональности от базового объекта Socket, так и требуют значительно меньше шаблонного кода, чем в случае с каррированием socket.on
и socket.emit
.
Заключение
Приведенный пример реализации сервиса Socket
является лишь концептом. Для полноценного использования его необходимо дополнить возможностью инжекта объекта io
, настроек строки подключения и авторизации, а также возможностью указания scope'ов, которые необходимо обновлять при получении сообщений от сервера. Код с примером можно найти на github.
Автор: Ostrovski