第310篇:YANG 数据模型基础

关键词:YANG、数据模型、NETCONF、RESTCONF、模型驱动、节点类型、容器、列表、配置数据、状态数据


一、引言

在前几篇中,我们通过CLI命令、SSH连接和文本模板管理网络设备。CLI的本质是"自由文本协议"——不同厂商使用不同的命令语法、不同的输出格式。即便Netmiko和NAPALM做了大量适配工作,底层仍然依赖正则表达式解析文本。

YANG(Yet Another Next Generation)是IETF定义的网络管理数据建模语言(RFC 7950)。它为网络设备的数据(配置和状态)提供了一套标准化的、结构化的描述方式。有了YANG模型,控制器和设备之间交换的不再是"文本字符串",而是"结构化数据"——如同数据库中表与字段的关系,明确、可验证、可编程。

YANG是NETCONF和RESTCONF协议的基石。不理解YANG,就无法深入模型驱动的网络管理。


二、YANG 的历史与定位

2.1 从 NETMOD 到 YANG 1.1

标准 RFC 年份 说明
NETMOD RFC 6020 2010 YANG 1.0前身,奠定了基本概念
YANG 1.0 RFC 6020 2010 初始版本,存在设计缺陷
YANG 1.1 RFC 7950 2016 主流版本,修复1.0问题,新增特性
YANG 1.2 草案阶段 2024+ 增强语义约束

2.2 模型驱动管理架构

模型驱动管理架构 - 图示

2.3 YANG 在协议栈中的位置

层级 协议/模型 职责
数据建模层 YANG 定义数据的结构、类型、约束、关系
数据编码层 XML / JSON 序列化YANG模型中的数据
传输协议层 NETCONF / RESTCONF RPC调用、数据读写的事务机制
传输层 SSH / HTTPS 安全传输通道
链路层 TCP/IP 网络传输

三、YANG 基础语法

3.1 模块结构

module huawei-system {
    yang-version 1.1;
    namespace "urn:huawei:yang:system";
    prefix "hw-sys";

    organization "Huawei Technologies Co., Ltd.";
    contact "support@huawei.com";
    description
        "华为设备系统管理模块";
    revision 2023-06-15 {
        description "初始版本";
        reference "RFC 7950";
    }

    // 容器节点开始
    container system {
        config true;

        leaf hostname {
            type string {
                length "1..63";
            }
            description "设备主机名";
        }

        leaf domain-name {
            type string {
                length "1..255";
            }
            description "域名";
        }
    }
}

3.2 关键字对照

YANG关键字 说明 类比
module YANG模块定义 Python的module
submodule 子模块(拆分大模块) Python的package
container 容器节点(分组) JSON对象 {}
leaf 叶子节点(单值) JSON字段 "key": "value"
leaf-list 叶子列表(多值) JSON数组 ["a", "b"]
list 列表节点(有序重复) JSON数组对象 [{}, {}]
choice 互斥选择 Python的enum
anyxml 任意XML数据 动态字段
rpc 远程过程调用 设备操作(重启、保存等)
notification 异步通知 设备推送事件
uses 引用节点组(复用) Python的import
augment 扩展已有节点 继承与扩展

四、数据类型详解

4.1 内置数据类型

module data-types-demo {
    yang-version 1.1;
    namespace "urn:demo:data-types";
    prefix "dt";

    container demo {
        config true;

        // 基本类型
        leaf name {
            type string;
        }

        leaf age {
            type uint8;
        }

        leaf temperature {
            type decimal64 {
                fraction-digits 2;
            }
        }

        leaf enabled {
            type boolean;
        }

        // 枚举类型
        leaf status {
            type enumeration {
                enum "up";
                enum "down";
                enum "testing";
                enum "dormant";
            }
        }

        // 标识符类型
        leaf protocol {
            type identityref {
                base "routing-protocol";
            }
        }

        identity routing-protocol {
            description "路由协议基类";
        }

        identity ospf {
            base "routing-protocol";
        }

        identity isis {
            base "routing-protocol";
        }

        identity bgp {
            base "routing-protocol";
        }
    }
}

4.2 约束修饰符

leaf interface-mtu {
    type uint16 {
        range "68..9216";          // 数值范围
    }
    default 1500;                  // 默认值
    mandatory true;                // 是否必须
    description "接口MTU值";
}

leaf description {
    type string {
        length "0..223";           // 字符串长度
        pattern '[a-zA-Z0-9 _-]+'; // 正则表达式
    }
    description "接口描述";
}

leaf-list allowed-vlans {
    type uint16 {
        range "1..4094";
    }
    min-elements 1;                // 最少元素
    max-elements 4094;             // 最多元素
    ordered-by user;               // 由用户维护顺序
    description "Trunk允许通过的VLAN列表";
}

五、容器、列表与复杂结构

5.1 IETF Interfaces 标准模型

module ietf-interfaces {
    yang-version 1.1;
    namespace "urn:ietf:params:xml:ns:yang:ietf-interfaces";
    prefix "if";

    import ietf-yang-types {
        prefix "yang";
    }

    // 顶层容器:所有接口
    container interfaces {
        // 接口列表(可重复)
        list interface {
            key "name";     // 列表的key

            leaf name {
                type string;
                description "接口名称";
            }

            leaf description {
                type string;
                description "接口描述";
            }

            leaf type {
                type identityref {
                    base "if:interface-type";
                }
                mandatory true;
            }

            leaf enabled {
                type boolean;
                default "true";
                description "接口管理状态";
            }

            leaf admin-status {
                type enumeration {
                    enum up;
                    enum down;
                    enum testing;
                }
            }

            leaf oper-status {
                type enumeration {
                    enum up;
                    enum down;
                    enum testing;
                    enum unknown;
                }
                config false;   // 状态数据,不可配置
            }

            leaf mtu {
                type uint16;
                description "接口MTU";
            }

            leaf if-index {
                type int32;
                config false;   // 只读状态
            }

            leaf phys-address {
                type yang:phys-address;
                config false;   // MAC地址,只读
            }

            leaf-list supported-statistic-types {
                type identityref {
                    base "if:statistic-type";
                }
                config false;
            }

            // 嵌套容器:子接口
            container subinterfaces {
                list subinterface {
                    key "index";

                    leaf index {
                        type uint32;
                    }

                    leaf ipv4-address {
                        type yang:dotted-quad;
                    }
                }
            }

            // 统计信息(只读)
            container statistics {
                config false;

                leaf in-octets {
                    type yang:counter32;
                }

                leaf out-octets {
                    type yang:counter32;
                }

                leaf in-unicast-pkts {
                    type yang:counter32;
                }

                leaf in-discards {
                    type yang:counter32;
                }
            }
        }
    }
}

5.2 对应的 JSON 数据

上述YANG模型对应的一台设备接口数据的JSON表示:

{
  "ietf-interfaces:interfaces": {
    "interface": [
      {
        "name": "GigabitEthernet0/0/1",
        "description": "To-Core-SW-02",
        "type": "iana-if-type:ethernetCsmacd",
        "enabled": true,
        "admin-status": "up",
        "oper-status": "up",
        "mtu": 9216,
        "if-index": 1,
        "phys-address": "00:1e:10:aa:bb:cc",
        "statistics": {
          "in-octets": 1234567890,
          "out-octets": 987654321,
          "in-unicast-pkts": 1234567,
          "in-discards": 0
        }
      },
      {
        "name": "GigabitEthernet0/0/2",
        "description": "To-Core-SW-03",
        "type": "iana-if-type:ethernetCsmacd",
        "enabled": true,
        "admin-status": "up",
        "oper-status": "up",
        "mtu": 9216,
        "if-index": 2,
        "phys-address": "00:1e:10:dd:ee:ff",
        "statistics": {
          "in-octets": 2345678901,
          "out-octets": 876543210,
          "in-unicast-pkts": 2345678,
          "in-discards": 3
        }
      }
    ]
  }
}

5.3 config true vs config false

这是YANG模型中最重要的概念之一:

属性 config true config false
语义 配置数据 状态数据
可写 ✅ 可通过PUT/POST修改 ❌ 只读
可删 ✅ 可通过DELETE移除 ❌ 由设备维护
持久化 保存到启动配置 不保存(设备运行时生成)
示例 hostname、VLAN、接口IP 接口Up/Down状态、流量统计、BGP会话状态
container bgp {
    // 配置数据:管理员可以修改
    config true;

    leaf local-as {
        type uint32;
    }

    leaf router-id {
        type yang:dotted-quad;
    }

    // 嵌套的状态数据
    container neighbors {
        config false;   // 状态数据

        list neighbor {
            key "address";

            leaf address {
                type yang:dotted-quad;
            }

            leaf state {
                type enumeration {
                    enum idle;
                    enum connect;
                    enum active;
                    enum opensent;
                    enum openconfirm;
                    enum established;
                }
            }

            leaf uptime {
                type yang:timeticks;
            }

            leaf messages-received {
                type yang:counter32;
            }
        }
    }
}

六、RPC 操作与 Notification

6.1 RPC(远程过程调用)

// 定义RPC:保存配置
rpc save-configuration {
    description "将运行配置保存为启动配置";

    input {
        leaf copy-run-to-startup {
            type boolean;
            default true;
            description "是否将running-config保存为startup-config";
        }
    }

    output {
        leaf success {
            type boolean;
        }

        leaf message {
            type string;
        }
    }
}

// 定义RPC:重启设备
rpc reboot-device {
    description "重启网络设备";

    input {
        leaf delay-seconds {
            type uint16;
            default 60;
            description "延迟重启时间(秒)";
        }

        leaf comment {
            type string;
            description "重启原因说明";
        }
    }

    output {
        leaf scheduled {
            type boolean;
        }
    }
}

6.2 Notification(异步通知)

// 定义通知:接口状态变化
notification interface-status-change {
    description "接口状态发生变化时推送";

    leaf interface-name {
        type string;
    }

    leaf previous-state {
        type enumeration {
            enum up;
            enum down;
        }
    }

    leaf new-state {
        type enumeration {
            enum up;
            enum down;
        }
    }

    leaf timestamp {
        type yang:date-and-time;
    }
}

// 定义通知:BGP会话状态变化
notification bgp-session-change {
    description "BGP邻居会话状态变化";

    leaf neighbor-ip {
        type yang:dotted-quad;
    }

    leaf previous-state {
        type string;
    }

    leaf new-state {
        type string;
    }

    leaf reason {
        type string;
    }

    leaf timestamp {
        type yang:date-and-time;
    }
}

七、YANG 工具链

7.1 YANG 验证与转换工具

# pyang:最流行的YANG验证工具
pip install pyang

# 验证YANG文件语法
pyang --lint my_model.yang

# 转换为XML Schema
pyang -f rfc my_model.yang > my_model_rfc.txt

# 生成Python类
pip install yangson
pyang -f tree my_model.yang

# 查看树形结构
pyang -f tree ietf-interfaces.yang
# 输出:
# +--rw interfaces
#    +--rw interface* [name]
#       +--rw name?               string
#       +--rw description?        string
#       +--rw type?               identityref
#       +--rw enabled?            boolean
#       +--ro oper-status?        enumeration
#       +--ro statistics
#          +--ro in-octets?       counter32
#          +--ro out-octets?      counter32

7.2 YANG 在线资源

资源 地址 说明
IETF YANG Registry https://datatracker.ietf.org/wg/netmod/documents/ 官方标准模型库
Open YANG Models https://github.com/YangModels/yang 社区贡献的厂商模型
pyang https://github.com/mbj4668/pyang YANG验证工具
YANG Catalog https://yangcatalog.org/ YANG模型在线目录

八、网络自动化测试框架与验证脚本

8.1 测试框架架构

测试框架架构 - 图示

8.2 YANG 模型单元测试

# tests/test_yang_models.py
import pytest
import subprocess
import yaml

# 测试YANG模型语法正确性
def test_yang_syntax_valid(yang_file):
    """使用pyang验证YANG文件语法"""
    result = subprocess.run(
        ["pyang", "--lint", yang_file],
        capture_output=True,
        text=True
    )
    assert result.returncode == 0, f"YANG语法错误: {result.stderr}"

def test_yang_no_warnings(yang_file):
    """检查YANG模型无警告"""
    result = subprocess.run(
        ["pyang", "--lint", "--ignore-errors", yang_file],
        capture_output=True,
        text=True
    )
    warnings = [line for line in result.stdout.split('\n')
                if 'warning' in line.lower()]
    assert len(warnings) == 0, f"存在警告: {warnings}"

# 参数化测试:验证多个YANG文件
yang_files = [
    "models/ietf-interfaces.yang",
    "models/huawei-system.yang",
    "models/custom-vlan.yang",
]

@pytest.mark.parametrize("yang_file", yang_files)
def test_all_yang_files(yang_file):
    """批量验证所有YANG文件"""
    test_yang_syntax_valid(yang_file)

# 测试YANG模型树形结构
def test_yang_tree_structure(yang_file):
    """验证YANG模型的树形结构"""
    result = subprocess.run(
        ["pyang", "-f", "tree", yang_file],
        capture_output=True,
        text=True
    )
    tree_output = result.stdout
    # 验证树中包含预期的节点
    assert "container" in tree_output, "缺少container节点"
    assert "leaf" in tree_output, "缺少leaf节点"

8.3 NETCONF 集成测试

# tests/test_netconf_operations.py
import pytest
import ncclient

@pytest.fixture
def netconf_session():
    """NETCONF会话fixture"""
    options = {
        "host": "192.168.1.1",
        "port": 830,
        "username": "admin",
        "password": "H@wa123!",
        "hostkey_verify": False,
        "device_params": {"name": "huawei"},
    }
    device_handler = ncclient.manager.get_device_handler(options)
    session = ncclient.manager.connect(
        host=options["host"],
        port=options["port"],
        username=options["username"],
        password=options["password"],
        hostkey_verify=False,
        device_params=options["device_params"],
    )
    yield session
    session.close_channel()

def test_get_config(netconf_session):
    """测试get-config操作"""
    reply = netconf_session.get_config(
        source="running",
        filter=("subtree", "<interfaces xmlns=\"urn:ietf:params:xml:ns:yang:ietf-interfaces\"/>")
    )
    assert reply is not None
    assert b"<interfaces>" in reply.xml

def test_edit_config(netconf_session):
    """测试edit-config操作"""
    config = """
    <config>
        <interfaces xmlns="urn:ietf:params:xml:ns:yang:ietf-interfaces">
            <interface>
                <name>TestInterface</name>
                <description>Test by pytest</description>
                <enabled>true</enabled>
            </interface>
        </interfaces>
    </config>
    """
    reply = netconf_session.edit_config(target="running", config=config)
    assert reply.errors is None or len(reply.errors) == 0

def test_rpc_operation(netconf_session):
    """测试RPC操作"""
    reply = netconf_session.dispatch(
        """<rpc xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="101">
            <get>
                <filter type="subtree">
                    <interfaces xmlns="urn:ietf:params:xml:ns:yang:ietf-interfaces"/>
                </filter>
            </get>
        </rpc>"""
    )
    assert reply is not None

8.4 配置验证脚本

# scripts/config_validator.py
import yaml
import json
import re
from dataclasses import dataclass
from typing import List, Optional

@dataclass
class ValidationResult:
    """验证结果"""
    device: str
    check_name: str
    passed: bool
    message: str
    expected: str = ""
    actual: str = ""

class ConfigValidator:
    """配置验证器"""

    def __init__(self, baseline_file: str):
        with open(baseline_file, 'r', encoding='utf-8') as f:
            self.baseline = yaml.safe_load(f)

    def validate_hostname(self, device_config: dict, hostname: str) -> ValidationResult:
        """验证设备主机名"""
        expected = self.baseline.get("hostname", hostname)
        actual = device_config.get("hostname", "")
        passed = expected == actual
        return ValidationResult(
            device=hostname,
            check_name="hostname",
            passed=passed,
            message=f"主机名{'匹配' if passed else '不匹配'}",
            expected=expected,
            actual=actual
        )

    def validate_vlan_list(self, device_config: dict, hostname: str) -> ValidationResult:
        """验证VLAN列表"""
        expected_vlans = set(self.baseline.get("vlans", []))
        actual_vlans = set(device_config.get("vlans", []))
        missing = expected_vlans - actual_vlans
        extra = actual_vlans - expected_vlans

        passed = not missing and not extra
        message = "VLAN列表匹配"
        if missing:
            message += f", 缺少: {missing}"
        if extra:
            message += f", 多余: {extra}"

        return ValidationResult(
            device=hostname,
            check_name="vlan_list",
            passed=passed,
            message=message,
            expected=str(sorted(expected_vlans)),
            actual=str(sorted(actual_vlans))
        )

    def validate_interface_description(self, device_config: dict,
                                        hostname: str) -> ValidationResult:
        """验证接口描述规范性"""
        pattern = r'^[A-Za-z0-9_\-]+(To-|From-)[A-Za-z0-9_\-]+$'
        interfaces = device_config.get("interfaces", {})

        failed_ifaces = []
        for iface_name, iface_data in interfaces.items():
            desc = iface_data.get("description", "")
            if desc and not re.match(pattern, desc):
                failed_ifaces.append(f"{iface_name}: {desc}")

        passed = not failed_ifaces
        return ValidationResult(
            device=hostname,
            check_name="interface_description",
            passed=passed,
            message=f"接口描述{'全部' if passed else '部分'}规范",
            actual="; ".join(failed_ifaces) if failed_ifaces else ""
        )

    def run_all_validations(self, device_configs: dict) -> List[ValidationResult]:
        """运行所有验证"""
        results = []
        for hostname, config in device_configs.items():
            results.append(self.validate_hostname(config, hostname))
            results.append(self.validate_vlan_list(config, hostname))
            results.append(self.validate_interface_description(config, hostname))
        return results

    def generate_report(self, results: List[ValidationResult]) -> str:
        """生成验证报告"""
        total = len(results)
        passed = sum(1 for r in results if r.passed)
        failed = total - passed

        report = f"""
=== 配置验证报告 ===
总检查项: {total}
通过: {passed}
失败: {failed}
通过率: {passed/total*100:.1f}%

"""
        for r in results:
            status = "✓" if r.passed else "✗"
            report += f"{status} [{r.device}] {r.check_name}: {r.message}\n"
            if not r.passed and r.actual:
                report += f"    期望: {r.expected}\n"
                report += f"    实际: {r.actual}\n"

        return report

8.5 pytest 测试组织

# pytest.ini
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
markers =
    unit: 单元测试标记
    integration: 集成测试标记
    slow: 耗时测试标记
    gnmi: gNMI相关测试
    netconf: NETCONF相关测试
addopts = -v --tb=short
# tests/conftest.py
import pytest
import yaml

@pytest.fixture
def test_devices():
    """测试设备清单fixture"""
    with open("tests/fixtures/test_devices.yaml", 'r') as f:
        return yaml.safe_load(f)

@pytest.fixture
def baseline_config():
    """基线配置fixture"""
    with open("tests/fixtures/baseline.yaml", 'r') as f:
        return yaml.safe_load(f)

@pytest.fixture
def sample_yang_models():
    """示例YANG模型fixture"""
    return [
        "models/test-iface.yang",
        "models/test-system.yang",
    ]

九、总结

8.1 故障现象

使用NETCONF从控制器获取华为CE6865的接口信息时,设备返回bad-element错误:

<rpc-error>
    <error-type>protocol</error-type>
    <error-tag>bad-element</error-tag>
    <error-path>/interfaces/interface[name='GE0/0/1']/hw-if:if-extender</error-path>
    <error-message>Unknown element</error-message>
</rpc-error>

8.2 排查过程

步骤1:检查控制器使用的YANG模型版本 控制器使用 ietf-interfaces@2014-05-08(旧版)

步骤2:检查设备支持的YANG模型

<!-- 通过 get-schema 获取设备支持的模型 -->
<rpc message-id="101">
    <get-schema xmlns="urn:ietf:params:xml:ns:yang:ietf-yang-library">
        <yang-module>ietf-interfaces</yang-module>
    </get-schema>
</rpc>

<!-- 设备返回: ietf-interfaces@2018-02-20(新版) -->

步骤3:对比新旧版本的差异

特性 2014-05-08 2018-02-20
admin-status 存在 移除(合并到enabled)
oper-status 存在 存在
phys-address if:phys-address 移至子模块
统计计数器 counter32 新增counter64

8.3 根因与解决

控制器的NETCONF请求中包含了旧版模型特有的if-extender扩展节点,而设备的新版YANG模型已移除此节点。

解决方案: 1. 更新控制器的YANG模型库至2018-02-20版本 2. 在控制器中移除对if-extender节点的引用 3. 建立YANG模型版本管理机制,确保控制器与设备模型版本对齐


九、总结

知识点 要点
YANG定位 IETF标准数据建模语言,NETCONF/RESTCONF的数据层
核心节点 container(分组)、leaf(单值)、list(列表)、leaf-list(多值)
数据类型 string、int、boolean、enumeration、identityref、union等
约束 range、length、pattern、min/max-elements、mandatory、default
config语义 config true=配置数据(可读写),config false=状态数据(只读)
RPC 定义设备操作(保存、重启等),有input/output
Notification 设备主动推送事件(接口变化、BGP状态变化)
工具链 pyang验证、yangcatalog查模型、Open YANG Models社区库

思考题

  1. YANG模型中list和leaf-list的本质区别是什么?如果用一个列表存储接口名,另一个存储VLAN ID列表,分别该用哪种?
  2. config true和config false的划分对NETCONF的操作(get-config vs get)有什么影响?为什么get-config拿不到状态数据?
  3. 设计一个YANG模型来管理VLAN:需包含VLAN ID(1-4094)、名称(最长32字符)、描述、成员接口列表,且VLAN ID作为列表key。

下篇预告

第311篇:NETCONF与RESTCONF协议。学习如何通过NETCONF(XML+SSH)和RESTCONF(JSON+HTTPS)读写YANG模型数据,实现真正的模型驱动网络管理。