MyBatis3源码深度解析(九)MyBatis常用工具类(二)ScriptRunnerSqlRunner

文章目录

    • 3.2 使用ScriptRunner执行脚本
      • 3.2.1 ScriptRunner工具类简介
      • 3.2.2 ScriptRunner工具类示例
      • 3.2.3 ScriptRunner工具类源码
    • 3.3 使用SqlRunner操作数据库
      • 3.3.1 SqlRunner工具类简介
      • 3.3.2 SqlRunner工具类示例
      • 3.3.3 SqlRunner工具类源码解读

3.2 使用ScriptRunner执行脚本

3.2.1 ScriptRunner工具类简介

ScriptRunner是MyBatis提供的读取脚本文件中的SQL语句并执行的工具类。

它提供了一些属性,用于控制执行SQL脚本的一些行为:

源码1:org/apache/ibatis/jdbc/ScriptRunner.java

public class ScriptRunner {
    // 执行脚本遇到异常时是否中断执行
    private boolean stopOnError;
    // 是否抛出SQLWarning警告
    private boolean throwWarning;
    // 是否自动提交
    private boolean autoCommit;
    // 属性为true时,批量执行文件中的SQL语句
    // 属性为false时,逐条执行文件中的SQL语句
    private boolean sendFullScript;
    // 是否取出windows系统换行符中的 \r
    private boolean removeCRs;
    // 设置Statement属性是否支持转义处理
    private boolean escapeProcessing = true;
    // 日志输出位置,默认是标准输入输出,即控制台
    private PrintWriter logWriter = new PrintWriter(System.out);
    // 错误日志输出位置,默认是标准输入输出,即控制台
    private PrintWriter errorLogWriter = new PrintWriter(System.err);
    // 脚本文件中SQL语句的分隔符,默认是分号
    private String delimiter = DEFAULT_DELIMITER;
    // 是否支持SQL语句分割符,单独占一行
    private boolean fullLineDelimiter;
    // ......
}

3.2.2 ScriptRunner工具类示例

  1. 编写脚本文件 insert-data.sql ,并放在resources目录下:
insert into user (name, age, phone, birthday) values('U1', 15, '18705464523', '2024-03-09');
insert into user (name, age, phone, birthday) values('U2', 16, '18705464523', '2024-03-10');
insert into user (name, age, phone, birthday) values('U3', 17, '18705464523', '2024-03-11');
insert into user (name, age, phone, birthday) values('U4', 18, '18705464523', '2024-03-12');
insert into user (name, age, phone, birthday) values('U5', 19, '18705464523', '2024-03-13');
  1. 编写测试代码:
@Test
public void testScriptRunner() {
    try {
        Connection connection = DbUtils.getConnection();
        ScriptRunner scriptRunner = new ScriptRunner(connection);
        scriptRunner.runScript(Resources.getResourceAsReader("insert-data.sql"));
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}
  1. 执行测试代码,控制台打印了5条SQL语句,数据库插入了这5条数据:


如示例代码所示,ScriptRunner工具类的构造方法需要需要一个Connection对象作为参数。创建ScriptRunner对象后,调用该对象的runScript()方法,传入一个SQL脚本文件的Reader对象,即可执行脚本文件中的SQL语句。

3.2.3 ScriptRunner工具类源码

源码2:org/apache/ibatis/jdbc/ScriptRunner.java

public void runScript(Reader reader) {
    // 设置事务是否自动提交
    setAutoCommit();
    try {
        if (sendFullScript) {
            // 批量执行文件中的SQL语句
            executeFullScript(reader);
        } else {
            // 逐条执行文件中的SQL语句
            executeLineByLine(reader);
        }
    } finally {
        rollbackConnection();
    }
}

由 源码2 可知,runScript()方法首先会调用setAutoCommit方法使事务的自动提交配置生效,实际上就是将autoCommit属性的值设置到Connection对象中。

该方法接下来根据sendFullScript属性的值,判断出是批量执行文件中的SQL语句,还是逐条执行文件中的SQL语句。

源码3:org/apache/ibatis/jdbc/ScriptRunner.java

private void executeFullScript(Reader reader) {
    StringBuilder script = new StringBuilder();
    try {
        // 逐行读取脚本文件的SQL语句,并以LINE_SEPARATOR拼接
        BufferedReader lineReader = new BufferedReader(reader);
        String line;
        while ((line = lineReader.readLine()) != null) {
            script.append(line);
            script.append(LINE_SEPARATOR);
        }
        String command = script.toString();
        println(command);
        // 执行拼接起来的SQL语句
        executeStatement(command);
        // 提交事务
        commitConnection();
    } // catch ......
}

private void executeStatement(String command) throws SQLException {
    try (Statement statement = connection.createStatement()) {
        statement.setEscapeProcessing(escapeProcessing);
        String sql = command;
        if (removeCRs) {
            // removeCRs属性是指是否取出windows系统换行符中的\r
            sql = sql.replace("\r\n", "\n");
        }
        try {
            // 执行SQL语句
            boolean hasResults = statement.execute(sql);
            while (!(!hasResults && statement.getUpdateCount() == -1)) {
                checkWarnings(statement);
                printResults(statement, hasResults);
                hasResults = statement.getMoreResults();
            }
        } // catch ...
    }
}

由 源码3 可知,批量执行SQL语句的方法是executeFullScript(),该方法会逐行读取脚本文件的SQL语句,并以LINE_SEPARATOR(如果是Linux系统,则是"\n";如果是Windows系统,则是"\r\n")拼接起来,然后直接执行这个拼接起来的SQL语句,并提交事务。

源码4:org/apache/ibatis/jdbc/ScriptRunner.java

private void executeLineByLine(Reader reader) {
    StringBuilder command = new StringBuilder();
    try {
        BufferedReader lineReader = new BufferedReader(reader);
        String line;
        while ((line = lineReader.readLine()) != null) {
            // 读一行,处理一行
            handleLine(command, line);
        }
        // 提交事务
        commitConnection();
        checkForMissingLineTerminator(command);
    } // catch ......
}

private void handleLine(StringBuilder command, String line) throws SQLException {
    String trimmedLine = line.trim();
    if (lineIsComment(trimmedLine)) {
        // 该行是注释:以"//"或"--"开头
        Matcher matcher = DELIMITER_PATTERN.matcher(trimmedLine);
        if (matcher.find()) {
            delimiter = matcher.group(5);
        }
        println(trimmedLine);
    } else if (commandReadyToExecute(trimmedLine)) {
        // 该行是待执行的命令
        // 获取该行分号之前的内容
        command.append(line, 0, line.lastIndexOf(delimiter));
        // 添加一个分号
        command.append(LINE_SEPARATOR);
        println(command);
        // 执行SQL语句
        executeStatement(command.toString());
        command.setLength(0);
    } else if (trimmedLine.length() > 0) {
        // 如果不是待执行的命令,则说明这条SQL还没结束
        // 则追加到上一行内容
        command.append(line);
        command.append(LINE_SEPARATOR);
    }
}

// 判断某行是否是注释:以"//"或"--"开头
private boolean lineIsComment(String trimmedLine) {
    return trimmedLine.startsWith("//") || trimmedLine.startsWith("--");
}

// 判断某行是否是待执行的命令
// 如果分割符没有占一行,则必须包括分号
// 如果分割符占一行,则只能是分号
private boolean commandReadyToExecute(String trimmedLine) {
    return !fullLineDelimiter && trimmedLine.contains(delimiter) || fullLineDelimiter && trimmedLine.equals(delimiter);
}

由 源码4 可知,逐行执行SQL语句的方法是executeLineByLine(),该方法会逐行读取脚本文件的SQL语句,然后直接调用handleLine()方法进行处理。

handleLine()方法中,首先会判断这一行内容是否是SQL注释(以"//“或”–"开头),如果是则打印注释内容;

其次判断这一行内容是否是待执行的命令(包含分号),如果是则立即调用Statement对象的execute()方法执行SQL语句;

如果既不是注释,又不是命令,则说明这条SQL还没结束,需要追加到上一行内容中。

3.3 使用SqlRunner操作数据库

3.3.1 SqlRunner工具类简介

SqlRunner是MyBatis提供的用于操作数据库的工具类,它对JDBC做了很好的封装,结合SQL工具类,可以方便地通过Java代码执行SQL语句并检索SQL执行结果。

SqlRunner工具类提供了几个操作数据库的方法:

源码5:org/apache/ibatis/jdbc/SqlRunner.java

// 关闭Connection对象
public void closeConnection() {...}

// 执行SELECT语句,只返回1条记录,如果查询结果行数不等于1,则抛出异常
// SQL语句中可以使用占位符,可变参数args为占位符赋值
public Map<String, Object> selectOne(String sql, Object... args) throws SQLException {...}

// 执行SELECT语句,但返回多条记录,返回值中每个Map对象就是一行记录
// SQL语句中可以使用占位符,可变参数args为占位符赋值
public List<Map<String, Object>> selectAll(String sql, Object... args) throws SQLException {...}

// 分别执行INSERT、UPDATE、DELETE语句,插入、更新、删除一条数据
public int insert(String sql, Object... args) throws SQLException {...}
public int update(String sql, Object... args) throws SQLException {...}
public int delete(String sql, Object... args) throws SQLException {...}

// 执行任意一条SQL语句,最好为DDL语句
public void run(String sql) throws SQLException {...}

3.3.2 SqlRunner工具类示例

@Test
public void testSqlRunner() {
    try {
        Connection connection = DbUtils.getConnection();
        SqlRunner sqlRunner = new SqlRunner(connection);
        // 插入一条记录
        String insertSql = new SQL() {{
            INSERT_INTO("user");
            INTO_COLUMNS("name", "age", "phone", "birthday");
            INTO_VALUES("?,?,?,?");
        }}.toString();
        sqlRunner.insert(insertSql, "王母娘娘", 1000, "12530", "0000-05-21");
        System.out.println("执行INSERT语句成功");
        // 查询该条记录
        String selectSql = new SQL() {{
            SELECT("*");
            FROM("user");
            WHERE("name = ?");
        }}.toString();
        Map<String, Object> resultMap = sqlRunner.selectOne(selectSql, "王母娘娘");
        System.out.println(resultMap);
        // 修改该条记录
        String updateSql = new SQL() {{
            UPDATE("user");
            SET("phone = ?");
            WHERE("name = ?");
        }}.toString();
        sqlRunner.update(updateSql, "12345", "王母娘娘");
        System.out.println("执行UPDATE语句成功");
        // 再次查询该条记录
        resultMap = sqlRunner.selectOne(selectSql, "王母娘娘");
        System.out.println(resultMap);
        // 删除这条记录
        String deleteSql = new SQL() {{
            DELETE_FROM("user");
            WHERE("name = ?");
        }}.toString();
        sqlRunner.delete(deleteSql, "王母娘娘");
        System.out.println("执行DELETE语句成功");
        // 再次查询该条记录
        resultMap = sqlRunner.selectOne(selectSql, "王母娘娘");
        System.out.println(resultMap);
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}

控制台打印执行结果:

执行INSERT语句成功
{PHONE=12530, ID=20, BIRTHDAY=0001-05-21 00:00:00.0, NAME=王母娘娘, AGE=1000}
执行UPDATE语句成功
{PHONE=12345, ID=20, BIRTHDAY=0001-05-21 00:00:00.0, NAME=王母娘娘, AGE=1000}
执行DELETE语句成功

java.lang.RuntimeException: java.sql.SQLException: Statement returned 0 results where exactly one (1) was expected.

在以上案例中,先后执行SqlRunner工具类的insert()→selectOne()→update()→selectOne()→delete()→selectOne()方法,测试了SqlRunner工具类的增删改查的功能。

可以发现,最后一次执行selectOne()方法时抛出异常,提示返回0条记录但却使用了selectOne()方法,这说明delete()方法确实生效了。

3.3.3 SqlRunner工具类源码解读

selectAll()方法为例,研究SqlRunner工具类的具体实现。

源码6:org/apache/ibatis/jdbc/SqlRunner.java

public List<Map<String, Object>> selectAll(String sql, Object... args) throws SQLException {
    try (PreparedStatement ps = connection.prepareStatement(sql)) {
        // 为参数占位符设置参数
        setParameters(ps, args);
        // 执行查询操作
        try (ResultSet rs = ps.executeQuery()) {
            // 处理结果
            return getResults(rs);
        }
    }
}

由 源码6 可知,selectAll()方法的逻辑有三步:

(1)调用Connection对象的prepareStatement()方法获取PreparedStatement对象,并调用setParameters()方法为SQL语句中的占位符设置参数;
(2)调用PreparedStatement的executeQuery()方法执行查询操作;
(3)调用getResults()方法将ResultSet对象转换为List集合,其中List集合中每一个Map对象对应数据库中的一条记录。

源码7:org/apache/ibatis/jdbc/SqlRunner.java

private void setParameters(PreparedStatement ps, Object... args) throws SQLException {
    // 遍历参数
    for (int i = 0, n = args.length; i < n; i++) {
        // 参数为空,直接抛出异常
        if (args[i] == null) {
            // throw ...
        }
        if (args[i] instanceof Null) {
            // 参数是Null类型的,则为占位符设置null
            ((Null) args[i]).getTypeHandler().setParameter(ps, i + 1, null, ((Null) args[i]).getJdbcType());
        } else {
            // 正常参数,根据参数类型获取对应的TypeHandler
            TypeHandler typeHandler = typeHandlerRegistry.getTypeHandler(args[i].getClass());
            if (typeHandler == null) {
                // TypeHandler对象没有获取到,抛出异常
                // throw ...
            } else {
                // 使用TypeHandler对象为占位符设置参数
                typeHandler.setParameter(ps, i + 1, args[i], null);
            }
        }
    }
}

由 源码7 可知,setParameters()方法会对参数进行遍历,逐个处理:

(1)如果参数为空,直接抛出异常;
(2)如果参数是Null类型的,则为占位符设置null值;
(3)如果是正常参数,则先根据参数类型获取对应的TypeHandler;TypeHandler对象没有获取到,则抛出异常;获取到了则调用TypeHandler对象的setParameter()方法为占位符设置参数。

源码:org/apache/ibatis/jdbc/SqlRunner.java

private List<Map<String, Object>> getResults(ResultSet rs) throws SQLException {
    List<Map<String, Object>> list = new ArrayList<>();
    List<String> columns = new ArrayList<>();
    List<TypeHandler<?>> typeHandlers = new ArrayList<>();
    // 1.获取ResultSetMetaData对象,通过该对象获取所有列名
    ResultSetMetaData rsmd = rs.getMetaData();
    for (int i = 0, n = rsmd.getColumnCount(); i < n; i++) {
        columns.add(rsmd.getColumnLabel(i + 1));
        try {
            // 2.获取列的JDBC类型
            Class<?> type = Resources.classForName(rsmd.getColumnClassName(i + 1));
            // 根据列的JDBC类型获取对应的TypeHandler对象
            TypeHandler<?> typeHandler = typeHandlerRegistry.getTypeHandler(type);
            // 如果没有获取到则获取Object对象的TypeHandler对象
            if (typeHandler == null) {
                typeHandler = typeHandlerRegistry.getTypeHandler(Object.class);
            }
            typeHandlers.add(typeHandler);
        } catch (Exception e) {
            typeHandlers.add(typeHandlerRegistry.getTypeHandler(Object.class));
        }
    }
    // 遍历ResultSet对象,将ResultSet对象中的记录行转换为Map对象
    while (rs.next()) {
        Map<String, Object> row = new HashMap<>();
        for (int i = 0, n = columns.size(); i < n; i++) {
            String name = columns.get(i);
            TypeHandler<?> handler = typeHandlers.get(i);
            // 往Map对象中添加一行数据
            // key - 列名,转为大写
            // value - 通过TypeHandler对象的getResult方法将JDBC类型转换为JAVA类型数据
            row.put(name.toUpperCase(Locale.ENGLISH), handler.getResult(rs, name));
        }
        list.add(row);
    }
    return list;
}

由 源码 可知,getResults()方法的处理逻辑是:

(1)获取ResultSetMetaData对象,该对象封装了结果集的元数据信息,包括所有的字段名称及列的数量等信息;
(2)遍历所有列,获取每一列的JDBC类型,根据JDBC类型获取对应的TypeHandler对象,如果没有获取到则获取Object对象的TypeHandler对象,最后将TypeHandler对象注册到变量名为typeHandlers的List集合中。
(3)遍历ResultSet对象,将ResultSet对象中的记录行转换为Map对象。这个Map对象的key是列名(转为大写),value是通过TypeHandler对象的getResult()方法将JDBC类型数据转换为JAVA类型数据。

本节完,更多内容请查阅分类专栏:MyBatis3源码深度解析

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/448371.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

基于YOLOv8/YOLOv7/YOLOv6/YOLOv5的火焰检测系统(Python+PySide6界面+训练代码)

摘要&#xff1a;本研究详述了一种采用深度学习技术的火焰检测系统&#xff0c;该系统集成了最新的YOLOv8算法&#xff0c;并与YOLOv7、YOLOv6、YOLOv5等早期算法进行了性能评估对比。该系统能够在各种媒介——包括图像、视频文件、实时视频流及批量文件中——准确地识别火焰目…

RESTful API学习

RESTful API REST&#xff08;英文&#xff1a;Representational State Transfer&#xff0c;简称REST&#xff0c;直译过来表现层状态转换&#xff09;是一种软件架构风格、设计风格&#xff0c;而不是标准&#xff0c;只是提供了一组设计原则和约束条件。它主要用于客户端和…

大数据开发-Hive介绍以及安装配置

文章目录 数据库和数据仓库的区别Hive安装配置Hive使用方式Hive日志配置 数据库和数据仓库的区别 数据库&#xff1a;传统的关系型数据库主要应用在基本的事务处理&#xff0c;比如交易&#xff0c;支持增删改查数据仓库&#xff1a;主要做一些复杂的分析操作&#xff0c;侧重…

3.8_理解代码(3)

fliplr函数 其中fliplr函数为flip array left to right&#xff0c;此处fliplr(i)的输出结果为[4 3 2 1] 我的代码实验 area1 fill(i,u_up(i),cyan,FaceAlpha,0.3);把我都弄得无语了&#xff0c;就实现fill怎么这么难 真是不知道向量长度哪里不同&#xff0c;知道了哈哈 终于…

【并查集】一种简单而强大高效的数据结构

目录 一、并查集原理 二、并查集实现 三、并查集应用 1. LeetCode并查集相关OJ题 2. 并查集的其他应用及总结 一、并查集原理 并查集&#xff08;Disjoint Set&#xff09;是一种用来管理元素分组和查找元素所属组别的数据结构。它主要支持两种操作&#xff1a;查找&…

IntelliJ IDEA配置Tomcat

一、简介 概念&#xff1a;Tomcat是Apache 软件基金会一个核心项目&#xff0c;是一个开源免费的轻量级Wcb服分%&#xff0c;支持Servlet/JSP少量avaEE风范。 JavaEE: Java Enterprise Edition, Java企业版。指Java企业级开发的技术规范总和。包食13m技术规论&#xff1a;JDB…

22.1 分布式_线程池

线程池 1. 学习内容2. 简介2.1 池概念2.2 不使用线程池创建线程2.3 线程池的好处2.4 线程池应用场景****************************************************************************************************************1. 学习内容 2. 简介 2.1 池概念 <

JS-01-javaScript的介绍

一、javaScript简介 JavaScript是世界上最流行的脚本语言&#xff0c;因为你在电脑、手机、平板上浏览的所有的网页&#xff0c;以及无数基于HTML5的手机App&#xff0c;交互逻辑都是由JavaScript驱动的。 htmlcss&#xff1a;静态页面 js&#xff1a;给页面添加交互和功能 J…

delphi7中出现“无法更改以命令对象为源的记录集对象..“的错误解决

我在delphi7环境下写一个数据库应用程序&#xff0c;每次关闭界面时总出现“无法更改以命令对象为源的记录集对象.."的错误。如图所示。 经查阅资料&#xff0c;我得到一些思路&#xff1a;最 这个错误信息通常表示在关闭窗体时&#xff0c;有一个或多个数据库组件&…

【Qt】—— 信号与槽

目录 &#xff08;一&#xff09;信号和槽概述 1.1 信号的本质 1.2 槽的本质 &#xff08;二&#xff09;信号和槽的使用 2.1 信号和槽的连接 2.2 查看内置信号和槽 2.3 通过Qt Creator⽣成信号槽代码 &#xff08;三&#xff09;自定义信号和槽 3.1 基本语法 3.2 带参…

单例模式及线程安全的实践

&#x1f31f; 欢迎来到 我的博客&#xff01; &#x1f308; &#x1f4a1; 探索未知, 分享知识 !&#x1f4ab; 本文目录 引言基本的单例模式长啥样&#xff1f;怎样才能线程安全&#xff1f;**懒汉模式** ( 双 重 检 查 ) &#x1f389;总结&#x1f389; 引言 单例模式是个…

动态代理详解(原理+代码+代码解析)

动态代理 1.什么是动态代理&#xff1f; 动态代理是一种在运行的时候动态的生成代理对象的技术。它在不改变原始类的情况下&#xff0c;对原始类的方法进行拦截或者增强。 2.动态代理可以实现的功能&#xff1f; 使用动态代理可以实现如下常用功能&#xff1a; 1.AOP&#x…

为什么要使用数字档案管理系统

机关企事业单位使用数字档案管理系统&#xff0c;主要有以下几个原因&#xff1a; 1. 档案管理效率提升&#xff1a;玖拓智能数字档案管理系统可以帮助综合档案馆实现对档案的全面管理和翔实记录&#xff0c;包括档案的入库、整理、检索、借阅等工作。系统化的管理使得档案管理…

调整分区失败,硬盘难启:原因分析与数据恢复之道

在数字化时代&#xff0c;硬盘作为存储数据的重要工具&#xff0c;其稳定性和安全性至关重要。然而&#xff0c;有时在调整分区的过程中&#xff0c;我们可能会遭遇失败&#xff0c;导致硬盘无法打开&#xff0c;数据无法访问。这种情况不仅令人沮丧&#xff0c;更可能带来不可…

第16章——西瓜书强化学习

在强化学习中&#xff0c;智能体通过与环境的交互来学习如何做出决策。在每个时间步&#xff0c;智能体观察当前的环境状态&#xff0c;并根据其策略选择一个动作。环境会对智能体的动作做出响应&#xff0c;并给出一个奖励信号&#xff08;reward&#xff09;&#xff0c;该信…

crossover玩不了qq游戏大厅怎么办 仍有五亿人坚持用QQ crossover玩游戏 Mac电脑玩QQ游戏

从1999年2月&#xff0c;QQ首个版本QICQ&#xff08;OPEN-ICQ&#xff09;上线。到2024年&#xff0c;靠着5亿月活用户&#xff0c;守住社交领域TOP2位置。你还记得QQ经典的铃声吗&#xff1f; 根据月狐数据2023年12月的统计&#xff0c;QQ月活跃账户数比微博和知乎加在一起还要…

【C++庖丁解牛】STL之vector容器的介绍及使用 | vector迭代器的使用 | vector空间增长问题

&#x1f4d9; 作者简介 &#xff1a;RO-BERRY &#x1f4d7; 学习方向&#xff1a;致力于C、C、数据结构、TCP/IP、数据库等等一系列知识 &#x1f4d2; 日后方向 : 偏向于CPP开发以及大数据方向&#xff0c;欢迎各位关注&#xff0c;谢谢各位的支持 目录 1.1 vector的介绍2 v…

创造一款安卓自定义控件(4)——使用Matrix的setPolyToPoly方法实现图像纠正

接上文&#xff1a; 创造一款安卓自定义控件_任意4顶点裁剪框http://t.csdnimg.cn/vu1r5 创造一款安卓自定义控件_任意4顶点裁剪框2_为裁剪框添加放大镜功能http://t.csdnimg.cn/qkngh 创造一款安卓自定义控件_裁剪原理介绍http://t.csdnimg.cn/ORRRL 需求 随着需求修改&#x…

Linux系统部署火狐浏览器结合内网穿透实现公网访问

目录 前言 1. 部署Firefox 2. 本地访问Firefox 3. Linux安装Cpolar 4. 配置Firefox公网地址 5. 远程访问Firefox 6. 固定Firefox公网地址 7. 固定地址访问Firefox 结语 前言 作者简介&#xff1a; 懒大王敲代码&#xff0c;计算机专业应届生 今天给大家聊聊Linux系统…

企业接入SD-WAN组网需要花费多少?

企业数字化转型的不断深入&#xff0c;越来越多的企业开始考虑采用SD-WAN&#xff08;软件定义广域网&#xff09;技术来优化其网络架构&#xff0c;提升网络性能和安全性。然而&#xff0c;对于企业来说&#xff0c;接入SD-WAN组网 需要花费多少是一个关键问题。以下是一些影响…