Sr-01-lab-1.0

From Studia Informatyczne

Spis treści

Zdalne wywołanie procedur Sun RPC

Wprowadzenie

Mechanizm zdalnego wywołania procedur (ang. remote procedure call) umożliwia wywoływanie procedur udostępnianych przez zdalne serwery w sposób zapewniający dużą przezroczystość wywołań w stosunku do wywołań procedur lokalnych. Koncepcja wywołań zdalnych została zrealizowana w wielu różnych środowiskach programowych. W ramach ćwiczeń zapoznamy się z konkretną realizacją: Sun RPC. Sun RPC jest zestawem bibliotek programowych i narzędzi wspomagających umożliwiających tworzenie aplikacji rozproszonych w języku C.

Rysunek pokazuje warstwy oprogramowania zaangażowane w realizację zdalnego wywołania procedury. Transparentność (przezroczystość) wywołania zdalnego zapewniają pieńki (ang. stubs) po stronie klienta i serwera. Pieniek klienta dostarcza lokalnie interfejsu zdalnej procedury. Po wywołaniu procedury w aplikacji klienta następuje przygotowanie komunikatu zawierającego kod wywołania procedury z argumentami i wysłanie go do serwera. Pieniek serwera odbiera komunikat, odpakowuje argumenty i wywołuje lokalną procedurę zaimplementowaną w aplikacji serwera. Wynik wywołania procedury jest następnie pakowany do nowego komunikatu i przesyłany do klienta. Pieniek klienta odbiera komunikat i przekazuje wyniki do aplikacji. Kod pieńków jest generowany automatycznie, co umożliwia programiście skoncentrowanie się na właściwej funkcjonalności aplikacji.

Warstwy oprogramowania realizujące zdalne wywołanie procedury RPC
Warstwy oprogramowania realizujące zdalne wywołanie procedury RPC

Procedury Sun RPC identyfikowane są po numerach. W typowych usługach sieciowych (np. WWW czy FTP), identyfikacja usługi następuje poprzez wskazanie dwóch wartości: adresu IP serwera i numeru portu (TCP lub UDP). W przypadku usługi RPC zdalna procedura identyfikowana jest czwórką wartości: adres IP serwera, numer usługi RPC, numer wersji usługi oraz numer procedury w ramach usługi. Oczywiście w systemie rozproszonym komunikacja ostatecznie musi sprowadzić się do zastosowania protokołów warstwy transportowej, co oznacza, że aplikacja klienta musi mieć możliwość pozyskania w jakiś sposób numerów portów do komunikacji z serwerem. Funkcjonalność tą zapewnia dodatkowa usługa portmapper (implementowana jako program o nazwie portmap), która pracuje na ogólnie znanym numerze portu (111). Zadaniem usługi jest odwzorowywanie numerów usług RPC na numery portów i nazwy protokołów transportowych poprzez które można się dostać do serwera zdalnej procedury. Wywołanie zdalnej procedury realizowane jest więc zgodnie ze schematem przedstawionym na rys.. Aplikacja klienta (a dokładnie pieniek) wysyła zapytanie do usługi portmapper na port 111 pytając o usługę RPC o konkretnym numerze i wersji (1). Uzyskuje informacje o protokołach transportowych i numerach portów, poprzez które odpowiedni serwer RPC jest osiągalny (2). Następny komunikat zawiera już właściwe zlecenie wywołania zdalnej procedury i jest kierowany do właściwej aplikacji-serwera (3). Serwer RPC po wykonaniu zdalnej procedury odsyła wyniki (4). W kolejnych dowołaniach procedur można pominąć etap odpytywania usługi portmapper, zakładając, że konfiguracja serwera RPC nie zmieniła się.

Schemat wywołania zdalnej procedury w Sun RPC
Schemat wywołania zdalnej procedury w Sun RPC

Szerszy opis RPC można znaleźć w [GD95]. Dokładna specyfikacja znajduje się w RFC 1050 [RFC1050].

Proste wywołanie zdalnej procedury

Wywołanie zdalnej procedury może być realizowane na różnych poziomach szczegółowości. Najwyższy poziom zapewnia maksimum przezroczystości i faktycznie nie różni się od wywołania lokalnej procedury. Zejście niżej ujawnia pewne szczegóły realizacji zdalnego wywołania, dając jednakże możliwość zoptymalizowania jego przebiegu. Najniższy poziom pozwala na uzyskanie niestandardowych schematów realizacji zdalnego wywołania. Kosztem jest tu jednakże konieczność zapoznania się ze szczegółami implementacyjnymi pieńków oraz ich ręczna modyfikacja.

Na najwyższym poziomie wywołanie zdalnej procedury RPC można zrealizować z wykorzystaniem funkcji:

callrpc(char* hostname, u_long prognum,
        u_long versnum, u_long procnum,
        xdrproc_t inproc, char* in, 
        xdrproc_t outproc, char* out)

Przykładowy klient usługi rusers został przedstawiony w przykładzie.

  1: #include <stdio.h>
  2: #include <rpc/rpc.h>
  3: #include <rpcsvc/rusers.h>
  4: 
  5: int main(int argc, char **argv)
  6: {
  7:   long nusers;
  8:   enum clnt_stat stat;
  9: 
 10:   if (argc <= 1)
 11:   {
 12:     printf("Wywolanie:  %s <serwer>\n", argv[0]);
 13:     exit(1);
 14:   }
 15: 
 16:   stat=callrpc(argv[1], RUSERSPROG, RUSERSVERS_3, RUSERSPROC_NUM,
 17:                xdr_void, NULL, xdr_u_long, &nusers);
 18:   if(stat != RPC_SUCCESS)
 19:   {
 20:     clnt_perrno(stat);
 21:     printf("\n");
 22:     exit(1);
 23:   }
 24:   printf("W systemie %s pracuje %d użytkowników.\n", 
 25:          argv[1], nusers);
 26: }

Kompilację programu można przeprowadzić następującym wywołaniem:

# gcc -o rusers rusers.c
rusers.c: In function `main':
rusers.c:19: warning: passing arg 5 of `callrpc' from incompatible pointer type
rusers.c:19: warning: passing arg 7 of `callrpc' from incompatible pointer type
rusers.c:19: warning: passing arg 8 of `callrpc' from incompatible pointer type

Kompilacja kończy się ostrzeżeniami związanymi z niezgodnością typów danych niektórych argumentów. Dla uproszczenia zapisu przykładowego programu zrezygnowano bowiem z rzutowania argumentów do wymaganych przez funkcję callrpc() argumentów. Powstały program rusers można wykonać podając jako argument nazwę serwera:

# rusers localhost
RPC: Program not registered

Zgłoszony błąd wynika z nieobecności serwera usługi rusers. Każdy program RPC przed właściwym wywołaniem metody zdalnej musi bowiem dokonać odwzorowania numeru usługi RPC na numer portu w odpowiednim protokole transportowym. W powyższym przykładzie następuje odwołanie do usługi identyfikowanej jako RUSERSPROG. W pliku /usr/include/rpcsvc/rusers.h można znaleźć deklarację identyfikatora RUSERSPROG wskazującą na wartość 100002. Aktualnie zarejestrowane usługi można wyświetlić komendą rpcinfo:

# /usr/sbin/rpcinfo -p
   program vers proto   port
    100000    2   tcp    111  portmapper
    100000    2   udp    111  portmapper
    100007    2   udp    932  ypbind
    100007    1   udp    932  ypbind
    100007    2   tcp    935  ypbind
    100007    1   tcp    935  ypbind

W kolumnie program wyświetlany jest numer usługi RPC, kolumna vers opisuje wersję danej usługi, kolumna proto to nazwa protokołu transportowego, a kolumna port zawiera numer portu, na którym nasłuchuje odpowiedni serwer. W przykładowym systemie dostępne są więc dwie usługi: portmapper i ypbind. Serwer usługi rusers można uruchomić komendą:

# /usr/sbin/rpc.rusersd

której wykonanie powoduje uruchomienie w tle procesu o tej samej nazwie. Nowy serwer rejestruje swoją obecność w usłudze portmapper:

# /usr/sbin/rpcinfo -p
   program vers proto   port
    ...
    100002    3   udp  32769  rusersd
    100002    2   udp  32769  rusersd

Program rusers wyświetla teraz poprawnie liczbę procesów:

# rusers localhost
W systemie localhost pracuje 4 użytkowników.

Przykład aplikacji RPC

Poniższa kompletna aplikacja rkill ma za zadanie umożliwić zatrzymywanie zdalnych procesów poprzez wysłanie do nich sygnału KILL.

Definicja usługi

Definicja jest zapisana w pliku rkill.x:

program RKILL_PRG {
  version RKILL_VERSION_1 {
    int rkill(int pid) = 1;
  } = 1;
} = 0x21000001;

W przykładzie zdefiniowano usługę RKILL_PRG o numerze 0x21000001 (dziesiętnie: 553648129), która występuje w jednej wersji (identyfikowanej jako RKILL_VERSION_1). Usługa definiuje jedną funkcję o następującym nagłówku:

int rkill(int pid);

Wymagany jest więc jeden argument będący identyfikatorem zatrzymywanego procesu. Funkcja zwraca status zakończenia operacji wysłania sygnału.

Generator kodu rpcgen

Na podstawie przygotowanego pliku definicyjnego można automatycznie wygenerować pliki źródłowe przykładowej aplikacji:

# rpcgen -a -N rkill.x
# ls
Makefile.rkill  rkill.x         rkill_clnt.c    rkill_svc.c
rkill.h         rkill_client.c  rkill_server.c

Powstałe pliki reprezentują odpowiednio:

rkill.h 
plik nagłówkowy z definicjami poszczególnych identyfikatorów,
rkill_client.c 
szkielet aplikacji klienckiej,
rkill_server.c 
szkielet serwera,
rkill_clnt.c 
pieniek klienta,
rkill_svc.c 
pieniek serwera,
Makefile.rkill 
plik Makefile zarządzający kompilacją projektu.

Kompilację najprościej jest przeprowadzić z wykorzystaniem programu make (Zadaniem programu make jest przeprowadzenie wszystkich niezbędnych kompilacji cząstkowych projektu w odpowiedniej kolejności z zachowaniem zależności pomiędzy plikami źródłowymi.):

# make -f Makefile.rkill
cc -g  -c -o rkill_clnt.o rkill_clnt.c
cc -g  -c -o rkill_client.o rkill_client.c
cc -g  -o rkill_client  rkill_clnt.o rkill_client.o -lnsl
cc -g  -c -o rkill_svc.o rkill_svc.c
cc -g  -c -o rkill_server.o rkill_server.c
cc -g  -o rkill_server  rkill_svc.o rkill_server.o -lnsl

W wyniku kompilacji powinny powstać m.in. dwa programy: rkill_client i rkill_server. Pozwala to na testowe uruchomienie serwera i klienta (np. w różnych oknach środowiska graficznego). Domyślna implementacja serwera nie powoduje jednak wykonania żadnego przetwarzania, stąd trudno zauważyć efekty pracy programu. Obserwację działania serwera umożliwi modyfikacja kodu serwera zawarta w pliku rkill_server.c:

  1: #include "rkill.h"
  2: 
  3: int *
  4: rkill_1_svc(int pid,  struct svc_req *rqstp)
  5: {
  6: 	static int  result;
  7: 
  8:   printf("Zdalna procedura\n");
  9: 
 10: 	return &result;
 11: }

Dodanie linii 8 powoduje wyświetlanie komunikatu na ekranie przy każdorazowym wywołaniu zdalnej procedury. Dalsza modyfikacja kodu serwera umożliwia realizację powierzonego mu zadania:

result = kill(pid, 9);

Linia ta powinna zostać dopisana na pozycji 9. Argument pid będzie dostarczony przez bibliotekę RPC, a wartość zwrotna funkcji kill() powinna zostać zapisana do statycznej zmiennej result, w celu późniejszego przesłania jej do klienta.

Implementacja kodu klienta wymaga wprowadzenia większej ilości zmian. Przykład przedstawia zmodyfikowaną wersję klienta:

  1: #include "rkill.h"
  2: 
  3: 
  4: int
  5: rkill_prg_1(char *host, int pid)
  6: {
  7: 	CLIENT *clnt;
  8: 	int  *result_1;
  9: 	int rkill_1_pid;
 10: 
 11: #ifndef	DEBUG
 12: 	clnt = clnt_create (host, RKILL_PRG, RKILL_VERSION_1, "udp");
 13: 	if (clnt == NULL) {
 14: 		clnt_pcreateerror (host);
 15: 		exit (1);
 16: 	}
 17: #endif	/* DEBUG */
 18: 
 19: 	result_1 = rkill_1(pid, clnt);
 20: 	if (result_1 == (int *) NULL) {
 21: 		clnt_perror (clnt, "call failed");
 22: 	}
 23: #ifndef	DEBUG
 24: 	clnt_destroy (clnt);
 25: #endif	 /* DEBUG */
 26: }
 27: 
 28: 
 29: int
 30: main (int argc, char *argv[])
 31: {
 32: 	char *host;
 33: 
 34: 	if (argc < 3) {
 35: 		printf ("usage: %s server_host\n", argv[0]);
 36: 		exit (1);
 37: 	}
 38: 	host = argv[1];
 39: 	rkill_prg_1 (host, atoi(argv[2]));
 40:   exit (0);
 41: }

W linii 39 dodano odwołanie do drugiego argumentu wywołania programu wraz z konwersją do liczy dziesiętnej (funkcja atoi()). Dodatkowy argument funkcji rkill_prg_1() pojawił się również w nagłówku tej funkcji (linia 5). Identyfikator procesu przekazywany jest dalej do pieńka klienta w linii 19. Po każdorazowej zmianie kodu należy oczywiście wykonać ponownie komendę make. W obecnej postaci możliwe jest więc zatrzymanie dowolnego procesu na komputerze z uruchomionym serwerem rkill:

# rkill_client unixlab 1845

gdzie unixlab jest przykładową nazwą docelowego serwera.

Dalsze modyfikacje kodu aplikacji klienta powinny umożliwić uzyskanie informacji o przebiegu wykonania wysłania sygnału. Należy w tym celu odwołać się do wartości zwracanej przez funkcję rkill_1 będącą implementacją pieńka klienta. Wartość ta jest wskaźnikiem na liczbę, która przechowuje zwróconą przez serwer liczbę.

Dodatkowy argument funkcji rkill()

Jeżeli w wyniku zmiany specyfikacji wymagań należy dodać nowy argument wywołania funkcji rkill(), to możliwe są dwa rozwiązania: ręczne korygowanie odpowiednich pieńków i kodów klienta/serwera (niezalecane), lub ponowne wygenerowanie kodu aplikacji (zalecane). Zmodyfikowane wcześniej fragmenty kodu trzeba jednak w drugim przypadku ręcznie wprowadzić do nowej wersji aplikacji. Jako ćwiczenie należy więc zaimplementować usługę o następującym interfejsie:

program RKILL_PRG {
  version RKILL_VERSION_1 {
    int rkill(int pid, int sig) = 1;
  } = 1;
} = 0x21000001;
Protokół transportowy

Kod programu klienta z przykładu zawiera w linii 18 wywołanie funkcji clnt_create() tworzącej uchwyt komunikacyjny dla warstwy RPC. Parametrem tej funkcji jest nazwa protokołu transportowego. Zmiana domyślnego protokołu UDP na TCP nie spowoduje zmiany funkcjonalnej aplikacji, co pokazuje niezależność mechanizmu RPC od warstwy transportowej.

Plik Makefile

Nazwę standardowo generowanego przez rpcgen pliku Makefile.rkill warto zmienić na Makefile aby nie używać przełącznika -f komendy make. Zawartość tego pliku również warto zmienić, ponieważ znajduje się tam wywołanie komendy rpcgen powodujące ponowne tworzenie kodu źródłowego po stwierdzeniu modyfikacji specyfikacji w pliku rkill.x. Należy w tym celu usunąć następujące 2 linie:

$(TARGETS) : $(SOURCES.x)
    rpcgen $(RPCGENFLAGS) $(SOURCES.x)

Drugą zalecaną modyfikacją pliku Makefile jest modyfikacja kodu dla operacji clean. Standardowo powoduje ona usunięcie kodu źródłowego w języku C! Należy usunąć odwołanie do zmiennej TARGETS, uzyskując następujące linie:

clean:
    $(RM) core $(OBJECTS_CLNT) $(OBJECTS_SVC) $(CLIENT) $(SERVER)

Ostatnia zmiana ma na celu poprawę jakości tworzonego kodu. Zmienna CFLAGS ustawiana w pliku Makefile umożliwia przesłanie dodatkowych opcji dla kompilatora. Warto włączyć w tym miejscu wypisywanie wszystkich ostrzeżeń:

CFLAGS += -g -Wall

i doprowadzić kod aplikacji do stanu, w którym kompilacja będzie przebiegała bez żadnych ostrzeżeń.

Zadania

  1. Przetestuj działanie klienta usługi rusers z przykładu.
  2. Stwórz aplikację rkill korzystając z generatora kodu rpcgen. Aplikacja klienta powinna być wywoływana z 2 argumentami: nazwą komputera docelowego i identyfikatorem procesu. Serwer powinien wysyłać sygnał SIGINT do wskazanego procesu i zwracać wynik z funkcji kill(). Wynik wykonania zdalnej procedury powinien zostać przedstawiony po stronie klienta.
  3. Stwórz rozszerzoną aplikację rkill udostępniającą dwuargumentową funkcję rkill() umożliwiającą wysłanie dowolnego sygnału do dowolnego procesu.
  4. Przetestuj działanie aplikacji dla protokołów transportowych UDP i TCP.