1
\$\begingroup\$

I've rewritten my HTTP server several times over the past year, because I tend to encounter an issue midway through an implementation realizing I didn't abstract, decompose, or decouple certain methods properly. My primary goal with these rewrites is to slowly edge towards a modular and small, but still precise implementation of the HTTP protocol; with the vision of high vertical & horizontal scalability in mind.

So, to begin the implementation I create the abstract base class defining the generic protocol, with which the socket server is going to interface with when socket events occur:

class BaseProtocol(ABC):
    def __init__(self, server, sk_client: socket.socket, mqueue: queue.Queue):
        self.server = server
        self.trans = sk_client
        self._mqueue = mqueue

    @abstractmethod
    def on_new_connection(self, address: tuple[str, int]) -> None:
        pass

    @abstractmethod
    def on_data_received(self, data: bytes) -> None:
        pass

    @abstractmethod
    def on_connection_closed(self) -> None:
        pass

    def close_socket_connection(self) -> None:
        self.server._close_connection(self.trans)

    def send(self, data: Union[str, bytes]) -> None:
        if isinstance(data, str):
            data = data.encode()
        self._mqueue.put_nowait(data)

In terms of minutiae, I think the style of adding @staticmethod to abstract-methods may appear cleaner. Then the implementation of the AsyncSocketServer:

class AsyncSocketServer:
    RX_CHUNKSIZE = 1024

    def __init__(self, host: str, port: int, prot: BaseProtocol,
            *, backlog=15):
        self.host = host
        self.port = port
        self.prot = prot
        self.backlog = backlog

        self.socket = socket.socket()
        self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, True)
        self.socket.bind((host, port))
        self.socket.listen(backlog)
        self.socket.setblocking(False)

        self._socket_rwx = {
            "inputs": [self.socket],
            "outputs": []
        }
        self._socket_clients = {}
        self._socket_priority = []

    def _close_connection(self, sockfd: socket.socket,
            *, _bypass=False) -> None:
        prot = self._socket_clients[sockfd]
        if not _bypass and not prot._mqueue.empty():
            self._socket_priority.append({
                "socket": sockfd,
                "prot": prot,
                "callback": lambda: self._close_connection(
                    sockfd, _bypass=True
                )
            })
            return
        self._socket_rwx['inputs'].remove(sockfd)
        self._socket_rwx['outputs'].remove(sockfd)
        prot.on_connection_closed()
        del self._socket_clients[sockfd]
        sockfd.shutdown(socket.SHUT_RDWR)
        sockfd.close()

    def accept_clients(self) -> None:
        while self._socket_rwx['inputs']:
            while self._socket_priority:
                pr_sockinfo = self._socket_priority.pop(0)
                pr_sockinfo['socket'].send( # assume writeable
                    pr_sockinfo['prot']._mqueue.get_nowait()
                )
                pr_sockinfo['callback']()

            try:
                readable, writeable, exceptional = select.select(
                    self._socket_rwx['inputs'], self._socket_rwx['outputs'],
                    self._socket_rwx['inputs']
                )
            except KeyboardInterrupt:
                logging.info("caught keyboard interrupt, closing all clients")
                for sockfd in self._socket_clients:
                    self._close_connection(sockfd, _bypass=True)
                return

            for sk_exc in exceptional:
                self._close_connection(sk_exc)

            for sk_writeable in writeable:
                if (proto := self._socket_clients.get(sk_writeable)) is None:
                    continue
                if proto._mqueue.empty():
                    continue
                sk_writeable.send(proto._mqueue.get_nowait())

            for sk_readable in readable:
                if sk_readable is self.socket:
                    sk_client, cl_addr = self.socket.accept()
                    sk_client.setblocking(False)

                    self._socket_rwx['inputs'].append(sk_client)
                    self._socket_rwx['outputs'].append(sk_client)

                    self._socket_clients[sk_client] = \
                        (cl := self.prot(self, sk_client, queue.Queue()))
                    cl.on_new_connection(cl_addr)
                    continue

                sk_buffer = b""
                try:
                    while (fragment := sk_readable.recv(
                            AsyncSocketServer.RX_CHUNKSIZE)):
                        sk_buffer += fragment
                except BlockingIOError:
                    pass
                
                if not sk_buffer:
                    self._close_connection(sk_readable)
                    continue
                self._socket_clients[sk_readable].on_data_received(
                    sk_buffer
                )

My code adheres to a strict 80 character line-break, and in terms of documentation, I refrain from commenting most of any code since I believe in the most critical parts (in terms of maintanability), the code is clear to anyone reading, therefore comments would just insert noise.

So, I'm just wondering if there's any ideas that I could consider with AsyncSocketServer to make it more production-ready, whether in terms of easier readability in certain points, or regions of code that I can pull out to make it easier on the eyes, or anything.

\$\endgroup\$
4
  • \$\begingroup\$ I see nothing here that's HTTP-specific. Is that something you haven't gotten to yet? \$\endgroup\$
    – Reinderien
    Apr 29, 2022 at 3:11
  • \$\begingroup\$ @Reinderien, the HTTP implementation is a little too underdeveloped to post here. I mention it in the question because it might give better context for which direction I want to aim my code, e.g. perhaps there's some way to adapt my socket-server to provide a better or more flexible interface for an HTTP implementation later to implement peripheries like Keep-Alive, which I found to be pretty ugly in my current code \$\endgroup\$ Apr 29, 2022 at 3:14
  • \$\begingroup\$ Have you considered using asyncio.start_server? \$\endgroup\$ May 7, 2022 at 19:51
  • \$\begingroup\$ @ZacharyVance yeah, a few iterations ago I used asyncio's server, but I decided to rewrite the event loop for more fine tuned control \$\endgroup\$ May 8, 2022 at 2:53

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Browse other questions tagged or ask your own question.