统一日志服务器的设计实现(1)

背景

在我们开发各种服务的时候,日志是一个非常基础,但非常重要的环节。最原始的方式,莫过于去直接通过屏幕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条)。

image.png


因此,我做了一些改造,先看下100次日志打印的效果:时间变成了127毫秒,缩短到了原来的1/10。time=1的时候,也稳定到了60毫秒以下。

image.png


改造的核心内容

改造的核心思路是:尽可能复用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 转载需授权!

分享到:
原文链接:,转发请注明来源!
「统一日志服务器的设计实现(1)」评论列表

发表评论