极致CMS帮助文档极致CMS帮助文档
首页
论坛
视频
加群
工具
GitHub
首页
论坛
视频
加群
工具
GitHub
  • 引言
  • 条款
  • 起步
  • 基础标签

    • 系统配置
    • 配置栏目
    • 栏目导航
    • 面包屑导航
    • 栏目列表
    • 单页
    • 文章内容页
    • 商品内容页
    • 点击量
    • 点赞
    • 收藏
    • 评论
    • 购买
    • 轮播图/幻灯片
    • 友情链接
    • 网站底部
    • 自定义字段
    • TAG
    • 内链
    • 碎片化
  • LOOP标签

    • 基本用法
    • 分页
    • 空数据
    • tid
    • like
    • notlike
    • limit
    • jzattr
    • day
    • table
    • sql
    • jzcache
    • notjz
  • Screen筛选
  • 功能标签
  • 极致模型函数
  • 自定义路由
  • 相关统计
  • 邮箱配置
  • 多语言支持
  • 搜索模块

    • 单模型搜索
    • 多模型搜索
    • 搜索结果页
  • 留言模块

    • 基本用法
    • 高级用法
    • 搜索结果页
  • 自定义模块
  • 模板制作

    • 准备工作
    • 网站栏目
    • 页面规划
    • 自定义函数
    • 模板信息
    • 客户权限
    • 桌面设置
    • 上线部署
  • 模板列表
  • 插件相关

    • 安装卸载
    • 控制器方法覆盖(简单)
    • 控制器方法钩子(高级)
    • 覆盖Common公共控制器
    • 极致CMS升级插件
    • 系统API接口插件
    • 数据库修复插件
    • 多语言建站
    • 模板助手插件
    • 阿里云短信注册插件
    • 独立静态网站插件
    • 云储存插件
    • Excel导入导出插件
    • 后台登录安全插件
    • 屏蔽IP插件
    • 在线编辑模板插件
    • 生成多尺寸缩略图插件
    • 火车头采集Web发布插件
    • 伪原创插件
    • 留言发送邮箱插件
    • 留言提交安全插件
    • QQ一键登录插件
    • Skycaiji蓝天采集API接口插件
    • 多域名绑定插件
    • 百度SEO推送
    • 百度百家推送
    • 极致插件示例
    • 栏目便捷工具
  • 支付相关

    • 支付宝支付
    • 微信支付
    • 立即支付
    • 接入第三方支付
  • 会员模块

    • 页面模板说明
    • 个人中心
    • 我的资料/修改密码
    • 我的关注
    • 我的粉丝
    • 我的投稿
    • 我的收藏
    • 我的喜欢
    • 我的评论
    • 我的钱包
    • 购买记录
    • 我的购物车
    • 订单管理
    • 消息设置
    • 公开页
    • 会员登录
    • 会员注册
    • 忘记密码
    • 微信登录
  • 微信小程序

    • 开始起步
    • 小程序开发
    • API接口
    • 轮播图/幻灯片
    • 获取栏目信息
    • 获取内容详情
    • 留言交互
  • Windows部署
  • 宝塔一键部署
  • 伪静态配置
  • 系统架构
  • 数据字典
  • 视频教程
  • 版本更新
  • Vue & App接入(v2.5.2+)

    • 验证码
    • 上传文件
    • 会员注册
    • 会员登录
    • 找回密码
    • 获取用户信息
    • 修改用户信息
    • 我的文章
    • 发布文章
    • 删除文章
    • 获取单篇文章信息
    • 我的收藏
    • 收藏/取消收藏
    • 是否收藏
    • 我的点赞
    • 点赞/取消点赞
    • 是否点赞
  • 常见问题

    • 如何判断首页,栏目页,详情页,单页?
    • 如何调用关联和相关文章内容?
    • 搜索超出设定范围如何解决?
    • 判断用户是否购买商品?
    • 判断会员分组?
    • 如何输出内容图片?
    • 如何判断自己是否适合使用极致CMS?
    • ajax数据交互,加载更多功能实现?
    • 如何实现后台录入时自定义检测重复内容的功能?
  • 附录

    • 时间格式
    • 富文本编辑器
    • 二维码生成
    • 验证码生成
    • 自定义后台主页
    • 客户端判断
    • classtypedata数据详解
    • 文章归档
    • RSS
    • 制作内容分页
    • 各种时间查询问题解决
    • 更换编辑器

控制器方法钩子(hook)


基本原理

hook 顾名思义,就是一个钩子,一个可以挂载到任何一个方法上面的钩子。它的方便之处在于不更改原方法,也可以让他执行。

案例

下面通过案例来一步步介绍如何制作这个钩子,钩子不能自动执行,必须通过后台的插件安装,进行注册。

实现一个带后台配置的插件,填写的配置可以输出到网站前台上。

  1. 后台 - 插件列表 - 下载 【极致插件DEMO】 这个插件,无需安装
  2. 在 app/admin/exts 复制 test 文件夹,并重命名为 newtest ,这个就是我们要制作的新插件
  3. 在 config.php 里面进行修改:
return [
	'name'=>'新测试插件',//插件名,必须与插件文件夹名字相同
	'desc'=>'这是一个极致插件开发的案例展示',//插件介绍
	'author'=>'留恋风2581047041@qq.com',//作者介绍,这里可以把自己的联系方式带上去,方便用户沟通
	'version'=>'1.0',//插件版本,默认1.0为最低版本
	'update_time'=>'2022-02-24',//插件更新时间,格式:Y-m-d
	'module'=>'Home',//插件应用的模块,Home表示前台模块,Admin表示后台模块.插件安装的时候会据此加载控制器到对应的目录中
];
  1. 打开 PluginsController.php 文件,下面很多可能用得上的函数,都做了注释,目前我们的需求只需要对,install 、uninstall 、setconfigdata 三个函数进行修改:
//执行SQL语句在此处处理,或者移动文件也可以在此处理
	public  function install(){
		//下面是新增test表的SQL操作
        /*
		$table = 'test';
		$sql = "CREATE TABLE IF NOT EXISTS `".DB_PREFIX.$table."` (
				`id` int(11) unsigned NOT NULL auto_increment,
				`tid` int(11) DEFAULT 0,
				`orders` int(11) DEFAULT 0,
				`comment_num` int(11) DEFAULT 0,
				`htmlurl` varchar(100) DEFAULT NULL,
				PRIMARY 
				KEY  (`id`)
				) ENGINE=MyISAM  DEFAULT CHARSET=utf8 AUTO_INCREMENT=1";
				
		$x = M()->runSql($sql);
		*/
		//转移一个文件到另一个文件下面
        /**
         * 方法1:将当前目录下的tpl/test.html 转移到 根目录static/test.html (需要确定转移的文件目录已存在)
         * copy(APP_PATH.APP_HOME.'/exts/test/tpl/test.html',APP_PATH.'static/test.html')
         * 方法2:将当前目录下的file文件夹下面所有文件(只是文件,不包含文件夹) 转移到 根目录static下面 (需要确定转移的文件目录已存在)
         * $this->removeFile(APP_PATH.APP_HOME.'/exts/test/file',APP_PATH.'static');
         * 方法3:将当前目录下的file文件夹下面所有文件(包含文件夹) 转移到 根目录static下面
         * $this->recurse_copy(APP_PATH.APP_HOME.'/exts/test/file',APP_PATH.'static');
         */

        //备份当前数据库
        /**
         * $this->JZ_backup();
         */
         
        /**
        这里进行注册 hook 
        **/
        
		$ww = [];
		$ww['module'] = 'home';
		$ww['controller'] = 'Home';//HomeController前台主要入口控制器
		$ww['action'] = 'jizhi';//主入口函数
		$ww['hook_controller'] = 'NewTest';//我们需要创建的控制器 NewTestController
		$ww['hook_action'] = 'index';//需要写的函数
		$ww['all_action'] = 1;//这个标识,HomeController下面的所有方法,是否都执行我的这个函数,1是0否
		$ww['isopen'] = 1;//开放这个钩子,必须写1
		$ww['plugins_name'] = 'newtest';//我们这个插件文件夹名字
		$ww['addtime'] = time();//当前时间,默认必须写
		M('hook')->add($ww);
        
        setCache('hook',null);//重置钩子,只要注册了钩子就必须重置才会执行

		return true;
		
	}
	
	//卸载程序,对新增字段、表等进行删除SQL操作,或者其他操作
	public function uninstall(){
		//下面是删除test表的SQL操作
//		$table = 'test';
//		$sql = "DROP TABLE `".DB_PREFIX.$table."` ;";
//		$x = M()->runSql($sql);

        /**
        注销hook
        **/
        M('hook')->delete(['plugins_name'=>'newtest']);

        setCache('hook',null);//重置钩子,卸载后需要加载最新的缓存
		return true;
	}
	
	//安装页面介绍,操作说明
	public function desc(){
		
		$this->display($this->tpl.'plugins-description.html');
	}
	
	//配置文件,插件相关账号密码等操作
	public  function setconf($plugins){
		//将插件赋值到模板中
		$this->plugins = $plugins;
		$this->configs = json_decode($plugins['config'],1);//不能设置成 $this->config 因为已经设定了 private $config
		
		$this->display($this->tpl.'plugins-body.html');
	}
	
	//获取插件内提交的数据处理
	public function setconfigdata($data){
	    //$data包含了后台提交的所有内容
	    
	    //我们在后台配置三个参数,分别是 title , description , litpic 
	    $config['title'] = format_param($data['title'],1);
	    $config['description'] = format_param($data['description'],1);
	    $config['litpic'] = format_param($data['litpic'],1);
	    
	    M('plugins')->update(['id'=>$data['id']],['config'=>json_encode($config,JSON_UNESCAPED_UNICODE)]);
	
		JsonReturn(['code'=>0,'msg'=>'设置成功!']);
	}

    //批量转移文件
    private function removeFile($from,$to){
        //移动后台插件控制器
        $sourcefile = $from;
        $target = $to;
        if(is_dir($sourcefile) && is_dir($target)){
            if (false != ($handle = opendir ( $sourcefile ))) {

                while ( false !== ($file = readdir ( $handle )) ) {
                    //去掉"“.”、“..”以及带“.xxx”后缀的文件
                    if ($file != "." && $file != ".." && !is_dir($sourcefile.'/'.$file) ) {
                        $fs = $sourcefile.'/'.$file;
                        $ft = $target.'/'.$file;
                        //备份源文件以防更新覆盖
                        copy($ft,  $sourcefile.'/back/'.$file);
                        $r = $this->file2dir($fs,$ft);
                        if(!$r){
                            JsonReturn(array('code'=>1,'msg'=>'文件转移失败!sourcefile:'.$fs.' targetfile:'.$ft));
                        }
                    }
                }
                //关闭句柄
                closedir ( $handle );
            }

        }

    }

    // 原目录,复制到的目录
    function recurse_copy($src,$dst) {

        $dir = opendir($src);
        @mkdir($dst);
        while(false !== ( $file = readdir($dir)) ) {
            if (( $file != '.' ) && ( $file != '..' )) {
                if ( is_dir($src . '/' . $file) ) {
                    $this->recurse_copy($src . '/' . $file,$dst . '/' . $file);
                }
                else {
                    copy($src . '/' . $file,$dst . '/' . $file);
                }
            }
        }
        closedir($dir);
    }
    //复制文件并转移
    function file2dir($sourcefile, $filename){
        if( !file_exists($sourcefile)){
            return false;
        }
        //$filename = basename($sourcefile);

        return copy($sourcefile,  $filename);
    }
    //返回表字段
    private function getTableFields($table){
        if(defined('DB_TYPE') && DB_TYPE=='sqlite'){
            $sql = "pragma table_info(".DB_PREFIX.$table.")";

            $list = M()->findSql($sql);
            $fields = [];
            foreach($list as $v){
                $fields[]=$v['name'];

            }
        }else{
            $sql = 'SHOW COLUMNS FROM '.DB_PREFIX.$table;
            $list = M()->findSql($sql);
            $isgo = true;
            $fields = [];
            foreach($list as $v){
                $fields[]=$v['Field'];

            }
        }



        return $fields;

    }
    //返回数据库表
    private function getTableData(){
        if(defined('DB_TYPE') && DB_TYPE=='sqlite'){
            $sql = "select name from sqlite_master where type='table' order by name";
        }else{
            $sql = "SHOW TABLES";
        }


        $tables = M()->findSql($sql);
        $ttable = array();
        foreach($tables as $value){
            foreach($value as $vv){
                $ttable[] = $vv;
            }

        }
        return $ttable;
    }
    //备份数据库
    private function JZ_backup(){
        $pconfig = array(
            'host' =>DB_HOST,
            'port' =>DB_PORT,
            'user' =>DB_USER,
            'password' =>DB_PASS,
            'database' =>DB_NAME
        );
        $this->config = array_merge($this->config, $pconfig);
        $this->handler = new \PDO("mysql:host=".$this->config['host'].";port={$this->config['port']};dbname={$this->config['database']}", $this->config['user'], $this->config['password']);
        $this->handler->query("set names utf8");

        $this->backup();
    }

    /**
     * 备份
     * @param array $tables
     * @return bool
     */
    private function backup($tables = array())
    {
        //存储表定义语句的数组
        $ddl = array();
        //存储数据的数组
        $data = array();
        $this->setTables($tables);
        if (!empty($this->tables))
        {
            foreach ($this->tables as $table)
            {
                $ddl[] = $this->getDDL($table);
                $data[] = $this->getData($table);
            }
            //开始写入
            $this->writeToFile($this->tables, $ddl, $data);
        }
        else
        {
            $this->error = '数据库中没有表!';
            return false;
        }
    }
    /**
     * 设置要备份的表
     * @param array $tables
     */
    private function setTables($tables = array())
    {
        if (!empty($tables) && is_array($tables))
        {
            //备份指定表
            $this->tables = $tables;
        }
        else
        {
            //备份全部表
            $this->tables = $this->getTables();
        }
    }
    /**
     * 查询
     * @param string $sql
     * @return mixed
     */
    private function query($sql = '')
    {
        $stmt = $this->handler->query($sql);
        $stmt->setFetchMode(\PDO::FETCH_NUM);
        $list = $stmt->fetchAll();
        return $list;
    }
    /**
     * 获取全部表
     * @return array
     */
    private function getTables()
    {
        $sql = 'SHOW TABLES';
        $list = $this->query($sql);
        $tables = array();
        foreach ($list as $value)
        {
            $tables[] = $value[0];
        }
        return $tables;
    }
    /**
     * 获取表定义语句
     * @param string $table
     * @return mixed
     */
    private function getDDL($table = '')
    {
        $sql = "SHOW CREATE TABLE `{$table}`";
        $ddl = $this->query($sql)[0][1] . ';';
        return $ddl;
    }
    /**
     * 获取表数据
     * @param string $table
     * @return mixed
     */
    private function getData($table = '')
    {
        $sql = "SHOW COLUMNS FROM `{$table}`";
        $list = $this->query($sql);
        //字段
        $columns = '';
        //需要返回的SQL
        $query = [];
        foreach ($list as $value)
        {
            $columns .= "`{$value[0]}`,";
        }
        $columns = substr($columns, 0, -1);
        $data = $this->query("SELECT * FROM `{$table}`");
        foreach ($data as $value)
        {
            $dataSql = '';
            foreach ($value as $v)
            {
                if($v==='' || $v===null){
                    $dataSql .= " NULL,";
                }else{
                    $dataSql .= "'{$v}',";
                }

            }
            $dataSql = substr($dataSql, 0, -1);
            $query[]= "INSERT INTO `{$table}` ({$columns}) VALUES ({$dataSql});\r\n";
        }
        return $query;
    }
    /**
     * 写入文件
     * @param array $tables
     * @param array $ddl
     * @param array $data
     */
    private function writeToFile($tables = array(), $ddl = array(), $data = array())
    {
        $public_str = "/*\r\nMySQL Database Backup Tools\r\n";
        $public_str .= "Server:{$this->config['host']}:{$this->config['port']}\r\n";
        $public_str .= "Database:{$this->config['database']}\r\n";
        $public_str .= "Data:" . date('Y-m-d H:i:s', time()) . "\r\n*/\r\n";
        $i = 0;
        //echo '备份数据库-'.$this->config['database'].'<br />';
        $countsql = 0;//记录sql数
        $filenum = 0;//文件序号
        $backfile = $this->config['target']==''? $this->config['database'].'_'.date('Y_m_d_H_i_s').'_'.rand(100000,999999): $this->config['target'].date('YmdHis');//文件名
        $str = $public_str."SET FOREIGN_KEY_CHECKS=0;\r\n";
        foreach ($tables as $table)
        {
            // echo '备份表:'.$table.'<br>';
            $str .= "-- ----------------------------\r\n";
            $str .= "-- Table structure for {$table}\r\n";
            $str .= "-- ----------------------------\r\n";
            $str .= "DROP TABLE IF EXISTS `{$table}`;\r\n";
            $str .= $ddl[$i] . "\r\n";

            $i++;
            //echo '备份成功!<br/>';

        }
        $i = 0;
        foreach($tables as $table){
            //echo '备份表数据:'.$table.' <br>';
            $str .= "-- ----------------------------\r\n";
            $str .= "-- Records of {$table}\r\n";
            $str .= "-- ----------------------------\r\n";
            //$str .= $data[$i] . "\r\n";
            foreach ($data[$i] as $v){
                $str .= $v;
                $countsql++;
                if($countsql%($this->limit)==0){
                    $str = '<?php die();?>'.$str;
                    if($filenum==0){
                        $isok = file_put_contents('backup/'.$backfile.'.php', $str);
                        if(!$isok){
                            JsonReturn(['code'=>1,'msg'=>'[ backup/'.$backfile.'.php ] 写入文件失败!']);
                        }
                        $filenum++;
                    }else{
                        $isok = file_put_contents('backup/'.$backfile.'_v'.$filenum.'.php', $str);
                        if(!$isok){
                            JsonReturn(['code'=>1,'msg'=>'[ backup/'.$backfile.'_v'.$filenum.'.php ] 写入文件失败!']);
                        }
                        $filenum++;
                    }
                    $str = $public_str;
                }
            }
            $i++;


        }
        if($str!='' && $str != $public_str){
            $str = '<?php die();?>'.$str;
            if($filenum==0){
                $isok = file_put_contents('backup/'.$backfile.'.php', $str);
                if(!$isok){
                    JsonReturn(['code'=>1,'msg'=>'[ backup/'.$backfile.'.php ] 写入文件失败!']);
                }
            }else{
                $isok = file_put_contents('backup/'.$backfile.'_v'.$filenum.'.php', $str);
                if(!$isok){
                    JsonReturn(['code'=>1,'msg'=>'[ backup/'.$backfile.'_v'.$filenum.'.php ] 写入文件失败!']);
                }
            }
        }

    }
    
  1. 将文件夹 newtest/controller/home 下面的 TestController.php 进行重命名为 NewTestController.php ,在上面 install 里面写了 NewTest 需要一一对应,并文件里面的 TestController 改为 NewTestController,如下:

namespace app\home\plugins;

use app\home\c\CommonController;

class NewTestController extends CommonController
{
	function index(){
		echo '这是一个插件页面!';
		//查询插件是否已经开启
		$plugins = M('plugins')->find(['filepath'=>'newtest','isopen'=>1]);
		if($plugins){
		    $config = json_decode($plugins['config']);//后台存储的json,所以要专为数组形式
		    
		    $this->newtest = $config;//将插件赋值到模板中
		    
		    
		    
		}else{
		    Error('插件未开启!');
		}
		
		
		
	}
	
	
}

温馨提示:
controller/home: 前台插件文件夹,安装后,自动将文件移动到 app/home/plugins/ 下面
controller/admin: 后台插件文件夹,安装后,自动将文件移动到 app/admin/plugins/ 下面

  1. 修改 newtest/tpl/plugins-description.html :
<!doctype html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	{include="style"}
	<style>
	blockquote.layui-elem-quote {
		font-size: 14px;
	}
	</style>

</head>
<body >
 <div class="layui-rows" style="    margin: 10px;">
    <h1 style="text-align:center">新的测试插件</h1>
    <div class="layui-content" style="margin: 20px 40px;text-align: center;"><p style="font-size: 16px;">作者:留恋风2581047041@qq.com&nbsp;&nbsp;</span></p>
    </div>
    
	<div style=" margin: 25px; font-size: 20px;">
		<fieldset class="layui-elem-field" style="border: none;padding: 0;border-top: 1px solid #eee;">
		  <legend>插件功能</legend>
		  <div class="layui-field-box">
		    <blockquote class="layui-elem-quote">这里写插件的一些功能说明...
			</blockquote>
		  </div>
		</fieldset>

		
		
		
	</div>
	
	
  </div>
  </div>

  
    <script>
	layui.use('code', function(){ //加载code模块
		  layui.code({'encode':true}); //引用code方法
		});
    </script>
 
</body>
</html>
  1. 修改 newtest/tpl/plugins-body.html :
<div class="layui-tab" style="margin:15px;">
  <ul class="layui-tab-title">
    <li class="layui-this">插件设置</li>
  </ul>
  <div class="layui-tab-content">
    <div class="layui-tab-item layui-show">
  <div class="layui-rows" style="margin: 10px;">
    <form class="layui-form layui-form-pane" action="">
  <blockquote class="layui-elem-quote">这个文件是后台插件的配置
  </blockquote>
  <input name="id" value="{$plugins['id']}" placeholder="这个插件ID必须带上" type="hidden">
     <div class="layui-form-item layui-form-text">
    <label class="layui-form-label">插件的名称 </label>
    <div class="layui-input-block">
      <input type="text" name="title" value="{$configs['title']}" placeholder="如果写插件的名称"  class="layui-input" />
    </div>
    
    </div>
    
    <div class="layui-form-item layui-form-text">
        <label class="layui-form-label">插件的简介 </label>
        <div class="layui-input-block">
          <textarea name="description"  placeholder="请填写插件描述" class="layui-textarea">{$configs['description']}</textarea>
        </div>
      </div>
     

   <div class="layui-form-item">
        <label for="litpic" class="layui-form-label">
			<span class="x-red">*</span>图片
        </label>
        
		<div class="layui-input-inline">
			<input name="litpic" placeholder="上传图片" type="text" class="layui-input" id="litpic"  value="{$configs['litpic']}" />
		</div>
		<div class="layui-input-inline">
			<button class="layui-btn layui-btn-primary" id="litpic_upload" type="button" >选择图片</button>
		</div>
		<div class="layui-input-inline">
			<img id="litpic_img" class="img-responsive img-thumbnail" style="max-width: 200px;" src="{$configs['litpic']}" onerror="javascipt:this.src='{__Tpl_style__}/style/images/nopic.jpg'; this.title='图片未找到';this.onerror=''">
			<button type="button" onclick="deleteImage(this)" class="layui-btn layui-btn-sm layui-btn-radius layui-btn-danger " title="删除这张图片" >删除</button>
		</div>
    </div>
   
  <div class="layui-form-item">
    <div class="layui-input-block">
      <button class="layui-btn" lay-submit lay-filter="formDemo">立即提交</button>

    </div>
  </div>
</form>
</div>
    </div>
   

  </div>
</div>
    <script>
        $(function  () {
      
            layui.use(['laydate','form','layer','upload'], function(){
               $ = layui.jquery;
              var form = layui.form
              ,layer = layui.layer,laydate = layui.laydate;
              var upload = layui.upload;
              upload.render({
			    elem: '#litpic_upload',
                url: "{fun U('Common/uploads')}"
                ,data:{}
                ,done: function(res){ 
                 
					if(res.code==0){
						 $('#litpic_img').attr('src',res.url);
						 $('#litpic').val(res.url);
					}else{
						 layer.alert(res.error, {icon: 5});
					}
                 
                }
              });
              //监听提交
              form.on('submit(formDemo)', function(data){
           
                    $.post("{fun U('setconf')}",data.field,function(res){
                        //console.log(res);return false;
                       var res = JSON.parse(res);
                       if(res.code==1){
                        layer.msg(res.msg);
                       }else{
                        layer.msg(res.msg, {icon: 6,time: 2000},function(){
                        window.location.reload();
                        });
                                 
                        
                         
                       }
                    })
        
                return false;
              });
            });
        })

        
    </script>
  1. 至此,所有插件内容已经弄好,下面进行安装插件。

插件列表 - 本地 - 找到这个插件,点击 安装

  1. 设置插件信息

  2. 插件列表,开启 插件

  3. 在前台模板 index.html 里面写:

插件名称:{$newtest['title']}
插件描述:{$newtest['description']}
图片:<img src="{$newtest['litpic']}" />

本章节的插件制作已经讲完,虽然有些繁琐,但是多尝试几遍,基本上是复制代码进行修改,还是比较简单易学的。

Edit this page
Last Updated:
Contributors: RMC
Prev
控制器方法覆盖(简单)
Next
覆盖Common公共控制器