Thursday, May 24, 2007

Klient WebServices "niżej" niż SoapHttpClientProtocol

Opis problemu 

Środowisko .NET upraszcza tworzenie kodu klienckiego usługi WebServices tak bardzo, że już chyba "bardziej się nie da".

Automatycznie budowana klasa opakowująca usługę, dziedzicząca z SoapHttpClientProtocol zawiera wywołania wszystkich publicznych metod usługi, dzięki czemu ich wywołanie po stronie klienta sprowadza się do wywołania metod klasy pośredniczącej.

Klientem usługi WebServices może być jednak przecież nie tylko kod .NETowy, zaś z poziomu .NET zachodzi czasem konieczność skorzystania z usług WebService przygotowanych w takich technologiach, dla których z jakiegoś powodu automatyczne generowanie klasy opakowującej na podstawie specyfikacji WSDL nie działa poprawnie.

Pokażę więc jak napisać kod kliencki usługi WebServices na niższym poziomie niż klient klasy opakowującej wygenerowanej ze specyfikacji WSDL. Mając do dyspozycji klasę HttpWebRequest zobaczymy jak takie żądania formułować z poziomu kodu .NET, na wypadek gdyby kod kliencki powstawał poza .NET, pokażę również jak korzystać z WebServices mając do dyspozycji wyłącznie gniazda TCP (użyję co prawda TcpClient, ale przeniesienie kodu do dowolnej technologii udostępniającej interfejs gniazd powinno być bezproblemowe).

Usługa WebService

Zacznijmy od prostej usługi WebService:

[WebService( Namespace="TheNamespace" )]
public class TestService : System.Web.Services.WebService
{
  ...
  [WebMethod()]
  public string Reverse( string String )
  {
    char[] chars = String.ToCharArray();
    Array.Reverse( chars );
    return new string( chars );
  }
}

Po opublikowaniu usługi i obejrzeniu jej specyfikacji WSDL dowiem się, że żądania można kierować albo za pomocą protokołu SOAP:

POST /WebService1/testservice.asmx HTTP/1.1
Host: localhost
Content-Type: application/soap+xml; charset=utf-8
Content-Length: length

<?xml version="1.0" encoding="utf-8"?>
<soap12:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap12="http://www.w3.org/2003/05/soap-envelope">
<soap12:Body>
<Reverse xmlns="TheNamespace">
<String>string</String>
</Reverse>
</soap12:Body>
</soap12:Envelope>
HTTP/1.1 200 OK
Content-Type: application/soap+xml; charset=utf-8
Content-Length: length

<?xml version="1.0" encoding="utf-8"?>
<soap12:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap12="http://www.w3.org/2003/05/soap-envelope">
<soap12:Body>
<ReverseResponse xmlns="TheNamespace">
<ReverseResult>string</ReverseResult>
</ReverseResponse>
</soap12:Body>
</soap12:Envelope>

albo HTTP POST:

POST /WebService1/testservice.asmx/Reverse HTTP/1.1
Host: localhost
Content-Type: application/x-www-form-urlencoded
Content-Length: length

String=string 
HTTP/1.1 200 OK
Content-Type: text/xml; charset=utf-8
Content-Length: length

<?xml version="1.0" encoding="utf-8"?>
<?string xmlns="TheNamespace">string</string> 

W tym prostym przypadku decyzja o tym, której wersji protokołu komunikacyjnego użyć jest w zasadzie nieistotna (jak widać i tak jest to HTTP POST w obu wypadkach) i sprowadza się do formatu zapytania i odpowiedzi. W bardziej skomplikowanych scenariuszach żądanie SOAP może okazać się jedyną możliwością skorzystania z elementów, które tylko ten protokół udostępnia (np. dodawania nagłówków wiadomości).


Klient korzystający z HttpWebRequest


HTTP POST

Zgodnie ze specyfikacją żądania HTTP POST naszym zadaniem jest ustalenie wartości parametru String, pozostałymi parametrami zajmie się sam HttpWebRequest. Należy jedynie zwrócić uwagę na linijki 4 i 5, gdzie ustalamy rodzaj żądania HTTP i typ zapytania.



   1: HttpWebRequest r = 
   2:   (HttpWebRequest)HttpWebRequest.Create( 
   3:     "http://localhost/WebService1/testservice.asmx/Reverse" );
   4: r.Method      = "POST";
   5: r.ContentType = "application/x-www-form-urlencoded";
   6:  
   7: StreamWriter ws = new StreamWriter( r.GetRequestStream() );
   8: ws.Write( "String=Ala ma kota" );
   9: ws.Close();
  10:  
  11: HttpWebResponse resp = (HttpWebResponse)r.GetResponse();
  12: StreamReader sr = 
  13:   new StreamReader( resp.GetResponseStream() );
  14:  
  15: MessageBox.Show( sr.ReadToEnd() );

Jak widać, żądanie składa się z dwóch części. W linijkach 7-9 formułowane jest zapytanie do serwera, w którym ustalona jest wartość parametru, w linijkach 11-13 odczytywana jest odpowiedź.


SOAP 1.2

W przypadku SOAP 1.2 zmienia się adres i typ i oczywiście treść zapytania (musi być to zapytanie zgodne ze specyfikacją zapytania udostępnianą przez usługę), sama komunikacja wygląda natomiast podobnie:



string xml =
"<?xml version=\"1.0\" encoding=\"utf-8\"?>" +
"<soap12:Envelope xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" xmlns:soap12=\"http://www.w3.org/2003/05/soap-envelope\">" +
"  <soap12:Body>" +
"    <Reverse xmlns=\"TheNamespace\" >" +
"      <String>Ala ma kota</String>" +
"    </Reverse>" +
"  </soap12:Body>" +
"</soap12:Envelope>";
 
HttpWebRequest r = 
  (HttpWebRequest)HttpWebRequest.Create( 
     "http://localhost/WebService1/testservice.asmx" );
r.Method = "POST";
r.ContentType = "application/soap+xml"; //application/soap+xml; charset=utf-8
r.ContentLength = xml.Length;
 
StreamWriter ws = new StreamWriter( r.GetRequestStream() );
ws.Write( xml );
ws.Close();
 
HttpWebResponse resp = (HttpWebResponse)r.GetResponse();
StreamReader sr = new StreamReader( resp.GetResponseStream() );
 
MessageBox.Show( sr.ReadToEnd() );

Klient korzystający z TcpClient


HTTP POST

W wypadku komunikacji na poziomie gniazd, zabawy jest już zdecydowanie więcej. Przede wszystkim cała komunikacja z serwerem musi być zgodna ze specyfikacją protokołu HTTP, muszę więc pamiętać o bezwzględnym ustaleniu na przykład wartości długości zapytania czy o poprawnym jego zakończeniu.



   1: string Request =
   2:     "POST /WebService1/testservice.asmx/Reverse HTTP/1.0\r\n" +
   3:     "Host: localhost\r\n" +
   4:     "Content-Type: application/x-www-form-urlencoded\r\n" +
   5:     "Content-Length: {0}\r\n\r\n";
   6: string Parameters = "String=Ala ma kota";
   7:  
   8:  
   9: TcpClient Client = new TcpClient( "localhost", 80 );
  10:  
  11: Stream TheStream = Client.GetStream();
  12:  
  13: StreamWriter sw = 
  14:   new StreamWriter( TheStream, Encoding.Default );
  15:  
  16: Request = string.Format( Request, Parameters.Length );
  17:  
  18: string Message = Request + Parameters + "\r\n\r\n";
  19:  
  20: sw.Write( Message );
  21: sw.Flush();            
  22:  
  23: StreamReader sr = new StreamReader( TheStream );
  24:  
  25: MessageBox.Show( sr.ReadToEnd() );

Jak widać wartość pola Content-Length jest wyznaczona na podstawie rzeczywistej długości parametrów żądania HTTP (linia 16), zapytanie jest najpierw wysłane do serwera (linia 20 i 21), a dopiero potem odczytana jest odpowiedź.


SOAP 1.2

Scenariusz podobny jak powyżej, zmienia się tylko parametr zapytania HTTP i format odpowiedzi:



   1: string Request =
   2:      "POST /WebService1/testservice.asmx HTTP/1.0\r\n" +
   3:      "Host: localhost\r\n" +
   4:      "Content-Type: application/soap+xml\r\n" +
   5:      "Content-Length: {0}\r\n\r\n";
   6:  string Parameters =
   7:  "<?xml version=\"1.0\" encoding=\"utf-8\"?>" +
   8:  "<soap12:Envelope xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" xmlns:soap12=\"http://www.w3.org/2003/05/soap-envelope\">" +
   9:  "  <soap12:Body>" +
  10:  "    <Reverse xmlns=\"TheNamespace\" >" +
  11:  "      <String>Ala ma kota</String>" +
  12:  "    </Reverse>" +
  13:  "  </soap12:Body>" +
  14:  "</soap12:Envelope>";
  15:  
  16:  TcpClient Client = new TcpClient( "localhost", 80 );
  17:  
  18:  Stream TheStream = Client.GetStream();
  19:  
  20:  StreamWriter sw = new StreamWriter( TheStream );
  21:  
  22:  
  23:  Request = string.Format( Request, Parameters.Length );
  24:  
  25:  string Message = Request + Parameters + "\r\n\r\n";
  26:  
  27:  sw.Write( Message );
  28:  sw.Flush();
  29:  
  30:  StreamReader sr = new StreamReader( TheStream );
  31:  
  32:  MessageBox.Show( sr.ReadToEnd() );

 


Chciałbym zwrócić uwagę na pewną ciekawostkę - otóż mimo, że opis usługi wyraźnie sugeruje, żeby w nagłówku zapytania umieścić informację HTTP 1.1, to w przykładach powyżej umieściłem HTTP 1.0. Dlaczego tak jest?


Proponuję przeprowadzić test powyższego kodu z informacją o wersji 1.1 w nagłówku i zwrócić uwagę na pojawiające się kłopoty. Na pytanie jak sobie z nimi poradzić odpowiedzi już mi się szukać nie chciało :)


Podsumowanie


Klientem usługi WebService nie musi koniecznie być klasa opakowująca usługę, automatycznie utworzona na podstawie jej specyfikacji WSDL. Można z usługami WebService komunikować się na "niższym" poziomie, dysponując nawet fundamentem w postaci socket, connect, send i recv.