Passer au contenu principal
clickhouse-c est un client C header-only pour le protocole natif de ClickHouse. Le code source et la référence de chaque en-tête se trouvent dans le dépôt GitHub. Contrairement aux clients de plus haut niveau, il en fait volontairement très peu pour vous. L’en-tête principal décode et encode des blocs au format Native via un callback d’E/S que vous fournissez. Vous gérez vous-même le socket, le contexte TLS, l’allocateur, les reprises sur erreur et le pool de connexions. Cela le rend suffisamment petit pour être embarqué : inclure uniquement clickhouse.h n’ajoute aucune dépendance à l’édition de liens autre que libc.
Cette bibliothèque est en développement actif. La v1 décode les principaux types de ClickHouse. Signalez les limitations ou les fonctionnalités manquantes via le suivi des issues. Gardez toutefois à l’esprit que l’absence de certaines fonctionnalités est intentionnelle.

Ce que la bibliothèque ne fait pas

Ce sont des non-objectifs délibérés. Gérez-les dans votre application ou avec une bibliothèque sœur :
  • Protocole HTTP. Encapsulez directement libcurl pour l’interface HTTP.
  • Résolution DNS, basculement d’endpoint, pool de connexions, réessais et backoff.
  • Cycle de vie du contexte TLS. Le backend OpenSSL utilise un SSL déjà connecté.
  • Gestion des threads. Chaque chc_client est mono-thread par conception.
  • E/S asynchrones au sein de la bibliothèque. Le client bloquant appelle chc_io.read de manière synchrone. Pour un client à boucle d’événements qui n’effectue lui-même aucune E/S, utilisez le client ioless.

Organisation de la bibliothèque

clickhouse-c est fourni sous la forme d’un ensemble plat d’en-têtes. Chaque en-tête contient à la fois les déclarations et l’implémentation, protégées par une macro sentinelle. Choisissez les en-têtes nécessaires à votre compilation.
En-têteRôleOptions de liaison
clickhouse.hCœur : types, erreurs, allocateur, vtable d’E/S, parseur de noms de type, lecture et écriture de blocs
clickhouse-client.hBoucle de paquets TCP : Hello, Query, Data, EndOfStream, Exception, Progress, Pong
clickhouse-async.hClient ioless : la même boucle de paquets, pilotée par l’appelant via l’envoi d’octets, sans socket
clickhouse-compression.hOrganisation des frames compressées, CityHash128, répartition des codecs et adaptateurs LZ4/ZSTD-llz4 -lzstd
clickhouse-posix-io.hBackend d’E/S reposant sur read(2)/write(2) bloquants
clickhouse-openssl.hBackend d’E/S reposant sur SSL_read/SSL_write-lssl -lcrypto

Paramètre du serveur requis

Le décodeur lit des noms de type lisibles sur le wire ; ils doivent donc être encodés en texte. ClickHouse les écrit en texte par défaut, mais forcez ce paramètre dans vos requêtes afin qu’un profil serveur ou de session qui le définit en binaire ne puisse pas compromettre le décodage :
output_format_native_encode_types_in_binary_format = 0

L’intégrer à votre projet

Il n’y a aucun paquet à installer, vous devez donc ajouter les fichiers d’en-tête à votre arborescence via un sous-module Git ou une copie. Une seule unité de traduction définit CHC_IMPLEMENTATION et y intègre l’implémentation ; toutes les autres unités incluent les mêmes fichiers d’en-tête uniquement pour les déclarations.
/* clickhouse_impl.c */
#define CHC_IMPLEMENTATION
#include "clickhouse.h"
#include "clickhouse-posix-io.h"
#include "clickhouse-client.h"
#include "clickhouse-compression.h"
/* every other TU */
#include "clickhouse.h"
#include "clickhouse-client.h"
Définissez CHC_PROVIDE_STDLIB_ALLOC avant d’inclure clickhouse.h pour utiliser chc_alloc_stdlib. Définissez CHC_NO_LZ4 ou CHC_NO_ZSTD pour clickhouse-compression.h afin de supprimer les dépendances à lz4/zstd.

Connexion via TCP

Pour communiquer avec un serveur ClickHouse, configurez vous-même le socket, encapsulez-le dans un chc_io, puis transmettez-le à chc_client_init, qui exécute la négociation initiale Hello de façon synchrone. La bibliothèque ne prend en charge ni le DNS, ni le failover, ni la reconnexion, ni le pool de connexions — cela relève de l’appelant.
int fd = socket(AF_INET, SOCK_STREAM, 0);
int one = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one);

struct sockaddr_in sa = {};
sa.sin_family      = AF_INET;
sa.sin_port        = htons(9000);
sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
connect(fd, (struct sockaddr *) &sa, sizeof sa);

chc_alloc al = chc_alloc_stdlib();
chc_posix_io state;
chc_io io;
chc_posix_io_init(&state, &io, fd, NULL, NULL);

chc_client *client = NULL;
chc_client_opts opts = {
    .user     = "default",
    .password = "",
    .database = "default",
};
chc_err err = {};
if (chc_client_init(&client, &opts, &al, &io, &err) != CHC_OK) {
    fprintf(stderr, "connect: %s\n", err.msg);
    chc_client_close(client);   /* safe to call on the NULL-on-failure handle */
    return 1;
}

const chc_server_info *info = chc_client_server_info(client);
printf("connected to %s %llu.%llu.%llu\n", info->display_name,
       (unsigned long long) info->version_major,
       (unsigned long long) info->version_minor,
       (unsigned long long) info->version_patch);
Chaque chc_client est mono-thread et encapsule une connexion. La bibliothèque appelle les callbacks chc_io de manière synchrone ; ce que ces callbacks font en interne (epoll, io_uring, WaitLatchOrSocket) vous appartient.

Exécuter une requête

Envoyez la requête, puis consommez les paquets jusqu’à CHC_PKT_END_OF_STREAM. Utilisez chc_client_send_query_ex pour ajouter le paramètre serveur requis ; chc_client_send_query, sans argument supplémentaire, envoie une liste des paramètres vide et hérite des valeurs par défaut du serveur.
chc_query_setting settings[] = {
    { .name = "output_format_native_encode_types_in_binary_format", .value = "0" },
};
chc_query_opts qopts = { .settings = settings, .n_settings = 1 };

const char *sql = "SELECT number, toString(number * number) FROM numbers(5)";
if (chc_client_send_query_ex(client, sql, strlen(sql), &qopts, &err) != CHC_OK) {
    fprintf(stderr, "query: %s\n", err.msg);
    return 1;
}

for (;;) {
    chc_packet pkt = {};
    if (chc_client_recv_packet(client, &pkt, &err) != CHC_OK) {
        fprintf(stderr, "recv: %s\n", err.msg);
        break;
    }

    if (pkt.kind == CHC_PKT_DATA) {
        for (size_t r = 0; r < chc_block_n_rows(pkt.block); r++)
            for (size_t c = 0; c < chc_block_n_columns(pkt.block); c++)
                print_value(chc_block_column_type(pkt.block, c),
                            chc_block_column(pkt.block, c), r);
    } else if (pkt.kind == CHC_PKT_EXCEPTION) {
        fprintf(stderr, "server: %s\n", pkt.exception->display_text);
    }

    bool done = pkt.kind == CHC_PKT_END_OF_STREAM;
    chc_packet_clear(client, &pkt);
    if (done) break;
}
Les exceptions du serveur arrivent sous forme de paquets CHC_PKT_EXCEPTION, et non via un retour non-OK de chc_client_recv_packet. Seules les défaillances au niveau du transport renvoient une valeur non-OK. Le premier paquet CHC_PKT_DATA d’un résultat est un bloc d’en-tête qui décrit le schéma avec zéro ligne ; les blocs de données suivent. chc_packet_clear libère le bloc ou l’exception du paquet — mettez d’abord ces champs du paquet à null pour en prendre possession à la place.

Lecture des données de colonnes

Les blocs sont orientés colonnes. Chaque colonne a une disposition physique, renvoyée par chc_column_layout, sur laquelle vous aiguillez le traitement ; son type déclaré provient de chc_block_column_type. Les dispositions composites sont imbriquées, donc lire un Nullable(Array(String)) consiste à déballer le Nullable, à parcourir les offsets du tableau, puis à découper les données de chaîne.
DispositionAccesseurs
CHC_COL_FIXEDchc_column_fixed_data(c, &elem_size)n_rows * elem_size octets en little-endian
CHC_COL_STRINGchc_column_string_data(c), chc_column_string_offsets(c)offsets[i] est la fin exclusive de la ligne i dans l’ordre des octets de l’hôte ; la ligne 0 commence à 0
CHC_COL_NULLABLEchc_column_null_map(c) (un octet par ligne, 1 = NULL), chc_column_nullable_inner(c)
CHC_COL_ARRAYchc_column_array_offsets(c) (fins cumulées), chc_column_array_values(c) ; Map se décode comme Array(Tuple(K, V))
CHC_COL_TUPLEchc_column_tuple_arity(c), chc_column_tuple_child(c, i) — chaque élément enfant a le même nombre de lignes
CHC_COL_LOW_CARDINALITYchc_column_lc_key_size(c) (1/2/4/8), chc_column_lc_keys(c), chc_column_lc_dict(c) ; l’entrée 0 du dictionnaire est la valeur par défaut
Un lecteur pour les colonnes numériques simples, String et Nullable :
void print_value(const chc_type *t, const chc_column *c, size_t row)
{
    if (chc_column_layout(c) == CHC_COL_NULLABLE) {
        if (chc_column_null_map(c)[row]) { fputs("\\N", stdout); return; }
        print_value(chc_type_child(t, 0), chc_column_nullable_inner(c), row);
        return;
    }

    switch (chc_column_layout(c)) {
    case CHC_COL_FIXED: {
        /* fixed_data is a raw little-endian byte slab. memcpy into a typed
           local to avoid unaligned loads and strict-aliasing UB, then
           byte-swap on big-endian hosts. */
        size_t es;
        const uint8_t *p = chc_column_fixed_data(c, &es) + row * es;
        switch (chc_type_kind(t)) {
        case CHC_UINT64: { uint64_t v; memcpy(&v, p, sizeof v); printf("%" PRIu64, v); break; }
        case CHC_INT32:  { int32_t  v; memcpy(&v, p, sizeof v); printf("%" PRId32, v); break; }
        case CHC_FLOAT64: { double  v; memcpy(&v, p, sizeof v); printf("%g", v); break; }
        /* ... remaining numeric kinds ... */
        default: break;
        }
        break;
    }
    case CHC_COL_STRING: {
        const uint8_t  *bytes   = chc_column_string_data(c);
        const uint64_t *offsets = chc_column_string_offsets(c);
        uint64_t start = row == 0 ? 0 : offsets[row - 1];
        fwrite(bytes + start, 1, (size_t) (offsets[row] - start), stdout);
        break;
    }
    default: break;
    }
}
Les données CHC_COL_FIXED sont en little-endian sur le wire ; sur les hôtes big-endian, vous devez vous-même permuter les octets des entiers multi-octets. Les offsets et les clés LowCardinality sont déjà convertis dans l’ordre de l’hôte au moment du décodage. Les UUIDs se composent de deux moitiés UInt64 little-endian, IPv4 est un entier little-endian sur 4 octets, et IPv6 est en network byte order. Les ticks DateTime64 sont en UTC — le fuseau horaire dans le type n’est qu’une métadonnée. Lors de l’ingestion depuis un pair non fiable, appelez chc_column_validate sur chaque colonne avant de la parcourir. chc_block_read ne valide pas les invariants entre champs, tels que les offsets de tableau et les clés LowCardinality, donc un bloc falsifié pourrait sinon lire au-delà des limites de la colonne interne.

Insertion de données

Construisez un bloc avec chc_block_builder, puis transmettez-le à chc_client_send_data. Le builder enregistre des pointeurs au lieu de copier les données ; les zones mémoire des colonnes doivent donc rester valides après l’envoi. Un INSERT envoie la requête, attend le bloc d’en-tête du serveur, envoie un ou plusieurs blocs de données, puis envoie un bloc vide pour terminer le flux.
const char *sql = "INSERT INTO greetings (id, message) VALUES";
chc_client_send_query(client, sql, strlen(sql), "", 0, &err);

/* Wait for the server's header block (schema, 0 rows). */
bool got_header = false;
while (!got_header) {
    chc_packet pkt = {};
    if (chc_client_recv_packet(client, &pkt, &err) != CHC_OK) {
        fprintf(stderr, "recv: %s\n", err.msg);
        return 1;
    }
    chc_packet_kind kind = pkt.kind;
    if (kind == CHC_PKT_DATA) got_header = true;
    else if (kind == CHC_PKT_EXCEPTION && pkt.exception)
        fprintf(stderr, "server: %s\n", pkt.exception->display_text);
    chc_packet_clear(client, &pkt);
    if (kind == CHC_PKT_EXCEPTION || kind == CHC_PKT_END_OF_STREAM) return 1;  /* no header coming */
}

chc_block_builder *bb = NULL;
chc_block_builder_init(&bb, &al, &err);

uint64_t ids[3] = { 1, 2, 3 };
chc_type *u64 = NULL;
chc_type_parse("UInt64", 6, &al, &u64, &err);
chc_block_builder_append_fixed(bb, "id", 2, u64, ids, 3, &err);

/* String columns: cumulative exclusive end offsets + a packed byte slab. */
uint64_t offsets[3] = { 5, 11, 20 };   /* "hello", "buenas", "goedendag" */
const uint8_t bytes[] = "hellobuenasgoedendag";
chc_block_builder_append_string(bb, "message", 7, offsets, bytes, 3, &err);

chc_client_send_data(client, bb, &err);   /* the populated block */
chc_client_send_data(client, NULL, &err); /* empty block ends the INSERT */

/* Drain to EndOfStream. */
for (;;) {
    chc_packet pkt = {};
    chc_client_recv_packet(client, &pkt, &err);
    bool done = pkt.kind == CHC_PKT_END_OF_STREAM;
    chc_packet_clear(client, &pkt);
    if (done) break;
}

chc_block_builder_destroy(bb);
chc_type_destroy(u64, &al);
chc_block_builder_append_fixed prend n_rows * elem_size octets little-endian ; chc_block_builder_append_string prend des offsets de fin cumulatifs exclusifs, dans l’ordre des octets de l’hôte, sur un slab compact. Faire passer le builder par chc_client_send_data plutôt que par la fonction de plus bas niveau chc_block_write permet au client de définir les options du bloc à partir de la révision négociée et d’appliquer la compression.

Compression

Indiquez un mode de compression et un codec renseigné dans chc_client_opts. Le client décompresse les paquets Data entrants et compresse les paquets sortants. L’en-tête de compression fournit des adaptateurs LZ4 et ZSTD ; chaque initialisation ne remplit que ses propres slots, appelez donc les deux pour prendre en charge l’un comme l’autre.
#include "clickhouse-compression.h"

chc_codec codec = {};
chc_lz4_codec_init(&codec);
chc_zstd_codec_init(&codec);

chc_client_opts opts = {
    .user        = "default",
    .compression = CHC_COMP_LZ4,   /* or CHC_COMP_ZSTD */
    .codec       = &codec,
};
Pour utiliser une bibliothèque de compression pour laquelle le projet ne fournit pas de liaison, définissez vous-même un chc_codec ; la vtable est déclarée dans clickhouse-compression.h.

TLS

clickhouse-openssl.h fournit un backend chc_io reposant sur SSL_read/SSL_write. C’est à vous de piloter OpenSSL : la bibliothèque ne crée jamais de SSL_CTX, ne vérifie pas les certificats, ne configure pas le SNI et n’appelle pas SSL_connect / SSL_shutdown. Lorsque chc_io.read est appelé, la négociation doit déjà être terminée.
#include "clickhouse-openssl.h"

SSL *ssl = /* connected, handshake complete */;
chc_openssl_io state;
chc_io io;
chc_openssl_io_init(&state, &io, ssl, NULL, NULL);
/* hand &io to chc_client_init, same as the POSIX backend */
ClickHouse Cloud et les autres déploiements où TLS est activé utilisent le protocole natif sur le port 9440. Les deux backends acceptent une fonction de rappel check_cancel facultative, consultée entre les opérations de lecture, ainsi qu’un délai limite de lecture via chc_openssl_io_set_deadline / chc_posix_io_set_deadline.

Client ioless (async)

clickhouse-async.h est une variante ioless du client TCP conçue pour les boucles d’événements. Il n’utilise jamais de socket : vous lui fournissez les octets reçus et récupérez ceux qu’il souhaite envoyer, en pilotant vous-même epoll, io_uring ou WaitLatchOrSocket. Les options, les types de paquets et le block builder sont les mêmes que pour le client bloquant. chc_async_client_init n’effectue aucune E/S et ne peut pas bloquer. La négociation s’exécute ensuite sous la forme d’une machine à états reprenable, tout comme chaque envoi et chaque réception. Lorsqu’un parse dépasse les octets que vous avez fournis, l’appel renvoie CHC_WOULD_BLOCK au lieu de bloquer — fournissez plus d’octets entrants et rappelez la fonction, et le parser reprend au milieu du bloc.
#include "clickhouse-async.h"

chc_async_client *c = NULL;
chc_client_opts opts = { .user = "default" };
chc_async_client_init(&c, &opts, &al, &err);

for (;;) {
    int rc = chc_async_handshake(c, &err);
    if (rc == CHC_OK) break;
    if (rc != CHC_WOULD_BLOCK) break;   /* hard error */
    pump(c);   /* drain pending_out to the socket; feed received bytes to chc_async_submit */
}

chc_async_send_query(c, sql, strlen(sql), "", 0, &err);

for (;;) {
    chc_packet pkt = {};
    int rc = chc_async_recv_packet(c, &pkt, &err);
    if (rc == CHC_WOULD_BLOCK) { pump(c); continue; }
    if (rc != CHC_OK) break;

    bool done = pkt.kind == CHC_PKT_END_OF_STREAM;
    if (pkt.kind == CHC_PKT_DATA && pkt.block) { /* read columns as above */ }
    chc_async_packet_clear(c, &pkt);
    if (done) break;
}
Votre pump fait circuler des octets dans les deux sens. En sortie, chc_async_pending_out renvoie un pointeur et une longueur sur les octets en file d’attente ; une fois qu’une partie a été acceptée par le socket, appelez chc_async_consume_out avec ce nombre ; une écriture partielle est acceptable. En entrée, transmettez à chc_async_submit les données lues sur le socket. Les envois ne bloquent jamais et n’exercent pas de backpressure ; surveillez donc la taille des données en attente de sortie et cessez d’envoyer lorsque celle-ci devient trop importante. Un pilote liburing fonctionnel se trouve dans test/test_async_uring.c.

Mémoire et l’allocateur

Chaque point d’entrée accepte une vtable chc_alloc, de sorte que l’allocation s’appuie sur le mécanisme utilisé par le système hôte.
typedef struct chc_alloc {
    void *ud;
    void *(*alloc)  (void *ud, size_t bytes);
    void *(*realloc)(void *ud, void *p, size_t old_bytes, size_t new_bytes);
    void  (*free)   (void *ud, void *p, size_t bytes);
} chc_alloc;
Définissez CHC_PROVIDE_STDLIB_ALLOC avant d’inclure clickhouse.h et appelez chc_alloc_stdlib() pour utiliser un allocateur standard basé sur malloc.

Erreurs et exceptions du serveur

Les fonctions renvoient CHC_OK (0) ou un code CHC_ERR_* non nul. Le code est la valeur de retour ; une chc_err allouée sur la pile par l’appelant contient le message en clair. La bibliothèque n’alloue jamais d’erreur sur le tas.
typedef struct chc_err {
    int  server_code;           /* set when the return code is CHC_ERR_SERVER */
    char msg[CHC_ERR_MSG_LEN];  /* NUL-terminated, default 256 bytes */
    char server_name[64];       /* ClickHouse exception class, if SERVER */
} chc_err;
Les erreurs de requête côté serveur ne sont pas des erreurs chc_err. Elles arrivent dans le flux de paquets sous la forme de CHC_PKT_EXCEPTION, avec les champs code, display_text et stack_trace du serveur. Réservez la vérification de chc_err aux erreurs de transport, de protocole et de décodage.

Types de données pris en charge

Le lecteur de blocs décode :
  • Int8Int256, UInt8UInt256
  • Float32, Float64, BFloat16
  • Bool
  • Decimal32, Decimal64, Decimal128, Decimal256
  • Date, Date32, DateTime, DateTime64, Time, Time64
  • String, FixedString(N)
  • UUID, IPv4, IPv6
  • Enum8, Enum16
  • Nullable(T), Array(T), Tuple(...), Map(K, V), Nested(...)
  • LowCardinality(T)
  • Interval
  • QBit(...)
  • Point, Ring, Polygon, MultiPolygon
  • SimpleAggregateFunction(f, T), qui est décodé comme son T interne
  • JSON et Object('json'), sous forme de colonnes String avec la sérialisation en chaîne de caractères (voir ci-dessous)
JSON et Object('json') ne sont décodés que lorsque la requête définit output_format_native_write_json_as_string=1. Chaque ligne arrive sous la forme d’un document JSON dans une colonne CHC_COL_STRING, que les accesseurs de chaîne peuvent donc lire ; le builder écrit la même structure avec chc_block_builder_append_json_string. Toute autre version de sérialisation JSON renvoie CHC_ERR_TYPE en indiquant le paramètre. Variant, Dynamic et AggregateFunction ne sont pas encore décodés et renvoient CHC_ERR_TYPE ; convertissez-les en String côté serveur comme solution de repli.
Dernière modification le 29 juin 2026