轻量级全文检索引擎 TNTSearch 和中文分词

之前全文检索一直用的Xunsearch, 但对于大多数中小项目来说, 用Xunsearch, Elastic总有点杀鸡用牛刀的感觉,所以想找一个轻量级的解决方案,碰巧在论坛看到一篇SummerTNTSearch介绍的一个
贴子 《TNTSearch - PHP 实现的全文索引引擎》,觉得很符合需求。

TNTSearch 的基本原理是用SQLite作为索引数据库,将要索引的字段进行分词,然后建立基于关键词的倒排索引。核心是2张表,一张存放分词后的关键词, 另一张存放关键词与文档对应的关系(相当于多对多的中间表)。

当用户输入一个查询时,将要查询的短语分词得到各个关键词,分别查询出每个关键词对应的文档ID, 再按文档中命中的关键词个数进行排序。

官方也提供了Laravel Scout的驱动 teamtnt/laravel-scout-tntsearch-driver,用起来很方便。唯一的不足就是对中文支持的不够,因为默认的分词器是以标点符号、特殊符号和空格为分隔符进行分词。

namespace TeamTNT\TNTSearch\Support;

class Tokenizer implements TokenizerInterface
{
    public function tokenize($text)
    {
        $text = mb_strtolower($text);
        return preg_split("/[^\p{L}\p{N}]+/u", $text, -1, PREG_SPLIT_NO_EMPTY);
    }
}

为了解决这个问题,我结合两个常用的php分词组件:scwsjieba-php,改写了官方的Laravel Scout驱动包, 加入了中文分词功能,发布到packagist上面。

packagist: vanry/laravel-scout-tntsearch

github: https://github.com/vanry/laravel-scout-tntsearch

有需要的同学可以去看下,readme上有使用说明,如果觉得对你有帮助,欢迎star和提issue :smiley:。

统一集中式管理你的 meta title 和 description

需求

平时在开发过程中,一般都是把标题、描述等标签直接写在模板中,如

# articles/show.blade.php

@section('title', "{$article->title} - {$article->category->name} - {$sitename}") `

在模板少的情况下问题还不大,一旦项目中有多套模板共用相同的标题和描述(比如pcmobile模板,多套不同风格主题的模板),管理起来就很繁琐了,每次修改都要先找到对应的模板,再一个一个地改。

如果能将这些标签内容抽取出来放在配置文件供复用,那就再方便不过了。

优点

  1. 再也不用人工一个一个去找对应的模板了。
  2. 用配置而不是硬编码的方式,达到一处修改,处处修改的目的。
  3. 模板中的meta标签更简洁了,再也不用字符串和变量混合在一起了。

配置文件

config目录下面新建meta.php文件

# config/meta.php
# 我喜欢用路由命名作为 key

return [
    'title' => [
        'home' => '首页标题',
        'articles.show' => '{title} - {category}',
    ],

    'description' => [
        'home' => '{sitename}为您提供...',
        'articles.show' => '{description}',
    ],
];

帮助函数

# app/helpers.php

if (! function_exists('meta')) {
    /**
     * Get meta tag from configuration.
     *
     * @param  string  $name
     * @return string
     */
    function meta($tag, $name, array $parameters = [])
    {
        $tag = config("meta.{$tag}")[$name];
       
        $search = array_map(function ($key) {
            return '{'.$key.'}';
        }, array_keys($parameters));

        $replace = array_values($parameters);

        return str_replace($search, $replace, $tag);
    }
}


if (! function_exists('meta_title')) {
    /**
     * Get meta title from configuration.
     *
     * @param  string  $name
     * @return string
     */
    function meta_title($name, array $parameters = [])
    {
        return meta('title', $name, $parameters);
    }
}

if (! function_exists('meta_description')) {
    /**
     * Get meta description from configuration.
     *
     * @param  string  $name
     * @return string
     */
    function meta_description($name, array $parameters = [])
    {
        return meta('description', $name, $parameters);
    }
}

用法

用法比较像route函数,由于是基于字符串替换,所以不能直接传对象。

# layouts/app.blade.php

<title>@yield('title') - {{ config('app.name') }}</title>


# articles/show.blade.php

@section('title', meta_title('articles.show', [
    'title' => $article->title,
    'category' => $article->category->name
]))


OR

// 命名的好处
@section('title', meta_title(Route::currentRouteName(), [
    'title' => $article->title,
    'category' => $article->category->name
]))

说明:当然了,除了标题描述标签之外,其他的标签也可以采用这种方式来统一管理。

Laravel使用二级缓存提高缓存命中率和内存使用效率

起因

一直都没找到二级缓存在php中应用的比较好的资料和案例,由于范凯RobbinWeb 应用的缓存设计模式Hibernate二级缓存的启示,记下这篇二级缓存在Eloquent ORM中的应用。

过程

比如博客的首页调用最新的20篇文章,相信不少同学在刚开始使用缓存的时候,会写下如下代码:

# 控制器
public function index()
{
    $articles = Article::latestArticles(20);

    return view('articles.index', ['articles' => $articles]);
}

# 模型
class Article extends Model
{
    public static function latestArticles($amount = 20)
    {
        return Cache::remember('articles:latest', 10, function () use ($amount) {
            return static::latest('id')->take($amount)->get();
        });
    }
}

当然,模型中还能预加载每篇文章的分类,作者和tag信息,看起来没有任何问题,而且非常符合人类直觉。但是,放大到全站缓存来看,还是有很大的改善空间。

首先,首页缓存的是一个包含20article对象的集合,集合的每一个单独的article对象除了在首页出现,还会在分类、作者和tag等列表页出现,还有文章详情页,而缓存的集合数据没办法在这些页面间共用,重复缓存大量相同的article对象是对内存资源的很大浪费,要是article中的text字段content没有单独拆分出去,内存浪费得就更严重了。

其次,不像详情页数据改动很少,首页作为列表页来说,更新频率很高,设置的缓存时间比较短,一般是分钟级别,缓存命中率并不高。

为了有效解决这两个问题,二级缓存就派上用场了,先说下自己对二级缓存的理解。

一级缓存可以看成是数据库里存的数据的一个镜像,只不过把数据从数据库搬到内存,一个key对应一条记录。key一般为表的标识符,比如keyarticles:1存的value就是id=1article对象。一级缓存时间可以设得比较长,甚至forever也行,对象修改删除时,只要删除对应的key就行。

二级缓存可以看成业务逻辑的缓存,首页最新20条文章 就属于业务逻辑,只缓存这20条文章的id,极大地节省了内存占用。等需要用到具体的数据再去一级缓存取,一级缓存没有才去查询数据库,由于都是主键查询,不会造成表的描述,查询效率非常高。即使二级缓存很快过期,一级缓存也不会失效。

个人觉得理解二级缓存最难的是要接受n+1查询这点,这个问题争议很大,明明各种ORM为了避免n+1使用了预加载,我们反而要抛弃它。包括我当初阅读范凯的《Web 应用的缓存设计模式》也心存疑惑,直到去了解了Hibernate二级缓存机制和自己在项目中的实践发现,还真是他说的那样。

拆分n+1条查询的方式,看起来似乎非常违反大家的直觉,但实际上这是真理,我实践经验证明:数据库服务器的瓶颈往往是磁盘IO,而不是SQL并发数量。因此 拆分n+1条查询本质上是以增加n条SQL语句为代价,简化复杂SQL,换取数据库服务器磁盘IO的降低 当然这样做以后,对于ORM来说,有额外的好处,就是可以高效的使用缓存了。

使用二级缓存来重构latestArticles方法


public static function latestArticles($amount = 20)
{
    // 二级缓存
    $ids = Cache::remember('articles:latest:ids', 10, function () use ($amount) {
        return static::latest('id')->take($amount)->pluck('id');
    });

    return $ids->map(function ($id) {
        // 一级缓存
        return static::findById($id); 
    });

}

public static function findById($id)
{
    return Cache::rememberForever("articles:{$id}", function () use ($id) {
        return static::find($id);
    });
}
    

除了返回Collection,还可以返回Generator

public static function latestArticles($amount = 20)
{
    // 二级缓存
    $ids = Cache::remember('articles:latest:ids', 10, function () use ($amount) {
        return static::latest('id')->take($amount)->pluck('id');
    });

    foreach ($ids as $id) {
        // 一级缓存
        yield static::findById($id); 
    }
}

更新与删除

一级缓存的更新和删除可能通过模型的updateddeleted事件来清除对应的缓存。二级缓存由于缓存时间比较短,影响不大。

关联关系

关联模型的缓存可能通过accessor来设置一个虚拟的属性来设置,比如在Article模型与Content模型是一对一的关系。
Article中:


// 一对一关联
public function content()
{
    return $this->hasOne(Content::class);
}

// contents表字段: article_id, body
public function getContentAttribute()
{
    return Cache::rememberForever("contents:{$this->id}", function () {
        return $this->content->body;
    });
}

Laravel Lumen分离Http日志和Console日志

解决命令行和fastcgi不同环境下生成的日志导致的权限问题


# Laravel: AppServiceProvider.
public function boot()
{
    if (PHP_SAPI == 'cli') {
        Log::useDailyFiles(storage_path('logs/artisan.log'), config('app.log_max_files', 5));
    }
}


# Lumen: /bootstrap/app.php
$app->configureMonologUsing(function(Monolog\Logger $monolog) {
    $filename = storage_path('logs/lumen-'.PHP_SAPI.'.log');
   
    $handler = new Monolog\Handler\RotatingFileHandler($filename, env('LOG_MAX_FILES', 5));
   
    $handler->setFormatter(new Monolog\Formatter\LineFormatter(null, null, true, true));
   
    $monolog->pushHandler($handler);
});

PHP自动加载配置


<?php

namespace App\Util;

use ArrayAccess;

class Config implements ArrayAccess
{
    protected $path;

    protected $config = [];

    public function __construct($path)
    {
        $this->path = $path;
    }

    public function offsetExists($key)
    {
        return isset($this->config[$key]);
    }

    public function offsetGet($key)
    {
        if (! isset($this->config[$key])) {
            $file = $this->path.'/'.$key.'.php';

            $this->config[$key] = file_exists($file) ? require $file : null;
        }

        return $this->config[$key];
    }

    public function offsetSet($key, $value)
    {
        $this->config[$key] = $value;
    }

    public function offsetUnset($key)
    {
        unset($this->config[$key]);
    }
}

简单工厂模式遵循开放封闭原则


<?php

class Factory
{
    protected $items;

    public function __construct(array $items = [])
    {
        $this->items = $items;
    }

    public function register($key, $value = null)
    {
        if (is_array($key)) {
            foreach ($key as $alias => $class) {
                $this->register($alias, $class);
            }
        } else {
            $this->items[$key] = $value;
        }

    }

    public function create($key)
    {
        if (array_key_exists($key, $this->items)) {
            return new $this->items[$key];
        }

        throw new InvalidArgumentException("Key [{$key}] is not registered.");
    }
}

// Test

$factory = new Factory;

$factory->register('std', StdClass::class);
$factory->register('dom', DOMDocument::class);
$factory->register('exception', Exception::class);

$classes = [
    'time' => DateTime::class,
    'queue' => SplQueue::class,
    'array' => ArrayObject::class,
];

$factory->register($classes);

var_dump(
    $factory->create('dom'),
    $factory->create('queue'),
    $factory->create('exception')
);

Laravel分页链接url伪静态

Laravel强大的路由功能使其url不管是对搜索引擎还是用户都是非常的友好,但美中不足的是它的分页默认url却是带参数查询的(http://www.lara.com/posts?page=2),这让做惯了seo的我心里总感觉有点别扭。

为了看着顺眼点,决定动手对它改造一下,首先要了解它的工作原理,打开分页器的服务提供者PaginationServiceProvider.php,查看源代码:

<?php

namespace Illuminate\Pagination;

use Illuminate\Support\ServiceProvider;

class PaginationServiceProvider extends ServiceProvider
{
    /**
     * Register the service provider.
     *
     * @return void
     */
    public function register()
    {
        Paginator::currentPathResolver(function () {
            return $this->app['request']->url();
        });

        Paginator::currentPageResolver(function ($pageName = 'page') {
            return $this->app['request']->input($pageName);
        });
    }
}

register方法中发现,laravel分页器使用http request对象中的page参数值作为当前页码,而page的值是通过GET方式获取的,而形如http://www.lara.com/posts/page/2url中没有任何GET参数。既然不能通过静态url不能传入page参数,我们就手工注入。在路由中,使用request对象的merge方法将page参数添加进去。

Route::get('posts/page/{page?}', function ($page = 1) {
    Request::merge(['page' => $page]);
    $posts = Post::orderBy('id', 'DESC')->paginate(3);

    return view('post.index', ['posts' => $posts]);
});

阅读剩余部分

SEO和网站性能优化

简单记录一下自己平时在SEO和网站性能优化方面的基本流程,主要按照访问先后和整体到局部的顺序来进行。

网络环境

  • DNS
  • CDN
  • BGP机房

系统优化

  • gzip cache
  • linux nginx mysql php-fpm
  • web前端加速

站点级别

  • robots.txt
  • url规划
  • 链接结构
  • 面包屑导航

阅读剩余部分

php实现简易网络爬虫

因为最近项目一直用到定向抓取各大网站的爬虫技术,加上自己对爬虫还算是比较熟悉,所以博客的第一篇就献给网络爬虫吧,内容主要是使用php来实现获取给定百科词条的所有相关词条的标题和简介。

由于只是展示爬虫的基本工作原理和实现过程,所以程序会设计得比较简单,只能称为一个简易爬虫。

爬虫简介

网络爬虫,也叫网页蜘蛛,网络机器人,是指能按照一定规则自动抓取互联网上的信息资源的程序。如果把互联网上的网页看作是一个一个的节点,把网页之间的超链接看作是节点的边,整个互联网就形成了一个Web有向图。

宽度优先遍历 深度优先遍历

爬虫的抓取就是对Web有向图的遍历,按照遍历顺序的不同,可以分为宽度优先(Breadth First)和深度优先(Depth First)两种遍历方式。

网络爬虫最主要的作用就是获取互联网上的数据和信息,广泛应用于搜索引擎、数据挖掘、信息采集、舆情监测等领域,还是广大站长和SEOer获取网站内容和数据的利器。

工作原理

网络爬虫架构

如图所示,首先选取一部分url作为种子url加入待抓取队列中,爬虫会依次读取待抓取队列中的url,并交给下载器将网页下载到本地,html解析器从网页中抽取所有链接,过滤出我们需要的链接。

为了避免爬虫对网页的重复抓取,需要维护一个已抓取集合,用来判断一个url是否已经被抓取过,过滤出的url经过去重后,加入到待抓取队列中,已抓取过的url加入到已抓取集合中,调度程序继续从待抓取队列中取出url进行抓取,如此循环,直到待抓取队列为空。

阅读剩余部分