thinkphp3.2实现用户级的插件功能

大兄弟 2019年03月11日0   77

毕业设计需要一个插件功能,结合fastAdmin和部分开源社区的前辈作品,有了一个初步的设想。先看一下目前以实现的插件目录:

image.png

这是一个对系统登录功能进行扩展的插件,插件除了插件配置文件和插件主体类文件外,其他均为tp3.2所述的“模块”,用于制作插件访问。

  1. View文件夹是插件所有模版文件的总汇文件夹,文件夹名称不允许更改,居于View文件夹根部的模版文件属于插件主体类的模版文件。

  2. LoginAddon.class.php是整个插件的主体逻辑实现,可以先看看类的构成:


    <?php
    
    namespace Addons\Login;
    
    use Common\Controller\AddonController;
    use Think\Verify;
    
    /**
     * 登录插件 原始系统只给予简单的账号密码验证
     * 考虑安全性 给予开发者一个更加自由的扩展接口
     * 可用于添加登录验证,如验证码 第三方登录等
     * Class LoginAddon
     * @package Addons\Login
     */
    class LoginAddon extends AddonController
    {
        public static $conf_name = 'login_addon_config';
        public static $default_conf = array(
            'verify_open'=>1,
            'verify'=>array (
                'expire' => '120',
                'font_size' => '16',
                'length' => '4',
                'imageW' => '0',
                'imageH' => '0',
                'seKey' => 'icy8',
            ),
            'third_open'=>1
        );
        /**
         * 注册钩子
         * 初步设想:
         * 返回钩子数组 用于告诉系统这个插件想要完成什么功能 要对哪些钩子进行实现
         * 数组结构:
         * ['系统钩子'=>'钩子的别名方法']
         * 或 ['系统钩子'=>['一些列的功能队列','一些列的功能队列','一些列的功能队列']] 这种写法多用于队列类型的功能
         * 或['系统钩子1','系统钩子2','系统钩子3',] 这种写法是最简便的,但执行的方法就只能是和系统钩子名称相同的本类的方法名了
         * 当然一个插件中允许你使用任意的钩子注册规则进行混合使用
         * @return array|void 必须返回
         */
        public function register_hook()
        {
            // 目前接入验证码、第三方登录等
            return [
                'login_input_end' => 'verify_code',// 登陆验证码
                'login_form_end' => 'third_login', // 第三方登录
                'login_start'
            ];
        }
    
        // 钩子逻辑实现
    
        /**
         * 验证码显示
         * @param $param
         */
        public function verify_code(&$param)
        {
            $this->display('verify_code');
        }
    
        /**
         * 第三方登录显示
         * @param $param
         */
        public function third_login(&$param) {
            $this->display('third_login');
        }
    
        /**
         * 登录验证开始
         * 对验证进行拦截 如果验证失败则终止系统运行 否则继续
         * @param $post
         */
        public function login_start($post) {
            $v = new Verify();
            $res = $v->check($post['verify_code']);
            if(!$res) {
                $this->error('验证码错误');
            }
        }
        // 钩子逻辑结束
    
        /**
         * 插件开关
         * @return bool 必须告诉系统 插件的开关结果
         */
        public function start()
        {
            return true;
        }
    
        /**
         * 插件安装
         * @return bool 必须告诉系统 插件的开关结果
         */
        public function install()
        {
            $model = D('Config');
            $data = $model->create(array(
                'type'=>'text',
                'group'=>1,
                'name'=>self::$conf_name,
                'value'=>serialize(self::$default_conf),
            ));
            if(!$data) return false;
            $res = $model->add();
            if(!$res) return false;
            return true;
        }
    
        /**
         * 插件卸载
         * @return bool 必须告诉系统 插件的开关结果
         */
        public function uninstall()
        {
            $map = array(
                'name'=>self::$conf_name,
            );
            D('Config')->where($map)->delete();
            return true;
        }
    }

    可以看到,整个插件逻辑分为:向系统注册钩子->别名或原型钩子逻辑实现->执行结果 ,其余为安装、卸载、开关的单步逻辑。其中钩子注册为插件的核心实现,所谓的钩子,即为tp3.2的Think\Hook类,也就是手册提到的“标签位”,另一个使用钩子实现的框架功能就是“行为”了,它是钩子中比较特殊的例子,它只能运行run方法,而自由的标签位是可以执行你任意想执行的方法。

    插件主体文件是建议继承AddonController类的,因为多数钩子逻辑都是需要配合模版或者其他控制器级方法的。

  3. 接下来是插件访问了,这个功能我考虑了很久,一开始我重新阅读了一遍tp3.2的框架代码,发现作者有预留插件功能代码的,但是效果并没有我预期的好,我期望的是每个插件都是独立的互不影响的应用,最后我只能从入口文件考虑了,因为共用一个入口文件就没有办法使插件独立起来了,所以只能新建一个名为addon.php的入口文件了,然后通过常量,把公共目录全部指向应用目录。本以为这样就解决了所有的问题,万万没想到,这样会导致插件访问过程中的U方法无法生产主程序的url。后来只能使用类似于重写U方法的办法进行改写函数逻辑了,我是通过替换入口文件名的方法来解决问题的,但是这还是会在伪静态模式下导致访问紊乱,伪静态的问题好像也不大,反正如果不考虑程序的用户群的话,这些小瑕疵是可以接受的,因为暂时针对的使用者就只有我自己,只要自己觉得可以就OK了。

  4. 接下来就是如何加载这些安装并开启了的插件“们”了,我的思路就是在控制器开始时载入插件,即监听action_begin标签位,标签位逻辑实现:


    <?php
    
    namespace Common\Behavior;
    
    use Think\Behavior;
    use Think\Hook;
    use Common\Service\AddonService;
    
    /**
     * 加载系统插件
     * Class AddonsBehavior
     * @package Common\Behavior
     */
    class AddonsBehavior extends Behavior
    {
        public function run(&$params)
        {
            $_list = $this->getHookList();
            foreach ($_list as $item) {
                foreach ($item['hook_list'] as $key=>$hook) {
                    if(is_array($hook)) {
                        // 队列模式
                        foreach ($hook as $v) {
                            Hook::add($key, $item['classname']);
                        }
                    }else if(is_numeric($key)) {
                        // 原型模式
                        Hook::add($hook, $item['classname']);
                    }else if(is_string($key)){
                        // 别名模式
                        Hook::add($key, $item['classname']);
                    }
                }
            }
            return true;
        }
    
        public function getHookList($status = 1)
        {
            $map = array(
                'status' => $status,
            );
            $result = M('Addon')->where($map)->select();
            $hook_list = array();
            foreach ($result as &$row) {
                $class = 'Addons\\' . $row['name'] . '\\' . $row['name'] . C('ADDON_BASE_EXT');
                $hand = new $class(AddonService::getLocalAddons()[$row['name']]);
                $hook_list[] = array('hook_list' => $hand->register_hook(), 'classname' => $class);
            }
            return $hook_list;
        }
    }

    代码写得很清晰了,基本上就是获取数据库已安装并已开启的插件,然后加入钩子队列中,我这个写法还缺少一个插件缓存功能,不然每次加载都对数据库进行查询会降低程序的执行速度。

    提醒一下,新的自定义的命名空间需要在配置文件进行配置命名空间和目录的映射,不然插件主体文件会加载不出来。


  5. 插件的实现基本上是这样的一个逻辑,目前比较尴尬的就是插件访问时的U方法问题,主应用和插件访问的url交互会变得比较繁琐,这个问题就留到以后解决吧。


接下来就是安装卸载等的逻辑了

  1. 首先参考fastAdmin写了一个插件服务类AddonService,这个类主要用于安装卸载和升级插件用的,暂时没有写云端插件,所以插件升级的逻辑还没开始研究。先看看我自己写的服务:


    <?php
    
    namespace Common\Service;
    class AddonService
    {
        public static $checkConfig = ['name', 'title', 'version', 'author',]; // 必须验证的配置项
        protected static $error = '';
    
        public static function getError()
        {
            return self::$error;
        }
    
        /**
         * 插件安装
         * @param $name
         * @return bool
         */
        public static function install($name)
        {
            $model = D('Addon');
            $list = self::getLocalAddons();
            $addon = $list[$name];
            if ($addon['installed']) {
                // 禁止重复安装
                self::$error = '插件已经安装';
                return false;
            }
            $path = self::getAddonsPath($name);
            $file = $path . $name . DIRECTORY_SEPARATOR . $name . C('ADDON_BASE_EXT') . EXT;
            if (is_file($file)) {
                $class = 'Addons\\' . $name . '\\' . $name . C('ADDON_BASE_EXT');
                $hand = new $class($addon);
                if (!method_exists($hand, 'install')) {
                    self::$error = '请编写安装脚本';
                    return false;
                }
                $res = $hand->install($addon);
                if ($res) {
                    // 安装成功马上向数据库记录相应数据
                    $m = array('name' => $name);
                    $data = $model->create($addon);
                    $res = $model->in2replace($data, $m) !== false ? true : false;
                }
                return $res;
            }
            self::$error = '插件主文件丢失';
            return false;
        }
    
        /**
         * 插件卸载
         * 执行逻辑基本与安装操作一致
         * @param $name
         * @return bool
         */
        public static function uninstall($name)
        {
            $list = self::getLocalAddons();
            $addon = $list[$name];
            if (!$addon['installed']) {
                // 禁止重复卸载
                self::$error = '插件已经卸载或未安装';
                return false;
            }
            $path = self::getAddonsPath($name);
            $file = $path . $name . DIRECTORY_SEPARATOR . $name . C('ADDON_BASE_EXT') . EXT;
            if (is_file($file)) {
                $class = 'Addons\\' . $name . '\\' . $name . C('ADDON_BASE_EXT');
                $hand = new $class($addon);
                if (!method_exists($hand, 'uninstall')) {
                    self::$error = '请编写卸载脚本';
                    return false;
                }
                $res = $hand->uninstall($addon);
                if ($res) {
                    // 卸载成功马上从数据库中删除插件的数据
                    $model = D('Addon');
                    $m = array(
                        'name' => $name
                    );
                    $res = $model->where($m)->delete();
                }
                return $res;
            }
            self::$erorr = '插件主文件丢失';
            return false;
        }
    
        /**
         * 插件开关
         * @param $name
         * @param $status 状态代码 1开启 0关闭
         * @return bool
         */
        public static function status($name, $status = 1)
        {
            $list = self::getLocalAddons();
            $addon = $list[$name];
            if (!$addon['installed']) {
                // 未安装的插件不允许开关
                self::$error = '插件已经卸载或未安装';
                return false;
            }
            $path = self::getAddonsPath($name);
            $file = $path . $name . DIRECTORY_SEPARATOR . $name . C('ADDON_BASE_EXT') . EXT;
            if (is_file($file)) {
                $class = 'Addons\\' . $name . '\\' . $name . C('ADDON_BASE_EXT');
                $hand = new $class($addon);
                if (!method_exists($hand, 'start')) {
                    self::$error = '请编写卸载脚本';
                    return false;
                }
                $res = $hand->start($status);
                if ($res) {
                    $model = D('Addon');
                    $m = array('name' => $name);
                    $res = $model->where($m)->save(array('status' => $status));
                }
                return $res;
            }
            self::$erorr = '插件主文件丢失';
            return false;
        }
    
        /**
         * 获取本地插件 & 检测已安装的插件
         * @return array
         */
        public static function getLocalAddons()
        {
            $dir = self::getAddonsPath() . DIRECTORY_SEPARATOR; // 插件路径
            $addons = array();
            $model = D('Addon');
            if (is_dir($dir)) {
                // 读插件目录
                foreach (glob($dir . DIRECTORY_SEPARATOR . '*') as $path) {
                    if (is_dir($path) && is_file($path . DIRECTORY_SEPARATOR . 'config.php')) {
                        $config = include $path . DIRECTORY_SEPARATOR . 'config.php';// 插件信息配置文件 固定命名
                        $config['installed'] = 0;
                        if (self::checkConfig($config)) {
                            if ($row = $model->findOneAddon($config['name'])) {
                                $config = array_merge($config, $row);
                                $config['installed'] = 1;
                                unset($row);
                            }
                            $addons[$config['name']] = $config; //
                        }
                    }
                }
            }
            return $addons;
        }
    
        /**
         * 验证插件配置是否完整
         * @param $config
         * @return bool
         */
        protected static function checkConfig($config)
        {
            foreach (self::$checkConfig as $v) {
                if (!isset($config[$v]) || $config[$v] == null || $config[$v] == false) {
                    return false;
                }
            }
            return true;
        }
    
        public static function getAddonsPath()
        {
            return C('ADDON_PATH');
        }
    
        // 组装某个插件的目录
        public static function makePath($name)
        {
            return self::getAddonsPath() . DIRECTORY_SEPARATOR . $name . DIRECTORY_SEPARATOR;
        }
    }

    代码写得很简单,也没有任何特色,哈哈没办法,毕竟技术就在那儿。大概就是安装成功就向数据库记录这个插件的大概信息,安装和卸载的逻辑基本上是一样的,我在考虑把这两个操作合并在一个方法,以减少代码重复率。

    接下来就是开发插件了,我还在体验自己制作的插件功能,感觉没有预期的效果。。。。