前言

借由做项目的机会,我在一边实践中一边学习,感觉自己不仅是在PHP和Laravel的运用上突飞猛进,在一次次与MySql和Redis做斗争的过程中也获益匪浅。

接下来的几篇文章我会节选一些我觉得比较有意义的经验与大家分享(大部分是自己想的或团队讨论结果,不一定是最优解,望各位斧正)。

Console\Command

  • 使用artisan派生一个Console\Command类:artisan make:command YourCommand

Console\Command类主要两个用途

  1. 在命令行直接执行代码:artisan your_command_name

    your_command_name对应成员$signature的值

  2. 定时任务:在Console\Kernel类的schedule中注册你的Command,并通过crontab来实现系统定时任务

    或是直接crontab创建第一种方法的定时任务

Console\Command算是Laravel框架原生的唯一可以主动执行代码,而不是被动等待用户请求时再执行代码的方法。

如果开发中要进行一些定时任务(如定时更新每日任务等),或是处理一些数据量较大又不需要非常及时的数据,可以考虑使用Console\Command

介绍点到为止,下面看两个实例。

实例一

任务

前端需要一个获取用户所在地的天气的接口,现有API https://openweathermap.org/current

然而要求尽量缩减成本,直接购买服务并由前端调用会造成不小开销,考虑到免费额度有一分钟60次,可前后端配合“白嫖”天气服务。

中国所有地区共4000左右,也就是说天气的更新间隔不过1-2小时,对于天气这种数据,这个更新频率也基本满足要求。

实现思路

  1. 该网站提供了全世界所有城市、地区、乡镇的数据,从中挑出属于中国的数据,存入数据库备用。
  2. 使用Command类定期轮循天气API,每分钟59次。将得到的天气数据存入数据库。
  3. 提供接口给前端获取相应地区数据。

遇到的问题

该API的城市名都是英文的,并且有重名、前端获取的地区名对不上的问题。就算有对应的id,让前端实现一一对应也异常繁琐。

因此在第0步的时候需要额外存储每个地区的经纬度,前端直接发送需要的地点的经纬度,后端使用球面距离公式计算得出与该点相距最近的数据点,返回该数据点的结果。

由于每次查询要扫一次表,可能对服务器的负担较大。可以考虑使用REDIS做进一步的优化。

例如让前端给每个地区设定一个唯一的经纬度,或是直接经纬度取整。这样后端可以在每次扫表后用经纬度对应数据生成REDIS记录(或是项目上线后用Command直接生成缓存),下次请求时就可以先在REDIS中查找。由于本篇主讲Laravel,不会贴出此步优化的代码。

实现代码(部分)

注:Controller层和路由省略

Console\Command

class GetWeatherCommand extends Command
{
// ...忽略前面的成员...

    /**
     * 获取天气并保存
     *
     * @return mixed
     */
    public function handle()
    {
        for($i = 1; $i <= 59; ++$i) {
            $result = WeatherService::getData();
            if($result['response']['cod'] != 200) {
                // API返回错误信息时保存返回信息
                Log::getMonolog()->popHandler();
                Log::useFiles(storage_path('logs/weather.log'), 'debug');
                Log::debug("请求错误, 返回码:{$result['response']['cod']}\nURL:{$result['url']}");
            }
            // sleep(1);
        }
    }
}

Service层:

use App\City; // Model
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;

class WeatherService
{
    public static $prefix = 'xxx';

    /**
     * 获取最近的数据点的天气信息
     * @param float $lat 纬度
     * @param float $lon 经度
     * @return mixed|null 最近的数据点的天气信息
     */
    public static function getWeather(float $lat, float $lon) {
        // 最大搜索经纬度差值为多少的数据点
        $range = 10;
        // 若请求缓慢可考虑降低pi的精度
        $pi = 3.141;
        $city = City::where([
            ['latitude',    '<=', $lat + $range],
            ['latitude',    '>=', $lat - $range],
            ['longitude',   '<=', $lon + $range],
            ['longitude',   '>=', $lon - $range]
        ])
            ->orderBy(DB::raw(
            'ACOS('.
                "SIN(($lat * $pi) / 180 ) * SIN((latitude * $pi) / 180 ) ".
                "+ COS(($lat * $pi) / 180 ) * COS((latitude * $pi) / 180 ) ".
                "* COS(($lon * $pi) / 180 - (longitude * $pi) / 180 ) ".
            ') * 6380'
        ), 'asc')
            ->first();

        if($city == null) {
            return null;
        }
        return json_decode($city->data, true);
    }

    /**
     * @see https://openweathermap.org/current
     * @return array 请求的url以及返回体
     */
    public static function getData() {
        // 指针,记录当前应请求的地区id
        $pointerKey = static::$prefix . 'pointer';
        $pointer = intval(Redis::get($pointerKey));
        if($pointer == null) {
            $pointer = 1;
        }

        $city = City::where('id', $pointer)->first();
        $lat = $city->latitude;
        $lon = $city->longitude;
        $url = 'http://api.openweathermap.org/data/2.5/weather?' . http_build_query([
                'lat'     => $lat,
                'lon'     => $lon,
                'appid' => env('OWM_APP_ID'),
                'lang'  => 'zh_cn',
                'units' => 'metric'
            ]);

        $response = json_decode(
            file_get_contents($url), true
        );
        // 判断是否请求成功,若成功则存入数据库
        // 指针+1,判断是否大于数据行数,若是则变为1
        // 返回请求结果以做log
}

getWeather中生成的SQL语句类似:

select * from `cities` 
    where (`latitude` <= ? and `latitude` >= ? and `longitude` <= ? and `longitude` >= ?) 
    order by 
        ACOS(
            SIN((107.45 * 3.141) / 180 ) * SIN((latitude * 3.141) / 180 ) 
            + COS((107.45 * 3.141) / 180 ) * COS((latitude * 3.141) / 180 ) 
            * COS((22.13 * 3.141) / 180 - (longitude * 3.141) / 180 ) 
        ) * 6380 
    asc 
limit 1

实例二

任务

现要对用户上传到CDN(本案例中使用七牛云作为CDN)的图片进行内容安全检测,也就是俗称的鉴黄鉴恐(本案例中使用腾讯云的内容安全服务)。

然而涉及的业务较多,同步检测可能会造成并发量过大,或是重复检测了同一图片,造成资源浪费。因此考虑使用Command进行异步检测。

实现思路

  1. 定时向七牛云请求CDN上的图片名,将新增的数据存入鉴定结果表
  2. 定时将鉴定结果表中状态为“未鉴定”的行取出进行鉴定,若鉴定不通过则锁定七牛云上的相应图片。

遇到的问题

此案例中的黑盒有点多,如果某张图片一直审核失败,有可能会无限重试。

因此可以在审核的Command将图片提入审核队列后,先将图片标记为审核中,若一段时间后仍然没有更新状态,再进行再次审核(同时保存Log)。

图片共有以下5种状态,用一个tinyint在数据表中保存:

  • 未审核
  • 审核中
  • 已通过
  • 未通过
  • 审核失败

实现代码(部分)

use App\Service\ModelService;
use Illuminate\Console\Command;
use Qiniu\Auth;
use Qiniu\Storage\BucketManager;
use App\Image;

class GetImagesFromQiniuCommand extends Command
{
    // 忽略前面的成员

    /**
     * 定期从七牛获取图片名
     *
     * @return mixed
     */
    public function handle()
    {
        // $limit表示一次最多获取多少图片名
        $limit = 1000;
        $marker = null;
        $auth = new Auth(env('QN_AK'), env('QN_SK'));
        $bucketManager = new BucketManager($auth);

        // 根据七牛的SDK和API文档可以写出以下代码
        $data = [];
        do{
            $response = $bucketManager->listFiles(env('QN_BUCKET'), null, $marker, $limit);
            $marker = $response[0]['marker'] ?? null;

            foreach ($response[0]['items'] as $item) {
                $data []= [
                    'key'   => $item['key']
                ];
            }
        } while($marker != '');

        // ModelService是Service层的一个类,下文会稍微讲讲
        ModelService::insertIgnoreMulti(new Image(), $data);
    }
}
use App\Service\ContentModerationService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Qiniu\Auth;
use Qiniu\Storage\BucketManager;
use App\Image;
use Carbon\Carbon;

class ContentModerationCommand extends Command
{
    // 忽略前面的成员

    /**
     * 定时审核图片
     *
     * @return mixed
     */
    public function handle()
    {
        $limit = 1000;
        $images = Image::where('cm_status', 0)
            ->orWhere([
                ['cm_status', 1],
                // 已经进入审核队列但因为各种原因未被审核的图片需要冷却一段时间再进行审核
                ['updated_at', '<', Carbon::createFromTimestamp(Carbon::now()->timestamp - 0 * 3600)]
            ])
            ->limit($limit)
            ->pluck('key');
        if($images !== []) {
            Image::whereIn('key', $images)->update(['cm_status' => 1]);

            $qiniuUrl = 'http://' . env('QINIU_URL') . '/';
            $auth = new Auth(env('QINIU_AK'), env('QINIU_SK'));
            $bucketManager = new BucketManager($auth);

            $images = $images->toArray();
            foreach ($images as &$image) {
                $image = "{$qiniuUrl}{$image}";
            }

            $cmResult = ContentModerationService::moderation(['images' => $images])['images'];

            $passed = [];
            $failed = [];
            $notPassed = [];
            foreach ($cmResult as $image => $result) {
                $image = ltrim($image, $qiniuUrl);

                if($result == 0) {
                    $passed []= $image;
                } elseif ($result == 500) {
                    // 审核失败的图片保存现场
                    $failed []= $image;
                } else {
                    // 审核不通过则锁定相应图片(无法被访问)
                    $deleteResult = $bucketManager->changeStatus(env('QINIU_BUCKET'), $image, 1);
                    if($deleteResult != null) {
                        Log::getMonolog()->popHandler();
                        Log::useFiles(storage_path('logs/cms_qiniu.log'), 'debug');
                        Log::debug(static::class . ': ' . $deleteResult->message() . "\n");
                    }
                    $notPassed []= $image;
                }
            }
            // 存表
        }
    }
}

Service层主要就是与数据库和SDK交互,并没有什么重点,因此省略。

Service层

Laravel中本没有这个层,工作室的小伙伴都这么写大概是因为前几届的学长觉得Laravel少了这一个层,就给手动加上了。

然而我觉得Laravel中与Service层对应的是Facade,后者可以给自定义的类起一个较短的别名。详情请见我上一篇文章。

不过Facade用着总是有点不顺手,可能是我知道这个东西太晚了,Service层已经深入我心了吧。

ModelService

Laravel的ORM对象无法实现同时插入多条数据同时维护时间戳,直接使用查询构造器DB来进行数据表操作的话又不会触发监视器。于是我在Model和Controller之间多增加了一层ModelService,通过ModelService来操作Model,解决上述问题和一些其他问题。

下面节选几个我和其他后端一直在用的方法。如果有操作需要监视器,则应注意任何时候都使用这个Service。

namespace App\Service;

use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;

class ModelService
{
    /**
     * 插入一条数据并维护时间戳,并可根据需要返回主键
     * @param Model $model 要操作的数据表的Model对象
     * @param array $data 要插入的数据 例: ['name' => 'xiaoming', 'age' => '19']
     * @param string|null $increment 主键名,如果需要插入数据后的主键则传入
     * @return mixed|null 如果第三个参数指定了主键名则会返回插入数据后的主键
     */
    public static function insert(Model $model, array $data, string $increment = null)
    {
        $model->fillable(array_keys($data));
        $model->fill($data);
        $model->save();

        if($increment != null) {
            return $model->$increment;
        }
        return null;
    }

    /**
     * 插入多条数据同时维护时间戳
     * @param Model $model 要操作的数据表的Model对象
     * @param array $data 例:[['name' => 'xiaoming', 'age' => '19'], ['name' => 'xiaohong', 'age' => '19']]
     */
    public static function insertMulti(Model $model, array $data)
    {
        $now = Carbon::now();
        foreach ($data as &$datum) {
            $datum += [
                'created_at'    => $now,
                'updated_at'    => $now
            ];
        }
        $model->insert($data);
    }

    /**
     * 插入多条数据同时维护时间戳,如果数据重复则忽略(insert ignore)
     * @param Model $model 要操作的数据表的Model对象
     * @param array $data 例:[['name' => 'xiaoming', 'age' => '19'], ['name' => 'xiaohong', 'age' => '19']]
     */
    public static function insertIgnoreMulti(Model $model, array $data) {
        $now = Carbon::now();
        foreach ($data as &$datum) {
            $datum += [
                'created_at'    => $now,
                'updated_at'    => $now
            ];
        }

        $sql = 'insert ignore into `' . $model->getTable() . '`';

        $keys = [];
        $values = [];
        foreach ($data as $datum) {
            $temp = [];
            // 检测是否出现缺失字段的情况,如果是则添上
            foreach ($datum as $key => $value) {
                if(!in_array($key, $keys)) {
                    $keys []= $key;
                    foreach ($values as &$_value) {
                        $_value []= null;
                    }
                }
                $temp []= $value;
            }
            $values []= $temp;
        }

        $sql = "$sql (";
        $valueSql = '(';
        $bindings = [];
        foreach ($keys as $key) {
            $sql .= "`$key`, ";
            $valueSql .= '?, ';
        }
        $sql = rtrim($sql, ', ') . ') values '; // insert ignore into `users` (`key1`, `key2`, `key3`) values
        $valueSql = rtrim($valueSql, ', ') . ')'; // (?, ?, ?)

        foreach ($values as $value) {
            foreach ($value as $_value) {
                $bindings []= $_value;
            }
            $sql = "{$sql}{$valueSql}, ";
        }
        $sql = rtrim($sql, ', ');
        // insert ignore into `users` (`key1`, `key2`, `key3`) values (?, ?, ?), (?, ?, ?), (?, ?, ?)

        DB::insert($sql, $bindings);
    }
}

insertIgnoreMulti方法

Laravel的ORM也不支持insert ignore同时插入多条数据,因此我通过拼接字符串的方式实现了一个insertIgnoreMulti方法。

并且通过数据binding的方式,防止了Sql注入。若某条数据中缺失某个字段,也会自动添上。

标签: none

添加新评论