Kubevela VelaUX 的 Plugin 機(jī)制及實(shí)現(xiàn)原理
作者:李艷芳,中國(guó)移動(dòng)云能力中心軟件研發(fā)工程師,專(zhuān)注于云原生、微服務(wù)、算力網(wǎng)絡(luò)等
Kube Vela 是 一 個(gè)現(xiàn)代化應(yīng)用交付與管理平臺(tái)。 VelaUX 以KubeVela addon的形式存在為 KubeVela 提供了可 視化的 UI 控制臺(tái)操作能力,大大降低了KubeVela的使用門(mén)檻,使得用戶(hù)只需通過(guò)頁(yè)面上操作就可完成應(yīng)用交付與管理。 為了滿(mǎn)足用戶(hù)的各種不同需求,VelaUX同樣提供了一種擴(kuò)展機(jī)制,使得用戶(hù)可以定制化自己的UI控制臺(tái),就是Plugin機(jī)制。 本文介紹Plugin的開(kāi)發(fā)和實(shí)現(xiàn)原理。
一、什么是Plugin機(jī)制
簡(jiǎn)單來(lái)說(shuō),Plugin機(jī)制提供了一種框架用戶(hù)通過(guò)開(kāi)發(fā)自己的Plugin可以為VelaUX新增自定義頁(yè)面。如下圖,VelaUx本身是沒(méi)有節(jié)點(diǎn)管理這個(gè)頁(yè)面的,現(xiàn)在我們可以開(kāi)發(fā)一個(gè)Plugin,為VelaUX新增這樣一個(gè)頁(yè)面
二、怎么開(kāi)發(fā)一個(gè)Plugin
社區(qū)提供了Plugin的模版,我們可以從克隆一個(gè)Plugin模版開(kāi)始開(kāi)發(fā)。從地址 https://github.com/kubevela-contrib/velaux-plugin-template 克隆一個(gè)Plugin下來(lái),我們看到一個(gè)Plugin的目錄結(jié)構(gòu)如下:
src
asset
components
App
index.less
index.tsx
PluginConfing
index.ts
module.ts
plugin.json
package.json
其中:
- plugin.json是Plugin的元數(shù)據(jù)如:Plugin的名字、id、描述信息以及其他相關(guān)信息。
- module.ts中的內(nèi)容一般無(wú)需修改。開(kāi)發(fā)完成的Plugin作為js的一個(gè)模塊存在,通過(guò)模塊加載機(jī)制將Plugin頁(yè)面加載到VelaUX主控制臺(tái)中,module.ts就是該js模塊的入口,主要是定義了一個(gè)AppPagePlugin對(duì)象,VelaUX渲染Plugin的時(shí)候就是通過(guò)該對(duì)象獲取具體需要渲染頁(yè)面內(nèi)容。
- components文件夾下App目錄和PluginConfig目錄分別用來(lái)編寫(xiě)新擴(kuò)展的頁(yè)面和其配置頁(yè)面,跟開(kāi)發(fā)普通的頁(yè)面沒(méi)有什么區(qū)別。繼續(xù)看App/index.tsx文件可以看到定義了一個(gè)App組件,該組件就是要新擴(kuò)展的頁(yè)面組件。
在開(kāi)發(fā)頁(yè)面組件或頁(yè)面配置組件時(shí),如果需要調(diào)用Vela Apiserver本身的接口只需要通過(guò)getBackendSrv().get('/api/v1/clusters')方式調(diào)用即可:
import { getBackendSrv } from '@velaux/ui';
getBackendSrv().get('/api/v1/clusters').then(res=>{console.log(res)})
想使用VelaUX中已經(jīng)寫(xiě)好的React組件也是像如下直接引用即可:
import { Table, Form } from '@velaux/ui'
完成Plugin開(kāi)發(fā)和build后只需要在啟動(dòng)VelaUX的命令后通過(guò)--plugin-path參數(shù)制定插件等位置,新擴(kuò)展的頁(yè)面就顯示到VelaUX控制臺(tái)中了。
三、VelaUX Apiserver中本身提供的接口不夠用怎么辦
有時(shí)候我們會(huì)發(fā)現(xiàn)需要使用的接口VelaUX并沒(méi)有提供,比如實(shí)現(xiàn)一個(gè)對(duì)集群的監(jiān)控頁(yè)面需要調(diào)用K8S本身的API接口,VelaUX本身的API是沒(méi)有提供的,這時(shí)就需要借助VelaUX的Plugin機(jī)制。Plugin機(jī)制內(nèi)部通過(guò)反向代理可以將接口轉(zhuǎn)發(fā)至需要的K8S Apiserver或者自定義的服務(wù)上。
如果需要新的API支持,開(kāi)發(fā)Plugin的時(shí)候需要修改Plugin元數(shù)據(jù),即在plugin.json文件中添加"backend"、"backendType"字段。backend設(shè)置為true代表需要后端接口支持。backendType用來(lái)指定API的類(lèi)型,有兩個(gè)取值:"kube-api"和"kube-service",分別代表將請(qǐng)求轉(zhuǎn)發(fā)至K8S Apiserver上和自定義的服務(wù)上。接口調(diào)用時(shí)也需要在路徑前加上"/proxy/plugins/${pluginID}",如下所示:
getBackendSrv().get(`/proxy/plugins/${pluginID}/${realPath}`).then(res=>{console.log(res)})
通過(guò)查看VelaUX的啟動(dòng)過(guò)程可以發(fā)現(xiàn),如果請(qǐng)求接口的路徑前綴是"/proxy/plugins/",VelaUX為其進(jìn)行了特殊處理-通過(guò)proxyPluginBackend方法進(jìn)行處理
func (s *restServer) ServeHTTP(res http.ResponseWriter, req *http.Request) {
....
switch {
case strings.HasPrefix(req.URL.Path, "/proxy/plugins/"):
utils.NewFilterChain(s.proxyPluginBackend, api.AuthTokenCheck, api.AuthUserCheck(s.UserService)).ProcessFilter(req, res)
return
proxyPluginBackend方法通過(guò)調(diào)用router.GetPluginHandler注冊(cè)了Plugin的plugin.json中配置的路由規(guī)則,并交由pluginBackendProxyHandler處理
func (s *restServer) proxyPluginBackend(req *http.Request, res http.ResponseWriter) {
plugin, err := s.PluginService.GetPlugin(req.Context(), pluginID)
// Register the plugin route
router.GetPluginHandler(plugin, s.pluginBackendProxyHandler).ServeHTTP(res, req)
}
pluginBackendProxyHandler中新建了一個(gè)PluginProxy對(duì)象pro, 并由該代理對(duì)象處理請(qǐng)求
func (s *restServer) pluginBackendProxyHandler(w http.ResponseWriter, r *http.Request, p httprouter.Params, plugin *plugintypes.Plugin, route *plugintypes.Route) {
...
pro, err := proxy.NewBackendPluginProxy(plugin, s.KubeClient, s.KubeConfig)
...
r.URL.Path = strings.Replace(r.URL.Path, "/proxy/plugins/"+plugin.PluginID(), "", 1)
r = r.WithContext(context.WithValue(r.Context(), &proxy.RouteCtxKey, route))
pro.Handler(r, w)
}
至此能看到所有路徑以"/proxy/plugins/"開(kāi)頭的請(qǐng)求,VelaUX都為其新建了代理,通過(guò)代理轉(zhuǎn)發(fā)到相應(yīng)的RESTFull服務(wù)上。
從NewBackendPluginProxy方法中可以看到VelaUX根據(jù)plugin的BackendType字段創(chuàng)建對(duì)應(yīng)類(lèi)型的代理。Plugin機(jī)制目前實(shí)現(xiàn)了兩種類(lèi)型的代理:KubeAPI類(lèi)型和KubeService類(lèi)型。KubeAPI類(lèi)型的代理可以將請(qǐng)求轉(zhuǎn)發(fā)至K8S Apiserver上,KubeService類(lèi)型代理可以將請(qǐng)求轉(zhuǎn)發(fā)至自定義服務(wù)上。
func NewBackendPluginProxy(plugin *types.Plugin, kubeClient client.Client, kubeConfig *rest.Config) (BackendProxy, error) {
p, ok := proxyCache[plugin]
switch plugin.BackendType {
case types.KubeAPI:
p, err = NewKubeAPIProxy(kubeConfig, plugin)
if err != nil {
return nil, err
}
case types.KubeService:
p = NewKubeServiceProxy(kubeClient, plugin)
default:
return nil, ErrAvailablePlugin
}
proxyCache[plugin] = p
return p, nil
}
繼續(xù)查看KubeServiceProxy的Handler方法發(fā)現(xiàn),VelaUX通過(guò)kubeClient去集群上查找指定NameSpace下端口為指定端口的Service服務(wù)。該Service服務(wù)的服務(wù)地址http://ClusterIP:Port就是請(qǐng)求將要被轉(zhuǎn)發(fā)到的目的地址,保存在變量k.availableEndpoint中。
var service corev1.Service
namespace := k.plugin.BackendService.Namespace
name := k.plugin.BackendService.Name
if namespace == "" {
namespace = kubevelatypes.DefaultKubeVelaNS
}
err := k.kubeClient.Get(req.Context(), apitypes.NamespacedName{Namespace: namespace, Name: name}, &service); err != nil {
matchPort := service.Spec.Ports[0].Port
if k.plugin.BackendService.Port != 0 {
havePort := false
for _, port := range service.Spec.Ports {
if k.plugin.BackendService.Port == port.Port {
havePort = true
matchPort = k.plugin.BackendService.Port
break
}
}
}
availableEndpoint, err := url.Parse(fmt.Sprintf("http://%s:%d", service.Spec.ClusterIP, matchPort))
if err != nil {
bcode.ReturnHTTPError(req, res, bcode.ErrNotFound)
}
k.availableEndpoint = availableEndpoint
接下來(lái)就是以k.availableEndpoint為目標(biāo)地址新建一個(gè)反向代理,這樣該P(yáng)lugin相應(yīng)的接口就都轉(zhuǎn)發(fā)到了所指定的NameSpace下的端口為指定端口的Service上。
director := func(req *http.Request) {
var base = *k.availableEndpoint
base.Path = req.URL.Path
req.URL = &base
if route != nil {
// Setting the custom proxy headers
for _, h := range route.ProxyHeaders {
req.Header.Set(h.Name, h.Value)
}
}
// Setting the authentication
if types.Basic == k.plugin.AuthType && k.plugin.AuthSecret != nil {
if err := k.setBasicAuth(req); err != nil {
klog.Errorf("can't set the basic auth, err:%s", err.Error())
return
}
}
for k, v := range req.URL.Query() {
for _, v1 := range v {
base.Query().Add(k, v1)
}
}
}
rp := &httputil.ReverseProxy{Director: director, ErrorLog: log.Default()}
rp.ServeHTTP(res, req)
}
KubeAPIProxy實(shí)現(xiàn)類(lèi)似,這里不再贅述。
四、Plugin的加載過(guò)程
總的來(lái)說(shuō)Plugin的加載過(guò)程就是:
1、就是從先從指定目錄下遍歷查找并讀取plugin.json文件內(nèi)容,并創(chuàng)建對(duì)應(yīng)的plugin對(duì)象
2、判斷是否是需要KubeAPI類(lèi)型的后端支持,如果是就為其創(chuàng)建對(duì)應(yīng)的ClusterRole/ClusterRoleBinding資源
下面就是加載plugin的代碼,p.loader.Load一行完成了plugin的加載和plugin對(duì)象的創(chuàng)建,range循環(huán)部分通過(guò)判斷plugin類(lèi)型,按需初始化plugin角色,其實(shí)就是創(chuàng)建對(duì)應(yīng)的ClusterRole/ClusterRoleBinding資源對(duì)象。
func (p *pluginImpl) LoadNewPlugin(ctx context.Context, s types.PluginSource) error {
plugins, err := p.loader.Load(s.Class, s.Paths, nil)
for _, plugin := range plugins {
if plugin.BackendType == types.KubeAPI && len(plugin.KubePermissions) > 0 {
if err := p.InitPluginRole(ctx, plugin); err != nil {
.....
}
}
err := p.registry.Add(ctx, plugin);
}
return nil
}
從下面代碼中可以看到,加載plugin的過(guò)程就是:先從指定目錄下遍歷查找dist目錄下的plugin.json文件并讀取plugin.json中的內(nèi)容,保存在foundPlugins變量中, 然后為找到的所有plugin創(chuàng)建的對(duì)應(yīng)的plugin對(duì)象
pluginJSONPaths, err := l.pluginFinder.Find(paths)
for _, pluginJSONPath := range pluginJSONPaths {
plugin, err := l.readPluginJSON(pluginJSONPath)
pluginJSONAbsPath, err := filepath.Abs(pluginJSONPath)
foundPlugins[filepath.Dir(pluginJSONAbsPath)] = plugin
}
loadedPlugins := make(map[string]*types.Plugin)
for pluginDir, pluginJSON := range foundPlugins {
plugin := createPluginBase(pluginJSON, class, pluginDir)
loadedPlugins[plugin.PluginDir] = plugin
}
五、Plugin的渲染過(guò)程
VelaUX中Plugin機(jī)制定義了路由規(guī)則,所有的Plugin頁(yè)面的路由地址都是"/plugins/:pluginId",pluginId是Plugin的id,而且都通過(guò)AppRootPage這個(gè)組件來(lái)渲染,如下代碼:
<Route
path="/plugins/:pluginId"
render={(props: any) => {
return <AppRootPage pluginId={props.match.params.pluginId}></AppRootPage>;
}}
/>
AppRootPage組件中會(huì)去加載相應(yīng)的plugin并賦值給常量app, 而app.root就是Plugin中用戶(hù)開(kāi)發(fā)的需要在頁(yè)面上渲染的內(nèi)容,也就是我們?cè)陂_(kāi)發(fā)plugin時(shí)定在components目錄下定義的頁(yè)面App組件。
function RootPage({ pluginId }: Props) {
const [app, setApp] = React.useState<AppPagePlugin>();
React.useEffect(() => {
loadAppPlugin(pluginId, setApp);
}, [pluginId]);
const AppRootPage = app.root
return (<AppRootPage meta={app.meta} />);
}
怎么可以確認(rèn)app.root真的是我們新定義的App組件呢?我們可以查看我們定義插件時(shí)的mudule.ts文件,其中new了一個(gè)AppPagePlugin對(duì)象,并調(diào)用了setRootPage方法并將App作為參數(shù),而此App就是我們?cè)赾omponents中定義的App組件
import { App } from './components/App';
export const plugin = new AppPagePlugin<{}>().setRootPage(App).addConfigPage({
...
});
查看AppPagePlugin類(lèi)型的定可以看到其方法setRootPage就是將接收到到參數(shù)賦值給root屬性。
export class AppPagePlugin {
setRootPage(root) {
this.root = root;
return this;
}
}
至此我們的Plugin頁(yè)面已經(jīng)渲染出來(lái)的。這里還有一個(gè)疑問(wèn)就是RootPage是如何將Plugin資源加載進(jìn)來(lái)的?這里是使用了SystemJS模塊加載器,通過(guò)SystemJS.import(path)加載進(jìn)來(lái)的模塊內(nèi)容,就是Plugin定義中的module.ts中導(dǎo)出的內(nèi)容,即:AppPagePlugin類(lèi)型的對(duì)象。
async function importPluginModule(path: string, version?: string): Promise<any> {
return SystemJS.import(path);
}
function importAppPagePlugin(meta: PluginMeta): Promise<AppPagePlugin> {
return importPluginModule(meta.module, meta.info?.version).then((pluginExports) => {
const plugin = pluginExports.plugin as AppPagePlugin;
plugin.init(meta);
plugin.meta = meta;
return plugin;
});
}
至于Plugin中用到的其他依賴(lài),則是通過(guò)SystemJS.registerDynamic提前將這些依賴(lài)注冊(cè)進(jìn)來(lái)的。
export function exposeToPlugin(name: string, component: any) {
SystemJS.registerDynamic(name, [], true, (require: any, exports: any, module: { exports: any }) => {
module.exports = component;
});
}
exposeToPlugin('lodash', _);
exposeToPlugin('moment', moment);
exposeToPlugin('@velaux/data', velauxData);
exposeToPlugin('@velaux/ui', velauxUI);
exposeToPlugin('react', react);
exposeToPlugin('react-dom', ReactDom);
exposeToPlugin('redux', Redux);
exposeToPlugin('dva/router', DvaRouter);
至此Plugin頁(yè)面的渲染已經(jīng)全部完成,至于菜單的渲染就容易了,只需要在渲染菜單之前獲取一下Plugin列表,并根據(jù)相關(guān)菜單配置生成菜單項(xiàng)并渲染即可
loadPluginMenus = () => {
if (this.pluginLoaded) {
return Promise.resolve(this.menus);
}
return getPluginSrv()
.listAppPagePlugins()
.then((plugins) => {
plugins.map((plugin) => {
plugin.includes?.map((include) => {
if (!this.menus.find((m) => m.name == include.name)) {
const pluginMenu: Menu = {
workspace: include.workspace.name,
type: include.type,
name: include.name,
label: include.label,
to: include.to,
relatedRoute: include.relatedRoute,
permission: include.permission,
catalog: include.catalog,
};
this.menus.push(pluginMenu);
}
});
});
this.pluginLoaded = true;
return Promise.resolve(this.menus);
});
};
小結(jié)
Plugin機(jī)制是VelaUX提供的一種擴(kuò)展機(jī)制,本文介紹了如何開(kāi)發(fā)一個(gè)Plugin,并通過(guò)對(duì)接口代理轉(zhuǎn)發(fā)、Plugin的加載、渲染等過(guò)程的代碼分析介紹了Plugin的核心實(shí)現(xiàn)原理。
