Мультимедиа-программирование вместе с Red 5 server. Часть 7
Сегодняшняя статья продолжит рассказ об одной из самых полезных возможностей, которые получаются от объединения flash и red5, а именно, SharedObjects. “Общие объекты” представляют собой отличное средство для организации взаимодействия и обмена информацией между несколькими flash-клиентами, подключенными к red5 серверу. В прошлый раз мы разобрали пример приложения чат, в котором демонстрировалось, как клиенты могут обмениваться между собой текстовыми сообщениями. Однако остался нераскрытым вопрос об участии в этом “общении” не только flash-клиентов, но и red5-сервера, точнее написанных на java-приложений, выполняющихся в среде red5 и использующих всевозможные библиотеки и прочие “вкусности”, доступные для java-программистов.
Однако перед тем как перейти к теме java-кода, “разговаривающего” с SharedObject, сначала нужно закрыть вопрос о жизненном цикле SharedObject-ов. Т.е. когда “общий объект” создается и когда он уничтожается. Для создания хороших приложений нужно четко понимать в какой момент времени происходит каждое из этих событий и то, от чего они зависят. Например, ответим на вопрос: когда происходит создание SharedObject и можно ли в этот момент выполнить какую-то специальную работу по его подготовке к дальнейшему использованию? Существует два вида SharedObject-ов в зависимости от того, кем они создаются: сервером или клиентом. Показанный в прошлой статье пример чата, демонстрировал методику, когда SharedObject создавался flash-клиентом:
chat = SharedObject.getRemote("chat", nc.uri);
chat.addEventListener(SyncEvent.SYNC, onSync)
Итак, когда какой-либо из flash-клиентов вызывает метод getRemote, то flash обращается к серверу red5 и спрашивает его: вдруг, в рамках текущей комнаты (scope) уже есть SharedObject с запрошенным именем? Если это так, то клиенту возвращается ссылка на этот существующий SharedObject. В противном же случае red5 создает новый “пустой” SharedObject, который будет возвращен и этому и всем последующим клиентам. Всякий раз, когда кто-либо из посетителей чата отсоединяется от SharedObject-а, вызывая на нем метод close или просто закрывая окно браузера с нашим flash-роликом. То на сервер red5 посылается специальное сообщение об этом. И как только абсолютно все клиенты отключатся от “общего объекта”, то сервер red5 может его безопасно удалить. В практике использовать создание объектов, инициируемое клиентом, неудобно из-за того, что часто приходится выполнять какую-то подготовительную работу. Т.е. даже в нашем простом примере с чатом, я после получения ссылки на SharedObject, анализировал его состояние и если переданный мне SharedObject был “новорожденным”, то я создавал в нем свойство “история сообщений в чате”:
if (!chat.data.remoteHistory)
chat.data.remoteHistory = [];
Как вы понимаете, в более серьезных приложениях процедура начальной настройки SharedObject-а будет гораздо сложнее, и ее лучше выполнять на java-стороне. Не говоря уже о том, что на java-стороне можно к подобному SharedObject-у привязать управляемые spring-ом объекты. Например, сервисы доступа к данным (dao), сервисы обмена сообщениями (jms); и все эти сервисы могут работать в рамках общей “длинной” транзакции. Т.е. действие, начатое одним пользователем (бизнес-операция и связанная с ней транзакция) будет завершено (транзакция будет подтверждена) другим пользователем. Главное, чтобы эти пользователи были подключены к одному SharedObject-у, тогда они могут видеть и работать с одной и той же информацией. Следующий текст о том, что такое scope или комнаты (room), как они соотносятся с приложением (app) и какой у них жизненный цикл, нужно читать, держа перед глазами текст, приведенный в прошлой статье: именно там я ввел базовые понятия комнат, их иерархии и связи с SharedObject-ами. Для демонстрации я создал новое red5 приложение, которое состоит из тех методов, что вызываются при наступлении различных событий в жизненном цикле red5 приложения. Устройство всех нижележащих методов тривиально: на экран просто печатается название метода, который был вызван:
public class HelloApplication extends ApplicationAdapter {
public boolean appConnect(final IConnection conn, Object[] params) {
System.out.println("appConnect: client " + conn.getClient());
return super.appConnect(conn, params);
}
public boolean roomConnect(IConnection conn, Object[] params) {
System.out.println("roomConnect: conn " + conn);
return super.roomConnect(conn, params);
}
public boolean appStart(IScope app) {
System.out.println("appStart: scope " + app);
return super.appStart(app);
}
public void appStop(IScope app) {
System.out.println("appStop: scope " + app.getName());
super.appStop(app);
}
public boolean roomStart(IScope room) {
System.out.println("roomStart: room " + room.getName());
return super.roomStart(room);
}
public void roomStop(IScope room) {
System.out.println("roomStop: room " + room.getName());
super.roomStop(room);
}
public boolean appJoin(IClient client, IScope app) {
System.out.println("appJoin: app " + app.getName());
return super.appJoin(client, app);
}
public boolean roomJoin(IClient client, IScope room) {
System.out.println("roomJoin: room " + room.getName());
return super.roomJoin(client, room);
}
public void roomLeave(IClient client, IScope room) {
System.out.println("roomLeave: room " + room.getName());
super.roomLeave(client, room);
}
public void appLeave(IClient client, IScope app) {
System.out.println("roomLeave: app " + app.getName());
super.appLeave(client, app);
}
}
На стороне flash-клиента я создал простенькое приложение, состоящее из одного текстового поля и кнопки. В текстовое поле вы должны ввести произвольную строку – адрес подключения к red5 приложению. А после нажатия на кнопку “подключиться” именно этот адрес будет использован при вызове метода connect:
<?xml version="1.0" encoding="utf-8"?>
<mx:Application xmlns:mx="http://www.adobe.com/2006/mxml" layout="absolute">
<mx:Script><![CDATA[
private var nc:NetConnection;
private function doConnect(event:MouseEvent):void {
nc = new NetConnection();
nc.objectEncoding = ObjectEncoding.AMF0;
nc.addEventListener(NetStatusEvent.NET_STATUS, netStatus);
nc.connect('rtmp://localhost/'+txtRooms.text);
}
private function netStatus(event:NetStatusEvent):void {
trace (event);
}
]]></mx:Script>
<mx:VBox paddingTop="10" paddingLeft="10">
<mx:HBox>
<mx:TextInput id="txtRooms" text="warmodule/fruits/apples/"/>
<mx:Button click="doConnect(event)" label="подключиться"/>
</mx:HBox>
</mx:VBox>
</mx:Application>
Полученную пару приложений удобно использовать как исследовательский инструмент, чтобы понять то, как происходит создание комнат, как выполняется присоединение нового пользователя к существующей комнате и когда комнаты уничтожаются. Очевидно, что методы, начинающиеся на “app”, обозначают события из жизненного цикла всего приложения, а методы, начинающиеся на “room”, – связаны с жизненным циклом отдельной комнаты. Скомпилировав и запустив созданное java-приложение в среде red5-сервера, мы сразу же увидим в его консоли с сообщениями строку:
appStart: scope [WebScope@16e3a7e Depth = 1, Path = '/default', Name = 'warmodule']
Это значит, что метод appStart вызывается сразу после завершения запуска сервера и до того, как пришел запрос на подключение от первого из клиентов. Таким образом, внутри этого метода удобно размещать код инициализации ресурсов общих для всего приложения и для всех клиентов (например, загрузить spring контекст с сервисами приложения). Теперь откроем в браузере flash-клиент и немного с ним “поиграем”. Для начала введем в текстовое поле строку "warmodule/fruits/apples/". Таким образом, flash-клиент попробует подключиться по следующему полному адресу: “rtmp://localhost/warmodule/fruits/apples”. Здесь warmodule – это имя нашего веб-приложения, а имена “fruits/apples” задают иерархию двух комнат с такими же названиями. Из анализа сообщений напечатанных в консоли сервера можно сделать вывод, что app и join-методы методы вызываются в следующем порядке:
roomStart: room fruits
roomStart: room apples
appConnect: client Client: 0
appJoin: app warmodule
roomConnect: conn apples
roomJoin: room fruits
roomConnect: conn apples
roomJoin: room apples
Если открыть еще одно окно браузера и повторно нажать на кнопку подключения к серверу с тем же адресом, то список сообщений будет следующим:
appConnect: client Client: 1
appJoin: app warmodule
roomConnect: conn apples
roomJoin: room fruits
roomConnect: conn apples
roomJoin: room apples
Как видите, метод roomStart вызывается один только раз: при первом подключении клиента к комнате. А после того как комната “стартовала”, то клиенты к ней присоединяются (join). Методы connect вызываются всякий раз, когда к комнате или приложению приходит запрос на подключение. Если закрыть окошко браузера, то в консоли будет напечатан следующий набор сообщений, говорящих о том, что пользователь покинул (leave) следующие комнаты. Видите, что клиент “покидает” комнаты в порядке обратном тому, в котором он в них “заходил”:
roomLeave: room apples
roomLeave: room fruits
roomLeave: app warmodule
Когда же я закрою и второе окно браузера, то помимо “leave” методов, red5 вызовет еще методы остановки комнат “roomStop” последовательно для комнат apples и fruits. После того как комната была остановлена, то red5 уничтожает все ресурсы, связанные с ней (в том числе и SharedObject-ы). Если в последующем клиент еще раз захочет присоединиться к комнате, то она будет заново создана (вызваны методы “roomStart”). Далее: как и ожидалось, отключение абсолютно всех клиентов не приводит к остановке приложения. Т.е. метод “appStop” будет вызван только тогда, когда сам веб-сервер получит сигнал на завершение работы. В качестве эксперимента, попробуйте подключиться к серверу с адресом "warmodule/fruits/apples/", а сразу за этим в еще одном окне браузера подключиться с адресом "warmodule/fruits/grapes/". Вы должны увидеть, что метод “roomStart” будет вызван только для “новой” комнаты “grapes”, а для “старых” комнат будет вызван метод присоединения “roomJoin”. Приятно, что в любой момент времени вы можете легко узнать ту иерархию комнат, к которой присоединяется клиент, если воспользуйтесь следующим примером:
IScope scope = Red5.getConnectionLocal().getScope();
while (scope != null) {
System.out.println("scope " + scope);
scope = scope.getParent();
}
Возвращаясь назад к задаче создания SharedObject-ов: внутри методов roomStart удобнее всего и выполнять создание “общих объектов”. В следующем примере я переиграю наш старый пример с чатом и перенесу код инициализации массива с историей сообщений в чате с flash-клиента на red5-сервер. Также я решил перенести с flash-стороны на java код метода, добавляющего в историю сообщений чата новую запись от клиента при его регистрации в чате, а также сообщение, посылаемое пользователем при нажатии на кнопку “send”. Здесь предполагается, что мы возьмем пример из прошлой статьи и, оставив без изменения его mxml-разметку, отвечающую за внешний вид, попробуем переписать часть бизнес-логики. Во-первых, я раз я решил что, модификация SharedObject-а, точнее его истории, будет выполняться на стороне сервера, а показываться на стороне клиента, то мне потребуется создать общий класс, экземпляры которого будут хранить записи в истории чата. Как я уже говорил в предыдущих статьях, создать подобный общий тип данных совсем не сложно: нужно на стороне java и на стороне flash-клиента создать классы с одинаковыми названиями и одинаковой структурой. Также требуется, чтобы у этих классов был конструктор с пустым списком параметров, а те поля, которыми мы хотим обмениваться должны быть либо объявлены как public, либо иметь привязанные к ним методы get и set. Итак, вот что у меня получилось на стороне flash:
package blz.red5demo {
[RemoteClass(alias="blz.red5demo.ChatHistoryItem")]
public class ChatHistoryItem {
public var user:String;
public var date:Date;
public var message:String;
public function ChatHistoryItem() { }
}
}
И соответствующий ему аналог на стороне java:
package blz.red5demo;
import java.util.Date;
public class ChatHistoryItem {
public String user;
public Date date;
public String message;
}
Теперь смотрим, как выглядит код flash-клиента применительно к функции создания SharedObject-а. Эти действия я выполняю внутри функции netStatus сразу после того, как я получил извещение о том, что соединение с java-стороной было успешно установлено:
private function netStatus(event:NetStatusEvent):void {
if (event.info.code == 'NetConnection.Connect.Success') {
chat = SharedObject.getRemote("chat", nc.uri);
chat.addEventListener(SyncEvent.SYNC, onSync)
chat.connect(nc);
}
}
Как видите, я обращаюсь все к тому методу SharedObject.getConnection для получения ссылки на SharedObject с именем “chat”, находящегося в той же scope (комнате), что была создана на стадии подключения к red5-серверу. В следующем java-коде я в тот же момент времени, когда создается комната, выполняю ее начальную настройку, т.е. создаю SharedObject:
public boolean roomStart(IScope room) {
if (!super.roomStart(room))
return false;
createSharedObject(room, "chat", false);
ISharedObject chat = getSharedObject(room, "chat");
chat.setAttribute("remoteHistory", new ArrayList<ChatHistoryItem>());
return true;
}
После того как соединение было установлено и создан SharedObject, то flash-клиент получит первое уведомление о синхронизации с SharedObject-ом. Именно здесь я должен послать в чат сообщение о том, что клиент только что зашел в чат. Но, я не хочу выполнять эту работу внутри flash-клиента – я хочу отправить извещение java-приложению, чтобы оно выполнило связанную с регистрацией нового клиента работу. Например, чат может ограничивать количество посетителей или требовать предварительной регистрации пользователей: т.е. действия, выполнить которые на стороне flash-клиента, будет тяжело. И только после всех этих проверок серверный код может изменить содержимое SharedObject-а. Для вызова серверного метода я использую знакомый нам по прошлым статьям метод “call” на объекте NetConnection (почему метод вызывается не на SharedObject, я расскажу попозже). Момент в том, что на этот раз второй параметр функции call равен null. Дело в том, что как раз вторым параметром flash-код передает ссылку на специальный объект Responder, который “слушает” извещения об успешном (или не успешном) завершении вызова серверного метода. Для моего примера чата такой контроль является избыточным, так что я решил отказаться от Responder-а.
private function onSync(event:SyncEvent):void {
if (chatIsReady == false) {
chatIsReady = true;
nc.call("chatLogin", null, txtUser.text);
}
localHistory.source = chat.data.remoteHistory;
}
На java-стороне я определил внутри класса приложения метод chatLogin, который принимает как параметр имя пользователя, зашедшего в чат, и помещает эту информацию в историю сообщений чата:
public void chatLogin(String userName) {
chatMessage(userName, "Пользователь вошел в чат");
}
public void chatMessage(String userName, String message) {
IScope scope = Red5.getConnectionLocal().getScope();
ISharedObject chat = getSharedObject(scope, "chat");
List<ChatHistoryItem> history = (List<ChatHistoryItem>) chat.getAttribute("remoteHistory");
ChatHistoryItem item = new ChatHistoryItem();
item.user = userName;
item.date = new Date();
item.message = message;
history.add(item);
chat.setAttribute("remoteHistory", history);
}
Как видите, и на стороне сервера и на стороне flash-клиента вся работа с SharedObject-ом сводится к изменению составляющих его атрибутов. Плюс, в том случае, если java-код хочет “за один раз” изменить несколько атрибутов, то нужно окружить эти действия вызовом функций beginUpdate и endUpdate():
chat.beginUpdate();
// а тут меняем атрибуты
chat.endUpdate();
Завершающий штрих нашей переделки чата – это изменение методики, с помощью которой в чат добавляется текстовое сообщение. Здесь я снова решил использовать прием с посредником: flash-клиент посылает извещение java-приложению, которое после каких-то проверок пришедшего сообщения (например, на предмет наличия стоп-слов) модифицирует SharedObject. А дальше “магия” red5 выполняет рассылку изменений всем зарегистрированным flash-клиентам:
private function say(e:MouseEvent):void {
if (! chatIsReady) {
mx.controls.Alert.show("Сначала нужно подключиться к серверу");
return;
}
nc.call("chatMessage", null, txtUser.text, txtMessage.text);
}
То, что у меня получилось, показано на рис. 1.
И хотя визуально чат не отличается от своего предшественника, пример с которым мы разбирали в прошлой статье, но возможности по расширению функционала у сегодняшнего примера гораздо выше. В следующий раз я завершу рассказ о работе с SharedObject-ами, рассказав о методиках сохранения содержимого SharedObject-а в какое-то постоянное хранилище (например, в файл или базу данных).