ServerSide ActionScript живет в довольно узкой нише. Для него нет каких-то общепринятых стандартов, фреймворков и библиотек. Сам Адоб в развитии FMS напирает на работу с видео (и это совершенно обосновано), а язык остается в стороне, ибо он не так много участвует в этой работе.
Конечно, хорошо и правильно использовать какие-то общеизвестные библиотеки и фреймворки, прошедшие шлифовку временем, проверенные в сотнях проектов тысячами разработчиков. Но для FMS ничего подобного нет. Нет также каких-либо стандартов построения серверного приложения. Все нужно придумывать самим.
И поэтому у меня набралось некоторое количество велосипедов собственного изготовления, которыми я хочу поделиться. Из них самый интересный, который я изготовил всего пару недель назад и которым очень горжусь -- фреймворк для юнит-тестирования с простым и выразительным названием Tester :)
UPDATE: Нижеописаный код устарел, но заложеные в нем идеи остались те же. Свежий код смотрите тут https://github.com/yzh44yzh/fms-tools.
Одно из неудобств SSAS это то, что каждый asc файл нужно загрузить методом load(). У наших проектах таких файлов 2-3 десятка, так что нужно 30 раз писать вызов этого метода.
Поэтому я написал класс Core, которые упрощает загрузку asc файлов (помимо некоторых других задач).
Core = {};
Core.toString = function() { return 'Core'; }
Core.verboseLoading = false;
Core._loadedLibs = ['core/Core.asc', 'utils/UArray.asc'];
load('utils/UArray.asc');
G = {}; // global scope
Core.Error = function(msg)
{
trace('^ ERROR ^ ' + msg);
}
Core.LoadAllLibs = function(dir)
{
if(this.verboseLoading) trace(this + '.LoadAllLibs [' + dir + ']');
var d = new File(dir);
if(!d.exists)
{
Core.Error(this + '.LoadAllLibs dir not exists [' + dir + ']');
return false;
}
if(!d.isDirectory)
{
Core.Error(this + '.LoadAllLibs dir is not a directory [' + dir + ']');
return false;
}
var files = d.list();
for(var i = 0; i < files.length; i++)
{
var file = files[i];
if(!file.isFile) continue;
var lib = file.name;
if(lib.indexOf('/') == 0) lib = lib.substr(1);
this.LoadLib(lib);
}
}
Core.LoadLib = function(lib)
{
if(UArray.FindIndex(this._loadedLibs, lib) != -1) return;
if(this.verboseLoading) trace(this + '.LoadLib [' + lib + ']');
this._loadedLibs.push(lib);
load(lib);
}
Core.Build = function(objName)
{
if(G[objName])
{
Core.Error(objName + ' is already exists');
return;
}
G[objName] = {};
}
Core.LoadAllLibs("core");
Core.LoadAllLibs("utils");
Как видите, он находит все asc файлы в заданном каталоге и подключает каждый из них. Некоторые файлы и каталоги он подключает сам по умолчанию, остальные -- если его явно об это попросит. Ну и еще он проверяет, чтобы один и тот же файл не подключался дважды.
Вот так он используется в main.asc
load("core/Core.asc");
Core.LoadAllLibs('settings');
Core.LoadAllLibs('client');
Core.LoadAllLibs('room');
По-хорошему надо бы еще сделать, чтобы он обходил каталоги рекурсивно, но я поленился. Ибо у меня нет вложенности глубже одного каталога.
Я делю объекты приложения на синглeтоны и динамические объекты. Синглeтоны создаются именно как объекты, а не как классы, что исключает возможность создания второго экземпляра.
Динамические объекты предназначены для того, чтобы создавать много экземпляров, и я создаю их как классы.
// core или util синглетон
UtilObject = {};
UtilObject.prop1 = 'value1';
UtilObject.DoSomething = function() {};
// специфичный для приложения синглетон
Core.Build('RoomManager');
G.RoomManager._rooms = new Hash();
// динамический объект
function Room()
{
this.id = 'room0';
this.name = 'Room';
this.Join = function(client);
}
// другой способ создания динамического объекта, которым я не пользуюсь :)
Room = function()
Room.prototype.id = 'room0';
Room.prototype.name = 'Room';
Room.prototype.Join = function(client);
Синглeтонами я делаю Core и Util объекты, составляющие библиотеку общего назначения, применяемую во всех приложениях. Этих я создаю в глобальной области видимости.
И синглетонами делаю некоторые объекты, специфичные для конкретного приложения. Но этих я создаю в отдельной области видимости с помощью фабричного метода Core.Build() (он есть в листинге класса Core), что позволяет проверять существование такого объекта и не создавать его дважды.
Что касается динамических объектов, то есть два разных синтаксиса для их создания, дающих идентичный результат. Смешивать оба эти синтаксиса я считаю плохим стилем, и волевым решением выбрал только один из них, чтобы применять всегда, и отверг другой, чтобы не применять никогда :) При этом руководствовался исключительно эстетическими мотивами, других мотивов для выбора не было :)
Внимание, мы начинаем приближаться к юнит-тестам. Итак, серверное приложение активно работает с объектам типа Client, которые представляю соединение с клиентским приложением. Объекты этого типа системные, они создаются и удаляются неявно. И программист сам не может создавать такие объекты, а значит их невозможно использовать в юнит-тестах. А между тем, на них приходится значительная доля функциональности.
Значит взамен клиента нужно что-то другое, какой-то объект-заглушка, который мог бы имитировать оригинального клиента. И вот он:
function MockClient()
{
this.id = Util.GenerateId('mc');
this.ip = '00.00.00.00';
this.referrer = 'http://localhost/';
this.lastCallMethod = null;
this.lastCallData = null;
this.toString = function() { return 'MockClient ' + this.id; }
this.call = function(methodName, resultObj, param1, param2, param3)
{
this.lastCallMethod = methodName;
this.lastCallData = param1;
}
this.ClearLastCall = function()
{
this.lastCallMethod = null;
this.lastCallData = null;
}
}
Так видите, кода тут совсем не много. Добавляются только три свойства: ip, id, referrer и один метод -- call, которые есть у клиента. Причем без этих свойств вполне можно и обойтись, важен только метод call, ибо он часто используется чтобы передать данные клиентскому приложению.
В MockClient мы сохраняем эти данные, вместо того, чтобы куда-то передавать. Благодаря этому в тестах мы можем проверить, какие данные были переданы.
Очевидно сам по себе MockClient не многое умеет делать. И оригинальный класс Client тоже умеет немного. Ценность его в свойствах и методах, которые добавляются разработчиком. Эти методы и составляют API серверного приложения, которое можно вызывать из клиентской части.
И нам нужен какой-то способ, чтобы добавлять эти методы и в класс Client, и в класс MockClient. И я сделал специальный класс-миксер, который добавлять (примешивать) методы в переданный ему объект. Причем ему безразлично, какой именно объект (Client или MockClient) был передан.
Core.Build('ClientMixer');
G.ClientMixer.Init = function(client)
{
client.user = new User();
// more properties here
client.Login = function(userName, password)
{
}
client.GetRoomList = function()
{
return G.RoomManager.GetAllRooms();
}
client.JoinRoom = function(roomID)
{
var res = G.RoomManager.Join(roomID, this);
return {success:res, roomID:roomID};
}
// more methods here
}
В режиме тестов будут передаваться экземпляры MockClient, а режиме реальной работы приложения -- экземпляры Client.
Возможно вас немного напугало прототипное ООП, но на самом деле писать код под FMS очень легко. При этом даже не обязательно понимать это самое ООП, а можно просто запомнить типовые синтаксические конструкции и пользоваться ими.
Встроенных объектов у FMS сервера немного, используется в работе еще меньшая их часть, и все это легко запоминается.
Да, писать легко. Но при этом сложно отлаживать :) Прямо скажем, отладка FMS приложения -- весьма утомительное занятие. Давайте представим себе этот процесс:
Пункты 2-4 можно автоматизировать. Можно написать свое приложение (мне подойдет bash-скрипт, запускаемый в консоли), которое будет копировать код на FMS сервер, пользуясь админиским API рестартовать приложение и выводить в консоль логи. Админское API доступно через HTTP-туннелинг, что очень кстати, ибо позволяет рестартовать приложение GET-запросом по HTTP. Отображение логов можно сделать лучше, чем в админконсоли: добавить фильтрацию, подсветку разными цветами значимых частей, показ/скрытие времени каждого трейса.
Но пункты 5-7 -- это утомительная ручная работа. Имейте в виду, что в языке нет статической типизации и даже опечатки в названии свойства или метода выявляются только на 7 этапе.
Хочется две вещи: как-то выявлять банальные ошибки, типа опечаток, на ранних этапах; и как-нибудь отлаживать серверное приложение без необходимости запускать клиентские приложения.
Эти два желания привели меня к юнит-тестам.
Идея Test Driven Development (разработка через тестирование) состоит в том, что перед тем, как реализовать какую-либо функциональность, мы пишем тесты, проверяющие, что функциональность работает правильно. Запускаем эти тесты, и они проваливаются. Ибо функциональность не реализована. Потом реализуем функциональность, и запускаем тесты еще раз. Потом исправляем ошибки ( в том числе в тестах :) и опять запускаем. И так пока тесты не пройдут.
Важный момент здесь -- сперва тесты, потом реализация. Инстинктивно хочется сделать наоборот, ибо мозг за долгие годы программирования привык сперва делать, потом проверять. Но желательно переломить этот инстинкт. Тогда начинаешь думать более качественно, абстрагировавшись от проблем реализации.
Когда пишем тест, функциональность представляем себе просто черным ящиком. Что-то подаем на вход, что-то получаем на выходе, смотрим, то ли получили, что ожидали. Кучу мелких задач, которые неизбежно связаны с реализацией, выкидываем из головы.
В итоге имеем две выгоды:
Я сам, когда читал умные книжки по теме, не особо в это верил. Мало ли чего пишут. Но на практике оказалось именно так -- код в целом проще и понятнее. Для меня это оказалось не только техникой программирования, но и некой ментальной дисциплиной -- изменило образ мышления.
Ну и да, метод не панацея и не везде применим. И я не спешу применять его в клиентской части (где все гораздо сложнее для TDD), а на сервере обкатаю как следует.
Готовых решений для тестирования FMS нету. Но я знаком с теорией, поэтому без труда сделал свой велосипед. Благодаря гибкости SSAS он получился небольшим и простым.
testHolder = {};
AssertTrue = function(condition, message)
{
if(condition) return true;
else
{
trace(' ~ ' + message);
return false;
}
}
AssertFalse = function(condition, message)
{
return AssertTrue(!condition, message);
}
Tester = {};
Tester.toString = function() { return 'Tester'; }
Tester._tasks = [];
Tester._total = 0;
Tester._passed = 0;
Tester._failed = 0;
Tester.LoadAllTasks = function(dir)
{
var d = new File(dir);
if(!d.exists)
{
Core.Error(this + '.LoadAllTasks dir not exists [' + dir + ']');
return false;
}
if(!d.isDirectory)
{
Core.Error(this + '.LoadAllTasks dir is not a directory [' + dir + '] # ');
return false;
}
var files = d.list();
for(var i = 0; i < files.length; i++)
{
var file = files[i];
var task = file.name;
if(task.indexOf('/') == 0) task = task.substr(1);
this.AddTask(task);
}
}
Tester.AddTask = function(taskFile)
{
if(UArray.FindIndex(this._tasks, taskFile) != -1) return;
this._tasks.push(taskFile);
}
Tester.Run = function()
{
trace('= ' + this + '.Run');
for(var i = 0; i < this._tasks.length; i++) this._RunTask(this._tasks[i]);
trace('=== test results: ===')
trace('total: ' + this._total + ' passed: ' + this._passed + ' failed: ' + this._failed);
trace('\n\n\n');
}
Tester._RunTask = function(taskFile)
{
trace('= run task [' + taskFile + ']');
var f = new File(taskFile);
if(!f.exists)
{
Core.Error(this + '._RunTask file not exists [' + taskFile + '] ');
return;
}
f.open('utf8', 'read');
var lines = f.readAll();
f.close();
load(taskFile);
for(var i = 0; i < lines.length; i++)
{
var line = lines[i];
if(line.indexOf('testHolder.Test') == -1) continue;
var test = line.substring(line.indexOf('testHolder.Test') + 11, line.indexOf(' = function'));
this._RunTest(test);
}
}
Tester._RunTest = function(testName)
{
this._total++;
trace('== run test [' + testName + ']');
if(testHolder[testName]())
{
this._passed++;
}
else
{
this._failed++;
trace(' # FAIL TEST [' + testName + '] # ');
}
}
Tester загружает все тесты в заданной папке, каждый файл (Task) парсит, находит в нем функции-тесты и запускает их. Собирает и показывает статистику тестов.
Чтобы от него была какая-то польза, нужны тесты. Например такие:
testHolder.TestLoginSuccess = function()
{
var client = new MockClient();
G.ClientMixer.Init(client);
var answer = new XML('<Auth result="OK"><userData><id>user1</id>' +
'<name><![CDATA[Bob]]></name><gender>male</gender>' +
'<level>regular</level></userData></Auth>');
client.LoginResult(answer.firstChild);
var res = true;
res = AssertTrue(client.user != null, 'client.user must be not null') && res;
res = AssertTrue(client.user.id == 'user1', 'invalid client.user.id') && res;
res = AssertTrue(client.user.name == 'Bob', 'invalid client.user.name') && res;
res = AssertTrue(client.user.gender == 'male', 'invalid client.user.gender') && res;
res = AssertTrue(client.user.level == 'regular', 'invalid client.user.level') && res;
res = AssertTrue(client.lastCallMethod == 'LoginResult', 'invalid lastCallMethod') && res;
res = AssertTrue(client.lastCallData != null, 'invalid lastCallData') && res;
res = AssertTrue(client.lastCallData.id == 'user1', 'invalid lastCallData.id') && res;
res = AssertTrue(client.lastCallData.name == 'Bob', 'invalid lastCallData.name') && res;
return res;
}
testHolder.TestLoginFail = function()
{
var client = new MockClient();
G.ClientMixer.Init(client);
var answer = new XML('<Auth result="FAIL"/>');
client.LoginResult(answer.firstChild);
var res = true;
res = AssertTrue(client.user.id == '', 'client.user.id must be empty') && res;
res = AssertTrue(client.lastCallMethod == 'LoginResult', 'invalid lastCallMethod') && res;
res = AssertTrue(client.lastCallData.id == '', 'lastCallData.id must be empty') && res;
return res;
}
testHolder.TestJoinLeaveRoom = function()
{
var client1 = new MockClient();
G.ClientMixer.Init(client1);
var client2 = new MockClient();
G.ClientMixer.Init(client2);
G.RoomManager.InitWithDefaultRooms();
var res = true;
res = AssertFalse(G.RoomManager.Join('room1', client1),
'adding emply user must not be successful') && res;
client1.InitUser({id:'user1', name:'Bill', gender:'male', level:'regular'});
client2.InitUser({id:'user2', name:'Bob', gender:'male', level:'regular'});
res = AssertFalse(G.RoomManager.Join('roomX', client1),
'adding user to not existing room must not be successful') && res;
res = AssertTrue(G.RoomManager.GetNumUsers('room1') == 0,
'room must be empty before adding users') && res;
res = AssertTrue(G.RoomManager.Join('room1', client1),
'adding client1 to room1 must be successful') && res;
res = AssertTrue(G.RoomManager.GetNumUsers('room1') == 1,
'must be 1 user in room after adding client1') && res;
res = AssertTrue(G.RoomManager.Join('room1', client2),
'adding client2 to room1 must be successful') && res;
res = AssertTrue(G.RoomManager.GetNumUsers('room1'),
'must be 2 users in room after adding client2') && res;
res = AssertFalse(G.RoomManager.Leave('roomX', client1),
'removing user from invalid room must not be successful') && res;
res = AssertTrue(G.RoomManager.Leave('room1', client1),
'removing client1 from room1 must be successful') && res;
res = AssertTrue(G.RoomManager.GetNumUsers('room1') == 1,
'must be 1 user in room after removing client1') && res;
res = AssertTrue(G.RoomManager.Leave('room1', client2),
'removing client2 from room1 must be successful') && res;
res = AssertTrue(G.RoomManager.GetNumUsers('room1') == 0,
'room must be after removing client2') && res;
res = AssertFalse(G.RoomManager.Leave('room1', client2),
'removing not existing user from room must not be successful') && res;
G.RoomManager.Clear();
return res;
}
И все это подключается в main.asc следующим образом:
application.onAppStart = function()
{
Tester.LoadAllTasks('test');
Tester.Run();
}
Ну и все :)
Add new comment