7. ODBCを使ったデータベース連携

ここまでの章では、フォームから受け取った値をCGIプログラムの中で処理し、HTMLとして返してきました。

実際のWebアプリケーションでは、投稿、ユーザー情報、設定値などをリクエストをまたいで保存する必要があります。この章では、C言語からODBCを使ってMariaDBに接続し、SQLを実行する基本を扱います。


この章で学ぶこと


データベースを使う理由

CGIプログラムだけでも、受け取った値をその場で処理することはできます。しかし、次のような機能を作るには、データを保存し、検索し、更新できる仕組みが必要です。

この役割を担うのがデータベースです。本書では、MariaDBを例にして説明します。


ODBCとは何か

ODBC(Open Database Connectivity)は、アプリケーションからデータベースへ接続するための共通APIです。

C言語には、標準ライブラリだけでMariaDBやPostgreSQLに接続する機能はありません。各DB製品の専用ライブラリを使う方法もありますが、本書では共通的な手順で扱えるODBCを使います。

ODBCは、主に次の要素で構成されます。

要素 役割
アプリケーション C言語で書いたCGIやコマンドラインプログラム
ドライバマネージャ ODBC API呼び出しを適切なドライバへ中継する層
ODBCドライバ MariaDBなど、実際のDB製品と通信する部品
データベース データを保存する本体

Linuxでは、ドライバマネージャとして unixODBC を使うことが一般的です。

[Cプログラム]
    |
    | ODBC API
    v
[unixODBC]
    |
    | MariaDB ODBC Driver
    v
[MariaDB]

ODBCを使っても、SQL文そのものの違いが完全になくなるわけではありません。DB製品ごとのSQL方言や型の違いは残ります。ただし、接続、SQL実行、結果取得の基本手順は共通化できます。


事前準備

この章のサンプルでは、次のようなデータベースとテーブルを使います。

CREATE DATABASE sample;

CREATE TABLE sample.users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    age INT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

接続ユーザーの作成例です。

CREATE USER 'user'@'localhost' IDENTIFIED BY 'pass';
GRANT ALL PRIVILEGES ON sample.* TO 'user'@'localhost';
FLUSH PRIVILEGES;

ODBCドライバや odbc.ini / odbcinst.ini の設定は環境によって異なります。詳細はAppendixを参照してください。この章では、接続文字列を直接指定する例を使います。

DRIVER={MariaDB};SERVER=localhost;DATABASE=sample;UID=user;PWD=pass;

ODBC接続の基本手順

ODBCでは、いくつかのハンドルを順番に確保して使います。

ハンドル 用途
SQLHENV ODBC全体の環境
SQLHDBC データベース接続
SQLHSTMT SQL文の実行

基本の流れは次のとおりです。

環境ハンドルを確保する
ODBCバージョンを設定する
接続ハンドルを確保する
データベースに接続する
ステートメントハンドルを確保する
SQLを実行する
結果を確認する
ハンドルを解放する

ODBC関数は、戻り値として成功・失敗を返します。多くの場合、次の2つを成功として扱います。

ret == SQL_SUCCESS || ret == SQL_SUCCESS_WITH_INFO

この判定は何度も使うので、補助関数にしておくと読みやすくなります。

int odbc_succeeded(SQLRETURN ret) {
    return ret == SQL_SUCCESS || ret == SQL_SUCCESS_WITH_INFO;
}

エラー情報を表示する

ODBCで失敗したときは、SQLGetDiagRec() を使うと詳しい情報を取得できます。

#include <stdio.h>
#include <sql.h>
#include <sqlext.h>

void print_odbc_error(SQLSMALLINT handle_type, SQLHANDLE handle) {
    SQLCHAR state[6];
    SQLINTEGER native_error;
    SQLCHAR message[256];
    SQLSMALLINT message_len;

    SQLRETURN ret = SQLGetDiagRec(
        handle_type,
        handle,
        1,
        state,
        &native_error,
        message,
        sizeof(message),
        &message_len
    );

    if (ret == SQL_SUCCESS || ret == SQL_SUCCESS_WITH_INFO) {
        fprintf(stderr, "ODBC error [%s] %s\n", state, message);
    }
}

この関数は、接続失敗やSQL実行失敗の原因を調べるときに使います。ブラウザに内部エラーをそのまま表示するのは避け、開発中は標準エラーやApacheのエラーログに出すようにします。


接続して切断する

まずは、データベースに接続してすぐ切断する最小プログラムです。

#include <stdio.h>
#include <stdlib.h>
#include <sql.h>
#include <sqlext.h>

int odbc_succeeded(SQLRETURN ret) {
    return ret == SQL_SUCCESS || ret == SQL_SUCCESS_WITH_INFO;
}

int main(void) {
    SQLHENV env = SQL_NULL_HENV;
    SQLHDBC dbc = SQL_NULL_HDBC;
    SQLRETURN ret;
    int connected = 0;
    SQLCHAR conn_str[] =
        "DRIVER={MariaDB};SERVER=localhost;DATABASE=sample;UID=user;PWD=pass;";

    ret = SQLAllocHandle(SQL_HANDLE_ENV, SQL_NULL_HANDLE, &env);
    if (!odbc_succeeded(ret)) {
        fprintf(stderr, "Failed to allocate environment handle.\n");
        goto cleanup;
    }

    ret = SQLSetEnvAttr(env, SQL_ATTR_ODBC_VERSION, (void *)SQL_OV_ODBC3, 0);
    if (!odbc_succeeded(ret)) {
        fprintf(stderr, "Failed to set ODBC version.\n");
        goto cleanup;
    }

    ret = SQLAllocHandle(SQL_HANDLE_DBC, env, &dbc);
    if (!odbc_succeeded(ret)) {
        fprintf(stderr, "Failed to allocate connection handle.\n");
        goto cleanup;
    }

    ret = SQLDriverConnect(
        dbc,
        NULL,
        conn_str,
        SQL_NTS,
        NULL,
        0,
        NULL,
        SQL_DRIVER_NOPROMPT
    );
    if (!odbc_succeeded(ret)) {
        fprintf(stderr, "Failed to connect database.\n");
        goto cleanup;
    }

    connected = 1;
    printf("Connected.\n");

cleanup:
    if (dbc != SQL_NULL_HDBC) {
        if (connected) {
            SQLDisconnect(dbc);
        }
        SQLFreeHandle(SQL_HANDLE_DBC, dbc);
    }
    if (env != SQL_NULL_HENV) {
        SQLFreeHandle(SQL_HANDLE_ENV, env);
    }

    return odbc_succeeded(ret) ? EXIT_SUCCESS : EXIT_FAILURE;
}

コンパイル例です。

gcc db_connect.c -o db_connect -lodbc

実行します。

./db_connect

接続に失敗した場合は、接続文字列、ODBCドライバ名、MariaDBのユーザー、権限、起動状態を確認します。


INSERTを実行する

次に、INSERT でデータを追加します。

ユーザー入力をSQL文字列に直接連結するのは危険です。SQLインジェクションの原因になります。ODBCでは、? プレースホルダを使い、SQLBindParameter() で値を渡します。

SQLCHAR sql[] = "INSERT INTO users (name, age) VALUES (?, ?)";
SQLCHAR name[] = "Taro";
SQLINTEGER age = 25;
SQLLEN name_len = SQL_NTS;
SQLLEN age_len = 0;

ret = SQLAllocHandle(SQL_HANDLE_STMT, dbc, &stmt);
if (!odbc_succeeded(ret)) {
    fprintf(stderr, "Failed to allocate statement handle.\n");
    goto cleanup;
}

ret = SQLPrepare(stmt, sql, SQL_NTS);
if (!odbc_succeeded(ret)) {
    fprintf(stderr, "Failed to prepare INSERT.\n");
    goto cleanup;
}

ret = SQLBindParameter(
    stmt,
    1,
    SQL_PARAM_INPUT,
    SQL_C_CHAR,
    SQL_VARCHAR,
    sizeof(name),
    0,
    name,
    sizeof(name),
    &name_len
);
if (!odbc_succeeded(ret)) {
    fprintf(stderr, "Failed to bind name.\n");
    goto cleanup;
}

ret = SQLBindParameter(
    stmt,
    2,
    SQL_PARAM_INPUT,
    SQL_C_SLONG,
    SQL_INTEGER,
    0,
    0,
    &age,
    0,
    &age_len
);
if (!odbc_succeeded(ret)) {
    fprintf(stderr, "Failed to bind age.\n");
    goto cleanup;
}

ret = SQLExecute(stmt);
if (!odbc_succeeded(ret)) {
    fprintf(stderr, "Failed to execute INSERT.\n");
    goto cleanup;
}

printf("Inserted.\n");

このコード片は、前節の接続後に SQLHSTMT stmt = SQL_NULL_HSTMT; を用意して使います。後始末では、次のようにステートメントハンドルも解放します。

if (stmt != SQL_NULL_HSTMT) {
    SQLFreeHandle(SQL_HANDLE_STMT, stmt);
}

SELECTで結果を取得する

SELECT では、SQLExecDirect() でSQLを実行し、SQLFetch() で1行ずつ結果を取り出します。列の値は SQLGetData() で取得します。

SQLCHAR sql[] = "SELECT id, name, age FROM users ORDER BY id";
SQLINTEGER id;
SQLCHAR name[101];
SQLINTEGER age;
SQLLEN id_ind;
SQLLEN name_ind;
SQLLEN age_ind;

ret = SQLAllocHandle(SQL_HANDLE_STMT, dbc, &stmt);
if (!odbc_succeeded(ret)) {
    fprintf(stderr, "Failed to allocate statement handle.\n");
    goto cleanup;
}

ret = SQLExecDirect(stmt, sql, SQL_NTS);
if (!odbc_succeeded(ret)) {
    fprintf(stderr, "Failed to execute SELECT.\n");
    goto cleanup;
}

while ((ret = SQLFetch(stmt)) != SQL_NO_DATA) {
    if (!odbc_succeeded(ret)) {
        fprintf(stderr, "Failed to fetch row.\n");
        goto cleanup;
    }

    SQLGetData(stmt, 1, SQL_C_SLONG, &id, 0, &id_ind);
    SQLGetData(stmt, 2, SQL_C_CHAR, name, sizeof(name), &name_ind);
    SQLGetData(stmt, 3, SQL_C_SLONG, &age, 0, &age_ind);

    printf("%d\t%s\t%d\n", id, name, age);
}

SQLFetch()SQL_NO_DATA を返したら、取得できる行がなくなったという意味です。これはエラーではありません。

NULLを含む列を扱う場合は、SQLGetData() の最後の引数に入るインジケータ値を確認します。値が SQL_NULL_DATA なら、その列はNULLです。


CGIでSELECT結果をHTMLにする

CGIとしてDBの内容を表示する場合は、ヘッダを出してからHTMLを組み立てます。

printf("Content-Type: text/html; charset=UTF-8\r\n\r\n");
printf("<!DOCTYPE html>\n");
printf("<html lang=\"ja\"><body>\n");
printf("<table>\n");
printf("<tr><th>ID</th><th>Name</th><th>Age</th></tr>\n");

while ((ret = SQLFetch(stmt)) != SQL_NO_DATA) {
    SQLGetData(stmt, 1, SQL_C_SLONG, &id, 0, &id_ind);
    SQLGetData(stmt, 2, SQL_C_CHAR, name, sizeof(name), &name_ind);
    SQLGetData(stmt, 3, SQL_C_SLONG, &age, 0, &age_ind);

    printf("<tr>");
    printf("<td>%d</td>", id);
    printf("<td>");
    html_escape_print(stdout, (const char *)name);
    printf("</td>");
    printf("<td>%d</td>", age);
    printf("</tr>\n");
}

printf("</table>\n");
printf("</body></html>\n");

ここでも、DBから取り出した文字列をHTMLに出力するときはエスケープします。DBに入っている値だから安全、とは考えません。


リソース管理

ODBCでは、失敗した場合でも確保済みのハンドルを解放する必要があります。C言語では、goto cleanup を使って後始末を一箇所にまとめると、漏れを減らせます。

cleanup:
    if (stmt != SQL_NULL_HSTMT) {
        SQLFreeHandle(SQL_HANDLE_STMT, stmt);
    }
    if (dbc != SQL_NULL_HDBC) {
        if (connected) {
            SQLDisconnect(dbc);
        }
        SQLFreeHandle(SQL_HANDLE_DBC, dbc);
    }
    if (env != SQL_NULL_HENV) {
        SQLFreeHandle(SQL_HANDLE_ENV, env);
    }

SQLDisconnect() は、接続に成功したあとで呼びます。前述の接続サンプルでは connected フラグを使い、接続できた場合だけ切断するようにしています。


CGIでDBを扱うときの注意点

CGIからDBに接続する場合、リクエストごとにプロセスが起動し、そのたびにDB接続が発生します。学習用途では仕組みが見えやすい一方、高負荷な用途には向きません。

また、次の点に注意します。

本書では理解しやすさを優先して接続文字列をコード内に置いています。実際の運用では、設定ファイルや環境変数から読み込む構成を検討してください。


小まとめ

次章では、Cookieとセッションを使って、ログイン状態を管理する方法に進みます。