学习编程到现在,虽然写过很多代码,但是总感觉自己依旧是业余选手。究其原因,可能是写过的正儿八经地追求高质量代码的项目做的太少。就Web开发而言,可能大家刚开始都是使用过几门语言、用过几个框架写一些东西,但是总感觉缺了点什么。所以最近准备分析一些评价和关注度都不错的开源项目的源码,边学习边记录。

关于Typecho

Typecho貌似基于magike修改演化而来(未考证)。项目维护有好几年了,所以必须考虑到历史问题,故在分析的过程中取其精华、对比学习即可。官网首页评价很不错哈哈。

仅仅7张数据表,加上不足 400KB 的代码,就实现了完整的插件与模板机制。 ... 原生支持 Markdown 排版语法,易读更易写。 ... 精心打磨过的操作界面,依然是你熟悉的面孔,更多了一份成熟与贴心。...Typecho 浑身都透着她简洁的性格,就像一个苗条的美女那样吸引着你。...

入口文件、配置文件分析

Typecho 入口文件清晰明了,就是根目录的 index.php 。入口文件首先载入配置文件,配置文件里面定义了一堆路径的常量,然后载入了很多东西,在 Typecho 里面以 Widget 表示,这里称为组件好了,好几个组件在 config.inc.php 里面先载入,以备后面使用(当然其实需要使用组件时也会自动载入,这里载入多了有点冗余,提了PR),思路可以说很常规。这些载入这么放着很容易让人先进去挨个分析,我刚开始看也是这么干的,回头再看感觉刚开始分析不宜挨个去分析,应当先把握大体流程。配置文件里面的数据库的设置就不说了,关注以下两个地方即可:

/** 设置包含路径 */
@set_include_path(get_include_path() . PATH_SEPARATOR .
__TYPECHO_ROOT_DIR__ . '/var' . PATH_SEPARATOR .
__TYPECHO_ROOT_DIR__ . __TYPECHO_PLUGIN_DIR__);

⇧这里设置了 include_path ,作用是之后如果代码里面进行文件包含操作例如 include('example.php') 的时候, php 解释器会先按参数给出的路径寻找,如果没有给出目录(只有文件名)时则按照 include_path 也就是设置的目录(可以为多个)去寻找。如果在 include_path 下还没找到该文件则 include 最后才在调用 php 脚本文件所在的目录和当前工作目录下面去寻找。而显然,这里设置了多个 include_path ,以 PATH_SEPARATOR 分割(linux 下就是 :),而这里的多个目录就是 Typecho 目录的 varusr/plugins,插件目录先不关注,var 目录构成按照官方说法:

在Typecho中,所有的程序文件以包(package)形式组织。我们亦集成了一些优秀的第三方开源包,向Typecho贡献包文件是最常见和容易的实现方式。我们对包文件的集成方式采用如下两种方案:
1.对于可以直接独立使用的包,我们会直接放在/var目录下,供程序直接使用。比如IXR包。
2.对于需要整合才能使用的包,我们会集成到/var/Typecho目录下,作为Typecho包的一个子集。比如Feed和I18n包,它们都是被我们扩展或修改后使用的。
不论是否会被我们修改,我们都将遵循第三方包的发行协议,并保留作者版权(如果有的话),我们将在文件头部声明出处。并尽可能得保留程序注释与代码风格。对于经过修改的包,我们会标注我们修改的地方。

配置文件中需要关注的第二个点代码如下:

/** 程序初始化 */
Typecho_Common::init();

⇧这里执行了 Typecho_Common 组件的 init 操作,组件名和操作名一看,显然是执行了一些对整个程序跑起来所必须的初始化操作,跟进去一看便知,初始化操作代码如下:

/**
 * 程序初始化方法
 *
 * @access public
 * @return void
 */
public static function init()
{
    /** 设置自动载入函数 */
    spl_autoload_register(array('Typecho_Common', '__autoLoad'));

    /** 兼容php6 */
    if (function_exists('get_magic_quotes_gpc') && get_magic_quotes_gpc()) {
        $_GET = self::stripslashesDeep($_GET);
        $_POST = self::stripslashesDeep($_POST);
        $_COOKIE = self::stripslashesDeep($_COOKIE);

        reset($_GET);
        reset($_POST);
        reset($_COOKIE);
    }

    /** 设置异常截获函数 */
    set_exception_handler(array('Typecho_Common', 'exceptionHandle'));
}

可见,在 init 操作中注册了全局自动载入函数,然后对 $_GET、$_POST、$_COOKIE 三个超全局数组进行了去转义化操作,注释写的是兼容 php6 ,然而... php 并没有版本6,这里可以认为是兼容自动开启 HTTP 转义的环境。最后设置了全局 Exception Handler ,在没有 try ... catch ... 并且没有忽略异常的上下文里产生的任何 Exception 都将由其捕获并进行处理,在 php7 中,将捕获实现了 Throwable Interface 的异常,也就是 Error、Exception 都会由其处理。具体的自动载入过程如下:

public static function __autoLoad($className)
{
    @include_once str_replace(array('\\', '_'), '/', $className) . '.php';
}

那么结合之前设置的包含路径,Typecho_Common 的路径实际上就是 var/Typecho/Common.php,而若在 var 目录下找不到则去 usr/plugins 目录下去尝试加载插件。

整体流程

看到这里暂且打住,现在知道自动载入有了,基本的错误处理有了,数据库连接配置完毕,剩下的就是载入需要的组件,接受 HTTP
信息,进行处理并返回结果了。继续看 index.php 的内容:

/** 初始化组件 */
Typecho_Widget::widget('Widget_Init');

/** 注册一个初始化插件 */
Typecho_Plugin::factory('index.php')->begin();

/** 开始路由分发 */
Typecho_Router::dispatch();

/** 注册一个结束插件 */
Typecho_Plugin::factory('index.php')->end();

可见这里调用了 Typecho_Widget 组件的 widget 静态方法,套用一下所谓设计模式的说法,这是一个工厂函数,实例化了 Widget_Init 类并进行其他初始化操作,然后是一个插件挂载点 index.php:begain 这里先不用在意,然后进行路由分发,最后是整体结束的插件挂载点 index.phhp:end 。那么很显然,先关注初始化操作和路由分发即可。跟进去工厂函数可见其代码如下:

/**
 * 工厂方法,将类静态化放置到列表中
 *
 * @access public
 * @param string $alias 组件别名
 * @param mixed $params 传递的参数
 * @param mixed $request 前端参数
 * @param boolean $enableResponse 是否允许http回执
 * @return Typecho_Widget
 * @throws Typecho_Exception
 */
public static function widget($alias, $params = NULL, $request = NULL, $enableResponse = true)
{
    $parts = explode('@', $alias);
    $className = $parts[0];
    $alias = empty($parts[1]) ? $className : $parts[1];

    if (isset(self::$_widgetAlias[$className])) {
        $className = self::$_widgetAlias[$className];
    }

    if (!isset(self::$_widgetPool[$alias])) {
        /** 如果类不存在 */
        if (!class_exists($className)) {
            throw new Typecho_Widget_Exception($className);
        }

        /** 初始化request */
        if (!empty($request)) {
            $requestObject = new Typecho_Request();
            $requestObject->setParams($request);
        } else {
            $requestObject = Typecho_Request::getInstance();
        }

        /** 初始化response */
        $responseObject = $enableResponse ? Typecho_Response::getInstance()
        : Typecho_Widget_Helper_Empty::getInstance();

        /** 初始化组件 */
        $widget = new $className($requestObject, $responseObject, $params);

        $widget->execute();
        self::$_widgetPool[$alias] = $widget;
    }

    return self::$_widgetPool[$alias];
}

可见其专门用于实例化各组件类,并且有个组件池做的单例模式。需要注意 1、每个类实例化的时候都会送入 Typecho_RequestTypecho_Response 的单例形成数据流,每个组件都可以按需获取需要的请求信息及输出信息; 2、每个类实例化之后都会调用其 execute() 方法。而此时则是执行 Widget_Init 的 execute 方法,其主要初始化了设置信息,Cookie封装,语言、字符集,时区设置,session管理等,这里先不用关注。下一步是路由分发,需要先看数据库里面的配置信息有个印象(已经过处理方便阅读):

[index] => Array
        (
            [url] => /
            [widget] => Widget_Archive
            [action] => render
            [regx] => |^[/]?$|
            [format] => /
            [params] => Array
                (
                )

        )

[archive] => 。。。。。。
。。。。。。
[post] => Array
        (
            [url] => /archives/[cid:digital]/
            [widget] => Widget_Archive
            [action] => render
            [regx] => |^/archives/([0-9]+)[/]?$|
            [format] => /archives/%s/
            [params] => Array
                (
                    [0] => cid
                )

        )
。。。。。。

而跟进去路由的 dispatch 操作:

/**
 * 路由分发函数
 *
 * @return void
 * @throws Exception
 */
public static function dispatch()
{
    /** 获取PATHINFO */
    $pathInfo = self::getPathInfo();

    foreach (self::$_routingTable as $key => $route) {
        if (preg_match($route['regx'], $pathInfo, $matches)) {
            self::$current = $key;

            try {
                /** 载入参数 */
                $params = NULL;

                if (!empty($route['params'])) {
                    unset($matches[0]);
                    $params = array_combine($route['params'], $matches);
                }

                $widget = Typecho_Widget::widget($route['widget'], NULL, $params);

                if (isset($route['action'])) {
                    $widget->{$route['action']}();
                }

                Typecho_Response::callback();
                return;

            } catch (Exception $e) {
                if (404 == $e->getCode()) {
                    Typecho_Widget::destory($route['widget']);
                    continue;
                }

                throw $e;
            }
        }
    }

    /** 载入路由异常支持 */
    throw new Typecho_Router_Exception("Path '{$pathInfo}' not found", 404);
}

其实就是依次根据路由配置信息里面的 regx 正则信息去匹配当前的 url_pathinfo ,一旦匹配上则实例化对应的组件,并调用其与路由配置信息的 action 项所对应的方法。而有些 url 里面带有参数信息,如上面的 post 项,则通过正则的捕获获取,参数名是由路由表配置的 param 项确定,最终通过 $params = array_combine($route['params'], $matches); 构造并作为信息流的一部分传递给当前 url 对应的组件。接下来以 index 项为例,通过参考上述路由信息的 action 项可知接下来会进入其 render 方法,代码如下:

/**
 * 输出视图
 *
 * @access public
 * @return void
 */
public function render()
{
    /** 处理静态链接跳转 */
    $this->checkPermalink();
    
    /** 添加Pingback */
    if (2 == $this->options->allowXmlRpc) {
        $this->response->setHeader('X-Pingback', $this->options->xmlRpcUrl);
    }
    $validated = false;

    //~ 自定义模板
    if (!empty($this->_themeFile)) {
        if (file_exists($this->_themeDir . $this->_themeFile)) {
            $validated = true;
        }
    }
    
    if (!$validated && !empty($this->_archiveType)) {

        //~ 首先找具体路径, 比如 category/default.php
        if (!$validated && !empty($this->_archiveSlug)) {
            $themeFile = $this->_archiveType . '/' . $this->_archiveSlug . '.php';
            if (file_exists($this->_themeDir . $themeFile)) {
                $this->_themeFile = $themeFile;
                $validated = true;
            }
        }

        //~ 然后找归档类型路径, 比如 category.php
        if (!$validated) {
            $themeFile = $this->_archiveType . '.php';
            if (file_exists($this->_themeDir . $themeFile)) {
                $this->_themeFile = $themeFile;
                $validated = true;
            }
        }

        //针对attachment的hook
        if (!$validated && 'attachment' == $this->_archiveType) {
            if (file_exists($this->_themeDir . 'page.php')) {
                $this->_themeFile = 'page.php';
                $validated = true;
            } else if (file_exists($this->_themeDir . 'post.php')) {
                $this->_themeFile = 'post.php';
                $validated = true;
            }
        }

        //~ 最后找归档路径, 比如 archive.php 或者 single.php
        if (!$validated && 'index' != $this->_archiveType && 'front' != $this->_archiveType) {
            $themeFile = $this->_archiveSingle ? 'single.php' : 'archive.php';
            if (file_exists($this->_themeDir . $themeFile)) {
                $this->_themeFile = $themeFile;
                $validated = true;
            }
        }

        if (!$validated) {
            $themeFile = 'index.php';
            if (file_exists($this->_themeDir . $themeFile)) {
                $this->_themeFile = $themeFile;
                $validated = true;
            }
        }
    }

    /** 文件不存在 */
    if (!$validated) {
        Typecho_Common::error(500);
    }

    /** 挂接插件 */
    $this->pluginHandle()->beforeRender($this);

    /** 输出模板 */
    require_once $this->_themeDir . $this->_themeFile;

    /** 挂接插件 */
    $this->pluginHandle()->afterRender($this);
}

到这里就是取用需要的信息,确定需要包含的主题文件,并且 include 进来。那么很显然,主题里面经常使用的 $this 实际上就是 Widget_Archive 的实例。例如官网文档提到的神奇的 is 语法 $this->is('single') ,实际上对应于 Widget_Archive 的 is 方法,如下:

/**
 * 判断归档类型和名称
 *
 * @access public
 * @param string $archiveType 归档类型
 * @param string $archiveSlug 归档名称
 * @return boolean
 */
public function is($archiveType, $archiveSlug = NULL)
{
    return ($archiveType == $this->_archiveType ||
    (($this->_archiveSingle ? 'single' : 'archive') == $archiveType && 'index' != $this->_archiveType) ||
    ('index' == $archiveType && $this->_makeSinglePageAsFrontPage))
    && (empty($archiveSlug) ? true : $archiveSlug == $this->_archiveSlug);
}

总结

分析下来感觉 Typecho 的整体设计还是很小巧的。调用时自动载入需要的类,工厂方式提供需要的组件并且形成信息流,通过配置的路由信息结合当前 URL 调用对应的组件与方法,在方法中加载主题文件顺便暴露有用的对象信息给主题,也让主题有较高的可定制性。整个流程简单来看就是这样,当然还有很多细节未提到,后面再记录分享。