Pull to refresh

быстрое создание веб-приложений на Perl: вводная

Reading time16 min
Views4.4K
Сейчас сложилась такая ситуация, что язык Perl незаслуженно забыт. Хочу немного поднять авторитет этого чудесного языка своими заметками.
Эта макро-заметка ориентирована на изучающих Perl, знатоков этого языка, а так же на тех, которые только хотят побольше узнать о Perl. В заметке хочу поделиться просто своим опытом.

Хочется рассмотреть простую ситуацию, которая по моему мнению, часто имеет место быть при разработке малых и средних проектов. А ситуация такая: необходимо создать небольшой (средний) сайт, причем принимается решение отказаться от CMS, так как движок нужен небольшой, наворотов в админке не нужно, сложность примерно 16-24 человеко/часов. Для примера требуется небольшой сайт, который будет содержать статьи определенного типа (обычные текстовые статьи) и новости. Плюс небольшая админка для добавления статей и новостей. Условимся, что у нас есть «большая» разница между этими двумя типами контента в рамках этой статьи.

Проблема


В таких ситуациях довольно часто принимается решение написать свой велосипед, то есть движок. Рассмотрим именно такую ситуацию, на примере которой рассмотрим так же прелести Perl и CPAN.
Полноценную реализацию MVC не предлагаю, это слишком много для нашего маленького проекта. Для Perl написан вагон и маленькая тележка фреймворков (как MVC, так и не очень), например отличный Catalyst, который очень и очень похож на RubyOnRails (или наоборот, я не в курсе хронологии). Так же есть множество поменьше, для любопытствующих стоит взглянуть сюда.

Мы же для простоты реализуем похожий механизм, но попроще. Итак, посмотрим на составляющие нашего движка (LAMP — это as default) в виде модулей:
1. Данные — DBIx::Class
2. Отображение — Template Toolkit
3. Управление — своими руками
Небольшое отступление. Я давно не люблю папку cgi-bin и всячески стараюсь ее избегать, почти на всех хостингах (а тем более дома) разрешены файлы .htaccess. Создаем такой файл в корневой папке проекта и записываем туда:
Options +ExecCGI
AddHandler cgi-script pl
DirectoryIndex index.pl

Теперь можно исполнять скрипты с расширением .pl прям в текущей директории. Кроме того страницей по умолчанию будет наш скрипт index.pl.
Далее советую всегда создавать конфиг. Вариаций множество, каждому нравится по разному, у меня минимально это выглядит так:
package Conf;
use warnings;
use strict;

BEGIN
{
    use Exporter;
    our (@ISA, @EXPORT);
    @ISA = qw(Exporter);
    @EXPORT = qw(
        $DB_Host $DB_Port $DB_Name $DB_User $DB_Pass
    );
}

our $DB_Host = "host";
our $DB_Port = 3306;
our $DB_Name = "our_db";
our $DB_User = "our_table";
our $DB_Pass = "our_password";
1;


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

Данные


Структура БД


Вернемся к движку. Пункт первый — это работа с данными, за нее у нас будет отвечать пакет DBIx::Class, который включает несколько модулей. Для начала создадим простую БД, с которой мы будем работать. Не стоит критично относится к структуре базы, она максимально простая, так же синтаксис без всего лишнего, минимизируем затраты.
create table users (
    id smallint not null primary key auto_increment,
    name varchar(32) not null,
    pass varchar(32) not null);

create table categories (
    id int not null primary key auto_increment,
    name varchar(128) not null) charset cp1251;

create table articles (
    id int not null primary key auto_increment,
    category_id int not null,
    title varchar(255) not null,
    content text not null,
    author varchar(128) not null comment 'Author of article',
    added_at timestamp not null,
    added_by smallint not null comment 'Admin user ID') charset cp1251;

create table news (
    id int not null primary key auto_increment,
    added_at timestamp not null,
    title varchar(255) not null,
    content text not null,
    is_put_on_main bool not null default 0 comment 'Show on main page?',
    added_by smallint not null) charset cp1251;

Таблица пользователей содержит самые основные данные (учет посещений и прочих нас пока не волнует), таблица categories содержит разделы статей, например «автомото», «спорт», «кулинария» и т.п., таблица articles содержит собственно статьи, а таблица news — новости. В последней есть поле is_put_on_main, которое отвечает за показ новости на главной. Так же почти в каждой таблице я задаю кодировку — это привычка, кто уверен — не делайте.

Отображение в код


Хорошо, таблицы у нас есть, теперь необходимо отобразить их в коде. Модуль DBIx::Class позволяет полностью отойти от написания SQL-кода и общаться с таблицами, как с объектами. Работать с этим модулем можно двумя способами: либо вручную описывать структуры каждой таблицы, либо воспользоваться автоматикой. Рассмотрим оба способа по порядку.

Ручной метод


Смотрим в код, далее будут пояснения. Создадим в корне нашего проекта папку с именем DB и в ней создадим четыре файла: User.pm, Category.pm, Article.pm, News.pm, вот содержимое этих файлов.
# file User.pm
package DB::User;

use base qw/DBIx::Class/;

__PACKAGE__->load_components(qw/PK::Auto Core/);
__PACKAGE__->table('users');
__PACKAGE__->add_columns(qw/id name pass/);
__PACKAGE__->set_primary_key('id');

__PACKAGE__->has_many('articles' => 'DB::Article',
    { 'foreign.added_by' => 'self.id' });
__PACKAGE__->has_many('news' => 'DB::News',
    { 'foreign.added_by' => 'self.id' });

1;

# file Category.pm
package DB::Category;

use base qw/DBIx::Class/;

__PACKAGE__->load_components(qw/PK::Auto Core/);
__PACKAGE__->table('categories');
__PACKAGE__->add_columns(qw/id name/);
__PACKAGE__->set_primary_key('id');

__PACKAGE__->has_many('articles' => 'DB::Article',
    { 'foreign.category_id' => 'self.id' });

1;

# file Article.pm
package DB::Article;

use base qw/DBIx::Class/;

__PACKAGE__->load_components(qw/InflateColumn::DateTime PK::Auto Core/);
__PACKAGE__->table('articles');
__PACKAGE__->add_columns(qw/id category_id title content added_by author/);
__PACKAGE__->add_columns('added_at' => { data_type => 'timestamp' });
__PACKAGE__->set_primary_key('id');

__PACKAGE__->belongs_to('category' => 'DB::Category',
    { 'foreign.id' => 'self.category_id' });
__PACKAGE__->belongs_to('user' => 'DB::User',
    { 'foreign.id' => 'self.added_by' });

1;

# file News.pm
package DB::News;

use base qw/DBIx::Class/;

__PACKAGE__->load_components(qw/InflateColumn::DateTime PK::Auto Core/);
__PACKAGE__->table('news');
__PACKAGE__->add_columns(qw/id title content is_put_on_main added_by/);
__PACKAGE__->add_columns('added_at' => { data_type => 'timestamp' });
__PACKAGE__->set_primary_key('id');

__PACKAGE__->belongs_to('user' => 'DB::User',
    { 'foreign.id' => 'self.added_by' });

1;

Итак, небольшие пояснения. Имеем четыре очень похожих файла, сначала объявляем базовым модуль DBIx::Class, далее используя механизм __PACKAGE__ вызываем его методы, а именно: load_components — загружаем компоненты для нашего модуля (PK::Auto для работы с автоинкрементированными primary_key, Core — основной набор для работы со связями, строками и столбцами). Далее указываем таблицу, после чего добавляем названия столбцов. Для работы со столбцами таких типов, как datetime, date и timestamp используется небольшой модуль InflateColumn::DateTime. С помощью него поля указанных типов можно использовать в программе, как объекты типа DateTime, со всеми вытекающими удобствами. После чего указываем primary key (если он составной, то указываем несколько полей set_primary_key(qw/name1 name2/);.
Далее находятся знакомые для знающих RubyOnRails методы has_many(), belongs_to() и другие. Эти методы предназначены для создания связей между таблицами.
Документация по чудному модулю DBIx::Class, где все подробно описано, включая туториал и cookbook.

Теперь нам нужно использовать сие чудо, для этого нам нужен модуль DBIx::Class::Shema, который является абстракцией схемы данных. В корневой папке проекта создаем файл с именем, идентичным имени папки с классами, описывающими таблицы, в нашем случае это будет DB.pm Вот как он выглядит у меня.
package SDB;

use base qw/DBIx::Class::Schema/;
use Conf;

__PACKAGE__->load_classes();

sub GetSchema()
{
    my $dsn = "dbi:mysql:$DB_Name:$DB_Host";
    my $sch = __PACKAGE__->connect($dsn, $DB_User, $DB_Pass);
    
    return $sch;
}

1;

В целом, использовать DBIx::Class::Schema можно и без функции GetShema(), метод load_classes() автоматически загружает все файлы, найденные в одноименной папке. Я дописал небольшую функцию, что бы удобнее было получать схему. Без этой функции соединение в коде выглядело бы так:
my $dsn = "dbi:mysql:$DB_Name:$DB_Host";
my $sch = DB->connect($dsn, $DB_User, $DB_Pass);

В случае с функией можно написать их несколько либо по другому конфигурировать соединение с разными типа баз.

Автоматический метод


В «ручном» примере мы вручную задавали все связи между таблицами. Существует модуль DBIx::Class::Shema::Loader, который выполняет загрузку и создание классов автоматически. Для этого необходимо добавить в структуру БД описание внешних ключей (foreign keys). Используя их загрузчик автоматически создаст необходимые связи. Вот как это выглядит:
package DB;
use base qw/DBIx::Class::Schema::Loader/;

__PACKAGE__->loader_options(
    inflect_singular => 1,
    components => qw/InflateColumn::DateTime/
);

1;

# Использование

use DB;
my $sch = DB->connect( $dsn, $user, $password, $attrs);

Так же можно добавить выше функцию GetShema() (см. выше) и использовать ее. В данном случае папка DB и четыре файла в ней становятся не нужными и у нас остается один файл-описание схемы. Загрузчик поддерживает множество опций, которые задают пространство имен для создаваемых классов, параметры генерации имен классов, и другие.

Использование схемы


Теперь посмотрим, как это все вместе используется непосредственно в коде.
use DB;

my $sch = DB->GetShema();
# Поиск пользователя по id
my $user = $sch->resultset('User')->find({ id => $id });

# Добавление новости
my $new_id = $sch->resultset('Category')->populate(
[
    [qw/title content is_put_on_main added_by/],
    [$ntitle, $ncontent, 0, $user_id]
]);

# Удаление статьи
$sch->resultset('Article')->find({ id => $aid })->delete;

Далее, покажем наши данные.

Отображение


Я использую систему шаблонов Template Toolkit. Есть еще несколько систем, например Mason, но так исторически сложилось, что мой выбор пал на Template Toolkit.
Template Toolkit — это система обработки шаблонов. Посмотрим ее использование сразу на примере. Для начала создадим в корне проекта папку tmpl и в ней создадим папку site. В папке tmpl/site создадим файл site следующего содержания:
Portal


[% PROCESS $content %]





Далее, сделаем там же файл start_page:
News and articles

Вот такой простой файл с одной строчкой. Это будет заготовка нашей стартовой страницы. Свяжем все вместе и получим примерно такой код нашего скрипта index.pl:
#!/usr/bin/perl -w

use strict;

use CGI;
use Template;

use Conf;
use DB;

# инициализируем CGI
my $q = CGI->new;
my %p = $q->Vars;

# ...шаблоны
my $tmpl = Template->new(
{
    INCLUDE_PATH => 'tmpl/site',
    INTERPOLATE => 1,
    EVAL_PERL => 1
}) || die "$Template::ERROR\n";

# ...данные
my $sch = DB->GetShema();

# теперь мы готовы к работе
my $tmpl_vars = {};
$tmpl_vars->{content} = 'start_page';

print $q->header(-type => 'text/html', -charset => 'windows-1251');
$tmpl->process('site', $tmpl_vars) || die $tmpl->error(), "\n";

Две строчки про CGI думаю всем понятны, далее идет создание объекта Template, главным параметром которого является INCLUDE_PATH — пусть к шаблонам. Чуть ниже мы создаем схему данных и соединяемся с базой. Далее мы создаем хэш, в который будем складывать все переменные, которые необходимо передать в шаблон. В нашем случае мы передаем только одну переменную content, эта переменная используется в директиве PROCESS в шаблоне site. Еще ниже мы запускаем обработку шаблона и указываем стартовый шаблон — site, а так же передаем хэш переменных.

В шаблоне site используется директива PROCESS, она запускает вложенную обработку другого шаблона, имя которого передано параметром, но так, как у нас имя хранится в переменной, то мы указываем это непосредственно — [% PROCESS $content %]. Таким образом в тело шаблона site вставится содержимое шаблона start_page. Добавим немного разнообразия. На главной странице мы должны отображать статьи и новости, но не все, а, скажем, последние десять. К тому же новости только те, которые помечены соответствующим флагом в таблице. Перед обработкой шаблона добавим в наш скрипт несколько строк:
my $articles = [$sch->resultset('Article')->search(undef,
{
    order_by => 'added_at desc',
    rows => 10,
    page => 1
})];
my $news = [$sch->resultset('News')->search(
{
    is_put_on_main => 1
},
{
    order_by => 'added_at desc',
    rows => 10,
    page => 1
})];
$tmpl_vars->{articles} = $articles;
$tmpl_vars->{news} = $news;

Следует заметить, что мы использовали [] для создания спискового контекста, иначе в скалярном контексте функция search() возвращает объект типа ResultSet, а нам нужен именно массив данных.

Итак, подробно описывать не имеет смысла, так как все довольно явственно. Единственное, это использование параметров rows/page. Они необходимы для создания так называемых pager-ов, с помощью которых удобно организовывать постраничный вывод, а так же применяются для простого отбора записей, что является частным случаем. Так же кол-во статей и новостей можно вынести в конфиг.

Далее, изменим шаблон start_page:

Новости


[% FOREACH n = news %]
        [% n.added_at.dmy('.') %] [% n.title %]
    
    [% n.content FILTER html %]

[% END %]

Статьи


[% FOREACH a = articles %]
        [% a.added_at.dmy('.') %] [% a.title %]
    
        Раздел: [% a.category.name %]
    [% a.content FILTER html %]

[% END %]

Отмечу использование поля added_at, как объекта. Для него вызывается метод dmy(), который форматирует дату в формат ДД-ММ-ГГГГ с переданным разделителем, в нашем случае точка. Объект DateTime поддерживает локали и корректно отображает дату в зависимости от текущей (или выбранной) локали. Так же он содержит множество методов для форматирования и работы с датами.

Я пока намеренно не добавлял валидные ссылки, сделаю это позже.
В целом мы видим два похожих блока, которые стоит вынести в отельный файл. Создадим файл short_note в папке tmpl/site:
    [% text = node.content;
    IF text.length > 512;
        text = text.substr(0, 512);
    END %]
        [% note.added_at.dmy('.') %] [% note.title %]
    
    [% IF note.category %]
        Раздел: [% note.category.name %]
    [% END %]
    [% text FILTER html %]


Теперь наш шаблон start_page примет такой вид:

Новости


[% FOREACH n = news %]
[% PROCESS short_note note = n %]
[% END %]

Статьи


[% FOREACH a = articles %]
[% PROCESS short_note note = a %]
[% END %]

Теперь мы вызываем обработку шаблона short_note и передаем ему в качестве параметра note текущую новость или статью.

В шаблоне выполняется проверка на наличие поля category, что у нас будет признаком статьи, в этом случае мы выводим название раздела.

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

Управление


Выше мы условились не применять всяческих фреймворков, попробуем сделать минимум своими руками. Для этого сделаем следующую простую структуру (ламерскую, да-да):
my $act = $p{'a'} || 'start';

if ($act eq 'start')
{
}
elsif ($act eq 'article')
{
}
elsif ($act eq 'news')
{
}
# ....
else
{
}

Итак, каждую ссылку в скрипте будет сопровождать параметр a — action. Он будет задавать текущий контекст. Таким образом, ссылки выше в шаблонах можно сменить на такие:

Далее, нужно подумать о безопасности. Сейчас нас интересует только параметр id, на данный момент других у нас не используется. Сделаем самый простой финт ушами непосредственно перед разбором контекста:
$p{'id'} =~ s/\D//g if ($p{'id'});

То есть, если у нас есть какой-то номер, будь то статьи, новости или каталога, то мы вырежем оттуда все не-цифры. Простой и дубовый метод.

Рассмотрим далее примеры кода для контекстов.
if ($act eq 'start')
{
    $tmpl_vars->{content} = 'start_page';
    my $articles = [$sch->resultset('Article')->search(undef,
    {
        order_by => 'added_at desc',
        rows => 10,
        page => 1
    })];
    my $news = [$sch->resultset('News')->search(
    {
        is_put_on_main => 1
    },
    {
        order_by => 'added_at desc',
        rows => 10,
        page => 1
    })];
    $tmpl_vars->{articles} = $articles;
    $tmpl_vars->{news} = $news;
}
elsif ($act eq 'article')
{
    $tmpl_vars->{content} = 'full_article';
    $tmpl_vars->{article} = $sch->resultset('Article')->find({ id => $p{'id'} });
}
elsif ($act eq 'category')
{
    $tmpl_vars->{content} = 'category';
    $tmpl_vars->{category} = $sch->resultset('Category')->find({ id => $p{'id'} });
}
elsif ($act eq 'news')
{
    $tmpl_vars->{content} = 'full_article';
    $tmpl_vars->{article} = $sch->resultset('News')->find({ id => $p{'id'} });
}
else
{
    # см. ниже
}

Поиск я не рассматривал, он довольно прост, из формы мы передаем введенные данные для поиска среди статей и новостей и выводим результаты. Так же стоит помнить, что в Perl существуют удобные модули для проверки данных на валидность, переданных из форм, например HTML-CheckArgs или HTML-QuickCheck. Существую более продвинутые инструменты, например HTML-Widget или HTML-Tag. Это полноценные системы для создания виджетов и проверки данных на валидность. Очень удобны в коде, а так же удобны для повторного использования. Единожды созданный виджет можно использовать во множестве приложений.

В последнем случае есть небольшая дилемма: что делать, если задан неверный контекст. Некоторые склоняются к выводу ошибки (для этого нужно создать шаблон, например error_action, и просто указать на него), я же склонен отправлять всех на главную:
    print $q->header(-location => '?a=start');
    exit;

Это не умно и не круто, зато безболезненно. Для тех, кто беспокоится о лишнем запросе к серверу, можно сделать следующим образом (до обработки контекста):
my %action = (
    'start' => 'Main page',
    'news' => 'News page',
    'article' => 'Full article',
    # ....
);
my $act = ( $p{'act'} && defined( $actions{$p{'act'}} )) ? $p{'act'} : 'start';

То есть, если задан контекст и он присутствует в списке — использовать его, иначе установить 'start'. Хэш контекстов используется вместо массива для облегчения проверки - defined(...).

Администрирование


Для администрирования необходимо создать некий инструмент. С точки зрения модели построения админка ничем не отличается от вышеприведенной системы, кроме авторизации и нюансов с текстом. Рекомендутеся создать отдельную папку для шаблонов, например tmpl/admin.
Для авторизации я использую два инструмента: Digest::SHA1 и CGI::Session. Первый обеспечивает шифрование, второй — сессии.
Итак, рассмотрим на простом примере применение этих инструментов. Пример намеренно упрощен до безобразия.

Шаблоны:
[%# Шаблон login %]
[% IF err %]
Wrong login
[% END %]
/>
Login: />
Password: />
/>



В скрипте админки нужно дописать вход и выход из системы, а так же сессии:
use CGI::Session;
use Digest::SHA1 qw(sha1_hex);

# ... после CGI загружаем сессию
my $s = CGI::Session->load(undef, undef, { Directory => 'ssss' } );

# ... после определения контекста
if ($s->empty && $act !~ /login(_form)?|logout/)
{
    print $q->header(-location => '?a=login_form');
    exit;
}
else
{
    my $user = $sch->resultset('User')->find({ id => $s->param('uid') });
    $tmpl_vars->{user} = $user;
}

if ($act eq 'login_form')
{
    $tmpl_vars->{content} = 'login_form';
}
elsif ($act eq 'login')
{
    unless (my $u = &login($p{'login'}, $p{'pass'}))
    {
        $tmpl_vars->{content} = 'login';
        $tmpl_vars->{err} = 1;
    }
    else
    {
        $s = $s->new;
        $s->param('uid', $u->id);
        
        print $s->header(-location => '?a=start');
        exit;
    }
}
elsif ($act eq 'logout')
{
    $s->delete;
    print $q->header(-location => '?a=login');
    exit;
}

# и небольшая функция
sub login
{
    my ($u, $p) = @_;
    
    my $pp = sha1_hex($p);
    my $res = $sch->resultset('User')->search({
        name => $u,
        pass => $pp
    });
    
    my $user = $res->next;
    return $user;
}


Пример сильно дубовый, но тем не менее, он показывает суть.
Модуль CGI::Session поддерживает хранение сессий как в файле, так и в БД. Так же необходимо указать срок истечения — expired. В примере использовано хранение в файлах в каталоге ssss.
Модуль Digest::SHA1 - как альтернатива MD5.

Следующий нюанс касается создания форм для ввода данных. Во-первых, необходимо создать так называемые CRUD-методы (CReate, Update, Delete). Для этого, например, существует модуль DBIx::Class::WebForm. Так же по запросу CRUD на CPAN можно найти еще несколько подобных модулей.
Во-вторых, необходимо организовать удобный ввод текста статей и новостей. Лично я использую FCKeditor, хотя есть множество других. Такие редакторы довольно просто интегрируются в страничку и дают пользователям удобство и счастье в жизни.
В-третьих, стоит позаботится о валидации данных из форм. Например, модуль DBIx::Class::Validation проверяет данные перед отсылкой в базу, так же есть всевозможные валидаторы данных из форм, которые работают совместно с виджетами или формами, например CGI::FormBuilder, CGI::QuickForm и т.д. По запросу "Form", "Validate" или "Widget" можно найти множество модулей для этих целей.

Заключение


В этой "небольшой" заметке я хотел поделиться своим небольшим опытом в создании простых приложений. Заметка вышла не маленькая, но, надеюсь, информативная. Приведенный здесь метод создания приложений очень эффективен в плане повторного использования, единожды созданные кусочки сайта можно потом использовать в других местах, почти ничего не меняя. Так же мы достаточно эффективно разделили вид и код, а так же абстрагировались от непосредственного написания SQL-запросов.

Вот архив примера, а здесь - рабочий пример. На создание примера ушло меньше часа.

-NOT_FOR_HOLYWARS-
Tags:
Hubs:
Total votes 8: ↑8 and ↓0+8
Comments31

Articles