How to build 'did you mean' functionality with Laravel Scout
Bài này được dịch từ bài gốc http://tnt.studio/blog/did-you-mean-functionality-with-laravel-scout?utm_source=learninglaravel.net Đầu tiên bạn hãy xem Demo Bây giờ chúng ta hãy cùng nghiên cứu cách xây dựng chức năng 'did you mean' này Giới thiệu Với chức năng này chúng ta sẽ sử ...
Bài này được dịch từ bài gốc http://tnt.studio/blog/did-you-mean-functionality-with-laravel-scout?utm_source=learninglaravel.net
Đầu tiên bạn hãy xem Demo
Bây giờ chúng ta hãy cùng nghiên cứu cách xây dựng chức năng 'did you mean' này
Giới thiệu
Với chức năng này chúng ta sẽ sử dụng lượng dữ liệu rất lớn, hơn 3 triệu record thông tin các thành phố. Với ý tưởng chức năng là hiển thị tên thành phố đúng trong trường hợp người dùng đánh sai. Danh sách các thành phố chúng ta sẽ lấy ở đây https://www.maxmind.com/en/free-world-cities-database.
Chúng ta sẽ sử dụng Laravel Scout và TNT search để cài đặt chức năng này.
Cài đặt laravel-scout-tntsearch-driver
Từ thư mục project bạn chạy composer để cài đặt driver
composer require teamtnt/laravel-scout-tntsearch-driver
Sau đó, khai báo provider cho việc sử dụng driver trên bằng cách thêm vào file config/app.php
'providers' => [
/*
* Package Service Providers...
*/
LaravelScoutScoutServiceProvider::class,
TeamTNTScoutTNTSearchScoutServiceProvider::class,
]
Tiếp theo, để config publish cho Scount bạn chạy tiếp
php artisan vendor:publish --provider="LaravelScoutScoutServiceProvider"
Và thiết lập TNTSearch với giá trị defaul trong file .env
SCOUT_DRIVER=tntsearch
Trong config/scount.php thiết lập tiếp giá trị storage_path
'tntsearch' => [
'storage' => storage_path(),
],
Xây dựng function
Tạo Migration
Tạo migration file
php artisan make:model City --migration
Với nội dung file migration
<?php
use IlluminateSupportFacadesSchema;
use IlluminateDatabaseSchemaBlueprint;
use IlluminateDatabaseMigrationsMigration;
class CreateCitiesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('cities', function (Blueprint $table) {
$table->increments('id');
$table->string('country');
$table->string('city');
$table->string('region');
$table->float('population');
$table->double('latitude', 15, 8);
$table->double('longitude', 15, 8);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('cities');
}
}
Trong model City.php chúng ta khai báo
public $timestamps = false;
Tạo Command
Tạo command ImportCities
php artisan make:command ImportCities
Sau khi chạy xong, file ImportCities sẽ được tạo ra. Chúng ta ghi nội dung file:
<?php
namespace AppConsoleCommands;
use AppCity;
use IlluminateConsoleCommand;
use TeamTNTTNTSearchIndexerTNTIndexer;
class ImportCities extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'import:cities';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Imports cities from MaxMind';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
$this->tnt = new TNTIndexer;
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$this->info("Downloading worldcitiespop.txt.gz from MaxMind");
$gzipedFile = storage_path().'/worldcitiespop.txt.gz';
$unZipedFile = storage_path().'/worldcitiespop.txt';
if (!file_exists($gzipedFile)) {
file_put_contents($gzipedFile, fopen("http://download.maxmind.com/download/worldcities/worldcitiespop.txt.gz", 'r'));
}
$this->info("Unziping worldcitiespop.txt.gz to worldcitiespop.txt");
$this->line("
Inserting cities to database");
if (!file_exists($unZipedFile)) {
$this->unzipFile($gzipedFile);
}
$cities = fopen(storage_path().'/worldcitiespop.txt', "r");
$lineNumber = 0;
$bar = $this->output->createProgressBar(3173959);
if ($cities) {
while (!feof($cities)) {
$line = fgets($cities, 4096);
if ($lineNumber == 0) {
$lineNumber++;
continue;
}
$line = explode(',', $line);
$this->insertCity($line);
$lineNumber++;
$bar->advance();
}
fclose($cities);
}
$bar->finish();
}
public function insertCity($cityArray)
{
if ($cityArray[4] < 1) {
return;
}
$city = new City;
$city->country = $cityArray[0];
$city->city = utf8_encode($cityArray[2]);
$city->region = $cityArray[3];
$city->population = $cityArray[4];
$city->latitude = trim($cityArray[5]);
$city->longitude = trim($cityArray[6]);
$city->n_grams = $this->createNGrams($city->city);
$city->save();
}
public function unzipFile($from)
{
$buffer_size = 4096;
$out_file_name = str_replace('.gz', ', $from);
$file = gzopen($from, 'rb');
$out_file = fopen($out_file_name, 'wb');
while (!gzeof($file)) {
fwrite($out_file, gzread($file, $buffer_size));
}
fclose($out_file);
gzclose($file);
}
public function createNGrams($word)
{
return utf8_encode($this->tnt->buildTrigrams($word));
}
}
Cần phải đăng ký command này trong app/Console/Kernel.php
<?php
protected $commands = [
AppConsoleCommandsImportCities::class
];
Vậy là chúng ta đã xây dựng xong chức năng tự động download file từ http://download.maxmind.com/download/worldcities/worldcitiespop.txt.gz và giải né nó rồi import dữ liệu vào bảng cities.
Tuy nhiên do dữ liệu được import vào là rất lớn, nên chúng ta cần phải có 1 giải pháp nào đó cho việc đánh index cho những record này. Vậy ta cần viết tiếp 1 command CreateCityTrigrams
php artisan make:command CreateCityTrigrams
Với nội dung của CreateCityTrigrams.php
<?php
namespace AppConsoleCommands;
use IlluminateConsoleCommand;
use TeamTNTTNTSearchTNTSearch;
class CreateCityTrigrams extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'city:trigrams';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Creates an index of city trigrams';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$this->info("Creating index of city trigrams");
$tnt = new TNTSearch;
$driver = config('database.default');
$config = config('scout.tntsearch') + config("database.connections.$driver");
$tnt->loadConfig($config);
$tnt->setDatabaseHandle(app('db')->connection()->getPdo());
$indexer = $tnt->createIndex('cityngrams.index');
$indexer->query('SELECT id, n_grams FROM cities;');
$indexer->setLanguage('no');
$indexer->run();
}
}
Vậy là xong, bạn chỉ cần chạy command line cho 2 phần
php artisan import:cities
cho import data
và
php artisan cities.index
cho đánh index
Tạo Controller
Tạo file CityController.php bằng command line
php artisan make:controllers CityController
Với nội dung
<?php
namespace AppHttpControllers;
use AppCity;
use AppHttpControllersController;
use IlluminateHttpRequest;
use TeamTNTTNTSearchIndexerTNTIndexer;
use TeamTNTTNTSearchTNTSearch;
class CityController extends Controller
{
public function search(Request $request)
{
$res = City::search($request->get('city'))->get();
if (isset($res[0]) && $this->isExactMatch($request, $res[0])) {
return [
'didyoumean' => false,
'data' => $res[0]
];
}
return [
'didyoumean' => true,
'data' => $this->getSuggestions($request)
];
}
public function isExactMatch($request, $result)
{
return strtolower($request->get('city')) == strtolower($result->city);
}
public function getSuggestions(Request $request)
{
$TNTIndexer = new TNTIndexer;
$trigrams = $TNTIndexer->buildTrigrams($request->get('city'));
$tnt = new TNTSearch;
$driver = config('database.default');
$config = config('scout.tntsearch') + config("database.connections.$driver");
$tnt->loadConfig($config);
$tnt->setDatabaseHandle(app('db')->connection()->getPdo());
$tnt->selectIndex("cityngrams.index");
$res = $tnt->search($trigrams, 10);
$keys = collect($res['ids'])->values()->all();
$suggestions = City::whereIn('id', $keys)->get();
$suggestions->map(function ($city) use ($request) {
$city->distance = levenshtein($request->get('city'), $city->city);
});
$sorted = $suggestions->sort(function ($a, $b) {
if ($a->distance === $b->distance) {
if ($a->population === $b->population) {
return 0;
}
return $a->population > $b->population ? -1 : 1;
}
return $a->distance < $b->distance ? -1 : 1;
});
return $sorted->values()->all();
}
}
Vậy là xong, bạn chỉ việc đặt route cho function search trong CityController để hiển thấy được kết quả. Demo
How dose it work?
Nếu nhìn vào mã code ở trên chắc hẳn sẽ rất khó để hiểu ý tưởng của chức năng này chạy như nào. Ta sẽ cùng phân tích theo hướng logic như thế này.
Ở đây, lấy ví dụ là "Berlin". Trigrams sẽ chia nhỏ và phân tích theo từ và cụm từ thì nó sẽ trở có dạng như __b _be ber erl rli lin in_ n__
Và giải sử bạn đánh sai từ Berlin đó thành "Berln". Trigrams sẽ chia nhỏ và phân tích thành __b _be ber erl rln ln_ n__
Và bạn có thể thấy, việc match những Trigrams giữa 2 phần này trong hình dưới