СЕРГЕЙ СУПРУНОВ
Внутренний веб-сервер
Что админу хорошо, то пользователю – смерть.
Народная мудрость
Очень часто системному администратору, работающему в небольшой фирме, попутно приходится разрабатывать программы для внутренних нужд компании. Требования к подобному ПО, как правило, невысоки, но это с лихвой компенсируется очень сжатыми сроками, отводимыми на разработку. Освоить Delphi для того, чтобы на предприятии появилась программа-каталог пользователей Интернета или программа, формирующая материальные отчеты, было бы просто замечательно. Но времени на подобные вещи, как обычно, не хватает. И потому приходится идти другим путём. В данной статье я хочу рассказать о своем опыте использования веб-сервера для решения подобных задач.
Почему был выбран столь экзотический способ? Во-первых, Perl я знаю несколько лучше, чем Delphi или C++ Builder. Во-вторых, такой подход без лишних усилий позволяет создавать клиент-серверные приложения, с которыми одновременно могут работать сотни пользователей, не заботясь о разработке клиентских программ – с этой ролью отлично справится любой браузер. В-третьих, налицо независимость от конкретных платформ и операционных систем – для сервера достаточно, чтобы на нем мог работать Apache + Perl (вы можете использовать и другую связку), от клиентов требуется лишь поддержка какого-нибудь графического обозревателя. И наконец, времени на разработку и сопровождение ПО в данном случае затрачивается заметно меньше, чем при «традиционных» способах.
Для наглядности рассмотрим в общих чертах процесс разработки незатейливого приложения: каталога пользователей ADSL, в который будут заноситься сведения об абонентах (фамилия, адрес, номер телефона), параметры конфигурации (IP-адреса, интерфейсы, номера PVC), параметры линии (длина, диаметр жилы, сопротивление шлейфа) и т. д.
В целях экономии места будет рассмотрена только первая функция (работа со сведениями об абонентах). Очень многие детали придется опустить. Так, не будут рассмотрены особенности Perl-модулей, подключаемых к нашим сценариям, способы работы с базой данных и т. д. Читателю понадобятся, по крайней мере, базовые знания Perl, HTML, PostgreSQL (или какой-нибудь другой СУБД). Если материал статьи окажется вам интересен, я постараюсь разложить все по полочкам в следующих статьях, оставляйте ваши отзывы на форуме журнала «Системный администратор».
Подготовительные мероприятия
Итак, прежде всего нам нужно собрать сервер. Я остановил свой выбор (поскольку все это уже есть и работает) на следующем ПО (правда, кое-что из этого списка уже пора бы обновить):
- ОС: FreeBSD 5.2
- Веб-сервер: Russian Apache 1.3.29
- Язык программирования: Perl 5.6.1
- СУБД: PostgreSQL 7.4.2
Рассматривать установку и конфигурирование всего этого я не буду – все довольно подробно описано и на страницах журнала, и на бескрайних просторах Интернета. Выбор именно этого ПО – вопрос, скорее, личных предпочтений, поскольку по каждому из пунктов можно привести массу как положительных, так и отрицательных доводов. В конце концов с тем же успехом (с поправкой на более высокие требования к ресурсам и вопросам безопасности) можно использовать и связку «Windows2003 – IIS – ASP – MSSQL».
Структура базы данных и доступ к СУБД
Начнем разработку с определения структуры БД. Нужно заметить, что это итерационный процесс, то есть обычно при разработке сценариев выясняется, что база данных должна быть несколько иной, потом корректировки вновь вносятся в код сценариев, снова исправляется БД и так до тех пор, пока разработчик не осознает, что эффект от дальнейшего улучшения уже не окупает затрат на исправления. Но мы остановимся на первой итерации, тем более что наша задача – показать сам принцип.
Итак, создадим БД с именем adsl, владельцем которой будет пользователь adsluser с паролем password. В ней нам потребуются следующие таблицы:
- sessions – информация сеансов пользователей (см. далее):
- id char(32) – идентификатор сессии;
- a_session text – информация сессии;
- login char(12) – имя пользователя;
- password varchar – пароль пользователя.
- users – информация об абонентах ADSL:
- uid serial – уникальный идентификатор абонента;
- name varchar – фамилия, имя, отчество;
- address varchar – адрес проживания;
- phone char(7) – номер телефона.
- dslam– информация о ADSL-портах:
- uid serial – уникальный идентификатор порта;
- userid numeric – идентификатор подключенного на порт абонента;
- num char(10) – номер порта (в виде nDSLAM/nBOARD/nPORT);
- vlan numeric(4) – номер VLAN, соответствующей ADSL-порту;
- vpi numeric(3) – номер VPI;
- vci numeric(3) – номер VCI, присвоенный клиенту;
- interface char(15) – имя интерфейса, на котором будет вестись учет трафика;
- ipaddress inet – IP-адрес, сопоставленный с данным портом.
- lines – характеристики линий связи:
- uid serial – уникальный идентификатор линии;
- portid numeric – идентификатор порта DSLAM, на который подключена эта линия;
- length numeric(5) – длина линии в метрах;
- diameter numeric(2,1) – диаметр жилы в миллиметрах;
- impedance numeric(4) – сопротивление шлейфа в Омах.
И еще одна таблица для хранения служебной информации:
- st_modules – список функциональных модулей:
- name char(20) – имя модуля;
- description varchar – описание модуля;
- ink varchar – ссылка на сценарий модуля;
- allow char(12)[] – массив, хранящий имена пользователей, которым позволено работать с данным модулем;
- orderby numeric(2) – данное поле задает порядок вывода модулей на экран.
В данном случае мы минимально задействуем расширенные возможности PostgreSQL, что позволит почти ничего не менять при использовании, например, MySQL.
Шаблон сайта – модульный подход
Поскольку переписывать все сначала при необходимости расширить функциональность нашего приложения – занятие не очень интересное, применим модульный подход. Пусть основной сценарий отвечает только за предоставление доступа к имеющимся функциям, а каждая функция будет реализована отдельным скриптом. Кроме того, часто используемые операции будем выносить в наш модуль My::Insite.
Выглядеть базовый сценарий будет примерно так:
#!/usr/bin/perl –w
#-------------------------------------------- adsl.cgi
use My::Insite;
# Подключаемся к БД и создаем объект CGI для работы с HTTP
$dbh = My::Insite->DBConnect("adsl", "adsluser", "password");
$cgi = My::Insite->CGIStart();
# Считываем значение HTTP-параметра «action»
($action = $cgi->param("action")) or $action = "";
# Выполняем процедуру выхода
if($action eq "logoff") { &doLogoff; }
# Процедура авторизации
if($action eq "logon") {
$savedLogin = $cgi->param("login");
$savedPassword = $cgi->param("password");
# ищем сессию для заявленного логина
($sSessId, $sPassword) = $dbh->selectrow_array("
SELECT id, password FROM sessions WHERE login=?
", undef, $savedLogin);
# если не нашли – повторный запрос авторизации
if(!$sSessId) { &doLogon("Failed"); }
# если сессия есть, но пароль не соответствует введенному, повторный запрос авторизации
if($sPassword ne $savedPassword) { &doLogon("Wrong"); }
# Если все нормально – сохраняем идентификатор сессии в cookie
$cookie = $cgi->cookie(-name => "sessid", -value => $sSessId);
print $cgi->header(-cookie => $cookie);
print "Авторизация выполнена успешно.";
print " <A href="adsl.cgi">Продолжить...</A>";
exit;
}
# action не имеет значения, пытаемся извлечь из cookie идентификатор сессии
$sessId = $cgi->cookie("sessid");
# Если безуспешно – уходим на авторизацию
if(!$sessId) { &doLogon("First"); }
# Если sessId есть, пытаемся получить пользователя этой сессии
($sLogin) = $dbh->selectrow_array("
SELECT login FROM sessions WHERE id=?;
", undef, $sessId);
# Если удачно – открываем сессию, иначе – на авторизацию
if($sLogin) {
$session = My::Insite->SessOpen($dbh, $sessId);
} else { &doLogon("Fialed"); }
# Выбираем из БД и выводим на экран список модулей
print $cgi->header;
$sth = $dbh->prepare("SELECT * FROM st_modules ORDER BY orderby;");
$sth->execute;
print "<P align="right">Вы вошли под именем $sLogin | ";
print " <A href="?action=logoff">Выход</A></P>";
while($rhash = $sth->fetchrow_hashref) {
# Печатать будем только те модули, для которых в поле allow есть имя вошедшего пользователя
if($$rhash{allow} =~ m($sLogin)) {
print "<DT><A href="$$rhash{link}"> $$rhash{name}</A>";
print "<DD>$$rhash{description}<BR>";
}
}
# Все закрываем (в принципе это не обязательно – все и так закроется)
My::Insite->SessClose($session);
My::Insite->DBDisconnect($dbh);
exit;
#-------------------------------------------- подпрограммы
sub doLogon { # подпрограмма авторизации
$status = shift @_;
if($status eq "Wrong") {
$status = Неправильный логин или пароль.";
} elsif($status eq "Failed") {
$status = Ошибка подключения данного пользователя.";
} else {
$status = Введите логин и пароль:";
}
print $cgi->header();
print <<__HTML__;
<CENTER><H3>$status</H3><FORM method="POST">
<TABLE border="1"><TR><TD><TABLE>
<INPUT type="hidden" name="action" value="logon">
<TR><TD>Login:<TD><INPUT type="text" name="login" value="">
<TR><TD>Password:
<TD><INPUT type="password" name="password" value="">
<TR><TD colspan="2" align="center">
<INPUT type="submit" value="Войти">
</TABLE></TABLE></FORM></CENTER>
__HTML__
exit;
}
sub doLogoff { # подпрограмма закрытия сеанса
# Записываем cookie с истекшим «сроком годности» (отрицательное значение параметра expire), что уничтожит cookie в памяти
$cookie = $cgi->cookie(-name => "sessid",
-value => "",
-expires => "-1d");
print $cgi->header(-cookie => $cookie);
print "<HEADER>";
print "<META http-equiv="refresh" content="1;url=adsl.cgi">";
print "</HEADER>";
print "До новых встреч!";
exit;
}
Задача данного сценария – выполнить авторизацию пользователя и предоставить ему список доступных для работы модулей. Управление поведением сценария осуществляется с помощью переменной «action», которая может иметь одно из следующих значений: logoff (закрыть сеанс), logon (выполнить процедуру авторизации, в ходе которой проверяется правильность пароля и открывается сессия, соответствующая данному пользователю, о чем делается запись в файлах cookie). Пустое значение данной переменной позволит вывести на экран перечень доступных модулей.
Список модулей хранится в БД, в таблице st_modules, откуда он выбирается и выводится на экран, причем отображаются только те модули, для которых в поле allow содержится имя текущего пользователя. Больше ничего от главного сценария не требуется. Для подключения к приложению очередного модуля достаточно поместить в папку cgi-bin реализующий его сценарий и добавить запись в таблицу st_modules. То есть в этой таблице будет что-то похожее:
adsl=> select link, name, allow from st_modules order by orderby;
link | name | allow
---------------------+------------------------------------------+----------------------
adsl-users.cgi | Абоненты ADSL | {"admin","operator"}
adsl-dslam.cgi | Конфигурация DSLAM | {"admin"}
adsl-admin.cgi | Модуль администратора | {"admin"}
(записей: 3)
|
Результат работы сценария adsl.cgi представлен на рисунках 1 и 2.
Рисунок 1
Рисунок 2
Модуль My::Insite в моем случае будет размещаться по такому адресу: /usr/local/lib/perl5/site_perl/5.6.1/My/Insite.pm. Узнать пути, по которым Perl ищет подключаемые модули, позволяет специальная переменная @INC:
#!/usr/bin/perl
#-------------------- testpath.pl
$, = " ";
print @INC;
exit;
На моей машине результат был получен следующий:
$ ./testpath.pl
/usr/local/lib/perl5/site_perl/5.6.1/mach
/usr/local/lib/perl5/site_perl/5.6.1
/usr/local/lib/perl5/site_perl
/usr/local/lib/perl5/5.6.1/BSDPAN
/usr/local/lib/perl5/5.6.1/mach
/usr/local/lib/perl5/5.6.1
|
Код модуля My::Insite представлен ниже:
package My::Insite;
use CGI;
use DBI;
use Apache::Session::Postgres;
sub CGIStart {
return CGI->new;
}
sub DBConnect {
my($obj, $dbName, $dbUser, $dbPwd) = @_;
return DBI->connect("dbi:Pg:dbname=".$dbName, $dbUser, $dbPwd);
}
sub DBDisconnect {
my($obj, $dbh) = @_;
$dbh->disconnect;
return(1);
}
sub SessOpen {
my($obj, $dbh, $sessId) = @_;
tie %session, "Apache::Session::Postgres", $sessId,
{Handle => $dbh, LockHandle => $dbh};
return(bless(\%session, $obj));
}
sub SessClose {
my($obj, $session) = @_;
untie(%$session);
return(1);
}
return(1);
Как видите, сюда вынесены функции подключения к БД, работы с сессиями и т. д.
Может показаться, что в некоторых функциях нет смысла. Например, зачем создавать CGIStart, которая только и делает, что вызывает функцию new() модуля CGI? Не проще ли вызывать эту функцию самому и не захламлять модуль?
А теперь представьте, что вы решили вместо модуля CGI перейти на более функциональный. Что проще – переписывать все имеющиеся сценарии или изменить одну функцию в My::Insite?
Думаю, в этом модуле все понятно без комментариев. Если что непонятно – всегда под рукой man DBI, man CGI, man Apache::Session.
Доступ на сайт и Apache::Session
Если вы доверяете всем сотрудникам или собираетесь ограничивать доступ к сайту «низкоуровневыми» средствами вроде брандмауэра для ограниченного круга лиц с равными правами, то этот пункт можно пропустить. В общем же случае желательно организовать проверку «подлинности» пользователя и соответствующим образом ограничивать его права в нашей программе. В серьезных случаях можно дополнительно организовать SSL-шифрование, но сейчас обойдемся без этого, чтобы не отвлекаться от основной цели.
Пароли для простоты хранить и передавать будем в явном виде, признак правильного входа в приложение, а заодно и некоторые персональные настройки будем хранить, используя механизм сессий. В Perl это выглядит несколько сложнее, чем в PHP, зато проще сделать именно то, что нужно. Для работы понадобится модуль Apache::Session. Если на вашей системе такого нет, для FreeBSD его, как и большинство других модулей, можно установить из коллекции портов:
# cd /usr/ports/www/p5-Apache-Session
# make install
Более универсальный путь, пригодный практически для всех систем – использование архива CPAN. Этот метод описан на страницах руководства man perlmodinstall.
Так как база данных у нас есть, целесообразно для хранения сессионной информации использовать именно ее. Поэтому будем использовать подмодуль Apache::Session:: Postgres. Поскольку число пользователей нашего приложения ограничено и все они известны, то имеет смысл для каждого из них заранее создать сессию, в которой будут храниться все пользовательские данные, и при авторизации подключать именно ее. Такой подход позволит не беспокоиться об удалении старых сессий и о хранении идентификатора сессии между сеансами. Вручную создавать новую сессию не очень удобно, поэтому будем использовать такой небольшой сценарий:
#!/usr/bin/perl –w
#------------------------------------------ adsl-adduser.pl
use DBI;
use Apache::Session::Postgres;
$login = $ARGV[0]; # первый аргумент – имя
$password = $ARGV[1]; # второй – пароль
# Запрашиваем все, что не передано в аргументах
if(!$login) {
print "Enter login: ";
chomp($login = <>);
}
if(!$password) {
print "Enter password: ";
chomp($password = <>);
}
# Создаем новую сессию и сразу закрываем
$dbh = DBI->connect("dbi:Pg:dbname=adsl", "adsluser", "password");
tie %session, "Apache::Session::Postgres", undef,
{Handle => $dbh, LockHandle => $dbh};
untie %session;
# В запись в таблице sessions, соответствующей нашему сеансу, добавляем имя пользователя и пароль, введенные выше
$pre = $dbh->prepare("
update sessions
set login = ?, password = ?
where login is null;
");
$pre->execute($login, $password);
$dbh->disconnect;
exit;
При желании этот модуль можно сделать CGI-скриптом и организовать доступ к нему через модуль администрирования, подключаемый к нашему приложению, как и все остальные.
Ну и раз информация сессий будет необходима нам в каждом модуле, то процедуры работы с ней вынесены в наш модуль My::Insite. Как все это будет работать – смотрите в листингах, приведенных в статье.
Взаимодействие с БД
Для работы с базой данных будем использовать Perl-модуль DBI с драйвером DBD::Pg. Данный модуль и нужный драйвер можно установить как из портов, так и из CPAN. Функции открытия и закрытия соединения вынесены в модуль My::Insite, остальное смотрите в коде конкретных модулей.
Модуль обработки информации об абоненте
Вот мы и добрались до первого «рабочего» модуля. В его рамках нам нужно решить следующие задачи: вывод на экран списка абонентов, ввод нового абонента, удаление абонента, изменение данных.
Код модуля следующий:
#!/usr/bin/perl –w
#------------------------------------ adsl-users.cgi
use My::Insite;
$dbh = My::Insite->DBConnect('adsl', 'adsluser', 'password');
$cgi = My::Insite->CGIStart();
$action = $cgi->param('action');
# Пытаемся считать из cookie идентификатор сессии, если безуспешно – отправляем на авторизацию
$sessId = $cgi->cookie('sessid');
if(!$sessId) {
&toLogon;
}
# Открываем сессию, или на авторизацию в случае ошибки
($sLogin) = $dbh->selectrow_array('
SELECT login FROM sessions WHERE id=?;
', undef, $sessId);
if($sLogin) {
$session = My::Insite->SessOpen($dbh, $sessId);
} else {
&toLogon;
}
# Проверяем, можно ли данному пользователю работать с этим модулем
($allow) = $dbh->selectrow_array('
SELECT allow FROM st_modules WHERE link=?;',
undef, 'adsl-users.cgi');
if($allow !~ m($sLogin)) {
&toLogon;
}
print $cgi->header;
# Разбираем возможные действия
if ($action eq '' ) { &showUsers; }
elsif($action eq 'user' ) { &userForm; }
elsif($action eq 'changeuser') { &changeUser; }
else { print 'Не могу выполнить: '.$action; }
My::Insite->SessClose($session);
My::Insite->DBDisconnect($dbh);
exit;
#------------------------------------- subroutines
sub showUsers { # подпрограмма вывода списка абонентов
$sth = $dbh->prepare('SELECT uid, name, address, phone
FROM users ORDER BY name;');
$sth->execute;
print '<TABLE><TR><TD><H3>Абоненты</H3>';
print '<TD align="right"><A href="adsl.cgi">Главная</A>';
print '<TR><TD colspan="2">';
print '<TABLE><TR><TD align="right">';
print '[ <A href="?action=user&type=add">
Добавить нового абонента</A> ]';
print '<TR><TD><TABLE border=1><TR bgcolor=#AAAAFF>
<TH>Абонент
<TH>Адрес
<TH>Телефон
<TH>Действие;
while(@res = $sth->fetchrow_array) {
$oper = ($res[6] eq 'I'?'inlager':'outlager');
print "<TR><TD>$res[1]<TD>$res[2]<TD>$res[3]<TD>
[ <A href='?action=user&type=update&uid=$res[0] '>Изменить</A> ] ::
[ <A href='?action=user&type=delete&uid=$res[0] '>Удалить</A> ]";
}
print '</TABLE><TR><TD align="right">';
print '[ <A href="?action=user&type=add">Добавить нового абонента</A> ]';
print '</TABLE></TABLE>';
return;
}
sub userForm { # выводит форму для манипуляций с данными
$uid = $cgi->param('uid');
$type = $cgi->param('type');
if($type eq 'add') { $submitName = 'Добавить'; }
elsif($type eq 'delete') { $submitName = 'Удалить'; }
elsif($type eq 'update') { $submitName = 'Изменить'; }
else {
print 'Ошибка операции: '.$type;
exit;
}
$header = $submitName.' абонента:';
if($type ne 'add') {
($name, $address, $phone) = $dbh->selectrow_array('
SELECT name, address, phone FROM users WHERE uid = ?;
', undef, $uid);
} else {
$name = $address = $phone = '';
}
if($type eq 'delete') {
$in1 = "<B>$name</B>";
$in2 = "<B>$address</B>";
$in3 = "<B>$phone</B>";
} else {
$in1 = "<INPUT type='text' name='name' value='$name' size='35'>";
$in2 = "<INPUT type='text' name='address' value='$address' size='35'>";
$in3 = "<INPUT type='text' name='phone' value='$phone' size='7'>";
}
print <<__HTML__;
<CENTER><H3>$header</H3>
<FORM method="GET">
<TABLE border="1"><TR><TD><TABLE>
<INPUT type="hidden" name="action" value="changeuser">
<INPUT type="hidden" name="type" value="$type">
<INPUT type="hidden" name="uid" value="$uid">
<TR><TD>Абонент: <TD>$in1
<TR><TD>Адрес: <TD>$in2
<TR><TD>Телефон: <TD>$in3
<TR><TD colspan="2"><HR>
<TR><TD>[ <A href="adsl-users.cgi?action=">Отмена</A> ]
<TD align="right"><INPUT type="submit" value="$submitName">
</TABLE></TABLE></FORM></CENTER>
__HTML__
exit;
}
sub changeUser { # запись изменений в БД
$type = $cgi->param('type');
$uid = $cgi->param('uid');
$name = $cgi->param('name');
$address = $cgi->param('address');
$phone = $cgi->param('phone');
if($type eq 'add') {
$res = $dbh->do('
INSERT INTO users(name, address, phone)
VALUES(?, ?, ?);',
undef, $name, $address, $phone);
} elsif($type eq 'delete') {
$res = $dbh->do('DELETE FROM users WHERE uid=?;', undef, $uid);
} elsif($type eq 'update') {
$res = $dbh->do('
UPDATE users SET name=?, address=?, phone=? WHERE uid=?;
', undef, $name, $address, $phone, $uid);
} else { print 'Ошибка операции: '.$type; }
if($res) { print 'Операция выполнена успешно. '};
print '<BR><A href="?action=">Продолжить...</A>';
exit;
}
sub toLogon {
print $cgi->header;
print '<META http-equiv="refresh" content="1;url=adsl.cgi?account=logon">';
print 'Ошибка входа. Перенаправление...';
exit;
}
В данном случае для управления поведением сценария используется еще одна переменная – type. Если action определяет, на какую подпрограмму следует передавать управление, то type содержит информацию о том, что именно следует делать в данной подпрограмме.
Сгенерированный приложением список абонентов имеет вид, представленный на рисунке 3. Рисунок 4 демонстрирует форму для изменения данных.
Рисунок 3
Рисунок 4
Прочие модули
Поскольку другие модули ничего нового и интересного в себе не содержат, отличаясь от приведенного лишь именами и количеством полей, а также некоторыми интерфейсными особенностями, то и тратить время на их рассмотрение не будем. Добавив соответствующую запись в таблицу st_modules, вы сделаете новый модуль доступным для работы.
Что можно изменить?
Как известно, нет предела совершенству. Рассмотренный здесь пример был очень сильно урезан и упрощен, чтобы за деталями не потерялась суть и чтобы уложиться в рамки журнальной статьи. Однако, разрабатывая реальное приложение, имеет смысл сделать некоторые улучшения.
Например, «шаблонность» нашего приложения оставляет желать лучшего. Каждый добавляемый модуль в принципе имеет очень схожую структуру и функциональность. То есть можно разработать один модуль-шаблон и настраивать его под конкретные таблицы и поля автоматически в процессе обращения к конкретной функции.
Можно сделать более гибкой систему разграничения доступа, помимо пользователей введя понятие групп пользователей, а также разграничивая права пользователей в пределах одного модуля (полный доступ, только чтение).
Механизм сессий используется очень слабо. Например, его можно использовать для передачи таких параметров как идентификатор пользователя (uid), вместо того чтобы делать это с помощью скрытых полей формы. Не совсем удобной выглядит необходимость в каждом модуле задавать логин и пароль для подключения к БД. Выносить это в модуль My::Insite неправильно (иначе будут сложности с использованием данного модуля в других приложениях для подключения к другим базам), а вот сделать что-то типа конфигурационного файла и брать нужные данные оттуда было бы намного лучше, поскольку в случае смены имени или пароля корректировка потребуется только в одном месте. В существенном улучшении нуждается проверка корректности вводимых данных, контроль ошибок, и т. д. В реальной жизни этим, конечно же, пренебрегать нельзя.
И вообще, можно сделать более удобный и красивый дизайн, добавить страничкам «динамизм» с помощью JavaScript (например, всплывающие подсказки, предупреждения и т. п.) и много еще чего хорошего и полезного.
Заключение
Ну что ж. Надеюсь, полученный результат хотя бы частично соответствует нашим ожиданиям, несмотря на множество недоработок, оставленных «за бортом». Мы получили гибкое, легко модифицируемое приложение, соответствующее большинству наших требований. В будущем его без труда можно расширить, добавив, например, модуль для работы с жалобами абонентов, для сбора статистики по потребленному трафику и оплатам и т. д. Единственное, чего мне в данный момент не хватает, это красивых отчетов, которые не стыдно было бы распечатать, сохранить в файл, отправить по электронной почте. Эта задача тоже решается довольно просто. Но об этом – в следующей статье.