这个问题来源于在Seaslog开发组的一次讨论。
SeasLog是一个声称遵循PSR-3规范的PHP日志工具,它是一个PHP扩展(采用C编写)。问题的核心在于能否能否在实际上改变了接口的定义之后还说自己遵循该接口。
PSR-3提供了9个方法,这里仅以一个为例来说明。
<?php
interface LoggerInterface
{
public function log($level, $message, array $context = array());
}
非常简单的接口定义,同时也给出了一个参考实现,这里简单描述一下。
<?php
include __DIR__ . '/LoggerInterface.php';
class Logger implements LoggerInterface
{
public function log($level, $message, array $context = array())
{
error_log($this->interpolate($message, $context), 3, '/tmp/a.log');
}
private function interpolate($message, array $context = array())
{
// build a replacement array with braces around the context keys
$replace = array();
foreach ($context as $key => $val) {
// check that the value can be casted to string
if (!is_array($val) && (!is_object($val) || method_exists($val, '__toString'))) {
$replace['{' . $key . '}'] = $val;
}
}
// interpolate replacement values into the message and return
return strtr($message, $replace);
}
}
从上面的代码反推,使用该日志类的过程应该是这样的
include_once __DIR__ . '/Logger.php';
$logger = new Logger();
$logger->log('debug', 'I am a {job}', ['job' => 'programmer']);
// 输出
// I am a programmer
也就是说,如果想让第三个参数$context
生效,必须在第二个参数中加入占位符,显然这不是一个友好的方式。但其实初衷很容易理解,就是要让$message
成为一行完整的sentence。但是在实际使用中,我们其实更倾向于Monolog的实现方式,即(简化版本)
<?php
include __DIR__ . '/LoggerInterface.php';
class Monolog implements LoggerInterface
{
public function log($level, $message, array $context = array())
{
error_log($this->interpolate($message, $context) . "\n", 3, './a.log');
}
private function interpolate($message, array $context = array())
{
return $message . '|' . (string)$context;
}
}
这样的话,用起来就是这样的:
<?php
include_once __DIR__ . '/Monolog.php';
$logger = new Monolog();
$logger->log('debug', 'job description', ['job' => 'programmer']);
// 输出
// job description|{"job":"programmer"}
这样做,一方面可以通过$message
中的内容快速定位到相同类型的日志,一方面可以省去了占位符,可读性也没问题。
分歧有两点:
- 接口并没有指定前两个参数的类型
- 接口没有也不应该指定这个方法该如何实现
下面分别剖析该这两个问题。
是否需要指定参数类型
对于第一个参数$level
,没有任何问题,因为它代表的是日志的严重等级,PSR-3为其定义了8个等级,这里不再赘述。
第二个参数$message
,是否可以是数组?接口并没有指定,这就给了接口实现者一些可发挥的空间。
比如我喜欢让$message
是数组,这样我就不需要再思考原本接口设计者认为的$message
的作用。最终的实现可能会是这样:
<?php
include __DIR__ . '/LoggerInterface.php';
class SeasLog implements LoggerInterface
{
public function log($level, $message, array $context = array())
{
if (is_array($message)) {
error_log(json_encode($message) . "\n", 3, './a.log');
} else {
error_log($this->interpolate($message, $context) . "\n", 3, './a.log');
}
}
private function interpolate($message, array $context = array())
{
// build a replacement array with braces around the context keys
$replace = array();
foreach ($context as $key => $val) {
// check that the value can be casted to string
if (!is_array($val) && (!is_object($val) || method_exists($val, '__toString'))) {
$replace['{' . $key . '}'] = $val;
}
}
// interpolate replacement values into the message and return
return strtr($message, $replace);
}
}
这样就变相支持了上面提到的两种调用方式。但这样混乱的支持真的好吗?
所以问题就在于,是否遵循PSR-3规范就意味着要遵循它的参数形式?
这个问题又可以分为两点:
- 第一个参数和接口参考实现中一样是字符串
- 第二个参数是数组,它的key需要以占位符
{$key}
的形式出现在$message
中
如果这两点都是肯定的那么其实PSR-3本质上绑定了一种实现。是否可以说上面的Monolog
类就不遵循PSR-3呢?
是否要绑定具体实现
从我对面向接口编程思想的理解来说,使用接口就是为了可替换,比如今天我使用PeasLog
类不爽想换成SeasLog
,那如果两个具体实现虽然都implements
了同一个接口,但就像上面提到的,两个类接收的参数其实并不同,也就无法做到直接替换。
而真实的Monolog那样的实现无疑是非常灵活的,实际上它定义了一个Processor
的概念用于解决这个问题。默认情况下它的做法就是return $message . '|' . (string)$context;
,但我们可以通过自定义Processor
来改变它的默认行为。比如它内置的PsrLogMessageProcessor
就是为了兼容PSR-3而实现的,同时也可以随意实现自定义Processor
来满足个性化需求。
在思考这个问题的过程中我查阅了一些资料,其中深入理解abstract class和interface中的理解和Monolog的实现如出一辙,或许能说明这是那些伟大的程序员们的共识吧。
套用原文中关于Door
类实现的讨论,这里讨论PlaceholderLogger
,首先它是一个(is a)Logger
,也需要实现接口定义的功能记录日志,但如何记录并不是问题的核心,可以放在另一个接口中定义。和文中讨论问题不同的是,Monolog中并没有所谓的Abstract class
,但我认为这并不影响结论。
Monolog里的Processor
颇有些像Slim框架中的Middleware,用于预处理输入,将结果继续交给下游处理,但其实下游根本感觉不到它的存在。
结论
本文并没有任何结论。只是思考一下接口和实现到底应该是什么样的关系。只能说我自己喜欢Monolog这样,提供默认实现,又暴露接口让用户可以自定义方法控制其实现方式的做法,而不喜欢SeasLog那样直接改变接口定义的参数类型强行实现重载。