Xây dựng thư viện gọi hàm từ xa (RPC) với tcp socket và C#

0

Gọi hàm từ xa là một cơ chế rất phổ biến để thực hiện truyền thông liên tiến trình, giúp xây dựng các ứng dụng phân tán. Trong loạt bài này chúng ta sẽ cùng xây dựng một thư viện RPC (Remote Procedure Call) đơn giản vận dụng lập trình socket Tcp trong C#. Thư viện này có thể giúp xây dựng những ứng dụng client/server nhỏ một cách tiện lợi.

Đây là bài thứ nhất của loạt bài về xây dựng thư viện RPC đơn giản với tcp socket:

Loạt bài Xây dựng thư viện RPC đơn giản:
Phần 1 – Xây dựng thư viện RPC
Phần 2 – Ví dụ minh họa

Gọi hàm từ xa – RPC (Remote Procedure Call)

Giới thiệu chung về RPC

Gọi hàm từ xa (RPC) là việc một chương trình gọi một phương thức/hàm/thủ tục/chương trình con (gọi chung là procedure) thực thi trong một không gian địa chỉ khác nhưng che đi các chi tiết về mặt trao đổi thông tin khiến cho bên gọi cảm giác như đang dùng một procedure cục bộ.

RPC là một dạng của truyền thông liên tiến trình (Inter-Process Communications, IPC), trong đó các tiến trình có không gian địa chỉ khác nhau. Các tiến trình thường sử dụng dịch vụ truyền thông mạng để trao đổi thông tin.

Trong lập trình hướng đối tượng, RPC cũng được gọi là gọi phương thức từ xa (Remote Method Invocation, RMI).

Điểm đặc biệt của RPC (hay RMI) là client code gọi các phương thức và nhận kết quả nhưng không biết được là phương thức đó thực tế hoạt động trên một tiến trình khác và (thường) ở trên một thiết bị khác. Nói cách khác, đối với client code, lời gọi RPC không có gì khác biệt gọi các phương thức local. RPC đã che hết các vấn đề về truyền thông của IPC.

Trên nền tảng .NET có thể sử dụng công nghệ .NET Remoting cho RPC. Tuy nhiên công nghệ này đã cũ và được thay thế bằng WCF (Windows Communications Foundation).

Các thành phần chính của một thư viện RPC

Client stub là thành phần giúp đóng gói tham số từ lời gọi hàm của client code vào gói tin truy vấn. Việc đóng gói các tham số được gọi là marshalling. Thành phần này cũng chịu trách nhiệm giải nén gói tin phản hồi từ server về dạng kết quả mà client code chờ đợi. Việc mở gói các tham số được gọi là unmarshalling. Nhiệm vụ khác của client stub là gọi tới dịch vụ truyền thông để gửi truy vấn đi và nhận phản hồi từ server.

Server stub, tương tự client stub, chịu trách nhiệm cho marshalling/unmarshalling và nhận truy vấn/ gửi phản hồi ở bên phía server.

Thành phần truyền thông là thực thi của một loại giao thức (tầng ứng dụng) nào đó chịu trách nhiệm chuyển đi và nhận về các gói tin. Rất nhiều thư viện RPC sử dụng giao thức HTTP (như WCF). Cũng có thể tự thiết kế và thực thi giao thức ứng dụng riêng. Hầu hết các giao thức này đều sử dụng dịch vụ giao vận TCP của hệ thống.

Khi ứng dụng RPC vào bài toán cụ thể cần lập ra các thỏa thuận về hoạt động và dữ liệu giữa client và server. Các thỏa thuận này được xây dựng dưới dạng interface mà client và server phải cùng thực thi. Tuy nhiên, client và server sẽ thực thi nó theo những cách khác nhau.

Mô hình hoạt động của thư viện RPC

RPC thường được thực thi theo mô hình client/server. Trong đó, client là bên gọi, server là bên thực thi. Bên gọi sẽ phát đi thông điệp truy vấn (request). Bên nhận sau khi hoàn thành công việc sẽ trả lại một thông điệp phản hồi (response/reply). Mô hình trao đổi thông điệp như vậy cũng thường được gọi là truy vấn / phản hồi.

Tương tác client/server và mô hình request/response trong RPC
Tương tác client/server và mô hình request/response trong RPC

Chuỗi sự kiện trong hoạt động của mô hình này như sau:

  1. Chương trình client gọi client stub: đây là một lời gọi cục bộ với các tham số được đẩy vào stack như bình thường thường.
  2. Client stub đóng gói tham số vào thông điệp truy vấn và gọi tới dịch vụ truyền thông để gửi gói tin này đi.
  3. Dịch vụ mạng trên hệ điều hành của máy khách sẽ gửi truy vấn đến máy chủ.
  4. Dịch vụ mạng trên hệ điều hành của máy chủ chuyển truy vấn đến cho server stub.
  5. Chương trình máy chủ mở gói tin truy vấn để lấy các tham số.
  6. Server stub gọi tới phương thức tương ứng yêu cầu của client.
  7. Phản hồi của server về client diễn ra tương tự nhưng theo chiều ngược lại.

Xây dựng thư viện RPC đơn giản

Sau đây chúng ta sẽ cùng xây dựng một bộ thư viện hàm dành cho RPC đơn giản.

Trước hết tạo một solution trống. Sau đó tạo một thư viện hàm (Class Library .NET framework) đặt tên Rpc. Tạo cấu trúc thư mục và file mã nguồn cho project Rpc như sau:

Cấu trúc dự án thư viện hàm RPC
Cấu trúc dự án thư viện hàm RPC

Lưu ý rằng khi xây dựng thư viện này chúng ta phải vận dụng các kỹ thuật lập trình với Tcp socket. Các bạn có thể tìm hiểu chi tiết qua tập bài giảng Tự học lập trình Tcp/Ip socket với C#.

Messages – thỏa thuận cấu trúc thông tin truyền giữa client và server

RPC bao gồm thành phần client và server hoạt động trên các thiết bị đầu cuối khác nhau. Hai thành phần này sử dụng dịch vụ mạng để trao đổi thông tin. Đây là một phần của quá trình truyền thông liên tiến trình.

Dịch vụ mạng (cấp độ giao vận) chủ yếu cho việc truyền thông này là TCP. Để các thành phần truyền thông qua TCP, giữa chúng phải đưa ra các quy ước về cấu trúc thông tin cần trao đổi và một số vấn đề khác. Các quy ước này gọi là giao thức.

Ở đây chúng ta đưa ra một cấu trúc gói tin truy vấn và phản hồi đơn giản. Truy vấn chỉ chứa vừa đủ thông tin về class, tên phương thức và danh sách tham số cho phương thức thực thi.

using System;
using System.Collections.Generic;

namespace Rpc.Message
{
    [Serializable]
    public class Request
    {
        public string Class { get; set; }
        public string Method { get; set; }
        public Dictionary<string, object> Parameter { get; set; }
    }
}
using System;

namespace Rpc.Message
{
    [Serializable]
    public class Response
    {
        public bool Success { get; set; }
        public string Phrase { get; set; }
        public object Result { get; set; }
    }
}

Cả hai class này đều đánh dấu Serializable để có thể sử dụng BinaryFormatter cho quá trình chuyển đổi (serialization) sang luồng byte.

Thành phần server

Thành phần server bao gồm 3 class: ServerSocket, ServerStub, ServerProxy.

ServerSocket

ServerSocket hỗ trợ thực hiện các thao tác truyền thông cơ bản ở phía server qua socket TCP. Các thao tác này bao gồm khởi tạo socket tcp, tiếp nhận kết nối tcp từ client, gửi/nhận object qua socket, ngắt kết nối tcp.

using System.Net;
using System.Net.Sockets;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;

namespace Rpc.Server
{
    public class ServerSocket
    {
        private TcpListener _listener;
        private TcpClient _worker;
        private BinaryFormatter _formatter = new BinaryFormatter();
        private NetworkStream _stream;

        public TcpClient Worker => _worker;

        public ServerSocket(int port) => _listener = new TcpListener(IPAddress.Any, port);
        public void Start() => _listener.Start();
        public void Accept() { _worker = _listener.AcceptTcpClient(); _stream = _worker.GetStream(); }
        public void CloseConnection() => _worker.Close();
        public void Send(object messsage) => _formatter.Serialize(_stream, messsage);
        public object Receive() => _formatter.Deserialize(_stream);
    }
}

Việc gửi object thực hiện thông qua một luồng mạng (NetworkStream). Để đơn giản hóa việc gửi dữ liệu, chúng ta sử dụng BinaryFormatter để serialize (trình tự hóa) object thẳng vào luồng mạng mà không tạo cấu trúc gói tin riêng.

ServerProxy

ServerProxy chịu trách nhiệm tạo ra vòng lặp tiếp nhận và xử lý truy vấn ở server. ServerProxy sử dụng ServerSocket cho các thao tác truyền thông cơ bản.

using Rpc.Message;
using System;
using System.Text;
using System.Threading;

namespace Rpc.Server
{
    public class ServerProxy
    {
        private readonly int _port;
        private ServerSocket _server;

        public Func<Request, Response> Process { get; set; }
        public Action<string> Log { get; set; }

        public ServerProxy(int port)
        {
            _port = port;
            _server = new ServerSocket(_port);
        }

        public void Run()
        {
            _server.Start();
            Log?.Invoke($"[{DateTime.Now}] Server started. Listening ...");
            while (true)
            {
                _server.Accept();
                Log?.Invoke($"[{DateTime.Now}] Accepted connection from {_server.Worker.Client.RemoteEndPoint}");

                ThreadPool.QueueUserWorkItem((o) =>
                {
                    var request = _server.Receive() as Request;
                    Log?.Invoke($"[{DateTime.Now}] Received request: {request.GetString()}");

                    var response = Process?.Invoke(request);
                    _server.Send(response);
                    _server.CloseConnection();
                });
            }
        }
    }

    internal static class Extension
    {
        public static string GetString(this Request request)
        {
            var sb = new StringBuilder();
            foreach (var o in request.Parameter.Values)
                sb.Append($" {o} ");
            return $"{request.Class}.{request.Method}({sb.ToString()})";
        }
    }
}

ServerProxy sau khi tiếp nhận một kết nối sẽ chuyển sang xử lý trên một luồng (thread) riêng. Cơ chế này cho phép server có thể cùng lúc phục vụ nhiều client.

Phương thức cụ thể được thực thi cho mỗi request sẽ được quyết định sau thông qua generic delegate Process. Delegate này bắt buộc phương thức phải chấp nhận object Request làm tham số đầu vào và trả về object Response.

ServerStub

ServerStub là class chịu trách nhiệm xử lý truy vấn và trả về phản hồi. Class này chỉ có một phương thức Process, về sau sẽ được gán cho delegate Process của ServerProxy.

using System;
using System.Linq;
using System.Reflection;
using Rpc.Message;

namespace Rpc.Server
{
    public class ServerStub
    {
        private readonly string _namespace;
        private ServerStub(string @namespace) { _namespace = @namespace; }

        private static ServerStub _instance;
        public static ServerStub GetInstance(string @namespace) => _instance ?? (_instance = new ServerStub(@namespace));

        public Response Process(Request request)
        {
            try
            {
                var assembly = Assembly.GetEntryAssembly().GetName();
                var type = Type.GetType($"{_namespace}.{request.Class}, {assembly}");
                var constructor = type.GetConstructor(Type.EmptyTypes);
                var instance = constructor.Invoke(null);
                var method = type.GetMethod(request.Method);

                var parameters = method.GetParameters()
                    .Select(p => Convert.ChangeType(request.Parameter[p.Name], p.ParameterType))
                    .ToArray();

                var response = new Response
                {
                    Success = true,
                    Phrase = ""
                };

                if (method.ReturnType == typeof(void))
                {
                    method.Invoke(instance, parameters);
                    response.Result = null;
                }
                else
                    response.Result = method.Invoke(instance, parameters);
                return response;
            }
            catch (Exception e)
            {
                return new Response
                {
                    Success = false,
                    Phrase = e.Message,
                    Result = null
                };
            }
        }
    }
}

Để đảm bảo tính uyển chuyển cao nhất, ServerStub (cụ thể, phương thức Process) sử dụng kỹ thuật lập trình Reflection. Kỹ thuật này cho phép khởi tạo object và gọi bất kỳ phương thức nào thông qua chuỗi ký tự.

Hiểu một cách đơn giản, nếu cung cấp một chuỗi ký tự trùng với tên một class nào đó, reflection có thể khởi tạo object của class đó. Tương tự, nếu tiếp tục cung cấp một chuỗi ký tự trung tên phương thức của class, reflection cho phép gọi phương thức tương ứng.

Cơ chế này cho phép server khởi tạo object và gọi phương thức từ các thông tin lưu trong truy vấn của client.

Tất cả các class cần gọi này sẽ do người dùng tự định nghĩa về sau.

Thành phần client

Tương tự như server, thành phần client cũng chứa ba class: ClientSocket cho truyền thông cơ bản, ClientProxy để gửi request và nhận response, ClientStub hỗ trợ xây dựng lớp client cho từng nhiệm vụ cụ thể và các nhiệm vụ như đã phân tích trong phần giới thiệu.

ClientSocket

using System.Net;
using System.Net.Sockets;
using System.Runtime.Serialization.Formatters.Binary;

namespace Rpc.Client
{
    public class ClientSocket
    {
        private TcpClient _worker;
        private BinaryFormatter _formatter = new BinaryFormatter();

        public ClientSocket() => _worker = new TcpClient();
        public void Connect(IPAddress address, int port) => _worker.Connect(address, port);
        public void Send(object message) => _formatter.Serialize(_worker.GetStream(), message);
        public object Receive() => _formatter.Deserialize(_worker.GetStream());
        public void Close() => _worker.Close();
    }
}

ClientProxy

using Rpc.Message;
using System.Net;

namespace Rpc.Client
{
    public class ClientProxy
    {
        private IPAddress _address;
        private int _port;
        public ClientProxy(string ip, int port)
        {
            _address = IPAddress.Parse(ip);
            _port = port;
        }
        public Response Execute(Request request)
        {
            ClientSocket client;
            client = new ClientSocket();
            client.Connect(_address, _port);
            client.Send(request);
            var response = client.Receive() as Response;
            client.Close();
            return response;
        }
    }
}

ClientStub

using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using Rpc.Message;

namespace Rpc.Client
{
    public class ClientStub<U>
    {
        protected ClientProxy Proxy;
        protected Request Request;

        protected ClientStub(string ip, int port, string service)
        {
            Request = new Request
            {
                Class = service
            };
            Proxy = new ClientProxy(ip, port);
        }

        private Response GetResponse(string method, params object[] args)
        {
            Request.Method = method;
            Request.Parameter = Param(method, args);
            var response = Proxy.Execute(Request);
            if (!response.Success)
                throw new Exception(response.Phrase);
            else
                return response;
        }

        protected void Execute([CallerMemberName]string method = null, params object[] args)
        {
            GetResponse(method, args);
        }

        protected T Execute<T>([CallerMemberName]string method = null, params object[] args)
        {
            var response = GetResponse(method, args);
            return (T)response.Result;
        }

        protected Dictionary<string, object> Param(string method, params object[] args)
        {
            var dict = new Dictionary<string, object>();
            var @params = typeof(U).GetMethod(method).GetParameters();
            for (var i = 0; i < @params.Length; i++)
            {
                dict.Add(@params[i].Name, args[i]);
            }
            return dict;
        }
    }
}

Biên dịch project trên sẽ thu được bộ thư viện hàm RPC đơn giản.

Kết phần

Trong phần 1 này chúng ta đã áp dụng lập trình socket TCP trên .NET framework để xây dựng một bộ thư viện gọi hàm từ xa (RPC) đơn giản. Trong bài sau chúng ta sẽ cùng vận dụng thư viện này để xây dựng một vài ứng dụng client/server để minh họa.

Loạt bài Xây dựng thư viện RPC đơn giản:
Phần 1 – Xây dựng thư viện RPC
Phần 2 – Ví dụ minh họa

Bình luận

avatar
  Đăng ký theo dõi  
Thông báo về