源码解析 - 主页入口

优质
小牛编辑
127浏览
2023-12-01

我们先从启动 Kibana 的命令行程序入手,可以看到这是一个 shell 脚本。最终执行的是 node src/cli serve 命令。然后跟着就可以找到 src/cli/serve 程序,其中最重要的是加载了 src/server/kbn_server.js。继续打开,可以看到它先后加载了 config, http, logging, plugin 和 uiExports。毫无疑问,其中重点是 http 和 uiExports 部分。

http/index.js 中,初始化了 Hapi.Server 对象,加载 hapi plugin,并声明了主要的 route。包括静态文件、模板文件、短地址跳转和主页默认跳转到 /app/kibana。目前来说 Kibana 在服务器端主动做的事情还比较少。在我们不基于 Hapi 框架做二次开发的情况下,不用过于关注这期间 Kibana 做了什么。

下面进入 src/ui/ 目录继续。

src/ui/index.js 中完成了更细节的各类 app 的加载和路由分配:

  1. const uiExports = kbnServer.uiExports = new UiExports({
  2. urlBasePath: config.get('server.basePath')
  3. });
  4. for (let plugin of kbnServer.plugins) {
  5. uiExports.consumePlugin(plugin);
  6. }
  7. const bundles = kbnServer.bundles = new UiBundleCollection(bundlerEnv, config.get('optimize.bundleFilter'));
  8. for (let app of uiExports.getAllApps()) {
  9. bundles.addApp(app);
  10. }
  11. server.route({
  12. path: '/app/{id}',
  13. method: 'GET',
  14. handler: function (req, reply) {
  15. const id = req.params.id;
  16. const app = uiExports.apps.byId[id];
  17. if (!app) return reply(Boom.notFound('Unknown app ' + id));
  18. if (kbnServer.status.isGreen()) {
  19. return reply.renderApp(app);
  20. } else {
  21. return reply.renderStatusPage();
  22. }
  23. }
  24. });

可以看到这里把所有的 app 都打包进了 bundle。这也是很多初次接触 Kibana 二次开发的新手很容易被绊倒的一点——改了一行代码怎么没生效?因为服务是优先使用 bundle 内容的,而不会每次都进到各源码目录执行。

如果确实在频繁修改代码的阶段,每次都等 bundle 确实太累了,可以看到上面代码段里有一个 config.get('optimize.bundleFilter')。是的,其实 Kibana 支持在 config 中设定具体的 optimize 行为,但是官方文档上并没有介绍。最完整的配置项,见 src/server/config/schema.js。前文说过,这是在启动 kbn_server 的时候最先加载的。

在 schema 中可以看到一个很可爱的配置:

  1. optimize: _joi2['default'].object({
  2. enabled: _joi2['default'].boolean()['default'](true),
  3. })

所以你只要在 config/kibana.yml 中加上这么一行配置就好了:optimize.enabled: false

kibana app

从 Kibana 4.5 版开始,Kibana 框架和 Kibana App 做了一个剥离。现在,我们进到 Kibana App 里看看。路径在 src/core_plugins/kibana

我们可以看到路径中有如下文件:

  • common/
  • index.js
  • package.json
  • public/
  • server/

这是一个很显然的普通 nodejs 模块的结构。我们可以看看作为模块描述的 package.json 里写了啥:

  1. {
  2. "name": "kibana",
  3. "version": "kibana"
  4. }

非常有趣的 version。事实上这个写法的意思是本插件的版本号和 Kibana 框架的版本号保持一致。事实上所有 core_plugins 的版本号都写的是 kibana。

然后 index.js 中,调用 uiExports 完成了 app 注册。也这是之后我们自己开发新的 Kibana 应用时必须做的。我们下面摘主要段落分别看一下:

  1. module.exports = function (kibana) {
  2. var kbnBaseUrl = '/app/kibana';
  3. return new kibana.Plugin({
  4. id: 'kibana',
  5. config: function config(Joi) {
  6. return Joi.object({
  7. enabled: Joi.boolean()['default'](true),
  8. defaultAppId: Joi.string()['default']('discover'),
  9. index: Joi.string()['default']('.kibana')
  10. })['default']();
  11. },
  12. uiExports: {
  13. app: {
  14. id: 'kibana',
  15. title: 'Kibana',
  16. listed: false,
  17. description: 'the kibana you know and love',
  18. main: 'plugins/kibana/kibana',

这是最基础的部分,注册成为一个 kibana.Plugin,id 叫什么,config 配置有什么,标题叫什么,入口文件是哪个,具体是什么类型的 uiExports,一般常见的选择有:app、visType。这两者也是做 Kibana 二次开发最容易入手的地方。

  1. uses: ['visTypes', 'spyModes', 'fieldFormats', 'navbarExtensions', 'managementSections', 'devTools', 'docViews'],
  2. injectVars: function injectVars(server, options) {...}
  3. },

uses 和 injectVars 是可选的方式,可以在 src/ui/ui_app.js 中看到起作用。分别是指明下列模块已经加载过,以后就不用再加载了;以及声明需要注入浏览器的 JSON 变量。

  1. links: [{
  2. id: 'kibana:discover',
  3. title: 'Discover',
  4. order: -1003,
  5. url: kbnBaseUrl + '#/discover',
  6. description: 'interactively explore your data',
  7. icon: 'plugins/kibana/assets/discover.svg'
  8. }, {
  9. ...
  10. }],
  11. },

这里是一个特殊的地方,一般来说其他应用不会用到 links 类型的 uiExports。因为 Kibana 应用本身不用单一的左侧边栏切换,而是需要把自己内部的 Discover、Visualize、Dashboard、Management 功能放上去。所以定义里,把自己的 listed 给 false 了,而把这具体的四项通过 links 的方式,添加到侧边栏上。links 具体可配置的属性,见 src/ui/ui_nav_link.js。这里就不细讲了。

  1. preInit: _asyncToGenerator(function* (server) {
  2. yield mkdirp(server.config().get('path.data'));
  3. }),

preInit 也是一个可选属性,如果有需要创建目录之类的要预先准备的操作,可以在这步完成。

  1. init: function init(server, options) {
  2. // uuid
  3. (0, _serverLibManage_uuid2['default'])(server);
  4. // routes
  5. (0, _serverRoutesApiIngest2['default'])(server);
  6. (0, _serverRoutesApiSearch2['default'])(server);
  7. (0, _serverRoutesApiSettings2['default'])(server);
  8. (0, _serverRoutesApiScripts2['default'])(server);
  9. server.expose('systemApi', systemApi);
  10. }
  11. });
  12. }

init 是最后一步。我们看到 Kibana 应用的最后一步是继续加载了一些服务器端的 route 设置。比如这个 _serverRoutesApiScripts2,具体代码是在 src/core_plugins/kibana/server/routes/api/scripts/register_languages.js 里:

  1. server.route({
  2. path: '/api/kibana/scripts/languages',
  3. method: 'GET',
  4. handler: function handler(request, reply) {
  5. var callWithRequest = server.plugins.elasticsearch.callWithRequest;
  6. return callWithRequest(request, 'cluster.getSettings', {
  7. include_defaults: true,
  8. filter_path: '**.script.engine.*.inline'
  9. }).then(function (esResponse) {
  10. var langs = _lodash2['default'].get(esResponse, 'defaults.script.engine', {});
  11. var inlineLangs = _lodash2['default'].pick(langs, function (lang) {
  12. return lang.inline === 'true';
  13. });
  14. var supportedLangs = _lodash2['default'].omit(inlineLangs, 'mustache');
  15. return _lodash2['default'].keys(supportedLangs);
  16. }).then(reply)['catch'](function (error) {
  17. reply((0, _libHandle_es_error2['default'])(error));
  18. });
  19. }
  20. });

我在之前 K4 源码解析中曾经讲过的一个二次开发场景 —— 切换脚本引擎支持。在 Elasticsearch 5.0 中,在 /_cluster/settings 接口里提供了具体的可用引擎细节,诸如一个个 script.engine.painless.inline 的列表。这样,就不必像之前那样明确知道自己可以用什么,然后硬改代码来支持了;而是可以通过这个接口数据,拿到集群实际支持什么引擎,当前默认是什么引擎等设置,直接在 Kibana 中使用。上面这段代码,就是提供了这个数据。

注意其中排除了 mustache,因为它只能做模板渲染,没法做字段值计算。

好了。应用注册完成,我们看到了 main 入口,那么去看看 main 入口的内容吧。打开 src/core_plugins/kibana/public/kibana.js。主要如下:

  1. import kibanaLogoUrl from 'ui/images/kibana.svg';
  2. import 'ui/autoload/all';
  3. import 'plugins/kibana/discover/index';
  4. import 'plugins/kibana/visualize/index';
  5. import 'plugins/kibana/dashboard/index';
  6. import 'plugins/kibana/management/index';
  7. import 'plugins/kibana/doc';
  8. import 'plugins/kibana/dev_tools';
  9. import 'ui/vislib';
  10. import 'ui/agg_response';
  11. import 'ui/agg_types';
  12. import 'ui/timepicker';
  13. import Notifier from 'ui/notify/notifier';
  14. import 'leaflet';
  15. routes.enable();
  16. routes
  17. .otherwise({
  18. redirectTo: `/${chrome.getInjected('kbnDefaultAppId', 'discover')}`
  19. });
  20. chrome
  21. .setRootController('kibana', function ($scope, courier, config) {
  22. $scope.$on('application.load', function () {
  23. courier.start();
  24. });
  25. ...
  26. });

基本上通过这一串 import 就可以看到 Kibana 中最主要的各项功能了。

而对内部比较重要的则是这个 ui/autoload/all。这里面其实是加载了 kibana 自定义的各种 angular module、directive 和 filter。像我们熟悉的 markdown、moment、auto_select、json_input、paginate、file_upload 等都在这里面加载。这些都是网页开发的通用工具,这里就不再介绍细节了,有兴趣的读者可以在 src/ui/public/ 下找到对应文件。

设置 routes 的具体操作在加载的 src/ui/public/routes/route_manager.js 文件里,其中会调用 sr/ui/public/index_patterns/route_setup/load_default.js 中提供的 addSetupWork 方法,在未设置 default index pattern 的时候跳转 URL 到 whenMissingRedirectTo 页面。

  1. uiRoutes
  2. .addSetupWork(...)
  3. .afterWork(
  4. // success
  5. null,
  6. // failure
  7. function (err, kbnUrl) {
  8. let hasDefault = !(err instanceof NoDefaultIndexPattern);
  9. if (hasDefault || !whenMissingRedirectTo) throw err; // rethrow
  10. kbnUrl.change(whenMissingRedirectTo);
  11. if (!defaultRequiredToasts) defaultRequiredToasts = [];
  12. else defaultRequiredToasts.push(notify.error(err));
  13. }
  14. )

而这个 whenMissingRedirectTo 页面是在 kibana 应用的源码里写死的,见 src/core_plugins/kibana/public/management/index.js

  1. uiRoutes
  2. .when('/management', {
  3. template: landingTemplate
  4. });
  5. require('ui/index_patterns/route_setup/load_default')({
  6. whenMissingRedirectTo: '/management/kibana/index'
  7. });

在原先的版本中,routes 里面还会检查 Elasticsearch 的版本号,在 5.0 版里,这件事情从 kibana plugin 改到 elasticsearch plugin 里完成了。

courier 概述

kibana.js 的最后,控制器则会监听 application.load 事件,在页面加载完成的时候触发 courier.start() 函数。

src/ui/public/courier/courier.js 中定义了 Courier 类。Courier 是一个非常重要的东西,可以简单理解为 kibana 跟 ES 之间的一个 object mapper。简要的说,包括一下功能:

  1. import DocSourceProvider from './data_source/doc_source';
  2. ...
  3. function Courier() {
  4. var self = this;
  5. var DocSource = Private(DocSourceProvider);
  6. self.DocSource = DocSource;
  7. ...
  8. self.start = function () {
  9. searchLooper.start();
  10. docLooper.start();
  11. return this;
  12. };
  13. self.fetch = function () {
  14. fetch.fetchQueued(searchStrategy).then(function () {
  15. searchLooper.restart();
  16. });
  17. };
  18. self.started = function () {
  19. return searchLooper.started();
  20. };
  21. self.stop = function () {
  22. searchLooper.stop();
  23. return this;
  24. };
  25. self.createSource = function (type) {
  26. switch (type) {
  27. case 'doc':
  28. return new DocSource();
  29. case 'search':
  30. return new SearchSource();
  31. }
  32. };
  33. self.close = function () {
  34. searchLooper.stop();
  35. docLooper.stop();
  36. _.invoke(requestQueue, 'abort');
  37. if (requestQueue.length) {
  38. throw new Error('Aborting all pending requests failed.');
  39. }
  40. };

从类的方法中可以看出,其实主要就是五个属性的控制:

  • DocSource 和 SearchSource:继承自 src/ui/public/courier/data_source/_abstract.js,调用 src/ui/public/courier/data_source/data_source/_doc_send_to_es.js 完成跟 ES 数据的交互,用来做 savedObject 和 index_pattern 的读写:
  1. es[method](params)
  2. .then(function (resp) {
  3. if (resp.status === 409) throw new errors.VersionConflict(resp);
  4. doc._storeVersion(resp._version);
  5. doc.id(resp._id);
  6. var docFetchProm;
  7. if (method !== 'index') {
  8. docFetchProm = doc.fetch();
  9. } else {
  10. // we already know what the response will be
  11. docFetchProm = Promise.resolve({
  12. _id: resp._id,
  13. _index: params.index,
  14. _source: body,
  15. _type: params.type,
  16. _version: doc._getVersion(),
  17. found: true
  18. });
  19. }

这个 es 在是调用了 src/ui/public/es.js 里定义的 service,里面内容超级简单,就是加载官方的 elasticsearch.js 库,然后初始化一个最简的 esFactory 客户端,包括超时都设成了 0,把这个控制交给 server 端。

  1. import 'elasticsearch-browser';
  2. import _ from 'lodash';
  3. import uiModules from 'ui/modules';
  4. let es; // share the client amongst all apps
  5. uiModules
  6. .get('kibana', ['elasticsearch', 'kibana/config'])
  7. .service('es', function (esFactory, esUrl, $q, esApiVersion, esRequestTimeout) {
  8. if (es) return es;
  9. es = esFactory({
  10. host: esUrl,
  11. log: 'info',
  12. requestTimeout: esRequestTimeout,
  13. apiVersion: esApiVersion,
  14. plugins: [function (Client, config) {
  15. // esFactory automatically injects the AngularConnector to the config
  16. // https://github.com/elastic/elasticsearch-js/blob/master/src/lib/connectors/angular.js
  17. _.class(CustomAngularConnector).inherits(config.connectionClass);
  18. function CustomAngularConnector(host, config) {
  19. CustomAngularConnector.Super.call(this, host, config);
  20. this.request = _.wrap(this.request, function (request, params, cb) {
  21. if (String(params.method).toUpperCase() === 'GET') {
  22. params.query = _.defaults({ _: Date.now() }, params.query);
  23. }
  24. return request.call(this, params, cb);
  25. });
  26. }
  27. config.connectionClass = CustomAngularConnector;
  28. }]
  29. });
  30. return es;
  31. });
  • searchLooper 和 docLooper:分别给 Looper.start 方法传递 searchStrategy 和 docStrategy,对应 ES 的 /_msearch/_mget 请求。searchLooper 的实现如下:
  1. import FetchProvider from '../fetch';
  2. import SearchStrategyProvider from '../fetch/strategy/search';
  3. import RequestQueueProvider from '../_request_queue';
  4. import LooperProvider from './_looper';
  5. export default function SearchLooperService(Private, Promise, Notifier, $rootScope) {
  6. let fetch = Private(FetchProvider);
  7. let searchStrategy = Private(SearchStrategyProvider);
  8. let requestQueue = Private(RequestQueueProvider);
  9. let Looper = Private(LooperProvider);
  10. let searchLooper = new Looper(null, function () {
  11. $rootScope.$broadcast('courier:searchRefresh');
  12. return fetch.these(
  13. requestQueue.getInactive(searchStrategy)
  14. );
  15. });
  16. ...

这里的关键方法是 fetch.these(),出自 src/ui/public/courier/fetch/fetch_these.js,其中调用的 src/ui/public/courier/fetch/call_client.js 有如下一段代码:

  1. Promise.map(executable, function (req) {
  2. return Promise.try(req.getFetchParams, void 0, req)
  3. .then(function (fetchParams) {
  4. return (req.fetchParams = fetchParams);
  5. });
  6. })
  7. .then(function (reqsFetchParams) {
  8. return strategy.reqsFetchParamsToBody(reqsFetchParams);
  9. })
  10. .then(function (body) {
  11. return (esPromise = es[strategy.clientMethod]({ body }));
  12. })
  13. .then(function (clientResp) {
  14. return strategy.getResponses(clientResp);
  15. })
  16. .then(respond)

在这段代码中,我们可以看到 strategy.reqsFetchParamsToBody(), strategy.getResponses()strategy.clientMethod,正是之前 searchLooper 和 docLooper 传递的对象属性。而最终发送请求,同样用的是前面解释过的 es 这个 service。

此外,Courier 还提供了自动刷新的控制功能:

  1. self.fetchInterval = function (ms) {
  2. searchLooper.ms(ms);
  3. return this;
  4. };
  5. ...
  6. $rootScope.$watchCollection('timefilter.refreshInterval', function () {
  7. var refreshValue = _.get($rootScope, 'timefilter.refreshInterval.value');
  8. var refreshPause = _.get($rootScope, 'timefilter.refreshInterval.pause');
  9. if (_.isNumber(refreshValue) && !refreshPause) {
  10. self.fetchInterval(refreshValue);
  11. } else {
  12. self.fetchInterval(0);
  13. }
  14. });

路径记忆功能的实现

src/ui/public/chrome/api/apps.js 中,我们可以看到路径记忆功能是怎么实现的:

  1. module.exports = function (chrome, internals) {
  2. internals.appUrlStore = internals.appUrlStore || window.sessionStorage;
  3. ...
  4. chrome.getLastUrlFor = function (appId) {
  5. return internals.appUrlStore.getItem(`appLastUrl:${appId}`);
  6. };
  7. chrome.setLastUrlFor = function (appId, url) {
  8. internals.appUrlStore.setItem(`appLastUrl:${appId}`, url);
  9. };

这里使用的 sessionStorage 是 HTML5 自带的新特性,这样,每次标签页切换的时候,都可以把 $location.url 保存下来。