N天学会Go语言

之前一直有了解 Go 语言,但没系统的学习,最近兴趣盎然生计所迫,得学一学了。整体来说 Go 语法很简洁,有其它语言基础的话学起来很快的。本文记录了一些学习笔记,加深理解。

基础语法

学语言,最重要的就是官方教程(https://go.dev/tour/list),建议不要一开始就跟着杂七杂八的教程学习。

变量

我们都知道 Go 语言是强类型的语言,所以定义变量的时候需要定义其类型,但像 C、Java,都是把类型放在前面:

咋一看有些别扭,但这有讲究的,比如符合英语系自然语言习惯,还有写起来就很方便,更突出变量名而不是类型(而且类型可以省略),官方博客有更多的介绍:Go's Declaration Syntax。还有,Go 和 Python一样,不需要写分号。

也还有简写的方式,因为定义变量的时候其实类型是确定的,编译器很容易推导,可以不用写类型。

看到var i = 1是不是有 JS 和 Java lombok/JDK10 的感觉,这样就很亲切了。

Methods are functions

一个函数定义:func add(x int, y int) int {},参数类型,返回值类型一目了然,这里可以看出还是类型后置,这点其实后面 Python 3.5 加入类型提示(限制)也是这样:def add(x: int, y: int) -> int:。这里顺带提一下,Python、PHP 这些无类型声明的语言并不是真的无类型(动态类型,运行时检查),而且新版都增加了类型检查的语法,解决了重构等痛点问题。

方法和函数其实一样,只是接收方参数不一样。

不过 Go 的函数没有 Python 的那么强大,没有默认参数、关键字参数等,虽然说 Go 讲究简洁,但这些语法糖还是很有用的,典型场景就是 Python requests 库:https://docs.python-requests.org/en/latest/api/#requests.request

Defer

延迟调用,这个比较特殊,defer定义的操作会在方法 return 后执行,但比如传的参数就是定义时那样,官方例子修改下:

输出

这个有什么作用呢,其实类似 Java 中 try/catch 中的 finally,可以用于资源的清理(经典应用就是释放锁)。官方博客有介绍:Defer, Panic, and Recover

Pointers

Go 的指针语法上来看,比 C 简单些,没有特殊的指针变量,&就是取地址,*就是取值(或者定义指针类型),也没有指针运算(其实也可以)。说到指针,又会牵扯到一个话题,传值还是传引用,简单来说 Go 传的是值,但并不代表改动不影响原数据,因为 map、slice等,可以理解本身数据结构就是指针,这些类型修改会影响原有数据,表现和其它语言一致。还有一个就是性能问题,本能的觉得传引用(指针)性能更高(其实还会带来 GC 等问题),比如字符串。

输出为:

咋一看 s2, s3 都是一个新的内存地址,这不影响性能嘛。但再看看:

输出:

可以看出 Go 对字符串等有两个优化:

1. 定义变量 literals(字面量)一致时(s1 和 s2),会指向同一个地址。

2. 变量传值时(s3 := s1)复制的是 string 这个数据结构。

data 指针指向的内容没有复制。因为要存储这个新的结构体,所以 &s3 是这个结构体的内存地址,而 s1,s3 结构体中 data 指向的地址是一样的。

指针这块细节可能理解不是太准确,但意思是语言层面已经做了很多优化,正常使用即可,不要过度优化到处使用指针。 Continue Reading...

Python import机制探究

最近遇到一个需求,需要动态加载一堆 Python 模块,但是这些 .py 是 pb 文件生成的,无法控制命名,导致导入的时候各种报错。

目录结构

package1.py 内容

package2.py 内容

package3.py 内容

可以看见在 basic.package1 中导入了 package2、package3。我们在 main 文件中导入 basic 包。

这样肯定不行,报错了:ModuleNotFoundError: No module named 'basic'

sys.path

Google 一下或者稍微了解 Python 的就会知道,Python 导入非系统库需要指定 PYTHONPATH,Python 启动时会读取此环境变量并加入到 sys.path 中。我们可以打印下:

可以看到默认加载了当前项目目录、Python系统目录以及 dist-packages(通过 pip 安装的),我们把 temp_dir 目录加进去就行了。代码增加:

但还是会报错:ImportError: cannot import name 'package2' from 'calendar' (/usr/lib/python3.8/calendar.py),不过至少说明,加载路径是设置成功了,basic 包本身导入成功了,只是 from calendar import package2 执行失败了。

这个是一个经典错误,搞 Python 的都会遇到,那就是自己定义的包名不能和系统的重复,一般是改个名字或者加上命名空间,但是我们这个场景中,Python 文件是 pb 生成的,无法修改源文件,修改生成的文件也比较麻烦。也能解决,就是提高我们路径的优先级,改成 sys.path.insert(0, temp_dir) 加载完了再改回来。

BuiltinImporter

不过又有新的报错:ImportError: cannot import name 'package3' from 'math' (unknown location),有输出 import package2,看着还是导入了系统的 math 库,没有导入自己的,还没有显示路径。这里匪夷所思,卡了很久,继续单步调试代码和查阅资料,发现 Python 其实是支持多种导入方式,甚至可以自定义。importlib._bootstrap._find_spec 中,是循环 sys.meta_path 中的 Importer 哪个找到算哪个。我们可以打印下 sys.meta_path:

可以看见最前面有个 BuiltinImporter(具体是在 importlib._bootstrap._install 时注入的),唉,灵光一闪,math 就是一个内建库呀,这里结合源码猜测,C 的库(详见 sys.builtin_module_names)都是通过这个导入的。去掉这个就能导入成功了(记得加载完了还原回来)。

终于成功了:

sys.modules

不过,这里只是个 demo。实际情况比这个复杂,还是会报 ImportError: cannot import name 'package2' from 'calendar' (/usr/lib/python3.8/calendar.py),这里很奇怪,已经保证了加载的优先级,但为什么还不行呢?没办法,扒一下源码,终于找到了一点端倪。importlib._bootstrap._find_and_load 方法中,会先在 sys.modules 里面找,如果有则直接返回,官方文档也有相应说明。

所以还得排除已加载的情况

最终到此才彻底解决,完整 demo 代码:https://github.com/iyaozhen/python-import-test

参考资料

https://docs.python.org/3/library/importlib.html

妙用Hook来研究Python的Import机制,https://www.51cto.com/article/527713.html

Python3.7源码剖析之import,https://zhuanlan.zhihu.com/p/361720373

工作5年,后3年都重复着前2年?写在离职百度之际(QA篇)

最近各种原因换了个工作,回想起来校招(实习)就进百度了,一晃5年多了。最近又宣传“十四五”,也想着自己确实得好好总结下这五年,而且市面上做 QA 的比较少,希望能对新同学有些帮助。

重新review下工作

做程序员这一块,可能都会遇到第一个瓶颈,工作前2两年成长很快(特别是 QA,都是现学),但后面就慢了,感觉重复着之前的工作。这里以“捕鼠器案例”为引子(杨震原字节内部分享,来源王梦秋百度的内部分享),重新捋一下 QA 的工作:

工作第1年

刚工作接到的任务一般比较简单、直接,比如导师可能叫你去放一下捕鼠器。这个时候你一般看看文档,多观察下,就知道捕鼠器是用来捕老鼠的,你只要不给它挂在墙上就行。甚至前人已经放过捕鼠器了,你只要依样画葫芦隔一段距离放一下就行了。

这个时候你就要稍微总结下了,放捕鼠器为什么有时没捕到老鼠,你可以会观察录像,看别人的捕鼠器怎么做的,这个时候稍作改进就会效果明显。具体的到QA的工作,你现在已经非常熟悉这块的业务,每次小功能的测试都游刃有余,而且通过学习你还会了 API/UI 自动化测试,效率也变高了。

工作第2年

由于表现还不错,你已经晋升为 T4 了。这个时候你会发现只放好老鼠夹子效果还是不好,具体到工作可能会发现线上还是有很多问题。你一想,捕鼠器是干啥,消灭老鼠,但消灭老鼠,不仅仅只有捕鼠器一种方法,或者说一种方法不够。

当一个人从完成任务到解决问题,能够再问一步,这个问题关联的问题是什么,进而去提出新的问题,这就是关键的突破点。

你可能会发现老鼠药更管用。类比测试工作上来说,你引入了更多的测试方法:diff 测试、性能测试、稳定性测试、兼容性测试、安全测试、ET测试等,工作开始变的顺风顺水。

工作第3-5年

这个时候你测试环节已经做的很不错了,能负责一个系统或者业务方向的测试了,一般的话也晋升为 T5(高级测试工程师)之列了。但这个时候就开始重复之前的工作了,项目不停迭代、新功能测也测不完。而且随着需求积累,回归测试也很多了,因此对于 QA 来说这种重复的感觉比 RD、FE 更强烈

这个时候还得回到原点来,捕老鼠是为了什么?为了保护粮食。那 QA 找 Bug 是为了什么?是为了“质量”。

(图 来源百度王梦秋分享 PPT)

这个时候介绍一个在百度内部非常重要的概念:全流程质量保障。作为 QA(Quality Assurance,质量保障),自己把自己定位成测试是不行的。

全流程质量保障

可能每个公司的项目流程都不一样,但都会包含以下几部分,当你跳出“测试”来看,就会发现能做的事情还有很多: Continue Reading...

Python使用logging为Flask增加logid

我们为了问题定位,常见做法是在日志中加入 logid,用于关联一个请求的上下文。这就涉及两个问题:1. logid 这个“全局”变量如何保存传递。2. 如何让打印日志的时候自动带上 logid(毕竟不能每个打日志的地方都手动传入)

logid保存与传递

传统做法就是讲 logid 保存在 threading.local 里面,一个线程里都是一样的值。在 before_app_request 就生成好,logid并放进去。

因为需要一个数字的 logid 所以简单使用 uuid.uuid1().time 一般并发完全够了,不会重复且趋势递增(看logid就能知道请求的早晚)。

打印日志自动带上logid

这个就是 Python 日志库自带的功能了,可以使用 Filter 来实现这个需求。

log_format 中我们用了很多系统自带的占位符,但 %(logid)s 默认没有的。每条日志打印输出前都会过 Filter,利用此特征我们就可以把 record.logid 赋值上,最终打印出来的时候就有 logid 了。

虽然最终实现了,但因为是通用化方案,所以有些复杂了。其实官方教程中介绍了一种更加简单的方式:injecting-request-information,看来没事还得多看看官方文档。

全部文章

iTerm2进阶使用技巧

iTerm2 的优点这里不做赘述了,第一次使用的话可以先看看官网的介绍:featuresHighlights for New Users。本文主要是结合实际使用场景,介绍一些进阶使用技巧(基本的 Oh My Zsh、rzsz 等配置就不重复说明了)。

跳板机自动登录

现在很多公司登录服务器都需要先登录到一个跳板机然后再登录到目标机器,每次输入密码(一般还是动态的)很麻烦。一般的教程推荐使用 expext 解决这个问题,这里介绍一个更简单、直接的办法。

首先,要先解决登录到跳板机的连接复用问题,这个输入 ssh 本身的范畴,一般教程都是说在 ~/.ssh/config 增加下面的配置:

但这样有个问题,iTerm2 第一次登录的 tab 关闭后,就失效了,再登录就又要密码了。其实再增加一个配置即可。

之前的设置只是实现了连接复用,但 tab 关闭 ssh 进程结束后,连接也被销毁了,ControlPersist 项的意思是进程结束后连接还保持,再有相同的(ControlPath配置)连接,还能继续使用此连接。

再者,每次输入 ssh xxx 很麻烦(而且跳板机登录后还得输入 ssh xxx),当然你可以配置 alias+expext 但也不是太方便,特别是 Windows 转过来的。对此,iTerm2 本身也提供了很强大的 Profiles 功能,能一键登录目标机器(还可以设置快捷键)。

有些自己的常用机器,还可以复制一下这份基础的 relay 配置。

再加一行(Send text at start处)。

还可以把机器打个标签,然后页面上就能分组,方便选择。

还有一个,有时候会发现,有些机器一会儿没操作就会被断开 ssh,一方面可以设置 ServerAliveInterval,但作用不大,因为实际连接服务器的是 relay 跳板机。iTerm2 其实没有可以显式设置这个功能的地方,有些同学可能找到个这个配置: Continue Reading...

Python进/线程池使用

在 Python 中,进程/线程是个非常重要的概念,特别是 Python 还有 GIL(同一时刻只有一个线程在执行 Python bytecode)限制,使得 Python 线程并不那么好用。但 GIL 更多的是影响 CPU 密集型任务,实际业务场景更多的是 IO 密集型任务,多线程还是适用绝大多数场景。不过话又说回来,很多时候不太好判断是 IO 密集型多还是 CPU 密集型多,需要在多进程、多线程环境下分别验证。

但多线程和多进程手写起来还是有点差别,好在 multiprocessing.Pool 提供了统一的接口,可以无缝切换:

下面介绍下 Pool 类如何使用。

首先是构造函数:

class multiprocessing.Pool([processes[, initializer[, initargs[, maxtasksperchild]]]])

接收 processes initializer initargs maxtasksperchild 4个参数:

processes,就是池里有多少个进程,可以不传,默认 CPU 个数,可以按需多设置几倍

initializer、initargs,如果设置了参数,则会在每个进程初始化的时候调用 initializer(*initargs)。这个非常有用,比如可以在初始化的时候建立连接,连接重用。

maxtasksperchild,用于设置每个子进程执行多少个任务后重启。虽然很简单粗暴,但这是防止内存溢出、资源未释放等问题的常见手段,类似 PHP-FPM 的 pm.max_requests 参数。线程池无此参数。

这里有一个 hack 技巧,将初始化的数据库连接,绑定在了请求函数上,这样调用请求函数发送请求时,就不用重新建立连接,直接使用即可。

我们一般使用线程池有两个场景,一是关注执行结果,比如我们并行去 redis mysql 各个地方请求数据,然后整合这些数据,二是不关注执行结果,比如新开一个线程打印一个请求的审计日志,不阻塞主进程返回数据。这里主要是介绍下 apply 和 apply_async 的区别,其它的都类似。

apply(func[, args[, kwds]]),主要是传一个执行函数和参数,阻塞并得到返回结果。

apply_async(func[, args[, kwds[, callback]]]),也是主要传执行函数和参数,但返回的是一个 multiprocessing.pool.AsyncResult 对象,AsyncResult 主要有 get([timeout])、wait([timeout])、ready()、successful() 4 个方法,都很好理解,用的比较多的是 get 方法,给定超时时间内获取执行的结果,如果超时抛出 multiprocessing.TimeoutError 异常。如果 timeout 是 None,则一直就等待,行为就和 apply 一致了,实际上 apply 也是调用的 apply_async get:

apply_async 还有一个有用的参数 callback,相对于异步回调了,一般用于上述的场景二。下面是一些示例:

运行结果:

注意代码的最后一行,这里不仅仅是为了看回调结果,还因为回调是回调到主进程执行,如果主进(线)程已经退出了,那就处理不到回调了,实际使用需要注意运行环境。

最后,multiprocessing 模块的 pool 功能只是其中很小的一部分,但比较实用,后面有新的心得再介绍其它功能。

参考资料:

https://docs.python.org/2.7/library/multiprocessing.html#module-multiprocessing.pool

Python Requests Session set-cookie不生效的坑

我们知道 Python Requests库 中的 Session 模块有连接池和会话管理的功能,比如请求一个登录接口后,会自动处理 response 中的 set-cookie,下次再请求时会自动把 cookie 带上。但最近出现了一个诡异的事情,cookie 没有自动带上,导致请求 403。

一开始怀疑是登录接口错误了,没有 set-cookie,但抓包发现 response header 中有 set-cookie,打印请求的 response.cookies 也有需要的 cookie。又怀疑是 set-cookie 的格式不对或者其它问题,但用浏览器实际跑了下流程,发现系统一切正常,那基本就是 requests 库的问题了。

没办法,只能 debug 了,单步调试了几轮,基本了解了 requests 的处理方式,首先把请求参数转变为 Request 对象,然后对使用 prepare_request 对 Request 进行预处理,其中有一步 merge_cookies 的操作(还有各种其它处理),把传入的 cookies 和 self.cookies merge 到 RequestsCookieJar 对象上去,这一步也没啥问题,merged_cookies 变量也是对的。后续将预处理过的请求,通过内置的 http adapter 发送出去。http adapter 底层是通过 urllib3.poolmanager 获取到 urllib3.connectionpool 连接(这里是连接池的核心部分),再通过 conn.urlopen 实际发送请求。虽然跟踪了解到了整个请求逻辑,但最终发出的请求还是没有带上需要的 cookie。

问题定位一度陷入僵局,只能再回顾上面的流程,cookie 肯定就是在 merged_cookies 和 conn.urlopen 之间没的,再仔细观察发现,conn.urlopen 请求参数里面压根没有 cookie 字段。

查阅资料发现,urllib3 的作者说,连接池只处理底层连接,cookie 跟踪等事情应该上层来做。大胆猜测,那 cookie 应该是放在 header 里了,往前捣看看 request.headers 是怎么变动的(此时里面的 Cookie 字段确实不正确)。

再走查代码发现 prepare_request 里面是调用了 PreparedRequest.prepare,其中有一步 prepare_cookies,主要是调用了 cookielib.CookieJar.add_cookie_header 最终将 cookie 放到了 self.headers['Cookie']。但里面有个 request.has_header("Cookie") 的判断,header 中没有 Cookie 字段才会放,不知道为什么这么考虑(最新版 3.8 还是这样),估计是 merge cookie 比较麻烦,但问题确实就出在这里,这次请求之前,Requests Session 已经直接通过 headers['Cookie'] 设置了 cookie。复现代码(修改自官方示例):

虽然找到了问题的原因,但又不好解决,总不能不让直接操作 headers['Cookie'] 吧,先不说无法限制使用者,而且之前的代码已经这样做了,改动量非常之大。不过好在,现在自己用的框架是在 Requests Session 上封装了一层,操作 header 都是调用的统一的 update_headers 方法:

对 headers['Cookie'] 的操作拦截一下,变成对 cookies 的操作:

这样即不用改之前的调用方代码,也防止了后人掉坑。

 

参考资料

https://requests.readthedocs.io/en/master/user/advanced/#session-objects

https://stackoverflow.com/questions/2422922/python-urllib3-and-how-to-handle-cookie-support

https://urllib3.readthedocs.io/en/latest/reference/index.html#module-urllib3.poolmanager

https://urllib3.readthedocs.io/en/latest/reference/index.html#urllib3.connectionpool.HTTPConnectionPool.urlopen

https://docs.python.org/2/library/cookie.html

Filebeat核心配置详解

Filebeat简介

现在 ELK(Elasticsearch、Logstash 和 Kibana)日志分析系统非常火,但相关的介绍忽略了重要的一环:日志采集。虽然 Logstash 也能采集日志,但比较重、资源占用高,显然不适合线上和业务部署,所以一开始搞了个 logstash-forwarder 后来整合为 Filebeat。慢慢还发展成了一个 Beats 系列,支持采集各种各样的元数据。

Filebeat原理

说到实时查看日志,最常用得莫过于 tail -f 命令,基于此可以自己实现一个简单的日志采集工具,https://github.com/iyaozhen/filebeat.py/blob/master/filebeat.py#L237

但这太简陋了,无法保证不丢不重。我们看看 Filebeat 是怎么实现的,官方说明:https://www.elastic.co/guide/en/beats/filebeat/current/filebeat-overview.html

简单来说 Filebeat 有两大部分,inputs 和 harvesters,inputs 负责找文件(类似 find 命令)和管理 harvesters,一个 harvester 则和一个文件一一对应,一行行读然后发送给 output(类似tail -f)。

当然还有很多细节问题,我们结合配置文件一一详解。

Log input配置详解

官方配置说明:https://www.elastic.co/guide/en/beats/filebeat/current/filebeat-input-log.html

先看一个基本例子(下面所述基于7.x版本,6.x版本也基本适用)

inputs 可以配置多块(block),就是相同类型的放一块,这个也很好理解。paths 可以配置多个文件,文件路径和文件名都支持通配。

ignore_older 和 scan_frequency

这就有两个细节问题了,一是路径下的历史文件可能很多,比如配置了按天分割,显然旧文件我们一般是不需要的。二是扫描频率如何控制,通配设置复杂的话频繁扫文件也是很大的开销。

问题一则是通过 ignore_older 参数解决,意思就是多久前的旧文件就不管了。比如设置为 1h,表示文件时间在 1h 之前的日志都不会被 input 模块搜集,直到有新日志产生。

问题二则是通过 scan_frequency 参数控制,表示多久扫描一次是否有新文件产生。比如设置 10s(默认),一个新文件产生 10s 后会被发现,或者一个旧文件(上面 ignore_older)新产生了一行日志 10s 才发现这个文件。有人说我需要实时性,是不是这个值设置的越小越好,其实是错误的,前面我们介绍了 Filebeat 架构,input 模块只是负责发现新文件,新文件是相对已经被 harvester 获取的文件,第一次发现之后就已经在被 harvester 一行行实时读取了,所以这里基本上只影响日志切分时的实时性(这种场景下的短暂延迟是可以接受的)。

close_* 和 clean_*

那么被 harvester 获取的文件就一直拿着不放吗?文件重命名或者被删除后怎么办呢?这里重点介绍这两组配置。 Continue Reading...

隐私政策

我们是谁

我们的站点地址是:https://iyaozhen.com。

我们收集何种及为何收集个人数据

媒体

如果您向此网站上传图片,您应当避免上传那些有嵌入地理位置信息(EXIF GPS)的图片。此网站的访客将可以下载并提取此网站的图片中的位置信息。

Cookies

您可以选择用cookies保存您的姓名、电子邮件地址和网站。这是通过让您可以不用再次填写相关内容而向您提供方便。这些cookies会保留一年。

其他站点的嵌入内容

此站点上的文章可能会包含嵌入的内容(如视频、图像、文章等)。来自其他站点的嵌入内容的行为和您直接访问这些其他站点没有区别。

这些站点可能会收集关于您的数据、使用cookies、嵌入额外的第三方跟踪程序及监视您与这些嵌入内容的交互,包括在您有这些站点的账户并登录了这些站点时,跟踪您与嵌入内容的交互。

统计

本站使用了百度统计,会记录你的IP、浏览器UA等信息,详见:
https://tongji.baidu.com/web/help/article?id=314&type=0

我们与谁共享您的信息

除WordPress插件本身行为外(详见下文其它信息),未主动与任何人共享任何个人数据。

我们保留多久您的信息

对于本网站的注册用户,我们也会保存用户在个人资料中提供的个人信息。所有用户可以在任何时候查看、编辑或删除他们的个人信息(除了不能变更用户名外)、站点管理员也可以查看及编辑那些信息。

您对您的信息有什么权利

如果您有此站点的账户,您可以请求我们提供我们所拥有的您的个人数据的导出文件,这也包括了所有您提供给我们的数据。您也可以要求我们抹除所有关于您的个人数据。这不包括我们因管理、法规或安全需要而必须保留的数据。

我们将您的信息发送到哪

基础访问信息可能会被发送到统计服务。

我们的联系信息

详见:https://iyaozhen.com/about_me

其他信息

使用的插件、服务的相关隐私政策

来源:iThemes Security

我们收集何种及为何收集个人数据

Security Logs

The IP address of visitors, user ID of logged in users, and username of login attempts are conditionally logged to check for malicious activity and to protect the site from specific kinds of attacks. Examples of conditions when logging occurs include login attempts, log out requests, requests for suspicious URLs, changes to site content, and password updates. This information is retained for 60 days.

我们与谁共享您的信息

This site is scanned for potential malware and vulnerabilities by Sucuri's SiteCheck. We do not send personal information to Sucuri; however, Sucuri could find personal information posted publicly (such as in comments) during their scan. For more details, please see Sucuri's privacy policy.

我们保留多久您的信息

Security logs are retained for 60 days.

我们将您的信息发送到哪

This site is part of a network of sites that protect against distributed brute force attacks. To enable this protection, the IP address of visitors attempting to log into the site is shared with a service provided by ithemes.com. For privacy policy details, please see the iThemes Privacy Policy.

来源:腾讯云

腾讯云非常重视保护您的隐私。为方便您登录、使用相关服务,以及为您提供更个性化的用户体验和服务,您在使用我们的服务时,我们可能会收集和使用您的相关信息。详见:腾讯云隐私声明

我们如何保护您的数据

服务器使用腾讯云,网站本身使用 iThemes Security 插件保障安全,程序包括但不限于 PHP、MySQL、WordPress,保持更新。

我们有何种数据泄露处理流程

暂无。

我们从哪些第三方接收数据

网站未从第三方,如广告商,接收关于用户的数据。

我们通过用户数据进行何种自动决策及/或归纳

网站无自动决策的服务。

行业监管披露要求

遵守《中华人民共和国宪法》及相关法规,特别的,受《中华人民共和国网络安全法》监管。

本 Blog 不支持评论,如有疑问或建议请联系我,以完善内容,期望帮助到更多的同学