CodeIgniter源码分析 3 - 输入输出

大家有没有想一下一般我们可以通过$_GET或许$_POST等获取表单数据,但是输入类提供了post,get等方法,那么这种方式是多此一举还是有别的需求?如果有区别,那么区别在哪里?

这一节我们看下CI提供给我们的输入类和输出类,通过研究这两个类的源码学习下表单数据处理,安全过滤(xss-跨站脚本,csrf-跨站请求伪造),网页缓存,输出控制以及http相关的一些知识。


输入类 CI_Input

先从构造方法开始

看下构造方法做了哪些初始化

public function __construct()
{
    /*
     * 读取相关的配置,分别为:
     * 是否销毁全局的GET数组的allow_get_array,
     * 是否进行xss过滤的global_xss_filtering,
     * 是否防御跨站请求伪造的csrf_protection,
     * 统一换行符的standardize_newlines,
     * */
    $this->_allow_get_array     = (config_item('allow_get_array') === TRUE);
    $this->_enable_xss      = (config_item('global_xss_filtering') === TRUE);
    $this->_enable_csrf     = (config_item('csrf_protection') === TRUE);
    $this->_standardize_newlines    = (bool) config_item('standardize_newlines');


    //security类主要进行xss和csrf的操作,load_class我们在之前的请求处理中分析过
    $this->security =& load_class('Security', 'core');

    //加载utf8类,将对字符进行utf8编码处理
    if (UTF8_ENABLED === TRUE)
    {
        $this->uni =& load_class('Utf8', 'core');
    }

    //该方法就是根据读取到的相关配置,进而对全局数组进行处理
    $this->_sanitize_globals();

    log_message('info', 'Input Class Initialized');
}

我们一般是从全局数组中获取表单数据,通过分析很明显看到是在_sanitize_globals()方法中对表单数据做了处理,看下此方法

protected function _sanitize_globals()
{
    //还记得构造方法中加载的配置allow_get_array吗?
    //可以看到该配置为false将会销毁全局GET数组
    if ($this->_allow_get_array === FALSE)
    {
        $_GET = array();
    }
    //如果允许全局GET数组,那么对全局GET数组的数据做清洗
    elseif (is_array($_GET) && count($_GET) > 0)
    {
        foreach ($_GET as $key => $val)
        {
            $_GET[$this->_clean_input_keys($key)] = $this->_clean_input_data($val);
        }
    }

    //对全局POST数组的数据做清洗
    if (is_array($_POST) && count($_POST) > 0)
    {
        foreach ($_POST as $key => $val)
        {
            $_POST[$this->_clean_input_keys($key)] = $this->_clean_input_data($val);
        }
    }

    //对cookie做清洗
    if (is_array($_COOKIE) && count($_COOKIE) > 0)
    {
        unset(
            $_COOKIE['$Version'],
            $_COOKIE['$Path'],
            $_COOKIE['$Domain']
        );

        foreach ($_COOKIE as $key => $val)
        {
            if (($cookie_key = $this->_clean_input_keys($key)) !== FALSE)
            {
                $_COOKIE[$cookie_key] = $this->_clean_input_data($val);
            }
            else
            {
                //不合法的cookie就删掉
                unset($_COOKIE[$key]);
            }
        }
    }

    //PHP_SELF是除了域名外的url段,这里清除在url中带标签的恶意脚本
    $_SERVER['PHP_SELF'] = strip_tags($_SERVER['PHP_SELF']);

    //如果配置中的csrf_protection为true,那么就需要调用security类对跨站请求伪造进行处理
    if ($this->_enable_csrf === TRUE && ! is_cli())
    {
        $this->security->csrf_verify();
    }

    log_message('debug', 'Global POST, GET and COOKIE data sanitized');
}

_sanitize_globals()方法中我们看到对于全局数组的key和value分别使用了_clean_input_keys()和_clean_input_data()做清洗,看下这两个方法

/*
 * 清洗key
 * */
protected function _clean_input_keys($str, $fatal = TRUE)
{
    //监测表单的key,如果key不合法就报错
    if ( ! preg_match('/^[a-z0-9:_\/|-]+$/i', $str))
    {
        if ($fatal === TRUE)
        {
            return FALSE;
        }
        else
        {
            set_status_header(503);
            echo 'Disallowed Key Characters.';
            exit(7); // EXIT_USER_INPUT
        }
    }

    //如果脚本内部编码支持utf8,那么对key进行utf8编码
    if (UTF8_ENABLED === TRUE)
    {
        return $this->uni->clean_string($str);
    }

    return $str;
}
/*
 * 清洗value
 * */
protected function _clean_input_data($str)
{
    //考虑到表单的value可能是数组,对value递归处理
    if (is_array($str))
    {
        $new_array = array();
        foreach (array_keys($str) as $key)
        {
            $new_array[$this->_clean_input_keys($key)] = $this->_clean_input_data($str[$key]);
        }
        return $new_array;
    }

    //5.4版本以下,如果magic_quotes为on,所有的'(单引号)、"(双引号)、\(反斜杠)和 NULL会被一个反斜杠
   //自动转义;在 PHP 5.4.O 起将始终返回 FALSE ,关于magic_quotes见:
    //http://php.net/manual/en/info.configuration.php#ini.magic-quotes-gpc
    if ( ! is_php('5.4') && get_magic_quotes_gpc())
    {
        $str = stripslashes($str);
    }

    //对value进行utf8编码
    if (UTF8_ENABLED === TRUE)
    {
        $str = $this->uni->clean_string($str);
    }

    //移除非打印字符
    $str = remove_invisible_characters($str, FALSE);

    //由于unix,windows,mac上换行符不一致,这里根据配置对换行符做一致性处理
    if ($this->_standardize_newlines === TRUE)
    {
        return preg_replace('/(?:\r\n|[\r\n])/', PHP_EOL, $str);
    }

    return $str;
}

至此整个构造方法相关的初始化就分析完了,我们发现整个过程本质就做了一件事,读取相关配置对全局数组进行清洗。

获取表单数据

前文的构造方法的源码分析中我们看到需要的表单数据已经被清洗完成,那么这时候Input类提供的表单数据获取方法get(),post()本质上就是从全局数组上拿数据就行了。由于这些方法大同小异,这里以post方法为例,看下表单数据读取

/*
 * post方法接受两个参数,第一个是表单的key,第二个是是否对xss(跨站脚本)做处理的可选参数
 */
public function post($index = NULL, $xss_clean = NULL)
{
    return $this->_fetch_from_array($_POST, $index, $xss_clean);
}

如果仔细看其他几个获取数据的方法,会发现它们内部都调用_fetch_from_array()这个方法,该方法用来从全局数组中获取相应的数据

protected function _fetch_from_array(&$array, $index = NULL, $xss_clean = NULL)
{
    //读取是否进行跨站脚本清洗的配置
    is_bool($xss_clean) OR $xss_clean = $this->_enable_xss;

    //判断key是否为空,如果为空就意味着读取整个全局数组
    isset($index) OR $index = array_keys($array);

    //如果是一次获取多个表单值,那么递归读取这些值
    if (is_array($index))
    {
        $output = array();
        foreach ($index as $key)
        {
            $output[$key] = $this->_fetch_from_array($array, $key, $xss_clean);
        }

        return $output;
    }

    //从全局数组中读取该值
    if (isset($array[$index]))
    {
        $value = $array[$index];
    }

    /*
     *          key         |     value
     *          favorite[]  |     a
     *          favorite[]  |     b
     * 数组格式的表单值的读取其key有两种风格,例如 : this->input->post('favorite') 和 this->input->post('favorite[]'),
     * 接下来的这段代码就是处理key为'favorite[]'这种读取表单值的方式,注意:全局数组是不支持这种带[]的key的
     * */
    elseif (($count = preg_match_all('/(?:^[^\[]+)|\[[^]]*\]/', $index, $matches)) > 1)
    {
        $value = $array;
        for ($i = 0; $i < $count; $i++)
        {
            $key = trim($matches[0][$i], '[]');
            if ($key === '')
            {
                break;
            }

            if (isset($value[$key]))
            {
                $value = $value[$key];
            }
            else
            {
                return NULL;
            }
        }
    }
    else
    {
        return NULL;
    }

    //xss过滤返回表单值
    return ($xss_clean === TRUE)
        ? $this->security->xss_clean($value)
        : $value;
}

通过分析post()获取表单值的源码,发现post(),get()等方法通过调用_fetch_from_array(),其本质上是从全局数组上读取已经被清洗好了的表单值,至此读取表单数据的源码分析就完成了。


安全性处理 CI_Security

我们在源码分析中多次看到xss_clean和csrf_verify对数据做安全性处理,那么xss和csrf是什么?

  • xss((Cross Site Scripting):跨站脚本攻击,为了不和层叠样式表(Cascading Style Sheets)的缩写混淆,故将跨站脚本攻击缩写为XSS,这是一种通过Web页面里插入恶意Script代码,当用户浏览该页之时,嵌入其中Web里面的Script代码会被执行,从而达到恶意攻击用户的目的。
  • CSRF(Cross-site request forgery)跨站请求伪造,这是一种通过伪造会话信息,冒充用户进而骗取服务器信任的一种攻击手段。通过描述可以看到xss和csrf是不一样的,它们属于不同维度的攻击手段。

CI框架提供了CI_Security类来处理xss和csrf,通过阅读该类的源码来学习下对这两种攻击手段的处理。

xss_clean()方法

关于xss_clean要想清楚这几个问题:clean哪些东西?或者说脚本会从哪里注入进来?脚本注入的类型有哪些?

想清楚上面的这几个问题后会发现其实xss_clean的原理从大的方面来说始终围绕两点

  • 转换脚本的标签为实体从而破坏脚本的执行环境
  • 将一些可能为系统命令或函数的关键字给清除

接下来,我们进入xss_clean()方法中看下其实现

public function xss_clean($str, $is_image = FALSE)
    {
        //如果待被过滤字符是数组,递归处理它
        if (is_array($str))
        {
            while (list($key) = each($str))
            {
                $str[$key] = $this->xss_clean($str[$key]);
            }

            return $str;
        }

        //移除非打印字符,这个我们在之前的源码分析第二篇中已经分析过了
        $str = remove_invisible_characters($str);


        //因为有些恶意字符会伪装成url编码,反正不管怎么样,先解码一下,让那些恶意字符现出原形
        do
        {
            $str = rawurldecode($str);
        }
        while (preg_match('/%[0-9a-f]{2,}/i', $str));


        //将标签符号转义成字符实体
        $str = preg_replace_callback("/[^a-z0-9>]+[a-z0-9]+=([\'\"]).*?\\1/si", array($this, '_convert_attribute'), $str);
        $str = preg_replace_callback('/<\w+.*/si', array($this, '_decode_entity'), $str);

        //移除非打印字符
        $str = remove_invisible_characters($str);

        //将正则制表符换成空格,后文中会去除这些空格
        $str = str_replace("\t", ' ', $str);

        // Capture converted string for later comparison
        $converted_string = $str;

        //清除字符中不被允许的关键字
        $str = $this->_do_never_allowed($str);

        //将字符中的php标签符号转换成实体
        if ($is_image === TRUE)
        {
            // Images have a tendency to have the PHP short opening and
            // closing tags every so often so we skip those and only
            // do the long opening tags.
            $str = preg_replace('/<\?(php)/i', '&lt;?\\1', $str);
        }
        else
        {
            $str = str_replace(array('<?', '?'.'>'), array('&lt;?', '?&gt;'), $str);
        }


        //去除关键字中的空格
        $words = array(
            'javascript', 'expression', 'vbscript', 'jscript', 'wscript',
            'vbs', 'script', 'base64', 'applet', 'alert', 'document',
            'write', 'cookie', 'window', 'confirm', 'prompt'
        );

        foreach ($words as $word)
        {
            $word = implode('\s*', str_split($word)).'\s*';

            // We only want to do this when it is followed by a non-word character
            // That way valid stuff like "dealer to" does not become "dealerto"
            $str = preg_replace_callback('#('.substr($word, 0, -3).')(\W)#is', array($this, '_compact_exploded_words'), $str);
        }


        //清除链接和图片路径中的关键字,并将链接中的标签符号转换成实体
        do
        {
            $original = $str;

            if (preg_match('/<a/i', $str))
            {
                $str = preg_replace_callback('#<a[^a-z0-9>]+([^>]*?)(?:>|$)#si', array($this, '_js_link_removal'), $str);
            }

            if (preg_match('/<img/i', $str))
            {
                $str = preg_replace_callback('#<img[^a-z0-9]+([^>]*?)(?:\s?/?>|$)#si', array($this, '_js_img_removal'), $str);
            }

            if (preg_match('/script|xss/i', $str))
            {
                $str = preg_replace('#</*(?:script|xss).*?>#si', '[removed]', $str);
            }
        }
        while ($original !== $str);

        unset($original);


        //清除标签属性中出现的关键字
        $str = $this->_remove_evil_attributes($str, $is_image);


        //有些关键字可能直接以html标签的方式出现,如果是这样,将标签符号转成实体
        $naughty = 'alert|prompt|confirm|applet|audio|basefont|base|behavior|bgsound|blink|body|embed|expression|form|frameset|frame|head|html|ilayer|iframe|input|button|select|isindex|layer|link|meta|keygen|object|plaintext|style|script|textarea|title|math|video|svg|xml|xss';
        $str = preg_replace_callback('#<(/*\s*)('.$naughty.')([^><]*)([><]*)#is', array($this, '_sanitize_naughty_html'), $str);


        //有些关键字可能会以函数或者系统命令的方式出现,如果是这样,将函数调用的括号()转换成实体
        $str = preg_replace('#(alert|prompt|confirm|cmd|passthru|eval|exec|expression|system|fopen|fsockopen|file|file_get_contents|readfile|unlink)(\s*)\((.*?)\)#si',
            '\\1\\2&#40;\\3&#41;',
            $str);

        //再次清除字符中不被允许的关键字
        $str = $this->_do_never_allowed($str);


        //最终清洗后得到的str和最初做了清洗的str($converted_string)做比较,如果它们是一样的,
        //就说明图片是安全的(true),如果不一样就说明该图片中含有xss代码,是不安全的(false)
        if ($is_image === TRUE)
        {
            return ($str === $converted_string);
        }

        return $str;
    }

xss_clean的源码分析到此结束了,从代码中不难发现其本质就是我们说的那两点,通过转换恶意脚本的标签符号为实体从而破坏脚本的执行环境;将恶意的函数/命令关键字删除以免在服务器被执行;

csrf_verify()方法

csrf防御一般都是通过token(令牌)实现的,其原理就是在你的页面服务端为你生成一条token,当你发送请求时携带上该token和服务端保存的token进行比对,从而判断该请求是否合法。

那么就有个疑问,跨域的ajax提交表单时怎么做csrf保护呢?其实现在的ajax请求一般都被设计成了 REST API 的方式,而 REST API天生带token,并且 REST API 原则上是屏蔽会话信息的, 也就是它不接受提交所谓的cookie,既然不接受会话信息,那也就谈不上会话的伪造了;当然token被破解又是另一说了。

CI_Security类在构造方法中对csrf相关的设置做了初始化

public function __construct()
    {
        //是否防御csrf
        if (config_item('csrf_protection'))
        {
            //读取相关的变量
            foreach (array('csrf_expire', 'csrf_token_name', 'csrf_cookie_name') as $key)
            {
                if (NULL !== ($val = config_item($key)))
                {
                    $this->{'_'.$key} = $val;
                }
            }

            //如果有需要,可以将该cookie挂在某个命名空间下面
            if ($cookie_prefix = config_item('cookie_prefix'))
            {
                $this->_csrf_cookie_name = $cookie_prefix.$this->_csrf_cookie_name;
            }

            //生成token,该token同时会被存储在cookie中
            $this->_csrf_set_hash();
        }

        $this->charset = strtoupper(config_item('charset'));

        log_message('info', 'Security Class Initialized');
    }

接着我们在csrf_verify()看下csrf的防御

public function csrf_verify()
    {
        //如果是非表单提交而是页面渲染的话,这时候就需要将token写入cookie中去
        if (strtoupper($_SERVER['REQUEST_METHOD']) !== 'POST')
        {
            return $this->csrf_set_cookie();
        }

        //排除不需要进行csrf防御的页面
        if ($exclude_uris = config_item('csrf_exclude_uris'))
        {
            $uri = load_class('URI', 'core');
            foreach ($exclude_uris as $excluded)
            {
                if (preg_match('#^'.$excluded.'$#i'.(UTF8_ENABLED ? 'u' : ''), $uri->uri_string()))
                {
                    return $this;
                }
            }
        }

        //比对客户端提交过来的token和服务端存储token,如果二者不一致,那就说明此请求是伪造的
        if ( ! isset($_POST[$this->_csrf_token_name], $_COOKIE[$this->_csrf_cookie_name])
            OR $_POST[$this->_csrf_token_name] !== $_COOKIE[$this->_csrf_cookie_name]) // Do the tokens match?
        {
            $this->csrf_show_error();
        }

        //从全局数组中删除该token,因为该元素只是用来校验token,不应该出现在后续的业务逻辑中
        unset($_POST[$this->_csrf_token_name]);

        //根据配置决定是否每次请求结束后从新生成令牌,该配置慎用,因为页面上看不见的异步请求可能会让没使用的token失效
        if (config_item('csrf_regenerate'))
        {
            // Nothing should last forever
            unset($_COOKIE[$this->_csrf_cookie_name]);
            $this->_csrf_hash = NULL;
        }
        
        //重新生成token,当然如果token有存活周期的话,就不需要重新生成了
        $this->_csrf_set_hash();
        $this->csrf_set_cookie();

        log_message('info', 'CSRF token verified');
        return $this;
    }

接下来我们分析下输出类。


输出类 CI_Output

输出类就像文档中说的

发送Web 页面内容到请求的浏览器,并且还能对视图进行缓存。

输出内容到浏览器

我们在控制器中定义一个方法,然后在这个方法中加载一个视图,视图中写入一段文字,并在浏览器访问,发现该视图确实被加载成功了

image.png

一般情况下,我们要想向浏览器输出内容,必须靠 echoprint_rvar_dump之类的输出函数,可是在视图加载的过程中,并没有看见这些函数,那视图中的hello world到底是如何输出的

如果大家仔细看文档的话,输出类中有个_display()方法,文档中是这样描述该方法的(注意图中圈出的文字)

image.png

那么_display()方法是在哪里被调用?是在引导文件CodeIgniter.php中调用的

_display()被调用的地方

那现在我们就设想下是不是在该方法中最终输出视图中的hello world的?

进入_display()方法一探究竟

/**
 *
 * 仔细看下该方法的注释其实已经说的很明白了:
 * 发送最终的数据以及头信息和分析数据到浏览器,并结束基准测试,并且注释中也提到了所有视图
 * 数据会被自动的添加到final_output这个变量中去,那么很清楚了,我们视图中的 hello world
 * 就是在这被输出的
 *
 *
 * Display Output
 *
 * Processes and sends finalized output data to the browser along
 * with any server headers and profile data. It also stops benchmark
 * timers so the page rendering speed and memory usage can be shown.
 *
 * Note: All "view" data is automatically put into $this->final_output
 *   by controller class.
 *
 * @uses    CI_Output::$final_output
 * @param   string  $output Output data override
 * @return  void
 */
public function _display($output = '')
{

    // 这里为什么不使用加载器$CI->load('Class')去加载这两个类的原因,是因为该方法可能会在输出缓存的逻辑中被调用,
    // 输出缓存的逻辑是在引导文件Codeigter.php 323-326 行处,该处一旦发现缓存有效就直接向浏览器输出,这就会导致
    // 代码执行不到 & get_instance() 这个方法处,也就生成不了CI_Controller的示例对象$CI,那自然也不能用加载器loader去加载类库了

    $BM =& load_class('Benchmark', 'core');
    $CFG =& load_class('Config', 'core');


    // 如果CI_Controller类存在实例化该对象,那就说明这是正常的输出,而非缓存输出,
    // 该 & get_instance()调用是Codeigter.php的,而Codeigter.php中的& get_instance()
    // 是对CI_Controller中 & get_instance()方法的引用
    if (class_exists('CI_Controller', FALSE))
    {
        $CI =& get_instance();
    }

    // --------------------------------------------------------------------

    //这里不管你输出的是啥,都把它看作是buffer,这里的final_output是在我们
    //$this->load->view('test/test')时在view函数中使用php的内置的缓存机制
    //oupt_buffering生成的,关注php的内置缓存机制见:
    //
    if ($output === '')
    {
        $output =& $this->final_output;
    }

    // --------------------------------------------------------------------

    //在输出的同时判断是不是还需要进行缓存
    if ($this->cache_expiration > 0 && isset($CI) && ! method_exists($CI, '_output'))
    {
        $this->_write_cache($output);
    }

    // --------------------------------------------------------------------


    // 由于该函数是终极输出函数,那自然相关的数据采集就得在这做了,包括调用基准测试类采集脚本的执行时间,
    // 收集内存的使用情况等
    $elapsed = $BM->elapsed_time('total_execution_time_start', 'total_execution_time_end');

    if ($this->parse_exec_vars === TRUE)
    {
        $memory = round(memory_get_usage() / 1024 / 1024, 2).'MB';
        $output = str_replace(array('{elapsed_time}', '{memory_usage}'), array($elapsed, $memory), $output);
    }

    // --------------------------------------------------------------------

    // 是否对输出内容压缩,注意:输出压缩依赖zlib扩展
    if (isset($CI) // This means that we're not serving a cache file, if we were, it would already be compressed
        && $this->_compress_output === TRUE
        && isset($_SERVER['HTTP_ACCEPT_ENCODING']) && strpos($_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip') !== FALSE)
    {
        ob_start('ob_gzhandler');
    }

    // --------------------------------------------------------------------

    //我们之前说了由于final_output本质上就是个一段buffer,至于让客户端怎么解析,我们只需要指定相应的mime头信息就行了
    if (count($this->headers) > 0)
    {
        foreach ($this->headers as $header)
        {
            @header($header[0], $header[1]);
        }
    }

    // --------------------------------------------------------------------

    // 这段逻辑是为缓存读取服务的,正常的输出是不走这段逻辑的,注意:如果_compress_output为true,
    // 也就意味着读取的缓存是被压缩了的,如果客户端不支持压缩则必须解压后才能输出
    if ( ! isset($CI))
    {
        if ($this->_compress_output === TRUE)
        {
            if (isset($_SERVER['HTTP_ACCEPT_ENCODING']) && strpos($_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip') !== FALSE)
            {
                header('Content-Encoding: gzip');
                header('Content-Length: '.strlen($output));
            }
            else
            {
                // User agent doesn't support gzip compression,
                // so we'll have to decompress our cache
                $output = gzinflate(substr($output, 10, -8));
            }
        }

        echo $output;
        log_message('info', 'Final output sent to browser');
        log_message('debug', 'Total execution time: '.$elapsed);
        return;
    }

    // --------------------------------------------------------------------

    //这里就是将程序运行过程中采集的数据通过profiler生成报表,不过感觉profiler生成报表的方式不雅观,
    //在代码中拼html的方式导致生成的报表样式很难看,其实我们自己拿到这些数据可以通过js制作一个好看的调试控制台
    if ($this->enable_profiler === TRUE)
    {
        $CI->load->library('profiler');
        if ( ! empty($this->_profiler_sections))
        {
            $CI->profiler->set_sections($this->_profiler_sections);
        }

        // If the output data contains closing </body> and </html> tags
        // we will remove them and add them back after we insert the profile data
        $output = preg_replace('|</body>.*?</html>|is', '', $output, -1, $count).$CI->profiler->run();
        if ($count > 0)
        {
            $output .= '</body></html>';
        }
    }



    //如果CI_Controller自身带有一个_output()的方法,将数据扔给它输出,否则就做最终的输出
    if (method_exists($CI, '_output'))
    {
        $CI->_output($output);
    }
    else
    {
        echo $output; // Send it to the browser!
    }

    log_message('info', 'Final output sent to browser');
    log_message('debug', 'Total execution time: '.$elapsed);
}

在上述的源码分析中我们说的输出给浏览器的数据 final_output,在输出类来看本质就是buffer,我们也说到 final_output 是 $this->load->view() 函数中通过php内置的缓存机制oupt_buffering生成的,其实也就是很简单的几行代码,在Loader.php 中936-944行处打开一个缓冲区,读取其中的内容通过调用输出类 Outputappend_output() 方法将内容扔给final_output,从而读取到了视图中的hello world

image.png

对于加载视图,并没有显式的调用输出函数但却输出了视图 hello world 的疑惑就解答完毕了,可以看到CI对于内容输出的处理很 "粗暴",管你是网页还是json文本或图片,都把你们看成是buffer,直接输出该buffer并指定一个mime头让浏览器自己解析,这不就是对http协议的封装么?

接下来看下,输出类中另一个很重要的功能:网页缓存。

网页缓存

网页缓存包含两部分,缓存读取与生成缓存。

缓存生成

输出类中提供了一个方法cache()来生成缓存,我们可以在控制器或视图页面中通过调用$this->output->cache($minute) 实现网页缓存。该方法在内部设置一个保存缓存过期时间的变量,该变量会作为生成缓存的一个条件

public function cache($time)
{
    $this->cache_expiration = is_numeric($time) ? $time : 0;
    return $this;
}

伴随着输出的同时生成了缓存,在_display()方法中通过调用_write_cache()函数生成缓存

image.png

我们分析下_write_cache()方法,看下具体的缓存生成的逻辑

public function _write_cache($output)
{
    //判断缓存文件所在的目录是否存在并且有写入的文件的权限
    $CI =& get_instance();
    $path = $CI->config->item('cache_path');
    $cache_path = ($path === '') ? APPPATH.'cache/' : $path;

    if ( ! is_dir($cache_path) OR ! is_really_writable($cache_path))
    {
        log_message('error', 'Unable to write cache file: '.$cache_path);
        return;
    }

    //生成缓存文件名,如果想缓存参数,就把参数写入到文件名中去,这里要注意下,
    //带参数的缓存和不带参数的缓存虽然缓存内容一样,但由于文件名的原因,它们是不同的缓存
    $uri = $CI->config->item('base_url')
        .$CI->config->item('index_page')
        .$CI->uri->uri_string();

    if ($CI->config->item('cache_query_string') && ! empty($_SERVER['QUERY_STRING']))
    {
        $uri .= '?'.$_SERVER['QUERY_STRING'];
    }

    //对文件名加密
    $cache_path .= md5($uri);

    //如果生成或者打开文件失败,就返回,ps:fopen当文件不存在时会生成文件
    if ( ! $fp = @fopen($cache_path, 'w+b'))
    {
        log_message('error', 'Unable to write cache file: '.$cache_path);
        return;
    }

    //写入缓存,注意:由于同一时间对于一个文件来说只能有一个写入者,所以打开文件的方式应该使用排它锁lock_ex,
    if (flock($fp, LOCK_EX))
    {
        //接着是根据输出时是否指定了压缩来决定生成的缓存是否也应该压缩,
        //同时记录设置缓存将来输出时的mime类型,默认将缓存内容输出成网页
        if ($this->_compress_output === TRUE)
        {
            $output = gzencode($output);

            if ($this->get_header('content-type') === NULL)
            {
                $this->set_content_type($this->mime_type);
            }
        }

        //得到缓存的过期时间
        $expire = time() + ($this->cache_expiration * 60);

        //将过期时间和头信息序列化,并做些手脚,方便将来读取缓存时通过正则获取到这些信息
        $cache_info = serialize(array(
            'expire'    => $expire,
            'headers'   => $this->headers
        ));

        $output = $cache_info.'ENDCI--->'.$output;

        //将数据写入缓存文件中,思考下为什么不直接file_put_content()一次性写入?
        for ($written = 0, $length = strlen($output); $written < $length; $written += $result)
        {
            if (($result = fwrite($fp, substr($output, $written))) === FALSE)
            {
                break;
            }
        }

        //解除文件锁
        flock($fp, LOCK_UN);
    }
    else
    {
        log_message('error', 'Unable to secure a file lock for file at: '.$cache_path);
        return;
    }

    fclose($fp);

    //如果写入成功,修改缓存文件的权限,并对客户端的缓存做一些设置,失败的话就删除缓存文件
    if (is_int($result))
    {
        chmod($cache_path, 0640);
        log_message('debug', 'Cache file written: '.$cache_path);
      
        //对客户端缓存也做一些设置,
        //set_cache_header()方法第一个参数是$last_modified,其实就是文件的最后一次修改时间filemtime(),
        //由于缓存写入的时间就是当前服务器的时间,所以这里的$last_modified就是$_SERVER['REQUEST_TIME']
        $this->set_cache_header($_SERVER['REQUEST_TIME'], $expire);
    }
    else
    {
        @unlink($cache_path);
        log_message('error', 'Unable to write the complete cache content at: '.$cache_path);
    }
}

缓存生成的逻辑大概就这样,我们还在代码中看到在生成服务端缓存的同时通过调用set_cache_header()对客户端的缓存也做了设置。

/*
 *  该函数的意思如果我发现你客户段的缓存还没失效的话,我返回一个304,这样客户端你读你本地缓存的就行了,
    如果你客户端的缓存失效了,那么你读取了服务端的缓存后麻烦你客户端也缓存一下,免得你每次跑来请求服务端浪费带宽
*/
public function set_cache_header($last_modified, $expiration)
{
    $max_age = $expiration - $_SERVER['REQUEST_TIME'];

    if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && $last_modified <= strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']))
    {
        $this->set_status_header(304);
        exit;
    }
    else
    {
        header('Pragma: public');
        header('Cache-Control: max-age='.$max_age.', public');
        header('Expires: '.gmdate('D, d M Y H:i:s', $expiration).' GMT');
        header('Last-modified: '.gmdate('D, d M Y H:i:s', $last_modified).' GMT');
    }
}

缓存生成的源码就结束了,接下来看下缓存读取的源码。

缓存读取

缓存一般就应该在系统做了最基本的初始化后访问资源时读取,所以我们发现CI框架的读取缓存的入口在引导文件CodeInigter.php中,通过调用_display_cache()方法发现一旦发现发现缓存有效,就不用再往下执行了,直接输出

image.png

看下缓存读取方法_display_cache()的源码

public function _display_cache(&$CFG, &$URI)
{

    //----------------------------采用和生成缓存一样的方式得到文件名------------------------

    $cache_path = ($CFG->item('cache_path') === '') ? APPPATH.'cache/' : $CFG->item('cache_path');

    $uri = $CFG->item('base_url').$CFG->item('index_page').$URI->uri_string;

    if ($CFG->item('cache_query_string') && ! empty($_SERVER['QUERY_STRING']))
    {
        $uri .= '?'.$_SERVER['QUERY_STRING'];
    }

    $filepath = $cache_path.md5($uri);


    //缓存文件不存在或者打开失败,就返回
    if ( ! file_exists($filepath) OR ! $fp = @fopen($filepath, 'rb'))
    {
        return FALSE;
    }

    //由于文件读取时并不拒绝他人在同一时间也读,所以这里用共享锁LOCK_SH
    flock($fp, LOCK_SH);

    //读取缓存内容
    $cache = (filesize($filepath) > 0) ? fread($fp, filesize($filepath)) : '';

    flock($fp, LOCK_UN);
    fclose($fp);

    //对内容处理下,我们之前在生成缓存时说过,对序列化信息动手脚的原因就是方便这里用正则匹配出它们来
    if ( ! preg_match('/^(.*)ENDCI--->/', $cache, $match))
    {
        return FALSE;
    }

    //读取保存在缓存文件中的过期时间
    $cache_info = unserialize($match[1]);
    $expire = $cache_info['expire'];

    $last_modified = filemtime($cache_path);

    // 如果缓存失效就删除该缓存文件
    if ($_SERVER['REQUEST_TIME'] >= $expire && is_really_writable($cache_path))
    {
        // If so we'll delete it.
        @unlink($filepath);
        log_message('debug', 'Cache file has expired. File deleted.');
        return FALSE;
    }
    else
    {
        //如果没失效,对客户断的缓存也做下设置
        $this->set_cache_header($last_modified, $expire);
    }

    //读取缓存文件的mime类型后设置,以便输出到浏览器解析成功解析它
    foreach ($cache_info['headers'] as $header)
    {
        $this->set_header($header[0], $header[1]);
    }

    //缓存也是buffer,通过调用_display输出
    $this->_display(substr($cache, strlen($match[0])));
    log_message('debug', 'Cache file is current. Sending it to browser.');
    return TRUE;
}

至此整个CI框架对于输入输出的源码分析就结束,通过分析源码,我们学习了表单处理,安全过滤,网页缓存,php的输出控制等,虽说有点繁琐,但这一路下来还是收获满满。

我们也看到事物的万变不离其宗,所谓的输入尽管兜转迂回其本质归根结底是对全局数组的操作。而所谓的输出尽管像幽灵一样躲在暗处其本质还是对 echo,print_r 这些输出函数的高度包装。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,937评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,503评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,712评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,668评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,677评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,601评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,975评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,637评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,881评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,621评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,710评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,387评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,971评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,947评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,189评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,805评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,449评论 2 342

推荐阅读更多精彩内容