• 猿创征文|实战开发openGauss DataStudio的sql联想结构


    前段时间正好完成国产数据库openGauss DataStudio的sql语句表提示功能优化,借此机会来给大家分享一下我的开发过程以及经验。
    项目暂时还处于结项阶段,如果大家在中间有更好的解决思路或者经验,非常欢迎大家前来讨论交流😎

    前情提要

    openGauss社区网址:https://www.opengauss.org/zh/

            DataStudio 是openGauss 的官方客户端工具,提供可视化管理 openGauss 数据库;支持管理和创建数据库、模式、表等各类数据库对象;执行SQL语句或脚本,高效进行sql开发;创建和执行sql语句,支持存储过程调试;表数据增、删、改、查操作等功能。Sql 的可视化客户端大大减少了开发人员的开发时间成本,具备简单、安全、逻辑数据独立性等优点。为了使用人员在查询时更加方便,应该将 DataStudio 的功能进行改进,把 sql 输入联想功能进行优化,把当前字联想改为表结构联想,并且为了方便操作数据库时更加方便,添加选择“all”时自动填充所有表结构。本文主要是对于开发功能的介绍以及在开发过程中遇到的问题。

    目前PR已经提交了
    在这里插入图片描述

    关于如何安装openGauss以及启动项目大家可以翻看我之前的博客内容。

    一 已完成工作

    1.1 实现功能

            对当前sql输入联想功能进行优化,把当前的关键字联想改为表结构联想,并且在选择“all”时自动填充所有表结构。

            当输入一个数据库表再敲一个左括号或英文句号后会展示出所有的数据库表的列以及该列的相关信息;增加一个“all”的选项,当我们选择该字段后,会将该数据库表中所有的列按照id大小将所有的列展示出来。

            前提说明:测试表名为students,该表的列有student_id,student_name,department
    在这里插入图片描述
            效果图如下:
    在这里插入图片描述

    1.2 方案描述

            以表列为例。

            通过输入的字符串进行切割,判断该值属于哪一范围,如果属于表并且以英文句号结尾的字符串或者以insert into 表名开头并以英文“(”结尾那么就会通过findAllChildObjects查找它的所有子对象,通过查找该表名的类型是否是TableMetaData,如果是那么通过该表名去查找它的子对象也就是列,如果是表元数据的话还会通过contentAssistUtil.getChildObject()该方法增加一个key值为“all”的数据,该数据通过getChildObject()通过依次获取列元数据的值将该值增加到“ColumnAll”类的name属性下。将该值返回到上一层,并通过其他函数进行展示。流程图如下:
    在这里插入图片描述

    1.3 代码修改

    经过前期阅读代码发现,如果要实现我们的功能,修改增加或修改的代码存在于

    1. org.opengauss.mppdbide.bl.serverdatacache中的ColumnMetaData
      在该类里增加“all”方法。通过该方法可以通过后续调用将“all”显示在提醒数据上。

      private static final String OCP = "all";
      /**
       * Gets the String.
       *
       * @param 
       * @return the string all
       */
      public String getClms() {
      return OCP;
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
    2. org.opengauss.mppdbide.bl.contentassist中的 ContentAssistProcesserData
      在该类中需要修改“findAllChildObjects”方法。
      当servObj为TableMetaData的时候,我们需要将表自己的拥有的列数据通过found.putAll(tbl.findAllChildObjects());该方法进行加载,还需要加载“all”的值,故需要执行found = contentAssistUtil.getChildObject(found, parentObjMap.size() > 1);将“all”对应的value值添加进去。

      /**
       * Find all child objects.
       *
       * @param parentObjMap the parent obj map
       * @return the sorted map
       */
       private SortedMap<String, ServerObject> findAllChildObjects(SortedMap<String, ServerObject> parentObjMap) {
          SortedMap<String, ServerObject> found = new TreeMap<String, ServerObject>(new AutoSuggestComparator());
          for (ServerObject servObj : parentObjMap.values()) {
              if (servObj instanceof GaussOLAPDBMSObject) {
                  if (!isInsert() && servObj instanceof Namespace) {
                      Namespace ns = (Namespace) servObj;
                      found.putAll(ns.findAllChildObjects());
                  } else if (servObj instanceof ForeignTable) {
                      ForeignTable fTable = (ForeignTable) servObj;
                      found.putAll(fTable.findAllChildObjects());
                  } else if (servObj instanceof PartitionTable) {
                      PartitionTable partitionTable = PartitionTable.class.cast(servObj);
                      found.putAll(partitionTable.findAllChildObjects());
                  } else if (servObj instanceof TableMetaData) {
                      TableMetaData tbl = (TableMetaData) servObj;
                      found.putAll(tbl.findAllChildObjects());
                      found = contentAssistUtil.getChildObject(found, parentObjMap.size() > 1);
                  } else if (servObj instanceof ViewMetaData) {
                       ViewMetaData view = ViewMetaData.class.cast(servObj);
                       found.putAll(view.findAllChildObjects());
                  }
              }
              if (isInsert()) {
                  found = contentAssistUtil.getChildObject(found, parentObjMap.size() > 1);
              }
              if (!servObj.isLoaded() && nonLoaded != null) {
                  nonLoaded.add(servObj);
              }
          }
          return found;
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33
      • 34
      • 35
      • 36
      • 37
    3. org.opengauss.mppdbide.bl.contentassist中的ContentAssistUtilOLAP
      在该类中需要修改“getChildObject”方法。
      在该方法中我们通过获取found里面的数据(该数据是通过执行上一步中获取的列数据),通过循环遍历,如果是我们需要的值就加入进去。方法主体是通过循环found数据,如果该类型是 ColumnMetaData 类型,那么就将该值加入到resultMap中,同时获得该数据的name,将该值作为一个字符串存储,如果不是found的最后一项,还需要在其后添加上“, ”,当数据加载到最后一项将字符串拼接完整后,创建ColumnAll 类并初始化,该字符串作为ColumnAll 的name保存,将其加入到resultMap值中。这样“all”对应的ServerObject就有了。

      @Override
      public SortedMap<String, ServerObject> getChildObject(SortedMap<String, ServerObject> found, boolean isParentDescNeeded) {
          SortedMap<String, ServerObject> resultMap = new TreeMap<String, ServerObject>();
          int len = found.size();
          if(isInsert()) {
              len = len - 1;
          }
          StringBuffer clName = new StringBuffer();
          int temp = 0;
          for (ServerObject obj : found.values()) {
              if (obj instanceof ColumnMetaData) {
                  ColumnMetaData clm = (ColumnMetaData) obj;
                  resultMap.put(clm.getClmNameWithDatatype(isParentDescNeeded), obj);
                  
                  if(temp < len) {
                      clName.append(((ColumnMetaData) obj).getParentTable().getColumnMetaDataList().get(temp).getName());
                      if(temp < len - 1) {
                          clName.append(", ");
                      }else {
                          ColumnAll ocb = new ColumnAll(temp,clName.toString(),OBJECTTYPE.COLUMN_METADATA, false);
                          resultMap.put(clm.getClms(), ocb);
                      }
                     temp++;
                  }     
              } else if (obj instanceof ViewColumnMetaData) {
                  ViewColumnMetaData clm = (ViewColumnMetaData) obj;
                  resultMap.put(clm.getClmNameWithDatatype(isParentDescNeeded), obj);
              }
          }
          return resultMap;
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
    4. org.opengauss.mppdbide.bl.serverdatacache.groups中增加一个新的类ColumnAll
      该类作为存储“all”的ServerObject存在,也就是当点击“all”时,就会调用该类里的getName方法进行展示。

      package org.opengauss.mppdbide.bl.serverdatacache.groups;
      
      import org.opengauss.mppdbide.bl.serverdatacache.OBJECTTYPE;
      import org.opengauss.mppdbide.bl.serverdatacache.ServerObject;
      import org.opengauss.mppdbide.bl.serverdatacache.groups.ColumnList;
      import org.opengauss.mppdbide.utils.IMessagesConstants;
      import org.opengauss.mppdbide.utils.loader.MessageConfigLoader;
      
      /**
       * Title: ColumnAll
       * Get all the current columns from the table and store them
       */
      
      public class ColumnAll extends ServerObject {
          
          private int id;
          private String name;
          
      
          /**
           * Instantiates a new ColumnsAll object.
           *
           * @param oid the oid
           * @param name the name
           * @param type the type
           * @param privilegeFlag the privilege flag
           */
          public ColumnAll(long oid, String name, OBJECTTYPE type, boolean privilegeFlag) {
              super(oid, name, OBJECTTYPE.COLUMN_METADATA, false);
              this.name = name;
          }
          
          public void setName(String name) {
              this.name = name;
          }
          
          public String getName() {
              return name;
          }
          
          public void setId(int id) {
              this.id = id;
          }
          
          public int getId() {
              return id;
          }
         
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33
      • 34
      • 35
      • 36
      • 37
      • 38
      • 39
      • 40
      • 41
      • 42
      • 43
      • 44
      • 45
      • 46
      • 47
      • 48
      • 49
    5. 在org.opengauss.mppdbide.bl.serverdatacache;的ServerObject
      我们需要修改该类中的isQualifiedSimpleObjectName方法。
      为了保持前面“all”的value值也就是ColumnAll这个serverObject的名字合法性,我们需要进行判断,否则输出的会是一段两边带双引号的字符串,而不是我们所需要的字符串。
      通过正则表达式来进行。

       /**
           * Checks if is qualified simple object name.
           *
           * @param objectName the object name
           * @return true, if is qualified simple object name
           */
       public static boolean isQualifiedSimpleObjectName(String objectName) {
           if (null != objectName && !objectName.isEmpty() && objectName.matches("^([a-z_][a-z|0-9|_|$]*)|([a-z_][a-z|0-9|_|,| |$]*)$")) {
               return true;
           }
       
           return false;
       }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13

    1.4 测试

           本节主要是针对所写功能进行测试。
           使用表名为students,该表存在的列名为student_id,student_name,department。

    测试用例编号1
    测试内容输入select student.
    测试预期输出界面展示所有列以及all
    测试结果图1
    是否符合要求符合

    在这里插入图片描述

    图1:输入“select students.”

    测试用例编号2
    测试内容输入select student.并点击某一列
    测试预期输出将该列的列值进行展示
    测试结果图2,图3
    是否符合要求符合

    在这里插入图片描述

    图2:输入“select students.”,在弹出的提醒框中选择某一列名

    在这里插入图片描述

    图3:输入“select students.”,展示输出内容

    测试用例编号3
    测试内容输入select student.并点击all
    测试预期输出展示所有的列
    测试结果图4,图5
    是否符合要求符合

    在这里插入图片描述

    图4:输入“select students.”,在弹出的提醒框中选择“all”

    在这里插入图片描述

    图5:输入“select students.”,展示所有的列名

    测试用例编号4
    测试内容输入insert into student(
    测试预期输出界面展示所有列以及all
    测试结果图6
    是否符合要求符合

    在这里插入图片描述

    图6:输入“insert into students (”

    测试用例编号5
    测试内容输入insert into student( 并点击某一列
    测试预期输出将该列的列值进行展示
    测试结果图7,图8
    是否符合要求符合

    在这里插入图片描述

    图7:输入“insert into students (”,在弹出的提醒框中选择某一列名

    在这里插入图片描述

    图8:输入“insert into students (”,输出结果

    测试用例编号6
    测试内容输入insert into student( 并点击all
    测试预期输出展示所有的列
    测试结果图9,图10
    是否符合要求符合

    在这里插入图片描述

    图9:输入“insert into students (”,在弹出的提醒框中选择“all”

    在这里插入图片描述

    图10:输入“insert into students (”,展示所有列

    二 遇到的问题以及解决方案

           在开发项目的过程中存在着很多问题,我会依据时间线将存在的问题进行阐述。

           在项目刚开始的时候遇到的最大困难,我发现我的项目竟然不能运行,并且代码存在问题。最初的时候我是将代码从gitee上clone下来,在本地通过开发工具Eclipse打开。结果发现我的代码在我的本地上存在着爆红。想着项目既然能开源并且存在时间长,如果有问题那必然是我的问题。接下来我就从网上搜索各种资源区解决这个问题,但是并没有找到合适的解决办法,后来寻求项目导师的帮助。导师帮助我解决了项目初期搭建上的很多问题,也找到解决办法。

           例如数据库openGauss如何安装;开发环境Ecplise必须是Eclipse IDE for RCP and RAP Developers,其他版本是不可以的;将项目导入之后遇到代码报错;如何查看自己Eclipse开发工具是否安装了相关组件等多个问题。

    2.1 安装数据库

           由于是个人开发,听导师的建议,安装了极简版3.0.0的openGauss数据库版本。由于数据库安装在Linux,需要Python3.6.X以上的,所以我还犯了一个将Linux中自带的Python版本删除了。这个问题的解决是我直接将Linux后退了,幸好之前把Linux做了快照处理,得益于此,我的Python版本得到了解决,通过查找资料将Python3.6.8安装在了本地虚拟机上。其他开发问题如下:

    (1)执行install.sh脚本安装openGauss出现以下错误 On systemwide basis, the maximum number of SEMMNI is not correct. the current SEMMNI value is: 128. Please check it.
    解决:在/etc/sysctl.conf中加入语句kernel.sem = 250 32000 100 999,然后执行sysctl -p

    (2)如果提示:No package gs_ctl available. Error: Nothing to do
    解决:这是因为yum源出现了问题,修改一下即可解决。
    本次使用的yum源是华为的https://repo.huaweicloud.com/repository/conf/CentOS-7-reg.repo。重新配置yum源。

    (3)yum安装时报错:Loaded plugins: fastestmirror
    解决:fastestmirror是yum的一个加速插件,意思就是不能用了,我们将其禁用即可。

    2.2 DataStudio的开发环境配置

           开发环境首先要将源代码编译,我是根据Gitee上的源代码里的编译指导进行的。出现的问题只有如果镜像不正确会导致编译失败,此时只需要修改阿里云镜像即可。如果还出现问题,只会是网络的问题,只需要重复执行命令即可。

    (1)配置Eclipse
    除了基础的配置之外还需要查看本地Eclipse是否安装了相关的组件。如果不安装该组件是不会成功的。

    (2)导入项目
    如果源代码报错也就是如下图所示
    **加粗样式**

    那么运行是不会成功的。

           解决: 右键view这个项目–Build Path – Configure Build Path —Libaries下的Classpath点击图中的Add External JARS,找到相应的jar包–Apply。加入即可解决该问题。

    在这里插入图片描述

           这里我有一个有很多错误的/org.opengauss.mppdbide.view,这个我导入很多的包才解决掉报红问题印象最深刻的是javafx.embed,这个我是去最开始下载的javafx-sdk-17.0.2里面导入了下图中的这个包才解决掉的。

    在这里插入图片描述
    在这里插入图片描述

           如果是JSON报错的话,可以通过下面的方式解决。
           因为Eclipse认为JSON文件不需要注释,所以报的编译错误,我们可以通过Eclipse的设置把它的编译检查给关掉。Window → Preferences → Validation(验证) → JSON Validator(JSON 验证器)把Manual和Build两个复选框的勾都取掉,点击Apply and Close。

    在这里插入图片描述

    (3)启动项目
           启动项目的过程中遇到需要手动添加 Plug-ins ,不能自动添加。否则会报错。
           当你在run configuration中 根据步骤进行启动时,点击Validate Plug-ins如果弹出图中的问题时
    在这里插入图片描述

           尝试在下图箭头的位置查找有可能缺少的Plug-ins。基本都找到后,点击Apply–>run 尝试运行。

    在这里插入图片描述

           若通过eclipse菜单 run-> run configurations -> Plug-ins -> Add Required Plug-ins -> validate Plug-ins -> Apply -> Run方法不行。
           在配置Run Configuration的Plug-ins时,勾选 Select All。然后运行。
           再将Plug-ins配置成 Add Required Plug-ins,再运行。

    (4)其他问题
           如果在启动Eclipse中还遇到了org.osgi.framework.BundleException
           解决办法:在eclipse.ini中设置eclipse默认jdk。如果出现该问题就去设置,没有就不管了。在-vmargs前配置-vm即可,添加的内容如下:
           添加的内容为(jdk路径填写自己的):
           -vm
           D:\Environment\Java\JDK11\jdk-11.0.2\bin\javaw.exe
           注意: -vm和jdk路径一定分2行。

    在这里插入图片描述

    2.3 开发过程

           在开发过程中我的困难主要是来自于在阅读代码的时候不能使用debug。这是最困难的,我只能通过自己浅浅的经验来找我需要的代码段,不断的通过全局搜索来查找接下来它会跳转到那里。这样很容易导致错过代码段,甚至是浪费自己的时间去阅读了自己不需要的代码。浪费了很多的时间。这个问题比较佛学,因为我之前每次运行代码的时候必须run configurations,这就导致我不能使用debug来运行。在经过一段时间的焦躁之后,在中后期的时候,可以直接run了,此时,我也终于可以debug了。也就是说如果在遇到这种情况可以尝试多运行几遍,可能在某一时刻它就可以了。

           在没有debug的时候,我通过自己阅读到的代码,以及自己的理解,编写了很长的一段代码,跨越了很多个类,尝试让内容展现出来,但是很遗憾,失败了。但也正是这段时间让我每天沉浸在代码中,我对流程的理解越来越深,也找到了我需要的代码段。这时候我就不断地在相关地代码中徘徊,一步一步的去将我需要的内容呈现出来。没有工具那就需要靠人力去不断地摸索,虽然最后对自己的任务完成的没有关系,但是从侧面可以加深自己对项目的理解。

           还有一个问题困扰了我许久。这是在开发的过程中,我需要的是一串字符串,但是代码原来保存的是一serverObject,例如SortedMap,一直在想怎么保存这个字符串,因为“all”对应的value值必须是一个ServerObject,我在想能不能重新写一个方法,里面key是String,value也是String,这样我需要的内容就得到了。但是ds的代码数据量已经非常大了,一旦我修改了这个代码,那么所有的代码都会变动,这个我是心惊胆颤的。在这块内容中我尝试着修改这个value值,如果直接传入一个ServerObject,那么会出现什么的值,如果使用我想要的值会不会不同例如:
           resultMap.put("a",clm.getParentTable().getColumnMetaDataList().get(0));这个值代表该表的id为0的值,通过运行代码,我发现这两个代码出现的内容是不同。就在想如果我传默认值难道不应该保存最开始的那个值吗?一直在修改这块的内容,不断地修改,直到我的debug可以用了,我通过debug发现,如果传ServerObject那么最后保存的循环遍历后的最后一个值,而如果传入一个固定的值那么最后还是出现那个固定值。

           我想到既然必须是一个ServerObject,那么我可以不可以将String转换成ServerObject,很明显,如果强制转换的话是不可以的,那么我直接就去创建了一个类,通过该类将字符串作为类的一个属性name进行保存,为什么作为name属性保存呢,因为在后续的时候,我们是通过getName()方法来获得该值的,这才将我所需要的值保存起来,并且在最后也得到了该值并且可以展示。

           但是此时发现我写的代码最后出来的竟然不是我所需要的字符串例如:student_id, student_name, department,出来的竟然是带双引号的字符串“student_id, student_name, department”,这时询问老师,老师说可以试着将字符串中的双引号使用方法替换掉,我直接去尝试了该方法,但是最后的结果还是原来的样子。放弃该方法之后就通过debug去找,通过代码的运行的步骤,来看value值是如何变化的,最后找到了正则表达式这里。如果该值如何正则表达式就是直接输出该值,否则就回将其转换成字符串输出,这就是导致我们展现的内容错误的原因。

    三 后续安排

           如果有机会继续会参加该项目,我希望可以更多的参与进来,虽然最终任务目标是完成了,但是可以在此基础上完成的更好,通过优化代码来使得细节更加完善。

           在判断字符是否符合标准的时候,是否可以增加通过该值类型判断符合正则表达式标准;对于点击“all”输出该表的子对象的格式是否还需要完善。

           这里说明以下:为什么当使用insert into函数的时候可以使用“(”来获得子对象,而使用select函数的时候不能使用“(”来获得子对象。在使用数据库操作的时候,对于select我们在编写数据库语句的时候在子对象前面增加表名更加可以表示该列是属于该表的,而不是其他表,因为相同的字段在其他表结构中也可能存在,那么一但两个表都有该字段,select 的时候没有表名该字段属于哪一张表,那么最终该数据库语句就会报错。所以在select语句的时候如果使用“.”才会出现同样的效果。Insert into语句本身就需要表名加“(”来获得子对象,如果表名多还需要添加的话直接输入点击“all”就会出现所有的子对象也就是列。

    四 总结

           数据库客户端时为了使用人员在操作时更加方便,通过改进功能将 sql 输入联想功能优化为表结构联想,同时添加选择“all”时自动填充所有表结构。本文设计的代码虽然在实际操作过程中取得了良好的成果,但也存在许多不足,欢迎各位前来指正,我将继续完善该功能。

           非常感谢开源之夏、openGauss社区、导师能给我这个机会参与到项目的开发过程中,在这段时间里让我真正接触到了一个大型项目,虽然我的任务仅是完成一个小功能,但是在此过程中遇到的问题,以及解决问题的办法是我开源路上的一个重大收获。同时也要感谢实验室的两位老师,她们从项目初期就开始关心我的项目进展,老师们也时刻关注着国产数据库的发展,鼓励我们去参与到项目中提升自己,在此感谢她们的对我的帮助。从项目初期到我真正实现该功能的过程虽然十分艰辛,但结果是美好的,此时的心情也是十分愉悦的。回想自己因为功能没有实现,睡觉前脑海中回想着应该怎么做,经过不断修改产生最佳结果的喜悦,这些都记忆犹新。如果有机会可以继续参与到该项目中,为openGauss的DataStudio继续开发奉献出自己的一份力量。

           在整个开发过程中遇到了很多的问题,但也正是这些问题使我的技能得到了很大的提升,同时我也将我解决问题的思路做了总结在前面的章节中,欢迎各位批评指正。我也将遇到的问题以及解决思路分享到了博客中,后续我会继续将我在开发过程中遇到的问题、解决方法、在开发DataStudio的经验分享到博客中,欢迎各位来互相交流。让更多的了解openGauss社区、开源之夏,让更多的人参与进来。

    后续

            在前段时间我将我的工作内容做成了PPT展示给实验室的老师以及同学们,通过和老师们的交流我发现了作为一个新手是多么的菜,老师们的思路和自己做开发的思路是不同的。通过几个月通过该项目感受到了人生路上不可获取的历程,做了事有所收获让后面的自己不再犯同样的错误,这是非常重要的收获。

            在这里小小总结一下:拿到项目,首先明确项目要求并列出提纲,将需求和目的,以及解决思路明确后进行下一步。如果中间出现问题,只要不是解决思路的问题那就是编码的问题了,不断解决就行。

  • 相关阅读:
    代码随想录算法训练营第五十六天| 1143.最长公共子序列 1035.不相交的线 53. 最大子序和
    vue 使用$router.push(参数)跳转同一路由页面,参数不同,跳转页面数据均为最后一次传值数据
    安装docker以及docker-compose并一键安装zabbix及其组件
    输电线路故障数据集(基于simulink仿真批量生成故障数据,单相接地故障、两相接地故障、两相间短路故障、三相接地故障、三相间短路故障和正常)
    C++图书管理案例
    MySQL备份与恢复工具之MYSQLDUMP
    人工智能行业源代码防数据防泄密需求分析
    stream流中 filter方法自定义过滤方式
    R语言 一种功能强大的数据分析、统计建模 可视化 免费、开源且跨平台 的编程语言
    Vue中使用Web Serial API连接串口,实现通信交互
  • 原文地址:https://blog.csdn.net/qq_43585922/article/details/127230861