商飞实习

商飞实习

暑假在上海浦东的商飞试飞中心实习了六周,还有三天结束。实习主要的工作是面向试飞测试数据,建立一套数据组织流程,并依据流程开发一套Web工具,最终基于所开发的工具,根据专业、试飞科目等信息对典型测试数据进行整理。我负责的部分主要是用Streamlit搭一个前端,这篇Blog主要的目的也是把学习和工作过程中遇到的一些问题记录整理一下。(感谢冯部长 主任和科室同事的照顾..

image-20230711123654585

Web开发

一般情况下,当我们想开发一个Web应用时,比如搭建一个网页实现一些功能,整个开发可以划分成后端和前端两个部分。前端主要负责网页界面的开发,后端则负责实现具体的逻辑。比如当用户点击前端界面的某个按钮时,触发后端的某个函数,实现数据拉取等功能,将结果返回后,再由前端展示给用户。

就像Python有很多库一样,当我们想用Python训练一个神经网络的时候,不需要从具体的某个节点和反向传播算法写起,TensorFlow或者Pytorch库已经把这些都写好了,我们只需要导入这些库,并按照他们提供的接口,就可以直接快速搭建起来一个神经网络了。

Web开发也是一样,前后端都有相应的框架去帮助我们构建一个Web应用,当我们想做一个比如滑动拼图验证码的时候,不需要手动实现每一个细节,只需要按照框架的接口建立一个验证码对象就可以了。后段框架可以提供包括数据库连接、身份验证等各种功能的接口,常用的后端框架比如基于Python的Django和Flask。前端主要实现的功能是界面展示与交互,要实现这个功能,整个前端可以拆分成三个要素:

  • HTML:搭建网页结构
  • CSS:定义网页样式
  • JavaScript:实现网页交互

因此,当我们谈前端的框架时,其实包含了两部分,一部属于CSS框架,预定义好了一些好看的界面和组件样式,比如Semantic UI、Bootstrap;另一部分主要功能是提供Js框架,实现交互的功能,比如典型的前端三大框架,angular、react、vue。

但即使我们想用这些框架搭建一个网页,也不可避免地需要学习基本的Web知识,比如路由、前后端信息传输、HTML+CSS+Js... 于是人们建立了又一层的封装,比如基于Python的Dash和Streamlit。开发者基本不需要任何Web的知识,不需要手动实现后端和前端之间的信息交互,一切流程都被封装起来了,用户只需要简单的定义些网页元素,然后后端直接写点Python函数,就可以建立一个简单的Web应用了。

Streamlit

Streamlit很好上手,也有很多基础教程,这里主要记录些我遇到的一些特殊需求。

树状目录

一个典型的树状目录结构可以用嵌套的 JSON 对象和数组来表示,比如对于这样的一个树状结构,

1
2
3
4
5
6
7
8
9
- Root
- Folder1
- Subfolder1
- File1.txt
- Subfolder2
- Folder2
- File2.txt
- File3.txt

用一个JSON可以表示成

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
{
"label": "Root",
"value": "Root",
"children": [
{
"label": "Folder1",
"value": "Folder1",
"children": [
{
"label": "Subfolder1",
"value": "Subfolder1",
"children": [
{
"label": "File1.txt",
"value": "File1.txt"
}
]
},
{
"label": "Subfolder2",
"value": "folder"
}
]
},
{
"label": "Folder2",
"value": "Folder2",
"children": [
{
"label": "File2.txt",
"value": "file"
}
]
},
{
"label": "File3.txt",
"value": "File3.txt"
}
]
}

Streamlit中现在有两个插件可以实现树状目录,一个是streamlit_tree_select,一个是st_ant_tree,这两个插件都是接收一个嵌套的JSON对象(在Python里就是一个嵌套的字典,在Python中可以很简单地用标准库json实现字典和json数据的转换,json.loads接收一个json字符串,返回一个字典;json.dumps接收一个字典,返回一个json字符串。),来实现树状目录。具体的接口和样式略有不同,st_ant_tree长成下面这样,可以折叠、定制长度,适合树中目录文件比较多的情况。

image

streamlit_tree_select长成下面这样,看起来更加强壮,没法控制展示的长度,适合目录文件比较少的情况。

Bildschirmfoto 2022-09-03 um 10.19.11

静态元素加载

Streamlit网页端没法直接通过HTML方式加载当前目录下的文件,就像一个网站只能通过相对路径访问自己站点目录下的页面,没法用相对路径访问同一计算机非站点目录下的文件一样。streamlit提供了static文件夹的方式,以实现在HTML元素中实现静态元素的加载,比如在某个div元素中加载某些图片资源,像下面这样:

1
2
3
<div class="image">
<img src="app/static/wakuwaku.jpg">
</div>

image-20230731203931155

我们需要在streamlit的.py文件下面新建一个 static目录,当将静态文件比如图片资源放置在 static 文件夹下时,streamlit 会将这些文件复制到应用程序的根目录中,并通过 streamlit 服务器对外提供访问。这使得客户端可以通过相对路径或者指定正确的 URL 来访问这些静态资源。streamlit设计的相对目录为/app/static,也就是说假如我在static目录下面放置了一个叫作wakuwaku.png的图片,当我们启动streamlit服务后,在浏览器中输入

1
http://localhost:8501/app/static/wakuwaku.jpg

是可以找到这张图片资源的(假如端口是8501)。但是,如果将静态文件放置在其他位置(不在 static 文件夹下),客户端是无法直接找到和访问它们的。这是因为 streamlit 并不会对位于其他位置的文件进行特殊处理,也不会提供对这些文件的访问能力。

同时,为了启用这个功能,我们需要在streamlit的.py文件下面新建一个 .streamlit目录,在该目录下新建一个config.toml文件,在文件里设置

1
2
[server]
enableStaticServing = true

但如果我们不是在HTML的src或者href来通过相对路径访问元素,而是通过Python代码,这个就无所谓了,因为Python本质上就是在当前主机的后端执行操作,而不涉及Web客户端的内容。

CSS

在 HTML 中,网页的内容被组织为一个个元素的集合。每个 HTML 元素由一个起始标签(opening tag)和一个结束标签(closing tag)组成,标签用来定义元素的类型和属性。标签的组合形成了嵌套的层级结构,从而构成了网页的整体结构。HTML 是一种标记语言,它有一组预定义的元素,这些元素用于创建网页的结构和内容。我们不能定义新的元素,但可以使用自定义的数据属性(data attributes)来扩展现有的 HTML 元素,以存储自定义数据或信息。自定义数据属性以 data- 为前缀,并可以添加在任何 HTML 元素上,例如:

1
<div data-custom-attribute="some value">This is a div with a custom data attribute.</div>

CSS美化了网页的样式,本质上就是定义了这些元素们的样式属性。所以CSS最重要的一部分,就是选择器,也就是选择要应用样式的HTML元素。一般常用的有以下几个选择器(大部分摘自RUNOOB.COM):

  • 元素选择器:通过元素名称选择HTML元素。

    如下代码,p选择器将选择所有 <p> 元素:

    1
    2
    3
    p {
    color: blue;
    }
  • 类选择器:通过类别名称选择具有特定类别的HTML元素。

    类选择器以.开头,后面跟着类别名称。

    如下代码,.highlight选择器将选择所有具有类别为highlight的元素。

    1
    2
    3
    .highlight {
    background-color: yellow;
    }

    元素标签里的class属性包括highlight的,就都会得到这样的属性,

    1
    <p class="highlight">This is a paragraph element with class "highlight"</p>

    我们也可以指定同时属于两个类的元素的属性

    1
    2
    3
    .highlight.strengthen {
    font-size: 2rem;
    }

    这时只有同时指定了两个类的元素才会获得这个属性:

    1
    <div class="highlight strengthen">Content</div>
  • ID 选择器:通过元素的唯一标识符(ID)选择 HTML 元素。

    ID 选择器以#开头,后面跟着 ID 名称。

    如下代码,#runoob选择器将选择具有 ID 为 "runoob" 的元素。

    1
    2
    3
    #runoob {
    width: 200px;
    }

    在 HTML 文档中,每个元素的 ID 属性都应该是唯一的,即不同元素不能拥有相同的 ID。

    1
    <div id="header">This is the header</div>
  • 属性选择器:通过元素的属性选择 HTML 元素。属性选择器可以根据属性名和属性值进行选择。

    如下代码,input[type="text"]选择器将选择所有type属性为 "text"<input>元素。

    1
    2
    3
    input[type="text"] {
    border: 1px solid gray;
    }
  • 后代选择器:通过指定元素的后代关系选择 HTML 元素。

    后代选择器使用空格分隔元素名称。

    如下代码,div p选择器将选择所有在 <div>元素内的 <p> 元素。

    1
    2
    3
    div p {
    font-weight: bold;
    }

    后代选择器也可以连续使用,表示后好几代的元素,比如

    1
    2
    3
    div div div {
    color: #333;
    }

    表示div元素下嵌套的第二层div元素。

  • 伪元素选择器:伪元素通常用于在选中元素的首部、尾部或者内部的某一行添加一些特殊样式、内容或图标。

    伪元素选择器使用双冒号::(早期的规范使用单冒号 : 来表示伪元素)表示伪元素。

    如下代码表示在 <footer> 元素后面插入虚拟的内容。在这个例子中,虚拟的内容是 "祝我们的大飞机越来越好✈️!❤️ from Tsinghua University",并通过 CSS 样式来定义这个虚拟元素的样式。

    1
    2
    3
    4
    5
    6
    7
    8
    footer::after {
    content:'祝我们的大飞机越来越好✈️!❤️ from Tsinghua University';
    visibility: visible;
    display: block;
    position: relative;
    padding: 5px;
    top: 2px;
    }

    这些选择器也是可以进行组合的,比如在更改streamlit多选框的样式的时候,

    1
    2
    3
    .stMultiSelect div div div div div::nth-of-type(2) {
    visibility: hidden;
    }

    这里就同时采用了三个元素选择器,

    1. .stMultiSelect: 这是一个类选择器,它选择所有具有 class="stMultiSelect" 属性的元素。
    2. div div div div div: 这是四个连续的后代选择器,表示选择位于 .stMultiSelect 元素内的第四层嵌套 <div> 元素。换句话说,这是一个表示深度为 5 的嵌套结构。
    3. :nth-of-type(2): 这是伪类选择器,用于选择一组元素中的第二个元素。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    <!-- 在父元素中选择第二个 <div> 元素 -->
    <div>
    <div>First Div</div>
    <div>Second Div</div> <!-- 这个会被选择 -->
    <div>Third Div</div>
    </div>

    <!-- 在父元素中选择偶数位置的 <p> 元素 -->
    <div>
    <p>Paragraph 1</p>
    <p>Paragraph 2</p> <!-- 这个会被选择 -->
    <p>Paragraph 3</p>
    </div>

    综合起来,这个复杂选择器会选择在 .stMultiSelect 元素内第四层嵌套结构中的所有 <div> 元素,并将其中的第二个 <div> 元素的 visibility 设置为 hidden,即将其隐藏起来。

css样式可以直接写到元素的style属性中,

1
<div style="color: red; font-size: 16px;">This is some text.</div>

也可以将样式写在 <style> 标签内,放置在 HTML 文档的 <head> 部分。这样的样式表将适用于整个 HTML 文档。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html>
<head>
<title>Internal Styles</title>
<style>
div {
color: red;
font-size: 16px;
}
</style>
</head>
<body>
<div>This is some text.</div>
</body>
</html>

也可以写进css文件中,在 HTML 文档中使用 <link> 标签将其链接到页面上。

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html>
<head>
<title>External Styles</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div>This is some text.</div>
</body>
</html>

在stremlit中,理论上虽然可以把css文件放到static文件夹中用<link> 标签加载,但是不知道为什么一直报错.. 不过我们还可以通过read文件的方式手动写进streamlit页面中:

1
2
3
4
5
6
7
# streamlit.py

def local_css(file_name):
with open(file_name, encoding='utf8') as f:
st.markdown(f'<style>{f.read()}</style>', unsafe_allow_html=True)

local_css("static/css/style.css")

通过这种方式在streamlit中写HTML时,必须要保证st.markdown中是一个完整的HTML元素,否则是无法正确渲染的。所以考虑以下这个需求,左侧是卡片目录树,我们需要在一个页面上显示用户勾选的数据卡片,同时还需要些按钮,来控制这些卡片的行为,在streamlit里我们应该怎么做呢?一个可选的方案是维护一个巨大的HTML字符串,假如用户勾选了5张卡片,那么这时就可以向这个字符串写进去5个HTML元素。如果要控制卡片的显示风格,也是通过写进<style>标签来实现的。

多页面及参数传递

streamlit官方提供了多页面的接口,但是效果并不好。为了在streamlit里实现多页面,我们可以借助st_pages库提供的接口。

1
2
3
4
5
6
7
8
9
st_pages.show_pages(
[
st_pages.Page("main_config.py", "家", "🏠"),
st_pages.Page("page/data_config.py", "卡片配置", "✈️"),
]
)

# 此时主页的侧边栏会显示所有的页面,如果要隐藏某个页面,可以用hide_pages方法
st_pages.hide_pages(["卡片配置"])

在这里有两点需要注意,一是页面的地址名称不再是它们的.py文件名了,而是后面指定的名字,比如家和卡片配置;二是所有的Python路径都默认是对应streamlit客户端页面的路径,比如启动的streamlit客户端是main_config.py,那么page/data_config.py中Python的默认路径还是main_config.py所在的路径。

现在我们已经建立了多个页面,那么如何在多页面间实现参数传递呢?这时候可以借助URL中的查询字符串。

1
url?name1=value1&name2=value2

url就是我们要访问的页面的链接,?后面的就是查询字符串,当我们访问这个地址的时候,访问的就是url指向的地址,但同时会向这个地址传递两个参数,分别是name1name2,这两个参数的值分别是value1value2

在streamlit中,我们可以通过一个<a>元素来实现查询字符串的配置,

1
2
3
<a class="right-corner" href="卡片配置?TABLE_NAME={row['TABLE_NAME']}&TABLE_CATALOG={row['TABLE_CATALOG']}&TABLE_ID={row['TABLE_ID']}&TABLE_OWNER={row['TABLE_OWNER']}&TABLE_TYPE={row['TABLE_TYPE']}" target="_blank" style="color: black;">  
<b>⚙️配置⛏️</b>
</a>

然后在目标页面中,通过streamlit提供的方法收获这些参数,

1
2
3
4
5
6
query_params = st.experimental_get_query_params()
TABLE_NAME = query_params.get("TABLE_NAME", [None])[0]
TABLE_CATALOG = query_params.get("TABLE_CATALOG", [None])[0]
TABLE_ID = query_params.get("TABLE_ID", [None])[0]
TABLE_OWNER = query_params.get("TABLE_OWNER", [None])[0]
TABLE_TYPE = query_params.get("TABLE_TYPE", [None])[0]

其实搜索引擎搜索本质也是这样,我们向搜索引擎所在的服务器发送一个GET请求,后面的字符串会被解析为查询字符串,搜索引擎服务器收到这个 GET 请求后,会根据查询字符串中的关键词进行搜索,并返回相应的搜索结果页面。搜索结果会包含与搜索关键词相关的网页、图片、视频等内容。

image-20230801093240589

Git协作

对于一个简单的、安全性需求不太高的工作,也不需要folk、branch这些操作,大家都在主分支上轮流提交就完事了。这个时候,在新建了一个Git项目之后,大家先分别Clone到本地,A修改代码,Commit + Push之后,远程主分支更新,这时B通过Pull拉取合并远程主分支的代码,B再把新修改的部分继续Commit+Push...

image-20230731120759356

我们可以在Git项目目录下面建立一些比如.ipynb的调试文件,这时候可以在项目目录下面新建一个.gitignore文件,.gitignore中包含的文件会被排除掉当前Git项目。