• 列式数据库管理系统——ClickHouse(version:22.7.1 环境部署)


    一、概述

    ClickHouse是一个用于联机分析(OLAP)的列式数据库管理系统(DBMS)。ClickHouse不单单是一个数据库, 它是一个数据库管理系统

    官方文档:https://clickhouse.com/docs/zh
    GitHub地址:https://github.com/ClickHouse/ClickHouse

    1)ClickHouse主要功能

    • DDL ( 数据定义语言 ):可以动态地创建、修改或删除数据库、表和视图,而无须重启服务;
    • DML ( 数据操作语言 ):可以动态查询、插入、修改或删除数据;
    • 权限控制:可以按照用户粒度设置数据库或者表的操作权限,保障数据的安全性;
    • 数据备份与恢复:提供了数据备份导出与导入恢复机制,满足生产环境的要求;
    • 分布式管理:提供集群模式,能够自动管理多个数据库节点。

    2)ClickHouse的特性

    • 列式数据库管理系统:在一个真正的列式数据库管理系统中,除了数据本身外不应该存在其他额外的数据。这意味着为了避免在值旁边存储它们的长度«number»,你必须支持固定长度数值类型。
    • 数据压缩:除了在磁盘空间和CPU消耗之间进行不同权衡的高效通用压缩编解码器之外,ClickHouse还提供针对特定类型数据的专用编解码器,这使得ClickHouse能够与更小的数据库(如时间序列数据库)竞争并超越它们。
    • 数据的磁盘存储:许多的列式数据库(如 SAP HANA, Google PowerDrill)只能在内存中工作,这种方式会造成比实际更多的设备预算。ClickHouse被设计用于工作在传统磁盘上的系统,它提供每GB更低的存储成本,但如果可以使用SSD和内存,它也会合理的利用这些资源。
    • 多核心并行处理:ClickHouse会使用服务器上一切可用的资源,从而以最自然的方式并行处理大型查询。
    • 多主架构:HDFS、Spark、HBase和Elasticsearch这类分布式系统,都采用了Master-Slave主从架构,由一个管控节点作为Leader统筹全局。而ClickHouse则由于它的集群架构和其他数据库不同,这种架构使得它是一个多主架构。
    • 多服务器分布式处理:在ClickHouse中,数据可以保存在不同的shard上,每一个shard都由一组用于容错的replica组成,查询可以并行地在所有shard上进行处理。
    • 支持SQL:ClickHouse支持一种基于SQL的声明式查询语言,它在许多情况下与ANSI SQL标准相同。支持的查询GROUP BY, ORDER BY, FROM, JOIN, IN以及非相关子查询。相关(依赖性)子查询和窗口函数暂不受支持,但将来会被实现。
    • 多样化的表引擎:ClickHouse和mysql一样,也将存储部分进行了抽象,把存储引擎作为一层独立的接口。所以说Clickhouse实现了很多种表引擎,比如mergetree,log,memory等类型的引擎,每一种表引擎都有着各自的特点,用户可以根据实际业务场景的要求,选择合适的表引擎使用。
    • 向量引擎:为了高效的使用CPU,数据不仅仅按列存储,同时还按向量(列的一部分)进行处理,这样可以更加高效地使用CPU。
    • 实时的数据更新:ClickHouse支持在表中定义主键。为了使查询能够快速在主键中进行范围查找,数据总是以增量的方式有序的存储在MergeTree中。因此,数据可以持续不断地高效的写入到表中,并且写入的过程中不会存在任何加锁的行为。
    • 在线查询:按照主键对数据进行排序,这将帮助ClickHouse在几十毫秒以内完成对数据特定值或范围的查找。
    • 支持数据复制和数据完整性:ClickHouse使用异步的多主复制技术。当数据被写入任何一个可用副本后,系统会在后台将数据分发给其他副本,以保证系统在不同副本上保持相同的数据。
    • 角色的访问控制:ClickHouse使用SQL查询实现用户帐户管理,并允许角色的访问控制,类似于ANSI SQL标准和流行的关系数据库管理系统。
    • 限制
      1. 没有完整的事务支持。
      2. 缺少高频率,低延迟的修改或删除已存在数据的能力。仅能用于批量删除或修改数据,但这符合 GDPR(General Data Protection Regulation:通用数据保护条例)。
      3. 稀疏索引使得ClickHouse不适合通过其键检索单行的点查询。

    3)稠密索引和稀疏索引

    • 稠密索引:即每一条记录,对应一个索引字段。稠密索引,访问速度非常块,但是维护成本大。根据索引字段不一样,有候选键索引和非候选键索引之分。

    • 稀疏索引:相对稠密索引,稀疏索引并没有每条记录,建立了索引字段,而是把记录分为若干个块,为每个块建立一条索引字段。稀疏索引字段,要求索引字段是按顺序排序的,否则无法有效索引。稀疏索引,数据查询速度较慢,但是存储空间小维护成本低

    二、ClickHouse库表引擎

    1)数据库引擎

    默认情况下,ClickHouse使用Atomic数据库引擎。它提供了可配置的table engines和SQL dialect。目前支持的数据库引擎有以下几种:

    • Atomic
    • MySQL
    • SQLite
    • PostgreSQL
    • MaterializeMySQL
    • Lazy
    • MaterializedPostgreSQL
    • Replicated

    关于更多,请查看官方文档:https://clickhouse.com/docs/zh/engines/database-engines/

    2)表引擎

    表引擎(即表的类型)决定了:

    • 数据的存储方式和位置,写到哪里以及从哪里读取数据
    • 支持哪些查询以及如何支持。
    • 并发数据访问。
    • 索引的使用(如果存在)。
    • 是否可以执行多线程请求。
    • 数据复制参数。

    表引擎类型

    1、MergeTree类型引擎

    适用于高负载任务的最通用和功能最强大的表引擎。这些引擎的共同特点是可以快速插入数据并进行后续的后台数据处理。 MergeTree系列引擎支持数据复制(使用Replicated* 的引擎版本),分区和一些其他引擎不支持的其他功能。该类型的引擎:MergeTree、ReplacingMergeTree、SummingMergeTree、AggregatingMergeTree、CollapsingMergeTree、VersionedCollapsingMergeTree、GraphiteMergeTree

    2、log类型引擎

    这些引擎是为了需要写入许多小数据量(少于一百万行)的表的场景而开发的。具有最小功能的轻量级引擎。当您需要快速写入许多小表(最多约100万行)并在以后整体读取它们时,该类型的引擎是最有效的。该类型的引擎:TinyLog、StripeLog、Log

    3、集成类型引擎

    ClickHouse 提供了多种方式来与外部系统集成,包括表引擎。像所有其他的表引擎一样,使用CREATE TABLE或ALTER TABLE查询语句来完成配置。 支持的集成引擎:Kafka、MySQL、ODBC、JDBC、HDFS等。

    4、用于其他特定功能的引擎

    特定功能的引擎有如下几种:Distributed、MaterializedView、Dictionary、Merge、File、Null、Set、Join、URL、View、Memory、Buffer

    三、ClickHouse架构

    Clickhouse的集群架构是和其他的数据集群有一定的区别,他的集群能力是表级别的,而我们熟知的大数据体系,比如hadoop系列的集群都是服务级别的。例如,一个hdfs集群,所有文件都会切片、备份;而Clickhouse集群中,建表时也可以自己决定用不用,也就是说其实Clickhouse单节点就能存活。

    • Parser与Interpreter

    Parser和Interpreter是非常重要的两组接口:Parser分析器是将sql语句已递归的方式形成AST语法树的形式,并且不同类型的sql都会调用不同的parse实现类。而Interpreter解释器则负责解释AST,并进一步创建查询的执行管道。Interpreter解释器的作用就像Service服务层一样,起到串联整个查询过程的作用,它会根据解释器的类型,聚合它所需要的资源。首先它会解析AST对象;然后执行"业务逻辑" ( 例如分支判断、设置参数、调用接口等 );最终返回IBlock对象,以线程的形式建立起一个查询执行管道。

    • 表引擎

    表引擎是ClickHouse的一个显著特性,clickhouse有很多种表引擎。不同的表引擎由不同的子类实现。表引擎是使用IStorage接口的,该接口定义了DDL ( 如ALTER、RENAME、OPTIMIZE和DROP等 ) 、read和write方法,它们分别负责数据的定义、查询与写入。

    • DataType

    数据的序列化和反序列化工作由DataType负责。根据不同的数据类型,IDataType接口会有不同的实现类。DataType虽然会对数据进行正反序列化,但是它不会直接和内存或者磁盘做交互,而是转交给Column和Filed处理。

    • Column与Field

    Column和Field是ClickHouse数据最基础的映射单元。作为一款百分之百的列式存储数据库,ClickHouse按列存储数据,内存中的一列数据由一个Column对象表示。Column对象分为接口和实现两个部分,在IColumn接口对象中,定义了对数据进行各种关系运算的方法,例如插入数据的insertRangeFrom和insertFrom方法、用于分页的cut,以及用于过滤的filter方法等。而这些方法的具体实现对象则根据数据类型的不同,由相应的对象实现,例如ColumnString、ColumnArray和ColumnTuple等。在大多数场合,ClickHouse都会以整列的方式操作数据,但凡事也有例外。如果需要操作单个具体的数值 ( 也就是单列中的一行数据 ),则需要使用Field对象,Field对象代表一个单值。与Column对象的泛化设计思路不同,Field对象使用了聚合的设计模式。在Field对象内部聚合了Null、UInt64、String和Array等13种数据类型及相应的处理逻辑。

    • Block

    ClickHouse内部的数据操作是面向Block对象进行的,并且采用了流的形式。虽然Column和Filed组成了数据的基本映射单元,但对应到实际操作,它们还缺少了一些必要的信息,比如数据的类型及列的名称。于是ClickHouse设计了Block对象,Block对象可以看作数据表的子集。Block对象的本质是由数据对象、数据类型和列名称组成的三元组,即Column、DataType及列名称字符串。Column提供了数据的读取能力,而DataType知道如何正反序列化,所以Block在这些对象的基础之上实现了进一步的抽象和封装,从而简化了整个使用的过程,仅通过Block对象就能完成一系列的数据操作。在具体的实现过程中,Block并没有直接聚合Column和DataType对象,而是通过ColumnWith TypeAndName对象进行间接引用。

    四、ClickHouse环境部署

    1)环境准备

    IP hostname 角色
    192.168.182.110 local-168-182-110 node1、zookeeper
    192.168.182.111 local-168-182-111 node2、zookeeper
    192.168.182.112 local-168-182-112 node3、zookeeper
    192.168.182.113 local-168-182-110 node4

    2)安装JDK(zookeeper需要JDK环境)

    官网下载:https://www.oracle.com/java/technologies/downloads/
    官网下载需要登录,这里提供一个百度云下载地址:

    链接:https://pan.baidu.com/s/1-rgW-Z-syv24vU15bmMg1w
    提取码:8888

    cd /opt/software/
    
    # 解压
    tar -xf jdk-8u212-linux-x64.tar.gz -C /opt/server/
    
    # 在文件加入环境变量/etc/profile
    export JAVA_HOME=/opt/server/jdk1.8.0_212
    export PATH=$JAVA_HOME/bin:$PATH
    export CLASSPATH=.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar
    # source加载
    source /etc/profile
    # 查看jdk版本
    java -version
    

    其它节点

    scp -r /opt/server/jdk1.8.0_212 local-168-182-111:/opt/server/
    scp -r /opt/server/jdk1.8.0_212 local-168-182-112:/opt/server/
    
    # 设置环境变量
    # 在文件加入环境变量/etc/profile
    export JAVA_HOME=/opt/server/jdk1.8.0_212
    export PATH=$JAVA_HOME/bin:$PATH
    export CLASSPATH=.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar
    # source加载
    source /etc/profile
    # 查看jdk版本
    java -version
    

    3)zookeeper安装

    想了解更多zookeeper,可以参考我之前的文章:分布式开源协调服务——Zookeeper

    zookeeper在clickhouse中主要用在副本表数据的同步(ReplicatedMergeTree引擎)以及分布式表(Distributed)的操作上。

    1、下载

    mkdir /opt/software && cd /opt/software
    wget https://dlcdn.apache.org/zookeeper/zookeeper-3.8.0/apache-zookeeper-3.8.0-bin.tar.gz --no-check-certificate
    mkdir -p /opt/server/ && tar -xf  apache-zookeeper-3.8.0-bin.tar.gz -C /opt/server/
    

    2、配置环境变量

    vi /etc/profile
    export ZOOKEEPER_HOME=/opt/server/apache-zookeeper-3.8.0-bin/
    export PATH=$ZOOKEEPER_HOME/bin:$PATH
    
    source /etc/profile
    

    3、配置

    cd $ZOOKEEPER_HOME
    cp conf/zoo_sample.cfg conf/zoo.cfg
    mkdir $ZOOKEEPER_HOME/data
    cat >conf/zoo.cfg<<EOF
    # tickTime:Zookeeper 服务器之间或客户端与服务器之间维持心跳的时间间隔,也就是每个 tickTime 时间就会发送一个心跳。tickTime以毫秒为单位。session最小有效时间为tickTime*2
    tickTime=2000
    
    # Zookeeper保存数据的目录,默认情况下,Zookeeper将写数据的日志文件也保存在这个目录里。不要使用/tmp目录
    dataDir=/opt/server/apache-zookeeper-3.8.0-bin/data
    
    # 端口,默认就是2181
    clientPort=2181
    
    # 集群中的follower服务器(F)与leader服务器(L)之间初始连接时能容忍的最多心跳数(tickTime的数量),超过此数量没有回复会断开链接
    initLimit=10
    
    # 集群中的follower服务器与leader服务器之间请求和应答之间能容忍的最多心跳数(tickTime的数量)
    syncLimit=5
    
    # 最大客户端链接数量,0不限制,默认是0
    maxClientCnxns=60
    
    # zookeeper集群配置项,server.1,server.2,server.3是zk集群节点;hadoop-node1,hadoop-node2,hadoop-node3是主机名称;2888是主从通信端口;3888用来选举leader
    server.1=local-168-182-110:2888:3888
    server.2=local-168-182-111:2888:3888
    server.3=local-168-182-112:2888:3888
    EOF
    

    4、配置myid

    echo 1 > $ZOOKEEPER_HOME/data/myid
    

    5、将配置推送到其它节点

    scp -r $ZOOKEEPER_HOME local-168-182-111:/opt/server/
    scp -r $ZOOKEEPER_HOME local-168-182-112:/opt/server/
    # 也需要添加环境变量和修改myid,local-168-182-111的myid设置2,local-168-182-112的myid设置3
    

    6、启动服务

    cd $ZOOKEEPER_HOME
    # 启动
    ./bin/zkServer.sh start
    # 查看状态
    ./bin/zkServer.sh status
    

    4)ClickHouse集群安装

    单机版安装很简单,可以参考官方文档:https://clickhouse.com/docs/zh/getting-started/install

    【方案一】

    在每个节点创建一个数据表,作为一个数据分片,使用ReplicatedMergeTree表引擎实现数据副本,而分布表作为数据写入和查询的入口。这是最常见的集群实现方式。

    1、添加yum源并安装clickhouse

    yum install -y yum-utils
    yum-config-manager --add-repo https://packages.clickhouse.com/rpm/clickhouse.repo
    yum install -y clickhouse-server clickhouse-client
    

    2、修改配置参数

    修改配置文件路径权限

    chmod -R 755 /etc/clickhouse-server/
    

    修改/etc/clickhouse-server/config.xml,是本地和远程可登陆

    <listen_host>0.0.0.0</listen_host>
    

    修改/etc/clickhouse-server/users.xml,配置密码,其它参数可以根据业务场景进行配置,在64行左右

    <password>123456</password>
    

    【温馨提示】如果是部署单机版,现在已经配置好了,启动就OK了,这里是部署集群模式,所以还得继续配置。

    clickhouse⾼可⽤配置主要⽤到metrika.xml,默认路径:/etc/metrika.xml。如果想调整文件路径,需要在/etc/clickhouse-server/config.xml文件中使用 include_from 来调整(注意位置)。

    <clickhouse>
    <!-- 默认是没有的,直接新增就行 -->
    <include_from>/etc/metrika.xml</include_from>
    <!--- 讲默认的配置删掉 -->
    <remote_servers incl="clickhouse_remote_servers" />
    <zookeeper incl="zookeeper" optional="true" />
    <macros incl="macros" optional="true" />
    <!-- 删掉默认配置 -->
    <compression incl="clickhouse_compression" optional="true" />
    
    </clickhouse>
    

    【温馨提示】当然也可以直接在/etc/clickhouse-server/config.xml文件里配置,不配置额外的metrika.xml,但是最好还是配置metrika.xml,这样看起来比较简洁。

    创建 /etc/metrika.xml 配置文件,内容如下:

    <yandex>
        <!--ck集群节点-->
        <clickhouse_remote_servers>
            <!-- 集群名称 -->
            <ck_cluster_2022>
                <!--shard 1(分片1)-->
                <shard>
                    <weight>1</weight>
                    <!-- internal_replication这个参数是控制写入数据到分布式表时,分布式表会控制这个写入是否的写入到所有副本中,这里设置false,就是只会写入到第一个replica,其它的通过zookeeper同步 -->
                    <internal_replication>false</internal_replication>
                    <replica>
                        <host>local-168-182-110</host>
                        <port>9000</port>
                        <user>default</user>
                        <password>123456</password>
                    </replica>
                    <!--replicat 1(副本 1)-->
                    <replica>
                        <host>local-168-182-111</host>
                        <port>9000</port>
                        <user>default</user>
                        <password>123456</password>
                    </replica>
                </shard>
                <!--shard 2(分片2)-->
                <shard>
                    <weight>1</weight>
                    <internal_replication>false</internal_replication>
                    <replica>
                        <host>local-168-182-113</host>
                        <port>9000</port>
                        <user>default</user>
                        <password>123456</password>
                    </replica>
                    <!--replicat 2(副本 2)-->
                    <replica>
                        <host>local-168-182-112</host>
                        <port>9000</port>
                        <user>default</user>
                        <password>123456</password>
                    </replica>
                </shard>
            </ck_cluster_2022>
        </clickhouse_remote_servers>
        <!--zookeeper相关配置-->
        <zookeeper>
            <node index="1">
                <host>local-168-182-110</host>
                <port>2181</port>
            </node>
            <node index="2">
                <host>local-168-182-111</host>
                <port>2181</port>
            </node>
            <node index="3">
                <host>local-168-182-112</host>
                <port>2181</port>
            </node>
        </zookeeper>
        <macros>
            <!-- 本节点副本名称,创建复制表时有用,每个节点不同,整个集群唯一,建议使用主机名+副本+分片) ,第一个分片+第一个副本,在当前节点上-->
            <shard>01</shard>        		
            <replica>local-168-182-110-01-1</replica>
        </macros>
        <!-- 监听网络 -->
        <networks>
            <ip>::/0</ip>
        </networks>
        <!--压缩相关配置-->
        <clickhouse_compression>
            <case>
                <min_part_size>1073741824</min_part_size>
                <min_part_size_ratio>0.01</min_part_size_ratio>
                <method>lz4</method>
                <!--压缩算法lz4压缩比zstd快, 更占磁盘-->
            </case>
        </clickhouse_compression>
    </yandex>
    

    【温馨提示】或者把配置文件放在/etc/clickhouse/conf.d/下也可以,会和config.xml里面配置合并
    其它节点配置

    scp /etc/clickhouse-server/config.xml local-168-182-111:/etc/clickhouse-server/
    scp /etc/clickhouse-server/users.xml  local-168-182-111:/etc/clickhouse-server/
    scp /etc/metrika.xml local-168-182-111:/etc/
    
    scp /etc/clickhouse-server/config.xml local-168-182-112:/etc/clickhouse-server/
    scp /etc/clickhouse-server/users.xml  local-168-182-112:/etc/clickhouse-server/
    scp /etc/metrika.xml local-168-182-112:/etc/
    
    scp /etc/clickhouse-server/config.xml local-168-182-113:/etc/clickhouse-server/
    scp /etc/clickhouse-server/users.xml  local-168-182-113:/etc/clickhouse-server/
    scp /etc/metrika.xml local-168-182-113:/etc/
    

    【温馨提示】记得修改/etc/metrika.xml文件里的clickhouse.macros

    <!-- node1 -->
    <macros>
        <!-- 本节点副本名称,创建复制表时有用,每个节点不同,整个集群唯一,建议使用主机名+副本+分片) ,第一个分片+第一个副本,在当前节点上-->
        <shard>01</shard>        		
        <replica>local-168-182-110-01-1</replica>
    </macros>
    
    <!-- node2 -->
    <macros>
        <!-- 本节点副本名称,创建复制表时有用,每个节点不同,整个集群唯一,建议使用主机名+副本+分片) ,第一个分片+第一个副本,在当前节点上-->
        <shard>01</shard>        		
        <replica>local-168-182-111-01-2</replica>
    </macros>
    
    <!-- node3 -->
    <macros>
        <!-- 本节点副本名称,创建复制表时有用,每个节点不同,整个集群唯一,建议使用主机名+副本+分片) ,第一个分片+第一个副本,在当前节点上-->
        <shard>02</shard>        		
        <replica>local-168-182-112-02-2</replica>
    </macros>
    
    <!-- node4 -->
    <macros>
        <!-- 本节点副本名称,创建复制表时有用,每个节点不同,整个集群唯一,建议使用主机名+副本+分片) ,第一个分片+第一个副本,在当前节点上-->
        <shard>02</shard>        		
        <replica>local-168-182-113-02-1</replica>
    </macros>
    

    3、启动服务

    systemctl restart clickhouse-server ; systemctl status clickhouse-server
    clickhouse-client -u default --password 123456 --port 9000 -h local-168-182-110 --multiquery
    
    select * from system.clusters;
    

    登录web界面

    http://local-168-182-110:8123/play

    主要字段说明:

    cluster: 集群的命名
    shard_num: 分片的编号
    shard_weight: 分片的权重
    replica_num: 副本的编号
    host_name: 机器的host名称
    host_address: 机器的ip地址
    port: clickhouse集群的端口
    is_local: 是否为你当前查询本地
    user: 创建用户
    

    【温馨提示】clickhouse安装后,默认的数据目录在/var/lib/clickhouse,如果需要修改,修改/etc/rc.d/init.d/clickhouse-server这个文件

    4、卸载

    # 卸载及删除安装文件(需root权限)
    yum list installed | grep clickhouse
    yum remove -y clickhouse-server clickhouse-client
    rm -rf /var/lib/clickhouse
    rm -rf /etc/clickhouse-*
    rm -rf /var/log/clickhouse-server
    

    【方案二】

    在每个节点创建一个数据表,作为一个数据分片,分布表同时负责分片和副本的数据写入工作。这种实现方案下,不需要使用复制表,但分布表节点需要同时负责分片和副本的数据写入工作,它很有可能称为写入的单点瓶颈。

    ZooKeeper不是一个严格的要求:在某些简单的情况下,您可以通过将数据写入应用程序代码中的所有副本来复制数据。 这种方法是不建议的,在这种情况下,ClickHouse将无法保证所有副本上的数据一致性。 因此需要由您的应用来保证这一点。

    【方案三】

    在每个节点创建一个数据表,作为一个数据分片,同时创建两个分布表,每个分布表(Distributed)节点只纳管一半的数据。副本的实现仍需要借助ReplicatedMergeTree类表引擎。
    创建库表

    【方案四】

    在每个节点创建两个数据表,同一数据分片的两个副本位于不同节点上,每个分布式表纳管一般的数据。这种方案可以在更少的节点上实现数据分布与冗余,但是部署上略显繁琐

    【总结】

    • CH(ClickHouse)的分片与副本功能完全靠配置文件实现,无法自动管理,所以当集群规模较大时,集群运维成本较高
    • 数据副本依赖ZooKeeper实现同步,当数据量较大时,ZooKeeper可能会称为瓶颈
    • 如果资源充足,建议使用方案一,主副本和副副本位于不同节点,以更好地实现读写分离与负载均衡
    • 如果资源不够充足,可以使用方案四,每个节点承载两个副本,但部署方式上略复杂

    后续会针以上几个方案使用案例讲解,请小伙伴耐心等待,有疑问的小伙伴欢迎给我留言哦~

  • 相关阅读:
    object sender和EventArgs e含义
    将十进制小数转化为二进制小数的方法
    什么是类、对象、方法、属性、类的成员
    asp.net代码中尖括号和百分号的含义
    打开某个AVI文件,explorer.exe遇到问题需要关闭的解决方法
    中国娱乐学习门户负责人吴晓林讲解项目
    系统流程图与业务流程图
    如何去掉Zblog的版权信息(powered by)
    利用教育游戏丰富与深化综合实践活动课程教与学的理论与实践研究 课题
    浅析C# 中object sender与EventArgs e(转)
  • 原文地址:https://www.cnblogs.com/liugp/p/16542039.html
Copyright © 2020-2023  润新知