Статьи

Сервисный локатор LedgerSMB для UDF: как это работает

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

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

Нижняя половина

Нижняя половина этого механизма — API вызова хранимой процедуры. Perl API использует следующий синтаксис:

 @results = call_procedure( procname => $procname, args => $args );

@results — это массив хеш-ссылок, каждая из которых представляет возвращаемую строку. $ procname, естественно, является именем процедуры, а $ args — ссылкой на массив значений скалярных аргументов.

Например, если я хочу вызвать функцию с именем company__get, которая принимает один аргумент $ id, я мог бы:

($company) = call_procedure( 
          procname => 'company__get', args => [$id] 
);

Затем эта функция генерирует запрос из аргументов:

SELECT * FROM company__get(?);

И запускает его, параметризованный, с $ id в качестве аргумента.

Полный исходный код функции в Perl:

sub call_procedure {
    my $self     = shift @_;
    my %args     = @_;
    my $procname = $args{procname};
    my $schema   = $args{schema};
    my @call_args;
    my $dbh = $LedgerSMB::App_State::DBH;
    if (!$dbh){
        $dbh = $self->{dbh};
    }
    @call_args = @{ $args{args} } if defined $args{args};
    my $order_by = $args{order_by};
    my $query_rc;
    my $argstr   = "";
    my @results;

    if (!defined $procname){
        $self->error('Undefined function in call_procedure.');
    }
    $procname = $dbh->quote_identifier($procname);
    # Add the test for whether the schema is something useful.
    $logger->trace("\$procname=$procname");
    $schema = $schema || $LedgerSMB::Sysconfig::db_namespace;

    $schema = $dbh->quote_identifier($schema);

    for ( 1 .. scalar @call_args ) {
        $argstr .= "?, ";
    }
    $argstr =~ s/\, $//;
    my $query = "SELECT * FROM $schema.$procname()";
    if ($order_by){
        $query .= " ORDER BY $order_by";
    }
    $query =~ s/\(\)/($argstr)/;
    my $sth = $dbh->prepare($query);
    my $place = 1;
    # API Change here to support byteas: 
    # If the argument is a hashref, allow it to define it's SQL type
    # for example PG_BYTEA, and use that to bind.  The API supports the old
    # syntax (array of scalars and arrayrefs) but extends this so that hashrefs
    # now have special meaning. I expect this to be somewhat recursive in the
    # future if hashrefs to complex types are added, but we will have to put
    # that off for another day. --CT
    foreach my $carg (@call_args){
        if (ref($carg) eq 'HASH'){
            $sth->bind_param($place, $carg->{value},
                       { pg_type => $carg->{type} });
        } else {
            $sth->bind_param($place, $carg);
        }
        ++$place;
    }
    $query_rc = $sth->execute();
    if (!$query_rc){
          if ($args{continue_on_error} and  #  only for plpgsql exceptions
                          ($dbh->state =~ /^P/)){
                $@ = $dbh->errstr;
          } else {
                $self->dberror($dbh->errstr . ": " . $query);
          }
    }

    my @types = @{$sth->{TYPE}};
    my @names = @{$sth->{NAME_lc}};
    while ( my $ref = $sth->fetchrow_hashref('NAME_lc') ) {
        for (0 .. $#names){
            #   numeric            float4/real
            if ($types[$_] == 3 or $types[$_] == 2) {
                $ref->{$names[$_]} = Math::BigFloat->new($ref->{$names[$_]});
            }
        }
        push @results, $ref;
    }
    return @results;
}

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

Логика в отношении обработки типов и тому подобного немного скромнее, но это примерно для основной логики.

Будущие улучшения для нижней половины

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

SELECT * FROM my_func(?) order by foo;

в

SELECT *, sum(amount) 
          over (partition by reference order by entry_id)
          AS running_balance
  FROM gl_report(?, ?, ?) order by transdate;

Подобные вещи сделали бы функции отчетности намного более гибкими.

Верхняя половина 

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

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

Кроме того, API заказа в коде Perl действительно неоптимален и должен быть переделан в будущих версиях.

Код Perl:

sub exec_method {
    my $self   = shift @_;
    my %args  = (ref($_[0]) eq 'HASH')? %{$_[0]}: @_;
    my $funcname = $args{funcname};

    my $schema   = $args{schema} || $LedgerSMB::Sysconfig::db_namespace;

    $logger->debug("exec_method: \$funcname = $funcname");
    my @in_args;
    @in_args = @{ $args{args} } if $args{args};

    my @call_args;

    my $query = "
        SELECT proname, pronargs, proargnames FROM pg_proc
         WHERE proname = ?
               AND pronamespace =
               coalesce((SELECT oid FROM pg_namespace WHERE nspname = ?),
                        pronamespace)
    ";
    my $sth   = $self->{dbh}->prepare(
                $query
    );
    my $ref;

    $ref = $sth->fetchrow_hashref('NAME_lc');

    my $pargs = $ref->{proargnames};
    my @proc_args;

    if ( !$ref->{proname} ) {    # no such function
        # If the function doesn't exist, $funcname gets zeroed?
        $self->error( "No such function:  $funcname");
#        die;
    }
    $ref->{pronargs} = 0 unless defined $ref->{pronargs};
    # If the user provided args..
    if (!defined $args{args}) {
        @proc_args = $self->_parse_array($pargs);
        if (@proc_args) {
            for my $arg (@proc_args) {
                #print STDERR "User Provided Args: $arg\n";
                if ( $arg =~ s/^in_// ) {
                     if ( defined $self->{$arg} )
                     {
                        $logger->debug("exec_method pushing $arg = $self->{$arg}");
                     }
                     else
                     {
                        $logger->debug("exec_method pushing \$arg defined $arg | \$self->{\$arg} is undefined");
                        #$self->{$arg} = undef; # Why was this being unset? --CT
                     }
                     push ( @call_args, $self->{$arg} );
                }
            }
        }
        for (@in_args) { push @call_args, $_ } ;
        $self->{call_args} = \@call_args;
        $logger->debug("exec_method: \$self = " . Data::Dumper::Dumper($self));
        return $self->call_procedure( procname => $funcname,
                                          args => \@call_args,
                                      order_by => $self->{_order_method}->{"$funcname"},
                                         schema=>$schema,
                             continue_on_error => $args{continue_on_error});
    }
    else {
        return $self->call_procedure( procname => $funcname,
                                          args => \@in_args,
                                      order_by => $self->{_order_method}->{"$funcname"},
                                         schema=>$schema,
                             continue_on_error => $args{continue_on_error});
    }
}

Возможности для будущих улучшений

Первоначальные улучшения включают замену API перечислимого аргумента на API, в котором хеш-адрес может быть передан с перезаписью части или всех аргументов, отправленных в базу данных. Это продолжит делать API гибким и динамичным, но позволит сделать клиентский код более насыщенным. API заказа также необходимо перенести в фактический вызов API, а не в отдельный вызов.

Интерфейс следующего поколения в разработке

Интерфейс следующего поколения будет поддерживать вызов API, например:

 SELECT * FROM save('(,A-12334,"Test, Inc.",232)'::entity);

Основной проблемой здесь является рекурсивное построение того, что по сути является потенциально вложенной структурой CSV. Например, мы могли бы иметь:

SELECT * FROM save('(,JE-12334,Cash Transfer,2013-05-01,f,"{""(,4,-1000)"",""(,7,1200)"",""(,12,200)""}")'::journal_entry);

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

Однако в дополнение к этой проблеме я хотел бы иметь возможность определять оконные функции для наборов результатов через API, чтобы можно было добавлять текущие балансы в базу данных (где они могут быть выполнены наиболее эффективно).

Над этим уже проделана большая работа.

Лицензирование

Приведенный выше код распространяется по лицензии GNU General Public License версии 2 или по вашему выбору любой более поздней версии. Код не является самым чистым кодом, который мы написали на эту тему, но это код, который используется LedgerSMB в производстве.

Если вы хотите, чтобы с лицензией BSD работал код, который также, вероятно, является более чистым кодом, ознакомьтесь с
реализацией PHP, которую мы с Джоном Локком соединили.