分类 PHP 下的文章

TOC的生成

TOC是什么

TOC 即 Table of Content,就是将文档中的 h1-h6 抽取出来,并结构化的展示;可以通过链接直接跳转到相应的内容 (这里我使用锚点)

这个本身是没什么好写的,但是涉及到树形的结构化处理,要考虑到怪异的层级问题。

方案

由于原始文档的格式(markdown,html,asciidoc)比较多,但是用于最后展示的还是 html,所以为了方便统一的处理,先将文档都选染成html

toc.png

  1. 使用 ParseDown 渲染markdown
  2. 使用 Crawler 解析html
  3. 提取 h{n},结构化 h{n},并设置html中的 id 属性
  4. 展示树形 TOC

预览

9FBAC831-0717-4728-A06F-CA5CCC4D62AF.png

实现

安装依赖包

composer require erusev/parsedown-extra
composer require symfony/dom-crawler
composer require symfony/css-selector

代码实现

public function getTocAttribute() {
    if ($this->headerNodes) {
        return $this->headerNodes;
    }
    $crawler = new Crawler();
    $crawler->addHtmlContent($this->html, 'utf-8');
    $this->headerNodes = $crawler->filter('h1,h2,h3,h4,h5,h6')->each(function (Crawler $node, $i) {
        $attrId = 'header_' . $i;
        $node->getNode(0)->setAttribute('id', $attrId);
        return [
            'target' => '#' . $attrId,
            'level' => intval(substr($node->nodeName(), 1)),
            'text' => $node->text(),
        ];
    });
    $this->tocedHtml = $crawler->html();
    return $this->headerNodes;
}

树形结构化

class MenuItem {
    public $level;
    public $text;
    public $target;
    public $children = [];
    public function __construct($target, $level, $text) {
        $this->target = $target;
        $this->level = $level;
        $this->text = $text;
    }
}
$rootMenu = new MenuItem(-1, 0, '__root__');
foreach ($wiki->toc as $hn) {
    $menuItem = new MenuItem($hn['target'], $hn['level'], $hn['text']);
    // 每个菜单都从根目录开始寻找
    $lastMenu = $rootMenu;
    while (true) {
        $nestMenu = end($lastMenu->children);
        // 如果比上个层级还大,就挂载到他后面
        if (!$nestMenu && $hn['level'] > $lastMenu->level) {
            $lastMenu->children[] = $menuItem;
            break;
        }
        // 如果循环下来发现没有匹配的层级,则放进根目录
        if ($hn['level'] < $nestMenu->level) {
            $rootMenu->children[] = $menuItem;
            break;
        }
        // 如果和上个层级一样,就赛到上层的children
        if ($nestMenu->level >= $hn['level']) {
            $lastMenu->children[] = $menuItem;
            break;
        }
        $lastMenu = $nestMenu;
    }
}

渲染输出

function tree($menu) {
    $html = '<ul>';
    $html .= '<li><a href="' . $menu->target . '">' . $menu->text . '</a>';
    if ($menu->children) {
        foreach ($menu->children as $subMenu) {
            $html .= tree($subMenu);
        }
    }
    $html .= '</li></ul>';
    return $html;
}
echo '<div class="wiki-index"><div class="wiki-index-header">文章目录</div><div class="wiki-index-body">';
foreach ($rootMenu->children as $menu) {
    echo tree($menu);
}
echo '</div></div>';

typecho代码高亮插件

这是很久之前写的一个typecho代码高亮插件了,由于是自己用的,所以高亮theme之类的都没有弄成可配置的;

自定义修改

需要更改theme的话,可以直接修改 HighLight/Plugin.php 文件里面引用css的代码行

echo '<link rel="stylesheet" href="' . $url . '/styles/solarized_dark.css">' . "\n";

@疯狂的杰作 同学提出是否要分享一下,所以就拿出来了。

安装方法

下载 HighLight.zip,解压

上传到 /path/to/your/typecho/usr/plugins/ 目录下,使用 admin 帐号登录你的 typecho 后台,在插件管理里面启用他就可以啦。

HighLight.zip

正则-编程语言调用代码的生成

演示: 正则表达式测试工具

功能介绍

给定一个正则,生成php的正则调用代码。

一般正则中都会或多或少的包含具体编程语言中的特殊字符,还需要考虑到具体语言中字符串的转义;

下面列出了一些常见的要考虑的情况:

1. 语言是否支持多行字符串?
2. 是否有定界符?
3. 语言的正则引擎是否支持逆序环视?
4. 正则修正符?

具体场景

我们拿PHP做个例子:

写一个特殊字符覆盖比较全的正则,并写出他的PHP匹配代码:

要匹配的内容

<!-- 模拟一个要匹配的字符串, 我们要取出其中的<div/>区块的内容 -->
<article>
  <div class="quote">'引用\/'</div>
</article>

正则

<div class="quote">'(.+?\\/)'</div>

PHP匹配代码

$contents = <<<EOT
<article>
  <div class="quote">'引用\/'</div>
</article>
EOT;

if (preg_match('/<div class="quote">\'(.+?)\\\\\/\'<\/div>/', $contents, $match)) {
    var_dump($match);
}

注意: 上面的正则写到PHP代码中的时候,以下字符进行了转义:

1. ' 单引号
2. / 定界符
3. \ 转义字符,这是个比较特殊的字符

实现思路

要实现一个可用的代码生成,无非就是要把上面提到的特殊字符给转义了;

本想要用正则再对正则的字符串转义,最后发现不太靠谱,越写越复杂;

记起之前看过的redis conf分析相关的代码之后,遂选择逐字判断字符,并对字符转义,效果不错;注意优先级!

public function escape($value)
{
    $chars = str_split($value);
    $len = count($chars);
    $result = '';
    for ($i = 0; $i < $len; $i++) {
        if ($chars[$i] === '\\' && $i < $len - 1) {
            // 转义字符的判断优先级最高,可以避免 \' 正则的重复转义
            $i++; // 跳过后面一个字符
            if ($chars[$i] === '\\') {
                $result .= '\\\\\\\\';
            } else {
                $result .= '\\' . $chars[$i];
            }
        } else if ($chars[$i] === '\'') {
            // 单引号,php字符串边界
            $result .= '\\\'';
        } else if ($chars[$i] === '/') {
            // / php正则定界符
            $result .= '\\/';
        } else {
            $result .= $chars[$i];
        }
    }

    return $result;
}

占位图片的优化

背景

在前端开发切图的时候,很多时候示例图片都是不需要的,只需要一个尺寸的图片,还方便后端清晰的知道要输出的图片尺寸。

对于访问时候的优化,有2个方案:

  1. 使用cdn回源 (对于对外开放的服务,自然会在cdn上留下大量一次性的图片)
  2. 在服务器的内存中生成图片返回 (由于只是提供开发者使用,只要对http缓存做好优化,压力不会很大)

大家都知道对于静态图片,nginx对于缓存这些东西都给我们做好了,只需要1~2行的配置就可以搞定;但是注意了,我们这里是在程序中动态生成图片,所以我们需要关心http中浏览器的缓存头

Etag

Expires:
Cache-Control: max-age=3600
Last-Modified:
If-Modified-Since:
ETag:
If-None-Match:

Cache-Control

http-cache-decision-tree.png

这里我采用的是对 text, fg, bg, width, height 进行md5处理,因为这几个值就可以确定唯一的图片

$md5 = md5(json_encode(compact('width', 'height', 'text', 'bg', 'fg')));
$etags = Input::getEtags();
if (isset($etags[0])) {
    $etag = str_replace('"', '', $etags[0]);
    if ($etag === $md5) {
        App::abort(304, '', ['Content-Type' => 'image/png', 'Cache-Control' => 'public, max-age=31536000', 'ETag' => $md5]);
    }
}

YUV 图片文字颜色的获取 (颜色明亮度)

更多的使用的时候,往往我们只需要指定背景色,不关心文字的颜色(能看清就行);于是在不同的背景色下面,我们都需要让文字颜色和背景色保持一定的对比度,好了直接看代码吧

$greyLevel = $r * .229 + $g * .587 + $b * .114;
if ($greyLevel >= 189) { // 192
    $fg = '#666666';
} else {
    $fg = '#ECF0F1';
}

字体选择的优化

全英文的情况下 Georgia 的展现效果比较不错,但是包含中文之后效果就大打折扣,所以这里有个小兼容

$fontFamily = 'Georgia.ttf';
// 如果包含中文
if (preg_match('/[\x{4e00}-\x{9fa5}]/u', $text)) $fontFamily = 'yahei_mono.ttf';

安全控制

由于图片的生成会消耗一定的内存,而且图片尺寸越大,耗的内存越多,所以这里对生成图片的尺寸做了1个限制,就是高宽都必须小于等于 2048,否则返回 404

使用phpexcel解析Excel

使用composer管理

composer require phpoffice/phpexcel
<?php

error_reporting(E_ALL);
date_default_timezone_set('Asia/Shanghai');

require __dir__ . '/../vendor/autoload.php';

// ... 注意自己赋值$file
$excel = PHPExcel_IOFactory::load($file);
$sheetCount = $excel->getSheetCount();
echo "sheet count:$sheetCount" . PHP_EOL;
foreach ($excel->getWorksheetIterator() as $sheet) {
    if ($sheet->getSheetState() === 'hidden') {
        continue;
    }
    $sheetName = $sheet->getTitle();
    echo "sheet name: $sheetName" . PHP_EOL;
    $highestColumn = $sheet->getHighestColumn();
    $highestColumnIndex = PHPExcel_Cell::columnIndexFromString($highestColumn);
    $highestRow = $sheet->getHighestRow();
    for ($row = 1; $row <= $highestRow; $row++) {
        $columns = [];
        for($col = 0; $col < $highestColumnIndex; $col++) {
            $cell = $sheet->getCellByColumnAndRow($col, $row);
            $val = $cell->getValue();
            if ($val instanceof PHPExcel_RichText) {
                $columns[] = trim($val->getPlainText());
            } else {
                $columns[] = trim($val);
            }
        }
        var_dump($columns);
    }
}

过滤隐藏的worksheet

if ($sheet->getSheetState() === 'hidden')

过滤隐藏的行

由Excel中auto filter隐藏的行

if ($sheet->getRowDimension($row)->getVisible())

获取单元格颜色

获取文字颜色

$cell->getStyle()->getFont()->getColor()->getRGB();

获取填充颜色

$cell->getStyle()->getFill()->getStartColor()->getRGB();

颜色的差异值

是这样,运营那边对不同类型的数据进行了颜色的分类,但是是多个人整理的,所以紫色淡紫色的运营那边认为是同一个东西;但是程序里面是没法判断,因为紫色的区域太大了;所以这边就需要用到颜色的差异值比对了。

| YUV |      -      |   -   |
|-----|-------------|-------|
|  Y  |     Luma    | 明亮度 |
|  U  | Chrominance |  色度  |
|  V  |    Chroma   |  浓度  |

Y'UV 的发明是由于彩色电视与黑白电视的过渡时期。黑白视讯只有 Y(Luma,Luminance)视讯,也就是灰阶值。到了彩色电视规格的制定,是以 YUV/YIQ 的格式来处理彩色电视图像,把 UV 视作表示彩度的 C(Chrominance或Chroma),如果忽略 C 讯号,那么剩下的 Y(Luma)讯号就跟之前的黑白电视讯号相同,这样一来便解决彩色电视机与黑白电视机的相容问题。

|  Lab  |  -   |   -   |
|-------|------|-------|
|   L   | Luma | 明亮度 |
|   a   |   a  |   -   |
|   b   |   b  |   -   |

RGB -> XYZ -> Lab -> delta E

RGB to closest predefined color
color distance
RGB到Lab的转换

| RGB |   -   |  -  |
|-----|-------|-----|
|  R  |  Red  | 红色 |
|  G  | Green | 绿色 |
|  B  | Blue  | 蓝色 |

php默认函数 imagecolorclosest // RGB几何距离,由于RGB空间不是颜色感知连续的,所以效果没有Lab'的好

这边有个实现好的颜色空间转换的代码 hasbridge/php-color

slim框架中pjax的实现

pjax = pushState + ajax

由于这是一个比较通用的组件,所以我把它写成了slim的Middleware (如果不知道middleware是什么或者怎么写,可以看这里)

大概原理是在slim框架渲染之后,输出之前,对dom结构进行分析,摘取出pjax所需要的部分,然后将这部分的内容作为response返回给浏览器的ajax请求

预览: 码农文库 点击分页可以看到效果

$app->add(new \Support\PjaxMiddleware());
<?php
namespace Support;

use Slim\Middleware;
use Slim\Http\Request;
use Slim\Http\Response;
use Symfony\Component\DomCrawler\Crawler;
class PjaxMiddleware extends Middleware
{
    public function call()
    {
        $request = $this->app->request();
        $response = $this->app->response();
        $this->next->call();
        if (!$request->headers('X-PJAX') || $response->isRedirect()) {
            return;
        }
        $this->filterResponse($response, $request->headers('X-PJAX-CONTAINER'))->setUriHeader($response, $request);
    }
    private function filterResponse(Response $response, $container)
    {
        $crawler = new Crawler($response->getBody());
        $response->setBody($this->makeTitle($crawler) . $this->fetchContents($crawler, $container));
        return $this;
    }
    private function makeTitle(Crawler $crawler)
    {
        $title = $crawler->filter('head > title')->html();
        return "<title>{$title}</title>";
    }
    private function fetchContents(Crawler $crawler, $container)
    {
        $content = $crawler->filter($container);
        if (!$content->count()) {
            $this->app->stop();
        }
        return $content->html();
    }
    private function setUriHeader(Response $response, Request $request)
    {
        $query = $request->get();
        unset($query['_pjax']);
        $response->header('X-PJAX-URL', $request->getResourceUri() . '?' . http_build_query($query));
    }
}

slim框架接入pysh

由于实际开发中需要查看接口返回值结构和实际的数据样例,每次都写controller再访问url看下是比较麻烦的。

所以如果能像 php -a 这样交互的方式调用网站应用中定义的model和function的话,将会极其的方便。

目录结构如下:

app/
public/
tinker

app为网站具体逻辑实现的部分
public下面有1个index.php为网站的入口
tinker就是我们需要接入pysh,实现REPL的文件了

由于Slim里面需要PATH_INFO的值,但是命令行运行的时候,这个值是不存在的,所以需要在代码中mock一下;代码如下:

#!/usr/bin/env php
<?php

require __DIR__ . '/vendor/autoload.php';

$app = require __DIR__ . '/app/bootstrap.php';

$app->environment = \Slim\Environment::mock([
    'PATH_INFO' => '/playground'
]);

$app->notFound(function () use ($app) {
    $path = $app->environment['PATH_INFO'];
    echo "Cannot route to $path" . PHP_EOL;
    $app->stop();
});

$app->error(function (\Exception $e) use ($app) {
    echo $e . PHP_EOL;
    $app->stop();
});

$app->any('/playground', function () {
    $config = new \Psy\Configuration([
        'tabCompletion' => true,
        'tabCompletionMatchers' => [
            new \Psy\TabCompletion\Matcher\ClassNamesMatcher,
            new \Psy\TabCompletion\Matcher\ClassMethodsMatcher,
            new \Psy\TabCompletion\Matcher\ClassAttributesMatcher,
            new \Psy\TabCompletion\Matcher\FunctionsMatcher,
        ],
    ]);
    $shell = new \Psy\Shell();
    $shell->run();
});

$app->run();

网易式评论箱的实现

预览

QQ20160416-0.png

实现

基础

  1. 表的设计
  2. 前端的实现

由于每个回复的展示都需要完整的引用路径,我们需要一个字段来记录本条回复所回复的回复 quote_id,在一个列表中如果每次都递归获取引用的评论,性能上会有很大的瓶颈,所以我们冗余一个字段,记录本条回复的引用路径 quote_path

CREATE TABLE `pre_comments` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `article_id` bigint(20) unsigned NOT NULL DEFAULT '0',
  `quote_id` bigint(20) unsigned NOT NULL DEFAULT '0',
  `quote_path` varchar(255) NOT NULL DEFAULT '' COMMENT '记录最近的20个值',
  `user_id` bigint(20) unsigned NOT NULL DEFAULT '0',
  `username` char(15) NOT NULL DEFAULT '',
  `content` varchar(1024) NOT NULL,
  `up` bigint(20) NOT NULL DEFAULT '0',
  `down` bigint(20) NOT NULL DEFAULT '0',
  `created_at` datetime NOT NULL,
  `updated_at` datetime NOT NULL,
  `deleted_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_article_id` (`article_id`),
  KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

数据展示流程:

  1. 获取最新的10条评论
  2. 获取与最新的10条评论相关被引用的评论,最大层级不超过20,超过20的使用查看更多跳转到单独的评论页面
  3. 组装数据,返回json
  4. Reactjs渲染数据

Untitled.png

用伪代码可以表示为如下结构

<CommentBox>
  <CommentList>
    <CommentItem>
      <CommentQuote>
        <CommentQuote>
          <CommentToolBar>
            <CommentForm></CommentForm>
          </CommentToolBar>
        </CommentQuote>
        <CommentToolBar>
          <CommentForm></CommentForm>
        </CommentToolBar>
      </CommentQuote>
      <CommentToolBar>
        <CommentForm></CommentForm>
      </CommentToolBar>
    </CommentItem>
  </CommentList>
  <CommentForm></CommentForm>
</CommentBox>

优化

  1. 分库分表
  2. 通用计数组件
  3. 缓存
  4. 静态化

通过查询场景来决定分库分表的策略

  1. 根据articleId查询最新的评论
  2. 根据articleId 和 commentId更新计数
  3. 根据articleId 和 quoteId写入新的评论
  4. 展示某个用户(userId)所有的评论

其中有两个分表的路由key articleIduserId

未完待续

php max_input_vars限制

这是一次线上 bug,后台 sku 修改的时候,使用了大量的数组,造成了 php 端 $stock 变量无法完全解析。

stock[1][price]=1234
stock[1][stock]=99
stock[1][id]=1
// ...
stock[500][price]=1234
stock[500][stock]=99
stock[500][id]=500

由于线上的 php 版本是由低版本升级上去的,所以以前是不会有这样的问题的,PHP 5.3.9为了防止hash冲突就加了那么个参数 max_input_vars

曾今天真的以为上面的例子的 input vars 应该是 500 个,看了 PHP 的源码之后才发现应该是 500 * 3;其实 PHP 是按照 & 为 token 来计数的。

// vim main/php_variables.c +438
// 这里的 separator,在 GET POST 中是 &; 在 COOKIE 中是 ;
var = php_strtok_r(res, separator, &strtok_buf);

// :458
while (var) {
    if (++count > PG(max_input_vars)) {
        // ...
    }
}

如何避免这样的问题

  1. 自然是想办法增加 max_input_vars 的大小,但这样有个难以逃避的问题,就是前端传过来数据的个数你是没办法知道应该是多少的
  2. 减少 post 的字段,先 js json_encode 一下,再用一个大字段传过来
  3. 使用 post 的 body 将数据传过来;在 php 端使用 file_get_contents('php://input') 来获取之后在 json_decode;我觉得这中方法比较可取,因为 restful 设计里面就采用的这种方法。

php中通配符的实现

该方法摘自Laravel5的Event getWildcardListeners

作用如下:

`item.*` -> `item.new`
         -> `item.edit`
class Str {
    public static function is($pattern, $value)
    {
        if ($pattern == $value) return true;

        $pattern = preg_quote($pattern, '#');
        $pattern = str_replace('\*', '.*', $pattern) . '\z';
        return (bool) preg_match('#^' . $pattern . '#', $value);
    }
}

该功能使用正则实现

  • * 替换成 .* 匹配所有字符
  • \z 这个转义序列从来没用过,查了手册是说不受修正符的影响,其实这边应该和$是一样的

PHP: Escape sequences - Manual

撸 php 源码

快速定位某个 php 函数在源码中的位置

# 所有php函数
ag 'PHP_FUNCTION\(\w+\)'
# 指定php函数
ag 'PHP_FUNCTION\(array_flip\)'

自定义个 shell 函数,方便搜索

# search php funtion in c source
function phpsrc()
{
    if [ $# -eq 0 ]; then
        echo 'usage: phpsrc <function>[ <dirname>]'
        return
    fi
    dirname="$2"
    if [ -z "$dirname" ]; then
        dirname=$(pwd)
    fi
    ag 'PHP_FUNCTION\('"$1"'\)' "$dirname"
}

快速定位 php 中的语言结构

# Zend/zend_compile.c
ag 'void zend_do_' Zend/zend_compile.c

获取Kohana的路由信息

为了debug方便,便想着把Konaha的生效路由都输出到一个页面,说闹就闹。

$routes = Route::all();
// 呵呵,尼玛返回值的属性全是 protected 的,还没有get方法,这是作死呢?
// 于是第一个想到是使用反射来获取值
function getProperty($obj, $prop) {
    $refc = new ReflectionClass($obj);
    $refp = $refc->getProperty($prop);
    $refp->setAccessible(true);
    return $refp->getValue($obj);
}

$routeContent = array();
foreach ($routes as $name => $aRoute) {
    $uri = getProperty($aRoute, '_uri');
    $defaults = getProperty($aRoute, '_defaults');
    $action = $defaults['controller'] . '::' . $defaults['action'];
    $routeContent[] = compact('name', 'uri', 'action');
}

var_dump($routeContent);

其实,刚开始的是我没有发现有setAccessible这个函数,这也是php5.3加上去的,想了个变态的方法:

ob_start();
var_dump($routes); // 这边是比较蛋疼的,Kohana的route居然是循环引用自己的,var_dump/var_export都是会到达一定的层级才会停下来
$contents = ob_get_clean();
# ... 然后这边用正则,呵呵

后来在群里问了下 @奇遇 大神,于是黑魔法就出现了,而且这个黑魔法的效率还是 php 的反射类的2倍,但是黑魔法也会产生一些问题,具体可看 官方的文档

function getProtected($obj, $prop) {
    $arr = (array) $obj;
    $key = "\0*\0" . $prop;
    return isset($arr[$key]) ? $arr[$key] : null;
}

function getPrivate($obj, $prop) {
    $arr = (array) $obj;
    $key = "\0" . get_class($obj) . "\0" . $prop;
    return isset($arr[$key]) ? $arr[$key] : null;
}

下面是演示:

PS: Kohana是个奇葩的框架