安装了 KubeSphere 几个月了,但是总是知其然不知其所以然,想添点东西也不知从何着手,于是决定好好看一下他的源码,如果有对 console 二开感兴趣的小伙伴,可以看看呦~
开工之前建议先了解一下:
作为一个开源前端项目,代码量很大的情况下,除了借助官方文档了解代码结构,还可以从浏览界面入手先理顺一条完整的调用链,这样其他的照葫芦画瓢就会比较容易
举一个具体的简单例子,比如首页的蜘蛛图(集群资源使用情况)数据获取,根据一个具体的比较好理解 console 用到的组件、路由、请求封装。
我刚开始接触 console 的时候,最困惑的事情是三样:
所以我们从最基础的捞代码开始,打开浏览器,在选择目标附近的文本进行搜索。
结合浏览器检查元素,可知搜索的文本是一个 Panel。
所以确定是这个不是上面两个,最上面四个 js 是国际化。
进入文件,查看 render 里面,是哪个组件渲染了数据。
找到 getResourceOptions
函数。
getResourceOptions = () => {
const data = getLastMonitoringData(this.metrics)
return [
{
name: 'CPU',
unitType: 'cpu',
used: this.getValue(data[MetricTypes.cpu_usage]),
total: this.getValue(data[MetricTypes.cpu_total]),
},
{
name: 'Memory',
unitType: 'memory',
used: this.getValue(data[MetricTypes.memory_usage]),
total: this.getValue(data[MetricTypes.memory_total]),
},
{
name: 'Pod',
unitType: '',
used: this.getValue(data[MetricTypes.pod_count]),
total: this.getValue(data[MetricTypes.pod_capacity]),
},
{
name: 'Local Storage',
unitType: 'disk',
used: this.getValue(data[MetricTypes.disk_size_usage]),
total: this.getValue(data[MetricTypes.disk_size_capacity]),
},
]
}
可知 getResourceOptions
的数据来源于 getLastMonitoringData(this.metrics)
,这里解释一下为什么集群资源首页的数据会名字与 Monitoring 相关,实际上是因为这里用的就是监控的数据,互通的。
export const getLastMonitoringData = data => {
const result = {}
Object.entries(data).forEach(([key, value]) => {
const values = get(value, 'data.result[0].values', []) || []
const _value = isEmpty(values)
? get(value, 'data.result[0].value', []) || []
: last(values)
set(result, `[${key}].value`, _value)
})
return result
}
由此可知,getLastMonitoringData
只是做了一下数据转换处理,并不是真正的数据来源,那么数据来源就是被 getLastMonitoringData
处理的 this.metrics
,回到数据渲染文件。
monitorStore = new ClusterMonitorStore({ cluster: this.props.cluster })
componentDidMount() {
this.fetchData()
}
get metrics() {
return this.monitorStore.data
}
可知 this.metrics
来源于 ClusterMonitorStore
,此处也可以进行本地调试打印辅助确认。
依照路径找到 ClusterMonitorStore
类(react 项目,引用路径默认是 src,所以有很多 stores 也不要疑惑,指的就是 src 下的那个)。
import { action, observable } from 'mobx'
import { get } from 'lodash'
import { to } from 'utils'
import Base from './base'
export default class ClusterMonitoring extends Base {
@observable
statistics = {
data: {},
isLoading: false,
}
@observable
resourceMetrics = {
originData: {},
data: [],
isLoading: false,
}
@action
async fetchStatistics() {
this.statistics.isLoading = true
const params = {
type: 'statistics',
}
const result = await to(request.get(this.getApi(), params))
const data = this.getResult(result)
this.statistics = {
data,
isLoading: false,
}
return data
}
@action
async fetchApplicationResourceMetrics({
workspace,
namespace,
autoRefresh = false,
...filters
}) {
if (autoRefresh) {
filters.last = true
this.resourceMetrics.isRefreshing = true
} else {
this.resourceMetrics.isLoading = true
}
if (filters.cluster) {
this.cluster = filters.cluster
}
const params = this.getParams(filters)
// set correct path
const paramsReg = /^[a-zA-Z]+_/g
const metricType = get(filters.metrics, '[0]', '').replace(
paramsReg,
'cluster_'
)
let path = 'cluster'
if (workspace) {
path = `workspaces/${workspace}`
params.metrics_filter = `${metricType.replace(paramsReg, 'workspace_')}$`
}
if (namespace && namespace !== 'all') {
path = `namespaces/${namespace}`
params.metrics_filter = `${metricType.replace(paramsReg, 'namespace_')}$`
}
const result = await to(request.get(`${this.apiVersion}/${path}`, params))
let data = this.getResult(result)
if (autoRefresh) {
data = this.getRefreshResult(data, this.resourceMetrics.originData)
}
this.resourceMetrics = {
originData: data,
data: get(Object.values(data), '[0].data.result') || [],
isLoading: false,
isRefreshing: false,
}
return data
}
fetchClusterDevopsCount = async () => {
const result = await request.get(
'kapis/tenant.kubesphere.io/v1alpha2/devopscount/'
)
return get(result, 'count', 0)
}
}
有意思的事情来了,遍寻 ClusterMonitoring
类也找不到数据来源,经过一番调试,发现 ClusterMonitoring
只是一个桥,数据来源在继承的 Base 类。
@action
async fetchMetrics({
autoRefresh = false,
more = false,
fillZero = true,
...filters
}) {
if (autoRefresh) {
filters.last = true
this.isRefreshing = true
} else {
this.isLoading = true
}
if (filters.cluster) {
this.cluster = filters.cluster
}
const params = this.getParams(filters)
const api = this.getApi(filters)
const response = await to(request.get(api, params))
let result = this.getResult(response)
if (autoRefresh) {
result = this.getRefreshResult(result, this.data)
}
if (more) {
result = this.getMoreResult(result, this.data)
}
this.data = fillZero ? fillEmptyMetrics(params, result) : result
this.isLoading = false
this.isRefreshing = false
return result
}
打印 const response = await to(request.get(api, params))
中的 params 以及 api,对照浏览器的请求头及参数。
去掉编码后的字符以及分页限制等等暂时和主线不相关的,这是完整的数据化获取请求头。
拆解开:
以上,就是一个简短的数据获取,以此类推,其他的也只是调用链长一点,顺序大致如此,数据怎么来的一般就可以这么找,下面我们来看具体的组件化。
以上面的来讲:
对应界面:
然后我们来看 ResourceItem 的实现,对于 react 来说,函数、类、html 标签等等都是组件,都可以以 <xxx/>
的形式呈现,然后经 render 渲染到界面。
import React from 'react'
import { Text } from 'components/Base'
import { PieChart } from 'components/Charts'
import { getSuitableUnit, getValueByUnit } from 'utils/monitoring'
import styles from './image/index.scss'
export default function ResourceItem(props) {
const title = t(props.name)
const unit = getSuitableUnit(props.total, props.unitType) || unit
const used = getValueByUnit(props.used, unit)
const total = getValueByUnit(props.total, unit) || used
return (
<div className={styles.item}>
<div className={styles.pie}>
//这是左边那个小环形图
<PieChart
width={48}
height={48}
data={[
{
name: 'Used',
itemStyle: {
fill: '#329dce',
},
value: used,
},
{
name: 'Left',
itemStyle: {
fill: '#c7deef',
},
value: total - used,
},
]}
/>
</div>
//文本
<Text
title={`${Math.round((used * 100) / total)}%`}
description={title}
/>
<Text title={unit ? `${used} ${unit}` : used} description={t('Used')} />
<Text
title={unit ? `${total} ${unit}` : total}
description={t('Total')}
/>
</div>
)
}
<PieChart>
的实现:
import React from 'react'
import PropTypes from 'prop-types'
import { PieChart, Pie, Cell } from 'recharts'
export default class Chart extends React.Component {
static propTypes = {
width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
}
static defaultProps = {
width: 100,
height: 100,
dataKey: 'value',
}
render() {
const { width, height, data, dataKey } = this.props
return (
<PieChart width={width} height={height}>
<Pie
data={data}
dataKey={dataKey}
innerRadius="60%"
outerRadius="100%"
animationDuration={1000}
>
{data.map(entry => (
<Cell
key={`cell-${entry.name}`}
{...entry.itemStyle}
strokeWidth={0}
/>
))}
</Pie>
</PieChart>
)
}
}
根据调用处导入的 Text 可知,用的是最顶层的 base 下的 Text 组件,<Text>
的实现。
import React from 'react'
import { Icon } from '@kube-design/components'
import { isUndefined } from 'lodash'
import classNames from 'classnames'
import styles from './image/index.scss'
export default class Text extends React.PureComponent {
render() {
const {
icon,
title,
description,
className,
ellipsis,
extra,
onClick,
} = this.props
return (
<div
className={classNames(
styles.wrapper,
{ [styles.clickable]: !!onClick, [styles.ellipsis]: ellipsis },
className
)}
onClick={onClick}
>
{icon && <Icon className={styles.icon} name={icon} size={40} />}
<div className={styles.text}>
<div>{isUndefined(title) || title === '' ? '-' : title}</div>
<p>{description}</p>
</div>
{extra}
</div>
)
}
}
对于一些复杂的组件,可能里面还会嵌套其他组件,但是原理都是一样的,根据导入路径一级一级找就行了,如果我们对于 UI 有修改的需求,比如要另一个样子的 <Text>
,那么可以复写一个 <Text>
组件,引用新写的 <Text>
组件
就上面讲到的,数据真正来自于 metrics。
fetchData = () => {
this.monitorStore.fetchMetrics({
metrics: Object.values(MetricTypes),
last: true,
})
}
fetchMetrics
函数 cluster.js
同级的 base.js
,我们去详细看一下这个 base 文件。
@action
async fetchMetrics({
autoRefresh = false,
more = false,
fillZero = true,
...filters
}) {
if (autoRefresh) {
filters.last = true
this.isRefreshing = true
} else {
this.isLoading = true
}
if (filters.cluster) {
this.cluster = filters.cluster
}
//获取参数列表
const params = this.getParams(filters)
//获取请求头的API
const api = this.getApi(filters)
//发起请求获取response
const response = await to(request.get(api, params))
let result = this.getResult(response)
if (autoRefresh) {
result = this.getRefreshResult(result, this.data)
}
if (more) {
result = this.getMoreResult(result, this.data)
}
this.data = fillZero ? fillEmptyMetrics(params, result) : result
this.isLoading = false
this.isRefreshing = false
return result
}
我们在浏览器打印一下看看。
console 直接用的就是 nodejs 的 request.get
请求,同时支持 http 和 https,这个例子里面就是缺省回调的写法,完整的长这样:
request.get(url, (error, response, body) => {
//需要xxx回调执行的代码放这里
});
我们再看一眼请求头
kapis/monitoring.kubesphere.io/v1alpha3/cluster
其中monitoring.kubesphere.io
是代表这个请求组属于 monitoring ,所以如果这个请求出现了服务器错误,就应该去看监控的 pod 是不是出问题了。
对照官方的 API 文档可以看到还有很多这样的 apiGroup。
//集群相关的cluster.kubesphere.io
/kapis/cluster.kubesphere.io/v1alpha1/clusters/{cluster}/agent/deployment
//devops相关的devops.kubesphere.io
/kapis/devops.kubesphere.io/v1alpha2/crumbissuer
//资源相关的resources.kubesphere.io
/kapis/resources.kubesphere.io/v1alpha3/{resources}
普通用法一般就这样,咱们再看一个比较特别的。
import { withProjectList, ListPage } from 'components/HOCs/withList'
import Table from 'components/Tables/List'
import OpAppStore from 'stores/openpitrix/application'
@withProjectList({
store: new OpAppStore(),
module: 'applications',
name: 'Application',
})
export default class OPApps extends React.Component {
// 多余的代码省略……
getColumns = () => {
const { getSortOrder } = this.props
//getColumns 的数据来自于 record,表面上并没有任何地方传入了 record (OPApps已经是根组件没有调用他的组件了)
//record由浏览器打印可以看到是一条完整的table数据
return [
{
title: t('Name'),
dataIndex: 'name',
render: (name, record) => (
<Avatar
isApp
to={`${this.prefix}/${record.cluster_id}`}
avatar={get(record, 'app.icon')}
iconLetter={name}
iconSize={40}
title={name}
desc={record.description}
/>
),
},
{
title: t('Status'),
dataIndex: 'status',
isHideable: true,
width: '16%',
render: (status, record) => (
<Status
name={t(record.transition_status || status)}
type={record.transition_status || status}
/>
),
},
{
title: t('Application'),
dataIndex: 'app.name',
isHideable: true,
width: '16%',
render: (name, record) => (
<Link to={`/apps/${get(record, 'version.app_id')}`}>{name}</Link>
),
},
{
title: t('Version'),
dataIndex: 'version.name',
isHideable: true,
width: '16%',
},
{
title: t('Last Updated Time'),
dataIndex: 'status_time',
sorter: true,
sortOrder: getSortOrder('status_time'),
isHideable: true,
width: 180,
render: (time, record) =>
getLocalTime(record.update_time || record.status_time).format(
'YYYY-MM-DD HH:mm:ss'
),
},
]
}
render() {
const { bannerProps, tableProps, match } = this.props
// table的Columns数据来自于getColumns
return (
<ListPage {...this.props}>
<Banner {...bannerProps} match={match} type={this.type} />
<Table
//真正的数据传入是tableProps
{...tableProps}
{...this.getTableProps()}
itemActions={this.itemActions}
columns={this.getColumns()}
/>
</ListPage>
)
}
}
record 打印:
实际上这里的参数只是起到占位及展示的作用,叫什么都可以,即使改变名字也是获取到同样的数据,因为实际向 Table
传递参数的是 tableProps
,this.getColumns()
的结果也作为 columns
参数传递过去,为了便于理解拆解开的,可以人为拆解开为三个回合,实际上反映到执行过程中只是参数的变化过程。
第一回合(即 render 之前):参数是 tableProps
以及 this.getColumns()
,此时 this.getColumns()
中有效参数是 title
和 dataIndex
,此时 render
是一个正在准备的回调方法。
{
title: t('Name'),
dataIndex: 'name',
render: (name, record) => (
<Avatar
isApp
to={`${this.prefix}/${record.cluster_id}`}
avatar={get(record, 'app.icon')}
iconLetter={name}
iconSize={40}
title={name}
desc={record.description}
/>
),
},
第二回合(render):参数是 tableProps
以及 this.getColumns()
此时 this.getColumns()
中有效参数是 title
dataIndex
和render
,此时 render
是根据 dataIndex 识别出的要装载那些参数的已经完成回调的一个对象。
第三回合(render 结束):this.getColumns()
装载内部渲染完成,以 columns
形式作为 Table
的一个参数进行 Table
渲染(此时 render
已经是 dom
元素了)。
继续回归到我们这个简单的例子,路由我们也以此为例先讲普遍简单的 路由的找法不推荐由界面==>代码,因为有多级路由逆向并不方便,还是老老实实从项目根路由找起,一般 react 项目的根路由都在 src 下名字与 route 有关(这里仅仅指单页面应用,多页面应用路由后面讲)。
由此可以找到 /src/core/routes.js
。
import { lazy } from 'react'
//懒加载代码省略……
export default [
{
component: BaseLayout,
routes: [
{
path: '/clusters',
component: Clusters,
},
{
path: '/access',
component: AccessControl,
},
{
path: '/:workspace/clusters/:cluster/projects/:namespace',
component: Projects,
},
{
path: '/:workspace/clusters/:cluster/devops/:devops',
component: DevOps,
},
{
path: '/:workspace/federatedprojects/:namespace',
component: FederatedProjects,
},
{
path: '/workspaces/:workspace',
component: Workspaces,
},
{
path: '/apps',
component: AppStore,
},
{
path: '/apps-manage',
component: ManageApp,
},
{
path: '/settings',
component: Settings,
},
{
path: '*',
component: Console,
},
],
},
]
怎么确定是不是根路由?
然后根据浏览器的访问路径 /clusters/default/overview
可知,我们的这个例子是第一个 clusters
的路由,所以如果要添加一个与集群管理同级页面路由在那里添就不言而喻了吧。
然后直接去找 overview
文件夹,我们本例的代码也确实在 overview 下(这里就和上面看到的代码无缝对接了,经过一串组件的封装之后渲染到了界面)。
ps:这里解释一下 default
,这是我的集群的名字叫 default。kubesphere 3.0 是支持多集群的,一级路由是 clusters
要进入二级路由当然需要指定是那个集群,选择你要进入哪一个集群的概览。
组件封装调用链:
// 红框1
export default class Overview extends React.Component {
get cluster() {
return this.props.clusterStore
}
render() {
const { isReady } = this.cluster.detail
if (!isReady) {
return <Initializing store={this.cluster} />
}
//加载<Dashboard/>
return <Dashboard match={this.props.match} />
}
}
//黄框2
<div>
<NewClusterTitle
className="margin-b12"
cluster={detail}
size="large"
noStatus
/>
<Columns>
<Column>
{globals.app.isMultiCluster && (
<ClusterInfo cluster={detail} version={this.cluster.version} />
)}
<ServiceComponents cluster={match.params.cluster} />
//加载<ResourcesUsage/>
<ResourcesUsage cluster={match.params.cluster} />
{globals.app.isPlatformAdmin && (
<Tools cluster={match.params.cluster} />
)}
</Column>
<Column className="is-narrow is-4">
<KubernetesStatus cluster={match.params.cluster} />
<ClusterNodes cluster={match.params.cluster} />
</Column>
</Columns>
</div>
//篮筐3
<Panel title={t('Cluster Resources Usage')}>
<Loading spinning={this.monitorStore.isLoading}>
<div className={styles.wrapper}>
<div className={styles.chart}>
//加载资源使用情况,具体渲染在RadarChart组件中,上面讲过了
<RadarChart
cx={180}
cy={158}
width={360}
height={316}
data={radarOptions}
>
<PolarGrid gridType="circle" />
<PolarAngleAxis dataKey="name" />
<PolarRadiusAxis domain={[0, 100]} />
<Radar dataKey="usage" stroke="#345681" fill="#1c2d4267" />
</RadarChart>
</div>
<div className={styles.list}>
{options.map(option => (
<ResourceItem key={option.name} {...option} />
))}
</div>
</div>
</Loading>
</Panel>
最简单的路由就这样就完了,再说一个复杂一点的多页面应用的路由。
先解释一下为什么需要多页面应用,因为作为单页面应用所有的页面都受同一个跟路由管控,当我们需要不受跟路由管控的页面时,就需要多页面应用 比如登录,和帮助手册。
多页面应用是需要在 webpack 下配置的,通常在 webpack.config.js
中,但是一搜会找到很多 webpack.config.js
文件,分不出来是哪一个?没关系我们可以去抄登录,这是现成的多页面引用的例子。
于是我们找到登录的注册路由 server/routes.js
。
const Router = require('koa-router')
//多余代码省略……
const {
handleLogin,
handleLogout,
handleOAuthLogin,
} = require('./controllers/session')
const {
renderView,
renderLogin,
renderDocument,//渲染同级目录/controllers/view下的document
renderMarkdown,
renderCaptcha,
} = require('./controllers/view')
const parseBody = convert(
bodyParser({
formLimit: '200kb',
jsonLimit: '200kb',
bufferLimit: '4mb',
})
)
const router = new Router()
router
//多余代码省略……
// session
.post('/login', parseBody, handleLogin)
.post('/logout', handleLogout)
.get('/login', renderLogin)
.get('/oauth/redirect', handleOAuthLogin)
// markdown template
.get('/blank_md', renderMarkdown)
// 注册一个document
.get('/document', renderDocument)
// page entry
.all('*', renderView)
module.exports = router
document 是一个简单的静态页面,但他的路由级别与整个 console 内部是同级的互不干扰的,也就是说我们也可以基于它构建和 console 一样复杂的页面。