Transakcyjny mailing
W czym problem?
Wysłanie wiadomości e-mail w .NET jest dziecinnie proste:
MailMessage message = new MailMessage(
"from@server.com", "to@server.com", "Temat", "Treść");
SmtpClient smtp = new SmtpClient();
smtp.Send(message);
Powyższy przykład jest bardzo krótki, choć i tak został napisany niezwykle rozwlekle - wersja zminimalizowana zajęłaby 1 linijkę (w obu przypadkach ustawienia serwera pocztowego znajdują się w pliku konfiguracyjnym). Właściwie ten kawałek kodu powinien wystarczyć nam do szczęścia, ale czasami zdarzają się bardziej skomplikowane przypadki.
Otóż, ostatnio pisałem kod, w którym kilka metod było wykonywanych w jednej transakcji. Jedna z metod wysyłała wiadomość e-mail z powiadomieniem o powodzeniu operacji. Problem pojawiał się, gdy wykonanie kolejnej się nie powiodło. Wówczas, transakcja nie była zatwierdzana - zmiany nie zostały zapisane do bazy, ale wiadomość o powodzeniu była wysyłana! Poniżej zamieściłem przykład takich metod:
public static void CreateUser(string email)
{
//rejestracja konta użytkownika
//...
//wysłanie powiadomienia o rejestracji
MailingHelper.Send(
new MailMessage("admin@serwis.com",email,
"Witaj na portalu!",
"Twoje konto zostało zarejestrowane."));
}
public static void GiveSiteAccess(string email)
{
//nadanie uprawnień do serwisu WWW
//...
}
Oraz ich wywołanie w ramach transakcji:
using (var ts = new TransactionScope())
{
CreateUser("user@mail.com");
GiveSiteAccess("user@mail.com");
ts.Complete();
}
Jak widać, gdy wywołanie metody GiveSiteAccess się nie powiedzie, to i tak do użytkownika trafi wiadomość e-mail o sukcesie rejestracji, choć ta de facto nie nastąpiła.
Zadałem, więc sobie pytanie, czy nie da się jakoś podpiąć procesu wysyłania maili do bieżącej transakcji? Okazuje się, że tak i to bardzo łatwo. Wystarczy napisać własny menadżer zasobów i dołączyć go do transakcji.
Rozwiązanie - własny menadżer zasobów
Do informacji o bieżącej transakcji dostajemy się poprzez statyczną właściwość Current klasy System.Transactions.Transaction (w assembly System.Transactions.dll). Tam dostępne są metody, które pozwalają "dopisać się" do bieżącej transakcji:
- EnlistVolatile - dołącza do transakcji menadżera zasobów ulotnych (zasoby ulotne to np. dane w pamięci; taki menadżer nie obsługuje odtwarzania stanu po błędzie),
- EnlistPromotableSinglePhase - dołącza do transakcji menadżera zasobów, który obsługuje tryb promotable single phase enlistment,
- EnlistDurable - dołącza do transakcji menadżera zasobów trwałych (zasoby trwałe to np. dane na dysku twardym; taki menadżer posiada obsługę odtwarzania stanu po błędzie).
Nas będzie interesować pierwsza metoda, gdyż wiadomość e-mail to zasób ulotny. Menadżer zasobów ulotnych w najprostszej postaci to klasa implementująca interfejs IEnlistmentNotification. Nasz menadżer będzie wyglądał następująco:
public class MailSender : IEnlistmentNotification
{
private readonly MailMessage message;
public MailSender(MailMessage message)
{
this.message = message;
}
#region IEnlistmentNotification Members
public void Commit(Enlistment enlistment)
{
//powoduje faktyczne wysłanie wiadomości
MailingHelper.SendImmediately(message);
enlistment.Done(); //menadżer zgłasza zakończenie swojej pracy
}
public void InDoubt(Enlistment enlistment)
{
enlistment.Done();
}
public void Prepare(PreparingEnlistment preparingEnlistment)
{
//menadżer potwierdza zakończenie przygotowań
preparingEnlistment.Prepared();
}
public void Rollback(Enlistment enlistment)
{
enlistment.Done(); //menadżer potwierdza wycofanie transakcji
}
#endregion
}
Metody interfejsu IEnlistmentNotification będą wykonywane zgodnie z protokołem dwufazowego zatwierdzania (two-phase commit). W dużym skrócie, proces ten składa się z dwóch faz: fazy przygotowania i fazy zatwierdzania. Menadżer transakcji najpierw żąda przygotowania się menadżerów zasobów - metoda Prepare. Każdy menadżer może zgłosić gotowość (wywołanie preparingEnlistment.Prepared()) lub wymusić wycofanie transakcji (wywołanie preparingEnlistment.ForceRollback()). Jeżeli wszystkie menadżery zgłoszą gotować, to transakcja jest zatwierdzana - metoda Commit. W przeciwnym razie wszystkie menadżery dostają polecenie wycofania transakcji - metoda Rollback. Natomiast, gdy menadżer transakcji straci łączność z którymś z menadżerów zasobów, to wywoływana jest metoda InDoubt.
Dołączenie menadżera zasobów do transakcji odbywa się następująco:
Transaction.Current.EnlistVolatile(new MailSender(message), EnlistmentOptions.None);
Ponieważ cała magia jest już znana, oto kod klasy pomocniczej MailingHelper:
public static class MailingHelper
{
public static void Send(MailMessage message)
{
if (Transaction.Current != null)
{
Transaction.Current.EnlistVolatile(
new MailSender(message), EnlistmentOptions.None);
}
else
SendImmediately(message);
}
public static void SendImmediately(MailMessage message)
{
var smtp = new SmtpClient();
smtp.EnableSsl = true;
smtp.Send(message);
}
}
Jak widać, metoda SendImmediately wysyła wiadomość natychmiast, podczas gdy metoda Send podpina wysłanie wiadomości pod bieżącą transakcję (jeżeli taka istnieje; w przeciwnym razie wysyła maila od razu). Efekt jest taki, jaki chcieliśmy osiągnąć - poczta jest wysyłana dopiero w momencie zatwierdzenia transakcji.
Kilka słów wyjaśnienia
Przedstawione rozwiązanie, wbrew tytułowi, nie jest receptą na zbudowanie w pełni transakcyjnego mailingu. Tutaj tylko osiągamy efekt opóźnienia wysłania wiadomości e-mail do momentu zatwierdzenia transakcji. Ponadto, kod został uproszczony dla zwiększenia czytelności.
Bardziej dociekliwych zapraszam do:
- Dokumentacja klasy Transaction.
- Dokumentacja klasy TransactionScope.
- Dokumentacja interfejsu IEnlistmentNotification.
- Artykuł o implementacji własnego menadżera zasobów.