Gestion du trafic de couche 4 et mise en œuvre d'un serveur Memcached avec OpenResty
API7.ai
November 10, 2022
Dans quelques articles précédents, nous avons introduit certaines API Lua pour gérer les requêtes, qui sont toutes liées à la couche 7. En outre, OpenResty fournit le module stream-lua-nginx-module
pour gérer le trafic de la couche 4. Il propose des instructions et des API qui sont fondamentalement les mêmes que celles du module lua-nginx-module
.
Aujourd'hui, nous allons parler de la mise en œuvre d'un serveur Memcached avec OpenResty, qui ne nécessite qu'environ 100 lignes de code. Dans ce petit exercice pratique, nous utiliserons beaucoup de ce que nous avons appris précédemment, et nous introduirons également certains contenus des chapitres sur les tests et l'optimisation des performances.
Et nous devons être clairs : l'objectif de cet article n'est pas de comprendre les fonctions de chaque ligne de code, mais de comprendre la vue d'ensemble de la manière dont OpenResty développe un projet à partir de zéro, du point de vue des besoins, des tests, du développement, etc.
Besoins originaux et solutions techniques
Nous savons que le trafic HTTPS devient dominant, mais certains anciens navigateurs ne prennent pas en charge les session tickets
, donc nous devons stocker l'ID de session côté serveur. Si l'espace de stockage local est insuffisant, nous avons besoin d'un cluster pour le stockage, et les données peuvent être perdues, donc Memcached est plus adapté.
À ce stade, introduire Memcached devrait être la solution la plus directe. Cependant, dans cet article, nous choisirons d'utiliser OpenResty pour réinventer la roue pour les raisons suivantes.
- Premièrement, introduire Memcached directement introduirait un processus supplémentaire, augmentant les coûts de déploiement et de maintenance.
- Deuxièmement, le besoin est suffisamment simple, nécessitant uniquement des opérations
get
etset
, et supportant l'expiration. - Troisièmement, OpenResty dispose d'un module
stream
, qui peut rapidement implémenter ce besoin.
Puisque nous voulons implémenter un serveur Memcached, nous devons d'abord comprendre son protocole. Le protocole Memcached peut supporter TCP et UDP. Ici, nous utilisons TCP. Voici le protocole spécifique des commandes get
et set
.
Get
obtenir la valeur avec la clé
Commande Telnet : get <key>*\r\n
Exemple :
get key
VALUE key 0 4 data END
Set
Enregistrer une paire clé-valeur dans memcached
Commande Telnet : set <key> <flags> <exptime> <bytes> [noreply]\r\n<value>\r\n
Exemple :
set key 0 900 4 data
STORED
Nous devons également savoir comment la "gestion des erreurs" du protocole Memcached est implémentée, en plus de get
et set
. La "gestion des erreurs" est très importante pour les programmes côté serveur, et nous devons écrire des programmes qui gèrent non seulement les requêtes normales mais aussi les exceptions. Par exemple, dans un scénario comme celui-ci :
- Memcached envoie une requête autre que
get
ouset
, comment la gérer ? - Quel type de retour dois-je donner au client Memcached en cas d'erreur côté serveur ?
De plus, nous voulons écrire une application cliente compatible avec Memcached. Ainsi, les utilisateurs n'auront pas à distinguer entre la version officielle de Memcached et l'implémentation OpenResty.
La figure suivante, tirée de la documentation de Memcached, décrit ce qui doit être retourné en cas d'erreur et le format exact, que vous pouvez utiliser comme référence.
Maintenant, définissons la solution technique. Nous savons que le shared dict
d'OpenResty peut être utilisé à travers les worker
s et que mettre des données dans un shared dict
est très similaire à les mettre dans Memcached. Les deux supportent les opérations get
et set
, et les données sont perdues lors du redémarrage du processus. Par conséquent, il est approprié d'utiliser un shared dict
pour émuler Memcached, car leurs principes et comportements sont les mêmes.
Développement piloté par les tests
La prochaine étape consiste à commencer à travailler dessus. Cependant, en nous basant sur l'idée du développement piloté par les tests, construisons le cas de test le plus simple avant d'écrire le code spécifique. Au lieu d'utiliser le framework test::nginx
, qui est notoirement difficile à démarrer, commençons par un test manuel en utilisant resty
.
$ resty -e 'local memcached = require "resty.memcached"
local memc, err = memcached:new()
memc:set_timeout(1000) -- 1 sec
local ok, err = memc:connect("127.0.0.1", 11212)
local ok, err = memc:set("dog", 32)
if not ok then
ngx.say("failed to set dog: ", err)
return
end
local res, flags, err = memc:get("dog")
ngx.say("dog: ", res)'
Ce code de test utilise la bibliothèque cliente lua-rety-memcached
pour initier les opérations connect
et set
et suppose que le serveur Memcached écoute sur le port 11212
de la machine locale.
Il semble que cela devrait fonctionner correctement. Vous pouvez exécuter ce code sur votre machine et, sans surprise, il retournera une erreur comme failed to set dog: closed
, puisque le service n'est pas démarré à ce stade.
À ce stade, votre solution technique est claire : utiliser le module stream
pour recevoir et envoyer des données et utiliser le shared dict
pour les stocker.
La métrique pour mesurer l'achèvement du besoin est claire : exécuter le code ci-dessus et imprimer la valeur réelle de dog
.
Construction du framework
Alors, qu'attendez-vous ? Commencez à écrire du code !
Mon habitude est de construire d'abord un framework de code minimal exécutable, puis de remplir progressivement le code. L'avantage de cette approche est que vous pouvez fixer de nombreux petits objectifs pendant le processus de codage, et les cas de test vous donneront un retour positif lorsque vous atteindrez un petit objectif.
Commençons par configurer le fichier de configuration NGINX, car stream
et shared dict
doivent être prédéfinis dedans. Voici le fichier de configuration que j'ai configuré.
stream {
lua_shared_dict memcached 100m;
lua_package_path 'lib/?.lua;;';
server {
listen 11212;
content_by_lua_block {
local m = require("resty.memcached.server")
m.run()
}
}
}
Comme vous pouvez le voir, plusieurs informations clés sont dans ce fichier de configuration.
- Premièrement, le code s'exécute dans le contexte
stream
de NGINX, pas dans le contexteHTTP
, et écoute sur le port11212
. - Deuxièmement, le nom du
shared dict
estmemcached
, et la taille est de100M
, qui ne peut pas être modifiée à l'exécution. - De plus, le code se trouve dans le répertoire
lib/resty/memcached
, le nom du fichier estserver.lua
, et la fonction d'entrée estrun()
, que vous pouvez trouver danslua_package_path
etcontent_by_lua_block
.
Ensuite, il est temps de construire le framework de code. Vous pouvez essayer par vous-même, puis regardons ensemble mon framework de code.
local new_tab = require "table.new"
local str_sub = string.sub
local re_find = ngx.re.find
local mc_shdict = ngx.shared.memcached
local _M = { _VERSION = '0.01' }
local function parse_args(s, start)
end
function _M.get(tcpsock, keys)
end
function _M.set(tcpsock, res)
end
function _M.run()
local tcpsock = assert(ngx.req.socket(true))
while true do
tcpsock:settimeout(60000) -- 60 secondes
local data, err = tcpsock:receive("*l")
local command, args
if data then
local from, to, err = re_find(data, [[(\S+)]], "jo")
if from then
command = str_sub(data, from, to)
args = parse_args(data, to + 1)
end
end
if args then
local args_len = #args
if command == 'get' and args_len > 0 then
_M.get(tcpsock, args)
elseif command == "set" and args_len == 4 then
_M.set(tcpsock, args)
end
end
end
end
return _M
Cet extrait de code implémente la logique principale de la fonction d'entrée run()
. Bien que je n'aie pas fait de gestion des exceptions et que les dépendances parse_args
, get
, et set
soient toutes des fonctions vides, ce framework exprime déjà entièrement la logique du serveur Memcached.
Remplissage du code
Ensuite, implémentons ces fonctions vides dans l'ordre d'exécution du code.
Tout d'abord, nous pouvons analyser les paramètres de la commande Memcached selon la documentation du protocole Memcached.
local function parse_args(s, start)
local arr = {}
while true do
local from, to = re_find(s, [[\S+]], "jo", {pos = start})
if not from then
break
end
table.insert(arr, str_sub(s, from, to))
start = to + 1
end
return arr
end
Mon conseil est d'implémenter d'abord une version la plus intuitive, sans penser à l'optimisation des performances. Après tout, l'achèvement est toujours plus important que la perfection, et l'optimisation incrémentielle basée sur l'achèvement est la seule façon de se rapprocher de la perfection.
Ensuite, implémentons la fonction get
. Elle peut interroger plusieurs clés à la fois, donc j'utilise une boucle for
dans le code suivant.
function _M.get(tcpsock, keys)
local reply = ""
for i = 1, #keys do
local key = keys[i]
local value, flags = mc_shdict:get(key)
if value then
local flags = flags or 0
reply = reply .. "VALUE" .. key .. " " .. flags .. " " .. #value .. "\r\n" .. value .. "\r\n"
end
end
reply = reply .. "END\r\n"
tcpsock:settimeout(1000) -- délai d'une seconde
local bytes, err = tcpsock:send(reply)
end
Il n'y a qu'une seule ligne de code centrale ici : local value, flags = mc_shdict:get(key)
, c'est-à-dire interroger les données du shared dict
; quant au reste du code, il suit le protocole Memcached pour assembler la chaîne et enfin l'envoyer au client.
Enfin, regardons la fonction set
. Elle convertit les paramètres reçus au format de l'API shared dict
, stocke les données, et en cas d'erreurs, les gère selon le protocole Memcached.
function _M.set(tcpsock, res)
local reply = ""
local key = res[1]
local flags = res[2]
local exptime = res[3]
local bytes = res[4]
local value, err = tcpsock:receive(tonumber(bytes) + 2)
if str_sub(value, -2, -1) == "\r\n" then
local succ, err, forcible = mc_shdict:set(key, str_sub(value, 1, bytes), exptime, flags)
if succ then
reply = reply .. “STORED\r\n"
else
reply = reply .. "SERVER_ERROR " .. err .. “\r\n”
end
else
reply = reply .. "ERROR\r\n"
end
tcpsock:settimeout(1000) -- délai d'une seconde
local bytes, err = tcpsock:send(reply)
end
De plus, vous pouvez utiliser des cas de test pour vérifier et déboguer avec ngx.log
tout en remplissant les fonctions ci-dessus. Malheureusement, nous utilisons ngx.say
et ngx.log
pour déboguer car il n'y a pas de débogueur à points d'arrêt dans OpenResty, ce qui est encore une ère primitive en attente d'exploration plus approfondie.
Résumé
Ce projet pratique se termine maintenant, et enfin, je voudrais laisser une question : Pourriez-vous prendre le code d'implémentation du serveur Memcached ci-dessus, l'exécuter complètement et passer le cas de test ?
La question d'aujourd'hui nécessitera probablement beaucoup d'efforts, mais c'est encore une version primitive. Il n'y a pas de gestion des erreurs, d'optimisation des performances et de tests automatisés, qui seront améliorés plus tard.
Si vous avez des doutes sur l'explication d'aujourd'hui ou sur votre pratique, vous êtes invités à laisser un commentaire et à en discuter avec nous. Vous êtes également invités à partager cet article avec vos collègues et amis afin que nous puissions pratiquer et progresser ensemble.