Тепер давайте створимо сервер TCP в Zig.
Я не є експертом у мережевому програмуванні, особливо в низькорівневому. Завдяки Карлу Сегуіну я створив TCP сервер в Zig. Код майже ідентичний, але мені довелося внести деякі зміни.
pub fn run(self: *Server, address: net.Address) !void {
const tpe: u32 = posix.SOCK.STREAM | posix.SOCK.NONBLOCK;
const protocol = posix.IPPROTO.TCP;
const listener = try posix.socket(address.any.family, tpe, protocol);
defer posix.close(listener);
try posix.setsockopt(listener, posix.SOL.SOCKET, posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1)));
try posix.bind(listener, &address.any, address.getOsSockLen());
try posix.listen(listener, 128);
self.polls[0] = .{ .fd = listener, .revents = 0, .events = posix.POLL.IN };
const thread = try std.Thread.spawn(.{}, listen, .{ self, listener });
thread.detach();
const stdin = std.io.getStdIn().reader();
while (true) {
var buffer: [8]u8 = undefined;
var msg: []u8 = undefined;
if (try stdin.readUntilDelimiterOrEof(buffer[0..], '\n')) |value| {
msg = value;
}
_ = self.clients[0].writeMessage(msg) catch |err| {
std.debug.print("{}", .{err});
return;
};
}
}
У цьому коді метод run структури Server трохи відрізняється від оригіналу, тому що я хочу, щоб мій сервер слухав повідомлення клієнтів в окремому потоці. Для основного потоку я хочу обробляти введення з командного рядка. Для цього потрібно видалити функцію listen зі структури та створити окрему функцію.
pub fn listen(self: *Server, listener: posix.socket_t) !void {
while (true) {
const next_timeout = self.enforceTimeout();
_ = try posix.poll(self.polls[0 .. self.connected + 1], next_timeout);
if (self.polls[0].revents != 0) {
// listening socket is ready
self.accept(listener) catch |err| log.err("failed to accept: {}", .{err});
}
var i: usize = 0;
var read_timeout_list = &self.read_timeout_list;
while (i < self.connected) {
const revents = self.client_polls[i].revents;
if (revents == 0) {
i += 1;
continue;
}
var client = self.clients[i];
if (revents & posix.POLL.IN == posix.POLL.IN) {
while (true) {
const msg = client.readMessage() catch {
self.removeClient(i);
break;
} orelse {
i += 1;
break;
};
if (std.mem.eql(u8, msg, "ping")) {
const written = client.writeMessage("pong") catch {
self.removeClient(i);
break;
};
if (written == false) {
self.client_polls[i].events = posix.POLL.OUT;
break;
}
}
}
}
}
}
Ця функція в основному така ж, як і оригінальна.
TCP клієнт
Для взаємодії з сервером нам, зрозуміло, потрібен інтерфейс командного рядка.
Але спершу давайте створимо TCP клієнта, який може підключатися до нашого сервера, слухати і відправляти повідомлення.
fn read(pollfd: []posix.pollfd, client: *Client.Client) !void {
const poll_result = try posix.poll(pollfd, -1);
const polled = pollfd[0];
if (poll_result == 0) {}
if (polled.revents & posix.POLL.IN == posix.POLL.IN) {
const stdout = std.io.getStdOut().writer();
while (true) {
const msg = client.readMessage() catch {
std.debug.print("Connection Timedout, retry\n", .{});
std.time.sleep(5000000000);
std.process.exit(0);
break;
};
if (msg) |ms| {
if (std.mem.eql(u8, ms, "pong")) {} else {
try stdout.print("{s}\n", .{ms});
}
}
}
}
}
pub fn main() !void {
const address = try std.net.Address.parseIp("127.0.0.1", 5882);
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
const tpe: u32 = posix.SOCK.STREAM;
const protocol = posix.IPPROTO.TCP;
const socket = try posix.socket(address.any.family, tpe, protocol);
try posix.connect(socket, &address.any, address.getOsSockLen());
var cl = try Client.Client.init(allocator, socket, address);
const client = &cl;
var polls: [1]posix.pollfd = undefined;
polls[0] = .{ .fd = socket, .revents = 0, .events = posix.POLL.IN };
defer posix.close(polls[0].fd);
const thread = try std.Thread.spawn(.{}, read, .{ &polls, client });
thread.detach();
const stdin = std.io.getStdIn().reader();
const stdout = std.io.getStdOut().writer();
while (true) {
var buffer: [256]u8 = undefined;
var msg: []u8 = undefined;
if (try stdin.readUntilDelimiterOrEof(buffer[0..], '\n')) |value| {
msg = value;
}
_ = client.writeMessage(command) catch |err| {
try stdout.print("{}", .{err});
return;
};
}
defer client.deinit(allocator);
}
Як ви можете побачити, ми слухаємо повідомлення від сервера в окремому потоці, а основний потік слухає введення від користувача.
Тепер ми можемо надсилати повідомлення між сервером та клієнтом.
Токенізатор та парсер
Командний рядок
redis-cli
дозволяє взаємодіяти з базою даних Redis.
redis-cli
використовує команди для запису або зчитування даних з бази даних Redis. Я думаю, найбільш використовувані команди:
- SET
- GET
- DEL
Я вважаю, ми можемо розглядати ці команди як мову програмування. Вона має синтаксис, чіткі правила та семантику. Кожна мова програмування потребує токенізатора та парсера. Для нашого інтерфейсу командного рядка нам потрібно створити їх. Для простоти я хочу підтримувати лише команди SET, GET, DEL.
Токенізатор
Щоб парсити команду, потрібно витягнути ключові слова, змінні, різні типи значень, такі як числа або рядки. Токенізатор — це автомат станів, який змінює стани і відслідковує їх.
Об'єднання в Zig дозволяють визначати типи, які зберігають одне значення з багатьох можливих полів; в будь-який момент часу може бути активним лише одне поле. І ми можемо дізнатися, яке поле активно, використовуючи оператор switch.
pub const Operation = enum {
SET,
GET,
DEL,
};
pub const TokenType = enum {
Operation,
STRING,
VAR,
INTEGER,
};
pub const Token = union(TokenType) {
Operation: Operation,
STRING: []const u8,
VAR: []const u8,
INTEGER: i32,
};
Ми маємо Token як мітки об'єднання, яке може бути Operation, STRING, VAR (рядок без подвійних лапок) і, нарешті, INTEGER. З цим об'єднанням ми можемо робити наступне:
switch (token) {
.Operation => |op| {
// Обробляти операції
},
.STRING => |s| {
// Обробляти рядкові значення
}
...
// тощо
}
З цим наш токенізатор буде розуміти, який токен ми маємо на даний момент і який він має тип.
Токенізатор має 2 дуже важливі функції.
- getNextToken
- hasNextToken
// Якщо є інший токен або досягнуто EOF
pub fn hasNextToken(self: *Tokenizer) bool {
return self.cursor < self.command.len;
}
pub fn getNextToken(self: *Tokenizer) ?Token {
if (!self.hasNextToken()) {
return null;
}
const str = self.command[self.cursor..];
var it = std.mem.splitScalar(u8, str, ' ');
if (it.next()) |ss| {
const op = std.meta.stringToEnum(Operation, ss);
if (op) |operation| {
switch (operation) {
.SET => {
self.cursor += 4;
return Token{ .Operation = Operation.SET };
},
.GET => {
self.cursor += 4;
return Token{ .Operation = Operation.GET };
},
.DEL => {
self.cursor += 4;
return Token{ .Operation = Operation.DEL };
},
}
}
}
if (self.isNumber(str[0])) {
var string = std.ArrayList(u8).init(self.allocator);
defer string.deinit();
var cursor: u32 = 0;
while (cursor < str.len and self.isNumber(str[cursor])) {
const slice = &[1]u8{str[cursor]};
string.appendSlice(slice) catch {};
cursor += 1;
}
const res = std.fmt.parseInt(i32, string.items, 10) catch {
return null;
};
self.cursor += cursor;
return Token{
.INTEGER = res,
};
}
if (str[0] == '"') {
var string = std.ArrayList(u8).init(self.allocator);
defer string.deinit();
var cursor: u32 = 1;
while (cursor < str.len and std.ascii.isAlphabetic(str[cursor])) {
const slice = &[1]u8{str[cursor]};
string.appendSlice(slice) catch {};
cursor += 1;
}
cursor += 1;
self.cursor += cursor;
const res = std.fmt.allocPrint(self.allocator, "{s}", .{string.items}) catch {
return null;
};
return Token{ .STRING = res };
}
if (std.ascii.isAlphabetic(str[0])) {
var string = std.ArrayList(u8).init(self.allocator);
defer string.deinit();
var cursor: u32 = 0;
while (cursor < str.len and std.ascii.isAlphabetic(str[cursor])) {
const slice = &[1]u8{str[cursor]};
string.appendSlice(slice) catch {};
cursor += 1;
}
cursor += 1;
self.cursor += cursor;
const res = std.fmt.allocPrint(self.allocator, "{s}", .{string.items}) catch {
return null;
};
return Token{ .VAR = res };
}
return null;
}
Ви можете зрозуміти вищенаведений код, використовуючи приклад.
Припустимо, наша команда — це "SET numm 32".
Є одна проблема — ми не маємо способу розрізняти "SET" і "numm" один від одного. Але у мене є чіткі правила, що перше слово після розділення команди пробілом повинно бути SET, GET або DEL. Щоб перевірити це, я використовую функцію Zig std.meta.stringToEnum. Ця функція поверне enum операції, якщо вказаний рядок відповідає одному з елементів enum.
Може бути неправильно, але я новачок у цій справі.
Після того, як ми зрозуміли, яку операцію потрібно виконати, ми повернемо її як Token в парсер і збільшимо курсор на 4. Чому ми збільшуємо курсор на 4? Тому що у нас є 3 літери для операції і 1 для пробілу.
const str = self.command[self.cursor..];
Як тільки ми отримаємо операцію, парсер знову викликає цей метод. І новий str буде "numm 32".
З цього моменту ми перевіряємо, чи є перший символ числа. Якщо це число, ми зберігаємо цей рядок в ArrayList і збільшуємо курсор на 1, поки не досягнемо кінця команди або поки символ не буде числом.
const res = std.fmt.parseInt(i32, string.items, 10) catch {
return null;
};
self.cursor += cursor;
return Token{
.INTEGER = res,
};
Потім ми парсимо отриманий рядок у ціле число і повертаємо Token.
Як ви можете бачити, токен має активне поле "INTEGER".
if (str[0] == '"') {
var string = std.ArrayList(u8).init(self.allocator);
defer string.deinit();
var cursor: u32 = 1;
while (cursor < str.len and std.ascii.isAlphabetic(str[cursor])) {
const slice = &[1]u8{str[cursor]};
string.appendSlice(slice) catch {};
cursor += 1;
}
cursor += 1;
self.cursor += cursor;
const res = std.fmt.allocPrint(self.allocator, "{s}", .{string.items}) catch {
return null;
};
return Token{ .STRING = res };
}
Тут ми намагаємося витягнути, чи є рядок між подвійними лапками. Наприклад: "SET ss "hello"".
if (std.ascii.isAlphabetic(str[0])) {
var string = std.ArrayList(u8).init(self.allocator);
defer string.deinit();
var cursor: u32 = 0;
while (cursor < str.len and std.ascii.isAlphabetic(str[cursor])) {
const slice = &[1]u8{str[cursor]};
string.appendSlice(slice) catch {};
cursor += 1;
}
cursor += 1;
self.cursor += cursor;
const res = std.fmt.allocPrint(self.allocator, "{s}", .{string.items}) catch {
return null;
};
return Token{ .VAR = res };
}
Якщо команда не починається з операції, якщо це не число і не подвійні лапки, то це має бути змінна. Другий термін у команді.
"SET numm 32".
Ось приклад вихідних даних.
tokenizer.Token{ .Operation = tokenizer.Operation.SET }
tokenizer.Token{ .VAR = { 110, 117, 109, 109 } }
tokenizer.Token{ .INTEGER = 32 }
Парсер
Тепер, коли ми створили наш токенізатор, нам потрібно створити Парсер.
pub const Command = struct {
Operation: u8,
Var: ?[]const u8,
Value: ?Tokenizer.Token,
};
pub const Parser = struct {
allocator: std.mem.Allocator,
tokenizer: ?Tokenizer.Tokenizer,
_lookahead: ?Tokenizer.Token,
command: ?Command,
index: u2,
pub fn init(allocator: std.mem.Allocator) Parser {
return Parser{
.allocator = allocator,
.tokenizer = null,
._lookahead = null,
.index = 0,
.command = null,
};
}
}
Структура Parser містить токенізатор, _lookahead (поточний токен) та команду. У парсера є 2 важливі функції.
- parse
- eat
Функція eat приймає тип токена та перевіряє, чи є тип _lookahead таким самим, як очікуваний тип.
fn eat(self: *Parser, ty: Tokenizer.TokenType) !?Tokenizer.Token {
const token = self._lookahead;
if (token == null) {
std.debug.print("Unexpected end of command, expected {any}", .{ty});
return SyntaxError.UnExpectedEnd;
}
switch (token.?) {
.Operation => {
return token;
},
.STRING => {
if (ty == Tokenizer.TokenType.STRING) {
return token;
} else {
return SyntaxError.UnExpectedToken;
}
},
.VAR => {
if (ty == Tokenizer.TokenType.VAR) {
return token;
} else {
return SyntaxError.UnExpectedToken;
}
},
.INTEGER => {
if (ty == Tokenizer.TokenType.INTEGER) {
return token;
} else {
return SyntaxError.UnExpectedToken;
}
},
}
return null;
}
Функція parse приймає рядок і аналізує його.
pub fn parse(self: *Parser, str: []const u8) !void {
self.tokenizer = Tokenizer.Tokenizer.init(self.allocator, str);
//Це не дуже красиво, але ми розберемося пізніше.
while (self.tokenizer.?.hasNextToken()) {
self._lookahead = self.tokenizer.?.getNextToken();
if (self.index == 0) {
const token = try self.eat(Tokenizer.TokenType.Operation);
if (token) |t| {
self.index += 1;
self.command = Command{
.Operation = @intFromEnum(t.Operation),
.Var = null,
.Value = null,
};
}
} else if (self.index == 1) {
const token = try self.eat(Tokenizer.TokenType.VAR);
if (token) |t| {
self.index += 1;
self.command.?.Var = t.VAR;
}
} else if (self.index == 2) {
if (self._lookahead) |t| {
self.command.?.Value = t;
self.index += 1;
}
}
}
}
Я вважаю, що використання індексу для відстеження того, який токен очікувати, є набагато простішим. Ця функція дійсно легка для розуміння. Вона очікує, що перший токен буде операцією, другий — змінною (VAR), а останній — або рядком, або цілим числом.
Як ви можете бачити, функція eat може повертати помилки.
- UnExpectedToken
- UnExpectedEnd
Ви можете протестувати це за допомогою "numm SET 32". Це викличе помилку UnExpectedToken.
(UnExpectedToken)
rezig/src/parser.zig:121:21: 0x100bf0a8b in eat (test)
return SyntaxError.UnExpectedToken;
Елементи та зберігання
Для зберігання елементів у нашому "redis" нам потрібен хеш-карта. Zig має вбудовані хеш-карти для зберігання інформації. Але я хочу зберігати як значення, так і тип даних, тому я створив структуру Item.
pub const Item = struct {
value: Tagged,
pub fn init(val: anytype) !Item {
if (@TypeOf(val) == i32 or @TypeOf(val) == comptime_int) {
return Item{
.value = Tagged{ .integer = val },
};
} else if (isSliceOf(@TypeOf(val), u8)) {
return Item{
.value = Tagged{ .str = val },
};
} else {
std.debug.print("Unsupported type\n", .{});
return ItemErrors.ItemTypeError;
}
}
};
Знову я використовую помічену (tagged) об’єднану структуру для зберігання як значення, так і типу елемента.
const Tag = enum { integer, str };
const Tagged = union(Tag) { integer: i32, str: []const u8 };
Рядки завжди складні. Я використав функцію isSliceOf, щоб перевірити, чи є дані рядком.
fn isSliceOf(comptime T: type, comptime Child: type) bool {
const typeInfo = @typeInfo(T);
switch (typeInfo) {
.pointer => {
const childType = typeInfo.pointer.child;
return @TypeOf([]const Child) == @TypeOf([]const childType);
},
else => return false,
}
}
Зберігання
Zig дозволяє вам легко створювати тип хеш-карти з типом ключа і типом значення.
Ці структури повинні бути ініціалізовані з використанням аллокатора.
Для rezig я використаю std.StringHashMap.
pub const Storage = struct {
allocator: std.mem.Allocator,
store: std.StringHashMap(*Item),
pub fn init(allocator: std.mem.Allocator) Storage {
const str = std.StringHashMap(*Item).init(allocator);
return Storage{
.allocator = allocator,
.store = str,
};
}
Ми ініціалізуємо хеш-карту для рядків, яка зберігає вказівник на об'єкт типу Item.
pub fn put(self: *Storage, key: []const u8, val: anytype) !void {
const item = try self.allocator.create(Item);
errdefer self.allocator.destroy(item);
item.* = Item.init(val) catch {
return;
};
try self.store.put(key, item);
}
За допомогою функції put ми додаємо дані в нашу хеш-карту.
/// Повертає вказівник на Item, якщо такий існує
pub fn get(self: *Storage, key: []const u8) ?*Item {
return self.store.get(key);
}
/// Повертає кількість існуючих елементів у сховищі як u32
pub fn count(self: *Storage) u32 {
return self.store.count();
}
/// Повертає "true", якщо значення було видалено, в іншому випадку повертає "false"
/// Автоматично звільняє пам'ять
pub fn delete(self: *Storage, key: []const u8) bool {
const kv = self.store.fetchRemove(key);
if (kv != null) {
self.allocator.destroy(kv.?.value);
return true;
}
return false;
}
pub fn deinit(self: *Storage) void {
var valueIter = self.store.valueIterator();
while (valueIter.next()) |value| {
self.allocator.destroy(value.*);
}
self.store.deinit();
}
Найважливішою функцією є deinit. Тому що нам потрібно звільнити пам'ять для збережених вказівників на елементи, коли програма завершується.
Комунікація між Cli та сервером
Ми оновили код cli ось так.
var parser = Parser.Parser.init(allocator);
defer parser.deinit();
const stdout = std.io.getStdOut().writer();
while (true) {
defer parser.clear();
// Регулюємо розмір буфера в залежності від довжини введених даних
// або використовуємо "readUntilDelimiterOrEofAlloc"
var buffer: [256]u8 = undefined;
var msg: []u8 =n' і зберігаємо значення, якщо немає помилки
if (try stdin.readUntilDelimiterOrEof(buffer[0..], ';')) |value| {
msg = value;
}
const clearmsg = normalizeWhitespace(allocator, msg);
defer allocator.free(clearmsg.buff);
try parser.parse(clearmsg.buff[0..clearmsg.until]);
if (parser.command) |com| {
const parsedCommand = try constructMessage(allocator, com);
if (parsedCommand) |command| {
_ = client.writeMessage(command) catch |err| {
try stdout.print("{}", .{err});
return;
};
}
}
}
Ми читаємо з командного рядка, поки не зустрінемо символ “;”.
Після цього я очищаю непотрібні пробіли та символи нового рядка за допомогою функції normalizeWhitespace.
fn normalizeWhitespace(allocator: std.mem.Allocator, input: []const u8) struct { buff: []const u8, until: u32 } {
var buffer = allocator.alloc(u8, input.len) catch return .{
.buff = input,
.until = @intCast(input.len),
};
var i: u32 = 0;
var j: u32 = 0;
var lastWasSpace = false;
while (i < input.len) : (i += 1) {
const c = input[i];
if (c == '\n') {
} else if (c == ',') {} else if (c == ' ') {
if (!lastWasSpace) {
buffer[j] = ' ';
j += 1;
lastWasSpace = true;
}
} else {
buffer[j] = c;
j += 1;
lastWasSpace = false;
}
}
return .{
.buff = buffer,
.until = j,
};
}
Для зручності комунікації між cli та сервером я також пропустив символ “,”, ви зрозумієте чому.
fn constructMessage(allocator: std.mem.Allocator, msg: Parser.Command) !?[]const u8 {
if (msg.Value) |value| {
switch (value) {
.STRING => |s| {
const str = try std.fmt.allocPrint(allocator, "{d},{s},{s}", .{
msg.Operation, msg.Var.?, s,
});
allocator.free(msg.Var.?);
allocator.free(msg.Value.?.STRING);
return str;
},
.INTEGER => |s| {
const str = try std.fmt.allocPrint(allocator, "{d},{s},{d}", .{
msg.Operation, msg.Var.?, s,
});
allocator.free(msg.Var.?);
return str;
},
else => {
return null;
},
}
} else {
const str = try std.fmt.allocPrint(allocator, "{d},{s}", .{
msg.Operation, msg.Var.?,
});
allocator.free(msg.Var.?);
return str;
}
}
Cli надасть серверу повідомлення, яке має таку структуру:
OperationCode,Variable,Data
На стороні сервера ми розділяємо цю команду через “,” і викликаємо відповідні функції.
var command = std.mem.splitScalar(u8, msg, ',');
var commands = std.ArrayList([]const u8).init(self.allocator);
defer commands.deinit();
while (command.next()) |com| {
try commands.append(com);
}
if (commands.items.len == 2) {
//GET або DEL
switch (commands.items[0][0]) {
'1' => {
//GET
const item = self.storage.get(commands.items[1]);
if (item) |it| {
var response: ?[]const u8 = null;
switch (it.*.value) {
.integer => |v| {
response = try std.fmt.allocPrint(self.allocator, "{d}", .{v});
},
.str => |v| {
response = try std.fmt.allocPrint(self.allocator, "{s}", .{v});
},
}
defer self.allocator.free(response.?);
const written = client.writeMessage(response.?) catch {
self.removeClient(i);
break;
};
if (written == false) {
self.client_polls[i].events = posix.POLL.OUT;
break;
}
} else {
const written = client.writeMessage("0") catch {
self.removeClient(i);
break;
};
if (written == false) {
self.client_polls[i].events = posix.POLL.OUT;
break;
}
}
},
'2' => {
//DEL
const deleted = self.storage.delete(commands.items[1]);
if (deleted) {
const written = client.writeMessage("1") catch {
self.removeClient(i);
break;
};
if (written == false) {
self.client_polls[i].events = posix.POLL.OUT;
break;
}
} else {
const written = client.writeMessage("0") catch {
self.removeClient(i);
break;
};
if (written == false) {
self.client_polls[i].events = posix.POLL.OUT;
break;
}
}
},
else => {},
}
} else if (commands.items.len == 3) {
//SET
self.storage.put(commands.items[1], commands.items[2]) catch {
const written = client.writeMessage("0") catch {
self.removeClient(i);
break;
};
if (written == false) {
self.client_polls[i].events = posix.POLL.OUT;
break;
}
};
const written = client.writeMessage("1") catch {
self.removeClient(i);
break;
};
if (written == false) {
self.client_polls[i].events = posix.POLL.OUT;
break;
}
}
Ось приклад виводу.
SET numm 32;
1
GET numm;
32
SET hello "World";
1
GET hello;
World
GET World;
0
Дякую, що приєдналися до цієї історії.
Я створю бібліотеку на Go для взаємодії з нашим "rezig".
До тих пір, бережіть себе.
Перекладено з: I couldn’t understand how Redis works. So I created a simple one in zig.