php composer.phar require mdmsoft/yii2-admin "~2.0"php composer.phar update然后配置中加入yii-admin的配置项,值的注意的是如果yii2-admin配置在common目录下是全局生效,那么你在执行命令控制台的时候就会报错,所以应将权限控制作用于web模块,我们这个项目没有使用高级模板,所以你可以直接把配置写在config下面的web.php中,配置如下:
"aliases" => ["@mdm/admin" => "@vendor/mdmsoft/yii2-admin",],在modules中添加admin组件:
"admin" => ["class" => "mdmadminModule","layout" => "@app/views/layouts/main_nifty",//yii2-admin的导航菜单],添加添加authManager配置项:
"authManager" => ["class" => "yii bacDbManager", // or use "yii bacDbManager"],添加as access:
"as access" => ["class" => "mdmadmincomponentsAccessControl","allowActions" => [// add or remove allowed actions to this list// "admin/*",//"*","site/*","api/*",]],需要说的是未知不要放错了,如下图所示:
2、配置数据库权限表
这一步不用自己去写,命令行切换到yii2目录,执行下面命令,创建rbac需要的表,但是数据库需要自己创建名字是:yii2basic,如果要执行命令,就需要把你刚下配置好的配置文件在在console.php中也写一份,如果执行不成功,可以吧生成数据表的脚本拿出来自己执行。
yii migrate --migrationPath=@yii/rbac/migrationsyii migrate --migrationPath=@mdm/admin/migrations如果执行成功会生成5张表,还需要一张user表,你可以自己添加
3、进行菜单控制
要进行菜单控制,就需要用到刚才创建的那几个表中的menu表,左侧的导航按照我们的设计应该可以通过权限进行控制,写死的导航不能达到目的,可扩展行还不强,所以菜单控制必须要支持。
需要注意的是,如果你的后台框架中用到了自己的layout,你需要自己去指定,我们这个项目就是,有我们自己的layout,上面再添加admin组件的时候已经添加了:
"layout" => "@app/views/layouts/main_nifty",然后我们操作菜单列表。添加菜单项,然后在打开layout文件,其实获取菜单的逻辑已经写好了,在MenuHelper中,添加命名空间mdmadmincomponentsMenuHelper; 然后注销原来的导航,添加下面的代码,基本上就可以实现权限-用户-导航的控制了。
echo Nav::widget(["encodeLabels" => false,"options" => ["class" => "sidebar-menu"],"items" => MenuHelper::getAssignedMenu(Yii::$app->user->id),]);好了说完了,最后看一下这个页面:
二、yii-admin优化和重写
在使用的过程中,yii-admin实现的导航权限控制远不能满足我们的需求,并且,这种组件试的开发,每个操作是完全独立的,比如,检查权限,取菜单,取用户信息,每个操作都需要执行SQL来进行下面是正常的检查权限和得到菜单的sql执行过程。其实这个过程是极其费时的,当用户量比较多,菜单比较大,权限表中的数据非常多的时候是不能这样干的,使用我们自己的sql检测工具可以看到,这个过程执行了20条之多的sql语句:
在图中可以看出,权限检查涉及了14次的sql查询,菜单涉及了5次sql查询,如此多的sql 执行一旦上线事没有什么并发可言的。yii-admin这个组件提供了方便的权限控制,菜单控制,但是性能上面我们不敢苟同。查看源码你就知道,这个组件在我看来是一个解耦比较高的组件,每个成分之间可以单独的使用,这就需要每个操作必须要有自己独立的数据库来源,说白了就需要每次都执行sql去取到想要的值,中间很少使用连表查询这样的sql,其实10条sql做的功能,在耦合上网情况下,一条sql就搞定了。
像我这种人是不能忍受这么多不相关的sql执行的,所以我就在根源上面修改了yii-admin的权限检查部分,修改的方法是我自己想的,不一定对,也不一定适合所有的场景,下面就写出来与大家分享。
1、菜单的优化
我们通过查看菜单的生成过程大致会执行了5条以上的sql,这个还算可以,我没有做sql上的优化,原因是我们的菜单是要对应不同的角色和子父级关系,在原来的基础上我添加了一个type来区分是那种角色能看到这种菜单,一级哪种角色对应某一个菜单显示的层级关系。这样管理员,省代用户,客户都会呈现不同的菜单。即使配置相同的权限,不同层级的用户也会看到不同的菜单。
我们的优化是缓存菜单的生成数据,我们这个菜单是定制的,没有采用一开始配置的Nav::widget来呈现,而是我们自己循环层级关系,这样虽然麻烦,但是能很好的提取菜单中我们需要的没一个逻辑,比如:面包屑的自动生成,就可以每次提取菜单的label,再比如子页面,不同控制器下得左导航的高亮,下面是代码,php和html混写了,以后会慢慢的提取。
<ul class="nav nav-list"><?php $idx = ;$request_url = "/" . $mod_id . "/" . $con_id . "/" . $act_id . "/";foreach ($menus_new["list"] as $label => $menu): ?><?phpif (empty($menu["label"]) && empty($menu["url"][])) {continue;}?><?php if(!isset($menu["items"])):?><li class="<?php if (isset($menu["openurl"]) && strstr($menu["openurl"], $request_url)) {echo "active";$breadcrumb[] = $menu["label"];}?>"><a href="<?php echo $menu["url"][] ?>"><i class="menu-icon fa fa-<?php echo $menu["icon"] ?>"></i><span class="menu-text"> <?php echo $menu["label"] ?> </span></a><b class="arrow"></b></li><?php else:?><li class="<?phpif (isset($menu["openurl"]) && strstr($menu["openurl"], $request_url)) {echo "open";$breadcrumb[] = $menu["label"];}?>"><a href="index.html"data-target="#multi-cols-<?php echo $idx ?>"class="dropdown-toggle"><i class="menu-icon fa fa-<?php echo $menu["icon"] ?>"></i><span class="menu-text"> <?php echo $menu["label"] ?> </span><b class="arrow fa fa-angle-down"></b></a><b class="arrow"></b><ul id="multi-cols-<?php echo $idx ?>" class="submenu"><?php foreach ($menu["items"] as $label => $menu): ?><?php if (empty($menu) || !is_array($menu)) { continue; }if(!isset($menu["items"])):?><li class="<?phpif (isset($menu["openurl"]) && strstr($menu["openurl"], $request_url)) {echo "active";$breadcrumb[] = $menu["label"];}?>"><a href="<?php echo $menu["url"][] ?>"><i class="menu-icon fa fa-caret-right"></i><?php echo $menu["label"] ?></a><b class="arrow"></b></li><?php else:?><li class="<?php if (isset($menu["openurl"]) && strstr($menu["openurl"], $request_url)) {echo "open";$breadcrumb[] = $menu["label"];}?>"><a href="#" class="dropdown-toggle"><i class="menu-icon fa fa-caret-right"></i><?php echo $menu["label"] ?><b class="arrow fa fa-angle-down"></b></a><b class="arrow"></b><ul class="submenu"><?php foreach ($menu["items"] as $label => $url): ?><?php if (empty($url) || !is_array($url)) { continue; } ?><li class="<?phpif (isset($url["openurl"]) && strstr($url["openurl"], $request_url)) {echo "active";$breadcrumb[] = $url["label"];}?>"><a href="<?php echo $url["url"][] ?>"><i class="menu-icon fa fa-caret-right"></i><?php echo $url["label"] ?></a><b class="arrow"></b></li><?php endforeach ?></ul> </li><?php endif?><?php endforeach ?></ul></li><?php endif?><?php $idx++; ?><?php endforeach ?></ul>这个导航是我自己改了好多版总结出适合我们自己的方案,其中breadcrumb是控制面包屑的显示,有时间我会抽离php。我介绍的是菜单优化,现在才完成了第一步,菜单的显示,说到优化我是采用缓存菜单数据的策略,就是缓存上面那个$menus_new["list"],策略如下:
$user_id = Yii::$app->user->id;$breadcrumb = [];$menus_new["list"] = MenuHelper::getAssignedMenu($user_id);$redis_key = MenuHelper::getMenuKeyByUserId($user_id);$redis_menu = Yii::$app->redis->get($redis_key);$redis_varsion = getVersion();if (!empty($redis_menu)) {$menus_new = json_decode($redis_menu, true);$old_version = isset($menus_new["version"]) ? $menus_new["version"] : "";//判断菜单的版本号,便于及时更新缓存if (!isset($menus_new["list"]) || empty($old_version) || intval($old_version) != $redis_varsion) {$menus_new = getMenu($user_id, $redis_varsion, $redis_key);$log = json_encode(["user_id" => $user_id,"varsion" => $redis_varsion,"redis_key" => $redis_key,"value" => $menus_new]);writeLog($log, "update_menu");}} else {$menus_new = getMenu($user_id, $redis_varsion, $redis_key);}function getMenu($user_id, $varsion, $redis_key){$menus_new["list"] = MenuHelper::getAssignedMenu($user_id);$menus_new["version"] = $varsion;Yii::$app->redis->set($redis_key, json_encode($menus_new));Yii::$app->redis->expire($redis_key, 300);return $menus_new;}//设置更新key便于时时更新redisfunction getVersion(){$version_key = Yii::$app->params["redis_key"]["menu_prefix"] . md5(Yii::$app->params["redis_key"]["menu_version"] . Yii::$app->db->dsn);$version_val = Yii::$app->redis->get($version_key);return empty($version_val) ? 1 : $version_val;}生成key和更新key的逻辑如下:/*** get menu one user by the id* @param $user_id* @return key string*/public static function getMenuKeyByUserId($user_id){if (empty($user_id)) {return false;}$list = (new yiidbQuery())->select("**")->from("**")->where(["user_id" => $user_id])->all();if (empty($list)) {return false;}$role_str = "";foreach ($list as $key => $value) {$role_str .= $value["item_name"];}$redis_key = Yii::$app->params["key"] . md5($role_str . Yii::$app->db->dsn);return $redis_key;}/*** 修改菜单更新状态,更新redis*/public static function UpdateMenuVersion(){$version_key = Yii::$app->params["key"] . md5(Yii::$app->params["key"] . Yii::$app->db->dsn);$version_val = Yii::$app->redis->get($version_key);if (empty($version_val)) {$version_val = "1";} else {$version_val++;}$log = json_encode(["user_id" => Yii::$app->user->id, "version_key" => $version_key, "version_val" => $version_val]);writeLog($log, "update_menu_version");Yii::$app->redis->set($version_key, $version_val);}2、导航的高亮,图标,是否显示
{"icon": "fa fa-home", "visible": true, "openurl":"/web/site/index/"}这样我们通过openurl就能知道哪个导航高亮,在页面中直接判断当前请求的url在不在这个openurl里面就可以,但是这样做有缺点,必须要有把高亮的页面加入到要高亮的导航里面,如果页面太多这种方式不怎么好,但是我没有想到更好的方法去解决,如果哪位大神有好的方法可以在评论中写出,非常感谢。
$user_type = Yii::$app->user->identity->type;$customer_id = Yii::$app->user->identity->customer_id;$callback_func = function($menu) use ($user_type, $customer_id) {$data = json_decode($menu["data"], true);$items = $menu["children"];$return = ["label" => $menu["name"],"url" => [$menu["route"]],];$return["visible"] = isset($data["visible"]) ? $data["visible"] : "";//菜单隐藏的逻辑if (empty($return["visible"])) {return false;}$return["icon"] = isset($data["icon"]) ? $data["icon"] : "";//控制菜单打开的逻辑$return["openurl"] = isset($data["openurl"]) ? $data["openurl"] : "";$items && $return["items"] = $items;return $return;};3、重写权限检测
/*** @inheritdoc*/public function beforeAction($action){$actionId = $action->getUniqueId();$user = $this->getUser();//预留系统检查权限的逻辑,一旦重写检查权限失败,调用系统检查权限的方法if ($user->can("/" . $actionId)) {return true;}$obj = $action->controller;do {if ($user->can("/" . ltrim($obj->getUniqueId() . "/*", "/"))) {return true;}$obj = $obj->module;} while ($obj !== null);$this->denyAccess($user);}因为全权限的检查包含了子父级检查,也就是说 /admin/menu/update的权限是对/admin/menu/* 和/admin/* 和 /*都可见的,所以我们会看到$user->can的调用会使用do -while来进行,这样就增加的检查的复杂度,执行的sql就会批量的增加,你想啊,没一个父级的检查都是一次全新的函数调用,所以最恶心的也莫过于此了,感兴趣的同学可以去看看他的这个过程,当你自己调用这个函数检测的时候就会发现,执行的sql不是一般的多。
/*** 权限判断方法 (先不要使用该方法,用的系统方法,效率极低,等有时间重写之后再用)* @param string/array $permission_name 权限值(URL 或者 权限名)/批量检测可以传入数组* @param int $user 用户id,不传值会取当前的登陆用户* @return boolen* @author zhaoyafei*/public static function permissionCheck($permission_name, $user = 0){//检查是否登陆过if (Yii::$app->user->isGuest) {Yii::$app->response->redirect("/site/login");}if (empty($permission_name)) {return false;}if (empty($user)) {$user = Yii::$app->user->id;}//管理员权限不能直接返回true,会存在管理员type = 1分到非管理员权限的人员(有坑)//匿名方法,处理管理员返回值的情况/*$setAdminSet = function($param) use ($permission_name) {$paramtmp = $permission_name;if (is_array($paramtmp)) {if (count($paramtmp) == 1) {return true;}$paramtmp = array_flip($paramtmp);foreach ($paramtmp as $key => &$value) {$value = true;}} else {$paramtmp = true;}return $paramtmp;};*///检查是否是管理员, 管理员都有权限/*if (empty($user)) {$user = Yii::$app->user->id;$user_type = Yii::$app->user->identity->type;if ($user_type == TYPE_ADMIN) {return $setAdminSet($permission_name);}} else {$user_sql = "SELECT type FROM xm_user WHERE id = :id";$user_info = Yii::$app->db->createCommand($user_sql)->bindValue(":id", $user)->queryOne();if (empty($user_info)) {return false;}if ($user_info["type"] == TYPE_ADMIN) {return $setAdminSet($permission_name);}}*///根据用户去取权限$permission_list = [];$sql = "SELECT xc.child, xc1.child as role_name FROM xm_auth_assignment xa INNER JOIN xm_auth_item_child xc ON xa.item_name = xc.parentLEFT JOIN xm_auth_item_child xc1 ON xc.child = xc1.parentWHERE xa.user_id = :user_id";$permission = Yii::$app->db->createCommand($sql)->bindValue(":user_id", $user)->queryAll();if (empty($permission)) {return false;}//组合权限列表foreach ($permission as $key => $value) {if (!empty($value["child"]) && !in_array($value["child"], $permission_list)) {$permission_list[] = $value["child"];}if (!empty($value["role_name"]) && !in_array($value["role_name"], $permission_list)) {$permission_list[] = $value["role_name"];}}//匿名方法,处理子url生成$getUrlList = function($url) {if (!strstr($url, "/")) {return [$url];}$url = "/" . trim($url, "/");$params = explode("/", $url);$param_arr = [];$param_str = [];if (!empty($params) && is_array($params)) {foreach ($params as $key => $value) {if (!empty($value)) {$param_arr[] = $value;}}}if (!empty($param_arr)) {$tmp_str = "";$param_str[] = $url;$count = count($param_arr);//生成子父级关系for ($i = $count -1; $i >= 0; $i--) {$tmp_str = "/" . $param_arr[$i] . $tmp_str;$chold_url = str_replace($tmp_str, "/*", $url);if (!in_array($chold_url, $param_str)) {$param_str[] = $chold_url;}}}return $param_str;};//拼接检查数据,兼容单传和传输组的情况$check_list = [];if (is_array($permission_name)) {foreach ($permission_name as $key => $value) {$check_list[$value] = $getUrlList($value);}} else {$check_list[$permission_name] = $getUrlList($permission_name);}if (empty($check_list)) {return false;}//批量检查是否有权限$ret = [];foreach ($check_list as $key => $value) {$ret[$key] = false;foreach ($value as $k => $v) {if (in_array($v, $permission_list)) {$ret[$key] = true;break;}}}//兼容一维数组if (count($ret) == 1) {$ret = array_values($ret);return $ret[0];}return $ret;}需要说明的是,注释掉的部分是管理员的权限检查,如果是管理员会自动返回所有的权限,但是这种不太好,因为实际情况中会分多种管理员,这样管理员不一定拥有所有的权限,如果这样不是超级管理员就不能使用,所以用的时候还是要慎重,最好统一使用权限检查。如果感觉那个SQL执行太慢可以添加缓存,缓存过期的时间和菜单过期类似,当用户的权限有变动的时候和菜单修改的时候跟新缓存。两一种解决办法是把这个方法协程单利,利用单利只是执行一次权限的查询,检查的阶段可以单独写成方法提供。