Статьи

NoSQL-подобные подходы к PostgreSQL, ответ Мартину Фаулеру

Основная мысль многих постов Мартина Фаулера, повторяемых в его книге, заключается в том, что NoSQL представляет собой движение от интеграции к базе данных и к инкапсуляции баз данных в приложениях и использованию сервисов для интеграции данных. Тем не менее, тот факт, что это не тот способ, которым всегда используются высокопроизводительные РСУБД, говорит о рынке больше, чем сама технология РСУБД. В течение последних пяти лет я строил реляционные базы данных по аналогичному подходу, используя веб-сервисы для вдохновения интерфейсов. В этом посте обсуждается несколько разных подходов к этому на PostgreSQL. Обратите внимание, что другие РСУБД могут отличаться во многих из этих аспектов.

Обещание модели реляционной базы данных

Реляционные базы данных предназначены для хранения информации в нейтральных форматах приложений и последующего представления ее различным приложениям различными способами. Цель, как говорит Фаулер, «интеграция базы данных». Чтобы облегчить достижение этой цели удобным способом, большинство СУБД поставляются с солидным набором функций, предназначенных для инкапсуляции физического хранилища за специфичным для приложения представлением хранимых данных. К ним относятся представления, функции и хранимые процедуры. Эти функции позволяют инженеру базы данных создавать приложения, которые инкапсулируют физическое хранилище, так что система управления базами данных, по существу, предоставляет информационную модель через множество клиентских интерфейсов. Это позволяет добиться того, что Фаулер защищает, а именно: инкапсулировать базу данных в приложение,но он допускает декларативный доступ к этому API. По крайней мере, теоретически, нет никакой разницы между тем, что Фаулер защищает для NoSQL, и тем, что многие администраторы баз данных делают с RDBMS. Это также говорит о том, что Фаулер занимается чем-то важным, но что отказ от СУРБД во многих случаях не может быть ответом.

Почему СУБД редко выполняет это обещание

Однако, несмотря на всю теорию, большинство СУБД редко используются таким образом. Я думаю, что есть несколько факторов, которые обычно влияют на такую ​​инкапсуляцию:

  1. Инвестиции (затраты на экспертизу и лицензию) в конкретные СУБД ИТ-отделами означают, что разработчики приложений хотят писать переносимый код и, следовательно, писать код с наименьшим общим знаменателем
  2. Разработчики приложений часто хотят, чтобы весь доступ проходил через их базы данных, и, следовательно, не хотят централизовать логику приложения в базе данных, и
  3. Многие из стандартов гарантируют, что инструменты не являются действительно оптимальными для этой задачи. Лучшие инструменты — это те, которые выходят за рамки стандартов, и, следовательно, имеют большее значение в отношении портативности.

Первый рыночный фактор связан с тем фактом, что системы управления реляционными базами данных являются дорогостоящими системами в нескольких смыслах. В дополнение к (зачастую огромным) затратам на лицензирование у вас есть необходимость нанимать людей с опытом и / или обучать их. Компании обычно решают эту проблему, объединяя свой опыт и серверы в одной СУБД. Таким образом, вы видите магазины Oracle, магазины SQL Server, магазины DB2 и т. Д. Это приводит к тому, что блокировка становится основной затратой для предприятий и сокращает рынок приложений, специфичных для баз данных. Поэтому разработчики приложений довольно рационально чаще выбирают написание кода SQL, который выполняется в любой другой СУБД. Это, естественно, требует отказа от расширенных функций, таких как Oracle Objects или функции pl / pgsql для ванильного SQL.

 Проблема, конечно, в том, что трудно инкапсулировать данные, когда вы ограничены самым базовым набором функций. Хотя представления могут работать, их обновляемость может отличаться и иметь разные последствия для каждой СУБД. Хранимые процедуры или функции намного хуже в этом отношении. Следовательно, необходимость написания переносимого кода требует по существу отказа от самих инструментов, используемых для инкапсуляции данных. По этой причине я не думаю, что вы можете написать как базу данных для использования несколькими приложениями (т. Е. Там, где внутренние структуры данных инкапсулированы), так и написать базу данных, которая работает на нескольких RDBMS. Вы должны выбрать. Разработчики не могут быть обвинены в выборе варианта, который дает их приложениям наибольшую рыночную привлекательность.

Конкурирующие желания блокировки приложений — еще один фактор. Поставщики СУБД обычно хотят ограничить доступ к определенному количеству клиентских лицензий или рабочих мест, и если соединения могут быть объединены в промежуточное программное обеспечение, это может помочь обойти некоторые из этих ограничений (это может вызвать договорные проблемы в зависимости от характера EULA, но таким образом можно легко обойти технические элементы управления, и в какой-то момент у вас возникают проблемы с определениями, особенно когда системы слабо связаны). Разработчики приложений хотят продавать свои собственные лицензии, и это может быть сделано, только если соединения проверены на прикладном уровне. Поэтому многие разработчики приложений противоречат интересам инкапсулированных схем баз данных. Вместо,СУБД используется в основном как частное хранилище данных с некоторыми дополнительными возможностями отчетности.  

Некоторые поставщики РСУБД фактически оптимизируют свои системы для вышеуказанных потребностей. Одна из возможностей, предлагаемых SQLAnywhere, заключается в том, что такие разработчики, как Intuit, могут заблокировать базу данных для определенного приложения, запретив всем сторонний доступ, и большая часть нынешней популярности MySQL может быть связана с тем фактом, что он хорошо оптимизирован для перемещения неинкапсулированных баз данных, которые работают на других БД к нему. В частности, установка sql_mode с одной стороны облегчает перенос кода на MySQL, а с другой делает его относительно небезопасным для такого рода инкапсуляции.

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

 Stored procedures, as the name implies are essentially imperative constructs which take specific arguments and return records.  The idea is that they provide a basic imperative interface language to a database.  Instead of SELECT …. you issue CALL some_proc(arg1, ….);

 There are several problems however with stored procedures as they are typically used.  The first is that they still have significant impedence mismatch with object-oriented programming.  The data structures they return tend to be fairly rigid so adding a new column tends to require multiple changes in code, often at least one in each layer of the program.  You can’t just define your object once and then re-use over and over in other layers of your code.

A second significant problem is that stored procedures are at their best when they are a few well-connected and modularized queries, and at their worst when they are many different queries tied together in complex ways.  This leads to limited utility in actually encapsulating the data, and in all cases the abstraction layer is not really a good match for what is typically done with it.  For these reasons stored procedures as typically used make the most sense when working outside the object-oriented approach.

 Stored procedures have been replaced by object-relation mapping tools (ORMs) which attempt to provide a mapping between a relational interface and an object-oriented development environment.  ORMs automate basic database operations for things like insert, select, update, and delete operations, but they don’t really provide an abstraction regarding the actual data mapping between the behavioral app layer and the information model layer.  This can currently only be done in the information model itself, so ORM’s are best paired with updatable views, but this comes at the cost of portable SQL code.

Aside from these approaches, or moving to NoSQL, there are a variety of methods to encapsulate the data store inside a relational application.  These approaches require understanding both the desire for encapsulatin and interfaces, and the desire to integrate with applications as a service rather than as a relatively simple persistence layer manipulated mathematically. 

Service Oriented Database Architecture

For most of the last five years, I have been building LedgerSMB using an approach I call «Service Oriented Database Architecture,» or SODA, which is inspired in part by RESTful web services and SOAP.  From SOAP I took the emphasis on discoverability, and from REST, I took, to the extent possible, the goal of re-using everything in the database that can be re-used in order to define an API.  This approach thus uses the database semantics the way REST re-uses HTTP semantics, and while there are some differences forced by the way PostgreSQL does things (every function called by a SELECT statement), this is not the end of the world.  The goal, of course is to build database interfaces suitable for loosely coupled application/database combinations.

 The SODA approach is based on a number of principles, namely that:

  1. Access to the database is through functions, not relations,
  2. Functions, to the extent possible, always return a useful result, usually in a data structure corresponding to an object, 
  3. Function names (within the domain of this architecture) are unique, and
  4. Function argument names correspond to the properties expected.

 If these are followed then the functions can be mapped, discovered, and run at run-time.  Here is a PHP class that implements such a run-time mapping:

 class DBObject
{
    protected $schema = 'public';
   
   /* function __call($procname, $order = null)
    * Maps in object properties into an arg array and calls call_procedure
    *
    * db procedures are checked for argument names and these are stripped of 
    * the "in_" prefix.  After this is complete, a property is matched and
    * mapped in.
    */
    public function __call($procname, $order = null){
        # variable initializations
        $procargs = array();

        # proc data lookup
        $procquery = "
            SELECT proname, pronargs, proargnames, proargtypes FROM pg_proc 
             WHERE proname = $1
               AND pronamespace = 
                   coalesce((SELECT oid FROM pg_namespace WHERE nspname = $2), 
                        pronamespace)";
         $db = DB::getObject();
         $sth = pg_query_params($db->dbhandle, $procquery, 
                               array($procname, $this->schema));
         $procdata = pg_fetch_assoc($sth);

         if (0 == pg_num_rows($sth)){
            throw new \exception('Function not found');
         }
         # building argument list
         preg_match('/^{(.*)}$/', $procdata['proargnames'], $matches);
         $procargnames = $phpArr = str_getcsv($matches[1]);
         foreach ($procargnames as $argname){
              $argname = preg_replace('/^in_/', '', $argname);
              array_push($procargs, $this->$argname);
         }

         # calling call_procedure
         return $this->call_procedure($procname, $procargs, $order);
    }
    /* function call_procedure($procname, $args = array(), $order = null)
     *
     * generates a query in the form of:
     * select * from $procname($1, $2, etc) ORDER BY colname
     * and runs it.  It returns an array of associative arrays representing the
     * output.
     */
    public function call_procedure($procname, $args = array(), $order = null){
         $results = array();
         # query generation
         $query = "select * from "
                       . pg_escape_identifier($this->schema) . "." 
                       . pg_escape_identifier($procname) . "(";
         $count = 1;
         $first = 1;
         foreach ($args as $arg){
             if (!$first){
                 $query .= ", ";
             }
             $query .= '$' . $count;
             $first = 0;
             ++ $count;
         }
         $query .= ')';
         if ($order){
            $query .= " ORDER BY " . pg_escape_identifier($order);
         }


         # execution and returning results
         $db = DB::getObject();
         $sth = pg_query_params($db->dbhandle, $query, $args);
         if (!$sth){
             return null;
         }
         for ($i = 0; $i < pg_num_rows($sth); $i++){
              print "retrieving row $i \n";
              array_push($results, pg_fetch_assoc($sth, $i));
         }
         return $results;
    }
    /* function merge(array $data)
     * merges data into the current object from an associative array
     * 
     * null or undef values are not set
     */
    public function merge($data){
        foreach ($this as $prop => $value){
             if (array_key_exists($prop, $data) and null != $data[$prop]){
                 $this->$prop = $data[$prop];
             }
        }
    }
    /* function is_allowed_role($role)
     * returns true if the user is allowed the role for the specific db 
     * i.e. $role should not include the prefix.  Otherwise it returns false
     */
    public function is_allowed_role($role){
        $db = DB::getObject();
        return $db->is_allowed_role($role);
    }
}  

The above code seems long but what it allows essentially is inheriting objects to simply declare that methods are mapped to stored procedures, and these mappings are automatically adjusted at the time that stored procedure is actually called.  Additionally this centralizes essentially all db access in a single file where it can be audited for SQL injection issues and the like, and you can go on programming as if you are hitting an object-oriented database.  Of course there are times when you need to  make modifications on many layers, such as when a new attribute needs to be added and stored, and it isn’t in the table yet, but generally these are relatively rare.

 What might a stored procedure look like?  Here is one:

CREATE OR REPLACE FUNCTION asset_dep_straight_line_yr_d
(in_asset_ids int[],  in_report_date date, in_report_id int)
RETURNS bool AS
$$
     INSERT INTO asset_report_line (asset_id, report_id, amount, department_id,
                                   warehouse_id)
     SELECT ai.id, $3,
            asset_dep__straight_line_base(
                  ai.usable_life, -- years
                  ai.usable_life -
                  get_fractional_year(coalesce(max(report_date),
                                         start_depreciation,
                                         purchase_date),
                                       coalesce(start_depreciation,
                                         purchase_date)),
                  get_fractional_year(coalesce(max(report_date),
                                         start_depreciation,
                                         purchase_date),
                                $2),
                  purchase_value - salvage_value,
                  coalesce(sum(l.amount), 0)),
            ai.department_id, ai.location_id
       FROM asset_item ai
  LEFT JOIN asset_report_line l ON (l.asset_id = ai.id)
  LEFT JOIN asset_report r ON (l.report_id = r.id)
      WHERE ai.id = ANY($1)
   GROUP BY ai.id, ai.start_depreciation, ai.purchase_date, ai.purchase_value,
            ai.salvage_value, ai.department_id, ai.location_id, ai.usable_life;

    UPDATE asset_report SET report_class = 1 WHERE id = $3;

    select true;
$$ language sql; 

Unfortunately the above has to return true because the nature of the operation does not really provide another effective approach though if we find one, it will be adjusted in the following major version upgrade. 

This approach is generally nice because  it is light-weight and conforms relatively well to more rapidly changing environments.  However, the lack of structure imposed may be a problem in some environments also.  Where more engineering is required, the other approaches may work better.  This works relatively well, however, if you build your API to assume a relatively loose coupling between your database and the application hitting this sort of API.

Object-Oriented Database-level interface

 Where tighter coupling is required, an object-oriented interface may be better.  In some ways this is worth avoiding because it leads to very ugly SQL queries, for example:

SELECT (save).*
   FROM save(row(null, '12345', 'My Company, LTD', 232, '33-55432334')::company);

  

The overall issue here is that you have the possibility of multiple levels of discoverability involved.  It works very well for code generators, but not so wellf or the human masters.  Note the above could be rewritten, assuming no additional arguments as:

 SELECT (save).*
   FROM (row(null, '12345', 'My Company, LTD', 232, '33-55432334')::company).save;

the advantage to this approach is that your objects form classes whose structure is discoverable, and overloading becomes possible.    Code generators thus can work well, as the database contains all information needed to create all boilerplate code.  The database code itself is simplified as well.  On the other hand, troubleshooting can be a bit of a pain.   It also has the property of essentially requiring the use of code generators in order to create libraries for interoperability.  This closely ties the generated libraries to the interface created.

In-database ORM and JSON (In Javascript!)

 One fascinating approach I came across recently, but have very little experience with, is xTuple’s in-database ORM which is largely written in pl/v8js stored procedures. Yes, you got that right, the stored procedures are written in Javascript.  I would invite people to check out the code and see what they think.  This is a fascinating approach and not one I have played around with yet but it definitely shows how far the encapsulation can be made to happen within PostgreSQL.

 Conclusions

Encapsulating an application inside the database is not something which one must go to NoSQL to do.  RDBMS’s which are strongly programmable are capable of doing this now, although perhaps few if any rival the flexibility in this area of PostgreSQL.  The RDBMS can then be an ‘information model server’ which serves out information models as requested, each of which encapsulates further data within it.  The data model can then be consumed and expanded in the Model of an MVC framework, but the impedance mismatch issues can largely be eliminated by understanding and utilizing separation of concerns to one’s advantage.

 Of course none of this is to disparage NoSQL.  These products have been successfully used quite often as adjuncts to traditional RDBMS’s, either preprocessing or post-processing data for later use or re-use.  Some users of polyglot storage models have found rapid increases in development pace when these are used together, with data often being piped from something like Mongo or Neo4j into PostgreSQL after essentially using these engines to transform the data, or using it to transform the data on output.   This is particularly true when processing large amounts of data which is to be processed in relatively non-relational ways, such as with graph databases, array-native databases (in the sciences) etc.  The combination between these approaches becomes very rich.

 But it does suggest that the days of utilizing the RDBMS  as a dumb data store for a given application are numbered in many cases.  I don’t see anything challenging the primacy of the RDBMS environment, but at the same time, that doesn’t mean no role for the other ones as well.  If you want a dumb persistence store, you can go with a NoSQL solution and be happier.  However, the overall approach of avoiding the mismatch by encapsulating data storage inside of an application is equally applicable to the RDBMS environment as any other.