...

понедельник, 23 декабря 2013 г.

Взаимодействие PostgreSQL с внешним сервисом для хранения изображений



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


  • изображения находятся целиком в БД

  • изображения находятся в файловой системе, в БД хранится имя файла

  • изображения находятся во внешнем специализированном сервисе




Хоть PostgreSQL и предоставляет возможность хранения в БД файлов (непосредственно в bytea полях или через large objects), это наименее оптимальный вариант, как в плане скорости, так и потребляемой памяти. Другой, общепринятой практикой, является хранение изображений в виде файлов на диске, для сайта формируется путь к изображению. Из преимуществ — возможность кеширования или использование специализированной файловой системы. И третий вариант — для изображений выделяется отдельный сервис, в котором может быть кеширование, маштабирование на лету, изменение формата. Попробуем реализовать взаимодействие PostgreSQL с таким сервисом.



Реализация




Обрисуем немного картину происходящего. У нас имеется http-сервис, по типу этого, для изображений, поддерживающий такие команды:


  • загрузка изображения — отправка POST-запроса с формой, в ответ приходит JSON с некоторой информацией об изображении, среди которой сгенерированный идентификатор

  • получение изображения — отправка GET-запроса c идентификатором изображения my.service.local/1001

  • удаление изображения — отправка DELETE-запроса c идентификатором изображения my.service.local/1001




В БД будут хранится идентификаторы изображений, в таком случае, на страницах сайта можно будет втраивать теги вида:

<img src="http://my.service.local/1001"/>



Со стороны пользователя загрузка изображения (равно как сохранение и удаление) должна выглядеть как вызов функции upload_image (с параметром filename), которая возращает идентификатор изображения в сервисе, записуемый затем в таблицу. Так как напрямую из PostgreSQL нельзя доступится к http запросам, необходимо реализовывать требуемый функционал на хранимых функциях на С, а в них уже есть где разгулятся. Для простоты, обойдёмся библиотеками curl и jansson (последняя для работы с JSON). Можем начинать.

Определим в заголовочном файле barberry_impl.h наши прототипы функций:



// get last error
char* barberry_error();

// upload file to BarBerry's service and return ID
int barberry_upload_file(const char *host, const char *filename);

// download file from BarBerry's service by ID
int barberry_download_file(const char *host, int id, const char *filename);

// delete file from BarBerry's service by ID
int barberry_delete_file(const char *host, int id);




В файле с исходном кодом barberry_impl.c поместим следующие глобальные переменные:

char last_error[1024];
FILE *file = NULL;
int result = 0;




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

Реализация функции barberry_error тривиальна — возврат last_error. Разберем подробно функцию barberry_upload_file.


Перед тем, как начать работу с библиотекой curl, необходимо проинициализировать окружение для неё (командой curl_gobal_init) и создать сессию (командой curl_easy_init, возращающей указатель на хэндл сессии). Далее, создаем submit-форму (через curl_formadd) и заполняем следующие опции:



  • CURLOPT_URL — хост, с которым мы работаем

  • CURLOPT_HTTPPOST — форма, отправляемая методом POST

  • CURLOPT_WRITEFUNCTION — CALLBACK-функция для ответа от хоста




Реализация barberry_upload_file:

int barberry_upload_file(const char *host, const char *filename)
{
result = -1;

curl_global_init(CURL_GLOBAL_ALL);

CURL *curl = curl_easy_init();

if (curl)
{
curl_easy_setopt(curl, CURLOPT_URL, host);

struct curl_httppost *httppost = NULL;
struct curl_httppost *last_ptr = NULL;

curl_formadd(&httppost, &last_ptr, CURLFORM_COPYNAME, "sendfile", CURLFORM_FILE, filename, CURLFORM_END);
curl_formadd(&httppost, &last_ptr, CURLFORM_COPYNAME, "submit", CURLFORM_COPYCONTENTS, "send", CURLFORM_END);

curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, upload_response);
curl_easy_setopt(curl, CURLOPT_HTTPPOST, httppost);

CURLcode res = curl_easy_perform(curl);

if (res != CURLE_OK)
{
sprintf(last_error, "%s", curl_easy_strerror(res));
}

curl_easy_cleanup(curl);
curl_formfree(httppost);
}

return result;
}




CALLBACK-функция upload_response имеет прототип:

size_t function(char *ptr, size_t size, size_t nmemb, void *userdata);




с параметрами:


  • ptr — указатель на получаемые данные

  • size * nmemb — их размер

  • userdata — указатель на FILE*, при необходимости устанавливаемый через опцию CURLOPT_WRITEDATA




Функция должна возратить фактической размер обработанных данных, т.е. size * nmemb. В данном, в этой функции необходимо распарсить JSON передаваемый в ответе:

size_t upload_response(char *ptr, size_t size, size_t nmemb, void *userdata)
{
(void)userdata;

parse_upload_response(ptr);

return size * nmemb;
}




Поручим это другой функции, в которой используем jansson для разбора ответа:

void parse_upload_response(const char *text)
{
if (!strcmp(text, "{}"))
{
sprintf(last_error, "%s", "Empty file");

return;
}

json_error_t error;

json_t *root = json_loads(text, 0, &error);

if (!root)
{
sprintf(last_error, "%s", text);

return;
}

json_t *id = json_object_get(root, "id");

if(!json_is_integer(id))
{
sprintf(last_error, "%s", text);

json_decref(root);

return;
}

result = json_integer_value(id);

json_decref(root);
}




В случае пустого файла, нам прийдёт ответ {}, обработаем этот случай. Если всё в порядке, файл был успешно загружен ответ прийдет в виде: { «id»:1001, «ext»:«png»… }. Интересует только id, его и записываем в result.

Функция для сохранения файла немного проще — нужно лишь сформировать GET-запрос, получить ответ и записать его в файл (обработав ситуацию, когда файл с нужным id не найден):


barberry_download_file


int barberry_download_file(const char *host, int id, const char *filename)
{
result = 0;

file = fopen(filename, "wb");

if (!file)
{
sprintf(last_error, "%s", "Can't create file");

return -1;
}

curl_global_init(CURL_GLOBAL_ALL);

CURL *curl = curl_easy_init();

if (curl)
{
char buffer[1024];

sprintf(buffer, "%s/%d", host, id);

curl_easy_setopt(curl, CURLOPT_URL, buffer);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, download_response);

CURLcode res = curl_easy_perform(curl);

if (res != CURLE_OK)
{
sprintf(last_error, "%s", curl_easy_strerror(res));

result = -1;
}

curl_easy_cleanup(curl);
}

fclose(file);

return result;
}







download_response


size_t download_response(char *ptr, size_t size, size_t nmemb, void *userdata)
{
(void)userdata;

if (!strcmp(ptr, "{}"))
{
sprintf(last_error, "%s", "File on server not found");

result = -1;
}
else
{
fwrite(ptr, size * nmemb, 1, file);
}

return size * nmemb;
}







Удаление файла в сервисе — это DELETE-запрос (тип запроса для curl устанавливается через опцию CURLOPT_CUSTOMREQUEST):

barberry_delete_file


int barberry_delete_file(const char *host, int id)
{
result = 0;

curl_global_init(CURL_GLOBAL_ALL);

CURL *curl = curl_easy_init();

if (curl)
{
char buffer[1024];

sprintf(buffer, "%s/%d", host, id);

curl_easy_setopt(curl, CURLOPT_URL, buffer);
curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "DELETE");
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, delete_response);

CURLcode res = curl_easy_perform(curl);

if (res != CURLE_OK)
{
sprintf(last_error, "%s", curl_easy_strerror(res));

result = -1;
}

curl_easy_cleanup(curl);
}

return result;
}







delete_response


size_t delete_response(char *ptr, size_t size, size_t nmemb, void *userdata)
{
(void)ptr;
(void)userdata;

return size * nmemb;
}







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

barberry_test.c


#include "barberry_impl.h"

void print_help()
{
fprintf(stdout, "Usage:\n");
fprintf(stdout, " bbtest upload my.service.local /home/username/image1000.png\n");
fprintf(stdout, " bbtest download my.service.local 1000 /home/username/image1000.png\n");
fprintf(stdout, " bbtest delete my.service.local 1000\n\n");
}

int main(int argc, char *argv[])
{
(void)argc;
(void)argv;

if (argc <= 2)
{
print_help();

return 0;
}

if (!strcmp(argv[1], "upload"))
{
if (argc != 4)
{
print_help();

return 0;
}

int id = barberry_upload_file(argv[2], argv[3]);

if (id != -1)
{
fprintf(stdout, "File uploaded with id %d\n", id);
}
else
{
fprintf(stderr, "%s\n", barberry_error());
}
}
else if (!strcmp(argv[1], "download"))
{
if (argc != 5)
{
print_help();

return 0;
}

int result = barberry_download_file(argv[2], atoi(argv[3]), argv[4]);

if (result != -1)
{
fprintf(stdout, "%s\n", "File downloaded");
}
else
{
fprintf(stderr, "%s\n", barberry_error());
}
}
else if (!strcmp(argv[1], "delete"))
{
if (argc != 4)
{
print_help();

return 0;
}

int result = barberry_delete_file(argv[2], atoi(argv[3]));

if (result != -1)
{
fprintf(stdout, "%s\n", "File deleted");
}
else
{
fprintf(stderr, "%s\n", barberry_error());
}
}
else
{
print_help();
}

return 0;
}







Собираем всё это дело (пути в Вашей ОС к заголовочным файлам и библиотекам могут отличатся) и тестируем:

cc -c barberry_impl.c
cc -c barberry_test.c
cc -L/usr/lib -lcurl -ljansson -o bbtest barberry_test.o barberry_impl.o
./bbtest upload my.service.local ~/picture01.png
File uploaded with id 1017




Если всё в порядке, можно переходит к PostgreSQL-части нашей библиотеки (подробней о хранимых функциях на C в PostgreSQL описано в [4]).

Обьявим экспортируемые для БД функции (с версией 1):



PG_FUNCTION_INFO_V1(bb_upload_file);
PG_FUNCTION_INFO_V1(bb_download_file);
PG_FUNCTION_INFO_V1(bb_delete_file);




Для конвертирования из text (тип в PostgreSQL) в c-string поможет небольшая функция:

char* text_to_string(text *txt)
{
size_t size = VARSIZE(txt) - VARHDRSZ;

char *buffer = (char*)palloc(size + 1);

memcpy(buffer, VARDATA(txt), size);

buffer[size] = '\0';

return buffer;
}




Реализация экспортируемых функций заключается в делегировании написанному ранее функционалу, с генерацией ошибки, если что-то пошло не так:

bb_upload_file


Datum bb_upload_file(PG_FUNCTION_ARGS)
{
char *host = text_to_string(PG_GETARG_TEXT_P(0));
char *filename = text_to_string(PG_GETARG_TEXT_P(1));

int result = barberry_upload_file(host, filename);

if (result == -1)
{
elog(ERROR, "%s", barberry_error());
}

pfree(host);
pfree(filename);

PG_RETURN_INT32(result);
}







bb_download_file


Datum bb_download_file(PG_FUNCTION_ARGS)
{
char *host = text_to_string(PG_GETARG_TEXT_P(0));
int id = PG_GETARG_INT32(1);
char *filename = text_to_string(PG_GETARG_TEXT_P(2));

int result = barberry_download_file(host, id, filename);

if (result == -1)
{
elog(ERROR, "%s", barberry_error());
}

pfree(host);
pfree(filename);

PG_RETURN_VOID();
}







bb_delete_file


Datum bb_delete_file(PG_FUNCTION_ARGS)
{
char *host = text_to_string(PG_GETARG_TEXT_P(0));
int id = PG_GETARG_INT32(1);

int result = barberry_delete_file(host, id);

if (result == -1)
{
elog(ERROR, "%s", barberry_error());
}

pfree(host);

PG_RETURN_VOID();
}







Собираем динамическую библиотеку и копируем ее к PostgreSQL (пути в Вашей ОС к заголовочным файлам и библиотекам могут отличатся):

rm -rf *.o
cc -I/usr/include/postgresql/server -fpic -c barberry.c
cc -I/usr/include/postgresql/server -fpic -c barberry_impl.c
cc -L/usr/lib -lpq -lcurl -ljansson -shared -o barberry.so barberry.o barberry_impl.o
cp *.so /usr/lib/postgresql




SQL-функции, создаваемые в БД, имеют вид:

CREATE OR REPLACE FUNCTION public.bb_upload_file ( p_host text, p_filename text )
RETURNS integer AS
'barberry', 'bb_upload_file'
LANGUAGE c VOLATILE STRICT;

CREATE OR REPLACE FUNCTION public.bb_download_file ( p_host text, p_id integer, p_filename text )
RETURNS void AS
'barberry', 'bb_download_file'
LANGUAGE c VOLATILE STRICT;

CREATE OR REPLACE FUNCTION public.bb_delete_file ( p_host text, p_id integer )
RETURNS void AS
'barberry', 'bb_delete_file'
LANGUAGE c VOLATILE STRICT;




Оформим динамическую библиотеку и SQL-скрипт в виде расширения к PostgreSQL (подробнее описано в [5]). Для этого потребуется управляющий файл barberry.control:

# BarBerry image service
comment = 'BarBerry image service'
default_version = '1.0'
module_pathname = '$libdir/barberry'
relocatable = true




SQL-скрипт для нашего расширения необходимо назвать как barberry--1.0.sql (согласно документации PostgreSQL). Скопируем эти два файла туда, где PostgreSQL хранить свои расширения.

Создание и использование расширения предельно простое:



CREATE EXTENSION barberry;
UPDATE avatar SET image = bb_upload_file ( 'my.service.local', 'images/avatar_admin.png' ) WHERE name = 'admin';




Исходные файлы




Библиотека выша как небольшая утилита, поэтому не размещена на github. Для облегчения сборки добавлен Makefile с целями barberry, barberry_test, clean, rebuild, install.

barberry_impl.h


#ifndef BARBERRY_IMPL_H
#define BARBERRY_IMPL_H

#include <stdio.h>
#include <string.h>
#include <curl/curl.h>
#include <jansson.h>

// get last error
char* barberry_error();

// upload file to BarBerry's service and return ID
int barberry_upload_file(const char *host, const char *filename);

// download file from BarBerry's service by ID
int barberry_download_file(const char *host, int id, const char *filename);

// delete file from BarBerry's service by ID
int barberry_delete_file(const char *host, int id);

#endif // BARBERRY_IMPL_H







barberry_impl.c


#include "barberry_impl.h"

char last_error[1024];
FILE *file = NULL;
int result = 0;

void parse_upload_response(const char *text)
{
if (!strcmp(text, "{}"))
{
sprintf(last_error, "%s", "Empty file");

return;
}

json_error_t error;

json_t *root = json_loads(text, 0, &error);

if (!root)
{
sprintf(last_error, "%s", text);

return;
}

json_t *id = json_object_get(root, "id");

if(!json_is_integer(id))
{
sprintf(last_error, "%s", text);

json_decref(root);

return;
}

result = json_integer_value(id);

json_decref(root);
}

size_t upload_response(char *ptr, size_t size, size_t nmemb, void *userdata)
{
(void)userdata;

parse_upload_response(ptr);

return size * nmemb;
}

size_t download_response(char *ptr, size_t size, size_t nmemb, void *userdata)
{
(void)userdata;

if (!strcmp(ptr, "{}"))
{
sprintf(last_error, "%s", "File on server not found");

result = -1;
}
else
{
fwrite(ptr, size * nmemb, 1, file);
}

return size * nmemb;
}

size_t delete_response(char *ptr, size_t size, size_t nmemb, void *userdata)
{
(void)ptr;
(void)userdata;

return size * nmemb;
}


char* barberry_error()
{
return last_error;
}

int barberry_upload_file(const char *host, const char *filename)
{
result = -1;

curl_global_init(CURL_GLOBAL_ALL);

CURL *curl = curl_easy_init();

if (curl)
{
curl_easy_setopt(curl, CURLOPT_URL, host);

struct curl_httppost *httppost = NULL;
struct curl_httppost *last_ptr = NULL;

curl_formadd(&httppost, &last_ptr, CURLFORM_COPYNAME, "sendfile", CURLFORM_FILE, filename, CURLFORM_END);
curl_formadd(&httppost, &last_ptr, CURLFORM_COPYNAME, "submit", CURLFORM_COPYCONTENTS, "send", CURLFORM_END);

curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, upload_response);
curl_easy_setopt(curl, CURLOPT_HTTPPOST, httppost);

CURLcode res = curl_easy_perform(curl);

if (res != CURLE_OK)
{
sprintf(last_error, "%s", curl_easy_strerror(res));
}

curl_easy_cleanup(curl);
curl_formfree(httppost);
}

return result;
}

int barberry_download_file(const char *host, int id, const char *filename)
{
result = 0;

file = fopen(filename, "wb");

if (!file)
{
sprintf(last_error, "%s", "Can't create file");

return -1;
}

curl_global_init(CURL_GLOBAL_ALL);

CURL *curl = curl_easy_init();

if (curl)
{
char buffer[1024];

sprintf(buffer, "%s/%d", host, id);

curl_easy_setopt(curl, CURLOPT_URL, buffer);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, download_response);

CURLcode res = curl_easy_perform(curl);

if (res != CURLE_OK)
{
sprintf(last_error, "%s", curl_easy_strerror(res));

result = -1;
}

curl_easy_cleanup(curl);
}

fclose(file);

return result;
}

int barberry_delete_file(const char *host, int id)
{
result = 0;

curl_global_init(CURL_GLOBAL_ALL);

CURL *curl = curl_easy_init();

if (curl)
{
char buffer[1024];

sprintf(buffer, "%s/%d", host, id);

curl_easy_setopt(curl, CURLOPT_URL, buffer);
curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "DELETE");
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, delete_response);

CURLcode res = curl_easy_perform(curl);

if (res != CURLE_OK)
{
sprintf(last_error, "%s", curl_easy_strerror(res));

result = -1;
}

curl_easy_cleanup(curl);
}

return result;
}







barberry.c


#include <postgres.h>
#include <fmgr.h>

#include "barberry_impl.h"

#ifdef PG_MODULE_MAGIC
PG_MODULE_MAGIC;
#endif

PG_FUNCTION_INFO_V1(bb_upload_file);
PG_FUNCTION_INFO_V1(bb_download_file);
PG_FUNCTION_INFO_V1(bb_delete_file);

char* text_to_string(text *txt)
{
size_t size = VARSIZE(txt) - VARHDRSZ;

char *buffer = (char*)palloc(size + 1);

memcpy(buffer, VARDATA(txt), size);

buffer[size] = '\0';

return buffer;
}

Datum bb_upload_file(PG_FUNCTION_ARGS)
{
char *host = text_to_string(PG_GETARG_TEXT_P(0));
char *filename = text_to_string(PG_GETARG_TEXT_P(1));

int result = barberry_upload_file(host, filename);

if (result == -1)
{
elog(ERROR, "%s", barberry_error());
}

pfree(host);
pfree(filename);

PG_RETURN_INT32(result);
}

Datum bb_download_file(PG_FUNCTION_ARGS)
{
char *host = text_to_string(PG_GETARG_TEXT_P(0));
int id = PG_GETARG_INT32(1);
char *filename = text_to_string(PG_GETARG_TEXT_P(2));

int result = barberry_download_file(host, id, filename);

if (result == -1)
{
elog(ERROR, "%s", barberry_error());
}

pfree(host);
pfree(filename);

PG_RETURN_VOID();
}

Datum bb_delete_file(PG_FUNCTION_ARGS)
{
char *host = text_to_string(PG_GETARG_TEXT_P(0));
int id = PG_GETARG_INT32(1);

int result = barberry_delete_file(host, id);

if (result == -1)
{
elog(ERROR, "%s", barberry_error());
}

pfree(host);

PG_RETURN_VOID();
}







barberry_test.c


#include "barberry_impl.h"

void print_help()
{
fprintf(stdout, "Usage:\n");
fprintf(stdout, " bbtest upload my.service.local /home/username/image1000.png\n");
fprintf(stdout, " bbtest download my.service.local 1000 /home/username/image1000.png\n");
fprintf(stdout, " bbtest delete my.service.local 1000\n\n");
}

int main(int argc, char *argv[])
{
(void)argc;
(void)argv;

if (argc <= 2)
{
print_help();

return 0;
}

if (!strcmp(argv[1], "upload"))
{
if (argc != 4)
{
print_help();

return 0;
}

int id = barberry_upload_file(argv[2], argv[3]);

if (id != -1)
{
fprintf(stdout, "File uploaded with id %d\n", id);
}
else
{
fprintf(stderr, "%s\n", barberry_error());
}
}
else if (!strcmp(argv[1], "download"))
{
if (argc != 5)
{
print_help();

return 0;
}

int result = barberry_download_file(argv[2], atoi(argv[3]), argv[4]);

if (result != -1)
{
fprintf(stdout, "%s\n", "File downloaded");
}
else
{
fprintf(stderr, "%s\n", barberry_error());
}
}
else if (!strcmp(argv[1], "delete"))
{
if (argc != 4)
{
print_help();

return 0;
}

int result = barberry_delete_file(argv[2], atoi(argv[3]));

if (result != -1)
{
fprintf(stdout, "%s\n", "File deleted");
}
else
{
fprintf(stderr, "%s\n", barberry_error());
}
}
else
{
print_help();
}

return 0;
}







barberry--1.0.sql


CREATE OR REPLACE FUNCTION public.bb_upload_file ( p_host text, p_filename text )
RETURNS integer AS
'barberry', 'bb_upload_file'
LANGUAGE c VOLATILE STRICT;

CREATE OR REPLACE FUNCTION public.bb_download_file ( p_host text, p_id integer, p_filename text )
RETURNS void AS
'barberry', 'bb_download_file'
LANGUAGE c VOLATILE STRICT;

CREATE OR REPLACE FUNCTION public.bb_delete_file ( p_host text, p_id integer )
RETURNS void AS
'barberry', 'bb_delete_file'
LANGUAGE c VOLATILE STRICT







barberry.control


# BarBerry image service
comment = 'BarBerry image service'
default_version = '1.0'
module_pathname = '$libdir/barberry'
relocatable = true







Makefile


#################################
# Makefile for barberry library #
#################################

# options

CC=cc
CFLAGS=-fpic -c
INCLUDEPATH=-I/usr/include/postgresql/server
LIBS=-L/usr/lib -lpq -lcurl -ljansson

# targets

all: barberry barberry_test

barberry: barberry.o barberry_impl.o
$(CC) $(LIBS) -shared -o barberry.so barberry.o barberry_impl.o

barberry_test: barberry_test.o barberry_impl.o
$(CC) $(LIBS) -o bbtest barberry_test.o barberry_impl.o

barberry.o:
$(CC) $(INCLUDEPATH) $(CFLAGS) barberry.c

barberry_impl.o:
$(CC) $(INCLUDEPATH) $(CFLAGS) barberry_impl.c

barberry_test.o:
$(CC) $(INCLUDEPATH) $(CFLAGS) barberry_test.c

clean:
rm -rf *.o *.so bbtest

rebuild: clean all

install:
cp *.so /usr/lib/postgresql
cp *.control /usr/share/postgresql/extension
cp *.sql /usr/share/postgresql/extension







Примечания





  • так как динамическая библиотека загружается от имени postgres (пользователь по умолчанию для СУБД), он же должен иметь доступ к загружаемым файлам и право на создание сохраняемых файлов

  • можно расширить идею, сделав интерфейс для доступа к curl из PostgreSQL, прикрутив описание формы, заголовков и всего прочего в XML-формате, распарсивая потом в C-коде и выполняя соответствующие команды в curl




Список литературы





  1. Документация по PostgreSQL.

  2. Документация по curl.

  3. Документация по jansson.

  4. Хранимые функции на C в PostgreSQL.

  5. Создание расширений в PostgreSQL.


This entry passed through the Full-Text RSS service — if this is your content and you're reading it on someone else's site, please read the FAQ at fivefilters.org/content-only/faq.php#publishers.


Комментариев нет:

Отправить комментарий