To co napisałem poniżej to narazie koncept, który w sumie już działa, ale wymaga jeszcze nieco szlifu. Potrzebowałem skomunikować ze sobą dwie aplikacje webowe oparte o ASP.NET MVC. Naczytałem się o WCF i innych sposobach komunikacji (na przykład Webservice) i zawsze oznaczało to dosyć sporą ilość dłubania (a to jakieś atrybuty, a to opasłe xml'e konfiguracyjne). Zresztą jak dobrze poszukać to znajdą się opinie, że budowanie osobnych serwisów WCF wewnątrz aplikacji ASP.NET MVC, która sama z natury doskonale nadaje się do wystawiania w ten sposób danych jest bez sensu. Dlatego też wpadł mi do głowy pomysł, aby nieco podrasować kontrolery (czyli nasz Webservice ;)) oraz dobudować do tego prosty mechanizm klienta, który będzie się odwoływał do naszego nowego serwisu. Zależało mi przede wszystkim na jak największej prostocie użycia oraz użyciem przyjętych konwencji zamiast opasłych konfiguracji.
MVC
Kontroler aplikacji ASP.NET MVC musi zwracać obiekt typu ActionResult, aby mógł być zinterpretowany i wysłany do klienta w odpowiednim formacie. Jeśli obiektem zwracanym przez akcję będzie na przykład bool to w odpowiedzi dostaniemy treść w postaci "System.Boolean". Nastąpi automatyczna konwersja ToString i wysłanie treści typu text/html. W tym, celu stworzyłem specjalny ActionInvoker. W przypadku gdy akcja zwróci obiekt innego typu niż ActionResult to odpowiednio zinterpretuje dane i je zserializuje do formatu o jaki prosił klient. Zdefiniujmy zatem nasz serwis:
public interface ITestService : IControllerService
{
bool Test(TestMessage msg);
}
IControllerService to tak zwany "marker" oznaczający nasz kontroler (potrzebny do wewnętrzengo działania ActionInvokera). Klasa wiadomości jest prosta no i musi być seralizowalna:
[Serializable]
public class TestMessage
{
public string Value { get; set; }
}
Przykładowy kod kontrolera będącego serwisem:
[CompressFilter]
public class TestServiceController : Controller, ITestService
{
[AcceptVerbs(HttpVerbs.Post)]
public void bool Test(TestMessage msg)
{
Debug.WriteLine(msg.Value);
return msg.Value != null;
}
}
To już prawie wszystko. Teraz jeszcze trzeba podstawić do kontrolera nasz magiczny ActionInvoker. Można to zrobić na różne sposoby. Najprostszy to wykonać podstawienie w konstruktorze (patrz poniżej) lub dla bardziej wymagających, można posłużyć się StructureMap i podczas tworzenia kontrolera użyć TypeInterceptor.
[CompressFilter]
public class TestServiceController : Controller, ITestService
{
public TestServiceController()
{
ActionInvoker = new ControllerServiceActionInvoker();
}
[AcceptVerbs(HttpVerbs.Post)]
public void bool Test(TestMessage msg)
{
Debug.WriteLine(msg.Value);
return msg.Value != null;
}
}
W tym momencie to już koniec tworzenia naszego serwisu. Banalne, prawda? :) Zwróćcie uwagę, że kontroller w pełni implementuje nasz interface ITestService. Interface ten będzie naszym tzw. kontraktem przy tworzeniu proxy po stronie klienta. Oznacza to, że wyciągamy nasz interface do zewnętrznego projektu Api (assembly), aby móc dodać do niego referencję w aplikacji klienckiej. Dzięki temu zmiana interfejsu pociągnie za sobą potrzebę zmiany kodu klienta jak i serwera, aby była cały czas zachowana spójność.
Klient
Teraz strona klienta. Załóżmy, że mamy już referencję do naszego Api. Tworzymy zatem proxy naszego serwisu.
ITestService srv = ServiceFactory.Create<ITestService>();
Koniec. :) Żartuję oczywiście, ale w tym momencie mamy już stworzony serwis, dzięki któremu możemy wywoływać metody poprzez nasze Api do zdefiniowanej powyżej akcji w aplikacji ASP.NET MVC. Wywołajmy zatem akcję.
bool result = srv.Test(new TestMessage(){ Value = "test"; });
Chciałem znów napisać, że to już koniec, ale się powstrzymam ;) W linii powyżej następuje:
- serializacja obiektu TestMessage do Json (domyślna serializacja, można podstawiać własne serializery)
- wysłanie Json'a POST'em (domyślny typ akcji, można zmieniać w zależności od potrzeby)
- deserialziacja Json'a (w serwisie) do obiektu TestMessage jako argumentu wejściowego akcji Test
- wykonanie kodu akcji na kontrolerze
- serializacja wyniku akcji do Json'a
- odebranie nowego Json'a u klienta
- deserializacja Json'a do spodziewanego typu, który zwraca metoda Test, czyli System.Boolean
Wszystkie te działania są opakowane w wewnętrzną implementację, której końcowy użytkownik/programista wogóle nie musi być świadomy. Bo po co? :) Padnie pewnie pytanie: Skąd klient wie, gdzie strzelać requesty? Odpowiedź jest prosta: Convention over configuration, ale od początku. Na serwerze musi być zdefiniowana ścieżka (Route) do akcji naszego serwisu. Powiedzmy:
routes.MapRoute(
"Api-Test",
"api/test/{action}",
new { controller = "testservice", action = "index" });
W związku z czym (na lokalnej maszynie) klient powinien strzelać pod adres:
http://localhost/nasza_aplikacja/api/test/{action}
I dokładnie powyższą linijkę wklejam w config naszego klienta w następujący sposób:
<appSettings>
<add key="ITestServiceUri" value="http://localhost/nasza_aplikacja/api/test/{action}"/>
</appSettings>
Konstrukcja klucza jest banalna: "nazwa interfejsu naszego serwisu" + "Uri".
FactorySettings
Dodatkowo naszą fabryką można troszkę posterować z poziomu kodu. Wystaczy do metody Create podstawić obiekt ServiceFactorySettings:
var settings = new ServiceFactorySettings();
settings.Credientals = new NetworkCredential("a", "b");
settings.Serializer = new XmlDefaultSerializer();
settings.ServiceUri = new System.Uri("https://test.pl/api/test/{action}");
ITestService srv = ServiceFactory.Create<ITestService>(settings);
Z powyższego wynika, że można dać własne Credientials, programowo podstawić Uri oraz inny Serializer, który zresztą ma bardzo banalny interface:
public interface ISerializer
{
object Deserialize(string value, Type returnType); //Deserializuje odebrane dane
string Serialize(object value); //Serializuje dane do wysłania
Encoding Encoding { get; } // Zwraca w jaki sposób dane mają być kodowane
string ContentType { get; } // Zwraca typ kontentu, np.: application/json
}
Obecna implementacja jaką do tej pory stworzyłem posiada dwa wbudowane serializery:
- JsonDefaultSerializer - korzysta z JavaScriptSerializer
- XmlDefaultSerializer - korzysta z XmlSerializer (ten jeszcze nie działa po stronie serwera :))
FormsAuthentication
Cały mechanizm pozwala na bardzo łatwe zbudowanie autentykacji w oparciu o FormsAuthentication. Kod klienta:
var srv = ServiceFactory.Create<ITestService>();
bool result = srv.Login(new LoginFormMessage() { Username = "a", Password = "b" });
Kod akcji kontrolera z użyciem wbudowanej metody CreateFormsAuthenticationSession
:
public bool Login(LoginFormMessage form)
{
// walidacja użytkownika
// powiedzmy, że wszystko jest ok
this.CreateFormsAuthenticationSession();
return true;
}
Wywołanie innej metody na tej samej instancji serwisu po stronie klienta:
bool result2 = srv.AuthorizedTest();
wykona poprawnie poniższą (w sumie bardzo standardową) akcję kontrolera, która wymaga autoryzacji:
[Authorize]
[AcceptVerbs(HttpVerbs.Post)]
public bool AuthorizedTest()
{
return true;
}
Akcje - REST
Domyślnie klient wykonuje akcje POST'em, ale możemy go oczywiście odpowiednio wysterować opisując metody interfejsu następującymi atrybutami:
- ExecuteName("test2") - podmienia nazwę akcji, zamiast domyślnej akcji będącej notabene nazwą metody wykona akcję test2
- ExecutePost - http POST
- ExecuteGet - http GET (w tym przypadku argumenty metody muszą być typami prostymi, gdyż zostaną przesłane jako QueryString)
- ExecutePut - http PUT
- ExecuteDelete - http DELETE
Przykład:
public interface ITestService : IControllerService
{
[ExecuteAction("test2")]
bool Test();
[ExecutePut]
[ExecuteAction("testvoid")]
void TestVoidPut(string value);
[ExecuteDelete]
[ExecuteAction("testvoid")]
void TestVoidDelete(string value);
}
Troszkę techniki
ControllerServices (nazwa kodowa) po stronie klienta swoje działanie opiera o Castle.DynamicProxy. Po stronie serwera (kontrolera) to tylko zabawa w podmianę domyślnego zachowania. Ogólne założenie jest takie, że serwis odpowiada takim typem kontentu o jaki został zapytany, czyli jeśli wysyłamy do serwisu zapytanie typu "application/json" to serwis odpowiada takim samym. Jeśli wysyłamy "text/xml" to serwis odpowiada "text/xml", itp, itd. Jeśli chcemy wysyłać własny format to piszemy własny Serializer. Podstawiamy do fabryki klienta oraz rejestrujemy w ActionInvokerze po stronie kontrolera. PS
Zwróćcie także uwagę na atrybut CompressFilter klasy TestServiceController. Nasz Json przelatuje z serwisu do klienta skompresowany Gzip'em. :) Oznacza to ni mniej ni więcej, że nasz "Webservice" działa jak najzwyczajniejszy kontroler aplikacji ASP.NET MVC. :)
Teraz to już naprawdę koniec ;)
Wydaje mi się, że "prostota" rozwiązania została osiągnięta. Dajcie znać jak to widzicie. Jeśli macie jakieś uwagi, pomysły to z chęcią posłucham/przeczytam :) Kod źródłowy wrzucę na Codeplex niebawem, ale do tego czasu przydała by się jakaś fajniejsza nazwa dla projektu. ;) Kod źródłowy można pobrać z codeplex.