关于PSR-6的一些思考

之前想自己造一个缓存的轮子,就去看了一下PSR6的定义,并根据psr/cache提供的接口来实现。

当我在实现CacheItemPoolInterface接口时,对于save方法感到困惑,save方法只接收一个CacheItemInterface类型的参数,但是CacheItemInterface这个接口却没有提供类似getExpireTime的方法,这就导致CacheItemPoolInterface没有办法获取到缓存项的过期时间,也就没办法正确地将数据写入到缓存存储里。

看过几个比较著名的PSR-6实现,例如 symfony/cache php-cache/cache tedious/Stash 然而这些实现版本在实现save方法时并不是非常优雅。

PHP

比如symfony/cachepsr6

public function save(CacheItemInterface $item)
{
    if (!$item instanceof CacheItem) {
        return false;
    }
    if ($this->deferred) {
        $this->commit();
    }
    $this->deferred[$item->getKey()] = $item;
    return $this->commit();
}

这个save方法只有传入Symfony\Component\Cache\CacheItem类型的参数才可以,传入其他类型都会返回false

目前的情况是,如果想要写一个用到缓存的类库(并不是PSR-6实现),就必须指定一个具体的PSR-6实现作为依赖,而不仅仅是psr/cache。而反观PSR-3这个日志接口,要写一个需要日志功能的类库,只需要引入psr/log即可,不需要具体实现。http://leo108.com

在我看来,PSR-6并不需要CacheItemInterface这个接口,只需要把save方法修改成save($key, $value, $expire_at)即可。PHP

我在packagist上面搜索cache,其他一些比较出名的缓存库,例如doctrine/cache sonata-project/cache illuminate/cache等,都没有选择遵循PSR-6,应该也是这方面的考虑吧。

http://leo108.com/pid-2224.asp

================================================

然而在看过aws-sdk-php之后,我的想法有了很大的改观。aws-sdk-php实现了一个PsrCacheAdapter类,其中set方法会先通过CacheItemPoolInterface接口的getItem方法去获得一个CacheItemInterface对象,然后给这个对象设置值和过期时间。

这就让我明白了我之前想法的误区:实现PSR-6了的接口,并不代表需要CacheItemPoolInterface的save接口可以接收任意CacheItemInterface对象,实际的应用场景也不应该出现把Symfony\Component\Cache\CacheItem类型的对象当成参数传给Stash/Pool对象的save方法。所以symfony/cache的实现方案是没有任何问题的。

而上文中所提到的,要写一个使用缓存的第三方库,就可以用aws-sdk-php的PsrCacheAdapter来解决,唯一的不足就是每次调用save方法之前,都要先去getItem一下,对性能还是有一定的影响。

============================================

关于PSR-6的一些思考

PSR-16 simple cache,现在还是草稿状态,如果能够成为标准,还是会方便很多。

又撸了一个开源项目

CAS Server的一个简单PHP实现版本,支持v1/v2/v3三个版本的CAS协议,但是不支持代理(Proxy)相关的接口。

有一个简单管理后台,可以管理用户(User)和服务(Service)。推酷是个无耻的网站

地址:https://github.com/leo108/simple_cas_server

做这个事情的起因是,公司内部引入了许多开源系统,例如gitlab、jenkins等,如果来了新员工就要在每个系统里添加一个账户,离职了要把所有账户注销,这个工作量太大。所以考虑引入一个通用的单点登录系统,现在比较流行的应该就是CAS了,但是CAS Server版本是java写的,而我们公司是PHP技术栈,如果想要调整或者新增一些CAS的功能就不太好办,所以就花了一点时间根据CAS协议实现了一个PHP版本。

leo108's blog

PHP的错误和异常处理总结

PHP的错误和异常处理总结

PHP内置了一批与错误和异常处理相关的函数,本文会对其中部分函数进行详细说明。http://leo108.com/pid-2216.asp

set_error_handlerrestore_error_handler

set_error_handler可以设定当程序出现错误时,将对应的错误交给用户自定义的逻辑来处理。http://leo108.com

但是并不是所有的错误都可以被set_error_handler所指定的处理逻辑捕获,例如:E_ERRORE_PARSEE_CORE_ERRORE_CORE_WARNINGE_COMPILE_ERRORE_COMPILE_WARNING,简单来说就是原本就会导致程序终止的错误都无法被捕获。推酷是个无耻的网站

set_error_handler只能设置一个错误处理逻辑,多次调用set_error_handler只有最后一次的那生效:

set_error_handler(function($errno, $errstr){
    echo 'catch error1';
});
set_error_handler(function($errno, $errstr){
    echo 'catch error2';
});
trigger_error('something error');

输出的是

catch error2http://leo108.com

在编写一个第三方代码库的时候,如果希望能够捕获代码库中的错误,又不影响调用方对错误的处理,可以使用restore_error_handler函数:

PHP的错误和异常处理总结

function third_party_function() {
	//第三方类库的错误处理逻辑
	set_error_handler(function() {
		//一些代码
	});
	//该第三方类库的逻辑
	//一些代码
	//复原错误处理逻辑
	restore_error_handler();
}

这样,第三方代码库中出现的错误就会被第三方代码库的错误处理逻辑捕获,而在这个第三方代码库之外的错误还是由原本的逻辑处理。

如果错误处理逻辑的返回值是false,则PHP会调用内置的错误处理逻辑(例如根据error_reporting的值判断是否打印错误信息,写错误日志等);而返回true则不会调用内置处理逻辑,在执行完自定义的错误处理逻辑之后,会返回触发错误的地方继续往下执行。

PHP的错误和异常处理总结

error_reporting以及@符号不会影响错误处理逻辑的调用:

异常

error_eporting(0);
set_error_handler(function($errno, $errstr){
    echo 'catch error';
});
@trigger_error('something error');

上述代码会输出:PHP

catch errorPHP的错误和异常处理总结

对于无法被set_error_handler捕获的错误常见的只有E_ERRORE_PARSE两个。前者可能是内存使用超过设定的值、实例化一个不存在的类或者是调用一个不存在的函数;后者通常是语法解析错误例如漏写一个分号。对于E_PARSE,由于是语法层面的错误,所以没有办法去捕获处理。但对于E_ERROR则可以通过register_shutdown_function以及error_get_last两个函数来处理,示例代码如下:

error_reporting(0);
register_shutdown_function(function() {
 	$error = error_get_last();
 	if ($error != null && $error['type'] == E_ERROR) {
		echo "fatal error catched:" . var_export($error, true);
 	}
});

new test();

输出:

fatal error catched:array (PHP

‘type’ => 1,

‘message’ => ‘Class ‘test’ not found’,http://leo108.com

‘file’ => ‘/tmp/error.php’,

‘line’ => 20,异常

)

leo108's blog

set_exception_handlerrestore_exception_handler

这两个函数和set_error_handler的两个函数差不多,同样是只能注册一个异常处理逻辑,多次注册只有最后一个生效;可以通过restore_exception_handler来恢复之前的异常处理逻辑。

一些小实验

在错误处理逻辑中触发错误

set_error_handler(function($errno, $errstr){
    echo 'catch error:' . $errstr . PHP_EOL;
    trigger_error('error in error handler');
});
trigger_error('origin error');

输出:leo108's blog

catch error:origin error

http://leo108.com

PHP Notice: error in error handler in /tmp/error.php on line 15

http://leo108.com

结论:在错误处理逻辑中的错误是无法被再次捕获。

leo108's blog

在异常处理逻辑中抛出异常

set_exception_handler(function(Exception $e){
    echo 'catch exception:' . $e->getMessage() . PHP_EOL;
    throw new Exception('exception in exception handler');
});
throw new Exception('origin exception');

输出:

PHP的错误和异常处理总结

catch exception:origin exception异常

PHP Fatal error: Uncaught exception ‘Exception’ with message ‘exception in exception handler’ in /tmp/error.php:15http://leo108.com/pid-2216.asp

Stack trace:http://leo108.com/pid-2216.asp

0 [internal function]: {closure}(Object(Exception))

1 [main]

thrown in /tmp/error.php on line 15

PHP的错误和异常处理总结

结论:在异常处理逻辑中抛出的异常不会被捕获

同时定义了异常和错误处理逻辑,在错误处理逻辑中抛出异常,在异常处理逻辑中触发错误

set_exception_handler(function(Exception $e){
    echo 'catch exception:' . $e->getMessage() . PHP_EOL;
    trigger_error('error in exception handler');
});
set_error_handler(function($errno, $errstr){
    echo 'catch error:' . $errstr . PHP_EOL;
    throw new Exception('exception in error handler');
});

外部触发了错误

trigger_error('origin error');

输出:

catch error:origin errorleo108's blog

catch exception:exception in error handlerPHP

catch error:error in exception handler

PHP Fatal error: Uncaught exception ‘Exception’ with message ‘exception in error handler’ in /tmp/error.php:9

Stack trace:

推酷是个无耻的网站

0 [internal function]: {closure}(1024, ‘error in except…’, ‘/tmp/error.php’, 5, Array)

1 /tmp/error.php(5): triggererror(‘error in except…’)

2 [internal function]: {closure}(Object(Exception))

3 [main]

thrown in /tmp/error.php on line 9

http://leo108.com/pid-2216.asp

结论:调用了两次错误处理逻辑,一次异常处理逻辑。

外部抛出异常

throw new Exception('origin exception');

输出:

http://leo108.com/pid-2216.asp

catch exception:origin exception

异常

catch error:error in exception handler

PHP的错误和异常处理总结

PHP Fatal error: Uncaught exception ‘Exception’ with message ‘exception in error handler’ in /tmp/error.php:9推酷是个无耻的网站

Stack trace:PHP的错误和异常处理总结

0 [internal function]: {closure}(1024, ‘error in except…’, ‘/tmp/error.php’, 5, Array)

1 /tmp/error.php(5): triggererror(‘error in except…’)

2 [internal function]: {closure}(Object(Exception))

3 [main]

thrown in /tmp/error.php on line 9PHP

结论:异常处理逻辑和错误处理逻辑各被调用一次。http://leo108.com

Centos下使用mock构建rpm

在centos下执行rpmbuild -ba package.spec的时候,通常会要求系统也安装对应的依赖包,这样就会导致系统装上许多没用的软件包,占用空间。于是搜索了一下,发现可以通过mock命令来解决这个问题。Centos下使用mock构建rpm

首先是安装mock,由于mock是在epel仓库里的,所以还需要先装epel仓库http://leo108.com

yum -y install epel-release
yum -y install mock

通常情况下使用rpmbuild会新开一个用户,比如builder,这样就不会污染系统环境。我们需要把builder用户加入mock用户组mock

usermod -a -G mock builder

mock下使用rebuild需要src.rpm文件,所以如果只有.spec文件的话,需要先生成src.rpm文件

推酷是个无耻的网站

rpmbuild -bs package.spec

这样在SRPM目录下就会生成一个src.rpm文件了,然后就可以通过mock命令来rebuild rpm文件rpm

首先需要初始化mock环境,在/etc/mock文件夹下有各个环境的配置文件,比如centos 6就是epel-6-x86_64,初始化命令就是:Centos下使用mock构建rpm

mock -r epel-6-x86_64 --init

初始化完毕之后就可以开始构建了

mock -r epel-6-x86_64 rebuild package-1.1-1.src.rpm

构建完毕,rpm文件会存放在/var/lib/mock/epel-6-x86_64/result目录下。当然我们可以通过–resultdir参数来指定rpm文件的生成目录http://leo108.com/pid-2207.asp

mock -r epel-6-x86_64 rebuild package-1.1-1.src.rpm --resultdir=/home/builder/rpms

最后执行clean命令清理环境

leo108's blog

mock -r epel-6-x86_64 --clean

 

Symfony支持多个站点(应用)

对于一个网页系统来说,通常会需要面向用户的站点和面向管理员的站点,有的甚至还需要面向App的api站点。这些站点拥有不同的域名,但却共享核心业务逻辑。

推酷是个无耻的网站

Symfony的标准发行版只支持一个站点,虽然可以通过路由系统中的Host配置,根据不同的域名使用不同的路由规则,这样也可以实现类似多站点的功能,但缺点也非常明显:

  • 如果希望某个Service在不同的站点有不同的表现,就没办法实现(DI不能直接注入Request)。
  • 静态文件没办法很好拆分开来
  • 每个页面请求都需要加载所有站点的配置(bundle、路由规则、Service等等),影响性能
  • 不同的站点的异常处理逻辑不同(例如对于NotFoundHttpException,在Api站点可能需要输出一个json串而在网页端需要输出一个404页面)

经过搜索,发现也有人有相同的困惑,也给出了一个初步的解决方案。但是还是有一些细节方面的问题,比如标准发行版自带的Composer post-install-cmd/post-update-cmd(清文件缓存、生成bootstrap.cache.php、发布静态文件到web根目录等)不能正常使用。那篇文章只是通过软链解决了bootstrap.cache.php的问题,但并没有提到清文件缓存等。Symfony支持多个站点(应用)

这个问题只能自己写代码来解决了,新建一个composer项目,依赖于sensio/distribution-bundle,新建一个ScriptHandler类,代码如下:

namespace Dreamore\DreamoreBundle\Composer;

use Composer\Script\CommandEvent;
use Sensio\Bundle\DistributionBundle\Composer\ScriptHandler as Base;

class ScriptHandler extends Base
{
    /**
     * Composer variables are declared static so that an event could update
     * a composer.json and set new options, making them immediately available
     * to forthcoming listeners.
     */
    protected static $options = array(
        'dm-apps'           => array(),
        'dm-assets-install' => 'hard',
        'dm-cache-warmup'   => false,
    );

    /**
     * Builds the bootstrap file.
     *
     * The bootstrap file contains PHP file that are always needed by the application.
     * It speeds up the application bootstrapping.
     *
     * @param $event CommandEvent A instance
     */
    public static function buildBootstrap(CommandEvent $event)
    {
        $options = static::getOptions($event);
        foreach ($options['dm-apps'] as $config) {
            $bootstrapDir = $config['app-dir'];
            $autoloadDir  = $config['autoload-dir'];

            if (!static::hasDirectory($event, 'app-dir', $bootstrapDir, 'build bootstrap file')) {
                return;
            }

            if (!static::hasDirectory($event, 'autoload-dir', $autoloadDir, 'build bootstrap file')) {
                return;
            }

            static::executeBuildBootstrap($event, $bootstrapDir, $autoloadDir, $options['process-timeout']);
        }
    }

    /**
     * Clears the Symfony cache.
     *
     * @param $event CommandEvent A instance
     */
    public static function clearCache(CommandEvent $event)
    {
        $options = static::getOptions($event);
        foreach ($options['dm-apps'] as $config) {
            $consoleDir = $config['app-dir'];
            if (!static::hasDirectory($event, 'app-dir', $consoleDir, 'execute command')) {
                return;
            }

            $warmup = '';
            if (!$options['dm-cache-warmup']) {
                $warmup = ' --no-warmup';
            }

            static::executeCommand($event, $consoleDir, 'cache:clear'.$warmup, $options['process-timeout']);
        }
    }

    /**
     * Installs the assets under the web root directory.
     *
     * For better interoperability, assets are copied instead of symlinked by default.
     *
     * Even if symlinks work on Windows, this is only true on Windows Vista and later,
     * but then, only when running the console with admin rights or when disabling the
     * strict user permission checks (which can be done on Windows 7 but not on Windows
     * Vista).
     *
     * @param $event CommandEvent A instance
     */
    public static function installAssets(CommandEvent $event)
    {
        $options = static::getOptions($event);
        foreach ($options['dm-apps'] as $config) {
            $needAssets = isset($config['need-assets']) ? $config['need-assets'] : true;
            if (!$needAssets) {
                continue;
            }

            $consoleDir = $config['app-dir'];
            if (!static::hasDirectory($event, 'app-dir', $consoleDir, 'execute command')) {
                return;
            }

            $webDir = $config['web-dir'];

            $symlink = '';
            if ($options['dm-assets-install'] == 'symlink') {
                $symlink = '--symlink ';
            } elseif ($options['dm-assets-install'] == 'relative') {
                $symlink = '--symlink --relative ';
            }

            if (!static::hasDirectory($event, 'web-dir', $webDir, 'install assets')) {
                return;
            }

            static::executeCommand($event, $consoleDir, 'assets:install '.$symlink.escapeshellarg($webDir), $options['process-timeout']);
        }

    }

    protected static function getOptions(CommandEvent $event)
    {
        $options = array_merge(static::$options, $event->getComposer()->getPackage()->getExtra());
        $options['process-timeout'] = $event->getComposer()->getConfig()->get('process-timeout');

        return $options;
    }
}

这里就重写buildBootstrap\clearCache\installAssets这三个方法的逻辑,同时为了避免冲突,我重新命名了配置项,composer.json的配置如下:

http://leo108.com

"autoload": {
    "psr-4": {
        "": "src/"
    },
    "files": [
        "apps/api/ApiKernel.php",
        "apps/admin/AdminKernel.php",
        "apps/wap/WapKernel.php"
    ]
},
"scripts": {
    "post-install-cmd": [
        "Dreamore\\DreamoreBundle\\Composer\\ScriptHandler::buildBootstrap",
        "Dreamore\\DreamoreBundle\\Composer\\ScriptHandler::clearCache",
        "Dreamore\\DreamoreBundle\\Composer\\ScriptHandler::installAssets"
    ],
    "post-update-cmd": [
        "Dreamore\\DreamoreBundle\\Composer\\ScriptHandler::buildBootstrap",
        "Dreamore\\DreamoreBundle\\Composer\\ScriptHandler::clearCache",
        "Dreamore\\DreamoreBundle\\Composer\\ScriptHandler::installAssets"
    ]
},
"extra": {
    "dm-apps": [
        {
            "app-dir": "apps/api",
            "autoload-dir": "apps",
            "need-assets": false
        },
        {
            "app-dir": "apps/admin",
            "autoload-dir": "apps",
            "web-dir": "web/admin"
        },
        {
            "app-dir": "apps/wap",
            "autoload-dir": "apps",
            "web-dir": "web/wap"
        }
    ],
    "dm-assets-install": "relative"
}

autoload中的file加入各个站点的kernel文件,这样就不需要手动require了。

scripts替换成我们自己的ScriptHandler

leo108's blog

dm-apps是一个数组,每个站点一项,每个站点的配置有app-dir、autoload-dir、web-dir、need-assets,app-dir代表kernel文件所在的目录;autoload-dir代表autoload.php文件所在的目录,由于各个站点的autoload.php完全一致,所以我就把这个文件放到apps目录下,所有站点共享(因此需要修改每个站点的app.php app_dev.php和console这3个文件);web-dir代表发布静态文件的目标目录;need-assets代表是否需要发布静态文件(比如api这个站点就不需要发布静态文件)。

leo108's blog

这样每次composer update或者composer install之后,我们的ScriptHandler就会对每一个站点执行生成bootstrap文件、清理文件缓存和发布静态文件的操作。symfony

vagrant环境中symfony程序速度慢解决方案

最近在写一个symfony程序,最开始是直接在Mac下通过console server:run命令启动一个简单的web服务器来访问,但是Mac本身自带的php没有memcached扩展,所以就把这个程序放到vagrant中,然而发现访问的速度非常慢,一个极其简单的页面也需要消耗10秒左右,于是搜索了一下,发现之前有人遇到相同的问题

推酷是个无耻的网站

该文章中列出的几个方案:

  1. 使用vagrant1.2版本(目测不合适,现在都已经1.7+了)
  2. 使用NFS方式挂载目录
  3. vagrant虚拟机中的Vbox Guest Additions版本与virtual box版本一致(我当前的环境就是一致的)
  4. 使用opcache扩展(我当前的环境已经安装了apc)
  5. 关闭xdebug和xphrof扩展(我当前环境已经关闭)

看这情况,只能尝试一下NFS方式了,根据vagrant的文档配置,还好OS X自带了nfsd,省去了安装的麻烦,只需要修改vagrantfile即可。http://leo108.com

在vagrantfile中增加两行:

symfony

config.vm.network :private_network, type: :dhcp
config.vm.synced_folder ".", "/vagrant", type: "nfs"

然后重启vagrant虚拟机vagrant reload

由于启用NFS需要修改/etc/exports文件,所以在启动的过程中会要求输入OS X用户的密码,输入即可。vagrant

再次尝试访问symfony程序,发现耗时已经降到200ms左右,完美解决。symfony

PHP程序配置文件(最佳?)实践

最原始的方式

写php程序时会直接把数据库、缓存的连接信息放在config.php文件里。这样做有两个弊端,1.在开发调试时必须先把连接信息改成本地的,要提交代码时再改回远程的,麻烦,也容易遗漏。2.开发人员可以直接看到线上数据库的连接地址、账号、密码,不安全。

怎么办?

线上运行PHP程序时通常需要一个Http服务器,例如apache、nginx。以nginx为例,在配置文件中可以通过fastcgi_param指令来给PHP传递变量

http://leo108.com

fastcgi_param  DB_HOST  "192.168.1.1";

这样就可以在PHP代码里通过$_SERVER[‘DB_HOST’]来获取到对应的值。

所以开发环境和线上环境只要配好nginx的配置,就可以实现不改代码执行程序了。

更进一步,可以传递一个标示当前环境的变量,例如配置

fastcgi_param  CODE_ENV  "production";

然后在程序里可以根据$_SERVER[‘CODE_ENV’]的不同来执行一些不同的逻辑(比如开发环境会打印所有错误信息而线上环境不显示)

leo108's blog

这样完美了吗?

并没有,在实际应用中发现有两个问题:1.如果这个PHP程序不仅仅提供web服务,还提供了cli工具(例如数据库升级脚本),这个时候nginx传递的变量就过不来了。2.貌似不支持传递数组变量。http://leo108.com

解决思路

要同时支持web和cli,通过nginx配置传变量已经行不通了,那能不能走php自己的配置呢?

于是在php.ini的末尾加上如下配置:PHP

[userconf]
userconf.db_host=127.0.0.1
userconf.db_name=test

重启php-fpm之后,发现通过ini_get(‘userconf.db_host’)取到的数据是空,于是详细查看了php的文档,发现对于自定义的配置项,需要通过get_cfg_var()函数来获得。经过测试,在web和cli模式下,通过get_cfg_var(‘userconf.db_host’)可以拿到正确的值。

再优化

但这个方案还是有弊端:1.修改配置需要重启php-fpm。2.虽然php.ini里面支持数组的数据,但是还是不够灵活,最好是能直接用php配置。

http://leo108.com/pid-2184.asp

所以我们可以在php.ini里面只配置一个配置目录的路径,这个目录下放置各个程序的配置文件,php程序先从php.ini获取到这个目录的路径,再从这个目录下读取php格式的配置文件。示例代码:

http://leo108.com

$path = get_cfg_var('userconf.dir');
$conf = include($path . '/test.php');

这样配置文件是每次访问都会重新读取,变更时不需要重启php-fpm;而且不同的站点只要选择不同的配置文件名,就可以在一台服务器上共存。

配置

但这样还是有一个问题没有解决,那就是无法在同一台服务器上部署两个相同的站点,不过这个场景也不多,不解决也没关系。

leo108's blog

vagrant centos升级内核版本、升级VBoxGuestAdditions版本

在vagrant的centos中,如果直接执行yum install kernel-devel,会提示”No matches found for: kernel-devel”,仔细观察了一下yum的输出,发现加载了一个versionlock的插件,于是猜测与这个插件有关,禁用了内核版本的更新,所以把这个插件禁用掉即可。centos

编辑/etc/yum/pluginconf.d/versionlock.conf文件,将enable的值改成0。然后再执行yum update kernel就可以将内核更新到最新版本。

但是这个时候如果重启了vagrant虚拟机,会发现vagrant报错

Failed to mount folders in Linux guest. This is usually because
the “vboxsf” file system is not available. Please verify that
the guest additions are properly installed in the guest and
can work properly.vagrant

搜索了下,发现可以通过执行/etc/init.d/vboxadd setup重新安装VBoxGuestAdditions来解决。

virtualbox

===============================================

centos

但是centos自带的VBoxGuestAdditions版本比较低,在启动vagrant的时候也会提示

leo108's blog

The guest additions on this VM do not match the installed version of VirtualBox! In most cases this is fine, but in rare cases it can prevent things such as shared folders from working properly. If you see shared folder errors, please make sure the guest additions within the virtual machine match the version of VirtualBox you have installed on your host and reload your VM.

所以可以更新一下VBoxGuestAdditions版本。访问http://download.virtualbox.org/virtualbox/,找到你当前virtualbox版本的目录,进去之后可以找到对应版本的VBoxGuestAdditions的iso文件,将这个iso文件下载到vagrant虚拟机中,然后执行以下命令:vagrant

mount VBoxGuestAdditions_5.0.10.iso -o loop /mnt
cd /mnt/
sh VBoxLinuxAdditions.run --nox11

执行完毕之后退出虚拟机,再次执行vagrant reload即可

http://leo108.com/pid-2180.asp

专注于技术,切不可沉湎于技术