Key-Value Store em Zig — Tutorial Passo a Passo

Key-Value Store em Zig — Tutorial Passo a Passo

Neste tutorial, vamos construir um banco de dados chave-valor em memória com interface de linha de comando. Este é um projeto fundamental para entender estruturas de dados, gerenciamento de memória e parsing de comandos em Zig.

O Que Vamos Construir

Nosso key-value store vai:

  • Armazenar pares chave-valor com strings
  • Suportar operações SET, GET, DEL, EXISTS e KEYS
  • Implementar TTL (Time-To-Live) opcional para expiração automática
  • Usar a HashMap da stdlib de Zig
  • Fornecer uma interface CLI interativa estilo Redis

Por Que Este Projeto?

Bancos de dados chave-valor são a base de sistemas como Redis e Memcached. Construir um do zero nos ensina sobre hashing, gerenciamento de memória com allocators, e como Zig lida com ownership de strings. O sistema de erros de Zig garante que nunca “perdemos” memória silenciosamente.

Pré-requisitos

Passo 1: Estrutura do Projeto

mkdir key-value-store
cd key-value-store
zig init

Vamos trabalhar no arquivo src/main.zig.

Passo 2: Definindo a Estrutura do Store

Começamos definindo a estrutura principal que vai armazenar nossos dados. Usamos a std.StringHashMap da stdlib, que é otimizada para chaves do tipo string.

const std = @import("std");
const mem = std.mem;
const time = std.time;
const io = std.io;
const fmt = std.fmt;

/// Representa um valor armazenado no store.
/// Cada valor pode ter um TTL (tempo de expiração) opcional.
const Entrada = struct {
    valor: []const u8,
    expira_em: ?i64, // timestamp em milissegundos, null = sem expiração

    /// Verifica se a entrada expirou.
    pub fn expirou(self: Entrada) bool {
        if (self.expira_em) |expiracao| {
            return time.milliTimestamp() > expiracao;
        }
        return false;
    }
};

/// Banco de dados chave-valor em memória.
/// Usa StringHashMap para busca O(1) e gerencia a memória
/// de chaves e valores com um allocator explícito.
const KVStore = struct {
    mapa: std.StringHashMap(Entrada),
    allocator: mem.Allocator,
    total_sets: u64,
    total_gets: u64,
    total_dels: u64,

    const Self = @This();

    pub fn init(allocator: mem.Allocator) Self {
        return .{
            .mapa = std.StringHashMap(Entrada).init(allocator),
            .allocator = allocator,
            .total_sets = 0,
            .total_gets = 0,
            .total_dels = 0,
        };
    }

    pub fn deinit(self: *Self) void {
        // Libera todas as chaves e valores alocados
        var it = self.mapa.iterator();
        while (it.next()) |entry| {
            self.allocator.free(entry.key_ptr.*);
            self.allocator.free(entry.value_ptr.valor);
        }
        self.mapa.deinit();
    }

    /// Armazena um par chave-valor. Se a chave já existir, atualiza o valor.
    /// ttl_ms: tempo de vida em milissegundos (null = sem expiração).
    pub fn set(self: *Self, chave: []const u8, valor: []const u8, ttl_ms: ?i64) !void {
        self.total_sets += 1;

        const expira_em: ?i64 = if (ttl_ms) |ttl|
            time.milliTimestamp() + ttl
        else
            null;

        // Se a chave já existe, libera o valor antigo e atualiza
        if (self.mapa.getPtr(chave)) |entrada| {
            self.allocator.free(entrada.valor);
            const valor_copia = try self.allocator.dupe(u8, valor);
            entrada.valor = valor_copia;
            entrada.expira_em = expira_em;
            return;
        }

        // Chave nova: aloca cópias da chave e do valor
        const chave_copia = try self.allocator.dupe(u8, chave);
        errdefer self.allocator.free(chave_copia);

        const valor_copia = try self.allocator.dupe(u8, valor);
        errdefer self.allocator.free(valor_copia);

        try self.mapa.put(chave_copia, .{
            .valor = valor_copia,
            .expira_em = expira_em,
        });
    }

    /// Busca um valor pela chave. Retorna null se não encontrado ou expirado.
    pub fn get(self: *Self, chave: []const u8) ?[]const u8 {
        self.total_gets += 1;

        if (self.mapa.getPtr(chave)) |entrada| {
            if (entrada.expirou()) {
                // Lazy deletion: remove quando acessado após expirar
                self.removerEntrada(chave);
                return null;
            }
            return entrada.valor;
        }
        return null;
    }

    /// Remove uma chave do store. Retorna true se a chave existia.
    pub fn del(self: *Self, chave: []const u8) bool {
        self.total_dels += 1;
        return self.removerEntrada(chave);
    }

    /// Verifica se uma chave existe e não está expirada.
    pub fn exists(self: *Self, chave: []const u8) bool {
        if (self.mapa.getPtr(chave)) |entrada| {
            if (entrada.expirou()) {
                self.removerEntrada(chave);
                return false;
            }
            return true;
        }
        return false;
    }

    /// Retorna todas as chaves não expiradas.
    pub fn keys(self: *Self, buf: *std.ArrayList([]const u8)) !void {
        var it = self.mapa.iterator();
        while (it.next()) |entry| {
            if (!entry.value_ptr.expirou()) {
                try buf.append(entry.key_ptr.*);
            }
        }
    }

    /// Retorna o número de chaves armazenadas.
    pub fn count(self: *Self) usize {
        return self.mapa.count();
    }

    // Função interna para remover e liberar memória de uma entrada.
    fn removerEntrada(self: *Self, chave: []const u8) bool {
        if (self.mapa.fetchRemove(chave)) |kv| {
            self.allocator.free(kv.key);
            self.allocator.free(kv.value.valor);
            return true;
        }
        return false;
    }
};

Decisão de design: Duplicamos chaves e valores com allocator.dupe(). Isso garante que o store é “dono” de toda sua memória e não depende de buffers externos que podem ser sobrescritos. O errdefer garante que não temos vazamentos se a operação falhar no meio.

Passo 3: Parser de Comandos

Agora precisamos interpretar os comandos que o usuário digita. Cada comando segue um formato simples: COMANDO arg1 arg2 ....

/// Representa um comando parseado do usuário.
const Comando = union(enum) {
    set: struct { chave: []const u8, valor: []const u8, ttl_ms: ?i64 },
    get: struct { chave: []const u8 },
    del: struct { chave: []const u8 },
    exists: struct { chave: []const u8 },
    keys: void,
    count: void,
    stats: void,
    ajuda: void,
    sair: void,
    invalido: []const u8,
};

/// Faz o parsing de uma linha de comando.
/// Suporta: SET chave valor [TTL_MS], GET chave, DEL chave, etc.
fn parsearComando(linha: []const u8) Comando {
    const trimmed = mem.trim(u8, linha, " \t\r\n");
    if (trimmed.len == 0) return .{ .invalido = "comando vazio" };

    var it = mem.splitScalar(u8, trimmed, ' ');
    const cmd = it.first();

    if (mem.eql(u8, cmd, "SET") or mem.eql(u8, cmd, "set")) {
        const chave = it.next() orelse return .{ .invalido = "SET requer chave e valor" };
        const valor = it.next() orelse return .{ .invalido = "SET requer um valor" };
        const ttl_str = it.next();
        var ttl_ms: ?i64 = null;
        if (ttl_str) |ts| {
            ttl_ms = fmt.parseInt(i64, ts, 10) catch
                return .{ .invalido = "TTL deve ser um numero em milissegundos" };
        }
        return .{ .set = .{ .chave = chave, .valor = valor, .ttl_ms = ttl_ms } };
    } else if (mem.eql(u8, cmd, "GET") or mem.eql(u8, cmd, "get")) {
        const chave = it.next() orelse return .{ .invalido = "GET requer uma chave" };
        return .{ .get = .{ .chave = chave } };
    } else if (mem.eql(u8, cmd, "DEL") or mem.eql(u8, cmd, "del")) {
        const chave = it.next() orelse return .{ .invalido = "DEL requer uma chave" };
        return .{ .del = .{ .chave = chave } };
    } else if (mem.eql(u8, cmd, "EXISTS") or mem.eql(u8, cmd, "exists")) {
        const chave = it.next() orelse return .{ .invalido = "EXISTS requer uma chave" };
        return .{ .exists = .{ .chave = chave } };
    } else if (mem.eql(u8, cmd, "KEYS") or mem.eql(u8, cmd, "keys")) {
        return .keys;
    } else if (mem.eql(u8, cmd, "COUNT") or mem.eql(u8, cmd, "count")) {
        return .count;
    } else if (mem.eql(u8, cmd, "STATS") or mem.eql(u8, cmd, "stats")) {
        return .stats;
    } else if (mem.eql(u8, cmd, "AJUDA") or mem.eql(u8, cmd, "ajuda") or mem.eql(u8, cmd, "HELP")) {
        return .ajuda;
    } else if (mem.eql(u8, cmd, "SAIR") or mem.eql(u8, cmd, "sair") or mem.eql(u8, cmd, "EXIT")) {
        return .sair;
    }

    return .{ .invalido = cmd };
}

Passo 4: Loop Principal e Interface CLI

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    var store = KVStore.init(allocator);
    defer store.deinit();

    const stdin = io.getStdIn().reader();
    const stdout = io.getStdOut().writer();

    try stdout.print(
        \\
        \\  ==========================================
        \\     KEY-VALUE STORE - Zig
        \\  ==========================================
        \\  Comandos: SET, GET, DEL, EXISTS, KEYS,
        \\            COUNT, STATS, AJUDA, SAIR
        \\  ==========================================
        \\
        \\
    , .{});

    var buf: [4096]u8 = undefined;

    while (true) {
        try stdout.print("kvstore> ", .{});

        const linha = stdin.readUntilDelimiter(&buf, '\n') catch |err| {
            if (err == error.EndOfStream) break;
            return err;
        };

        const comando = parsearComando(linha);

        switch (comando) {
            .set => |s| {
                store.set(s.chave, s.valor, s.ttl_ms) catch |err| {
                    try stdout.print("ERRO: {}\n", .{err});
                    continue;
                };
                try stdout.print("OK\n", .{});
            },
            .get => |g| {
                if (store.get(g.chave)) |valor| {
                    try stdout.print("\"{s}\"\n", .{valor});
                } else {
                    try stdout.print("(nil)\n", .{});
                }
            },
            .del => |d| {
                if (store.del(d.chave)) {
                    try stdout.print("(1) removido\n", .{});
                } else {
                    try stdout.print("(0) nao encontrado\n", .{});
                }
            },
            .exists => |e| {
                if (store.exists(e.chave)) {
                    try stdout.print("(1) existe\n", .{});
                } else {
                    try stdout.print("(0) nao existe\n", .{});
                }
            },
            .keys => {
                var lista = std.ArrayList([]const u8).init(allocator);
                defer lista.deinit();
                try store.keys(&lista);

                if (lista.items.len == 0) {
                    try stdout.print("(vazio)\n", .{});
                } else {
                    for (lista.items, 1..) |chave, i| {
                        try stdout.print("{d}) \"{s}\"\n", .{ i, chave });
                    }
                }
            },
            .count => {
                try stdout.print("(integer) {d}\n", .{store.count()});
            },
            .stats => {
                try stdout.print(
                    \\--- Estatisticas ---
                    \\  Total SETs:    {d}
                    \\  Total GETs:    {d}
                    \\  Total DELs:    {d}
                    \\  Chaves ativas: {d}
                    \\
                , .{ store.total_sets, store.total_gets, store.total_dels, store.count() });
            },
            .ajuda => {
                try stdout.print(
                    \\  Comandos disponiveis:
                    \\    SET chave valor [TTL_MS]  - Armazena um valor
                    \\    GET chave                 - Busca um valor
                    \\    DEL chave                 - Remove uma chave
                    \\    EXISTS chave              - Verifica existencia
                    \\    KEYS                      - Lista todas as chaves
                    \\    COUNT                     - Conta chaves ativas
                    \\    STATS                     - Exibe estatisticas
                    \\    SAIR                      - Encerra o programa
                    \\
                , .{});
            },
            .sair => {
                try stdout.print("Ate logo!\n", .{});
                break;
            },
            .invalido => |msg| {
                try stdout.print("Comando invalido: {s}. Digite AJUDA.\n", .{msg});
            },
        }
    }
}

Passo 5: Testes

test "set e get basico" {
    const allocator = std.testing.allocator;
    var store = KVStore.init(allocator);
    defer store.deinit();

    try store.set("nome", "Zig", null);
    try std.testing.expectEqualStrings("Zig", store.get("nome").?);
}

test "get chave inexistente retorna null" {
    const allocator = std.testing.allocator;
    var store = KVStore.init(allocator);
    defer store.deinit();

    try std.testing.expect(store.get("naoexiste") == null);
}

test "del remove chave" {
    const allocator = std.testing.allocator;
    var store = KVStore.init(allocator);
    defer store.deinit();

    try store.set("temp", "valor", null);
    try std.testing.expect(store.del("temp"));
    try std.testing.expect(store.get("temp") == null);
}

test "set sobrescreve valor existente" {
    const allocator = std.testing.allocator;
    var store = KVStore.init(allocator);
    defer store.deinit();

    try store.set("chave", "v1", null);
    try store.set("chave", "v2", null);
    try std.testing.expectEqualStrings("v2", store.get("chave").?);
}

test "exists verifica corretamente" {
    const allocator = std.testing.allocator;
    var store = KVStore.init(allocator);
    defer store.deinit();

    try store.set("a", "1", null);
    try std.testing.expect(store.exists("a"));
    try std.testing.expect(!store.exists("b"));
}

test "parser de comandos" {
    const cmd_set = parsearComando("SET nome Zig");
    switch (cmd_set) {
        .set => |s| {
            try std.testing.expectEqualStrings("nome", s.chave);
            try std.testing.expectEqualStrings("Zig", s.valor);
        },
        else => return error.TestUnexpectedResult,
    }

    const cmd_get = parsearComando("GET nome");
    switch (cmd_get) {
        .get => |g| try std.testing.expectEqualStrings("nome", g.chave),
        else => return error.TestUnexpectedResult,
    }
}

Compilando e Executando

# Compilar e executar
zig build run

# Rodar os testes
zig build test

# Exemplo de sessão interativa:
# kvstore> SET usuario joao
# OK
# kvstore> SET sessao abc123 60000
# OK
# kvstore> GET usuario
# "joao"
# kvstore> KEYS
# 1) "usuario"
# 2) "sessao"
# kvstore> DEL usuario
# (1) removido
# kvstore> SAIR

Conceitos Aprendidos

  • HashMap da stdlib para busca O(1) por chave
  • Gerenciamento de memória com ownership explícito de strings
  • errdefer para limpeza segura em caso de erro
  • Union types para representar comandos variados
  • Lazy deletion para entradas com TTL expirado

Próximos Passos

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.