最近在梳理团队项目依赖和各个项目技术栈的时候,发现使用技术雷达的形式来进行呈现和管理是个不错的点子。但是没找到维护简单,界面又清爽好看的 UI。

正巧访问到 Thoughtworks 新版本技术雷达,发现在线版本十分漂亮,远比官方提供的生成版本漂亮。

那么,记录下折腾这个技术雷达的过程吧,本篇是第一篇,聊聊如何使用 Node.js 完成一个上述雷达的本地版本。

写在前面

访问 官方地址 可以看到下面的新版技术雷达的界面。

Thoughtworks 在线版技术雷达

点击导航菜单中的【构建你的雷达】,即使我们不自己准备数据,使用官方默认数据,跟随官方提示,一路 “Next”,便会得到下图样式/交互的定制雷达

默认生成器版本

然而不论是可阅读性,还是界面流畅度,亦或者在“技术点”变化展示上,生成版本都不如线上版本。

那么先定一个小目标,让这个漂亮的版本能够在本地跑起来吧。

获取网站相关资源

一切的开始,是我们需要有一套可以运行的本地代码,所以这里可以使用各种方式将网页和相关资源镜像到本地。过程略,如果你熟悉 Node ,应该二十多行脚本就能解决战斗了吧。

将访问技术雷达时请求的资源根据实际状况保存到本地,使用 tree 进行查看,目录结构如下:

app/page
├── static.thoughtworks.com
│   ├── fonts
│   │   ├── latin-normal-300.woff2
│   │   ├── latin-normal-400.woff2
│   │   ├── latin-normal-600.woff2
│   │   ├── latin-normal-700.woff2
│   │   └── latin-normal-800.woff2
│   └── js
│       └── jquery-1.12.4.min.js
└── www.thoughtworks.com
    ├── 1140_grid
    │   └── css
    │       └── 1140.css
    ├── cn
    │   └── radar
    │       ├── languages-and-frameworks
    │       ├── platforms
    │       ├── techniques
    │       └── tools
    ├── css
    │   └── k8s_production
    │       └── screen.css
    ├── imgs
    │   ├── 1x1-transparent.gif
    │   ├── favicons
    │   │   └── favicon.ico
    │   ├── languages-ring.svg
    │   ├── platforms-ring.svg
    │   ├── search.svg
    │   ├── techniques-ring.svg
    │   ├── tools-ring.svg
    │   └── tw-logo.svg
    ├── javascripts
    │   └── lib
    │       ├── d3.v3.min.js
    │       ├── lodash.min.js
    │       ├── socialite.min.js
    │       └── underscore.string.min.js
    └── js
        └── k8s_production
            ├── application.js
            ├── css3-mediaqueries.js
            ├── dropdown.js
            ├── isInViewport.min.js
            ├── radar
            │   ├── quadrant.js
            │   ├── radar.js
            │   └── radar_tooltip.js
            ├── shared.js
            └── wistia_cookie_manager.js

17 directories, 33 files

获取完资源,我们试试先启动一个测试服务器,看看资源能否“离线运行”,评估下改动工作量有多少。

启动测试服务器

启动一个能够离线模拟页面功能的 Web 服务很简单,不到二十行代码解决问题:

const express = require("express");
const app = express();
const port = 3000;
const { readFileSync, existsSync } = require("fs");

app.get("/", (req, res) => res.redirect("/cn/radar/techniques"));

app.get("/cn/radar/:page", (req, res) => {
  res.header("Content-Type", "text/html");
  const file = `./public/${req.params.page}.html`;
  if (!existsSync(file)) return res.status(404).send("not found");
  return res.send(readFileSync(file, "utf-8"));
});

app.use(express.static("page/www.thoughtworks.com/"));

app.listen(port, "0.0.0.0", () => console.log(`Example app listening at port: ${port}`));

将代码保存后,使用 Node 启动脚本,访问 localhost:3000 会看到下面的页面。

缓存后的本地版本

上面代码解决了这三个问题:

  • 用户访问 localhost:3000 会自动跳转到技术雷达的“技术页面”,避免找不到缓存的页面文档。
  • 完全模拟线上版本的应用,提供相同的页面访问路由。
  • 使用本地的脚本样式资源,保持程序运行的“版本稳定”。

为了让我们对技术雷达中的数据有比较好的管理能力,我们需要对网站进行一定的程序抽象、数据解耦。

提取并整理页面中的数据

官网站点充分考虑了 SEO,以及浏览器渲染效率、禁用脚本情况页面的呈现状态,所以我们会看到大量数据和页面模版耦合在一起的情况。

这种状况对于维护来说,因为数据量比较大、数据之间有排序和关联,所以当前状态下对应用进行后续维护是比较麻烦的,为了后续进行数据管理,我们需要先将页面数据剥离出来。

<div class="blip-link" data-blip-id="9142" data-blip-status="c" data-event-action="Click to Summary" data-event-category="Tech Radar - Techniques" data-event-label="Adopt - Infrastructure as code" data-movement="c" data-radar-id="2" data-radius="60" data-theta="155" data-url="techniques/infrastructure-as-code" id="blip-link-9142" style="background-color: white; color: black;">
    <span class="blip-graphic-id">2. </span>
    <span class="blip-name" style="display: inline;">基础设施即代码</span>
    <a class="non-js-blip-desc-link" href="techniques/infrastructure-as-code" style="display: none;">基础设施即代码</a>
</div>
...

使用解析网页文档 DOM 结构的思路,将类似上面的片段进行序列化,并根据页面路由名称分别生成接下来需要使用的 JSON 文件。

const { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync } = require("fs");
const turndownService = new require("turndown")();
const { load } = require("cheerio");

function parser($, quadrantType = "adopt") {
  const container = $(`#quadrant-blip-list #${quadrantType}`);
  const titleContainer = container.find(`h3.ring.${quadrantType}`);
  const quadrantTips = titleContainer.find(".tooltip-icon").attr("title");
  titleContainer.find("span").remove();
  const quadrantTitle = titleContainer.text();

  const blipList = container.find("ul li");
  let result = [];
  blipList.map((i, el) => {
    const linkBlock = $(el).find(".blip-link");
    const name = linkBlock.find(".blip-name").text();
    const { blipId: id, blipStatus: status, movement, eventAction, eventCategory, eventLabel, radarId, radius, theta, url } = linkBlock.data();
    let changelog = null;

    const descBlock = $(el).find(".blip-description");
    const detectLastBlock = descBlock.children().last();
    if (detectLastBlock[0].name === "div") {
      const changelogButton = detectLastBlock.find("a");
      if (changelogButton.length) {
        if (detectLastBlock.find("a").text().indexOf("历史信息") > -1) {
          changelog = detectLastBlock.find("a").attr("href");
          detectLastBlock.empty();
        }
      }
    }
    result.push({ id, name, status, eventAction, eventCategory, eventLabel, movement, radarId, radius, theta, url, changelog, desc: turndownService.turndown(descBlock.html()) });
  });

  return { title: quadrantTitle, tips: quadrantTips, data: result };
}

if (!existsSync("./app/data/")) mkdirSync("./app/data");

readdirSync("./app/page/www.thoughtworks.com/cn/radar/").forEach((page) => {
  const file = readFileSync(`./app/page/www.thoughtworks.com/cn/radar/${page}`, "utf-8");

  const $ = load(file, { normalizeWhitespace: false, xmlMode: false, decodeEntities: false });
  const result = ["adopt", "trial", "assess", "hold"].map((type) => {
    return { ...parser($, type), type, category: page };
  });

  writeFileSync(`./app/data/${page}.json`, JSON.stringify(result));
  console.log(result);
});

上面的脚本执行完毕之后,生成的 JSON 文档内容会类似下面这样:

[
  {
    "title": "采纳",
    "tips": "我们强烈建议业界采用这些技术,我们将会在任何合适的项目中使用它们。",
    "data": [
      {
        "id": 1133,
        "name": "将产品管理思维应用于内部平台",
        "status": "move_in",
        "eventAction": "Click to Summary",
        "eventCategory": "Tech Radar - Techniques",
        "eventLabel": "Adopt - Applying product management to internal platforms",
        "movement": "t",
        "radarId": 1,
        "radius": 90,
        "theta": 165,
        "url": "techniques/applying-product-management-to-internal-platforms",
        "changelog": "/cn/radar/techniques/applying-product-management-to-internal-platforms",
        "desc": "越来越多的公司正在构建内部平台,借此快速有效地推出新型数字化解决方案。成功实施这一战略的企业正 **将产品管理思维应用于内部平台** 。这意味着与内部消费者(开发团队)建立共情,并在设计上彼此协作。平台的产品经理要建立路线图,确保平台为业务交付价值,为开发者改善体验。不幸的是,我们也见到了一些不太成功的方式,团队在未经验证的假设、没有内部客户的情况下,打造出的平台犹如空中楼阁。这些平台尽管采用了激进的内部策略,但往往无法充分利用,还耗尽了组织的交付能力。和其他产品一样,好的产品管理就是为消费者创造喜爱的产品。"
      },
...
]

官方生成器中使用的 Google Docs 文档数据中,描述内容使用的是 HTML 代码片段,在内容数量多了之后并不是很好维护,尤其是让呈现的样式保持一致,所以这里将 HTML 转换为 Markdown 格式进行保存。

抽象通用页面模版

通过观察发现 cn/radar 目录下的 languages-and-frameworksplatformstechniquestools 结构完全一致,如果我们将页面当前的数据去除,那么便可以只维护一个模版啦。

这里不论是使用手动处理,还是和镜像站点一样,写一段简单的脚本,都可以比较快的得到一个通用的模版结构。将处理后的模版单独保存(如 app/template/base.html 目录),稍后使用。

过程中可以根据自己需求,对页面模版、布局等进行适当修改,所以这里就不贴出完整代码实现啦,需要注意的是,为了后续数据能够再次比较容易的转换为代码,我们还需要单独抽象“技术列表”元素的模版,如(这里使用了 ejs 语法):

<% source.forEach((item) => { const {type, category,title,tips, data} = item; %>
<div id="<%-type%>">
  <h3 class="ring <%-type%>">
    <span class="dot <%-category%>">●</span>
    <%-title%><span class="tooltip-icon" title="<%-tips%>">?</span>
  </h3>
  <ul>
    <% data.forEach((subItem) => { const {id, name, status, eventAction, eventCategory, eventLabel, movement, radarId, radius, theta, url, desc, changelog} = subItem; %>
    <li>
      <div class="blip-link" data-blip-id="<%-id%>" data-blip-status="<%-status%>" data-event-action="<%-eventAction%>" data-event-category="<%-eventCategory%>" data-event-label="<%-eventLabel%>" data-movement="<%-movement%>" data-radar-id="<%-radarId%>" data-radius="<%-radius%>" data-theta="<%-theta%>" data-url="<%-url%>" id="blip-link-<%-id%>">
        <span class="blip-graphic-id"><%-radarId%>. </span>
        <span class="blip-name"><%-name%></span>
        <a class="non-js-blip-desc-link" href="<%-url%>"><%-name%></a>
      </div>
      <div class="blip-description" id="blip-description-<%-id%>">
        <%-desc%>
        <% if (changelog) { %>
        <div>
          <a href="<%-changelog%>">历史信息</a>
          <span class="right social-share"></span>
        </div>
        <% } %>
      </div>
    </li>
    <% }) %>
  </ul>
</div>
<% }) %>

编写数据转换模版程序

当模版抽象完毕后,我们就需要考虑如何将模版转换为页面内容,并提供给用户了。

这里通常有两个方案,第一种是启动一个 Node 服务,在服务运行时,动态渲染模版内容;第二种则是和我们镜像后的官方网站一样,提供多个页面模版供用户浏览访问。

本文考虑后续会使用静态服务器托管,脱离技术栈依赖,所以选择方案二。我们可以再编写一个简单的数据转换工具,让前面小节中抽象出的 JSON 数据再次转换为“模版”。(即使选择方案一,其实也可以使用下面的程序,做页面缓存,提高访问性能)

const { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync } = require("fs");
const { load } = require("cheerio");
const markdownIt = require("markdown-it")();
const { compile } = require("ejs");
const template = compile(readFileSync("./app/template/list.ejs", "utf-8"));

const $ = load(readFileSync("./app/template/base.html", "utf-8"), { normalizeWhitespace: false, xmlMode: false, decodeEntities: false });

const files = readdirSync("./app/data")
  .filter((file) => file.endsWith(".json"))
  .map((file) => {
    return { path: `./app/data/${file}`, name: file };
  });

files.forEach((file) => {
  const source = JSON.parse(readFileSync(file.path, "utf-8"));
  const renderData = source.map((item) => {
    item.data = item.data.map((data) => {
      data.desc = markdownIt.render(data.desc);
      return data;
    });
    return item;
  });

  const content = $(template({ source: renderData }));
  $("#quadrant-blip-list").html(content);

  $("#radar")
    .find(".radar-quadrant-navigation__quadrant a")
    .each((id, el) => {
      if (file.name.includes($(el).attr("nav-entry"))) {
        $(el).addClass("selected");
      } else {
        $(el).removeClass("selected");
      }
    });

  writeFileSync(`./app/public/${file.name}`.replace(".json", ".html"), $.html());
});

当程序执行完毕,应用目录会出现一个 Public 文件夹,使用 tree 命令查看内容:

public
├── languages-and-frameworks.html
├── platforms.html
├── techniques.html
└── tools.html

到现在为止,我们做到了使用我们自己的 JSON 数据和模版生成这些技术雷达页面。

启动新的 HTTP 服务器

在配置 Nginx 静态服务器规则之前,我们可以先将之前的测试服务器程序进行简单修改,来让我们接下来的程序定制调整更加容易。

const express = require("express");
const app = express();
const port = 3000;
const { readFileSync } = require("fs");

app.get("/", (req, res) => res.redirect("/cn/radar/techniques"));

app.get("/cn/radar/:page", (req, res) => {
  res.header("Content-Type", "text/html");
  res.send(readFileSync(`./public/${req.params.page}.html`, "utf-8"));
});

app.use(express.static("page/www.thoughtworks.com/"));

app.listen(port, "0.0.0.0", () =>
  console.log(`Example app listening at port: ${port}`)
);

只需要调整 app.get("/cn/radar/:page", ...) 路由的内容,就可以完成从本地镜像到我们自己生成页面模版的切换。

运行在本地的“新版本”技术雷达

最后

当前我们可以通过修改生成的 JSON 数据,以及执行刚刚编写的模版生成程序来完成页面内容的更新,但是这样对于使用者体验太差了,也无法容易的做到对在图表中的数据点的管理。

下一篇,我们继续针对这个技术雷达进行改造。

–EOF