背景
在我们开发各种服务的时候,日志是一个非常基础,但非常重要的环节。最原始的方式,莫过于去直接通过屏幕echo或var_dump一堆信息出来;稍微进步一点,就是把一些信息输出到统一的日志文件中方便查看,再加上info、warn、error等日志级别。
但是,很多时候,我们可能有多个服务,或者是多台服务器部署,这时候,我们就需要把它们的日志统一汇总进行查看、管理了。
现成的解决方案也有不少,大家第一个想到的可能会是ELK三件套。确实,用ELK来做日志的收集、整理、汇总、搜索,是一个比较成熟的全套方案。但是对于小型团队, 甚至个人开发者来说,部署一套ELK可能比较重,对资源的要求也不低。恰好看到腾讯云有推出ELK的云服务,看了下最便宜的价格是每个月150多。老实说,这个价格对于一个中小团队的服务来讲并不算贵,毕竟省下了很多运维性质的工作。但对我来说,提供的配置还是太高,用起来太浪费了。
那么,是否可以自己实现一套相对简单、方便的方案呢?
日志服务
我们肯定需要考虑搭建一套统一的日志服务。比如说对外提供一个接口,各个服务通过这个开放式的接口来进行日志记录。
而这个接口对外服务,如果以http的方式提供,无疑性能上会比较差,毕竟多次日志记录,就意味着多次的http连接。所以,这个服务通过tcp或者udp的方式来提供服务会更合适。
tcp和udp的差异不做赘述,udp无疑在网络传输性能上更有优势,但我综合考虑了之后,还是选择了安全性更好一些的tcp协议。那么,我这个日志服务,就需要在一台机器上,开启一个tcp的server,接收日志记录的请求,并进行统一的记录。
想要基于php来提供一套tcp的server,也有不少现成的框架可用。比较知名的,一个是swoole,一个是workerman。这两个框架,走的是两条完全不同的路。前者是php的C扩展,后者依赖php的event扩展,但是是用纯php代码实现的。直觉上前者的性能更高,但网上也有很多不同的声音。这两套框架我都用过,且同样是实现了日志服务的功能,但是没有做过性能对比,不好就这方面发表评价。
就我的感受来说,Swoole的使用门槛稍微高一些,Workerman用起来相对简单,部署容易。而且稳定性上,我个人觉得Workerman更高一些。所以我现在用的日志服务,还是基于Workerman来实现的。
我们可以在Workerman的官网上找到详细的文档,启动一个tcp的server非常简单:
use Workerman\Worker; use Workerman\Connection\TcpConnection; require_once __DIR__ . '/vendor/autoload.php'; $worker = new Worker('tcp://0.0.0.0:8686'); $worker->onMessage = function(TcpConnection $connection, $data) { $connection->send("hello"); }; // 运行worker Worker::runAll();
也就是说,除了配置(比如端口信息)之外,实际上我只要在onMessage,即接收到消息的回调函数里面,实现一些日志的统一记录就可以了。
当然,实际上,为了方便代码的阅读和维护,还是需要做一些封装改造的。而且,这里做了日志记录之后,考虑到后续的查询,还需要做一些特殊处理,即,怎样满足像Kibana那样的功能。不过这里先暂时不做说明,放在之后的章节里面再详细展开。
客户端调用
客户端调用的时候,需要考虑到各个类中,都需要有日志的调用。因此,肯定需要把日志服务统一抽取成为一个公共方法放在基类,或者公共函数里面。我这里通过继承基类,并通过单例模式设置了日志服务的统一入口。
例如,Controller类都会继承一个基类Base(它继承的BaseController是框架提供的基类):
class Base extends BaseController { ... protected function _logger() { return \poisonbian\MyLogger::get_instance(); } } abstract class BaseLogger { private static $instance = null; private function __construct() { $this->init_logger(); } /** * 初始化logger */ abstract function init_logger(); /** * 获得Logger实例 */ abstract function get_logger(); public static function get_instance() { if (self::$instance === null) { self::$instance = new \poisonbian\MyLogger(); } return self::$instance->get_logger(); } }
其中,BaseLogger这个日志基类是个抽象类,还需要有具体的实现。这里的设计是为了方便不同类型的情况下,有不同的日志库的调用方式。
除了Controller,其他的Manager层、公共函数等,也可以根据需要设置这样的基础类来提供日志功能,只要调用了BaseLogger的具体实现类就可以保证全局的日志都是单例的。
日志库的改造
日志记录的时候,如果只是通过类似echo的方式记录信息肯定是不够的。例如当前调用日志打印的文件、行数、时间等信息,需要自动地添加进来。因此可以考虑使用比较成熟的日志库,比如Log4php。
Log4php目前是Apache Log4j的子项目,可以说是PHP语言下进行日志记录的第一选择了。Log4php已经提供了许多强大的功能,并且原生地支持通过各种方式做日志记录,比如基于PDO、MongoDB、文件、Socket等。其中Socket支持,就能和我们前面说的“日志服务”做对接了。
不过,在试用了之后,发现这个功能稍微有一点小问题,在LoggerAppenderSocket这个类里,我们找到日志记录的代码:
public function append(LoggerLoggingEvent $event) { $socket = fsockopen($this->remoteHost, $this->port, $errno, $errstr, $this->timeout); if ($socket === false) { $this->warn("Could not open socket to {$this->remoteHost}:{$this->port}. Closing appender."); $this->closed = true; return; } if (false === fwrite($socket, $this->layout->format($event))) { $this->warn("Error writing to socket. Closing appender."); $this->closed = true; } fclose($socket); }
可以看到,其实每一次记录,都是重新打开和关闭socket连接。那么,如果我们在业务代码中,多次调用日志记录的请求,就意味着TCP的连接也需要建立和关闭多次。这显然对性能有不小的影响。
性能情况
我写了一个简单的测试代码,根据传入的参数,打印N条日志:
public function log() { $time = \think\facade\Request::get('time'); if ($time === null) { $time = 1; } // $post_array = \think\facade\Request::post(); $post_array = json_decode('[ 一堆随机json字符串 ]', true); for ($i = 0; $i < $time; $i++) { $this->_logger()->debug('poisonbian debug ' . $i, $post_array); } return json(Error::get_exit_array(Error::$SUCCESS, [ 'content' => $post_array, 'time' => $time, 'current_time' => \poisonbian\Time::get_current_datetime(), ])); }
服务端可以使用前面基于Workerman实现的服务端去进行测试,不过更简单的方式是直接用nc命令开启一个tcp端口:
nc -lk 8020
接下来,我就可以直接基于apifox,或者在浏览器中直接输入url地址进行测试,传入参数time=100,即表示连续打印100条日志。我的代码中实际封装了一个退出打印逻辑,会把服务端的整体耗时时间自动打印出来。可以看到,记录100条日志(我实现的框架层有3条日志打印,因此实际是调用了103次),服务端记录的花费了1400毫秒。
time=1的时候,大概花了100毫秒(加上框架层的日志,实际上打印了4条)。
因此,我做了一些改造,先看下100次日志打印的效果:时间变成了127毫秒,缩短到了原来的1/10。time=1的时候,也稳定到了60毫秒以下。
改造的核心内容
改造的核心思路是:尽可能复用socket连接,而非每次通过append方法记录日志时,都重新关闭和打开日志管道。因此核心代码就是这个LoggerAppenderSocketNotClose的类,它继承了默认的LoggerAppenderSocket,但socket连接开启之后,不进行关闭,而是在close函数中再关闭。这个close函数,实际上是在类销毁的时候被调用。
class LoggerAppenderSocketNotClose extends LoggerAppenderSocket { private $socket = null; private function get_socket() { if ($this->socket === null) { $this->socket = @fsockopen($this->remoteHost, $this->port, $errno, $errstr, $this->timeout); if ($errno !== 0) { $this->warn(sprintf("open socket fail, errno: %d, errstr: %s", $errno, $errstr)); } } return $this->socket; } /** Override the default layout to use serialized. */ public function getDefaultLayout() { return new \LoggerLayoutJSON2(); } public function append(LoggerLoggingEvent $event) { $socket = $this->get_socket(); if ($socket === false) { $this->warn("Could not open socket to {$this->remoteHost}:{$this->port}. Closing appender."); $this->closed = true; return; } try { if (false === fwrite($socket, $this->layout->format($event) . $this->eof)) { $this->warn("Error writing to socket. Closing appender."); $this->closed = true; } } catch (Exception $e) { } // fclose($socket); } public function close() { if ($this->socket !== null && $this->socket !== false) { fclose($this->socket); } parent::close(); } }
除了这个性能优化之外,我对日志库还进行了其他一些改造。比如说,支持通过array传入日志的详细信息(输出为json格式);文件名打印时不打印绝对路径,而是打印项目中的相对路径;日志中默认增加打印项目名称,以支持多项目自动区分;增加客户端日志信息的记录等等。这些和日志服务本身的搭建关系不大,主要就看业务实际的需求了。
至此,日志客户端基本就绪了,但是服务端目前只是使用了Workerman进行端口的监听。那么,日志到底要怎么记录,如何让我们可以方便快捷地查询?
欲听后事如何,且听下回分解。
本文链接:https://www.poisonbian.com/post/5050.html 转载需授权!