Building a PHP client for Faktory, Part 3: Making development easier

At this point, I'll take a break from Faktory-focused functionality to address the shortcomings I raised at the end of the last part. Specifically, I'd like to implement better logging and error-handling, which should hopefully make me more productive when working on other Faktory features. I can add these things later, but I'd like to try getting them done first, so I can work on the Faktory features with more confideence.

Logging

For logging, I'd like to make things configurable. I want to log to the console, but a user may want to log to a file or something. The best way to achieve this is by letting users provide their own logger implementation. The Faktory client can call methods like $this->logger->info(), and their logger will send it to wherever they prefer.

We do this is by using a common interface. In the PHP world, this is the PSR-3 Logger interface. Our client will declare that we use a logger of this kind, and users can pass in any compatible logger. Here we go:

composer require psr/log

And now we change the constructor to accept a logger:

use Psr\Log\LoggerInterface;

class TcpClient
{
  protected LoggerInterface $logger;

  public function __construct(LoggerInterface $logger)
  {
    $this->logger = $logger;
    // ...
  }
}

We'll use the logger to provide helpful info on what's going on. For starters, we can record every message sent and received, and log important actions like when we're establishing a connection.

public function connect(): bool
{
+ $this->logger->info("Connecting to Faktory server on $this->hostname");
  $this->createTcpConnection();
  // ...
}

protected function send($command, ...$args): void
{
  $message = $command . " " . join(' ', $args) . "\r\n";
+ $this->logger->debug("Sending: " . $message);
  fwrite($this->connection, $message);
}

protected function readLine(): mixed
{
  $line = fgets($this->connection);
+ $this->logger->debug("Received: " . $line);
  // ...
}

This is nice, but incomplete. As it stands, we always require the user to pass in a logger. We can improve this by making the logger optional, and falling back to a default logger if none was supplied. For this we'll use Monolog; it's PSR-3 compatible, and the most popular PHP logger.

composer require monolog/monolog
class TcpClient
{
  public function __construct(?LoggerInterface $logger = null)
  {
    $this->logger = $logger ?: self::makeLogger();
    // ...
  }

  protected static function makeLogger(): LoggerInterface
  {
    return new \Monolog\Logger(
      name: 'faktory-php',
      handlers: [
        new \Monolog\Handler\StreamHandler(
          'php://stderr', \Monolog\Level::Info
        )
      ]
    );
  }
}

So our default logger logs to stderr (the console), with a minimum level of info (which means debug messages won't be logged). While the user can override this by passing their own logger, we can make the API still friendlier by allowing the user to configure this default logger (log destination and log level). We then handle the creation of the logger with that configuration. Advanced users can stlll pass in a logger directly.

class TcpClient
{
  public function __construct(
      Level $logLevel = Level::Info,
      string $logDestination = 'php://stderr',
      ?LoggerInterface $logger = null)
  {
    $this->logger = $logger ?: self::makeLogger($logLevel, $logDestination);
    // ...
  }

  protected static function makeLogger(
      Level $logLevel, string $logDestination): LoggerInterface
  {
    return new Logger(
      name: 'faktory-php',
      handlers: [(new StreamHandler($logDestination, $logLevel))]
    );
  }

Now let's test it:

$client = new TcpClient(logLevel: \Monolog\Level::Debug);
$client->connect();
# Console output:
[2023-04-10T15:11:40.039586+00:00] faktory-php.INFO: Connecting to Faktory server on tcp://dreamatorium.local [] []

And with our new API, we can easily customise things:

// Show all messages
$client = new TcpClient(logLevel: \Monolog\Level::Debug);
# Console output:
[2023-04-10T17:27:08.913660+00:00] faktory-php.INFO: Connecting to Faktory server on tcp://dreamatorium.local [] []
[2023-04-10T17:27:08.947566+00:00] faktory-php.DEBUG: Received: HI {"v":2} [] []
[2023-04-10T17:27:08.948248+00:00] faktory-php.DEBUG: Sending: HELLO {"hostname":"dreamatorium","wid":"test-worker-1","pid":19420,"labels":[],"v":2}  [] []
[2023-04-10T17:27:08.952586+00:00] faktory-php.DEBUG: Received: OK [] []
[2023-04-10T17:27:08.953244+00:00] faktory-php.DEBUG: Sending: PUSH {"jid":"123861239abnadsa","jobtype":"SomeJobClass","args":[1,2,true,"hello"]}  [] []
[2023-04-10T17:27:08.958194+00:00] faktory-php.DEBUG: Received: OK [] []
// Show only error messages - nothing will be logged
$client = new TcpClient(logLevel: \Monolog\Level::Error);

Exceptions and errors

Now, I'll work on setting up proper exception classes rather than using the default \Exception. We currently throw an exception for two main reasons: if the initial connection fails, or if an operation returns a non-OK response. Let's make these separate classes. (I'm going to be a bit whimsical here, and put them in a Problems namespace, instead of the usual Exceptions.)

namespace Knuckles\Faktory\Problems;

class CouldntConnect extends \Exception
{
  public static function to($address, $errorMessage, $errorCode)
  {
    $message = "Failed to connect to Faktory on $address: $errorMessage (error code $errorCode)";

    return new self($message);
  }
}

class UnexpectedResponse extends \Exception
{
  public static function from($operation, $response)
  {
    return new self("$operation returned an unexpected response: \"$response\"");
  }
}

Of course, this isn't an exhaustive exception hierarchy. We may decide we want some more classes in the future, and we can then add them in the Problems namespace.

And to use them in our client class:

class TcpClient
{
  // ...
  protected function createTcpConnection()
  {
    $filePointer = fsockopen($this->hostname, $this->port, $errorCode, $errorMessage, timeout: 3);

    if ($filePointer === false) {
      throw CouldntConnect::to("{$this->hostname}:{$this->port}", $errorMessage, $errorCode);
    }

    // ...
  }

  private static function checkOk(mixed $result, $operation = "Operation")
  {
    if ($result !== "OK") {
      throw UnexpectedResponse::from($operation, $result);
    }

    return true;
  }
}

We're almost done, but not quite. As I mentioned at the end of Part 2, PHP has a messy history with errors. Functions like fsockopen() normally trigger a warning and return false. But you can register register a custom error handler that converts warnings and other PHP notices into actual exceptions. In that case, fsockopen() will throw an exception. This custom error handler is pretty common among frameworks, so we have to handle that as well.

$filePointer = false;
try {
  $filePointer = fsockopen($this->hostname, $this->port, $errorCode, $errorMessage, timeout: 3);
} catch (\Throwable $e) {
  $errorMessage = $e->getMessage();
  $errorCode = $e->getCode();
}

if ($filePointer === false) {
  throw CouldntConnect::to("{$this->hostname}:{$this->port}", $errorMessage, $errorCode);
}

Okay, let's wrap it up with a test.

it('raises an error when the response is not OK', function () {
  $client = new TcpClient(logLevel: \Monolog\Level::Error);
  $client->connect();
  
  $invalidJob = ["jobtype" => null];
  expect(fn() => client->push($invalidJob))->toThrow(UnexpectedResponse::class);
});

All good.

That's all for now. In the next part, I'll do some refactoring so we can get back to implementing the Faktory features.



I write about my software engineering learnings and experiments. Stay updated with Tentacle: tntcl.app/blog.shalvah.me.

Powered By Swish