最新发布《数智时代的AI人才粮仓模型解读白皮书(2024版)》,立即领取! 了解详情
写点什么

Jupyter 在美团民宿的应用实践

  • 2019-11-30
  • 本文字数:7859 字

    阅读完需:约 26 分钟

Jupyter在美团民宿的应用实践

前言

做算法的同学对于 Kaggle 应该都不陌生,除了举办算法挑战赛以外,它还提供了一个学习、练习数据分析和算法开发的平台。Kaggle 提供了 Kaggle Kernels,方便用户进行数据分析以及经验分享。在 Kaggle Kernels 中,你可以 Fork 别人分享的结果进行复现或者进一步分析,也可以新建一个 Kernel 进行数据分析和算法开发。Kaggle Kernels 还提供了一个配置好的环境,以及比赛的数据集,帮你从配置本地环境中解放出来。Kaggle Kernels 提供给你的是一个运行在浏览器中的 Jupyter,你可以在上面进行交互式的执行代码、探索数据、训练模型等等。更多关于 Kaggle Kernels 的使用方法可以参考 Introduction to Kaggle Kernels,这里不再多做阐述。



对于比赛类的任务,使用 Kaggle Kernels 非常方便,但我们平时的主要任务还是集中在分析、处理业务数据的层面,这些数据通常比较机密并且数量巨大,所以就不能在 Kaggle Kernels 上进行此类分析。因此,大型的互联网公司非常有必要开发并维护集团内部的一套「Kaggle Kernels」服务,从而有效地提升算法同学的日常开发效率。


本文我们将分享美团民宿团队是如何搭建自己的「Kaggle Kernels」—— 一个平台化的 Jupyter,接入了大数据和分布式计算集群,用于业务数据分析和算法开发。希望能为有同样需求的读者带来一些启发。

美团内部数据系统现状

现有系统与问题

算法同学在离线阶段主要包含三类任务:数据分析、数据生产、模型训练。为满足这些任务的要求,美团内部也开发了相应的系统:


  1. 魔数平台:用于执行 SQL 查询,下载结果集的系统。通常在数据分析阶段使用。

  2. 协同平台:用于使用 SQL 开发 ETL 的平台。通常用于数据生产。

  3. 托管平台:用于管理和运行 Spark 任务,用户提供任务的代码仓库,系统管理和运行任务。通常用于逻辑较复杂的 ETL、基于 Spark 的离线模型训练/预测任务等。

  4. 调度平台:用于管理任务的依赖关系,周期性按依赖执行调度任务。


这些系统对于确定的任务完成的比较好。例如:当取数任务确定时,适合在魔数平台执行查询;当 Spark 任务开发就绪后,适合在托管平台托管该任务。但对于探索性、分析性的任务没有比较好的工具支持。探索性的任务有程序开发时的调试和对陌生数据的探查,分析性的任务有特征分析、Bad Case 分析等等。以数据探索为例,我们经常需要对数据进行统计与可视化,现有的做法通常是:魔数执行 SQL -> 下载 Excel -> 可视化。这种方式存在的问题是:


  1. 分析和取数工具割裂。

  2. 大数据分析可视化困难。


以 Bad Case 分析为例,现有的做法通常是:



这种方式存在的问题是:


  1. 分析与取数割裂,整个过程需要较多的手工操作。

  2. 分析过程不容易复现,对于多人协作式的验证以及进一步分析不利。

  3. 本地 Python 环境可能与分析对象的依赖有冲突,需要付出额外精力管理 Python 环境。


离线数据相关任务的模式通常是取数(小数据/大数据)–> Python 处理(单机/分布式)–> 查看结果(表格/可视化)这样的循环。我们希望支持这一类任务的工具具有如下特质:


  1. 体验流畅:数据任务可以在统一的工具中完成,或者在可组合的工具链中完成。

  2. 体验一致:数据任务所用工具应该是一致的,不需要根据任务切换不同工具。

  3. 使用便捷:工具应是开箱即用,不需要繁琐的前置配置。

  4. 结果可复现:分析过程能够作为可执行代码保存下来,需要复现时执行即可,也应支持修改。


探索和分析类任务往往会带来可以沉淀的结果,如产生新的特征、模型、例行报告,希望可以建立起分析任务和调度任务的桥梁

我们需要怎样的 Jupyter

参考 Kaggle Kernels 的体验和开源 Jupyter 的功能,Notebook 方式进行探索分析具有良好的体验。我们计划定制 Jupyter,使其成为完成数据任务的统一工具。这个定制的 Jupyter 应具备以下功能:


  1. 接入 Spark:取数与分析均在 Jupyter 中完成,达到流畅、一致的体验。

  2. 接入调度系统:方便沉淀分析结果。

  3. 接入学城系统(内部 WiKi):方便分享和复现。

  4. 预配置环境:提供给用户开箱即用的环境。

  5. 用户隔离环境:避免用户间互相污染环境。

如何搭建 Jupyter 平台

Jupyter 项目架构

Project Jupyter 由多个子项目组成,通过这些子项目可以自由组合出不同的应用。子项目的依赖关系如下图所示:



这个案例中,Jupyter 应用是一个 Web 服务,我们可以从这个维度来看 Jupyter 架构:


Jupyter 扩展方式

整个 Jupyter 项目的模块化和扩展性上都非常优秀。上图中的 JupyterLab、Notebook Server、IPython、JupyterHub 都是可扩展的。

JupyterLab 扩展(labextension)

JupyterLab 是 Jupyter 全新的前端项目,这个项目有非常明确的扩展规范以及丰富的扩展方式。通过开发 JupyterLab 扩展,可以为前端界面增加新功能,例如新的文件类型打开/编辑支持、Notebook 工具栏增加新的按钮、菜单栏增加新的菜单项等等。JupyterLab 上的前端模块具有非常清楚的定义和文档,每个模块都可以通过插件获取,进行方法调用,获取必要的信息以及执行必要的动作。我们在提供分享功能、调度功能时,均开发了 JupyterLab 扩展。JupyterLab 扩展通常采用 TypeScript 开发,开发文档可参考:https://jupyterlab.readthedocs.io/en/stable/developer/extension_dev.html



JupyterLab 核心组件依赖图

Notebook Server 扩展(serverextension)

Notebook Server 是用 Python 写的一个基于Tornado的 Web 服务。通过 Notebook Server 扩展,可以为这个 Web 服务增加新的 Handler。增加新的 Handler 通常有两种用途:


  1. 为 JupyterLab 扩展提供对应的后端接口,用于响应一些需要由服务端处理的事件。例如调度任务的注册需要通过 JupyterLab 扩展发起请求,由 Notebook Server 扩展执行。

  2. 提供一个前端界面以及对应的后端处理服务。例如 jupyter-rsession-proxy,用于在 JupyterHub 中使用 RStudio。


Notebook Server 扩展开发文档可参考:


https://jupyter-notebook.readthedocs.io/en/stable/extending/handlers.html

Jupyter Kernels

Jupyter 用于执行代码的模块叫 Kernel,除了默认的 ipykernel 以外,还可以有其他的 Kernel 用于支持其他编程语言。例如支持 Scala 语言的almond、支持 R 语言的irkernel,更多详见语言支持列表

IPython Magics

IPython Magics 就是那些 %、%%开头的命令。常见的 Magics 有 %matplotlib inline,设置 Notebook 中调用 matplotlib 的绘图函数时,直接展示图表在 Notebook 中。执行 Magics 时,事实上是调用了该 Magics 定义的一个函数。对于 Line Magics(一个 %),传入函数的是当前行的代码;对于 Cell Magics(两个 %),传入的是整个 Cell 的内容。定义一个新的 IPython Magics 仅需定义一个函数,这个函数的入参有两个,一个是当前会话实例,可以用来遍历当前会话的所有变量,可以为当前会话增加新的变量;另一个是用户输入,对于 Line Magics 是当前行,对于 Cell Magcis 是当前 Cell。


IPython Magics 在简化代码方面非常有效,我们开发了 %%spark、%%sql 用于创建 Spark 会话以及 SQL 查询。另外很多第三方的 Magics 可以用来提高我们的开发效率,例如在开发 Word2Vec 变种时,使用 %%cython 来进行 Cython 和 Python 混合编程,省去编译加载模块的工作。


IPython Magics 开发文档可参考:https://ipython.readthedocs.io/en/stable/config/custommagics.html

IPython Widgets(ipywidgets)

IPython Widgets 是一种基于 Jupyter Notebook 和 IPython 的可交互控件。与普通可视化不同的是,在控件上的交互会触发和 Python 的通信并执行相应的代码,Python 上相应的动作也会触发界面实时变化。


IPython Widgets 在提供工具类型的功能增强上非常有用,基于它,我们实现了一个线上排序服务的调试和复现工具,用于展示排序结果以及指定房源在排序过程中的各种特征以及中间变量的值。IPython Widgets 的开发可以通过组合现有的 Widgets 实现,也可以完全自定义一个,IPython Widgets 开发文档可参考:https://ipywidgets.readthedocs.io/en/stable/examples/Widget Custom.html



ipyleaflet

扩展 JupyterHub

Authenticators

JupyterHub 是一个多用户系统,登录模块可替换,通过实现新的 Authenticator 类并在配置文件中指定即可。通过这个扩展点,我们实现了使用内部 SSO 系统登录 JupyterHub。Authenticator 开发文档可参考:https://jupyterhub.readthedocs.io/en/stable/reference/authenticators.html

Spawners

当用户登录时,JupyterHub 需要为用户启动一个用户专用 Notebook Server。启动这个 Notebook Server 有多种方式:本机新的 Notebook Server 进程、本机启动 Docker 实例、K8s 系统中启动新的 Pod、YARN 中启动新的实例等等。每一种启动方式都对应一个 Spawner,官方提供了多种 Spawner 的实现,这些实现本身是可配置的。如果不符合需求,也可以自己开发全新的 Spawner。由于我们需要实现 Spark 接入,对 K8s 的 Pod 有新的要求,所以基于 KubeSpawner 定制了一个 Spawner 来解决 Spark 连接集群的网络问题。Spawner 开发文档可参考:https://jupyterhub.readthedocs.io/en/stable/reference/spawners.html

我们的定制

回顾我们的需求,这个定制的 Jupyter 应具备以下功能:


  1. 接入 Spark:可以通过配置容器环境以及 Spawner 完成。

  2. 接入调度系统:需要开发 JupyterLab 扩展以及 Notebook Server 扩展。

  3. 接入学城系统:需要开发 JupyterLab 扩展以及 Notebook Server 扩展。

  4. 预配置环境:镜像配置。

  5. 用户隔离环境:通过定制 Authenticators + K8s Spawner 实现容器级别环境隔离。


我们的方案是基于 JupyterHub on K8s。下图是平台化 Jupyter 的架构图,从上到下可以看到三条主线:1. 分享复现、2. 探索执行、3. 调度执行。



几个关键组件介绍:


  1. JupyterLab:交互式执行的前端,开源项目。

  2. Jupyter Server:交互式执行的后端,开源项目。

  3. Commuter:浏览 Notebook 的工具,开源项目。

  4. K8s:容器编排系统,开源项目。

  5. Cantor:美团调度系统,同类开源项目有 AirFlow。

  6. 托管平台:美团离线任务托管平台,给定代码仓库和任务参数,为我们执行 Spark-Submit 的平台。

  7. 学城:美团文档系统。

  8. MSS:美团对象存储。

  9. NB-Runner:Notebook Runner,在 nbconvert 的基础上增加了参数化和 Spark 支持。


在定制 Jupyter 中,最为关键的两个是接入 Spark 以及接入调度系统,下文中将详细介绍这两部分的原理。JupyterHub on K8s 包括几个重要组成部分:Proxy、Hub、Kubernetes、用户容器(Jupyter Server Pod)、单点登录系统(SSO)。一个用户在登录后新建容器实例的过程中,这几个模块的交互如下图所示:



可以看到,新建容器实例后,用户的交互都是经过 Proxy 后与 Jupyter Server Pod 进行通信。因此,扩展功能的工作主要是定制 Jupyter Server Pod 对应的容器镜像。

让 Jupyter 支持 Spark

Jupyter 平台化后,我们得到一个接近 Kaggle Kernel 的环境,但是还不能够使用大数据集群。接下来,就是让 Jupyter 支持 Spark,Jupyter 支持 Spark 的方案有Toree,出于灵活性考虑,我们没有使用。我们希望让普通的 Python Kernel 能支持 PySpark。为了能让 Jupyter 支持 Spark,我们需要了解两方面原理:Jupyter 代码执行原理和 PySpark 原理。

Jupyter 代码执行原理

所用到的 Jupyter 分三部分:前端 JupyterLab、服务端 Jupyter Server、语言 Kernel IPython。这三个模块的通信如下图所示:



Jupyter 执行代码时序图


这里,需要在 IPython 的 exec 阶段支持 PySpark。

PySpark 原理

启动 PySpark 有两种方式:


  1. 方案一:PySpark 命令启动,内部执行了 spark-submit 命令。

  2. 方案二:任意 Python shell(Python、IPython)中执行 Spark 会话创建语句。


这两种启动方式有什么区别呢?


看一下 PySpark 架构图:



PySpark 架构图,来自SlideShare


与 Spark 的区别是,多了一个 Python 进程,通过 Py4J 与 Driver JVM 进行通信。

PySpark 方案启动流程


PySpark 启动时序图

IPython 方案启动流程


实际的 IPython 中启动 Spark 时序图


Toree 采用的是类似方案一的方式,脚本中调用 spark-submit 执行特殊版本的 Shell,内置了 Spark 会话。我们不希望这么做,是因为如果这样做的话就会:


  1. 多了一个 PySpark 专供的 Kernel,我们希望 Kernel 应该是统一的 IPython。

  2. PySpark 启动参数是固定的,配置在 kernel.json 里。希望 PySpark 任务是可以按需启动,可以灵活配置所需的参数,如 Queue、Memory、Cores。


因此我们采用方案二,只需要一些环境配置,就能顺利启动 PySpark。另外为了简化 Spark 启动工作,我们还开发了 IPython 的 Magics,%spark 和 %sql。

环境配置

为了让 IPython 中能够顺利启动起 Spark 会话,需要正确配置如下环境变量:


  • JAVA_HOME:Java 安装路径,如/usr/local/jdk1.8.0_201。

  • HADOOP_HOME:Hadoop 安装路径,如/opt/hadoop。

  • SPARK_HOME:Spark 安装路径,如/opt/spark-2.2。

  • PYTHONPATH:额外的 Python 库路径,如SPARK_HOME/python/lib/py4j-0.10.4-src.zip。

  • PYSPARK_PYTHON:集群中使用的 Python 路径,如./ARCHIVE/notebook/bin/python。集群中使用 Python 通常需要虚拟环境,通过 spark.yarn.dist.archives 带上去。

  • PYSPARK_DRIVER_PYTHON:Spark Driver 所用的 Python 路径,如果你用 Conda 管理 Python 环境,那这个变量应为类似/opt/conda/envs/notebook/bin/python 的路径。


为了方便,建议设置各 bin 路径到 PATH 环境变量中:SPARK_HOME/bin:HADOOP_HOME/bin:PATH。完成这些之后,可以在 IPython 中执行创建 Spark 会话代码验证:


import pysparkspark = pyspark.sql.SparkSession.builder.appName("MyApp").getOrCreate()
复制代码

在 Spark 任务中执行 Notebook

执行 Notebook 的方案目前有nbconvert,Python API 方式执行样例如下所示,暂时称这段代码为 NB-Runner.py:


# Import:首先我们import nbconvert和ExecutePreprocessor类:import nbformatfrom nbconvert.preprocessors import ExecutePreprocessor
# 加载:假设notebook_filename是notebook的路径,我们可以这样加载:with open(notebook_filename) as f: nb = nbformat.read(f, as_version=4)
# 配置:接下来,我们配置notebook执行模式:ep = ExecutePreprocessor(timeout=600, kernel_name='python')
# 执行(preprocess):真正执行notebook的地方是调用函数preprocess:ep.preprocess(nb, {'metadata': {'path': 'notebooks/'}})
#保存:最后,我们保存notebook执行结果:with open('executed_notebook.ipynb', 'w', encoding='utf-8') as f: nbformat.write(nb, f)
复制代码


现在有两个问题需要确认:


  1. 当 Notebook 中存在 Spark 相关代码时,Python NB-Runner.py 能否正常执行?

  2. 当 Notebook 中存在 Spark 相关代码时,Spark-Submit NB-Runner.py 能否正常执行?


之所以会出现问题 2,是因为我们的调度系统只能调度 Spark 任务,所以必须使用 Spark-Submit 的方式来启动 NB-Runner.py。为了回答这两个问题,需要了解 nbconvert 是如何执行 Notebook 的。



nbconvert 执行时序图


问题 1 从原理上看,是可以正常执行的。实际测试也是如此。对于问题 2,答案似乎并不明显。结合“PySpark 启动时序图”、“实际的 IPython 中启动 Spark 时序图”与“nbconvert 执行时序图”:



Spark-Submit NB-Runner.py 的方式存在问题的点可能在于,IPython 中执行 Spark.builder.getOrCreate 时,Driver JVM 已经启动并且 Py4J Gateway Server 已经实例化完成。如何让 Spark.builder.getOrCreate 执行时跳过上图“实际的 IPython 中启动 Spark 时序图”的 Popen(spark-submit)以及后续的启动 Py4J Gateway Server 部分,直接与 Py4J Gateway Server 建立连接?


在 PySpark 代码中,看到如下这段代码:


def launch_gateway(conf=None):    """    launch jvm gateway    :param conf: spark configuration passed to spark-submit    :return:    """    if "PYSPARK_GATEWAY_PORT" in os.environ:        gateway_port = int(os.environ["PYSPARK_GATEWAY_PORT"])    else:        SPARK_HOME = _find_spark_home()        # Launch the Py4j gateway using Spark's run command so that we pick up the        # proper classpath and settings from spark-env.sh        on_windows = platform.system() == "Windows"        script = "./bin/spark-submit.cmd" if on_windows else "./bin/spark-submit"...
复制代码


如果我们能在 IPython 进程中设置环境变量 PYSPARK_GATEWAY_PORT 为真实的 Py4J Gateway Server 监听的端口,就会跳过 Spark-Submit 以及启动 Py4J Gateway Server 部分。那么 PYSPARK_GATEWAY_PORT 从哪来呢?我们发现在 Python 进程中存在这个环境变量,只需要通过 ExecutorPreprocessor 将它传递给 IPython 进程即可。

使用案例

数据分析与可视化

数据探查和数据分析在这里都是同样的流程。用户要分析的数据通常存储在 MySQL 和 Hive 中。为了方便用户在 Notebook 中交互式的执行 SQL,我们开发了 IPython Magics %%sql 用来执行 SQL。


SQL Magics 的用法如下:


%%sql <var> [--preview] [--cache] [--quiet]SELECT field1, field2  FROM table1 WHERE field3 == field4
复制代码


SQL 查询的结果暂存在指定的变量名<var>中,对于 MySQL 数据源<var>的类型是 Pandas DataFrame,对于 Hive 数据源<var>的类型是 Spark DataFrame。<var>可用于需要对结果集进行操作的场合,如多维分析、数据可视化。目前,我们支持几乎所有的 Python 数据可视化库。下图是一个数据分析和可视化的例子:



数据分析与可视化

Notebook 分享

Notebook 不仅支持交互式的执行代码,对于文档编辑也有不错的支持。数据分析过程中的数据、表格、图表加上文字描述就是一个很好的报告。Jupyter 服务还支持用户一键将 Notebook 分享到美团内部的学城中。一键分享:



一键分享


上述数据分析分享到内部学城的效果如下图所示:



Notebook 分享效果

模型训练

基于大数据的模型训练通常使用 PySpark 来完成。除了 Spark 内置的 Spark ML 可以使用以外,Jupyter 服务上还支持使用第三方 X-on-Spark 的算法,如 XGBoost-on-Spark、LightGBM-on-Spark。我们开发了 IPython Magics %%spark 来简化这个过程。


Spark Magics 的用法如下:


%%spark[--conf <property-name>=<property-value>][--conf <property-name>=<property-value>]...
复制代码


执行 %%spark 后,会启动 Spark 会话,启动后 Notebook 会话中会新建两个变量 spark 和 sc,分别对应当前 Spark 会话的 SparkSession 和 SparkContext。


下图是一个使用 LightGBM-on-Yarn 训练模型的例子,基于 Azure/mmlspark 官方 Notebook 例子,仅需添加启动 Spark 语句以及修改数据集路径。



LightGBM on Spark Demo

排序策略调试

通过开发 ipywidgets 实现了一个线上排序策略的调试工具,可以用于查看排序结果以及排序原因(通过查看变量值)。


总结与展望

通过平台化 Jupyter 的定制与部署,我们实现了数据分析、数据生产、模型训练的统一开发环境。在此基础上,还集成了内部公共服务和业务服务,从而实现了从数据分析到策略上线到结果分析的全链路支持。


我们对这个项目未来的定位是数据科学的云端集成开发环境,而 Jupyter 项目所具有的极强扩展性,也能够支持我们朝着这个方向不断进行演进。


作者介绍


文龙,美团民宿研发团队工程师。


颖艺,美团民宿研发团队工程师。


本文转载自公众号美团技术团队(ID:meituantech)。


原文链接


https://mp.weixin.qq.com/s/x9SlEvQj4DdYGKtQOKuslg


2019-11-30 08:003754

评论

发布
暂无评论
发现更多内容

程序员自我修炼:《匠艺整洁之道》读书总结

博文视点Broadview

EMQ&思岚科技:物联网+AI支援抗疫,“无接触”机器人保障上海方舱稳定运转

EMQ映云科技

物联网 IoT mqtt emq 6月月更

大容量、高性能,国家级实验室分布式并行文件存储实践

焱融科技

人工智能 大数据 AI 基础设施 存储

打金?工作室?账号被封?游戏灰黑产离我们有多近

行者AI

从“预见”到“遇见”SAE 引领应用步入 Serverless 全托管新时代

Serverless Devs

阿里云 Serverless

一键部署Java构件到Nexus,同事见了都说好

Jianmu

后端 持续集成 私服 自动化运维 Java构件

【智人智语】剑维软件大中华区油气和智能制造业务部总经理刘晓光:我谨代表剑维软件预祝第六届世界智能大会圆满成功

InfoQ 天津

安势信息技术市场总监王峰,OpenChain线上研讨会首秀!

安势信息

Linux 开源 DevSecOps SCA SCA工具

选择广州软件定制开发的10个理由

低代码小观

软件开发 管理软件 企业管理软件 项目管理软件 软件定制

小程序IDE,大趋势下催生的效能提速工具

Speedoooo

ide 效率工具 编程效率 移动开发 APP开发

大型物联网平台如何来保障亿级设备安全连接上云?

华为云开发者联盟

物联网 华为云 iotda 大型物联网平台

趣步运动挖矿系统开发模式分析

开发微hkkf5566

面向高校 | “云原生技术应用与实践”示范课程项目开放申报

Serverless Devs

NFT+DeFi链游系统开发技术

薇電13242772558

NFT

软件开发教父 Martin Fowler:幸好我当初没把它扔进垃圾桶

图灵教育

软件开发

创新不止,英特尔强调HPC的开放性和可持续性

科技之家

InfoQ 极客传媒 15 周年庆征文|业务中台与B-PaaS的前世今生

小诚信驿站

架构 如何落地业务建模 领域建模 热门活动 InfoQ极客传媒15周年庆

最好用的 6 个 React Tree select 树形组件测评与推荐

蒋川

低代码 开发工具 React 组件 树形选择器

C#入门系列(六) -- 分支语句

陈言必行

C# 6月月更

当运行npm install 命令的时候带上ignore-scripts,会发生什么?

华为云开发者联盟

前段

2022年软饮料国潮发展洞察报告

易观分析

饮品市场

一篇万字博文带你入坑爬虫这条不归路 【万字图文】

孤寒者

爬虫 6月月更 爬虫必备知识讲解 万字图文 爬虫入坑文

【爬虫必备->Scrapy框架】初篇

孤寒者

爬虫 6月月更 scrapy框架

微信团队分享:微信后台在海量并发请求下是如何做到不崩溃的

JackJiang

微服务 即时通讯 im开发 微信架构

重磅!KubeEdge单集群突破10万边缘节点|云原生边缘计算峰会前瞻

华为云开发者联盟

云计算 云原生 华为云

常见滑动窗口实现(Java语言实现)

工程师日月

6月月更

信息时代,您需要这样的知识管理工具

小炮

flutter系列之:用来管理复杂状态的State详解

程序那些事

flutter 程序那些事 6月月更 widget

优酷移动端弹幕穿人架构设计与工程实战总结

阿里巴巴文娱技术

技术 音视频 弹幕 视频 移动端

新一期HarmonyOS认证正式发布,速来围观!

HarmonyOS开发者

HarmonyOS

OpenHarmony 3.2 Beta1版本正式发布

OpenHarmony开发者

Open Harmony

Jupyter在美团民宿的应用实践_文化 & 方法_文龙 颖艺_InfoQ精选文章