2011. december 31., szombat

Exim turbózása tárolt eljárások... helyett UDF azaz User DefinedFunction-el :)

Egy projekt kapcsán feldobták a feladatot: Mikor egy ügyfél felad egy hirdetést, a nyomonkövethetőség okán generáljon hozzá a rendszer egy e-mail címet, és azt jelenítse meg a usernek, majd erre az e-mail címre érkező, majd továbbított levelek száma legyen nyilvántartva, hogy fel tudjunk mutatni egy statisztikát, mennyire volt az sikeres.
Ennek megoldásához a már eleve MySQL alapokon működő eximünket módosítottam úgy, hogy a szükséges címek leválogatását rábízza egy UDF-re, egy egyszerű select eredményeképpen megkapjuk a listát, és maga a MySQL intézze el a továbbítások számolását.


Az exim MySQL-re átpattintásába most nem mennék bele, az egy bővebb téma lenne. Erre ajánlom Pásztor János kolléga által igen részletesen megírt "A nagy Exim tutorial" című cikket.

A levelek automatikus továbbításához létrehozunk egy táblát:
CREATE TABLE IF NOT EXISTS `exim_forward` (
  `email` varchar(128) NOT NULL default '',
  `newaddress` varchar(128) NOT NULL default '',
  PRIMARY KEY  (`email`,`newaddress`),
  KEY `email_index` (`email`)
);

Úgy gondolom, a mezők tartalma a nevük alapján nyilvánvaló.
Egy sima auto-forward exim szinten így néz ki:

userforward:
    local_parts = !default
    driver = redirect
    data = ${lookup mysql{select distinct(newaddress) from exim_forward where email = '${local_part}@${domain}'}{$value}{${lookup mysql{select email from exim_mail where email = '${local_part}@${domain}'}{$value}{${lookup mysql{select distinct(newaddress) from exim_forward where email='*@${domain}'}{$value}fail}}}}}
    check_ancestor

Ennek magyarázását most kihagyom, mivel most nem ez a poszt témája. Ettől függetlenül, ha csak sima auto-forward érdekel, ezt nyugodtan használhatod :) Ehhez egyébként nem szükséges lokálisan kezelt postafiók, csak az adott domain MX rekordja legyen a miénk.
Röviden amit a fenti tud:

e-mail -> e-mail // teszt@tesz.hu -> teszt2@teszt2.hu
domain (*@domain.tld) -> e-mail // barmi@teszt.hu -> fixcim@teszt2.hu


Ezen is változtatunk egy kicsit, így a következőt fogja tudni az eximünk:
e-mail -> e-mail // teszt@tesz.hu -> teszt2@teszt2.hu
domain (*@domain.tld) -> e-mail // barmi@teszt.hu -> fixcim@teszt2.hu
domain (*@domain.tld) -> domain (*@domain.tld) // barmi@teszt.hu -> barmi@teszt2.hu


Exim-nek fontos, hogy a data értéke az új címek listája legyen, minden cím új sorban, minden sallang nélkül.

Ezt a feltételt lovagoljuk meg, és csinálunk egy tárolt eljárást, ami leválogatja az új címeket, a továbbítások számát növeli eggyel, és visszatér az exim-hez.
Ehhez először kiegészítjük az exim_forward táblánkat egy fw_count, egy last_exim_id mezővel és az elsődleges kulcsot módosítjuk egy sima auto_increment mezőre:
ALTER TABLE `exim_forward` ADD `fw_count` INT NOT NULL DEFAULT '0';
ALTER TABLE `exim_forward` ADD  `last_exim_id` VARCHAR( 255 ) NULL;

ALTER TABLE `exim_forward` DROP PRIMARY KEY, ADD UNIQUE (`email`, `newaddress`);
ALTER TABLE `exim_forward` ADD `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY FIRST;

A last_exim_id-t eredetileg azért találtam ki, mert a továbbítások számolása során adódott egy kis probléma, miszerint az exim kétszer lookup-ol egy e-mail címre. Bevallom, mélyrehatóan nem néztem utána, de ha jól vettem ki, ennek az az oka, hogy első ízben ellenőrzi van-e mivel foglalkozni (van hova redirect-elni?), majd a tényleges továbbításhoz ismét lekéri a címlistát.

Szóval ennek köszönhetően a fw_count mező értéke mindig duplázódott, ami ugyan kikerülhető egy sima felezéssel, de nem valami elegáns. Első nekifutásra úgy gondoltam, hogy minden lookup alkalmával megkapom az e-mail exim által generált azonosítóját, ami egyrészről biztos egyedi, másrészről tudok ellenőrizni, ha az adott e-mailhez tartozó előző exim_id megegyezik az aktuálissal, akkor nem növelem az fw_count értékét.

Ez az elképzelés hibásnak bizonyult, tekintve, hogy első alkalommal még nem generál azonosítót az exim, akkor még a megkapott exim_id érték üres. Magán a megoldáson nem túl sokat módosít ez a tény, hiszen ez is könnyen ellenőrizhető, illetőleg a  last_exim_id legalább egy plusz információ lehet, ha bármi okból utána kell néznünk mondjuk az utolsó forwardnak.

Az előző tárolteljárásosfüggvényestriggeres írásomhoz képest nem túl sok dologgal fog többet mutatni a most használt függvény. Használni fogunk változókat, kurzort, iterálni fogunk eredményhalmazon, illetve alap MySQL függvényeket fogunk használni.

Lássuk magát az ördögöt:

DROP FUNCTION IF EXISTS get_forwardset;
DELIMITER //
CREATE DEFINER=CURRENT_USER FUNCTION get_forwardset(arg_local_part VARCHAR(64), arg_domain VARCHAR(255), arg_exim_id VARCHAR(255)) RETURNS TEXT
 NOT DETERMINISTIC
 MODIFIES SQL DATA
BEGIN
 DECLARE var_orig_email VARCHAR(255);
 DECLARE var_joker_email VARCHAR(255);
 DECLARE var_no_more_addresses INT DEFAULT '0';
 DECLARE var_curr_id INT;
 DECLARE var_curr_address VARCHAR(255);
 DECLARE var_curr_last_exim_id VARCHAR(255);
 DECLARE result_addresses TEXT DEFAULT '';
 
 DECLARE cur_fw_select CURSOR FOR SELECT DISTINCT newaddress, id, last_exim_id FROM exim_forward WHERE email = var_joker_email OR email = var_orig_email;
 DECLARE CONTINUE HANDLER FOR NOT FOUND SET var_no_more_addresses = 1;
 
 SET var_orig_email = CONCAT(arg_local_part, '@', arg_domain);
 SET var_joker_email = CONCAT('*@', arg_domain);
 
 OPEN cur_fw_select;
 
 FETCH cur_fw_select INTO var_curr_address, var_curr_id, var_curr_last_exim_id;
 WHILE var_no_more_addresses = 0 DO
  IF var_curr_address REGEXP '\\*@.+' = 1 THEN
   SET var_curr_address = CONCAT(arg_local_part, SUBSTRING(var_curr_address, 2));
  END IF;
  IF var_curr_address IS NOT NULL AND LOCATE(var_curr_address, result_addresses) = 0 AND LENGTH(var_curr_address) > 5 THEN
   SET result_addresses = CONCAT(result_addresses, var_curr_address, '\n');
   
   IF LENGTH(arg_exim_id) > 0 AND (var_curr_last_exim_id IS NULL OR var_curr_last_exim_id <> arg_exim_id) THEN
    UPDATE exim_forward SET fw_count = fw_count+1, last_exim_id = arg_exim_id WHERE id = var_curr_id;
   END IF;
  END IF;
  FETCH cur_fw_select INTO var_curr_address, var_curr_id, var_curr_last_exim_id;
 END WHILE;
 
 CLOSE cur_fw_select;
 SET var_no_more_addresses = 0;
 
 RETURN result_addresses;
END//
DELIMITER ;

Az UDF-ek szintaktikája minimálisan különbözik a tárolt eljárásokétól:

CREATE
    [DEFINER = { user | CURRENT_USER }]
    FUNCTION sp_name ([func_parameter[,...]])
    RETURNS type
    [characteristic ...] routine_body

A paraméterlista mint látható a fenti kódban "név TIPUS" felsorolás, ahol a típus bármely MySQL adattípus lehet. Ez igaz a visszatérési értékre is (RETURNS TIPUS).
Nekünk jelen esetben szükségünk lesz az eredeti e-mail cím local_part és domain részeire, illetőleg a már korábban említett exim által generált azonosítóra.

Én következetesen használok prefix-eket a változók megkülönböztetésére, így sokkal olvashatóbb és átláthatóbb lesz a kód. Az UDF paramétereit arg, az általam deklarált változókat pedig var prefixekkel látom el.

A characteristic rész a következőket tartalmazhatja:
COMMENT 'string'
  | LANGUAGE SQL
  | [NOT] DETERMINISTIC
  | { CONTAINS SQL | NO SQL | READS SQL DATA | MODIFIES SQL DATA }
  | SQL SECURITY { DEFINER | INVOKER }

Ez a rész azonban a MySQL számára csak jelzésértékű információkat tartalmaz, azok hatására a függvény vagy tárolt eljárás futása, eredménye nem változik. Éppen ezért ez a characteristic rész opcionális, akár el is hagyható teljes egészében.
A felsorolt információk szerintem egyértelműek, egyedül a DETERMINISTIC rész lehet idegen, ami csupán annyit árul el, hogy az UDF azonos paraméterek mellett ugyanazt az eredményt fogja-e prezentálni vagy sem.
Rutin törzs...
Először is deklaráljuk a későbbiekben használt változóinkat, kurzorainkat handlereinket.

Amit a kurzorokról tudni kell:
MySQL-ben használhatunk kurzorokat tárolt eljárásokban, függvényekben és triggerekben egyaránt. Segítségükkel egy lekérdezés eredményeként visszatérő eredményhalmazon iterálhatunk végig. A kurzorokkal csak egy irányba haladhatsz, nem hagyhatsz ki sorokat és nem léphetsz visszafelé (Non-scrollable). Nyitott kurzornál ne módosítsd a táblát, különben megbízhatatlan eredményt fogsz kapni (Asensitive).

Kurzorok előtt csak a deklarációs rész állhat, nem SET-elhetünk példának okáért változókat. Ezért is van a fenti kódban az, hogy bár az előzőleg deklarált változókat használjuk a kurzorban, azoknak az értékét csak a kurzor deklaráció után állítjuk be. Ezeknek az értékeknek a feltöltése a kurzorban úgyis csak annak a megnyitása során történik meg.

Kurzorok használatához a következő parancsokat kell ismernünk.
Először is deklarálnunk kell azt:

DECLARE cursor_name CURSOR FOR SELECT_statement;

Következő lépésként meg kell nyitnunk a kurzort az OPEN paranccsal, mielőtt eredményeket szeretnénk lekérdezni:

OPEN cursor_name;

Lekérdezni a kurzorként deklarált SELECT következő sorát, és tovább lépni az azt követőre a FETCH paranccsal lehet:

FETCH cursor_name INTO variable list;

A variable list azaz a változólista annyit tesz, hogy a kurzor által deklarált SELECT-ben lekérdezett mezők mely változókat töltsék fel értékkel. Fontos, hogy minden lekérdezett mezőhöz tartoznia kell egy változónak, különben panaszkodni fog a MySQL, hogy nem stimmel a változók száma.

Végül pedig a nyitott kurzort le kell zárni, hogy deaktiválódjon, és felszabadítsuk a memóriát ami az adott kurzorhoz lett lefoglalva.

CLOSE cursor_name;

Az egyik legfontosabb pontja a kurzorok használatának a NOT FOUND handlerek deklarálása, a lekérdezésünk "no data to fetch" eredményének lekezelésére:

DECLARE CONTINUE HANDLER FOR NOT FOUND SET var_no_more_addresses = 1;

Fontos, hogy ezzel nem azt mondtuk, hogy a var_no_more_addresses kezdőértéke 1, hanem azt, ha a FETCH utasítás nem ad vissza eredményt, ezt a változót állítsa 1-re! Ergo magát a változót előbb deklarálnunk kell (ahogy ezt meg is tettük), és vagy inicializáljuk azt, vagy nem.

A mi kurzorunk egy olyan lekérdezés eredményét adja, amiben lekérdezzük a célcímeket konkrét e-mail illetve domain (*@domain) alapján.

A 18. 19. sorokban összeállítottuk magát az eredeti e-mail címet, és a domainhez szükséges joker címet, a későbbi egyszerűbb használat érdekében.
No itt jön a kurzor, amit megnyitunk, majd a soron következő eredményeket a korábban deklarált változókba töltjük, és elkezdünk végig iterálni a sorokon a kurzorunk segítségével.

Mint látható, a WHILE egy elöltesztelős ciklus, aminek nincs hátultesztelős verziója. Ha ilyet szeretnénk, akkor a REPEAT-et kell használnunk:

domain_iterate: REPEAT
 [ciklus_torzs]
UNTIL var_no_more_addresses = 1
END REPEAT domain_iterate;

Az első FETCH utasítás azért a ciklus előtt van kiadva, mivel abba már csak akkor szeretnénk belekezdeni (és addig iterálni) ha (amíg) van eredmény.

A 25-27. sorokban ellenőrizzük a célcímet. Amennyiben az egy joker e-mail, az azt jelenti, hogy az új funkcionalitásról van szó, azaz egy komplett domaint irányítunk át egy másik domainre, megtartva a címzettet. Ebben az esetben a * helyére behelyettesítjük az eredeti postafiók nevet. Így valósul meg a barmi@teszt.hu -> barmi@teszt2.hu

A következő lépéseket csak akkor csináljuk meg, ha az aktuálisan megkapott célcím nem NULL, nem szerepel még az eredménylistában és a hossza nagyobb mint 5 karakter (6 karakteres a@a.hu mint legrövidebb előfordulási lehetőség)
Ha ezeknek a feltételeknek megfelel az e-mail cím amit kaptunk, hozzáfűzzük az eredménylistához újsor karakterrel a végén.

A 31-33. sor tartalmazza egyrészről a forwardolás tényének rögzítését, másrészről pedig a korábban említett ellenőrzést az exim_id alapján. Csak akkor számoljuk el a lekérdezést tényleges továbbításnak, ha van exim_id, és nem egyezik az előző exim_id -vel. Ekkor mondhatjuk csak ugyanis, hogy az exim ténylegesen a címlistára kíváncsi, hogy hova küldje a beérkező levelet (mint korábban említettem, első alkalommal csak megkérdezi, hogy van-e mivel foglalkoznia, akkor még exim_id sincs).

Mindezeket követően még a cikluson belül tovább kell löknünk a kurzort a következő rekordra. Amennyiben ennek not found az eredménye, a not found handlerünk a var_no_more_addresses értékét 1-re állítja, így a ciklusunk a végéhez ért.
A lefutott ciklus után lezárjuk a kurzort, a var_no_more_addresses értékét visszaállítjuk 0-ra, és visszatérünk a leválogatott e-mail címekkel amik külön sorokban szerepelnek, és fel tudja őket dolgozni az exim.

A függvényünk már készen van, be is töltöttük a MySQL-be, de még az eximet meg kell tanítanunk rá, hogy használja is. Ehhez módosítanunk kell a lookup-ot a userforward-ban:

userforward:
    local_parts = !default
    driver = redirect
    data = ${lookup mysql{select get_forwardset('${quote_mysql:$local_part}', '${quote_mysql:$domain}', '${quote_mysql:$message_exim_id}')}{$value}fail}
    check_ancestor

Ugye nem is volt bonyolult :D

Próbáljuk is ki. Először vegyünk fel egy új rekordot az exim_forward táblánkba:

INSERT INTO  `exim_forward` (`email`, `newaddress`, `fw_count`) VALUES ('nullstring@teszt.hu',  'nullstring@teszt2.hu',  '0');

Majd küldjünk egy e-mailt a nullstring@teszt.hu címre, amit a nullstring@teszt2.hu címen szeretnénk ha landolna, és nézzük mit csinál az exim:

2011-12-31 02:20:18 SMTP connection from [74.125.82.51] (TCP/IP connection count = 1)
2011-12-31 02:20:41 1Rgncn-0008ER-1R <= felado@felado.hu H=mail-ww0-f51.google.com [74.125.82.51] P=esmtp S=1552 id=CAGpH1bo4nR4Ju+GSH_h4cFPxJChTQ4vWpbgih2Yop=aJcPJvtA@mail.gmail.com T="fw-teszt" from <felado@felado.hu> for nullstring@teszt.hu
2011-12-31 02:20:41 1Rgncn-0008ER-1R => nullstring@teszt2.hu <nullstring@teszt.hu> R=lookuphost T=remote_smtp H=gmail-smtp-in.l.google.com [173.194.65.27] X=TLS1.0:RSA_ARCFOUR_SHA1:16 C="250 2.0.0 OK 1325294463 w45si16840104eeh.4" DT=0s
2011-12-31 02:20:41 1Rgncn-0008ER-1R Completed
2011-12-31 02:21:11 SMTP connection from mail-ww0-f51.google.com [74.125.82.51] closed by QUIT


Egyértelműen látható, hogy a felado@felado.hu -tól érkező nullstring@teszt.hu címre küldött levél esetén sikeresen lekérdezte az új e-mail címet, és arra tovább is küldte a levelet.

Nade fel is jegyezte ennek a tényét?

mysql> select * from exim_forward;
+----+---------------------+----------------------+----------+------------------+
| id | email               | newaddress           | fw_count | last_exim_id     |
+----+---------------------+----------------------+----------+------------------+
|  1 | nullstring@teszt.hu | nullstring@teszt2.hu |        1 | 1Rgncn-0008ER-1R |
+----+---------------------+----------------------+----------+------------------+
1 rows in set (0.00 sec)

Úgy tűnik igen, hiszen az fw_count értéke immáron 1. Az exim által generált azonosító is helyesen lett letárolva, így nincs más hátra mint előre,
ugorjunk bele a szilveszterbe! :D

BUÉK!

Nincsenek megjegyzések:

Megjegyzés küldése