Как программы на Си взаимодействуют с сервером БД PostgreSQL

Владимир Мешков

PostgreSQL является эффективным средством для хранения и обработки информации. Разработчики этой СУБД предоставили интерфейсы для многих языков программирования. Поддержка таких языков, как Perl, PHP, Python, обеспечивает широкое применение PostgreSQL в области веб-программирования. Язык системного программирования Cи позволит использовать эту СУБД, когда необходимо добиться от приложения максимального быстродействия.

С егодня мы рассмотрим пример взаимодействия программы на языке Си и сервера баз данных PostgreSQL c использованием библиотеки libpq. В случае отсутствия опыта работы с СУБД PostgreSQL рекомендую начать изучение этой темы со статьи Сергея Супрунова [1].

Обзор библиотеки libpq

Библиотека libpq является программным интерфейсом, обеспечивающим взаимодействие программы, составленной на языке Си, с сервером баз данных PostgreSQL. Эта библиотека содержит набор функций, позволяющих клиентской программе обмениваться информацией с базой данных. Библиотека входит в состав дистрибутива СУБД PostgreSQL.

Для выполнения информационного обмена клиентская программа вначале должна подключиться к базе данных. Для связи с сервером баз данных используется механизм сокетов, при этом если клиент и сервер расположены на одной локальной машине, используется сокет домена AF_UNIX, в случае расположения на удаленных машинах – сокет домена AF_INET. Тип домена указывается в параметрах системного вызова socket.

Для хранения адресной информации сокет домена AF_UNIX использует структурный тип sockaddr:

struct sockaddr {
    sa_family_t sa_family;
    char sa_data[14];
}

Поле sa_family определяет тип домена, к которому принадлежит сокет (AF_UNIX в нашем случае), массив sa_data содержит путь к файлу, который описывает сокет.

Таким образом, сокет домена AF_UNIX представляет собой специальный файл. Сервер PostgreSQL после запуска по умолчанию создает в каталоге /tmp сокет домена AF_UNIX в виде файла .s.PGSQL.5432, посмотреть на который можно при помощи команды ls -la. Среди прочих файлов будет запись следующего вида:

srwxrwxrwt 1 pgsql users 0 Okt 4 10:37 .s.PGSQL.5432

Литера «s» перед правами доступа означает, что данный файл является сокетом.

Команда netstat -a позволяет нам убедиться, что файл /tmp/.s.PGSQL.5432 входит в список активных сокетов домена AF_UNIX. Введем эту команду и увидим запись примерно такого вида:

unix 2 [ ACC ] STREAM LISTENING 20636071 /tmp/.s.PGSQL.5432

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

Листинг 1. Серверный процесс

#include <sys/types.h>
#include <sys/socket.h>

int main()
{
    int sock, newsock;
    struct sockaddr saddr;
    char c;
    static char rc = 1;
/* Создаем сокет домена AF_UNIX */
    sock = socket(AF_UNIX, SOCK_STREAM, 0);

/* Заполняем адресную структуру saddr */
    memset((void *)&saddr,0, sizeof(saddr));
    saddr.sa_family = AF_UNIX; /* тип домена */

    /* путь к файлу */
    memcpy(saddr.sa_data,"/tmp/.sock.new", 14);

    bind(sock, (struct sockaddr *)&saddr, sizeof(struct sockaddr));

    listen(sock, 1);

    for(;;) {
    newsock = accept(sock,NULL, NULL);
    if(fork() == 0) {
        while(recv(newsock, &c, 1, 0) > 0) {
           send(newsock, &i, 1, 0);
           i++;
        }
        close(newsock);
        exit(0);
    }
    close(newsock);
    }
    return 0;
}

Листинг 2. Клиентский процесс

int main()
{
    int sock;
    struct sockaddr saddr;
    char c, rc;

    sock = socket(AF_UNIX,SOCK_STREAM, 0);

    memset((void *)&saddr, 0, sizeof(saddr));

    saddr.sa_family = AF_UNIX;

    memcpy(saddr.sa_data,"/tmp/.sock.new", 14);

    connect(sock, (struct sockaddr *)&saddr, sizeof(struct sockaddr));

    for(;;) {
    c = getchar();
    send(sock, &c, 1, 0);
    if(recv(sock, &rc, 1, 0) > 0) printf("From server: %d\n", rc);
    else {
        close(sock);
        exit(0);
    }
    }

    return 0;
}

Запустим процессы в разных терминалах. Сервер после запуска создаст в каталоге /tmp файл .sock.new. Через этот файл будет осуществляться взаимодействие между клиентом и сервером: клиент будет отправлять серверу символы, вводимые пользователем, а сервер будет возвращать числовые значения, каждый раз увеличивая их на 1. При этом на каждый введенный символ сервер отвечает двумя. Тут все правильно, т.к. серверу передается еще и символ перевода строки «\n», вот он на него и реагирует.

После остановки сервера сигналом SIGINT (комбинация клавиш <Ctrl+C>) файл .sock.new останется в каталоге /tmp. Его необходимо удалить вручную, или переопределить обработчик сигнала SIGINT для закрытия сокета и удаления файла .sock.new, иначе при повторном запуске сервера системный вызов bind не сможет привязать адресную структуру к сокету, сообщая нам, что «Address already in use (адрес уже используется)».

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

PGconn *PQsetdbLogin(const char *pghost,
           const char *pgport, const char *pgoptions,
           const char *pgtty, const char *dbName,           const char *login, const char *pwd);

Эта функция устанавливает новое соединение с базой данных, которое описывается при помощи объекта типа PGconn. Параметрами функции являются:

n  pghost – если сервер и клиент расположены на локальном хосте, этот параметр принимает значение NULL, и взаимодействие с сервером осуществляется через сокет домена AF_UNIX, по умолчанию расположенный в каталоге /tmp. При работе через сеть это поле содержит имя или IP-адрес хоста, на котором находится сервер баз данных;

  pgport – номер порта (NULL для локального хоста);

n  pgoptions – дополнительные опции, посылаемые серверу для трассировки/отладки соединения;

n  pgtty – терминал или файл для вывода отладочной информации;

n  dbName – имя базы данных;

n  login, pwd – имя пользователя и пароль доступа к базе данных.

 

Функция PQsetdbLogin всегда возвращает указатель на объект типа PGconn, независимо от того, успешно было установлено соединение или нет. Проверку состояния соединения выполняет функция PQstatus. Объект типа PGconn передается этой функции в качестве параметра, возвращаемое функцией значение характеризует состояние соединения:

n  CONNECTION_BAD – не удалось установить соединение с базой данных;

n  CONNECTION_OK – соединение с базой данных успешно установлено.

Эти значения определены в заголовочном файле libpqfe.h.

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

PGresult *PQexec(PGconn *conn, const char *query);

Параметрами функции PQexec являются указатель на объект типа PGconn (результат работы функции PQsetdbLogin) и строка, содержащая запрос к базе данных. Отправив запрос, функция ожидает ответ от базы и сохраняет в структуре типа Pgresult статус запроса и данные, полученные от базы. Для обработки статуса запроса к базе данных используется функция PGresultStatus.

ExecStatusType PQresultStatus(const PGresult *res);

Функция PQresultStatus может возвращать следующие значения, определенные в файле libpq-fe.h:

n  PGRES_EMPTY_QUERY – серверу отправлена пустая строка запроса;

n  PGRES_COMMAND_OK – запрос, не требующий возврата данных из базы, выполнен успешно;

n  PGRES_TUPLES_OK – успешное чтение данных из базы;

n  PGRES_FATAL_ERROR – при обращении к базе данных произошла критическая ошибка.

Если статус запроса равен PGRES_TUPLES_OK, структура PGresult будет содержать данные, полученные от базы. Данные представляют собой последовательность (кортеж) строк таблицы, и каждая строка состоит из нескольких ячеек. Выполнить выборку содержимого определенной ячейки можно при помощи функции PQgetvalue:

char* PQgetvalue(const PGresult *res, int tup_num, int field_num);

Здесь tup_num – это номер строки таблицы, а field_num – номер ячейки в строке, из которой считываются данные. Для определения числа строк, считанных из таблицы, используется функция PQntuples (tuple в переводе с английского означает кортеж, последовательность):

t; font-family: "Courier New";">int PQntuples(const PGresult *res);

Функция PQnfields вернет число ячеек в одной строке таблицы:

int PQnfields(const PGresult *res);

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

void PQclear(PQresult *res);
void PQfinish(PGconn *conn);

Пример использования библиотеки libpq

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

Для выполнения этой задачи устанавливаем на локальную машину СУБД PostgreSQL (см. [1]). После инициализации базы данных создаем нового пользователя my_user и новую базу my_database:

createuser -a -d my_user -E -P
createdb -O my_user my_database

Для доступа к базе данных пользователь my_user должен указать пароль. Сам пароль будет храниться в зашифрованном виде, в конфигурационном файле pg_hba.conf меняем значение поля METHOD c trust на md5.

Далее, подключаемся к базе данных my_database и создаем в ней таблицу, состоящую из двух полей: поля fname типа char(100) для хранения имен файлов и поля fsize типа int для хранения размеров файлов.

Заполнение базы данных информацией

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

# ./insert_data -d [имя базы данных] -t [имя таблицы] -p [имя каталога]

Определим переменные для хранения имен базы данных, таблицы и каталога для чтения:

unsigned char *dbname = NULL; /* имя базы данных */
unsigned char *table = NULL; /* имя таблицы */
unsigned char *pathname = NULL; /* каталог, из которого считываются данные */

Проверяем число переданных аргументов. Их должно быть 7:

if(argc != 7) usage();

Если количество переданных аргументов не соответствует указанному значению, при помощи функции usage() отобразим формат вызова нашей программы:

void usage()
{
    fprintf(stderr, "Usage: insert_data -d [имя базы данных] -t [имя таблицы] -p [исходный каталог]\n");
    exit(0);
}

Считываем параметры командной строки. Разбор командной строки выполним при помощи функции getopt:

while((int c = getopt(argc, argv, "d:t:p:")) != EOF) {
    switch(c) {
        case 'd':
           /* имя базы данных */
           dbname = (unsigned char *)optarg;
           break;
        case 't':
           /* имя таблицы */
           table = (unsigned char *)optarg;
           break;
        case 'p':
           /* имя каталога */
           pathname = (unsigned char *)optarg;
           break;
        /* ошибка в параметрах */
        case '?':
        default:
           usage();
    }
}

Считываем имя пользователя и пароль для доступа к базе данных:

unsigned char user[80]; /* имя пользователя */
unsigned char pwd[80]; /* пароль доступа к базе данных */

memset(user, 0, sizeof(user));
printf("Login: ");
scanf("%s", user);

memset(pwd, 0, 80);
printf("Password:");

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

#include <termios.h>
int tcgetattr(int ttyfd, struct termios *told);
int tcsetattr(int ttyfd, int actions, const struct termios *tnew);

Функция tcgetattr сохраняет текущее состояние терминала в структуре told типа termios. Параметр ttyfd должен быть дескриптором файла, описывающего терминал. Для получения доступа к своему управляющему терминалу процесс может использовать имя файла /dev/tty, которое всегда интерпретируется как текущий управляющий терминал или стандартный вывод с дескриптором 0. Вызов функции tcsetattr установит новое состояние терминала, заданное структурой tnew, а параметр actions определяет, когда и как будут установлены новые атрибуты терминала:

n  TCSNOW – немедленное выполнение изменений;

n  TCSADRAIN – перед установкой новых параметров ожидается опустошение очереди вывода;

n  TCSAFLUSH – ожидается опустошение очереди вывода, затем также очищается очередь ввода.

Для доступа к управляющему терминалу открываем соответствующий файл устройства:

int ttyfd = open("/dev/tty",O_RDWR);

Далее считываем текущее состояние терминала в структуру struct termios t, снимаем флаг отображения символов ECHO в поле c_lflag и устанавливаем новое состояние терминала:

tcgetattr(ttyfd, &t); /* сохраняем настройки терминала */
t.c_lflag &= ~ECHO; /* сбрасываем флаг ECHO */
tcsetattr(ttyfd, TCSANOW, &t); /* устанавливаем новое состояние терминала */

Наличие флага TCSANOW требует немедленного выполнения изменений. Подробности управления терминалом смотрите в man termios.

После этих действий вводим пароль для доступа к базе данных:

scanf("%s", pwd);

Вернем настройки терминала в исходное состояние – включим отображение вводимых символов на экране:

t.c_lflag |= ECHO; /* устанавливаем флаг ECHO */
tcsetattr(ttyfd, TCSANOW, &t);
close(ttyfd);

Подключаемся к базе данных, вызвав функцию PQsetdbLogin. Эта функция вернет указатель на объект типа PGconn, независимо от того, успешно было установлено соединение или нет:

PGconn *conn = PQsetdbLogin(NULL, NULL, NULL, NULL, dbname, user, pwd);

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

PQsetdbLogin("192.168.1.1", "5432", NULL, NULL, dbname, user, pwd),

где 192.168.1.1 – IP адрес хоста, на котором установлен сервер баз данных, 5432 – порт, который слушает база.

Анализируем состояние соединения и в случае ошибки завершаем выполнение программы:

if(PQstatus(conn) == CONNECTION_BAD) {
    fprintf(stderr, "Connection to database failed.\n");
    fprintf(stderr, "%s", PQerrorMessage(conn));
    exit(1);
}

При успешном установлении соединения с базой данных считываем необходимую нам информацию из указанного каталога. Считывание выполняет рекурсивная функция list_dir(), в параметрах которой мы передаем указатель на объект типа PGconn, имя таблицы в базе данных и имя каталога:

int list_dir(PGconn *conn, unsigned char *table, 
    unsigned char *pathname)
{
    struct dirent *d;
    struct stat s;
    DIR *dp;
    PGresult *res; /* результат обращения к базе данных */
    unsigned char full_path[256];
/* абсолютное путевое имя файла */
    unsigned char query[QUERY_LEN];
/* строка запроса к базе данных */
    unsigned char escape_string[80];
/* данные, передаваемые базе */

    /* Открываем каталог */
    if((dp = opendir(pathname)) == NULL) {
           perror("opendir");
           return -1;
    }

    /* Пропускаем родительский и текущий каталоги */

    d = readdir(dp); //"."
    d = readdir(dp); //".."

    /* Цикл чтения записей каталог */
    while(d = readdir(dp)) {
    /* Формируем абсолютное путевое имя файла и получаем информацию о нем */
           memset(full_path, 0, 256);
           sprintf(full_path,"%s/%s", pathname, d->d_name);
           stat(full_path,&s);

    /* Если это каталог – выполняем рекурсивный вызов функции */
           if(S_ISDIR(s.st_mode)) list_dir(conn, table, full_path);

    /* Добавляем в базу информацию о файле, при этом преобразуем путевое имя файла при помощи */

    /* функции PQescapeString */
           memset(escape_string, 0, 80);
           PQescapeString(escape_string, full_path, 80);

    /* Формируем запрос и отправляем его базе данных */
           memset(query, 0, QUERY_LEN);
           sprintf(query, "INSERT INTO %s values('%s','%u')", table, full_path, s.st_size);
           res = PQexec(conn, query);

    /* Проверяем статус запроса. Он должен быть равен PGRES_COMMAND_OK, т.к. данных от базы мы не получаем */

           if(PQresultStatus(res) != PGRES_COMMAND_OK) {
                 fprintf(stderr, "INSERT query failed.\n");
                 break;
           }
    }
    closedir(dp);
    PQclear(res);
    return 0;
}

После записи информации в базу данных отключаемся от нее:

PQfinish(conn);

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

Если вместо функции отключения от базы перед выходом из программы организовать бесконечный цикл и ввести в соседнем терминале команду netstat, то можно увидеть, что процесс установил соединение с базой данных через сокет домена AF_UNIX. При остановке процесса сигналом SIGINT (комбинация клавиш Ctrl-C) это соединение исчезает, даже если мы не вызываем функцию PQfinish. Другое дело, если процесс не закрыл соединение и продолжает функционировать (например, если это фоновый процесс). Тогда возможна ситуация несанкционированного использования уже установленного соединения (сокет не закрыт) для доступа к базе данных, и при этом необязательно знать пароль. Поэтому закрывать соединение надо явно.

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

#include <ftw.h>
int ftw(const char *path, int(*func)(), int depth);

Первый параметр path определяет имя каталога, с которого должен начаться рекурсивный обход дерева. Параметр depth управляет числом используемых функцией ftw различных дескрипторов файлов. Чем больше значение depth, тем меньше будет случаев повторного открытия каталогов, что сократит общее время обработки вызова. Второй параметр func – это определенная пользователем функция, вызываемая для каждого файла или каталога, найденного в поддереве каталога path. При каждом вызове функции func будут передаваться три аргумента: заканчивающаяся нулевым символом строка с именем объекта, указатель на структуру stat с данными об объекте и целочисленный код. Функция func, следовательно, должна быть построена следующим образом:

int func(const char *name, const struct stat *sptr, int type)
{
    /* Тело функции */
}

Целочисленный аргумент type может принимать одно из нескольких возможных значений, определенных в заголовочном файле и описывающих тип встретившегося объекта:

n  FTW_F – объект является файлом;

n  FTW_D – объект является каталогом;

n  FTW_DNR – объект является каталогом, который нельзя прочесть;

n  FTW_SL – объект является символьной ссылкой;

n  FTW_NS – объект не является символьной ссылкой, для него нельзя успешно выполнить вызов stat.

Работа вызова будет продолжаться до тех пор, пока не будет завершен обход дерева или не возникнет ошибка внутри функции ftw. Обход также закончится, если определенная пользователем функция возвратит ненулевое значение. Тогда функция ftw прекратит работу и вернет значение, возвращенное функцией пользователя. Ошибки внутри функции ftw приведут к возврату значения -1, тогда в переменной errno будет выставлен соответствующий код ошибки.

Вызовем в нашей программе вместо рекурсивной функции list_dir функцию ftw:

ftw(pathname, list_dir1, 1);

Функция list_dir1 передает базе данных информацию о каждом регулярном файле:

int list_dir1(const char *name, const struct stat *s, 
    int type)
{
    PGresult *res;
    /* строка запроса к базе данных */
    unsigned char query[QUERY_LEN];
    unsigned char escape_string[80];

    /* Возвращаемся, если вызов stat завершился неудачно */
    if(type == FTW_NS) return 0;

    /* Если объект является регулярным файлом, добавляем информацию о нем в базу */
    if((type == FTW_F) && S_ISREG(s->st_mode)) {
           memset(escape_string,
0, sizeof(escape_string));
           PQescapeString(escape_string,
name, sizeof(escape_string));
           memset(query, 0, QUERY_LEN);
           sprintf(query, "INSERT INTO %s values('%s','%u')", table, name, s->st_size);
           res = PQexec(conn, query);
    /* Проверяем статус запроса */

           if(PQresultStatus(res) !=PGRES_COMMAND_OK) {
                 fprintf(stderr, "INSERT query failed.\n");
                 return -1;
           }
           PQclear(res);
    }
    return 0;
}

Для получения исполняемого модуля введем команду:

# gcc -o insert_data insert_data.c -lpq

Чтение информации из базы данных

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

# gcc -o insert_data insert_data.c -lpq

conn = PQsetdbLogin(NULL, NULL, NULL, NULL, dbname, user, pwd);
if(PQstatus(conn) == CONNECTION_BAD) {
    fprintf(stderr, "Connection to database failed.\n");
    fprintf(stderr, "%s", PQerrorMessage(conn));
    exit(1);
}

Формируем и отправляем запрос к базе для выборки всех полей из таблицы:

memset(query, 0, QUERY_LEN);
sprintf(query, "SELECT * FROM %s", table);
res = PQexec(conn, query);

В случае успешного чтения данных из базы статус запроса должен быть равен PGRES_TUPLES_OK. Проверяем это:

if(PQresultStatus(res) != PGRES_TUPLES_OK) {
    fprintf(stderr, "SELECT query failed.\n");
    goto out;
}

Отображаем результаты чтения:

for(i = 0; i < PQntuples(res); i++) {
    for(n = 0; n < PQnfields(res); n++) printf("%-20s", PQgetvalue(res, i, n));    printf("\n");
}

Функция PQntuples вернет число прочитанных из таблицы строк, а функция PQnfields – число ячеек в одной строке.

Работоспособность программ была проверена для ОС Linux Slackware 10.2 и FreeBSD 5.2, использовался сервер баз данных PostgreSQL 8.0.3.

Литература:

1. Супрунов С. PostgreSQL: первые шаги. – Журнал «Системный администратор», №7, 2004 г. – 26-33 с (http://www.samag.ru/cgi-bin/go.pl?q=articles;n=07.2004;a=06).

2. PostgreSQL 7.3.2 Programmer’s Guide by The PostgreSQL Global Development Group.

3. Кейт Хэвиленд, Дайна Грей, Бен Салама. Системное программирование в UNIX. Руководство программиста по разработке ПО = Unix System Programming. A programmer’s guide to software development: Пер. с англ. – М., ДМК Пресс, 2000 г. – 368 с., ил.

Back to top

(С) Виктор Вислобоков, 2008-2023