SWAN测试执行流程
以下do-tests测试脚本执行完整测试。全部用例成功421个,失败10个。有打印信息可知,每个测试用例分为三个步骤:pre/test/post。
$ cd strongswan-5.8.1/testing
$ sudo ./do-tests
Guest kernel : 5.2.11
strongSwan : 5.8.1
Date : 20190917-0126-52[ ok ] 1 af-alg/alg-camellia: pre..test..post
...
[FAIL] 429 tnc/tnccs-20-pts-no-ecc: pre..test..post
[ ok ] 430 tnc/tnccs-20-tls: pre..test..post
[ ok ] 431 tnc/tnccs-dynamic: pre..test..postPassed : 421
Failed : 10The results are available in /srv/strongswan-testing/testresults/20190917-0126-52
or via the link http://192.168.0.150/testresults/20190917-0126-52Finished : 20190917-0213-51
测试准备
首先,获取到各个虚拟主机的IP地址。两个变量ipv4_KaTeX parse error: Expected group after '_' at position 12: {host}和ipv6_̲{host}中保存的是各个虚拟主机的第一个网卡的IPv4和IPv6地址;另外,对于具有两个网卡的虚拟网关moon和sun,以及虚拟主机alice、carol和dave,增加后缀1来表示其第二个网卡的IPv4和IPv6地址。例如,对于moon网关,ipv4_moon1和ipv6_moon1分别表示其IPv4和IPv6地址。
for host in $STRONGSWANHOSTSdoeval ipv4_${host}="`echo $HOSTNAMEIPV4 | sed -n -e "s/^.*${host},//gp" | awk -F, '{ print $1 }' | awk '{ print $1 }'`"eval ipv6_${host}="`echo $HOSTNAMEIPV6 | sed -n -e "s/^.*${host},//gp" | awk -F, '{ print $1 }' | awk '{ print $1 }'`"case $host inmoon)eval ipv4_moon1="`echo $HOSTNAMEIPV4 | sed -n -e "s/^.*${host},//gp" | awk -F, '{ print $2 }' | awk '{ print $1 }'`"eval ipv6_moon1="`echo $HOSTNAMEIPV6 | sed -n -e "s/^.*${host},//gp" | awk -F, '{ print $2 }' | awk '{ print $1 }'`";;sun)eval ipv4_sun1="`echo $HOSTNAMEIPV4 | sed -n -e "s/^.*${host},//gp" | awk -F, '{ print $2 }' | awk '{ print $1 }'`"eval ipv6_sun1="`echo $HOSTNAMEIPV6 | sed -n -e "s/^.*${host},//gp" | awk -F, '{ print $2 }' | awk '{ print $1 }'`";;alice)eval ipv4_alice1="`echo $HOSTNAMEIPV4 | sed -n -e "s/^.*${host},//gp" | awk -F, '{ print $2 }' | awk '{ print $1 }'`"eval ipv6_alice1="`echo $HOSTNAMEIPV6 | sed -n -e "s/^.*${host},//gp" | awk -F, '{ print $2 }' | awk '{ print $1 }'`";;venus);;bob);;carol)eval ipv4_carol1="`echo $HOSTNAMEIPV4 | sed -n -e "s/^.*${host},//gp" | awk -F, '{ print $2 }' | awk '{ print $1 }'`"eval ipv6_carol1="`echo $HOSTNAMEIPV6 | sed -n -e "s/^.*${host},//gp" | awk -F, '{ print $2 }' | awk '{ print $1 }'`";;dave)eval ipv4_dave1="`echo $HOSTNAMEIPV4 | sed -n -e "s/^.*${host},//gp" | awk -F, '{ print $2 }' | awk '{ print $1 }'`"eval ipv6_dave1="`echo $HOSTNAMEIPV6 | sed -n -e "s/^.*${host},//gp" | awk -F, '{ print $2 }' | awk '{ print $1 }'`";;winnetou);;esacdone
由于测试过程将使用SSH登陆到待测试的虚拟主机或者网关上执行命令,在这里先行打开各个主机(STRONGSWANHOSTS)的SSH通道,令其在后台运行。当测试完成退出时,使用kill命令终止此SSH后台通道进程。
# open ssh sessions#for host in $STRONGSWANHOSTSdossh $SSHCONF -N root@`eval echo \\\$ipv4_$host` >/dev/null 2>&1 &eval ssh_pid_$host="`echo $!`"do_on_exit kill `eval echo \\\$ssh_pid_$host`done
SSH通道打开之后,就可使用如下的命令执行指定主机上面的命令,如下,登录到虚拟主机winnetou,指定uname -r命令,将结果保存在变量内核版本KERNELVERSION中。
############################################################################### determine actual software versions#[ -f $SHAREDDIR/.strongswan-version ] && SWANVERSION=`cat $SHAREDDIR/.strongswan-version`KERNELVERSION=`ssh $SSHCONF root@\$ipv4_winnetou uname -r 2>/dev/null`
测试目标
使用:./do-tests 开始测试,其中testnames为测试名称,即存放测试配置的子目录,所有的测试都存放于目录strongswan-5.8.1/testing/tests/下(变量DEFAULTTESTSDIR),可指定多个测试名称。如果不指定,将进行全部的测试。
DEFAULTTESTSDIR=$TESTDIR/testing/tests# enter specific test directory#if [ $# -gt 0 ]thenTESTS=$(printf "%s\n" $* | sort -u)elseTESTS=$(ls $DEFAULTTESTSDIR)fi
如下方式指定测试名称:
./do-tests ikev2/net2net-psk ikev2/net2net-cert
StrongSwan的测试用例都是分两级目录保存的,如以上指定测试名称的情况,第二级目录名称(如net2net-psk 何net2net-cert)才是真正的测试名称。这种情况下SUBTESTS和SUBTESTS和SUBTESTS和SUBDIR变量是不相等的。对于未指定测试用例的情况,两个变量相等,即以下的第一个分支,此情况下,取得子分支下的所有测试用例名称,赋值于变量SUBTESTS。
for SUBDIR in $TESTSdoSUBTESTS="`basename $SUBDIR`"if [ $SUBTESTS = $SUBDIR ]thenSUBTESTS="`ls $DEFAULTTESTSDIR/$SUBDIR`"elseif [[ $SUBTESTS == *'*'* ]]thenSUBTESTS="`basename -a $DEFAULTTESTSDIR/$SUBDIR`"fiSUBDIR="`dirname $SUBDIR`"fi
所以,对于命令行指定了测试用例的情况,变量SUBTESTS每次循环仅有一个测试例;相反,对于命令行未指定测试用例的情况,变量SUBTESTS可能包含多个测试用例。嵌套一个循环处理SUBTESTS变量中的测试用例。
测试流程要求每个测试用例包含以下五个文件,否则出错。
for name in $SUBTESTSdotestname=$SUBDIR/$name[ -f $DEFAULTTESTSDIR/${testname}/description.txt ] || die "!! File 'description.txt' is missing"[ -f $DEFAULTTESTSDIR/${testname}/test.conf ] || die "!! File 'test.conf' is missing"[ -f $DEFAULTTESTSDIR/${testname}/pretest.dat ] || die "!! File 'pretest.dat' is missing"[ -f $DEFAULTTESTSDIR/${testname}/posttest.dat ] || die "!! File 'posttest.dat' is missing"[ -f $DEFAULTTESTSDIR/${testname}/evaltest.dat ] || die "!! File 'evaltest.dat' is missing"
以下的脚本load-config使用scp命令将代码目录:strongswan-5.8.1/testing/hosts/$host/etc下的所有配置文件发送到相应的虚拟主机上,测试过程中将使会用到。
$DIR/scripts/load-testconfig $testnamesource $TESTDIR/test.conf
随后,如果配置了TCPDUMPHOSTS主机(此变量配置在以上的$TESTDIR/test.conf文件中),启动tcpdump在后台运行,如果未指定了tcpdump运行的接口,默认使用eth0。由于整个测试过程都在使用ssh与虚拟主机通信,在tcpdump命令中排除ssh端口。
# run tcpdump in the background#if [ "$TCPDUMPHOSTS" != "" ]thenecho -e "TCPDUMP\n" >> $CONSOLE_LOG 2>&1for host_iface in $TCPDUMPHOSTSdohost=`echo $host_iface | awk -F ":" '{print $1}'`iface=`echo $host_iface | awk -F ":" '{if ($2 != "") { print $2 } else { printf("eth0") }}'`tcpdump_cmd="tcpdump -l $TCPDUMP_IM -i $iface not port ssh and not port domain >/tmp/tcpdump.log 2>/tmp/tcpdump.err.log &"echo "$(print_time)${host}# $tcpdump_cmd" >> $CONSOLE_LOGssh $SSHCONF root@`eval echo \\\$ipv4_$host '$tcpdump_cmd'`eval TDUP_${host}="true"donefi
如果在测试用例的配置文件$TESTDIR/test.conf中指定了DBHOSTS,此处将创建DBDIR目录,并且使用mount挂载一块5M大小的内存文件系统到此目录。并不是所有的测试用例都需要。
DBDIR=/etc/db.d# create database directory in RAM#for host in $DBHOSTSdoeval HOSTLOGIN=root@\$ipv4_${host}ssh $SSHCONF $HOSTLOGIN "mkdir -p $DBDIR; mount -t ramfs -o size=5m ramfs $DBDIR" >/dev/null 2>&1ssh $SSHCONF $HOSTLOGIN "chgrp www-data $DBDIR; chmod g+w $DBDIR" >/dev/null 2>&1done
以下部分执行与测试相关的系统清理工作,避免影响之后要开始的测试用例。使用conntrack -F命令清空各个虚拟主机上的连接跟踪条目。使用ip xfrm命令清空虚拟主机中的SADB和SPDB。
# flush conntrack table on all hosts#for host in $STRONGSWANHOSTSdossh $SSHCONF root@`eval echo \\\$ipv4_$host` 'conntrack -F' >/dev/null 2>&1done########################################################################### flush IPsec state on all hosts#for host in $STRONGSWANHOSTSdossh $SSHCONF root@`eval echo \\\$ipv4_$host` 'ip xfrm state flush; ip xfrm policy flush' >/dev/null 2>&1done
之后,开始执行测试用例的特定任务。
执行用例的预测试pretest.dat
以下为do-tests文件中执行pre-test阶段命令的脚本:
# execute pre-test commands#echo -n "pre.."echo -e "\nPRE-TEST\n" >> $CONSOLE_LOG 2>&1eval `awk -F "::" '{if ($1 !~ /^#.*/ && $2 != ""){printf("echo \"$(print_time)%s# %s\"; ", $1, $2)printf("ssh \044SSHCONF root@\044ipv4_%s \"%s\"; ", $1, $2)printf("echo;\n")}}' $TESTDIR/pretest.dat` >> $CONSOLE_LOG 2>&1
对于af-alg/alg-camellia测试用例而言,其pretest.dat文件如下。在预测试pre-test阶段,备份moon和carol主机的iptables配置。启动strongswan。使用脚本expect-connection检测名称为net的连接(carol主机上为home)是否建立,超过5秒钟检测不到,打印失败信息。swanctl初始化一个名称为home的子连接。
1 moon::iptables-restore < /etc/iptables.rules2 carol::iptables-restore < /etc/iptables.rules3 moon::systemctl start strongswan4 carol::systemctl start strongswan5 moon::expect-connection net6 carol::expect-connection home7 carol::swanctl --initiate --child home 2> /dev/null
执行测试用例并检验结果
在do_tests脚本中的以下代码负责执行测试用例的evaltest.dat文件中定义的命令,其中每一行由四个部分组成,使用双冒号::做间隔。四个部分分别表示:host、command、pattern和hit(期待结果)。这里需要注意的是,如果第二个字段的命令为tcpdump,这里并不执行此命令,而是检查文件/tmp/tcpdump.log中的内容。
eval `awk -F "::" '{host=$1command=$2pattern=$3hit=$4if (host ~ /^#.*/ || command == ""){next}printf("cmd_err=\044(tempfile -p test -s err); ")printf("cmd_out=\044(tempfile -p test -s out); ")if (command == "tcpdump"){printf("if [ \044TDUP_%s == \"true\" ]; then stop_tcpdump %s; fi; \n", host, host)printf("ssh \044SSHCONF root@\044ipv4_%s cat /tmp/tcpdump.log > \044cmd_out; ", host)}else{printf("ssh \044SSHCONF root@\044ipv4_%s %s >\044cmd_out 2>\044cmd_err; ", host, command)}printf("cmd_res=\044(cat \044cmd_out | grep \"%s\"); ", pattern)printf("cmd_exit=\044?; ")printf("cmd_fail=0; ")if (hit ~ /^[0-9]+$/){printf("if [ \044(echo \"\044cmd_res\" | wc -l) -ne %d ] ", hit)}else{printf("if [ \044cmd_exit -eq 0 -a \"%s\" = \"NO\" ] ", hit)printf("|| [ \044cmd_exit -ne 0 -a \"%s\" = \"YES\" ] ", hit)}printf("; then STATUS=\"failed\"; cmd_fail=1; fi; \n")printf("if [ \044cmd_fail -ne 0 ]; then echo \"~~~~~~~ FAIL ~~~~~~~\"; fi; \n")if (command == "tcpdump"){printf("echo \"$(print_time)%s# cat /tmp/tcpdump.log | grep \047%s\047 [%s]\"; ", host, pattern, hit)}else{printf("echo \"$(print_time)%s# %s | grep \047%s\047 [%s]\"; ", host, command, pattern, hit)}printf("if [ -n \"\044cmd_res\" ]; then echo \"\044cmd_res\"; fi; \n")printf("cat \044cmd_err; \n")printf("if [ \044cmd_fail -ne 0 ]; then \n")printf("if [ -s \044cmd_out ]; then echo \"~~ output ~~~~~~~~~~\"; \n")printf("if [ \"\044verbose\" == \"YES\" ]; then cat \044cmd_out;\n")printf("else cat \044cmd_out | head; fi; fi; \n")printf("echo \"~~~~~~~~~~~~~~~~~~~~\"; fi; \n")printf("rm -f -- \044cmd_out \044cmd_err; \n")printf("echo; ")}' $TESTDIR/evaltest.dat` >> $CONSOLE_LOG 2>&1
测试用例af-alg/alg-camellia的测试文件evaltest.dat如下所示,如第一行所示,在carol主机上执行ping命令,期待alice返回的信息符合pattern:(128 bytes from PH_IP_ALICE: icmp_.eq=1)。
carol::ping -c 1 -s 120 -p deadbeef PH_IP_ALICE::128 bytes from PH_IP_ALICE: icmp_.eq=1::YES...moon:: ip xfrm state::enc cbc(camellia)::YEScarol::ip xfrm state::enc cbc(camellia)::YESmoon::tcpdump::IP carol.strongswan.org > moon.strongswan.org: ESP.*length 208::YESmoon::tcpdump::IP moon.strongswan.org > carol.strongswan.org: ESP.*length 208::YES
停止后台TCPDUMP
以下函数用于停止之前在后台启动的tcpdump命令。
# stop tcpdump#function stop_tcpdump {# wait for packets to get processed, but don't wait longer than 1seval ssh $SSHCONF root@\$ipv4_${1} "\"i=100; while [ \\\$i -gt 0 ]; do pkill -USR1 tcpdump; tail -1 /tmp/tcpdump.err.log | perl -n -e '/(\\d+).*?(\\d+)/; exit (\\\$1 == \\\$2)' || break; sleep 0.01; i=\\\$((\\\$i-1)); done;\""echo "$(print_time)${1}# killall tcpdump" >> $CONSOLE_LOGeval ssh $SSHCONF root@\$ipv4_${1} "\"killall tcpdump; while true; do killall -q -0 tcpdump || break; sleep 0.01; done;\""eval TDUP_${1}="false"echo "" >> $CONSOLE_LOG}
执行posttest.dat
以下为do-tests文件中执行post-test阶段命令的脚本:
# execute post-test commands#echo -n "post"echo -e "\nPOST-TEST\n" >> $CONSOLE_LOG 2>&1eval `awk -F "::" '{if ($1 !~ /^#.*/ && $2 != ""){printf("echo \"$(print_time)%s# %s\"; ", $1, $2)printf("ssh \044SSHCONF root@\044ipv4_%s \"%s\"; ", $1, $2)printf("echo;\n")}}' $TESTDIR/posttest.dat` >> $CONSOLE_LOG 2>&1
对于af-alg/alg-camellia测试用例而言,其posttest.dat文件如下。其中的命名正是与pretest.dat文件中的命令相反。
1 carol::swanctl --terminate --ike home2 carol::systemctl stop strongswan3 moon::systemctl stop strongswan4 moon::iptables-restore < /etc/iptables.flush5 carol::iptables-restore < /etc/iptables.flush
恢复测试环境
将测试开始之前,脚本load-config拷贝到虚拟主机上的配置文件(/etc目录下)备份回来,由脚本restore-defaults实现。
# copy default host config back if necessary#$DIR/scripts/restore-defaults $testname
测试日志与报告
在测试脚本do-tests执行过程中产生的日志信息都保存在console.log文件中,其保存在目录(/srv/strongswan-testing/testresults/20190917-0126-52/af-alg/alg-camellia/)下,即测试根目录+时间+具体测试用例的目录下。此目录下保存了测试过程中生成的众多文件,其中index.html索引文件,将目录下所有的文件链接起来。
在测试完成之后,作为测试结果备份的文件如下。这些文件都保存在以上提到的具体测试用例的测试结果目录下。首先是ipsec数据库文件
for host in $DBHOSTSdoeval HOSTLOGIN=root@\$ipv4_${host}scp $SSHCONF $HOSTLOGIN:/etc/db.d/ipsec.sql $TESTRESULTDIR/${host}.ipsec.sql > /dev/null 2>&1done
接下来是参与测试的各个主机(IPSECHOSTS)中的配置文件,包括:
- /etc/strongswan.conf
- /etc/swanctl/swanctl.conf
- /etc/ipsec.d/ipsec.sql
以及使用命令保存的状态文件,包括:
- swanctl.sas
- swanctl.stats
- ${host}.ip.policy
- ${host}.ip.state
- ${host}.ip.route
- ${host}.ip.iptables
以及swanctl命令生成的以下文件:
- ${host}.swanctl.conns
- ${host}.swanctl.algs
- ${host}.swanctl.certs
- ${host}.swanctl.pools
- ${host}.swanctl.authorities
- ${host}.swanctl.sas
- ${host}.swanctl.pols
实现脚本如下:
for host in $IPSECHOSTSdoeval HOSTLOGIN=root@\$ipv4_${host}scp $SSHCONF $HOSTLOGIN:/etc/strongswan.conf $TESTRESULTDIR/${host}.strongswan.conf > /dev/null 2>&1if [ -n "$SWANCTL" ]thenscp $SSHCONF $HOSTLOGIN:/etc/swanctl/swanctl.conf $TESTRESULTDIR/${host}.swanctl.conf > /dev/null 2>&1for subsys in conns algs certs pools authorities sas polsdossh $SSHCONF $HOSTLOGIN swanctl --list-$subsys > $TESTRESULTDIR/${host}.swanctl.$subsys 2>/dev/nulldonessh $SSHCONF $HOSTLOGIN swanctl --stats > $TESTRESULTDIR/${host}.swanctl.stats 2>/dev/nullecho "" >> $TESTRESULTDIR/${host}.swanctl.sascat $TESTRESULTDIR/${host}.swanctl.pols >> $TESTRESULTDIR/${host}.swanctl.sascat $TESTRESULTDIR/${host}.swanctl.algs >> $TESTRESULTDIR/${host}.swanctl.statselsefor file in ipsec.conf ipsec.secretsdoscp $SSHCONF $HOSTLOGIN:/etc/$file $TESTRESULTDIR/${host}.$file > /dev/null 2>&1donefor command in statusall listalldossh $SSHCONF $HOSTLOGIN ipsec $command > $TESTRESULTDIR/${host}.$command 2>/dev/nulldonefiif (! [ -f $TESTRESULTDIR/${host}.ipsec.sql ] ) thenscp $SSHCONF $HOSTLOGIN:/etc/ipsec.d/ipsec.sql $TESTRESULTDIR/${host}.ipsec.sql > /dev/null 2>&1fissh $SSHCONF $HOSTLOGIN ip -s xfrm policy > $TESTRESULTDIR/${host}.ip.policy 2>/dev/nullssh $SSHCONF $HOSTLOGIN ip -s xfrm state > $TESTRESULTDIR/${host}.ip.state 2>/dev/nullssh $SSHCONF $HOSTLOGIN $IPROUTE_CMD > $TESTRESULTDIR/${host}.ip.route 2>/dev/nullssh $SSHCONF $HOSTLOGIN $IPTABLES_CMD > $TESTRESULTDIR/${host}.iptables 2>/dev/nullssh $SSHCONF $HOSTLOGIN $IPTABLES_SAVE_CMD > $TESTRESULTDIR/${host}.iptables-save 2>/dev/null
END
SWAN测试执行流程相关推荐
- 【测试】测试执行流程
目录 1. 需求测试 2. 内部发布版本测试(冒烟测试) 3. 系统测试 4. 回归测试 5. 交叉测试 6. 测试报告的输出 1. 需求测试 基于需求的测试方法是基本的测试方法,而需求的质量直接影响 ...
- iTunes connect Testflight 2017-04-20改版后的内部测试执行流程
2017-04-20 iTunes connect改版后,苹果对Testflight进行了很大的改版,众所周知,之前在Testflight里面分为"内部测试员"和"外部测 ...
- 【转】Android兼容性测试CTS --环境搭建、测试执行、结果分析
原文网址:http://www.cnblogs.com/zh-ya-jing/p/4396918.html 为了确保Android应用能够在所有兼容Android的设备上正确运行,并且保持相似的用户体 ...
- 动态执行流程分析和性能瓶颈分析的利器——gperftools的Cpu Profiler
在<动态执行流程分析和性能瓶颈分析的利器--valgrind的callgrind>中,我们领略了valgrind对流程和性能瓶颈分析的强大能力.本文将介绍拥有相似能力的gperftools ...
- 使用Caffe进行手写数字识别执行流程解析
之前在 http://blog.csdn.net/fengbingchun/article/details/50987185 中仿照Caffe中的examples实现对手写数字进行识别,这里详细介绍下 ...
- Caffe中对MNIST执行train操作执行流程解析
之前在 http://blog.csdn.net/fengbingchun/article/details/49849225 中简单介绍过使用Caffe train MNIST的文章,当时只是仿照ca ...
- djangorestframework源码分析2:serializer序列化数据的执行流程
djangorestframework源码分析 本文环境python3.5.2,djangorestframework (3.5.1)系列 djangorestframework源码分析-serial ...
- 测试环境搭建流程_案例解析:一个完整的项目测试方案流程,应该是怎么的?...
作为一名软件测试工程师,为项目制作完成的测试方案并执行,是我们日常工作的重要部分,同时,也是一名合格的软件测试工程师应有的专业素养.那么,很多小白和测试新手肯定要问了:一个完整的项目测试方案流程,应该 ...
- Java Web - Struts2基本执行流程
一 前台测试页面 <%@ page language="java" import="java.util.*" pageEncoding="UTF ...
最新文章
- TX Text Control文字处理教程(13)实现拖放操作
- Ajax基础知识梳理
- ACM-ICPC 2018 沈阳赛区网络预赛 F. Fantastic Graph(有源上下界最大流 模板)
- 【已解决】蓝桥杯 2017年C组第五题 杨辉三角(分析与总结)
- python题库选择填空_python练习题4.18猴子选大王
- LeetCode 980. 不同路径 III(DFS+回溯)
- 为什么选择Bootstrap
- Java电子书平滑翻页效果_(转载)Android 平滑和立体翻页效果1
- 有些投资人从机构出来,自己单干做投资,募资一毛钱都没募到
- linux命令grep如何使用,Linux命令之grep命令简单使用
- 均方根误差有没有单位_mse均方误差是否有单位
- AltiumDesigner绘制PCB(一)
- 【OpenCV + Python】时域和频域傅里叶变换
- IKexpression解读二
- 【多多情报通】看完让人焕然大悟的6种拼多多店铺玩法
- linux外网服务器跳转内网服务器实现内网访问(iptables)
- /和/*的区别和用法
- 解决使用高分辨率笔记本分辨率放大100%以上运行程序界面控件不跟随方大方式qt+gtk+ui
- linux如何打印环境变量,在Linux中打印环境变量
- 邮件营销 | 精准投放,独立站可提升6倍转化率
热门文章
- 高性能计算之九-GPU在ANSYS高性能仿真计算中的应用
- 图像矫正--python_OpenCV实现透视变换
- html完成公告滚动条,原生js实现公告滚动效果
- 我用尽了全力,过着平凡的一生。我的全部努力,不过完成了普通的生活。
- 【wpa_supplicant】从 assoc 动作窥伺supplicant与driver的交互(一)
- python 可视化 皮肤,Python下载王者荣耀皮肤及个数可视化
- Vue 城市联动下拉选择组件实现
- 《平凡的世界》《白鹿原》《废都》读后感
- 鉴客 Android Intent 用法全面总结
- 自学《STM32不完全手册》的笔记三