2023年3月

通过一段时间的使用和学习,对G6有了更一步的经验,这篇博文主要从以下几个小功能着手介绍,文章最后会给出完整的demo代码。

1. 树图的基本布局和使用

树图的布局,使用的模板是官网所提供的
紧凑树
模板,在此基础上,进行一些定制化的改造,
官网紧凑树案例

属性说明:

graph = new G6.TreeGraph({
    container,
    width: document.documentElement.clientWidth,
    height:document.documentElement.clientHeight,
    // .....
    layout: {
      type: 'compactBox',    // 布局类型
      direction: 'LR',       // 树图布局方向, 从左向右
      getHeight: function getHeight() { // 高度
        return 16
      },
      getWidth: function getWidth() {  // 宽度
        return 16
      },
      getVGap: function getVGap() {   // 节点之间 垂直间距
        return 25
      },
      getHGap: function getHGap() {  // 节点之间 水平间距
        return 150
      }
    }
})

2. 根据返回数据的属性不同,定制不一样的节点样式

大部分在实际项目中,数据都是由后端返回,可能会存在多种类型的数据,需要进行不同的处理和展示,那么此时只在 graph 初始化时,定义defaultNode显然是不够用的,G6支持动态改变节点样式。需要在 graph 实例化之后:

// 以下函数均在下方有实现代码:
graph.node((node)=> {
  return {
    label: node.label || formatLabel(node),   
    icon: formatIcon(node),
    size: node.size || 40,
    labelCfg: { position: setLabelPos(node) },  // label 显示位置
    style: {
      fill: getNodeColor(),
      stroke: getNodeColor()
    }
  }
})

3. 节点 label 文案显示过长时,通过截断的方式,显示...

首先判断当前节点有无子节点,后续会进行label的拼接,显示叶子节点的数量,在进行结束,此书由于数据的原因,案例中数据
label

id
使用同一个字段,后续同学们可以自己按实际情况进行调整:我截取的长度是 15

// label 过长截断 显示...
const formatLabel = (node) => {
  const hasChildren = node.childrenBak?.length || node.children?.length
  const ellipsis = node.id.length > 15 ? '...' : ''
  return `${node.id.slice(0, 15)}${ellipsis}${hasChildren ? ' (' + hasChildren + ')' : ''}`
}

4. 当一个父节点包含children叶子节点时,label后显示children的长度,格式为:node.label(children.length)

这个小功能,与上一个label 截断,统一都是处理label的,因此放在同一个函数中返回,其主要实现代码为 这一行:

 return `${node.id.slice(0, 15)}${ellipsis}${hasChildren ? ' (' + hasChildren + ')' : ''}`

5. 截断后的label ,通过鼠标悬浮,完全显示在 tooltip 中,定义并改写 tooltip 样式

这个小功能设计的改动比较多,同学们在看的时候,不要看错了哈

第一步:首先定义一个函数,返回tooltip,官网的案例中并不是返回的函数,而是直接返回了一个对象,这个在实际使用过程中会存在问题,就是新增的数据无法使用到这个插件,因此通过函数调用的方式 ,可以解决该现象:

// label 显示... 时,显示提示 tip
const treeTooltip = ()=> {
  return new G6.Tooltip({
    offsetX: 10,  // 鼠标偏移量
    offsetY: 20,
    shouldBegin(e: any) {
      return e.item?.get('model')?.label?.includes('...')   // label中有...才显示,表示被截断
    },
    getContent(e: any) {
      let outDiv = document.createElement('div')   // 设置tip容器和样式
      outDiv.innerHTML = `
      <p style="max-width:600px;word-wrap:break-word;border-radius:5px;font-size:15px;color:#fff;background:#333;padding:10px">
        ${e.item.getModel().id} 
      </p>`
      return outDiv
    },
    itemTypes: ['node']    // 表示触发的元素类型
  })
}

第二步:在new 实例化的options中添加 插件使用

graph = new G6.TreeGraph({
    container,
    width: document.documentElement.clientWidth,
    height:document.documentElement.clientHeight,
    plugins: [treeTooltip()],
    // .....
})

第三步:完整以上代码后,基本可以看出
tip
的提示框,但是由于是改写原有的样式,因此还需要 改一下 style, 由于画布操作较多,因此canvas画布,修改鼠标样式,全文只有这里提到了style修改。就写在一起了,实际并不影响 tooltip 功能

<style scoped>
#container >>> .g6-component-tooltip {
    background:#333;
    color:#fff;
    padding: 0 8px;
}

canvas {
  cursor: pointer !important;
}
</style>

6. 根据节点展开收起状态 动态变更 label 显示位置,展开时在上,收起时在右

此功能的设计是为了优化,当节点label过长并展开时,父子之间水平间距不够时会出现文案互相重叠等问题,做了一个小优化,

首先判断节点是否是展开状态,以及是否有叶子节点,节点有children并展开时在上,其余情况都显示在右边,这是初始化时的代码:

// 根据节点展开收起状态 动态变更 label 显示位置,展开时在上,收起时在右
const setLabelPos = (node) => {
  return !node.collapsed && node.children?.length ? 'top' : 'right'
}

因为树图可以监听节点的展开收起状态,因此在切换的时候,也需要进行 label定位的问题:在new 实例化的options中添加
modes

graph = new G6.TreeGraph({
    container,
    width: document.documentElement.clientWidth,
    height:document.documentElement.clientHeight,
    plugins: [treeTooltip()],
    // .....
    modes: {
       default: [
          {
            type: 'collapse-expand',
            onChange: function onChange(item: any, collapsed) {
              const data = item?.get('model')
              data.collapsed = collapsed
              const model = {
                id: data.id,
                labelCfg: { position: !collapsed ? 'top' : 'right' }
              }
              item.update(model)
              item.refresh()
              return true
            }
          },
          'drag-canvas',
          'zoom-canvas'
        ]
     }
}) 

7. 设置节点的icon样式和背景色(随机色可自行定制)

废话不多话,处理节点的icon图表,图表可以使用图片,也可以使用文字,此处就用文字了,截取的label前两个字符串,并设置背景颜色(随机色可自行定制)

// 叶子节点 图标处理  截取ID 前两个字符串
const formatIcon = (node) => {
  node.icon = {
    text: node.id.slice(0,2),
    fill: '#fff',
    stroke: '#fff',
    textBaseline: 'middle',
    fontSize: 20,
    width: 25,
    height: 25,
    show: true
  }
}

// 叶子节点 背景颜色随机填充
const getNodeColor = () =>  {
  const colors = ["#8470FF",  "#A020F0", "#C0FF3E", "#FF4500", "#66d6d1"];
  return colors[Math.floor(Math.random() * colors.length)];
}

8. 叶子节点超过 (xxx)条, 折叠叶子节点,显示展开更多

当叶子节点很多时,并不想全部展开,而是先只展示一部分,其他节点折叠在 展开按钮中,实现思路,定义一个属性,接受原有全部children, 然后进行截取,在push一个 展开按钮,实现:

第一步:定义一个
childrenBak
属性接受
children
数据

//  子还有子,因此需要递归
const splitChild = (node) => {
  node.childrenBak = node.children ? [...node.children] : []
  let result: any = []

  if(node.children){
    result = node.children.slice(0, 5)
    if (node.children.length > 5) {
      result.push({ id: `Expand-${node.id}`, label: ' 展开更多...' })
    }
    node.children = result
    node.children.forEach(child =>{
      splitChild(child)
    })  
  }
}

9. 点击展开更多节点,渲染被折叠的叶子节点

第二步:(接功能小8继续)点击展开更多时,显示被折叠的剩余节点,,定义
node:click
事件

思路:找到展开更多 , 找到展开更多节点的 父节点, 更新父节点的 children

graph.on('node:click', (evt) => {
    const { item } = evt
    const node = item?.get('model')
    if (node.id.includes('Expand')) {  // id 中包含Expand 表示是展开更多 
      const parentNode = graph.getNeighbors(item, 'source')[0].get('model')  // 找到展开更多节点的 父节点
      graph.updateChildren(parentNode.childrenBak, parentNode.id)   //  使用上一步声明的childrenBak 更新父节点的 children
    } 
  })

10. 定义鼠标点击事件,聚焦当前点击节点至画布中心点

小9 说了点击事件,那么点击事件中,还有一个小的优化点,就是将当前点击的节点,移动至画布中心,并赋予高亮选中样式

  const animateCfg = { duration: 200, easing: 'easeCubic' }
  graph.on('node:click', (evt) => {
    const { item } = evt
    const node = item?.get('model')
    // if (node.id.includes('Expand')) {    //  功能点 9 代码
    //   const parentNode = graph.getNeighbors(item, 'source')[0].get('model')
    //   console.log(parentNode,parentNode.childrenBak);
    //   graph.updateChildren(parentNode.childrenBak, parentNode.id)
    // } 
    setTimeout(() => {
      if (!node.id.includes('Expand')) {
        graph.focusItem(item, true, animateCfg)
        graph.getNodes().forEach((node) => {   
          graph.clearItemStates(node)  // 先清空其他节点的 高亮样式
        })
        graph.setItemState(item, 'selected', true)   // selected 需要在实例化处进行定义
      }
    }, 500)
  })

selected
表示的是
nodeStateStyles
,也就是节点状态样式

graph = new G6.TreeGraph({
    container,
    width: document.documentElement.clientWidth,
    height:document.documentElement.clientHeight,
    plugins: [treeTooltip()],
    // .....
    nodeStateStyles: {
      active: {   // 这个用在了 鼠标悬浮,可以自行定义
        fill: 'l(0) 0:#FF4500 1:#32CD32',
        stroke: 'l(0) 0:#FF4500 1:#32CD32',
        lineWidth: 5
      },
      selected: {  // 这个用在了 鼠标选中,可以自行定义
        fill: 'l(0) 0:#FF4500 1:#32CD32',
        stroke: 'l(0) 0:#FF4500 1:#32CD32',
        lineWidth: 5
      }
    }
}) 

11. 定义鼠标移入移出事件

  graph.on('node:mouseenter', (evt) => {
    const { item } = evt
    graph.setItemState(item, 'active', true)   // active 与 selected 都是节点状态样式
  })

  graph.on('node:mouseleave', (evt) => {
    const { item } = evt
    graph.setItemState(item, 'active', false)
  })

12. 根据返回数据的属性不同,定制不一样的 边 样式

关于节点的差不多介绍完了,关于连线,内容就比较少了,动态定义连线样式,及连线上的文字样式:

可以根据 link的不同属性自定义 连线颜色和label颜色,因为是测试数据,因此就用一个自增长的数判断奇偶性来进行区分,以便明白其中定制化的方法

let selfGrowthNum = 0
graph.edge((edge)=> {
    // let {source, target } = edge   // 解构连线的 起始节点
    selfGrowthNum++
    return {
      style: {
        opacity: 0.5,
        stroke: selfGrowthNum % 2 ? '#ADD8E6' : "#FFDEAD",
        lineWidth: 2
      },
      labelCfg: {
        position: 'end',
        style: {
          fontSize: 16,
          fill: selfGrowthNum % 2 ? '#ADD8E6' : "#FFDEAD",
        }
      },
      label:  selfGrowthNum % 2 ? 'even' : "odd"
    }
  })

13. 设置连线上关系文案样式

上述代码基本完成了 连线的样式和文案的样式,但此时,线是贯穿文字的,看着比较乱,因此还需要修改连线样式
defaultEdge

graph = new G6.TreeGraph({
    container,
    width: document.documentElement.clientWidth,
    height:document.documentElement.clientHeight,
    // .....
    defaultEdge: {
      type: 'cubic-horizontal',
      style: {    // 如果不定制化,这个就是默认样式
        opacity: 0.5,
        stroke: '#ccc',
        lineWidth: 2
      },
      labelCfg: {
        position: 'end',   // 文字显示在线段的哪个位置,
        refX: -15,
        style: {
          fontSize: 16,
          background: {
            fill: '#ffffff',  // 给文字添加背景色,解决文字被横穿的问题
            padding: [2, 2, 2, 2]
          }
        }
      }
    }
})

14. 解决画布拖拽,出现黑色残影问题

G6 4.x 依赖的渲染引擎 @antv/g@4.x 版本支持了局部渲染,带了性能提升的同时,也带来了图形更新时可能存在渲染残影的问题。比如拖拽节点时,节点的文本会留下轨迹。由于目前 @antv/g 正在进行大版本的升级(到 5.x),可能不考虑在 4.x 彻底修复这个问题。当我们遇到这个问题的时候,可以通过
关闭局部渲染
的方法解决,但是这样可能导致性能有所降低。

graph.get('canvas').set('localRefresh', false)。

15. Demo 动图演示

16. 完整Demo案例

<template>
  <div id="container"></div>
</template>

<script lang="ts" setup>
import G6 from "@antv/g6";
import { onMounted } from "vue";

let graph: any = null;

// 树图初始数据
const treeData = {
  id: "Modeling Methods",
  color: "",
  children: [
    {
      id: "Classification",
      children: [
        { id: "Logistic regression" },
        { id: "Linear discriminant analysis" },
        { id: "Rules" },
        { id: "Decision trees" },
        { id: "Naive Bayes" },
        { id: "Knearest neighbor" },
        { id: "Probabilistic neural network" },
        { id: "Support vector machine" },
      ],
    },
    {
      id: "Methods",
      children: [
        { id: "Classifier selection" },
        { id: "Models diversity" },
        { id: "Classifier fusion" },
      ],
    },
  ],
};
onMounted(() => {
  splitChild(treeData);
  drawTreeGraph();
});

function drawTreeGraph() {
  if (graph) graph.destroy();
  const container = document.getElementById("container") as HTMLElement;

  graph = new G6.TreeGraph({
    container,
    width: document.documentElement.clientWidth - 300,
    height: document.documentElement.clientHeight,
    fitView: false,
    fitViewPadding: [10, 50, 10, 50],
    animate: true,
    plugins: [treeTooltip()],
    defaultNode: {
      type: "circle",
      size: 40,
      collapsed: false,
      style: {
        fill: "#fff",
        lineWidth: 2,
        cursor: "pointer",
      },
      labelCfg: {
        position: "right",
        offset: 10,
        style: {
          fill: "#333",
          fontSize: 20,
          stroke: "#fff",
          background: {
            fill: "#ffffff",
            padding: [2, 2, 2, 2],
          },
        },
      },
      anchorPoints: [
        [0, 0.5],
        [1, 0.5],
      ],
      icon: {
        show: true,
        width: 25,
        height: 25,
      },
    },
    defaultEdge: {
      type: "cubic-horizontal",
      labelCfg: {
        position: "end",
        refX: -15,
        style: {
          fontSize: 16,
          background: {
            fill: "#ffffff",
            padding: [2, 2, 2, 2],
          },
        },
      },
    },
    modes: {
      default: [
        {
          type: "collapse-expand",
          onChange: function onChange(item: any, collapsed) {
            const data = item?.get("model");
            data.collapsed = collapsed;
            const model = {
              id: data.id,
              labelCfg: { position: !collapsed ? "top" : "right" },
            };
            item.update(model);
            item.refresh();
            return true;
          },
        },
        "drag-canvas",
        "zoom-canvas",
      ],
    },
    layout: {
      type: "compactBox",
      direction: "LR",
      getHeight: function getHeight() {
        return 30;
      },
      getWidth: function getWidth() {
        return 16;
      },
      getVGap: function getVGap() {
        return 30;
      },
      getHGap: function getHGap() {
        return 150;
      },
    },
    nodeStateStyles: {
      active: {
        fill: "l(0) 0:#FF4500 1:#32CD32",
        stroke: "l(0) 0:#FF4500 1:#32CD32",
        lineWidth: 5,
      },
      selected: {
        fill: "l(0) 0:#FF4500 1:#32CD32",
        stroke: "l(0) 0:#FF4500 1:#32CD32",
        lineWidth: 5,
      },
    },
  });

  graph.node((node: { label: any; size: any }) => {
    return {
      label: node.label || formatLabel(node),
      icon: formatIcon(node),
      size: node.size || 40,
      labelCfg: { position: setLabelPos(node) },
      style: {
        fill: getNodeColor(),
        stroke: getNodeColor(),
      },
    };
  });
  let selfGrowthNum = 0;
  graph.edge((edge: any) => {
    // let {source, target } = edge   // 也可以根据 link的属性不同自定义 连线颜色和label颜色,因为是测试数据,因此就用一个自增长的数判断奇偶性来进行区分,以便明白其中定制化的方法
    selfGrowthNum++;
    return {
      style: {
        opacity: 0.5,
        stroke: selfGrowthNum % 2 ? "#ADD8E6" : "#FFDEAD",
        lineWidth: 2,
      },
      labelCfg: {
        position: "end",
        style: {
          fontSize: 16,
          fill: selfGrowthNum % 2 ? "#ADD8E6" : "#FFDEAD",
        },
      },
      label: selfGrowthNum % 2 ? "even" : "odd",
    };
  });

  graph.on("node:mouseenter", (evt: { item: any }) => {
    const { item } = evt;
    graph.setItemState(item, "active", true);
  });

  graph.on("node:mouseleave", (evt: { item: any }) => {
    const { item } = evt;
    graph.setItemState(item, "active", false);
  });

  const animateCfg = { duration: 200, easing: "easeCubic" };
  graph.on("node:click", (evt: { item: any }) => {
    const { item } = evt;
    const node = item?.get("model");
    if (node.id.includes("expand")) {
      const parentNode = graph.getNeighbors(item, "source")[0].get("model");
      console.log(parentNode, parentNode.childrenBak);
      graph.updateChildren(parentNode.childrenBak, parentNode.id);
    }
    setTimeout(() => {
      if (!node.id.includes("expand")) {
        graph.focusItem(item, true, animateCfg);
        graph.getNodes().forEach((node: any) => {
          graph.clearItemStates(node);
        });
        graph.setItemState(item, "selected", true);
      }
    }, 500);
  });

  graph.on("canvas:click", () => {
    graph.getNodes().forEach((node: any) => {
      graph.clearItemStates(node);
    });
  });

  graph.data(treeData);
  graph.render();
  graph.zoom(0.9);
  graph.fitCenter();
  graph.get("canvas").set("localRefresh", false);
}
// label 过长截断 显示...
const formatLabel = (node) => {
  const hasChildren = node.childrenBak?.length || node.children?.length;
  const ellipsis = node.id.length > 15 ? "..." : "";
  return `${node.id.slice(0, 15)}${ellipsis}${
    hasChildren ? " (" + hasChildren + ")" : ""
  }`;
};

// 叶子节点 图标处理  截取ID 前两个字符串
const formatIcon = (node) => {
  node.icon = {
    text: node.id.slice(0, 2),
    fill: "#fff",
    stroke: "#fff",
    textBaseline: "middle",
    fontSize: 20,
    width: 25,
    height: 25,
    show: true,
  };
};

// 叶子节点 背景颜色随机填充
const getNodeColor = () => {
  const colors = ["#8470FF", "#A020F0", "#C0FF3E", "#FF4500", "#66d6d1"];
  return colors[Math.floor(Math.random() * colors.length)];
};

// 根据节点展开收起状态 动态变更 label 显示位置,展开时在上,收起时在右
const setLabelPos = (node: { collapsed: any; children: string | any[] }) => {
  return !node.collapsed && node.children?.length ? "top" : "right";
};
// label 显示... 时,显示提示 tip
const treeTooltip = () => {
  return new G6.Tooltip({
    offsetX: 10,
    offsetY: 20,
    shouldBegin(e: any) {
      return e.item?.get("model")?.label?.includes("...");
    },
    getContent(e: any) {
      let outDiv = document.createElement("p");
      outDiv.innerHTML = ` ${e.item.getModel().id} `;
      return outDiv;
    },
    itemTypes: ["node"],
  });
};

// 叶子节点超过 5(xxx)条, 折叠叶子节点,显示展开更多
const splitChild = (node: any) => {
  node.childrenBak = node.children ? [...node.children] : [];
  let result: any = [];

  if (node.children) {
    result = node.children.slice(0, 5);
    if (node.children.length > 5) {
      result.push({ id: `expand-${node.id}`, label: " 展开更多..." });
    }
    node.children = result;
    node.children.forEach((child: any) => {
      splitChild(child);
    });
  }
};
</script>
<style scoped>
#container >>> .g6-component-tooltip {
    background:#333;
    color:#fff;
    padding: 0 8px;
}

canvas {
  cursor: pointer !important;
}
</style>

Curve 是网易主导自研的现代化存储系统, 目前支持文件存储(CurveFS)和块存储(CurveBS)。

CurveBS 的核心应用场景主要包括:

  • 虚拟机/容器的性能型、混合型、容量型云盘或持久化卷,以及物理机的远程存储盘
  • 高性能存算分离架构:基于RDMA+SPDK的高性能低时延架构,支撑MySQL、kafka等各类数据库、中间件的存算分离部署架构,提升实例交付效率和资源利用率

CurveFS 的核心应用场景主要包括:

  • AI训练(含机器学习等)场景下的高性价比存储
  • 大数据场景下的冷热数据自动化分层存储
  • 公有云上高性价比的共享文件存储:可用于AI、大数据、文件共享等业务场景
  • 混合云存储:热数据存储在本地IDC,冷数据存储在公有云

使用 CurveAdm 部署 CurveFS

CurveAdm 是 Curve 团队为提高系统易用性而设计的工具,其主要用于快速部署和运维 CurveBS/CurveFS 集群。主要特性:

  • 快速部署 CurveBS/CurveFS 集群
  • 容器化服务
  • 运维 CurveBS/CurveFS 集群
  • 同时管理多个集群
  • 一键升级
  • 错误精确定位

安装 CurveAdm

bash -c "$(curl -fsSL https://curveadm.nos-eastchina1.126.net/script/install.sh)"

主机列表

主机模块用来统一管理用户主机,以减少用户在各配置文件中重复填写主机
SSH
连接相关配置。我们需导入部署集群和客户端所需的机器列表,以便在之后的各类配置文件中填写部署服务的主机名。

这里采用一台服务器,做单节点集群。

配置免密登陆

生成密钥并配置服务器免密登陆

# 一直回车即可
ssh-keygen

# 使用 ssh-copy-id 配置
ssh-copy-id root@172.31.98.243

# 验证免密
ssh root@172.31.98.243

# 无需输入密码登陆成功即可

导入主机列表

准备主机列表文件
hosts.yaml

$ vim hosts.yaml

global:
  user: root # ssh 免密登陆用户名
  ssh_port: 22 # ssh 端口
  private_key_file: /root/.ssh/id_rsa # 密钥路径

hosts:
  - host: curve
    hostname: 172.31.98.243

导入主机列表

$ curveadm hosts commit hosts.yaml

查看主机列表

$ curveadm hosts ls

准备集群拓扑文件

CurveFS 支持单机部署和高可用部署,这里我们采用单机部署验证。

创建
topology.yaml
文件,只需修改
target: curve
,其他都默认即可。

$ vim topology.yaml

kind: curvefs
global:
  report_usage: true
  data_dir: ${home}/curvefs/data/${service_role}${service_host_sequence}
  log_dir: ${home}/curvefs/logs/${service_role}${service_host_sequence}
  container_image: opencurvedocker/curvefs:v2.4
  variable:
    home: /tmp
    target: curve

etcd_services:
  config:
    listen.ip: ${service_host}
    listen.port: 2380${service_host_sequence}         # 23800,23801,23802
    listen.client_port: 2379${service_host_sequence}  # 23790,23791,23792
  deploy:
    - host: ${target}
    - host: ${target}
    - host: ${target}

mds_services:
  config:
    listen.ip: ${service_host}
    listen.port: 670${service_host_sequence}        # 6700,6701,6702
    listen.dummy_port: 770${service_host_sequence}  # 7700,7701,7702
  deploy:
    - host: ${target}
    - host: ${target}
    - host: ${target}

metaserver_services:
  config:
    listen.ip: ${service_host}
    listen.port: 680${service_host_sequence}           # 6800,6801,6802
    listen.external_port: 780${service_host_sequence}  # 7800,7801,7802
    global.enable_external_server: true
    metaserver.loglevel: 0
    braft.raft_sync: false
  deploy:
    - host: ${target}
    - host: ${target}
    - host: ${target}
      config:
        metaserver.loglevel: 0

部署集群

添加
my-cluster
集群,并指定集群拓扑文件

curveadm cluster add my-cluster -f topology.yaml

切换
my-cluster
集群为当前管理集群

curveadm cluster checkout my-cluster

开始部署集群

$ curveadm deploy
......
Cluster 'my-cluster' successfully deployed ^_^.

终端出现
Cluster 'my-cluster' successfully deployed ^_^.
即部署成功。

查看集群运行情况

$ curveadm status
Get Service Status: [OK]

cluster name      : my-cluster
cluster kind      : curvefs
cluster mds addr  : 192.168.3.81:6700,192.168.3.81:6701,192.168.3.81:6702
cluster mds leader: 192.168.3.81:6702 / 7f5b7443c563

Id            Role        Host   Replicas  Container Id  Status
--            ----        ----   --------  ------------  ------
6ae9ac1ae448  etcd        curve  1/1       d3ecb4e81318  Up 17 minutes
c45e2f0b9266  etcd        curve  1/1       8ce9befa54b8  Up 17 minutes
6c6bde442a04  etcd        curve  1/1       cbf093c6605f  Up 17 minutes
9516d8f5d9ae  mds         curve  1/1       f338ec63c493  Up 17 minutes
fe2bf5d8a072  mds         curve  1/1       b423c3351256  Up 17 minutes
7f5b7443c563  mds         curve  1/1       7ad99cee6b61  Up 17 minutes
e6fe68d23220  metaserver  curve  1/1       d4a8662d4ed2  Up 17 minutes
b2b4dbabd7bf  metaserver  curve  1/1       65d7475e0bc4  Up 17 minutes
426ac76e28f9  metaserver  curve  1/1       f413efeeb5c9  Up 17 minutes

部署 Rainbond

Rainbond
是一个云原生应用管理平台,使用简单,不需要懂容器、Kubernetes和底层复杂技术,支持管理多个Kubernetes集群,和管理企业应用全生命周期。

可以通过一条命令快速安装 Rainbond 单机版。

curl -o install.sh https://get.rainbond.com && bash ./install.sh

执行完上述脚本后,耐心等待 3-5 分钟,可以看到如下日志输出,表示 Rainbond 已启动完成。

INFO: Rainbond started successfully, Please pass http://$EIP:7070 Access Rainbond

部署 MinIO

由于目前 CurveFS 只支持 S3 作为后端存储,CurveBS 后端即将支持。 所以我们需要部署一个 MinIO 对象存储。

通过 Rainbond 开源应用商店一键部署单机版 MinIO 或者集群版 MinIO。进入到 Rainbond 的
平台管理 -> 应用市场
,在开源应用商店中搜索
minio
进行一键安装。

部署完成后,通过 Rainbond 提供的域名访问 MinIO 控制台,默认用户密码
minio/minio123456
。然后需要创建一个 Bucket 供 CurveFS 使用。

部署 CurveFS-CSI

  • 前提:Rainbond 版本要在 v5.13+

通过 Rainbond 开源应用商店一键部署,进入到 Rainbond 的
平台管理 -> 应用市场
,在开源应用商店中搜索
curve-csi
进行一键安装。

由于 CurveFS-CSI 没有 Rainbond 应用模型类的组件,都属于 k8s 资源类型,可在
应用视图内 -> k8s资源
下看到。

安装完成后,需要修改
curvefs-csi-cluster-role-binding

curvefs-csi-role-binding
的 namespace 为当前团队的 namespace,如当前团队 namespace 为
dev
,如下:

# curvefs-csi-role-binding
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: curvefs-csi-role-binding
......
subjects:
- kind: ServiceAccount
  name: curvefs-csi-service-account
  namespace: dev # changed

# curvefs-csi-cluster-role-binding
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: curvefs-csi-cluster-role-binding
......
subjects:
- kind: ServiceAccount
  name: curvefs-csi-service-account
  namespace: dev # changed

创建
storageclass
资源,同样在
应用视图内 -> k8s资源 -> 添加

kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
  name: curvefs-sc
provisioner: csi.curvefs.com
allowVolumeExpansion: false
reclaimPolicy: Delete
parameters:
  mdsAddr: "172.31.98.243:6700,172.31.98.243:6701,172.31.98.243:6702"
  fsType: "s3"
  s3Endpoint: "http://9000.grda6567.1frt0lmq.b836cf.grapps.cn"
  s3AccessKey: "minio"
  s3SecretKey: "minio123456"
  s3Bucket: "curve"
  • mdsAddr:通过
    curveadm status
    命令获取。

    $ curveadm status
    ......
    cluster mds addr  : 172.31.98.243:6700,172.31.98.243:6701,172.31.98.243:6702
    
  • s3Endpoint:填写 MinIO 组件的 9000 端口对外服务域名。

  • s3AccessKey:MinIO 访问 Key,填 root 用户或生成 AccessKey。

  • s3SecretKey:MinIO 密钥 Key,填 root 密码或生成 SecretKey。

  • s3Bucket:MinIO 桶名称。

在 Rainbond 上使用 CurveFS

通过镜像创建一个 Nginx 组件,在
组件 -> 其他设置
修改组件部署类型为
有状态服务
。在 Rainbond 上只有 有状态服务 可以使用自定义存储,无状态服务使用默认的共享存储。

进入到
组件 -> 存储
添加存储,选择类型为
curvefs-sc
,保存并重启组件。

等待组件启动完成后,进入组件的 Web 终端内,测试写入数据。

然后进入到 MinIO 桶内查看,数据已写入。

未来规划

Rainbond 社区未来会使用 Curve 云原生存储作为 Rainbond 底层的共享存储,为用户提供更好、更简单的云原生应用管理平台和云原生存储,共同推进开源社区生态以及给用户提供一体化的解决方案。

桌面应用自动化WinAppDriver入门

关于winappdriver

介绍

  • WinAppDriver全称是Windows Application Driver,它提供了一些API,使得用户可以像selenium操作web一样来操作windows的应用程序
  • 它支持的系统是
    Windows 10
    (Home and Pro) 和
    Windows Server 2016
  • 源码暂未开源
  • WinAppDriver可以独立运行,也可以作为appium的一个插件来使用

支持应用类型

  • UWP
    – Universal Windows Platform, also known as Universal Apps or Modern Apps, It's Microsoft’s latest desktop application technology. It's XAML based. Only runs on Windows 10 machines

  • WPF
    - also XAML based, much more mature, runs on any Windows version and has been around since 2006.

  • WinForms -
    one of the older technologies, now found mostly on legacy applications.

    WPF和WinForms 是两套界面渲染方式。一个是对传统windows界面元素的封装,通过gdi绘制。另一个是全新的dx渲染绘制的界面,也脱离了对传统windows控件的依赖,没有历史包袱,理论上可以展现更炫酷的界面。
    
  • MFC/Classic Windows -
    MFC is a UI library normally paired with Win32 applications. This option is normally chosen when more efficiency is needed with low-level C++ handling or when supporting non-Microsoft platforms.

资源

素材 地址 说明
FlaUInspect https://github.com/FlaUI/FlaUInspect/releases 定位工具
WinAppDriver https://github.com/microsoft/WinAppDriver/releases/tag/v1.2.1
UIRecorder https://github.com/microsoft/WinAppDriver/tree/master/Tools/UIRecorder 定位工具
inspect 微软官方工具集成于 Windows SDK 定位工具
  1. UIRecorder(下文不涉及,仅供参考与备忘)
  1. Open
    WinAppDriverUIRecorder.sln
    in Visual Studio
  2. Select
    Debug
    >
    Start Debugging
    or simply
    Run

支持的定位方式

Client API Locator Strategy Matched Attribute in inspect.exe Example
FindElementByAccessibilityId accessibility id AutomationId AppNameTitle
FindElementByClassName class name ClassName TextBlock
FindElementById id RuntimeId (decimal) 42.333896.3.1
FindElementByName name Name Calculator
FindElementByTagName tag name LocalizedControlType (upper camel case) Text
FindElementByXPath xpath Any //Button[0]

配置

开启windows的开发者模式

  • 你没看错,不是手机,windows也有
  • 第一步:搜开发者设置
  • 第二步:打开开发人员模式

  • 第三步:确认启用

启动winappdriver

  • 不开启开发人员模式的提示

    C:\Program Files (x86)\Windows Application Driver>WinAppDriver.exe
    Developer mode is not enabled. Enable it through Settings and restart Windows Application Driver
    Failed to initialize: 0x80004005
    
  • 开启后启动winappdriver

    C:\Program Files (x86)\Windows Application Driver>WinAppDriver.exe
    Windows Application Driver listening for requests at: http://127.0.0.1:4723/
    Press ENTER to exit.
    
  • 还可以这样启动

    WinAppDriver.exe 4727
    WinAppDriver.exe 10.0.0.10 4725
    WinAppDriver.exe 10.0.0.10 4723/wd/hub   # 推荐
    

实例

appium-python-client 版本不要用2.0+,此处是1.2.0

记事本

  • 比如记事本

    from appium import webdriver
    des_cap = {}
    des_cap['app'] = r'C:\Windows\System32\notepad.exe'
    driver = webdriver.Remote(command_executor='http://127.0.0.1:4723/wd/hub',
                              desired_capabilities=des_cap)
    driver.implicitly_wait(5)
    driver.find_element_by_name('文件(F)').click()
    from time import sleep
    sleep(2)
    driver.find_element_by_name('保存(S)	Ctrl+S').click()
    # driver.find_element_by_name('退出(X)').click()
    sleep(1)
    import pyautogui
    pyautogui.PAUSE = 0.5
    pyautogui.typewrite(r'D:\hello.txt')
    pyautogui.press('enter')
    
  • 这里的难点是
    保存(S) Ctrl+S
    的获取

  • 这里需要用到inspect.exe

计算器

  • 你可能会写这样的代码
from appium import webdriver
des_cap = {}
des_cap['app'] = r'C:\Windows\System32\calc.exe'
driver = webdriver.Remote(command_executor='http://127.0.0.1:4723/wd/hub',
                          desired_capabilities=des_cap)
driver.implicitly_wait(5)
  • 但会报错
Traceback (most recent call last):
  File "D:/demo_calc.py", line 5, in <module>
    desired_capabilities=des_cap)
  File "D:\Python37\lib\site-packages\appium\webdriver\webdriver.py", line 157, in __init__
    AppiumConnection(command_executor, keep_alive=keep_alive), desired_capabilities, browser_profile, proxy
  File "D:\Python37\lib\site-packages\selenium\webdriver\remote\webdriver.py", line 157, in __init__
    self.start_session(capabilities, browser_profile)
  File "D:\Python37\lib\site-packages\appium\webdriver\webdriver.py", line 226, in start_session
    response = self.execute(RemoteCommand.NEW_SESSION, parameters)
  File "D:\Python37\lib\site-packages\selenium\webdriver\remote\webdriver.py", line 321, in execute
    self.error_handler.check_response(response)
  File "D:\Python37\lib\site-packages\selenium\webdriver\remote\errorhandler.py", line 242, in check_response
    raise exception_class(message, screen, stacktrace)
selenium.common.exceptions.WebDriverException: Message: Failed to locate opened application window with appId: C:\Windows\System32\calc.exe, and processId: 4472

进程已结束,退出代码为 1

  • 打开计算器,然后在powershell中执行如下命令
Get-StartApps |Select-String "计算器"
# 输出
@{Name=计算器; AppID=Microsoft.WindowsCalculator_8wekyb3d8bbwe!App} # 你要的是这里的AppID
  • 代码
from appium import webdriver
des_cap = {}
des_cap['app'] = r'Microsoft.WindowsCalculator_8wekyb3d8bbwe!App'
driver = webdriver.Remote(command_executor='http://127.0.0.1:4723/wd/hub',
                          desired_capabilities=des_cap)
driver.implicitly_wait(5)
driver.find_element_by_name('一').click()
driver.find_element_by_name('二').click()
driver.find_element_by_name('加').click()
driver.find_element_by_name('三').click()
driver.find_element_by_name('四').click()
driver.find_element_by_name('等于').click()
# 通过inspect 获取 automationID 
print(driver.find_element_by_accessibility_id('CalculatorResults').text) # 得到的是   ·显示为 46·  你仍然要处理才能做测试

driver.quit()
  • 就写这么多了,你可以结合你的应用来尝试,封装。我就带到这里。

计算器测试(官网)

我没跑,仅供参考,你可以认为是为了增加篇幅

# https://raw.githubusercontent.com/microsoft/WinAppDriver/master/Samples/Python/calculatortest.py
import unittest
from appium import webdriver

class SimpleCalculatorTests(unittest.TestCase):

    @classmethod

    def setUpClass(self):
        #set up appium
        desired_caps = {}
        desired_caps["app"] = "Microsoft.WindowsCalculator_8wekyb3d8bbwe!App"
        self.driver = webdriver.Remote(
            command_executor='http://127.0.0.1:4723',
            desired_capabilities= desired_caps)

    @classmethod
    def tearDownClass(self):
        self.driver.quit()

    def getresults(self):
        displaytext = self.driver.find_element_by_accessibility_id("CalculatorResults").text
        displaytext = displaytext.strip("Display is " )
        displaytext = displaytext.rstrip(' ')
        displaytext = displaytext.lstrip(' ')
        return displaytext


    def test_initialize(self):
        self.driver.find_element_by_name("Clear").click()
        self.driver.find_element_by_name("Seven").click()
        self.assertEqual(self.getresults(),"7")
        self.driver.find_element_by_name("Clear").click()

    def test_addition(self):
        self.driver.find_element_by_name("One").click()
        self.driver.find_element_by_name("Plus").click()
        self.driver.find_element_by_name("Seven").click()
        self.driver.find_element_by_name("Equals").click()
        self.assertEqual(self.getresults(),"8")

    def test_combination(self):
        self.driver.find_element_by_name("Seven").click()
        self.driver.find_element_by_name("Multiply by").click()
        self.driver.find_element_by_name("Nine").click()
        self.driver.find_element_by_name("Plus").click()
        self.driver.find_element_by_name("One").click()
        self.driver.find_element_by_name("Equals").click()
        self.driver.find_element_by_name("Divide by").click()
        self.driver.find_element_by_name("Eight").click()
        self.driver.find_element_by_name("Equals").click()
        self.assertEqual(self.getresults(),"8")

    def test_division(self):
        self.driver.find_element_by_name("Eight").click()
        self.driver.find_element_by_name("Eight").click()
        self.driver.find_element_by_name("Divide by").click()
        self.driver.find_element_by_name("One").click()
        self.driver.find_element_by_name("One").click()
        self.driver.find_element_by_name("Equals").click()
        self.assertEqual(self.getresults(),"8")

    def test_multiplication(self):
        self.driver.find_element_by_name("Nine").click()
        self.driver.find_element_by_name("Multiply by").click()
        self.driver.find_element_by_name("Nine").click()
        self.driver.find_element_by_name("Equals").click()
        self.assertEqual(self.getresults(),"81") 

    def test_subtraction(self):
        self.driver.find_element_by_name("Nine").click()
        self.driver.find_element_by_name("Minus").click()
        self.driver.find_element_by_name("One").click()
        self.driver.find_element_by_name("Equals").click()
        self.assertEqual(self.getresults(),"8")

if __name__ == '__main__':
    suite = unittest.TestLoader().loadTestsFromTestCase(SimpleCalculatorTests)
    unittest.TextTestRunner(verbosity=2).run(suite)

Stanford CS 144, Lab 0: networking warmup


>>> lsb_release -a // 运行环境展示
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 22.04 LTS
Release:        22.04
Codename:       jammy

>>> g++ -v
gcc version 8.4.0 (Ubuntu 8.4.0-3ubuntu2) 

2 Networking by hand

2.1 Fetch a Web page

visit
http://cs144.keithw.org/hello
and observe the result:

observeWebPage

Now, we want to do same things by our hand.

  1. telnet cs144.keithw.org http
    This tells the telnet program to open a reliable byte stream between your computer and another computer (named
    \(\texttt{cs144.keithw.org}\)
    ), and with a particular service running on that computer: the “http” service, for the Hyper-Text Transfer Protocol, used by the World Wide Web. Then, i saw the output in the terminal:

    >>> user$ telnet cs144.keithw.org http
    Trying 104.196.238.229...
    Connected to cs144.keithw.org.
    Escape character is '^]'.
    Connection closed by foreign host.
    

    Telnet是一种网络协议,用于远程登录到计算机或其他设备上并在其上执行命令。通过Telnet,用户可以通过网络连接到远程主机并像本地主机一样进行命令行操作。在Linux系统中,Telnet客户端程序和服务器程序可以使用telnet命令来启动和连接。

    Telnet协议使用客户端/服务器模型。Telnet客户端向Telnet服务器建立连接并提供凭据以进行身份验证。一旦连接建立,Telnet客户端可以像使用本地终端一样在远程系统上运行命令。

    尽管Telnet协议在过去很受欢迎,但由于其不安全性,现在已被SSH协议取代。因为Telnet协议在传输数据时未加密,所以可能会泄露用户的敏感信息(例如用户名和密码)。SSH协议提供了加密和身份验证功能,可以更安全地远程连接到Linux系统。

  2. Type
    GET /hello HTTP/1.1 ⏎

  3. Type
    Host: cs144.keithw.org ⏎

  4. Type
    Connection: close ⏎

  5. Hit the Enter key one more times:

    This sends an empty line and tells the server that you are done with your HTTP request.

  6. If all went well, you will see the same response that your browser saw, preceded by HTTP
    headers
    that tell the browser how to interpret(explain) the response.

>>> user$ telnet cs144.keithw.org http
Trying 104.196.238.229...
Connected to cs144.keithw.org.
Escape character is '^]'.
GET /hello HTTP/1.1
Host: cs144.keithw.org
Connection: close

HTTP/1.1 200 OK
Date: Mon, 20 Mar 2023 11:23:14 GMT
Server: Apache
Last-Modified: Thu, 13 Dec 2018 15:45:29 GMT
ETag: "e-57ce93446cb64"
Accept-Ranges: bytes
Content-Length: 14
Connection: close
Content-Type: text/plain

Hello, CS144!
Connection closed by foreign host.

Then, we'll explain the meaning of each step.

GET /hello HTTP/1.1
This tells the server the
path
part of the URL(The starting with the third slash, like:
http://cs144.keithw.org/hello
.)

这段命令是 HTTP 协议中客户端向服务器发送 HTTP 请求的一部分,它由三部分组成:

  1. 请求方法(Request Method):在这里是 GET。它指定了客户端请求的动作类型,常见的方法有 GET、POST、PUT、DELETE 等。
  2. 请求 URI(Uniform Resource Identifier):在这里是 /hello。它指定了客户端要请求的资源的位置,URI 由路径和查询参数组成。例如,在这个例子中,URI 是 /hello,表示客户端请求位于服务器根目录下的名为 hello 的资源。
  3. 协议版本(Protocol Version):在这里是 HTTP/1.1。它指定了客户端使用的 HTTP 协议版本。在 HTTP/1.1 中,客户端和服务器之间的通信是持久连接的,这意味着客户端可以在同一连接上发送多个请求,并且服务器可以在同一连接上返回多个响应。

因此,GET /hello HTTP/1.1 这个命令的意思是客户端使用 HTTP/1.1 协议,向服务器发送一个 GET 请求,请求服务器位于根目录下的名为 hello 的资源。服务器在收到请求后将返回相应的响应,包括状态码、头部信息和响应内容等。

Host: cs144.keithw.org
This tells the server the host part of the URL. (The part between
http://
and the third slash.)

这段命令是HTTP请求中的一个头部信息(header),用于指定客户端请求的目标服务器。

在这个例子中,Host: cs144.keithw.org 指定了客户端要请求的服务器主机名为 cs144.keithw.org。HTTP/1.1 引入了“虚拟主机”(Virtual Host)的概念,使得多个域名可以共享同一个IP地址,并根据 Host 头部信息将请求路由到正确的服务器。因此,Host 头部信息对于客户端请求的处理非常重要。

除了 Host 头部信息,HTTP请求还可以包含许多其他头部信息,用于传递关于客户端、请求内容、请求处理方式和请求接受格式等方面的信息。这些头部信息通常使用“键值对”的形式表示,例如“Content-Type: application/json”表示请求中包含的数据类型为JSON格式。

Connection: close
This tells the server that you are finished making requests, and it should close the connection as soon as if finishes replying.

这段命令是HTTP请求中的一个头部信息(header),用于指定客户端和服务器之间的连接类型。

在这个例子中,Connection: close 指定了客户端和服务器之间的连接类型为“关闭连接”。这意味着,在客户端发送完请求并收到服务器的响应后,连接将被立即关闭,而不是保持打开状态以等待其他请求。这种连接类型称为“短连接”(short-lived connection)。

在HTTP/1.1中,默认情况下,客户端和服务器之间的连接类型为“持久连接”(persistent connection),也称为“长连接”(long-lived connection)。这意味着客户端可以在同一连接上发送多个请求,并且服务器可以在同一连接上返回多个响应。在这种情况下,Connection头部信息应设置为“Connection: keep-alive”。

在HTTP/2中,连接类型默认为“持久连接”,而不需要显式指定 Connection头部信息。

因此,Connection头部信息用于指定客户端和服务器之间的连接类型,通常包括“关闭连接”和“保持连接”两种类型。它对于HTTP请求和响应的处理和性能优化非常重要。

Assignment
:

>>> user$ telnet cs144.keithw.org http
Trying 104.196.238.229...
Connected to cs144.keithw.org.
Escape character is '^]'.
GET /lab0/sunetid HTTP/1.1
Host: cs144.keithw.org
Connection: close

HTTP/1.1 200 OK
Date: Mon, 20 Mar 2023 11:59:46 GMT
Server: Apache
X-You-Said-Your-SunetID-Was: sunetid
X-Your-Code-Is: 746452
Content-length: 666666
Vary: Accept-Encoding
Connection: close
Content-Type: text/plain

Hello! You told us that your SUNet ID was "sunetid". Please see the HTTP headers (above) for your secret code.

2.2 Send yourself an email

Now that you know how to fetch a Web page, it’s time to send an email message, again using a reliable byte stream to a service running on another computer. (
Since we don't have a Stanford email, we have to use our own, such as QQ email.
)

QQ 邮箱授权码 tuplkrnwplxtbage


由于我们没有 Stanford 的邮箱

就像 Web Api 接口可以对入参进行验证,避免用户传入非法的或者不符合我们预期的参数一样,选项也可以对配置源的内容进行验证,避免配置中的值与选项类中的属性不对应或者不满足预期,毕竟大部分配置都是通过字符串的方式,验证是很有必要的。

1. 注解验证

像入参验证一样,选项验证也可以通过特性注解方便地对选项类中的某个属性进行验证,这种是最简单便捷的方式。使用选项标签注解验证,需要引入 Microsoft.Extensions.Options.DataAnnotations Nuget 包。

在选项类中通过以下方式添加数据验证规则:

public class BlogOptions
{
	public const string Blog = "Blog";

	[StringLength(10, ErrorMessage = "Title is too long. {0} Length <= {1}")]
	public string Title { get; set; }

	public string Content { get; set; }

	public DateTime CreateTime { get; set; }
}

之后在进行选项类配置的时候就不能直接使用 Configure 方法了,而是要用以下方式:

builder.Services.AddOptions<BlogOptions>()
	.Bind(builder.Configuration.GetSection(BlogOptions.Blog))
	.ValidateDataAnnotations();

2. 自定义验证逻辑

预定义的数据注解毕竟有限,在某些验证逻辑比较复杂的情况下,数据注解可能并不能完全满足我们的需求,我们可以通过 OptionsBuilder
类中的 Validate 方法传入一个委托来实现自己的验证逻辑。

builder.Services.AddOptions<BlogOptions>()
	.Bind(builder.Configuration.GetSection(BlogOptions.Blog))
	.Validate(options =>
	{
		// 标题中不能包含特殊字符
		if (options.Title.Contains("eval"))
		{
			// 验证失败
			return false;
		}
		// 验证通过
		return true;
	});

3. IValidateOptions 验证接口

如果逻辑更加复杂,通过 Validate 方法会导致代码臃肿,不好管理和维护,这时候我们可以通过 IValidateOptions
接口实现相应的选项验证类。

public class BlogValidation : IValidateOptions<BlogOptions>
{
	public ValidateOptionsResult Validate(string name, BlogOptions options)
	{
		var failures = new List<string>();
		if(options.Title.Length > 100)
		{
			failures.Add($"博客标题长度不能超过100个字符。");
		}
		if(options.Content.Length > 10000)
		{
			failures.Add($"博客内容太长,不能超过10000字。");
		}
		if (failures.Any())
		{
			return ValidateOptionsResult.Fail(failures);
		}
		return ValidateOptionsResult.Success;
	}
}

然后将其注入到依赖注入容器中,可以同时注入针对同一个选项类的验证逻辑类,这些验证类都会被调用,只有全部验证逻辑通过才能正常配置。

builder.Services.Configure<BlogOptions>(builder.Configuration.GetSection(BlogOptions.Blog));
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<BlogOptions>, BlogValidation>());



参考文章:
ASP.NET Core 中的选项模式 | Microsoft Learn
选项模式 - .NET | Microsoft Learn
面向 .NET 库创建者的选项模式指南 - .NET | Microsoft Learn
理解ASP.NET Core - 选项(Options)



ASP.NET Core 系列:

目录:
ASP.NET Core 系列总结
上一篇:
ASP.NET Core - 选项系统之选项使用