說到CMS,最需要有的東西就是權限控制,特別是一些復雜的場景,多用戶,多角色,多部門,子父級查看等等。最近在開發一個線下銷售的東東,這個系統分為管理員端,省代端,客戶端,門店端,銷售端, 部門端,部門老大下面分子部門等等,惡心的需求。我們這個項目使用yii框架開發,yii在php屆還是比較流行的,雖然說laravel現在橫行,但是一些部門一些團隊還是采用了yii框架,比如我們。
我是剛接觸yii這個框架,開始的時候對這種面向組件的框架甚是別扭。當時打算自己寫權限的,自己創建權限表,關聯表等,但是學習使用yii開發文檔後,發現有個權限控制RBAC,借助於yii-admin可以實現完美的權限,菜單的控制。這篇博客分兩部門,第一部分我會講述怎麼搭建權限管理包括:安裝yii-admin,創建權限表,使用權限控制菜單和訪問權限等基本的操作,這部分大致說一下,想要看更詳細的步驟可以參考這個比較詳細的講解:http://www.manks.top/tag/rbac.html,畢竟搭建和使用都不是難事,只要按照步驟來。第二部分我會講解我自己的理解,包括:菜單的優化,子頁面導航的選擇性高亮,分角色顯示菜單,權限檢測的改進等。
一、yii-admin的搭建相關
1、搭建yii-admin
首先你應該安裝一個yii礦建,因為yii-admin是基於yii框架的,沒有框架玩毛啊!你可以在github上直接下載源碼
yii2:https://github.com/yiisoft/yii2
yii2-admin:https://github.com/mdmsoft/yii2-admin
當然你可以使用composer來安裝,這樣最好不過,如果你安裝好了yii,你就可以切換到項目目錄下,直接執行下面的命令:
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' => 'mdm\admin\Module', 'layout' => '@app/views/layouts/main_nifty',//yii2-admin的導航菜單 ],
添加添加authManager配置項:
需要強調的是,yii中的authManager組件有PhpManager和DbManager兩種方式,這兩種方式是由區別的,PhpManager將權限關系保存在文件裡,DbManager方式,將權限關系保存在數據庫。我們采用保存在數據庫中的方式。
'authManager' => [ 'class' => 'yii\rbac\DbManager', // or use 'yii\rbac\DbManager' ],
添加as access:
'as access' => [ 'class' => 'mdm\admin\components\AccessControl', 'allowActions' => [ // add or remove allowed actions to this list // 'admin/*', //'*', 'site/*', 'api/*', ] ],
需要說的是未知不要放錯了,如下圖所示:
2、配置數據庫權限表
這一步不用自己去寫,命令行切換到yii2目錄,執行下面命令,創建rbac需要的表,但是數據庫需要自己創建名字是:yii2basic,如果要執行命令,就需要把你剛下配置好的配置文件在在console.php中也寫一份,如果執行不成功,可以吧生成數據表的腳本拿出來自己執行。
yii migrate --migrationPath=@yii/rbac/migrations yii migrate --migrationPath=@mdm/admin/migrations
如果執行成功會生成5張表,還需要一張user表,你可以自己添加
menu //菜單表
auth_rule //規則表
auth_item_child //角色對應的權限,parent角色,child權限名
auth_item //角色、權限表,type=1表示角色,type=2表示權限
auth_assignment //角色與用戶對應關系表
如果全部成功的話,再訪問index.php?r=admin 就可以了看到權限的控制可視化頁面,如果出錯,你認真查看錯誤原因,基本上都是配置不對。配置好的話,訪問其他頁面就沒有權限了,然後你可以修改as access中的allowActions,在開發api或者一些共用的模塊的時候很有用,因為這些頁面不需要進行權限的控制。
權限控制頁面如下圖:
3、進行菜單控制
要進行菜單控制,就需要用到剛才創建的那幾個表中的menu表,左側的導航按照我們的設計應該可以通過權限進行控制,寫死的導航不能達到目的,可擴展行還不強,所以菜單控制必須要支持。
需要注意的是,如果你的後台框架中用到了自己的layout,你需要自己去指定,我們這個項目就是,有我們自己的layout,上面再添加admin組件的時候已經添加了:
'layout' => '@app/views/layouts/main_nifty',
然後我們操作菜單列表。添加菜單項,然後在打開layout文件,其實獲取菜單的邏輯已經寫好了,在MenuHelper中,添加命名空間mdm\admin\components\MenuHelper; 然後注銷原來的導航,添加下面的代碼,基本上就可以實現權限-用戶-導航的控制了。
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): ?> <?php if (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="<?php if (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="<?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-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="<?php if (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'],策略如下:
這個策略使用角色緩存數據,就是使用每個角色的權限加上uid和環境配置MD5以後生成key,考慮到用戶比較多每個用戶都緩存的話開銷太大,並且用戶相同權限的的比較多,特殊權限的可以特殊對待,這樣省去了存儲好多重復的數據,環境配置是區分線上數據和測試數據,便於我們進行調試。
過期機制:更重要的是緩存的過期機制,緩存有了但是當菜單或者權限發生變化的時候就要更新緩存,這裡我們引入了版本的概念,能做到緩存變更的最小開銷。比如菜單變化,所有人導航都應該修改,這裡我們在redis中加入一個導航版本的變量,每次讀入緩存的時候都會先判斷這個版本與緩存中自己存儲版本是否一致,如果一致證明導航沒有變化,如果不一致認為菜單有修改,導航已過期,需要重新得到緩存,這樣相同的角色,只要有一個人更新了導航,其他人下次再進來的時候就會訪問到最新的導航(統一角色)。這個全局的redis變量會在導航變更和權限變更的時候自動加1,保證版本的變化,這樣如果有4類角色,幾萬人的用戶,實際的數據修改只發生的4次(實際會比這個多,比如同一個角色不同的權限,那麼他對應的redis key 就不一樣,它需要自己去取緩存)。具體的代碼實現如下:
$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便於時時更新redis function 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 \yii\db\Query())->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、導航的高亮,圖標,是否顯示
默認的導航高亮是按照模塊,控制器,方法來進行直接匹配的,這樣一來有一種需求無法滿足,比如:A控制器下得頁面下載B控制器下面高亮,這種事無法實現的,所以要修改他們高亮機制。我們沒有再采用他的高亮邏輯,而是自己實現了一個新的邏輯。我首先把要高亮的頁面url加入到菜單的data裡面,data是一個json數據,如下所示:
{"icon": "fa fa-home", "visible": true, "openurl":"/web/site/index/"}
這樣我們通過openurl就能知道哪個導航高亮,在頁面中直接判斷當前請求的url在不在這個openurl裡面就可以,但是這樣做有缺點,必須要有把高亮的頁面加入到要高亮的導航裡面,如果頁面太多這種方式不怎麼好,但是我沒有想到更好的方法去解決,如果哪位大神有好的方法可以在評論中寫出,非常感謝。
圖標和可見性的控制可以借助於MenuHelper中getAssignedMenu的回調方法實現,你可以在調用該方法的時候傳入回調方法,我直接寫的匿名方法,添加在了該方法裡面,如下所示:
$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、重寫權限檢測
剛才已經說了,yii-admin 的權限檢測執行太費時間,執行SQL太多,所以我打算重寫他的權限檢查的方法,通過讀源碼可以看到,他們檢查是通過user中的can方法調用的,然後通過mdm\admin\components\AccessControl中的beforeAction實現的,我們可以看一下:
/** * @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不是一般的多。
下面是我的重寫方法,一條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.parent LEFT JOIN xm_auth_item_child xc1 ON xc.child = xc1.parent WHERE 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執行太慢可以添加緩存,緩存過期的時間和菜單過期類似,當用戶的權限有變動的時候和菜單修改的時候跟新緩存。兩一種解決辦法是把這個方法協程單利,利用單利只是執行一次權限的查詢,檢查的階段可以單獨寫成方法提供。