dragon_case():Laravel中snake_case()的替代性方案

在Laravel中的Helpers函数中,有一个很实用的方法 snake_case() ,可以将任何字符串转换为下划线分隔的形式,对于一些常见情况来说,实现的很好,比如:

$this->assertEquals("foo_bar", snake_case("FooBar"));
$this->assertEquals("foo_bar123", snake_case("FooBar123"));
$this->assertEquals("foo123_bar", snake_case("Foo123Bar"));
$this->assertEquals("123_foo_bar", snake_case("123FooBar"));
$this->assertEquals("foo_bar", snake_case("  FooBar  "));
$this->assertEquals("foo_bar", snake_case("fooBar"));
$this->assertEquals("foo_bar", snake_case("Foo Bar"));
$this->assertEquals("foo_bar", snake_case("Foo  Bar"));

在上面这些测试用例中,我们可以看到不管是包含数字,还是前后或者中间包含空格,都可以很好的转化,转化的结果也和预期一样。但是下面这几个例子,看起来就会比较奇怪了:

$this->assertEquals("foo_b_a_r", snake_case("fooBAR"));
$this->assertEquals("f_o_o_bar", snake_case("FOOBar"));
$this->assertEquals("foo._bar", snake_case("Foo.Bar"));
$this->assertEquals("foo__bar", snake_case("Foo_Bar"));
$this->assertEquals("foo-_bar", snake_case("Foo-Bar"));

对于我的期望来说,一个单词有三种情况,要么是完全小写,要么是完全大写,要么是首字母大写,但明显snake_case只认识两种形式,而且如果中间有特殊符号的话,应该就这些符号统一过滤为一个下划线,如果特殊符号就是下划线,那么最终的结果应该只有一个,而不应该是两个。

于是我们来看看修订版 dragon_case() 的测试用例,首先基本的能力要OK:

$this->assertEquals("foobar", dragon_case("foobar"));
$this->assertEquals("foo_bar", dragon_case("foo_bar"));
$this->assertEquals("foo_bar", dragon_case("FooBar"));
$this->assertEquals("foo_bar", dragon_case("fooBar"));
$this->assertEquals("another_foo_bar", dragon_case("AnotherFooBar"));

然后是对完全大写的单词的支持:

$this->assertEquals("foo_bar", dragon_case("FooBAR"));
$this->assertEquals("foo_bar", dragon_case("FOOBar"));
$this->assertEquals("foobar", dragon_case("FOOBAR"));

然后是在各个位置混合上了数字的情况:

$this->assertEquals("foo_bar123", dragon_case("FooBar123"));
$this->assertEquals("foo123_bar", dragon_case("Foo123Bar"));
$this->assertEquals("123_foo_bar", dragon_case("123FooBar"));
$this->assertEquals("foo123bar", dragon_case("Foo123bar"));
$this->assertEquals("123foo_bar", dragon_case("123fooBar"));

最后是在各个位置,混杂了一个或者多个特殊符号的情况:

$this->assertEquals("foo_bar", dragon_case("FOO____BAR"));
$this->assertEquals("foo_bar", dragon_case("FOO#BAR"));
$this->assertEquals("foo_bar", dragon_case("FOO.BAR"));
$this->assertEquals("foo_bar", dragon_case("Foo  Bar"));
$this->assertEquals("foo_bar", dragon_case("   foo  BAR   "));

在这个版本的实现中,我们把数字完全当成小写字母对待,所以先准备两个辅助函数:

function lower_case($chars, $index) {
    return isset($chars[$index]) && (ctype_lower($chars[$index]) || ctype_digit($chars[$index]));
}
function upper_case($chars, $index) {
    return isset($chars[$index]) && ctype_upper($chars[$index]);
}

最后的实现反而没有什么花头,就是将字符串拆解成一个字符数组,然后一个个的判断,是不是需要在字符前面增加一个下划线分隔,实现方式有点繁琐,有待进一步优化。

function dragon_case(string $input): string {
    $input = trim($input);
    $chars = str_split($input);
    $result = '';

    foreach($chars as $index => $char) {

        if( ctype_upper($char) && $index !== 0 ) {
            if( lower_case($chars, $index+1) && upper_case($chars, $index-1) ) {
                $result .= '_';
            }

            if( lower_case($chars, $index+1) && lower_case($chars, $index-1) ) {
                $result .= '_';
            }

            if( upper_case($chars, $index+1) && lower_case($chars, $index-1) ) {
                $result .= '_';
            }
        }

        if( ctype_alnum($char) === false ) {
            if( str_split($result)[count(str_split($result)) - 1] != '_' ) {
                $result .= '_';
            }
        } else {
            $result .= strtolower($char);
        }
    }

    return $result;
}

这样 dragon_case() 就实现成功了,之所以叫 dragon,完全是因为 dragon 是 snake 的进化版么。如果想使用这个 Helpers 方法,大家可以直接加载 cuimingda/laravel-helpers 组件,已经包含在其中了。

composer require cuimingda/laravel-helpers

为Composer Package配置PHPUnit测试环境

由于Package主要是提供给其他人使用,而且可能会有多人协作修改的情况,完整的单元测试是必不可少的,一个新创建的Package,首先需要引入PHPUnit,因为这东西只有开发时候用,所以不要忘了–dev

composer require phpunit/phpunit --dev -vvv

然后创建一个默认的配置文件 phpunit.xml

<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
         backupStaticAttributes="false"
         bootstrap="./vendor/autoload.php"
         colors="true"
         convertErrorsToExceptions="true"
         convertNoticesToExceptions="true"
         convertWarningsToExceptions="true"
         processIsolation="false"
         stopOnFailure="false">
    <testsuites>
        <testsuite name="Application Test Suite">
            <directory>./tests/</directory>
        </testsuite>
    </testsuites>
</phpunit>

创建 tests 目录,并且在里面新增一个测试文件,比如这个例子就是 HelpersTest.php,文件名要以Test.php结尾:

<?php

use PHPUnit\Framework\TestCase;

final class HelpersTest extends TestCase
{
    public function testSomeMethod()
    {
        $input = "abc";
        $expectedResult = "def";

        $this->assertEquals($expectedResult, someMethod($input));
    }
}

因为我要测试的是全局函数,所以可以通过文件的形式,将这些函数加载到autoload里,修改 composer.json

"autoload": {
    "files": ["src/Helpers.php"],
},

而如果不是全局函数,是一些class的话,那么可以用下面这种方式

{
    "autoload": {
        "classmap": ["src/", "lib/", "Something.php"]
    }
}

每一次修改 composer.json 的 autoload 小结,记得都要重新生成一遍 autoload 文件:

composer dump-autoload

最后一步,就是执行测试用例,在其他的文章中介绍的,还需要传入各种参数,实际上这些参数都在phpunit.xml中配置好了,最后只需要这一句:

phpunit

至此,已经在Composer Package中配置好了PHPUnit的测试环境,剩下的逻辑,就是一个测试驱动的组件开发过程,不管做什么功能,都先写测试用例,让测试用例执行失败,写代码,然测试用例成功。

基于JSON查询结果在数据库中新增记录

一个典型的场景,就是通过API抓取了关于一个产品的一大串数据,都是JSON格式,我们要取出其中的部分字段,新增一条记录。

获取JSON数据以后,第一步要将JSON数据解析为对象,有两种模式,一种是Class,一种是数组。

如果走Class的路线,那么最后的代码就是这样子,可以想象,如果要关联的字段很多,这段代码还是非常繁琐的。

$product = Product::firstOrCreate([
    "Sku" => $item->Sku,
    "FriendlyUrl" => $item->FriendlyUrl
]);

如果在json_decode第二个参数传入true,就可以返回一个数组,那么最后的创建命令就会非常简洁:

$item = array_only($item, ['Sku', 'FriendlyUrl']);
$product = Product::firstOrCreate($item);

综合起来看,还是建议将json_decode返回为数组模式。

将PHP数组所有的Key,都从驼峰形式转变成下划线形式

比如有这样一个数组:

[
    "Sku" => "A1B2C3D4",
    "FriendlyUrl" => "http://example.com/1.html"
]

希望能够转化为下面这个形式的数组:

[
    "sku" => "A1B2C3D4",
    "friendly_url" => "http://example.com/1.html"
]

这里借助Laravel的Collection对象和snake_case这个helper函数,可以很方便实现这个效果

function array_snake_case_keys(array $items): array {
    $items = collect($items);
    return $items->keys()->map(function($key) {
        return snake_case($key);
    })->combine($items->values())->all();
}

btw,这个函数已经封装到了 cuimingda/laravel-helpers 中,只要引入进来就可以直接像其他helper函数一样使用:

composer require cuimingda/laravel-helpers

还有需要说明的是,snake_case这个函数有个bug,如果是imgURL这种大写字母组成的字符串,期望转化成img_url,但实际会转化为img_u_r_l,在laravel/framework中提了一个Issue,但被认为没有问题。

通过Composer调用本地正在开发中的Package

第一步,在 composer.json 中配置本地 Package 的位置:
– type表明是本地目录
– 将packagist.org设置为false,理论就不会去远程抓取,实际还会的
– symlink设置为true以后,对package的修改都实时生效,不需要update,超级实用
– url是相对于当前项目的相对路径,指向 package 的 composer.json 所在的目录。

"repositories": [
    {
        "type": "path",
        "packagist.org": false,
        "symlink": true,
        "url": "../../Projects/cuimingda/truncate"
    },
    {
        "type": "path",
        "packagist.org": false,
        "symlink": true, 
        "url": "../../Projects/cuimingda/laravel-helpers"
    }
],

第二步,引用本地组件,如果直接使用命令行,就是下面这种,关键是版本号一定要写成 @dev :

composer require cuimingda/truncate:@dev --dev -vvv

当然也可以编辑 composer.json 来完成,效果一样:

"require-dev": {
    "cuimingda/truncate": "@dev",
    "cuimingda/laravel-helpers": "@dev",
},

在实际项目的开发过程中,本地调试组件,实时生效,调试完毕以后,提交到GitHub,并更新到Packagist上,那么在线上环境只要 composer update 就可以了。

将第三方类库封装成在Laravel中方便调用的组件

事情的起因是需要在一个 Laravel 项目中引入 Snoopy 这个组件,这个项目在 2014 年就停止维护了,可以下载源代码,是只包含一个Class的独立文件,基于 GNU LESSER GENERAL PUBLIC LICENSE 2.1 授权。

我们当然可以把这个文件复制到Laravel的项目中,比如放在 app/Libraries 目录中,但猜想应该有更方便的方式,于是在 GitHub 上搜索相关的项目,结果发现确实有很多封装,但大家的封装方法还停留在过去式,需要include文件的那种,于是就有了这个简单的封装项目,也希望能分享基于Package封装第三方组件的套路。

先说目标,我希望能通过下面这种方式安装这个组件。

composer require cuimingda/laravel-snoopy

希望通过这种方式直接使用组件,不需要增加任何额外配置。

use Snoopy;

$snoopy = new Snoopy;
$snoopy->fetchtext("http://www.php.net/");
print $snoopy->results;

先创建 GitHub 项目,名称就叫 cuimingda/laravel-snoopy ,默认可以把 Readme 和 License 都勾选上,然后把代码 clone 到本地。

git clone git@github.com:cuimingda/laravel-snoopy.git

在本地初始化 composer 组件,这个命令就是生成 composer.json 的默认内容,不用命令,手动编辑也没有问题。

composer init

我们先放一些通用的基本设置,配置的key都很清晰,注意license不是随便写的,完整的名单可以看这里SPDX License List,这里使用 LGPL-2.1 是为了和Snoopy原来的授权协议一致。

"name": "cuimingda/laravel-snoopy",
"description": "封装Snoopy,方便在Laravel下调用",
"homepage": "https://github.com/cuimingda/laravel-snoopy",
"keywords": ["laravel", "snoopy"],
"authors": [
    {
        "name": "Cui Mingda",
        "email": "cuimingda@gmail.com",
        "homepage": "https://github.com/cuimingda",
        "role": "Developer"
    }
],
"minimum-stability": "dev",
"license": "LGPL-2.1",

这时候我们把Snoopy的源代码,复制到src下的一个子目录,比如Snoopy-2.0.0下,我们看一下目录结构:

.
├── LICENSE
├── README.md
├── composer.json
└── src
    ├── ServiceProvider.php
    └── Snoopy-2.0.0
        ├── AUTHORS
        ├── COPYING.lib
        ├── ChangeLog
        ├── FAQ
        ├── INSTALL
        ├── NEWS
        ├── README
        ├── Snoopy.class.php
        └── TODO

对源文件做的唯一修改,就是在 Snoopy.class.php 中增加一个命名空间:

namespace Cuimingda\LaravelSnoopy;

然后新增并编辑 ServiceProvider.php,有用的代码只有一句,就是在注册package的时候,引入Snoopy的定义类文件,这里要注意 DIR 对应的是放 composer.json 的那级目录,而且末尾是没有‘/’,所以文件名设置的时候要补全。

namespace Cuimingda\LaravelSnoopy;

class ServiceProvider extends \Illuminate\Support\ServiceProvider
{
    public function boot()
    {
    }

    public function register()
    {
        $this->registerSnoopy();
    }

    private function registerSnoopy()
    {
        require_once(__DIR__ . '/Snoopy-2.0.0/snoopy.class.php');
    }
}

回过头再来修改 composer.json 文件,先是要设置自动加载 src 目录下的所有代码

"autoload": {
    "psr-4": {
        "Cuimingda\\LaravelSnoopy\\": "src/"
    }
},

然后配置 Laravel 的 Package Discover 功能,效果就是自动注册这个Service,并且设置一个别名,这样全局都可以调用。

"extra": {
    "laravel": {
        "providers": [
            "Cuimingda\\LaravelSnoopy\\ServiceProvider"
        ],
        "aliases": {
            "Snoopy": "Cuimingda\\LaravelSnoopy\\Snoopy"
        }
    }
}

当所有这些代码工作都完成了以后,就是提交Git代码,记得一定要打Tag,大家可能注意 composer.json 中是没有写版本号的,Packagist会从GitHub项目中读取版本号。

git add .
git commit -m 'init'
git tag v2.0.1
git push -u origin master --tags

提交了代码以后,我们就可以用 GitHub 账号登录 Packagist,注意这里一定要用GitHub账号登录,这样顺便可以给GitHub授权,以后只要GitHub更新了,Packagist的Package就会自动更新,否则还要过来手动操作的。

在 Packagist 右上角点击 Submit,需要做的所有事情就是填写GitHub项目地址,比如这里例子就是:

https://github.com/cuimingda/laravel-snoopy

Packagist会去GitHub上抓取代码,如果失败会有提示,成功就会在这个页面上看到了。如果写了 README.md ,不仅内容会在GitHub上显示,也会在Packagist页显示。

Packagist如果从GitHub上自动更新代码出现错误,实际上是会发邮件的,只是会非常的滞后,建议提交代码以后,去Packagist上刷新一下,看看最新的版本是不是显示出来了。在Packagist上显示出来以后,可能还需要等一会 composer 才能抓到,验证是否有最新版本

composer show cuimingda/laravel-snoopy --latest -vvv

以后如果对这个Package有进一步的修改,基本步骤就是修改代码,测试,Git Commit,Git Tag,Git Push,等着Packagist更新,然后到项目目录里面composer update这个Package,就完成更新了。

composer update cuimingda/laravel-snoopy --dev -vvv

参考资料

  • https://laravel.com/docs/5.7/packages
  • https://laraveldaily.com/how-to-use-external-classes-and-php-files-in-laravel-controller/