【ClickHouse内核】Select语句的执行链路

目录

概述

服务器实例启动逻辑

语句的执行链路

结论


概述

ClickHouse 简要整体架构图

 

 

服务器实例启动逻辑

源码存放目录

 

ClickHouse程序启动服务,绑定端口逻辑

// 文件名称: /dbms/programs/server/Server.cppint Server::main(){    // 初始化上下文, 上下文包含查询执行所依赖的所有内容:设置可用函数、数据类型、聚合函数、数据库等等    global_context = std::make_unique<Context>(Context::createGlobal());    global_context->setApplicationType(Context::ApplicationType::SERVER);         // zk初始化    zkutil::ZooKeeperNodeCache main_config_zk_node_cache([&] { return global_context->getZooKeeper(); });        //其他config的初始化    //...        //绑定端口,对外提供服务    auto address = make_socket_address(host, port);    socket.bind(address, /* reuseAddress = */ true);    //根据网络协议建立不同的server类型    //现在支持的server类型有:HTTP,HTTPS,TCP,Interserver,mysql    /// HTTP    create_server("http_port", [&](UInt16 port)    {        Poco::Net::ServerSocket socket;        auto address = socket_bind_listen(socket, listen_host, port);        auto handler_factory = createDefaultHandlerFatory<HTTPHandler>(*this,                                 "HTTPHandler-factory");        servers.emplace_back(std::make_unique<Poco::Net::HTTPServer>(...);    });    /// HTTPS    create_server("https_port", [&](UInt16 port)    {        Poco::Net::SecureServerSocket socket;        auto address = socket_bind_listen(socket, listen_host, port, /* secure = */ true);        servers.emplace_back(std::make_unique<Poco::Net::HTTPServer>(...);    });    /// TCP    create_server("tcp_port", [&](UInt16 port)    {        Poco::Net::ServerSocket socket;        auto address = socket_bind_listen(socket, listen_host, port);        servers.emplace_back(std::make_unique<Poco::Net::TCPServer>(...);    });    /// TCP with SSL    create_server("tcp_port_secure", [&](UInt16 port)    {        Poco::Net::SecureServerSocket socket;        auto address = socket_bind_listen(socket, listen_host, port, /* secure = */ true);        servers.emplace_back(std::make_unique<Poco::Net::TCPServer>(...);    });    /// Interserver IO HTTP    create_server("interserver_http_port", [&](UInt16 port)    {        Poco::Net::ServerSocket socket;        auto address = socket_bind_listen(socket, listen_host, port);        servers.emplace_back(std::make_unique<Poco::Net::HTTPServer>(...);    });    create_server("interserver_https_port", [&](UInt16 port)    {        Poco::Net::SecureServerSocket socket;        auto address = socket_bind_listen(socket, listen_host, port, /* secure = */ true);        servers.emplace_back(std::make_unique<Poco::Net::HTTPServer>(...);    });        create_server("mysql_port", [&](UInt16 port)    {        Poco::Net::ServerSocket socket;        auto address = socket_bind_listen(socket, listen_host, port, /* secure = */ true);        servers.emplace_back(std::make_unique<Poco::Net::TCPServer>(...);    });    /// Prometheus (if defined and not setup yet with http_port)    create_server("prometheus.port", [&](UInt16 port)    {        Poco::Net::ServerSocket socket;        auto address = socket_bind_listen(socket, listen_host, port);        auto handler_factory = new HTTPRequestHandlerFactoryMain(...);        handler_factory->addHandler<PrometheusHandlerFactory>(async_metrics);        servers.emplace_back(std::make_unique<Poco::Net::HTTPServer>(...);    });        //启动server    for (auto & server : servers)            server->start();   }

 

归纳总结主要逻辑说明

  • 解析应用的配置文件信息;
  • 初始化上下文(包含查询执行所依赖的所有内容:设置可用函数、数据类型、聚合函数、数据库等等);
  • 初始化Zookeeper(分布式DDL执行、ReplicatedMergeTree表主备节点之间的状态同步);
  • 常规配置初始化(flags目录、用户文件目录、dictionaries库目录);
  • 绑定服务端的端口,根据网络协议初始化Handler,对客户端提供服务。

 

 

语句的执行链路

TCP端口响应用户SQL请求逻辑

// 文件名称: /dbms/programs/server/TCPHandler.cppvoid TCPHandler::runImpl(){    //设置socket属性    socket().setReceiveTimeout(global_receive_timeout);    socket().setSendTimeout(global_send_timeout);    socket().setNoDelay(true);        //实例化套接字对应的输入和输出流缓冲区    in = std::make_shared<ReadBufferFromPocoSocket>(socket());    out = std::make_shared<WriteBufferFromPocoSocket>(socket());        while (1){        // 接收请求报文        receivePacket();                // 执行Query        state.io = executeQuery(state.query, *query_context, false, state.stage, may_have_embedded_data);            //根据Query种类来处理不同的Query        //处理insert Query        processInsertQuery();        //使用pipeline处理的Query和结果发送客户端        processOrdinaryQueryWithProcessors();        //不使用pipeline处理的Query和结果发送客户端        processOrdinaryQuery();    }}

 

执行结果发送给客户端逻辑

// 文件名称: /dbms/programs/server/TCPHandler.cppvoid TCPHandler::processOrdinaryQuery(){    /// Pull query execution result, if exists, and send it to network.    if (state.io.in)    {        /// This allows the client to prepare output format        if (Block header = state.io.in->getHeader())            sendData(header);        AsynchronousBlockInputStream async_in(state.io.in);        while (true)        {            if (async_in.poll(...))            {                const auto block = async_in.read();                sendData(block);            }        }        async_in.readSuffix();        sendData({});    }    state.io.onFinish();}

 

归纳总结主要逻辑说明

客户端发来的请求根据各自网络协议的不同,转发给对应的“XXXHandler”来进行的,server在启动的时候“XXXHandler”会被初始化并绑定在指定端口中。

我们以TCPHandler为例,看看服务端是如何处理客户端发来的请求的,重点关注TCPHandler::runImpl 的函数实现:

  • 设置Socket的属性;
  • 初始化输入和输出流的缓冲区;
  • 接受请求报文,拆包;
  • 执行Query(包括整个词法语法分析,Query重写,物理计划生成和生成结果);
  • 把Query结果保存到输出流,然后发送到Socket的缓冲区,等待发送回客户端。

 

SQL执行逻辑

// 文件名称: /dbms/src/Interpreters/executeQuery.cppstatic std::tuple<ASTPtr, BlockIO> executeQueryImpl(){    // 构造SQL解析器    ParserQuery parser(end, settings.enable_debug_queries);    ASTPtr ast;    // 解析Query并转化为AST(抽象语法树)    ast = parseQuery(parser, begin, end, "", max_query_size);    // 根据AST的类型生成interpreter实例    auto interpreter = InterpreterFactory::get(ast, context, stage);        // SQL资源管理,检查当前节点是否还能处理SQL    std::shared_ptr<const EnabledQuota> quota;    if (!interpreter->ignoreQuota())    {        quota = context.getQuota();        if (quota)        {            quota->used(Quota::QUERIES, 1);            quota->checkExceeded(Quota::ERRORS);        }    }            // interpreter执行AST,结果是BlockIO    res = interpreter->execute();    // 返回结果是抽象语法树和解析后的结果组成的二元组,让上层把数据返回给客户端    return std::make_tuple(ast, res);}

 

归纳总结主要逻辑说明

  • 通过Parser,把Query解析成AST(抽象语法树)。
  • InterpreterFactory根据AST生成对应的Interpreter实例并进行AST优化改写。
  • 检测资源使用情况,判断节点是否能够执行这个Query。
  • AST是由Interpreter来解析的,执行结果是一个BlockIO,并把BlockIO返回给上层,让上层发送给客户端。

 

这个函数是执行SQL的核心,那么我们把这个函数拆解一个一个单独的讲解。

 

Parser(解析器)中抽象语法树(AST)的生成逻辑

ClickHouse选择采用手写一个递归下降的Parser来对SQL进行解析,生成的结果是这个SQL对应的抽象语法树(AST),抽象语法树由表示各个操作的节点(IAST)表示。而本节主要介绍Parser背后的核心逻辑:

ClickHouse的Parser利用lexer将扫描用户下发的SQL字符串,将其分割为一个个的Token, token_iterator 即一个Token流迭代器,然后parser再对Token流进行解析生成AST抽象语法树。

// 文件名称: dbms/src/Parsers/parseQuery.cppASTPtr tryParseQuery(){    // Token为lexer词法分析后的基本单位,词法分析后生成的是Token流    Tokens tokens(pos, end, max_query_size);    IParser::Pos token_iterator(tokens, max_parser_depth);    ASTPtr res;        // Token流经过语法分析生成AST抽象语法树    bool parse_res = parser.parse(token_iterator, res, expected);    return res;}

 

通过上面的代码,我们可以看到核心的代码是Parser中的parse方法,我们看下parse的实现方式

bool ParserQuery::parseImpl(Pos & pos, ASTPtr & node, Expected & expected){    /*    ShowTablesQuery、SelectWithUnionQuery、TablePropertiesQuery、       DescribeTableQuery、    ShowProcesslistQuery、CreateQuery、AlterQuery、RenameQuery、DropQuery、CheckQuery、    OptimizeQuery、KillQueryQuery、WatchQuery、        ShowCreateAccessEntityQuery、    ShowGrantsQuery、ShowQuotasQuery、ShowRowPoliciesQuery    */    ParserQueryWithOutput query_with_output_p(enable_explain);    // Parse INSERT INTO [db.]table xxxxx    ParserInsertQuery insert_p(end);    // Parse Query USE db    ParserUseQuery use_p;    // Parse SET name1 = value1, name2 = value2    ParserSetQuery set_p;    // Parse System Query    ParserSystemQuery system_p;    // Parse CREATE USER | ALTER USER    ParserCreateUserQuery create_user_p;    // Parse CREATE ROLE | ALTER ROLE    ParserCreateRoleQuery create_role_p;    // Parse CREATE QUOTA | ALTER QUOTA    ParserCreateQuotaQuery create_quota_p;    // Parse CREATE [ROW] POLICY | ALTER [ROW] POLICY    ParserCreateRowPolicyQuery create_row_policy_p;    // Parse CREATE SETTINGS PROFILE | ALTER SETTINGS PROFILE    ParserCreateSettingsProfileQuery create_settings_profile_p;    // Parse DROP USER | DROP ROLE | DROP QUOTA | DROP [SETTINGS] PROFILE | DROP [ROW] POLICY    ParserDropAccessEntityQuery drop_access_entity_p;    // Parse GRANT access_type | REVOKE access_type    ParserGrantQuery grant_p;    // Parse SET ROLE | SET DEFAULT ROLE    ParserSetRoleQuery set_role_p;    bool res = query_with_output_p.parse(pos, node, expected)        || insert_p.parse(pos, node, expected)        || use_p.parse(pos, node, expected)        || set_role_p.parse(pos, node, expected)        || set_p.parse(pos, node, expected)        || system_p.parse(pos, node, expected)        || create_user_p.parse(pos, node, expected)        || create_role_p.parse(pos, node, expected)        || create_quota_p.parse(pos, node, expected)        || create_row_policy_p.parse(pos, node, expected)        || create_settings_profile_p.parse(pos, node, expected)        || drop_access_entity_p.parse(pos, node, expected)        || grant_p.parse(pos, node, expected);    return res;}

 

通过上面的代码可知,ClickHouse数据库大致把SQL类型分为下面几种情况

  • QueryWithOutput
  • InsertQuery
  • UseQuery
  • SetQuery
  • SystemQuery
  • CreateUserQuery
  • CreateRoleQuery
  • CreateQuotaQuery
  • CreateRowPolicyQuery
  • CreateSettingsProfileQuery
  • DropAccessEntityQuery
  • GrantQuery
  • SetRoleQuery

 

每一种SQL类型都有自己专属的解析器,如果遇到接收到一个SQL,则调用专门的解析器进行SQL解析。以QueryWithOutput中的SELECT解析器为例

// 文件名称: dbms/src/Parsers/ParserSelectQuery.cppbool ParserSelectQuery::parseImpl(Pos & pos, ASTPtr & node, Expected & expected){    ParserKeyword s_select("SELECT");    ParserKeyword s_distinct("DISTINCT");    ParserKeyword s_from("FROM");    ParserKeyword s_prewhere("PREWHERE");    ParserKeyword s_where("WHERE");    ParserKeyword s_group_by("GROUP BY");    ParserKeyword s_with("WITH");    ParserKeyword s_totals("TOTALS");    ParserKeyword s_having("HAVING");    ParserKeyword s_order_by("ORDER BY");    ParserKeyword s_limit("LIMIT");    ParserKeyword s_settings("SETTINGS");    ParserKeyword s_by("BY");    ParserKeyword s_rollup("ROLLUP");    ParserKeyword s_cube("CUBE");    ParserKeyword s_top("TOP");    ParserKeyword s_with_ties("WITH TIES");    ParserKeyword s_offset("OFFSET");    // ........    //依次对Token流爬取上述关键字    xxxx.parse(pos, tables, expected)    //根据语法分析结果设置AST的Expression属性,可以理解为如果SQL存在该关键字,这个关键字都会转化为AST上的一个节点    ast->setExpression(ASTSelectQuery::Expression::WITH, std::move(with_expression_list));    ast->setExpression(ASTSelectQuery::Expression::SELECT, std::move(select_expression_list));    ast->setExpression(ASTSelectQuery::Expression::TABLES, std::move(tables));    ast->setExpression(ASTSelectQuery::Expression::PREWHERE, std::move(prewhere_expression));    ast->setExpression(ASTSelectQuery::Expression::WHERE, std::move(where_expression));    ast->setExpression(ASTSelectQuery::Expression::GROUP_BY, std::move(group_expression_list));    ast->setExpression(ASTSelectQuery::Expression::HAVING, std::move(having_expression));    ast->setExpression(ASTSelectQuery::Expression::ORDER_BY, std::move(order_expression_list));    ast->setExpression(ASTSelectQuery::Expression::LIMIT_BY_OFFSET, std::move(limit_by_offset));    ast->setExpression(ASTSelectQuery::Expression::LIMIT_BY_LENGTH, std::move(limit_by_length));    ast->setExpression(ASTSelectQuery::Expression::LIMIT_BY, std::move(limit_by_expression_list));    ast->setExpression(ASTSelectQuery::Expression::LIMIT_OFFSET, std::move(limit_offset));    ast->setExpression(ASTSelectQuery::Expression::LIMIT_LENGTH, std::move(limit_length));    ast->setExpression(ASTSelectQuery::Expression::SETTINGS, std::move(settings));}

 

归纳总结主要逻辑说明

  • 通过内置的SQL解析器逐个尝试解析用户下发的SQL,如果可以解析器可以解析则使用当前解析器;
  • 解析器中通过TokenIterator进行词法分析,再Token流中爬取这些关键词;
  • 如果成功爬取,则 setExpression 函数会组装该关键字对应的AST节点。

 

Interpreter(解析器)的构建逻辑

根据抽象语法树的类型生成各种的Interpreter。

// 文件名称: /dbms/src/Interpreters/InterpreterFactory.cppstd::unique_ptr<IInterpreter> InterpreterFactory::get(...){    if (query->as<ASTSelectQuery>())        return std::make_unique<InterpreterSelectQuery>(...);    else if (query->as<ASTSelectWithUnionQuery>())        return std::make_unique<InterpreterSelectWithUnionQuery>(...);    else if (query->as<ASTInsertQuery>())        return std::make_unique<InterpreterInsertQuery>(...);    else if (query->as<ASTCreateQuery>())        return std::make_unique<InterpreterCreateQuery>(...);    else if (query->as<ASTDropQuery>())        return std::make_unique<InterpreterDropQuery>(...);    else if (query->as<ASTRenameQuery>())        return std::make_unique<InterpreterRenameQuery>(...);    else if (query->as<ASTShowTablesQuery>())        return std::make_unique<InterpreterShowTablesQuery>(...);    else if (query->as<ASTUseQuery>())        return std::make_unique<InterpreterUseQuery>(...);    else if (query->as<ASTSetQuery>())        return std::make_unique<InterpreterSetQuery>(...);    else if (query->as<ASTSetRoleQuery>())        return std::make_unique<InterpreterSetRoleQuery>(...);    else if (query->as<ASTOptimizeQuery>())        return std::make_unique<InterpreterOptimizeQuery>(...);    else if (query->as<ASTExistsTableQuery>())        return std::make_unique<InterpreterExistsQuery>(...);    else if (query->as<ASTExistsDictionaryQuery>())        return std::make_unique<InterpreterExistsQuery>(...);    else if (query->as<ASTShowCreateTableQuery>())        return std::make_unique<InterpreterShowCreateQuery>(...);    else if (query->as<ASTShowCreateDatabaseQuery>())        return std::make_unique<InterpreterShowCreateQuery>(...);    else if (query->as<ASTShowCreateDictionaryQuery>())        return std::make_unique<InterpreterShowCreateQuery>(...);    else if (query->as<ASTDescribeQuery>())        return std::make_unique<InterpreterDescribeQuery>(...);    else if (query->as<ASTExplainQuery>())        return std::make_unique<InterpreterExplainQuery>(...);    else if (query->as<ASTShowProcesslistQuery>())        return std::make_unique<InterpreterShowProcesslistQuery>(...);    else if (query->as<ASTAlterQuery>())        return std::make_unique<InterpreterAlterQuery>(...);    else if (query->as<ASTCheckQuery>())        return std::make_unique<InterpreterCheckQuery>(...);    else if (query->as<ASTKillQueryQuery>())        return std::make_unique<InterpreterKillQueryQuery>(...);    else if (query->as<ASTSystemQuery>())        return std::make_unique<InterpreterSystemQuery>(...);    else if (query->as<ASTWatchQuery>())        return std::make_unique<InterpreterWatchQuery>(...);    else if (query->as<ASTCreateUserQuery>())        return std::make_unique<InterpreterCreateUserQuery>(...);    else if (query->as<ASTCreateRoleQuery>())        return std::make_unique<InterpreterCreateRoleQuery>(...);    else if (query->as<ASTCreateQuotaQuery>())        return std::make_unique<InterpreterCreateQuotaQuery>(...);    else if (query->as<ASTCreateRowPolicyQuery>())        return std::make_unique<InterpreterCreateRowPolicyQuery>(...);    else if (query->as<ASTCreateSettingsProfileQuery>())        return std::make_unique<InterpreterCreateSettingsProfileQuery>(...);    else if (query->as<ASTDropAccessEntityQuery>())        return std::make_unique<InterpreterDropAccessEntityQuery>(...);    else if (query->as<ASTGrantQuery>())        return std::make_unique<InterpreterGrantQuery>(...);    else if (query->as<ASTShowCreateAccessEntityQuery>())        return std::make_unique<InterpreterShowCreateAccessEntityQuery>(...);    else if (query->as<ASTShowGrantsQuery>())        return std::make_unique<InterpreterShowGrantsQuery>(...);    else if (query->as<ASTShowQuotasQuery>())        return std::make_unique<InterpreterShowQuotasQuery>(...);    else if (query->as<ASTShowRowPoliciesQuery>())        return std::make_unique<InterpreterShowRowPoliciesQuery>(...);    else        throw Exception(...)}

 

AST(抽象语法树)进一步优化逻辑

InterpreterSelectQuery::InterpreterSelectQuery(....){    // 获取AST    ASTSelectQuery & query = getSelectQuery();    // 对AST做进一步语法分析,对语法树做优化重写    syntax_analyzer_result = SyntaxAnalyzer(...).analyzeSelect(...);    // 每一种Query都会对应一个特有的表达式分析器,用于爬取AST生成执行计划(操作链)    query_analyzer = std::make_unique<SelectQueryExpressionAnalyzer>(...);}

语法分析直接生成的AST转化成执行计划可能性能上并不是最优的,因此需要SyntaxAnalyzer 对其进行优化重写,在其源码中可以看到其涉及到非常多基规则优化RBO(rule based optimization) 的功能。 SyntaxAnalyzer 会逐个针对这些规则对查询进行检查,确定其是否满足转换规则,一旦满足就会对其进行转换。

SyntaxAnalyzerResultPtr SyntaxAnalyzer::analyzeSelect(...) const{    // 删除重复列    renameDuplicatedColumns(...);    // 根据配置判断是否进行谓词下推    replaceJoinedTable(...);    // 优化查询中的布尔表达式的函数    LogicalExpressionsOptimizer().perform();    // 公共子表达式的消除    QueryNormalizer(...).visit(...);    // 消除select从句后的冗余列    removeUnneededColumnsFromSelectClause(...);    // 执行标量子查询,并且用常量替代标量子查询结果    executeScalarSubqueries(...);    // 谓词表达式下推到子查询    PredicateExpressionsOptimizer(...).optimize();    // group by语句中删除内射函数调用和常量表达式    optimizeGroupBy(...);    // 删除order by后面的重复项    optimizeOrderBy(...);    // 删除order by limit后面的重复项    optimizeLimitBy(...);    // 删除using 的重复项    optimizeUsing(...);}

 

物理执行器执行物理计划

ExpressionAnalyzer类的作用可以理解为解析优化重写后的AST,然后对所要进行的操作组成一条操作链,即物理执行计划,举个例子如下所示:

/** These methods allow you to build a chain of transformations over a block, that receives values in the desired sections of the query.      *      * Example usage:      *   ExpressionActionsChain chain;      *   analyzer.appendWhere(chain);      *   chain.addStep();      *   analyzer.appendSelect(chain);      *   analyzer.appendOrderBy(chain);      *   chain.finalize();*/

上述代码把where,select,orderby操作都加入到操作链中,接下来就可以从Storage层读取Block,对Block数据应用上述操作链的操作。而执行的核心逻辑,就在对应Interpreter的 executeImpl 方法实现中,这里以select语句的Interpreter来了解下读取Block数据并且对block数据进行相应操作的流程。

 

void InterpreterSelectQuery::executeImpl(...){   // 对应Query的AST   auto & query = getSelectQuery();   // 物理计划,判断表达式是否有where,aggregate,having,order_by,litmit_by等字段   AnalysisResult expressions = ExpressionAnalysisResult(...);   // 从Storage读取数据   executeFetchColumns(...);   // 根据SQL的关键字在BlockStream流水线中执行相应的操作, 如where,aggregate,distinct, order by, order by limit, rollup|cube, having, union都分别由一个函数负责执行   executeWhere(...);   executeAggregation(...);   executeDistinct(...);   executeOrder(...);   executeLimitBy(...);   executeSubqueriesInSetsAndJoins(...);   executeMergeAggregated(...);   executeRollupOrCube(...);   executeHaving(...);   executeMergeSorted(...);   executeUnion(...);}

分析出了执行计划AnalysisResult(即物理执行计划),接下来就需要从storage层中读取数据来执行对应的操作,核心逻辑在 executeFetchColumns 中: 核心操作就是从storage层读取所要处理列的Block,并组织成BlockStream。

 

void InterpreterSelectQuery::executeFetchColumns(...){    // 实例化Block Stream    auto streams = storage->read(...)    // 读取列对应的Block,并且组织成Block Stream    streams = {std::make_shared<NullBlockInputStream>(storage->getSampleBlockForColumns(...))};    streams.back() = std::make_shared<ExpressionBlockInputStream>(...);}

读取完Block Stream之后就是对其执行各种execute操作如 executeWhere、executeAggregation、executeDistinct操作。

 

归纳总结主要逻辑说明

  • 对AST进行优化重写 ;
  • 解析重写后的AST并生成操作链(执行计划) ;
  • 从存储引擎中读取要处理的Block数据 ;
  • 对读取的Block数据应用操作链上的操作。

Interpreter处理后的结果会通过TCPHandler发送给客户端。详细代码请见上面的TCPHandler::processOrdinaryQuery函数的处理逻辑。

 

结论

上面讲述了一条查询语句在ClickHouse数据库中的执行流程和涉及到的模块,下面进行归纳总结一下。

  • 服务端接收客户端发来的SQL请求,具体形式是一个网络包,Server的协议层需要拆包把SQL解析出来;
  • Server负责初始化上下文与Network Handler,然后 Parser 对Query做词法和语法分析,解析成AST;
  • Interpreter的 SyntaxAnalyzer 会应用一些启发式规则对AST进行优化重写;
  • Interpreter的 ExpressionAnalyzer 根据上下文信息以及优化重写后的AST生成物理执行计划;
  • 物理执行计划分发到本地或者分布式的executor,各自从存储引擎中获取数据,应用执行计划;
  • Server把执行后的结果以Block流的形式输出到Socket缓冲区,Client从Socket中读取即可得到结果。

 

本文还比较粗略,还有一些细节内容会在后续文章中描述,比如说ClickHouse的表引擎的实现方式什么、Piepline处理器和调度器是如何运转的,Codegen和SIMD的实现方式等等。

 

参考资料

  • https://bohutang.me/2020/07/25/clickhouse-and-friends-parser/
  • https://blog.csdn.net/yunqiinsight/article/details/107953294
分享大数据行业的一些前沿技术和手撕一些开源库的源代码
微信公众号名称:技术茶馆
微信公众号ID    :    Night_ZW
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值