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,EXISTSeKEYS - 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
- Zig 0.13+ instalado (guia de instalação)
- Familiaridade com slices e strings
- Conhecimento básico de allocators
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
errdeferpara limpeza segura em caso de erro- Union types para representar comandos variados
- Lazy deletion para entradas com TTL expirado
Próximos Passos
- Explore a documentação de HashMap na stdlib
- Aprenda mais sobre gerenciamento de memória
- Veja como manipular strings em Zig
- Construa o próximo projeto: CSV para JSON Converter