Minipodcast. Часть 1 -- общение флэш клиента с Erlang сервером.

Intro

Задумал я тут мелкий проект -- помесь миниблога с подкастом -- миниподкаст. Зачем? Erlang пощупать, да скринкасты записать :) Ну и потом запустить это на своем сайте, чтобы доставать народ не только своей занудной писаниной, но и своим занудным голосом.

Технически все просто -- флэш клиент (audio recorder) соединяется с erlyvideo, захватывает микрофон и публикует аудиопоток. Erlyvideo сохраняет это дело в flv файл.

Затем флэш клиент соединяется с Erlang сервером (пока отдельный сервер, потом, может быть, это будет плагин к erlyvideo), и просит его сохранить инфу о записи (id, name, description, creation date и т.д.). Сервер сохраняет это дело в базе данных Mnesia.

Затем другой флэш клиент (minipodcast player) соединяется с Erlang сервером, получает список записей, отображает их. Для прослушивания записи соединяется с erlyvideo и получает flv файл.

Ну и все.

Работу над сим несложным проектом я решил осветить в серии статей и скринкастов.

Скринкаст к данной статье:

Если бы это был коммерческий проект, мы бы оценили его, помимо всего прочего, со стороны возможных рисков. В проекте есть часть, по которой нет ни знаний, ни опыта -- это написание Erlang сервера и общение с ним флэш клиента. А раз нет ни знаний, ни опыта, то эта часть самая рискованная. А раз она сама рискованная, то с нее и нужно начинать работу :)

Почему Erlang?

В коммерческом проекте мы бы выбирали технологию под требования проекта. В домашнем проекте можно поступать наоборот. И можно даже полностью проект придумать под технологию. Что я и сделал в данном случае :)

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

Erlang сервер

Разумеется, для знакомства с Erlang нужно прочитать (хотя бы частично) одну из двух книг:

А еще весьма актуальна для данного проекта книга Erlang OTP in Action, где подробно описан OTP фреймворк, стандартный для Erlang проектов. В этой книге, в 11-й главе есть практически готовый вариант TCP сервер, который нужно лишь чутка адаптировать к флэш клиенту. А именно -- реагировать на его security policy request и сериализовать/десериализовать AMF данные.

Там предлагается TCP сервер в виде стандартного OTP приложения, с супервайзером, с gen_server behaviour и прочими такого рода штуками. Он умеет принимать соединения, получать и отправлять бинарные данные. Разумеется, он может обслуживать несколько клиентов одновременно, запуская для каждого из них отдельный поток. Что не проблема для Erlang (а для Java, например, было бы проблемой).

Пересказывать то, что прекрасно описано в книге, во-первых, глупо, во-вторых, лень. Посему остановлюсь только на том, что специфично в данном проекте.

Privacy policy request

Разработчики флэш плеера решили осложнить мне, разработчику под флэш плеер, жизнь. И для этого придумали модель безопасности. Это модель подразумевает, что я, разработчик под флэш плеер, являюсь злобным созданием, и из всех сил стремлюсь навредить пользователю. В принципе подход правильный :)

Про crossdomain.xml, получение данных с веб-сервера, и кроссдоменные ограничения слыхали все. С TCP сервером (не веб) и бинарным или XML сокетом дела обстоят иначе. Когда флэш плеер соединяется с сервером он первым делом посылает запрос <policy-file-request/> и ждет определенный ответ.

Увы, если сервер не заточен специально под общение с флэш плеером, то нужного ответа не будет. И тогда флэш плеер закроет соединение и выбросит Security Error. Из-за этого я, например, не могу написать на флэше jabber-клиент, работающий, скажем, с OpenFire сервером.

Стало быть, либо вы можете модифицировать свой сервер и научить его отвечать на <policy-file-request/>, либо вы используете master policy server. Master policy server -- штука не хитрая. Он всего-то и делает, что висит на порту 843, принимает вышеупомянутые запросы, и отвечает на них. Но есть маленький нюанс -- вам понадобятся права root, чтобы запустить сервер на порту ниже 1024 :)

Итак, в модуле mp_server я определяю два макроса.


-define(POLICY_REQUEST, <<"<policy-file-request/>", 0>>).
-define(POLICY_ANSWER, <<"<?xml version=\"1.0\"?>", 
  "<cross-domain-policy>",
  "<allow-access-from domain=\"*\" to-ports=\"*\" />", 
  "</cross-domain-policy>", 0>>).

Первый из них -- запрос от флэш плеера. Второй -- правильный ответ на этот запрос. Если заморочиться, то можно ограничить доменное имя (или IP адрес) и порт, с которыми флэш плееру можно работать. Но это может быть интересно только master policy server, а тут не интересно.

Обратите внимание на нолик в конце. Флэш плеер каждый свой запрос заканчивает нулевым байтом. И сервер должен делать тоже самое.

Запросы принимает функция handle_info/2. И для нее определено несколько clouse. Два из них мы рассмотрим:


handle_info({tcp, Socket, ?POLICY_REQUEST}, State) ->
  io:format("policy request"),
  gen_tcp:send(Socket, ?POLICY_ANSWER),
  {noreply, State};

handle_info({tcp, Socket, RawData}, State) ->
  gen_tcp:send(Socket, process_data(RawData)),
  {noreply, State};

Так вот, здесь первый close как раз и ловит policy-file-request, и отвечает на него. А второй close ловит все другие запросы от клиента.

Все, проблема решена.

(Тут небольшое обсуждение, что такое close, и как это можно перевести на русский язык).

AMF сериализация

Ну вот, байты передаются туда-сюда, теперь нужна сериализация. С этим просто, На гитхабе есть подходящий проектик -- eAMF, где реализована AMF сериализация на Erlang. Всего 418 строк кода дают полноценную AMF3 сериализацию.

Забавно сравнить это с реализацией AMF на Red5, которая реализована в 17 классах и занимает 3641 срок кода. Причем я с трудом вытащил этот код из общего кода Red5, ибо оный проект не страдает модульностью. Там любой класс цепляет зависимости и тянет за собой весь проект. Мне стоило определенных трудов оборвать эти зависимости.

К тому же после покрытия оного кода юнит-тестами выяснилось, что эта реализация глючная, и ее пришлось фиксить :) Между тем eAMF уже покрыт тестами, за что автору большой респект.

Вот пару примеров, как это работает:

С клиента отправляем:
{userID:25, userName:"Bob", age:99, city:"Minks"}

На сервере получаем байты:
<<10,11,1,7,97,103,101,4,99,17,117,115,101,114,78,97,109,101,6,7,
66,111,98,13,117,115,101,114,73,68,4,25,9,99,105,116,121,6,11,
77,105,110,107,115,1>>

прогоняем через amf3:decode(Data)
и получаем красивый трейт:
{{object,<<>>,
	[{<<"age">>,99},
	{<<"userName">>,<<"Bob">>},
	{<<"userID">>,25},
	{<<"city">>,<<"Minks">>}]},
	<<>>}

Попробуем вложенный объект:

С клиента отправляем:
{userListID:48, users:[{id:1, name:"Bob"}, {id:2, name:"Bill"}]}

На сервере получаем байты:
<<10,11,1,21,117,115,101,114,76,105,115,116,73,68,4,48,11,117,115,
101,114,115,9,5,1,10,1,9,110,97,109,101,6,7,66,111,98,5,105,100,
4,1,1,10,1,4,6,9,66,105,108,108,8,4,2,1,1>>

Десериализуем:
{{object,<<>>,
	[{<<"userListID">>,48},
	{<<"users">>,
		[{object,<<>>, [{<<"name">>,<<"Bob">>},{<<"id">>,1}]},
		{object,<<>>, [{<<"name">>,<<"Bill">>}, {<<"id">>,2}]}]
	}]}, <<>>}

Попробуем типизированные данные. Создадим такой вот класс:


package com.yzh44yzh.mp
{
import flash.net.registerClassAlias;

public class User
{
	registerClassAlias("com.yzh44yzh.mp.User", User);

	public var id : int;
	public var name : String;
	public var age : uint;

	public function User(id : int, name : String, age : uint)
	{
		this.id = id;
		this.name = name;
		this.age = age;
	}
}
}


и отправим экземпляр этого класса:

С клиента отправляем:
new User(2, "Steve", 33)

На сервере получаем байты:
<<10,51,41,99,111,109,46,121,122,104,52,52,121,122,104,46,109,112,
46,85,115,101,114,5,105,100,7,97,103,101,9,110,97,109,101,4,2,4,
33,6,11,83,116,101,118,101>>

Десериализуем:
{{object,<<"com.yzh44yzh.mp.User">>,
	[{id,2},{age,33},{name,<<"Steve">>}]},
	<<>>}

Еще попробуем отправить массив таких объектов:

С клиента отправляем:
[new User(1, "John", 25), new User(2, "Bob", 24)]

На сервере получаем байты:
<<9,5,1,10,51,41,99,111,109,46,121,122,104,52,52,121,122,104,46,
109,112,46,85,115,101,114,5,105,100,7,97,103,101,9,110,97,109,
101,4,1,4,25,6,9,74,111,104,110,10,1,4,2,4,24,6,7,66,111,98>>

Десериализуем:
{[{object,<<"com.yzh44yzh.mp.User">>,
		[{id,1},{age,25},{name,<<"John">>}]},
  {object,<<"com.yzh44yzh.mp.User">>,
		[{id,2},{age,24},{name,<<"Bob">>}]}],
	<<>>}

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

Поэтому класс User мы слегка переделаем таким образом:


package com.yzh44yzh.mp
{
import flash.net.registerClassAlias;

public class User
{
	registerClassAlias("com.yzh44yzh.mp.User", User);

	public var id : int;
	public var name : String;
	public var age : uint;

	public function User()
	{
	}

	public function init(id : int, name : String, age : uint) : User
	{
		this.id = id;
		this.name = name;
		this.age = age;
		return this;
	}
}
}

На сервере создадим отдельный clause для функции process_data


process_data(<<6, 39, "test-array-of-users">>) ->
  amf3:encode([{object,<<"com.yzh44yzh.mp.User">>,
                [{id,3},{age,35},{name,<<"John Doe">>}]},
              {object,<<"com.yzh44yzh.mp.User">>,
                [{id,4},{age,34},{name,<<"Bob Bobob">>}]}]);

Таким образом, что если клиент пошлет строку "test-array-of-users",
то в ответ получит массив c экземплярами класса User:


public function onData(data : Object) : void
{
	trace("onData");
	for(var prop : String in data)
	{
		if(data[prop] is User)
		{
			var user : User = data[prop] as User;
			trace("User " + user.id + " " + user.name + " " + user.age);
		}
		else trace("[" + prop + "] [" + data[prop] + "]");
	}

	sendNextData();
}
[trace] onData
[trace] User 3 John Doe 35
[trace] User 4 Bob Bobob 34

Красота. Пользоваться этим -- одно удовольствие :)

Тестовый клиент

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

Поэтому отдельным модулем в проекте сделан тестовый клиент. Пока он нужен только для того, чтобы проверить работу сервера и прогнать все эти варианты AMF сериализации. Клиент соединяется и посылает серию запросов с разными данными. Затем принимает ответы сервера и выводит их трейсами в отладчик. Вот и все, что он делает.


package com.yzh44yzh.mp
{

import com.yzh44yzh.mp.service.BinService;

import com.yzh44yzh.mp.service.IServiceListener;

import flash.display.Sprite;

public class MinipodcastTestClient extends Sprite implements IServiceListener
{
	private var service : BinService;

	private var data2send : Array = [
		{userID:25, userName:"Bob", age:99, city:"Minks"},
		{userListID:48, users:[{id:1, name:"Bob"}, {id:2, name:"Bill"}]},
		new User().init(2, "Steve", 33),
		[new User().init(1, "John", 25), new User().init(2, "Bob", 24)],
		"test-array-of-users"
	];

	private var nextData : int = 0;

	public function MinipodcastTestClient()
	{
		service = new BinService(this);
		service.init();
	}

	public function onConnect() : void
	{
		sendNextData();
	}

	public function onData(data : Object) : void
	{
		trace("onData");
		for(var prop : String in data)
		{
			if(data[prop] is User)
			{
				var user : User = data[prop] as User;
				trace("User " + user.id + " " + user.name + " " + user.age);
			}
			else trace("[" + prop + "] [" + data[prop] + "]");
		}

		sendNextData();
	}

	private function sendNextData() : void
	{
		if(nextData >= data2send.length) return;
		
		trace("sendNextData " + nextData);

		service.send(data2send[nextData]);
		nextData ++;
	}
}
}

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

Еще я не показал класс BinService, в котором спрятан бинарный сокет и собственно работа с сервером. Этот класс нужен всем клиентам, поэтому он вынесен в отдельный модуль.


package com.yzh44yzh.mp.service
{
import flash.events.Event;
import flash.events.IOErrorEvent;
import flash.events.ProgressEvent;
import flash.events.SecurityErrorEvent;
import flash.net.Socket;

public class BinService
{
	private var sock : Socket;

	private var listener : IServiceListener;

	public function BinService(listener : IServiceListener)
	{
		this.listener = listener;
	}

	public function init() : void
	{
		trace("init");

		sock = new Socket();
		sock.addEventListener(Event.CONNECT, onConnect);
		sock.addEventListener(Event.CLOSE, onClose);
		sock.addEventListener(ProgressEvent.SOCKET_DATA, onData);
		sock.addEventListener(IOErrorEvent.IO_ERROR, onError);
		sock.addEventListener(SecurityErrorEvent.SECURITY_ERROR, onError);

		sock.connect("localhost", 1055);
	}

	private function onConnect(ev : Event) : void
	{
		trace("onConnect");
		listener.onConnect();
	}

	public function send(data : Object) : void
	{
		trace("send");
		sock.writeObject(data);
		sock.flush();
	}

	private function onData(ev : ProgressEvent) : void
	{
		trace("onData [" + ev + "]");
		trace("sock.bytesAvailable: " + sock.bytesAvailable);

		var data : Object = sock.readObject();
		listener.onData(data);
	}

	private function onClose(ev : Event) : void
	{
		trace("onClose");
		sock.close();
	}

	private function onError(ev : Event) : void
	{
		trace(ev.toString());
	}
}
}

Продолжение следует...

Comments

У тебя в {tcp, Socket, Bin} может прийти тысяча пакетиков по одному байту и ты должен склеить их сам.

Надо накапливать в буфере и анализировать каждый раз.

yzh44yzh's picture

ок, исправлюсь )

Отличная статья! Но когда же будет продолжение?

yzh44yzh's picture

Замысел мне самому нравится и интересен. Но конкретных сроков я не планирую.

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

Не обязательно вешать отвечалку на полиси-реквест на 843-й порт. Когда с флеша подключаешься к какому-то порту он в этот порт и будет запрос слать. А на 843-й параллельно отсылает такой же запрос. Так что на 843-й можно забить и без рутовых прав обойтись. Проверено лично :-)

yzh44yzh's picture

Мастер-сервер на 843 порту нужен, когда вы не можете или не хотите изменеть поведение сервера, с которым работает ваша флэшка.

Допустим, не вы писали этот сервер, и он не умеет отвечать на policy-request. И у вас нет сорцов от него. Или есть, но лазить в них долго.

Вот тогда пригодится мастер-сервер.

Например, я запускаю мастер-сервер, чтобы моя флэшка могла работать с jabber-сервером Open Fire http://www.igniterealtime.org/projects/openfire/

А, в таком случае да, полностью согласен.

Add new comment

Filtered HTML

  • Web page addresses and e-mail addresses turn into links automatically.
  • Allowed HTML tags: <a> <em> <strong> <cite> <blockquote> <code> <ul> <ol> <li> <dl> <dt> <dd>
  • Lines and paragraphs break automatically.

Plain text

  • No HTML tags allowed.
  • Web page addresses and e-mail addresses turn into links automatically.
  • Lines and paragraphs break automatically.
CAPTCHA
question for bots )
Image CAPTCHA
Enter the characters shown in the image.