跳转至

Blog

Android 测试框架 —— JUnit 介绍

介绍

JUnit 是 Java 编程语言的一个单元测试框架。该框架广泛应用于各种 Java 项目中,主要用于测试代码的正确性和稳定性。JUnit 提供了一些方便实用的断言方法和测试运行器,使得开发人员可以轻松编写和执行单元测试。

JUnit 最初由 Erich Gamma 和 Kent Beck 在 1997 年共同开发,目前已经成为 Java 社区中最流行的单元测试框架之一。JUnit 可以和各种 Java 开发工具和构建工具集成使用,例如 Eclipse、IntelliJ IDEA、Maven、Gradle 等。JUnit 也是 Test-Driven Development(TDD)和 Behavior-Driven Development(BDD)方法论的重要工具之一。

JUnit 的每个版本都带来了更大的灵活性和更好的可维护性,使得单元测试在 Java 开发中的应用越来越广泛。

  1. JUnit 1 - 3: 初步实现了 Java 单元测试的基本功能,支持测试用例的组织和断言。
  2. JUnit 4: 大幅简化了测试代码,提供了注解和更多的灵活性,成为广泛使用的标准。
  3. JUnit 5: 引入了模块化架构,进一步增强了注解和扩展机制,支持参数化测试、嵌套测试以及更复杂的条件化测试,成为现代 Java 测试的核心框架。

JUnit 1 (1997)

  • 发布日期: 1997年
  • 特点: JUnit 1 是由 Kent Beck 和 Erich Gamma 开发的最早版本,基于 "Test-First" 编程理念,专门为单元测试提供支持。它基于 Java 反射机制,支持自动化执行测试用例。
  • 功能:
    • 提供了基本的断言方法,如 assertTrue(), assertEquals()
    • TestCase 类组织测试用例。
    • 测试类需要继承 TestCase 类,并通过 run() 方法执行。

JUnit 2 (1999)

  • 发布日期: 1999年
  • 特点: JUnit 2 在 1 版本基础上做了改进,并支持更多的功能。
  • 功能:
    • 增强了 TestCase 类的功能。
    • 引入了测试套件(Test Suites)概念,可以将多个测试类组织成一个测试套件批量运行。
    • 可以更方便地定义多个测试方法。
    • 支持通过 setUp()tearDown() 方法进行初始化和清理操作。

JUnit 3.x (2000)

  • 发布日期: 2000年
  • 特点: JUnit 3.x 是一个重要的版本,它增加了更多的功能,使得测试用例更具结构性和可读性。
  • 功能:
    • 测试方法不再需要继承 TestCase 类,但通过继承来组织测试仍然是常见做法。
    • 引入了 TestSuite 类,用于组合多个测试类。
    • setUp()tearDown() 方法用于在每个测试之前和之后执行初始化和清理工作。
    • 增加了更多的断言方法,如 assertNotNull()assertSame()assertArrayEquals() 等。

JUnit 4 (2006)

  • 发布日期: 2006年
  • 特点: JUnit 4 是一个重大的版本更新,引入了很多新的特性,特别是对注解的支持,极大简化了测试代码的编写和组织。
  • 功能:
    • 注解支持:JUnit 4 引入了广泛使用的注解,替代了之前的继承方式,使得测试代码更加简洁、易懂。
      • @Test:标记一个方法是测试方法。
      • @Before:在每个测试方法之前执行的初始化方法。
      • @After:在每个测试方法之后执行的清理方法。
      • @BeforeClass:在所有测试方法执行之前执行的静态方法。
      • @AfterClass:在所有测试方法执行之后执行的静态方法。
      • @Ignore:标记一个测试方法被忽略,不会执行。
    • JUnitRunner:JUnit 4 引入了 JUnitCore 类来运行测试,也支持通过运行器(@RunWith)来扩展测试行为。
    • 断言方法:JUnit 4 保持了原来的断言方法,同时也允许用户定义自定义的断言。
    • 参数化测试:支持运行参数化测试,即用不同的输入值多次执行同一个测试方法。

JUnit 5 (2017)

  • 发布日期: 2017年
  • 特点: JUnit 5 是对 JUnit 框架的全面重构,改进了许多核心功能,特别是对现代开发环境的适应性。
  • 功能:
    • 模块化架构:JUnit 5 引入了三个主要模块:
      • JUnit Platform:提供了一个启动和运行测试的平台,支持与其他测试框架集成。
      • JUnit Jupiter:这是 JUnit 5 的核心模块,提供了新的测试 API 和扩展模型,支持注解、参数化测试等。
      • JUnit Vintage:用于支持运行 JUnit 4 和 JUnit 3 的测试。
    • 更丰富的注解:
      • @Test:用于定义测试方法。
      • @BeforeEach:在每个测试方法执行之前运行。
      • @AfterEach:在每个测试方法执行之后运行。
      • @BeforeAll:在所有测试方法执行之前运行。
      • @AfterAll:在所有测试方法执行之后运行。
      • @DisplayName:为测试方法指定显示名称,增强可读性。
      • @Tag:为测试方法添加标签,便于分类。
    • 条件化测试:JUnit 5 支持条件化执行测试的功能,如 @EnabledIf, @DisabledIf 等,可以根据不同条件来决定是否执行某些测试。
    • 参数化测试:JUnit 5 提供了 @ParameterizedTest,可以更简洁地进行参数化测试。
    • 扩展机制:JUnit 5 引入了 @ExtendWith 注解,提供了比 JUnit 4 更灵活的扩展机制。
    • 支持嵌套测试:JUnit 5 支持嵌套测试类 @Nested,可以组织更复杂的测试逻辑。

JUnit 5.x 版本后续更新

  • JUnit 5.1:继续改进注解和扩展机制。
  • JUnit 5.2:优化了条件化测试和增强了对 Java 9+ 模块化系统的支持。
  • JUnit 5.3+:持续更新以支持新的特性和与其他库的兼容性,改进性能和扩展功能。

版本总结对比

特性/版本 JUnit 1 JUnit 2 JUnit 3 JUnit 4 JUnit 5
发布年份 1997 1999 2000 2006 2017
继承结构 必须继承 TestCase 必须继承 TestCase 可选择继承 TestCase 注解驱动,无需继承 注解驱动,无需继承
注解支持 有 (@Test, @Before, @After等) 有 (@Test, @BeforeEach, @AfterEach等)
参数化测试
扩展机制 有(@RunWith 有(@ExtendWith
测试执行器 反射机制 反射机制 反射机制 JUnitCoreJUnitRunner 模块化架构(JUnit Platform)
支持版本 - - JUnit 3 JUnit 4 JUnit 4, 3(通过 Vintage)

Android 性能优化利器 —— 火焰图(FlameGraph)

介绍

火焰图(Flamegraph)是一种用于可视化软件程序性能剖析数据的强大工具,主要用于分析程序的 CPU 使用情况。它由性能工程专家 Brendan Gregg 开发,现已成为系统性能分析的标准工具之一。

Note

提到火焰图(FlameGraph)时,很难不提它的发明者 Brendan Gregg。Brendan Gregg 是性能优化领域的权威专家,曾在 Netflix、Sun Microsystems 等公司从事系统性能分析工作。他不仅开发了火焰图,还贡献了大量性能分析工具和方法论,如 DTrace(动态跟踪工具)的使用和优化。

Brendan Gregg 也是一位乐于分享技术的大牛,这是他的博客地址:https://www.brendangregg.com/

核心原理

火焰图的核心原理是通过将时间轴与堆栈跟踪信息巧妙结合,以直观的可视化方式展示程序在执行过程中的活动情况。其核心流程大致可以分成如下三个步骤:

  1. 通过采样获取程序运行时的调用栈信息;
  2. 统计各函数在调用栈中出现的频率;
  3. 将统计结果转化为层级化的可视化图表。

下图是一个火焰图可视化图表示例:

火焰图示例

火焰图是对 CPU 进行周期性的采样。通过设置一定的采样频率,让工具频繁地访问 CPU 上执行的指令的地址,并记录当前调用栈。每次访问 CPU 运行情况的瞬间,可以理解为采样点。在一定周期内采集到的点就可以汇集成“面”,这个“面”的精细程度由采样频率决定。 采样的频率越高,收集到的数据就越详细,“面”的也就越精细,反之亦然。 最后,再把这个“面”进行数据处理,比如符号解析、栈帧整理等操作,就得到了火焰图。 当用户调用脚本后,将这些原始数据通过可视化的界面(比如网页、SVG)展示出来。

Danger

初学者经常会把火焰图的长度看成是函数的执行时间,这是错误的!

或者换个思路,其实很好理解。把 CPU 想象成池塘,把每次采集到的点想象成做了标记的鱼。 当我们想对一个池塘的鱼的总数进行估算时,会先捞出一部分鱼做标记,丢回池塘,过一段时间,再捞起等同数量的鱼,计算被标记鱼的占比, 从而大概估算整个池塘鱼的数量。

火焰图也一样,只不过其是对 CPU 运行状态进行“标记”。假设 CPU 在 T0 时间下,采集到的采样点 P0 正在执行方法为 M0。当下一次采集时,CPU 在 T1 时间下,采集到的采样点 P1 正在执行方法为 M1。对比 P0 和 P1 函数和栈帧情况:

  • 若 P0 和 P1 完全相等,则 M0 和 M1 完全相等。则可以认为从 T0 时间到 T1 时间段,CPU 都在执行 M0 方法,或者 CPU 都在执行 M1 方法;
  • 如 P0 和 P1 不相等,则 M0 和 M1 不相等。则可以认为从 T0 时间到 T1 时间段,CPU 至少执行了 2 个及其以上的方法,其中包含 M0 和 M1。因为可能在 T0 到 T1 过程中,有某些方法瞬间执行,但是采样频率设置过长,导致没有采集到。

最后,将 P0、P1、P2、P3...... 采样点进行整理和归类,就可以得到多条连续的线段,将线段高度拉高,就成了火焰图上看到的矩形(可以理解成火焰图的 X 轴)。再根据函数调用栈的情况,将这些矩形进行上下排列,得到了形似火焰的阶梯形状(可以理解成火焰图的 Y 轴)。

为了方便理解,我们可以举个实际的代码的例子。以下是一段伪代码1

main() {
     // some business logic
    func3() {
        // some business logic
        func7();
    }

    // some business logic
    func4();

    // some business logic
    func1() {
        // some business logic
        func5();
    }

    // some business logic
    func2() {
        // some business logic
        func6() {
            // some business logic
            func8(); // cpu intensive work here
    }
}

性能分析启动时以每秒 X 次的频率进行采样。每次采集样本时,系统会保存当前的调用栈信息。下图展示了在排序与聚合处理前,未经整理的原始采样数据视图。

原始采样数据视图

这是每个函数被采样到的样本数:

  • func3()->func7(): 3 samples
  • func4(): 1 sample
  • func1()->func5(): 2 samples
  • func2()->func8(): 4 samples
  • func2()->func6(): 1 sample

样本随后会在应用程序的根节点(或主方法)之后的基底层按字母顺序进行排序。

样本字母排序

注意,X轴不表示时间维度。火焰图不会保留特定调用栈的采样时间信息,仅反映在性能分析期间各调用栈出现的频率统计。 系统将各调用栈层级中的相同函数块进行拼接整合,最终形成聚合的火焰图可视化视图。

聚合函数后的火焰图.png

本示例中,除 func4() 外,其他函数在调用栈基底层均未实际占用资源。实际资源消耗来自func5()func6()func7()func8() 函数,其中 func8() 是性能优化的重点候选对象。

火焰图最典型的应用场景是分析 CPU 使用率,但同时它也支持多种分析模式:内存分配分析(Allocation Profiling)可用于监测堆内存使用状况,而实时耗时分析(Wall-clock Profiling)则能有效诊断系统延迟问题。

Note

Q:是不是采样频率越高越好?

不是。采样的频率越高,收集到的数据就越详细,但也会对程序性能产生更大的影响。通常会选择一个折衷的采样频率,既能获得足够的数据分析程序行为,又不会过分干扰程序运行。

平台工具

火焰图可以通过多种工具和方式生成,主要取决于目标平台、编程语言和性能分析需求。以下是常见的生成方式分类:

分类 工具/方法 适用场景 典型命令/工具链
Linux系统 perf + FlameGraph Linux系统级分析 perf recordperf script → FlameGraph脚本
eBPF + BCC 现代Linux内核分析 profile/offcputime工具 → 导出数据生成
SystemTap 深度内核分析 SystemTap脚本采集 → 数据转换
Java/JVM Async-Profiler 低开销CPU/内存/锁分析 直接生成火焰图
JFR (Java Flight Recorder) 官方性能记录工具 记录文件 → 转换为火焰图格式
VisualVM 内置采样分析 导出调用栈 → 生成火焰图
Android平台 Simpleperf + Inferno Android Native代码分析 simpleperf recordinferno生成
Android Studio Profiler 官方IDE集成工具 CPU分析 → 导出调用栈 → 转换
Perfetto 系统级跟踪 跟踪数据 → trace_processor转换
其他语言 Python (py-spy/scalene) Python应用分析 py-spy record → 生成SVG
Node.js (0x/clinic.js) JavaScript性能分析 0x--cpu-prof生成
Go (pprof) Go应用分析 go tool pprof --web生成
Rust (flamegraph) Rust应用分析 cargo flamegraph生成
.NET (dotnet-trace) .NET应用分析 dotnet-trace collect → PerfView分析
通用/跨平台工具 Speedscope 在线可视化 支持多种输入格式直接可视化
Hotspot perf数据可视化 加载perf.data自动生成
VTune Intel处理器深度分析 内置火焰图生成功能

配色方案

默认的火焰图的配色方案以暖色调为主,反应的是 CPU 的繁忙程度(热程度)。除此之外,还有其他的的配色方案,比如用于 Java 混合模式的黄绿红橙配色方案:

Java 混合模式 CPU 火焰图

上图中颜色的含义如下:

火焰图的函数配色方案

生成方式

以 Android 为例,生成火焰图的方式如下几种:

  1. Simpleperf:Android 官方优化版 perf,支持 ARM 架构,性能开销低。无需修改代码,支持符号解析(需带调试信息的 .so)。
  2. Android Studio Profiler:图形化界面,集成在 Android Studio 中,易用性强。支持采样分析(Sampling 和插桩分析(Instrumentation)。
  3. Perfetto:高性能、低开销,支持 CPU、GPU、内存等多维度分析。可导出为 perfetto-trace 并用 UI 或脚本生成火焰图。

下表是这些方法的对比情况:

方法 适用层 Root 需求 精度 易用性 适用场景
Simpleperf Native/JNI 部分需 Root ⭐⭐⭐⭐ ⭐⭐⭐ NDK 开发、系统优化
Android Studio Profiler Java/Kotlin 无需 ⭐⭐ ⭐⭐⭐⭐ 应用层性能调试
Systrace + Flame Graph 系统/线程 无需 ⭐⭐ ⭐⭐ 卡顿分析、锁竞争
Perfetto 系统/应用 无需 ⭐⭐⭐ ⭐⭐⭐ 多维度系统级跟踪
eBPF 内核/Native 需 Root ⭐⭐⭐⭐ 底层性能分析
Async-Profiler Java/Native 部分需 Root ⭐⭐⭐⭐ ⭐⭐⭐ 混合语言应用(如游戏、音视频)

Simpleperf

Warning

若对 Simpleperf 的命令不熟悉,建议先阅读《Android 性能优化利器 —— Simpleperf》

以 Settings 为例,先使用 Simpleperf 生成火焰图的核心是获取到 perf.data 文件,然后再把此文件通过 report.html 转换成火焰图。

Step 1:获取 perf.data 文件。

# 方式 1:使用内置的 Simpleperf
$ adb shell ps -A | grep settings
system        7711   590 5405764 116988 do_epoll_wait       0 S com.android.setting

$ adb shell "simpleperf record -p 7711 --duration 10 -o /data/local/tmp/perf.data"
simpleperf I cmd_record.cpp:729] Recorded for 10.0152 seconds. Start post processing.
simpleperf I cmd_record.cpp:809] Samples recorded: 0. Samples lost: 0.

$ adb pull /data/local/tmp/perf.data D:/Downloads

# 方式 2:使用 Android NDK 下的脚本
python app_profiler.py -p com.android.settings
17:10:52,908 [INFO] (app_profiler.py:206) prepare profiling
...
17:11:09,340 [INFO] (app_profiler.py:213) profiling is finished.

方式 2 其实和方式 1 一样,在执行方式 2 的时候会显示执行的是如下的命令:

adb shell "simpleperf record -o /data/local/tmp/perf.data -e task-clock:u -f 1000 -g --duration 10 --log info --app com.android.settings"

Step 2:将 perf.data 转换成火焰图,推荐使用 Android NDK 下的 report.py 脚本完成:

python report.py -i <your_perf_data_path>

在参数 -i 后传递 perf.data 在 PC 的路径即可。

Danger

注意:生成的 Html 可能会加载失败,这是无法访问 JavaScript 源,解决办法参考 生成火焰图无法加载

Android Studio Profiler

不同的 Android Studio 的 Profiler 的入口和界面可能不同。在 Android Studio Ladybug(2024.2.1 Patch 2)的版本上, Profiler 的入口在:View > Tool Windows > Profiler。打开后,可以看到如下的界面:

Android Studio Profiler

  1. 提供了多种性能的分析方式,因为火焰图是对 CPU 执行的函数的采集,所以可以选择:Find CPU Hotspots;
  2. 选择录制的方式,可以选择 Tracing 或者 Sampling;
  3. 确认后,点击 Start profiler task。

等待录制完成,就可以获取到如下的火焰图:

CPU Hotspots

Perfetto

在 Perfetto 的 Record new trace 选项卡中,在 Stack Sampling 开启 Callstack sampling,并设置采集的频率:

Stack Sampling

然后抓取到的 Perfetto trace 中可以看到函数的调用情况:

Flamegraph in Perfetto

Note

通过 Perfetto 抓取到更像是函数的堆栈图,相比 Simpleperf 生成的图,总感觉这种方式缺少了点东西。

差分火焰图

差分火焰图(也称为红蓝差分图)是一种通过对比两个时间点的火焰图来分析性能变化的可视化图。它可以帮助开发者了解程序在不同时间段内的性能特征, 并识别出在两个时间点之间发生的性能变化。差分火焰图的重点在于分析函数的样本被采集到的数量以及两个时间点之间样本的变化情况。

在 Android 中,生成差分火焰图相对生成火焰图稍微复杂一些,需要使用 Simpleperf 和 FlameGraph 工具共同完成。大致的步骤可以总结如下:

  1. 通过 Android NDK 提供的脚本抓取数据,得到 perf.data。因为差分火焰图是对两份数据进行对比,通常需要在测试机和对比机上分别抓取;
  2. 使用 stackcollapse.pyperf.data 转成 FlameGraph 能够识别的 folded 格式;
  3. 最后使用 difffolded.pl 脚本做差分,得到差分火焰图。

以生成通讯录(Contacts)应用在冷启动过程中的差分火焰图为例,具体步骤如下:

Step 1:在测试机和对比机上分别抓取两份 perf.data 数据。

python app_profiler.py -p com.google.android.contacts -o perf_first_cold_start.data
python app_profiler.py -p com.google.android.contacts -o perf_second_cold_start.data

Step 2:下载 FlameGraph 源码,然后配置 Perl 环境:

配置 Perl 环境。网上有很多教程,自行搜索。检查配置是否成功:

# 检查 Perl 语言环境可用
$ perl -v 
# 输出 Perl 的信息
This is perl 5, version 36, subversion 0 (v5.36.0) built for MSWin32-x64-multi-thread

Step 3:将 perf.data 数据文件转 folded 文件:

python stackcollapse.py -i perf_first_cold_start.data > perf_first_cold_start.folded
python stackcollapse.py -i perf_second_cold_start.data > perf_second_cold_start.folded

若转换后的 folded 文件大小为 0 字节,说明数据抓取或者转换错误。返回之前的步骤,重新抓取。

Step 4:生成差分火焰图

perl difffolded.pl perf_first_cold_start.folded perf_second_cold_start.folded | perl flamegraph.pl --negate > D:\Temp\diff.svg

Step 5:得到 diff.svg 的差分火焰图,使用浏览器访问。

Java 混合模式 CPU 火焰图

上图中颜色的含义如下:

  • 蓝色:表示在后一时间点相比于前一时间点,函数调用次数减少了。这可能意味着在优化或者修复了某些问题后,相关函数的调用次数减少了;
  • 红色:表示在后一时间点相比于前一时间点,函数调用次数增加了。这可能意味着在代码中引入了新的性能问题或者程序流程发生了变化,导致某些函数被更频繁地调用。
  • 白色或灰色:表示未变化的部分。

版本控制 —— Git

Git 是一个版本控制系统,它被广泛用于协作软件开发项目。通过 Git,开发者可以跟踪文件的变化,协调多人对同一代码库的修改,以及回滚到之前的版本。它是一个分布式版本控制系统,意味着每个开发者都可以在本地拥有完整的代码仓库,并可以独立地工作,而不受网络连接的限制。

Git 的诞生背景

Git 是由 Linus Torvalds 于 2005 年开始开发的,最初是为了管理 Linux 内核开发而创建的。当时,Linux 内核开发团队使用的分布式版本控制系统 BitKeeper 出现了争议,因此 Torvalds 决定开发一个新的系统。Torvalds 在设计 Git 时借鉴了许多现有版本控制系统的优点,并为其加入了许多新特性。Git 的设计目标之一是速度,它能够在处理大型项目时保持高效。

Git 于 2005 年正式发布,很快就受到了开源社区的欢迎。随着时间的推移,Git 逐渐成为了最流行的版本控制系统之一,被广泛应用于软件开发和其他领域。 Git 拥有庞大的开发者社区,许多开源项目和商业项目都选择使用 Git 进行版本控制。此外,许多网站和服务,如 GitHub、GitLab 和 Bitbucket,提供 Git 仓库托管和协作工具,进一步推动了 Git 生态系统的发展。

Git 持续得到改进和发展,定期发布新版本以提供更好的性能、功能和安全性。同时,Git 也受到了许多其他版本控制系统的影响,并在不断地与其他工具和平台集成,以适应不断变化的开发需求。

Git 的优势和用途

Git 是一个强大的分布式版本控制系统,具有许多优势和广泛的用途。

Git 的优势
  1. 分布式版本控制:每个开发者都拥有本地的完整版本库,可以在没有网络连接的情况下工作。这种架构使得协作更加灵活和高效。
  2. 版本管理:Git 可以跟踪文件的每一次修改,轻松查看历史版本、比较差异并回滚到任意版本。这有助于团队协作和代码管理。
  3. 分支管理:Git 的分支操作非常快速和便捷,可以轻松创建、合并和删除分支,支持并行开发和实现复杂的工作流程。
  4. 高效性能:Git 的设计目标之一是高效性能,即使处理大型项目和大量历史数据,仍能保持速度快。
  5. 灵活性:Git 具有强大的配置选项和可扩展性,可以根据团队或项目的需求进行定制和扩展。
  6. 开源社区:Git 是开源的,拥有庞大的社区支持和活跃的开发者,可以获取大量的工具、插件和支持。
Git 的用途
  1. 版本控制:主要用于跟踪和管理软件开发过程中的代码变更,保证团队协作的效率和代码质量。
  2. 协作开发:多人协作开发同一项目时,Git 提供了分支管理和合并功能,支持团队并行开发不同功能或修复不同 bug。
  3. 代码备份:通过 Git,开发者可以轻松地将代码推送到远程仓库,实现代码的备份和跨设备同步。
  4. 版本发布:Git 可以用于管理项目的发布版本,记录每个版本的变更内容,方便回滚和追溯发布历史。
  5. 持续集成/持续部署 (CI/CD):许多 CI/CD 工具(如 Jenkins、GitLab CI)都与 Git 集成,用于自动化构建、测试和部署流程。

Q:什么是 CI 和 CD?

CI(持续集成)和 CD(持续部署/交付)是软件开发中常用的两个术语。CI 是一种软件开发实践,旨在通过频繁地将代码变更集成到共享存储库中,并自动运行构建和测试流程,以尽早地发现和解决集成问题。

CD 是一种软件开发实践,通过自动化流程将代码从版本控制系统部署到生产环境中。持续部署指的是每次通过 CI 测试后,自动将代码部署到生产环境;而持续交付则是将代码部署到可被测试的环境,但需要人工触发将代码部署到生产环境。

CI 关注的是代码集成和自动化测试,以保证代码质量和稳定性。CD 则更进一步,关注的是代码的自动化部署和交付,以实现快速、可靠地将功能交付给用户。

安装 Git

在 Windows 上安装 Git

在 Windows 上安装 Git 非常简单,你可以按照以下步骤操作:

  1. 下载 Git 安装程序

  2. 运行安装程序

    • 打开下载的安装程序(通常是一个 .exe 文件)。
    • 根据安装向导的提示,选择安装语言、安装路径等选项。
  3. 选择安装选项

    • 在安装选项中,通常可以选择是否将 Git 添加到系统的 PATH 环境变量中。建议选择此选项,这样可以在命令行中直接使用 Git 命令。
  4. 完成安装

    • 等待安装程序完成安装过程,可能需要一些时间。
    • 安装完成后,可以在开始菜单中找到 Git Bash(一个基于 Bash 的命令行工具),也可以在 Windows 资源管理器中右键点击文件夹空白处,选择 "Git Bash Here" 以在当前文件夹中打开 Git Bash。
  5. 验证安装

    • 打开命令提示符或 Git Bash,运行以下命令验证 Git 是否成功安装:
      git --version
      
    • 如果安装成功,将显示安装的 Git 版本信息。

安装完成后,你就可以在 Windows 上使用 Git 了,可以通过命令行或者 Git 图形界面工具进行版本控制操作。

在 Linux 上安装 Git

在 Linux 上安装 Git 也非常简单,具体步骤如下:

  1. 使用包管理器安装

    • 大多数 Linux 发行版都包含 Git 在其默认软件仓库中,因此你可以使用你的包管理器来安装它。

    • 对于 Debian/Ubuntu 等基于 Debian 的系统,可以使用以下命令安装:

    sudo apt install git
    
    • 对于 Fedora 等基于 RPM 的系统,可以使用以下命令安装:
    sudo yum install git
    

    或者

    sudo dnf install git
    
  2. 从源代码编译安装(可选):

    • 如果你想要安装 Git 的最新版本或者你的发行版没有提供预编译的包,你也可以选择从源代码编译安装 Git。

    • 首先,你需要安装一些编译 Git 所需的依赖项。这些依赖项通常包括 gccmakelibssl-dev(或类似的包)等。

    • 然后,从 Git 的官方网站下载源代码包(https://git-scm.com/download/linux)。

    • 解压源代码包并进入解压后的目录,然后执行以下命令进行编译和安装:

    make prefix=/usr/local all
    sudo make prefix=/usr/local install
    
  3. 验证安装

    • 安装完成后,打开终端并运行以下命令验证 Git 是否成功安装:
    git --version
    
    • 如果安装成功,将显示安装的 Git 版本信息。

安装完成后,你就可以在 Linux 上使用 Git 了,可以通过终端进行版本控制操作。

在 Mac 上安装 Git

在 macOS 上安装 Git 也非常简单,你可以按照以下步骤操作:

  1. 使用 Homebrew 安装(推荐):

    • 如果你已经安装了 Homebrew,那么只需在终端中运行以下命令即可安装 Git:
      brew install git
      
  2. 从官方网站下载安装程序

    • 访问 Git 官方网站的下载页面:https://git-scm.com/download/mac
    • 点击下载链接,下载最新版本的 Git for Mac 安装程序。
    • 下载完成后,双击下载的 .dmg 文件并按照提示进行安装。
  3. 通过 Xcode Command Line Tools 安装

    • 如果你已经安装了 Xcode Command Line Tools,那么 Git 应该已经包含在其中了。你可以在终端中运行以下命令来检查是否已安装 Git:
      git --version
      
    • 如果没有安装,系统会提示你安装 Xcode Command Line Tools。
  4. 验证安装

    • 安装完成后,打开终端并运行以下命令验证 Git 是否成功安装:
      git --version
      
    • 如果安装成功,将显示安装的 Git 版本信息。

安装完成后,你就可以在 macOS 上使用 Git 了,可以通过终端进行版本控制操作。

Git 基础知识

Git 基本概念

  1. 版本控制系统(Version Control System,VCS):版本控制系统是一种记录文件内容变化的系统,它可以帮助团队协作开发,并追踪文件的修改历史。
  2. 仓库(Repository):Git 仓库是存储项目代码及其历史记录的地方。可以是本地仓库(在你的计算机上)或远程仓库(托管在互联网上的服务器上)。
  3. 提交(Commit):提交是对仓库中文件修改的保存操作。每次提交都会生成一个唯一的标识符,用于标识这次修改。
  4. 分支(Branch):分支是 Git 中用来开发新功能或修复 bug 的独立线路。它允许你在不影响主线(通常是 master 分支)的情况下进行修改和实验。
  5. 合并(Merge):合并是将一个分支的修改内容合并到另一个分支的操作。通常用于将一个开发中的特性分支合并到主分支上。
  6. 远程仓库(Remote Repository):远程仓库是托管在网络服务器上的 Git 仓库,它可以被多个开发者访问和修改。常见的远程仓库托管服务有 GitHub、GitLab 和 Bitbucket。
  7. 拉取(Pull):拉取是将远程仓库的更新同步到本地仓库的操作。通常包括获取远程仓库的最新修改并合并到本地分支上。
  8. 推送(Push):推送是将本地仓库的更新上传到远程仓库的操作。通常用于分享自己的修改和提交。
  9. 冲突(Conflict):冲突是指在合并分支或拉取更新时,Git 无法自动解决的修改冲突。需要开发者手动解决冲突后才能继续操作。
  10. 标签(Tag):标签是用于标记重要的提交或版本的快照,通常用于发布版本或里程碑。

工作区

Git 中的工作区、暂存区、仓库区(本地仓库)、远程仓库之间的关系可以描述为一个多层次的版本控制系统,涵盖了文件的编辑、暂存、提交、同步等操作。

  1. 工作区(Working Directory):

    • 工作区是您当前正在进行编辑和修改文件的目录,即项目的实际文件系统。
    • 您对工作区中的文件进行的修改只存在于工作区,还未被 Git 管理或跟踪。
  2. 暂存区(Staging/Index Area):

    • 暂存区是 Git 仓库内部的一个虚拟空间,用于临时存放即将提交到版本历史的修改。
    • 当您使用 git add 命令将工作区中的文件添加到暂存区时,Git 会将文件的当前状态快照保存到暂存区中。
  3. 仓库区(本地仓库,Repository):

    • 仓库区是 Git 仓库中存储项目文件和历史记录的地方,包含了项目的所有文件及其各个版本的快照。
    • 您通过 git commit 命令将暂存区中的修改提交到本地仓库中,形成一个新的版本。
  4. 远程仓库(Remote Repository):

    • 远程仓库是位于网络上的另一个 Git 仓库,用于多人协作开发、备份和同步项目。
    • 您可以将本地仓库的修改推送(git push)到远程仓库,也可以从远程仓库拉取(git pull)最新的修改到本地仓库中。

他们的关系概括:

  • 工作区:您实际操作和编辑的项目文件所在的目录。
  • 暂存区:存放即将提交的文件快照的虚拟空间。
  • 本地仓库:存储项目完整历史记录的地方,通过提交将修改保存到本地仓库。
  • 远程仓库:多人协作、备份和同步项目的网络上的 Git 仓库,与本地仓库通过推送和拉取进行交互。

这些区域之间的交互和管理,使得 Git 成为了一个强大的版本控制系统,支持团队协作和项目管理。

Git 工作区
Git 工作区

创建一个新的 Git 仓库

要创建一个新的 Git 仓库,你可以按照以下步骤进行操作:

  1. 在本地创建新目录

    • 打开终端,并使用 mkdir 命令创建一个新的目录,作为你项目的根目录。例如:
      mkdir my_project
      
  2. 进入新目录

    • 使用 cd 命令进入你刚创建的目录。例如:
      cd my_project
      
  3. 初始化 Git 仓库

    • 使用 git init 命令在当前目录下初始化一个新的 Git 仓库。这将在当前目录下创建一个 .git 文件夹,用于存储 Git 的版本控制信息。例如:
      git init
      

添加和提交文件

添加文件
  • 将你的项目文件复制或移动到新创建的目录中。

  • 使用 git add 命令将这些文件添加到 Git 的暂存区。例如:

git add .

这将添加当前目录下的所有文件。如果你只想添加特定文件,可以使用文件名代替 .

提交更改
  • 使用 git commit 命令提交添加到暂存区的文件。例如:
git commit -m "Initial commit"

这将创建一个新的提交,并添加一条提交信息,描述本次提交的内容。

查看项目状态

要查看项目的状态,即了解哪些文件已修改、已暂存和未跟踪,可以使用 git status 命令。在你的项目目录中运行以下命令:

git status

这将显示当前 Git 仓库的状态信息,包括:

  • 修改过的文件
  • 已暂存的文件
  • 未跟踪的文件
  • 当前所在的分支
  • 等待被提交的修改等信息

通过查看状态信息,你可以了解项目当前的状态,并决定下一步的操作。

查看提交历史

要查看提交历史,可以使用 git log 命令。在你的项目目录中运行以下命令:

git log

这将显示项目的提交历史,包括每个提交的详细信息,如提交者、提交日期、提交信息等。默认情况下,git log 会按时间顺序列出所有提交,最新的提交会显示在最上面。

如果你只想查看最近几次的提交,你可以使用 git log -n 命令,其中 -n 是你想要查看的提交数量。例如,要查看最近的五次提交,你可以运行:

git log -5

你也可以使用 git log --oneline 命令来只显示每个提交的简要信息,例如提交的哈希值和提交信息,更加紧凑。

添加忽略

要忽略特定文件或文件夹,可以使用 .gitignore 文件。这个文件告诉 Git 哪些文件或文件夹不应该被跟踪或提交到版本控制中。创建 .gitignore 文件并将要忽略的文件或文件夹名称添加到其中,每行一个。

例如,如果要忽略所有 .log 文件和 node_modules 文件夹,可以这样写 .gitignore 文件:

*.log
  node_modules/

然后将 .gitignore 文件添加到项目的根目录中,并提交到 Git 仓库中:

git add .gitignore
git commit -m "Add .gitignore file to ignore log files and node_modules folder"

现在 Git 将忽略 .log 文件和 node_modules 文件夹的变更。

忽略规则

.gitignore 文件中的忽略规则遵循一定的模式匹配规则,可以使用以下语法:

  1. 通配符:

    • *:匹配零个或多个字符。
    • ?:匹配任意一个字符。
    • []:匹配括号内的任何一个字符。例如,[abc] 匹配字符 a、b 或 c。
    • !:在模式前加上感叹号表示取反,即不匹配该模式。
  2. 斜杠:

    • 斜杠 / 用于指定匹配路径的位置。在开头表示仅匹配项目根目录下的文件或文件夹,而在结尾表示匹配文件夹。
  3. 注释:

    • 使用 # 开头的行表示注释,将被 Git 忽略。
  4. 行内的空白:

    • 行内的空白会被忽略。
  5. 行内的转义字符:

    • 如果需要匹配特殊字符本身,可以使用反斜杠 \ 进行转义。

示例:

  • *.log:忽略所有以 .log 结尾的文件。
  • /logs/:忽略根目录下的 logs 文件夹。
  • !important.log:不忽略 important.log 文件,即使其他 .log 文件被忽略。
  • *.txt:忽略所有以 .txt 结尾的文件。
  • debug/*.log:只忽略 debug 文件夹下的 .log 文件。
  • build/:忽略根目录下的 build 文件夹及其内容。
  • /*.txt:只忽略根目录下的 .txt 文件,而不包括子目录中的同名文件。

通过这些规则,可以灵活地配置 .gitignore 文件,以满足项目中的特定需求,并确保不会将不必要的文件提交到版本控制中。

重新生效

.gitignore 文件提交后,再次修改时,通过如下的方式使其生效:

git rm -r --cached .                       # 清除缓存 
git add .                                  # 追踪文件
git commit -m "更新.gitignore"              # 注释提交 
git push origin master                     # 推送远程

分支管理

什么是分支

分支(Branch)是 Git 中的一个重要概念,它是指在版本控制中用来开发新功能、修复 bug 或者进行实验性工作的独立工作线路。

每个 Git 仓库都有默认的主分支,通常被称为 master 分支(尽管现在更推荐使用 main 分支作为主分支的名称)。除了主分支外,你可以创建任意数量的分支来并行开发不同的功能或解决不同的问题。

创建分支后,你可以在分支上进行修改和提交,而不会影响到主分支或其他分支。这允许团队成员在不干扰彼此的情况下独立开展工作。一旦在分支上的工作完成,你可以将其合并回主分支,以整合新的更改。

分支在 Git 中具有轻量级和低成本的特点,因此创建、切换、合并和删除分支都是非常快速和简便的。这使得分支成为了管理复杂项目和团队协作开发的强大工具。

创建和切换分支

要创建和切换分支,你可以按照以下步骤进行操作:

  1. 创建分支

    • 使用 git branch 命令创建新的分支。例如,要创建一个名为 feature 的新分支,你可以运行:
      git branch feature
      
  2. 切换到新分支

    • 使用 git checkout 命令切换到新创建的分支。例如,要切换到 feature 分支,你可以运行:
      git checkout feature
      
      或者,你也可以使用 git switch 命令来完成相同的操作:
      git switch feature
      

或者,你也可以使用 git checkout -b 命令来一次性创建并切换到新分支。例如,要创建并切换到名为 feature 的新分支,你可以运行:

git checkout -b feature

这样就成功创建了一个名为 feature 的新分支,并切换到了该分支上,现在你可以在这个分支上进行修改和提交。

合并分支

要合并分支,你可以使用 git merge 命令。以下是合并分支的基本步骤:

  1. 切换到目标分支: 首先,确保你已经切换到你想要将其他分支合并到其中的目标分支。例如,如果你想要将 feature 分支合并到 main 分支,你需要先切换到 main 分支:

    git checkout main
    

  2. 合并分支: 使用 git merge 命令将其他分支合并到目标分支中。例如,要将 feature 分支合并到 main 分支,你可以运行:

    git merge feature
    
    这将把 feature 分支中的更改合并到 main 分支中。

  3. 解决冲突(如果有必要): 如果合并过程中发生了冲突,Git 将提示你解决冲突。你需要手动解决这些冲突,并提交解决方案。在解决冲突后,运行 git merge --continue 继续合并操作。

  4. 提交合并: 一旦合并完成,你需要提交合并结果。如果没有冲突,Git 将会自动创建一个合并提交。你可以运行 git log 来查看合并提交的历史记录。

通过这些步骤,你就可以成功合并分支到目标分支中了。记得在合并分支之前,先确保你的工作目录是干净的,没有未提交的修改。

解决冲突

在合并操作中,Git 会提示你发生了冲突,并告诉你哪些文件发生了冲突。你可以运行 git status 来查看有冲突的文件列表。

解决 Git 合并冲突通常需要以下步骤:

  1. 打开冲突文件: 使用文本编辑器打开包含冲突的文件。在文件中,你会看到类似以下内容的标记:

    <<<<<<< HEAD
    // 代码来自目标分支(通常是当前所在分支,例如 main 分支)
    =======
    // 代码来自要合并的分支(例如 feature 分支)
    >>>>>>> feature
    

  2. 解决冲突

    • 手动解决冲突:根据标记,手动编辑文件以保留你希望保留的更改,并删除你不需要的部分。确保删除 Git 自动生成的冲突标记。
    • 合并工具解决冲突:你也可以使用合并工具(如 Visual Studio Code、GitKraken 等)来帮助解决冲突。
  3. 标记为已解决: 一旦你解决了冲突,保存文件并将其标记为已解决。

  4. 添加已解决的文件: 运行 git add 命令将已解决的文件标记为已解决状态。例如:

    git add <冲突文件>
    

  5. 继续合并: 运行 git merge --continue 继续合并操作。

  6. 提交合并: 如果没有其他冲突,合并操作完成后,运行 git commit 提交合并结果。如果 Git 自动生成了合并提交信息,请确保审查并编辑提交信息以确保准确反映合并的内容。

通过这些步骤,你应该能够成功解决 Git 合并冲突。记得在解决冲突之后及时提交你的更改。

远程仓库

配置 Git 信息

如果第一次使用 Git 需要配置个人的信息:

# git config  user.name 你的目标用户名
# git config  user.email 你的目标邮箱名
# 这种配置方式只有在当前仓库生效
git config user.name shuaige
git config user.email 669104343@qq.com

# 可以使用--global参数,配置全局的用户名和邮箱,这样别的git仓库就不需要重新配置了。
# 如果同时配置了局部的和全局的,那么局部的用户名和邮箱将会生效。
git config  --global user.name shuaige
git config  --global user.email 669104343@qq.com

# 查看配置信息
git config --list

生成密钥对

Git 密钥对由公钥和私钥组成,用于在您的本地系统和远程 Git 服务器之间进行安全通信和身份验证。下面是创建和使用 Git 密钥对的一般步骤:

  1. 创建 SSH Key: 运行以下命令生成 SSH 密钥对:
ssh-keygen -t rsa
  1. 找到 SSH 密钥文件夹: 在 Windows 系统中,默认情况下,SSH 密钥存储在 C:\Users\YourUsername\.ssh 文件夹中。

  2. 确认密钥文件:.ssh 文件夹中,您会找到两个文件:

    • 私钥:id_rsa
    • 公钥:id_rsa.pub
  3. 添加公钥到 GitHub: 登录到 GitHub 并导航到 Settings -> SSH and GPG keys 页面,然后点击“New SSH key”按钮。 在弹出的窗口中,将您的公钥文件 id_rsa.pub 中的内容复制并粘贴到文本框中,并为此 SSH 密钥起一个易于识别的标题。

  4. 验证 SSH 连接: 您可以通过运行以下命令来验证 SSH 连接是否正常:

ssh -T git@github.com

如果一切设置正确,您将看到一条消息表明认证成功。

  1. 使用 SSH 进行 Git 操作: 从现在开始,您可以使用 SSH URL (git@github.com:username/repository.git) 来克隆、推送或拉取 GitHub 上的仓库,而无需每次输入用户名和密码进行身份验证。

通过这些步骤,您已经成功设置了 SSH 密钥对,并且可以安全地与 GitHub 进行通信,确保了代码传输的安全性和便捷性。

添加远程仓库

要将远程仓库添加到你的本地仓库中,你可以按照以下步骤操作:

  1. 获取远程仓库地址: 首先,你需要知道远程仓库的地址。通常,这是一个 URL,类似于 https://github.com/user/repo.git

  2. 添加远程仓库: 运行以下命令将远程仓库添加到你的本地仓库中:

    git remote add <remote_name> <remote_url>
    
    其中 <remote_name> 是你为远程仓库指定的名称,通常是 origin<remote_url> 是远程仓库的地址。例如:

git remote add origin https://github.com/user/repo.git
  1. 验证远程仓库: 运行以下命令验证远程仓库是否成功添加:
    git remote -v
    

这将列出所有已添加的远程仓库及其 URL。

一旦完成这些步骤,你的本地仓库就与远程仓库建立了连接。你可以使用 git pullgit push 等命令来与远程仓库进行交互。

从远程仓库拉取更新

要从远程仓库拉取更新,你可以使用 git pull 命令。以下是具体步骤:

  1. 确保当前分支:确保你在要更新的分支上。你可以使用 git branch 命令来查看当前所在的分支以及可用的分支列表。

  2. 拉取更新:运行以下命令来拉取远程仓库的更新:

    git pull origin <branch>
    
    其中 <branch> 是你要更新的远程分支的名称。如果你当前在本地的 main 分支,并且想要拉取远程仓库的 main 分支更新,可以运行以下命令:
    git pull origin main
    

  3. 解决冲突(如果有):如果拉取操作引发了冲突,你需要按照之前提到的步骤来解决冲突。

  4. 提交解决方案(如果有冲突):一旦解决了冲突,将更改提交到本地仓库。

  5. 推送更改(可选):如果你在解决冲突后做了更改,并且希望将这些更改推送到远程仓库,可以使用 git push 命令。

通过这些步骤,你可以从远程仓库拉取更新到你的本地仓库中。

推送到远程仓库

要将本地更改推送到远程仓库,你可以使用 git push 命令。以下是具体步骤:

  1. 确保当前分支:确保你在要推送更改的分支上。你可以使用 git branch 命令来查看当前所在的分支以及可用的分支列表。

  2. 提交本地更改:首先,确保你已经将本地的更改提交到本地仓库。你可以使用 git add 命令添加要提交的更改,然后使用 git commit 命令提交更改。

  3. 推送更改:运行以下命令将本地更改推送到远程仓库:

    git push origin <branch>
    
    其中 <branch> 是你要推送到远程仓库的分支名称。如果你想要将当前分支的更改推送到远程仓库,可以简单地运行:
    git push origin
    

  4. 验证推送:成功推送后,你可以在远程仓库中查看更新。

通过这些步骤,你可以将本地仓库的更改推送到远程仓库中。

解决推送冲突

当你推送更改到远程仓库时,如果其他人已经向远程仓库推送了与你的更改冲突的更改,就会发生推送冲突。要解决这种冲突,你可以按照以下步骤操作:

  1. 拉取远程更新:首先,拉取远程仓库的更新到你的本地仓库。这可以通过运行以下命令来完成:

    git pull origin <branch>
    
    其中 <branch> 是你当前所在的分支名称。

  2. 解决冲突:拉取操作可能会引发冲突,Git 会在文件中标记出冲突的部分。你需要手动编辑这些文件,解决冲突。一旦解决了所有冲突,你可以将文件保存,并继续下一步。

  3. 提交解决方案:一旦你解决了冲突,将更改提交到本地仓库。你可以使用 git addgit commit 命令提交解决冲突后的更改。

  4. 推送更改:现在,你可以再次尝试推送更改到远程仓库。运行以下命令:

    git push origin <branch>
    
    确保将 <branch> 替换为你当前所在的分支名称。

  5. 验证推送:成功推送后,你可以在远程仓库中查看更新。

通过这些步骤,你应该能够成功解决并推送推送冲突。

高级 Git

标签

为 Git 仓库添加标签(Tag)通常用于标识项目的重要版本、发布或里程碑。以下是在 Git 中管理标签的基本步骤:

创建标签
  1. 列出现有标签:首先,你可以使用以下命令列出当前已有的标签:
git tag
  1. 创建轻量标签:轻量标签只是指向特定提交的指针,不包含额外的信息。使用以下命令创建轻量标签:

    git tag <tag_name>
    
    其中 <tag_name> 是你给标签取的名称。

  2. 创建附注标签:附注标签可以包含更多信息,比如标签创建者、日期和注释。使用以下命令创建附注标签:

    git tag -a <tag_name> -m "Tag message"
    
    这会创建一个包含注释信息的标签。

查看标签信息
  • 要查看特定标签的详细信息,可以运行:
    git show <tag_name>
    
推送标签到远程仓库
  1. 推送单个标签:要将单个标签推送到远程仓库,可以使用以下命令:

    git push origin <tag_name>
    
    其中 <tag_name> 是要推送的标签名称。

  2. 推送所有标签:如果想要推送所有标签到远程仓库,可以运行:

    git push origin --tags
    

删除标签
  • 要删除本地标签,可以使用以下命令:
    git tag -d <tag_name>
    
  • 要删除远程仓库上的标签,可以运行:
    git push origin --delete <tag_name>
    

通过这些命令,你可以管理 Git 仓库中的标签。

子模块

Git 子模块(Submodule)允许你将一个 Git 仓库作为另一个 Git 仓库的子目录引入。这在管理项目依赖或者包含其他项目的特定版本时非常有用。以下是如何使用 Git 子模块的基本步骤:

添加子模块
  1. 初始化子模块:在父仓库中,使用以下命令初始化子模块:

    git submodule add <repository_url> <path>
    
    其中 <repository_url> 是子模块仓库的 URL,<path> 是子模块在父仓库中的路径。

  2. 提交更改:添加子模块后,需要将更改提交到父仓库:

    git add .
    git commit -m "Add submodule <name>"
    

更新子模块
  • 当子模块仓库有更新时,你可以在父仓库中更新子模块:
    git submodule update --remote
    
克隆包含子模块的仓库
  • 当你克隆一个包含子模块的仓库时,可以使用以下命令来同时初始化和更新子模块:
    git clone --recurse-submodules <repository_url>
    
移除子模块
  1. 删除子模块:在父仓库中删除子模块的引用:

    git submodule deinit -f -- <path>
    rm -rf .git/modules/<path>
    git rm -f <path>
    
    其中 <path> 是子模块在父仓库中的路径。

  2. 提交更改:完成删除操作后,提交更改到父仓库:

    git commit -m "Remove submodule <name>"
    

通过这些步骤,你可以有效地管理 Git 仓库中的子模块,包括添加、更新和移除子模块。

重写历史

重写 Git 历史是一种修改提交历史的方法,通常用于整理提交记录、修改提交信息或者合并提交。这可以通过以下几种方式实现:

修改最近一次提交
git commit --amend

这个命令允许你修改最近一次提交的提交信息或者添加缺失的文件。它会打开一个文本编辑器,让你修改提交信息。

交互式重写历史
git rebase -i <commit>

这个命令可以让你进入交互式的重新基于某一提交进行重写历史的模式。你可以合并、修改、删除提交记录,以及重新排列提交的顺序。

过滤提交
git filter-branch

这个命令允许你使用自定义脚本过滤提交历史。它可以帮助你删除特定文件、修改提交信息等。

使用 git reset
git reset --soft HEAD~<n>

这个命令可以将最近的 n 个提交回退到工作目录,并保留这些更改的暂存状态,允许你修改提交信息后重新提交。

使用 git cherry-pick
git cherry-pick <commit>

这个命令可以将指定提交应用到当前分支,相当于重新提交这个提交,但不会修改历史记录。

在执行任何重写历史的操作之前,请确保你理解这些命令的含义以及它们可能产生的影响。重写历史可能会改变提交的哈希值,因此在与他人协作时需要小心谨慎。

Hooks

Git hooks 是在特定事件发生时自动执行的脚本,它们允许你自定义 Git 的行为。这些事件可以是提交代码、推送代码、合并分支等等。Git 提供了一些预定义的钩子,它们存储在 .git/hooks/ 目录下,你可以在那里创建自定义的脚本文件来响应这些事件。

以下是一些常见的 Git 钩子:

  1. pre-commit:在执行提交前运行,可以用于检查即将提交的更改,例如运行代码风格检查、单元测试等。

  2. prepare-commit-msg:在提交信息被编辑器编辑之前运行,可以用于修改提交信息的模板或添加自定义信息。

  3. post-commit:在提交成功后运行,可以用于通知团队成员或者执行其他后续操作。

  4. pre-push:在推送到远程仓库之前运行,可以用于运行额外的检查或测试。

  5. post-receive:在远程仓库接收到推送后运行,可以用于触发自动部署或其他服务器端操作。

  6. pre-rebase:在执行变基操作前运行,可以用于执行变基前的检查或准备工作。

  7. post-merge:在执行合并操作后运行,可以用于触发自动构建或其他后续操作。

要创建一个钩子,只需在 .git/hooks/ 目录下创建一个同名的可执行文件,并编写相应的脚本代码。例如,要创建一个 pre-commit 钩子,你可以执行以下步骤:

  1. 进入你的 Git 仓库目录。
  2. 进入 .git/hooks/ 目录。
  3. 创建一个名为 pre-commit 的可执行文件,例如:
    touch pre-commit
    
  4. 编辑 pre-commit 文件,添加你想要执行的操作,例如运行代码风格检查工具或者运行测试。
  5. 保存文件并确保它具有执行权限,例如:
    chmod +x pre-commit
    

现在,每次执行提交操作时,Git 都会自动运行 pre-commit 钩子中的脚本。

GIt 协作

开发工作流程

在 Git 中进行协作开发通常涉及多个开发者在同一个项目上共同工作。以下是一个常见的 Git 协作开发工作流程:

  1. 创建仓库

    • 项目的创建者在版本控制平台(如GitHub、GitLab、Bitbucket等)上创建一个新的仓库。
  2. 克隆仓库

    • 每位参与者将仓库克隆到本地计算机:
      git clone <仓库地址>
      
  3. 创建分支

    • 每位开发者在本地创建一个新的分支,用于开发特定的功能或修复问题:
      git checkout -b feature-branch
      
  4. 开发

    • 开发者在其分支上进行工作,修改文件,添加新功能或者修复 bug。
  5. 提交更改

    • 开发者将他们的更改提交到本地仓库:
      git add .
      git commit -m "描述提交的更改"
      
  6. 推送分支

    • 开发者将他们的分支推送到远程仓库:
      git push origin feature-branch
      
  7. 发起 Pull Request

    • 开发者在版本控制平台上发起一个 Pull Request,请求将他们的分支合并到主分支(通常是 mainmaster)。
  8. 审查代码

    • 其他团队成员(通常是项目的维护者或者其他开发者)审查 Pull Request,并提供反馈或建议。
  9. 解决反馈

    • 开发者根据审查反馈进行修改,并更新他们的分支。
  10. 合并分支

    • 维护者将通过审查的 Pull Request 合并到主分支中。
  11. 更新本地仓库

    • 所有参与者拉取最新的更改到本地仓库:
      git checkout main
      git pull origin main
      
  12. 删除分支(可选):

    • 完成工作后,开发者可以删除已合并的分支:
      git branch -d feature-branch
      

这是一个简单的协作开发工作流程示例,实际上可能因项目的复杂性或团队的特定需求而有所不同。

How does git work?
How does git work?

Pull Request 和 Code Review

Pull Request(PR)和 Code Review 是协作开发中非常重要的步骤,特别是在团队中共同开发代码时。简要解释一下它们的作用和流程:

Pull Request(PR)

  • Pull Request 是开发者在完成一个功能或修复一个 bug 后,向代码库的维护者请求将他们的更改合并到主分支的过程。
  • 开发者通过提交 Pull Request 来告知其他团队成员他们的更改,以便进行审查、讨论和合并。
  • PR 提供了一个讨论和反馈的平台,开发者和团队成员可以在其中进行讨论、提出问题、建议修改等。
  • PR 通常包含有关所做更改的描述、相关的 issue 编号、更改的范围等信息,以便其他人能够理解和审查。
  • 维护者或其他团队成员审查 PR,并提供反馈或批准合并。审查过程可能涉及代码质量、功能实现、性能影响、文档更新等方面的讨论。

Code Review

  • Code Review 是指开发者对另一位开发者提交的代码进行审查和评估的过程。
  • 通过 Code Review,团队成员可以确保代码质量、一致性和可维护性,并发现潜在的问题和错误。
  • Code Review 的目标是提高代码的质量、学习和分享最佳实践、确保团队成员之间的沟通和协作。

在一个典型的工作流程中,开发者完成工作后会提交一个 Pull Request,然后其他团队成员会对其进行 Code Review。开发者根据审查反馈进行修改,直到 PR 被批准并合并到主分支中为止。这种流程有助于确保代码质量、团队协作和项目的健康发展。

协作冲突解决

在协作开发过程中,冲突是不可避免的,特别是当多个开发者同时修改同一文件或同一行代码时。解决冲突的过程需要一定的沟通和合作。以下是解决协作冲突的一般步骤:

  1. 意识到冲突

    • 当尝试合并分支或拉取最新更改时,Git 会提示存在冲突。
  2. 了解冲突

    • 开发者需要仔细查看冲突的文件,了解冲突发生的原因和不同版本之间的差异。
  3. 解决冲突

    • 开发者在本地编辑冲突文件,手动解决冲突。通常,冲突部分会被标记,开发者需要决定保留哪些更改、丢弃哪些更改或者进行修改以整合两个版本的内容。
    • 解决冲突后,开发者需要将文件标记为已解决冲突:
      git add <冲突文件>
      
  4. 提交解决方案

    • 提交解决冲突后的文件:
      git commit -m "解决冲突:描述解决方案"
      
  5. 继续合并或拉取

    • 完成冲突解决后,开发者可以继续合并分支或拉取最新更改。
  6. 通知团队

    • 如果在合作开发中解决了冲突,开发者应该及时通知团队,以确保所有人都在同一页面上。

解决冲突的过程可能需要一些时间和技巧,尤其是在涉及复杂的代码更改时。在解决冲突时,保持沟通、耐心和合作是非常重要的。

Python 语言进阶 —— 数据类dataclass

介绍

Python 3.7 版本开始引入 dataclass 装饰器类。dataclass 是一个简化类定义的工具,特别适用于那些主要用于存储数据的类。 通过使用 dataclass,可以自动生成一些常用的方法,比如 __init____repr____eq__ 等,从而减少样板代码。

以学生类为例,在不使用 dataclass 类时,通常会这样定义:

class Student:
    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

    def __str__(self):
        return f'Name = {self.name} and age = {self.age}'


if __name__ == "__main__":
    stu = Student('Zhang San', 23)
    print(stu)

执行上述代码将会输出:

<__main__.Student object at 0x0000034AC19FACA0>

直接输出的是类所在的地址信息,很多时候这并不是我们想要的,我们想要的是当调用 print 方法时,可以自动输出每个属性的值。要是按照传统定义类的方式,此时 需要在类中添加 __str__ 方法。

class Student:
    ...

    def __str__(self):
        return f'Name = {self.name} and age = {self.age}'

这样当执行 print 方法时,才会显示类中属性的信息。

Name = Zhang San and age = 23

dataclass 就是为了解决上述定义类的繁琐过程。同样地使用 dataclass 的方式定义一个 Student 类:

@dataclass
class Student:
    name: str
    age: int


if __name__ == "__main__":
    stu = Student('Zhang San', 23)
    print(stu)

再次运行,可以得到相同的效果:

Name = Zhang San and age = 23

使用了 @dataclass 修饰的类,不需要编写 __init__,也不需要 __str__,通过 print 方法就可以打印对象的内容。

基础使用

默认值

在 Python 的 dataclass 中,可以通过多种方式设置默认值。以下是几种常见的方法:

使用简单的默认值

直接在类定义中指定默认值。

from dataclasses import dataclass


@dataclass
class Example:
    name: str = "Unnamed"  # 默认值为 "Unnamed"
    age: int = 0  # 默认值为 0
使用 field 函数

如果需要更复杂的默认值或其他参数(如 init=False),可以使用 field()

from dataclasses import dataclass, field


@dataclass
class Example:
    name: str = "Unnamed"
    values: list[int] = field(default_factory=list)  # 使用 default_factory 创建一个空列表
使用 default_factory

当默认值是可变类型(如列表、字典等)时,使用 default_factory 可以避免多个实例共享同一对象。

from dataclasses import dataclass, field
from typing import List


@dataclass
class Example:
    numbers: List[int] = field(default_factory=list)  # 每个实例都有一个独立的空列表
使用自定义函数

你可以定义一个函数来生成默认值,并将其传递给 default_factory

def create_default_dict() -> dict:
    return {"key": "value"}


@dataclass
class Example:
    config: dict = field(default_factory=create_default_dict)  # 使用函数生成默认字典
使用 lambda 表达式

对于简单的默认值,可以使用 lambda 表达式。

@dataclass
class Example:
    items: List[int] = field(default_factory=lambda: [1, 2, 3])  # 默认值为 [1, 2, 3]
结合其他类的实例

可以将其他类的实例作为默认值。

class InnerClass:
    def __init__(self):
        self.value = 42


@dataclass
class Example:
    inner: InnerClass = field(default_factory=InnerClass)  # 默认创建一个 InnerClass 的实例

隐藏信息

在 Python 的 dataclass 中,如果希望某些字段在输出时被隐藏或不被包含在自动生成的方法中(例如 __repr__ ),可以使用 field() 函数结合参数 repr=False。这使得在打印对象或调用 repr() 时,这些字段不会显示。

@dataclass
class UserProfile:
    username: str
    email: str
    password: str = field(repr=False)  # 隐藏密码
    api_key: str = field(repr=False)  # 隐藏 API 密钥


# 示例
user = UserProfile(username="user123", email="user@example.com", password="securepass", api_key="apikey123")
print(user)  # 输出: UserProfile(username='user123', email='user@example.com')

或者也可以自定义 __repr__ 方法:

@dataclass
class Product:
    name: str
    price: float
    secret_code: str = field(repr=False)

    def __repr__(self):
        return f"Product(name={self.name}, price={self.price})"


# 示例
product = Product(name="Gadget", price=99.99, secret_code="XYZ123")
print(product)  # 输出: Product(name=Gadget, price=99.99)

初始化

如果希望某个字段在初始化时存在,不包含在 init 方法中,可以使用 init=False

@dataclass
class UserProfile:
    username: str
    email: str = field(init=False)

只读对象

在 Python 的 dataclass 中,使用 frozen=True 可以创建不可变(immutable)的数据类。这意味着一旦实例被创建,就不能修改其字段。frozen 数据类通常用于需要保护数据不被修改的场景,比如在多线程环境中或者作为配置对象。

from dataclasses import dataclass


@dataclass(frozen=True)
class Point:
    x: int
    y: int


# 创建一个点实例
p1 = Point(10, 20)

print(p1)  # 输出: Point(x=10, y=20)

# 尝试修改字段会导致错误
# p1.x = 15  # 这一行会引发错误: FrozenInstanceError

Warning

使用了 frozen=True 属性后:

  1. 一旦实例化,所有字段都不能被修改。
  2. 由于是不可变的,frozen 数据类可以用作字典的键或放入集合中。

转换成元组和字典

在 Python 中,可以轻松地将 dataclass 实例转换为元组和字典。以下是如何实现这些转换的示例:

转换为元组

可以使用 dataclass 的内置方法 astuple,或者利用 tuple() 函数结合 __dict__ 属性来实现。

from dataclasses import dataclass, asdict, astuple


@dataclass
class Point:
    x: int
    y: int


# 创建一个点实例
p = Point(10, 20)

# 转换为元组
point_tuple = astuple(p)
print(point_tuple)  # 输出: (10, 20)
转换为字典

可以使用 asdict 方法,将 dataclass 实例转换为字典。

from dataclasses import dataclass, asdict


@dataclass
class Point:
    x: int
    y: int


# 创建一个点实例
p = Point(10, 20)

# 转换为字典
point_dict = asdict(p)
print(point_dict)  # 输出: {'x': 10, 'y': 20}

Python 语言进阶 —— 装饰器

介绍

Python 中的装饰器是一种用于修改或增强函数(或方法)功能的设计模式。装饰器本质上是一个返回函数的高阶函数,可以在不修改原有函数代码的情况下,为其添加额外的功能。

装饰器本质上是一个函数,它接收一个函数作为参数并返回一个新的函数。这个新的函数就是对原有函数的一种包装或增强。这样可以使得在不改变原函数代码的前提下,给 原函数额外增加一些功能。

定义步骤

定义一个装饰器的步骤可以分成如下几步:

  1. 编写装饰器函数。首先,创建一个装饰器函数,该函数将另一个函数作为参数接收。
  2. 实现包装逻辑。在装饰器函数内部,定义一个包装函数(wrapper)。这个包装函数负责调用传入的原函数,并能够在调用前后插入额外的逻辑。
  3. 返回包装函数。装饰器函数应返回这个包装函数,以便替代原始函数的行为。
  4. 应用装饰器。在需要装饰的函数声明前,使用 @ 符号并指定装饰器的名称。这样,Python 解释器会自动将目标函数传递给装饰器,并用返回的包装函数替换原函数
# 1. 编写装饰器函数
def decorator_function(original_function):
    # 2. 实现包装逻辑
    def wrapper_function(*args, **kwargs):
        # 在这里可以添加功能
        print("Wrapper executed before {}".format(original_function.__name__))

        # 调用原函数
        result = original_function(*args, **kwargs)

        # 在这里可以添加功能
        print("Wrapper executed after {}".format(original_function.__name__))
        return result

    # 3. 返回包装函数
    return wrapper_function


# 4. 应用装饰器
@decorator_function
def say_hello(name):
    print("Hello, {}".format(name))


# 调用被装饰的函数
say_hello("Alice")

输出:

Wrapper executed before say_hello
Hello, Alice
Wrapper executed after say_hello

带参数的装饰器

当被修饰的函数需要参数时,装饰器中的包装函数可以通过*args**kwargs 接收这些参数。

def decorator(func):
    def wrapper(*args, **kwargs):
        print("Function is called with arguments:", args, kwargs)
        result = func(*args, **kwargs)
        return result

    return wrapper


@decorator
def my_function(x, y):
    return x + y


print(my_function(3, 4))

传递参数给装饰器

如果需要给装饰器本身传递参数,可以使用一个外层函数来封装装饰器。

def repeat(num_times):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for _ in range(num_times):
                result = func(*args, **kwargs)
            return result

        return wrapper

    return decorator


@repeat(3)
def say_hello():
    print("Hello!")


say_hello()  # 输出三次Hello!

保留原信息

在使用装饰器时,原函数的元信息(如函数名、文档字符串等)会被包装函数所替代。为了保留这些信息,可以使用 functools.wraps 装饰器。

from functools import wraps


def my_decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print("Something is happening before the function is called.")
        result = func(*args, **kwargs)
        print("Something is happening after the function is called.")
        return result

    return wrapper


@my_decorator
def example():
    """This is an example function."""
    print("Hello from a function.")


print(example.__doc__)  # 输出: This is an example function.

Python 开源框架 —— Flask

介绍

Flask 是一个轻量级的 Python Web 框架,被广泛应用于构建 Web 应用程序和 RESTful API。它简洁而灵活,具有易学易用的特点,适合于快速开发原型和构建小型到中型规模的 Web 项目。 Flask 的主要特点包括:

  1. 简单易用: Flask 的设计简洁,学习曲线较低,开发者可以快速上手并迅速构建出功能完善的 Web 应用。
  2. 轻量级: Flask 框架本身功能精简,没有过多的依赖,因此性能较高,适合于开发小型应用或微服务。
  3. 灵活性: Flask 提供了丰富的扩展机制和组件,开发者可以根据项目需求灵活选择并集成需要的功能,如数据库集成、认证授权、表单处理等。
  4. 可扩展性: Flask 支持通过 Flask 扩展实现功能的扩展,这些扩展提供了丰富的功能,如用户认证、数据库集成、表单验证等,使得开发者能够快速实现各种需求。
  5. 模板引擎: Flask 默认使用 Jinja2 模板引擎,支持模板继承、控制结构、过滤器等功能,方便开发者构建动态的 HTML 页面。
  6. RESTful 支持: Flask 对构建 RESTful API 提供了良好的支持,开发者可以轻松地设计和实现符合 RESTful 风格的 API 接口。

Flask 是一个灵活、简单且功能强大的 Web 框架,适用于各种规模的 Web 开发项目,无论是个人项目、企业应用还是微服务架构都能发挥出良好的效果。

安装 Flask

安装 Flask 的方法通常有两种:使用 pip 安装和使用虚拟环境。

pip 安装

在命令行中执行以下命令安装 Flask:

pip install Flask

这将会从 Python Package Index (PyPI) 中下载并安装 Flask 框架及其依赖。

虚拟环境安装

使用虚拟环境可以避免不同项目之间的依赖冲突,并使项目的环境更加清洁和独立。

Step 1:首先,确保你已经安装了 virtualenv 工具。如果没有安装,可以通过以下命令安装:

pip install virtualenv

Step 2:创建一个新的虚拟环境,例如在项目目录下执行:

virtualenv venv

Step 3:激活虚拟环境(Windows 下的命令):

venv\Scripts\activate

或者在 Unix 或 macOS 系统中使用:

source venv/bin/activate

Step 4:激活后,你的命令行提示符会变成 (venv),表示当前处于虚拟环境中。

Step 5:然后使用 pip 安装 Flask:

pip install Flask

Step 6:这样就在虚拟环境中安装了 Flask,你可以在该环境中进行开发和测试。

创建 Flask 应用

Hello Flask!

创建一个 hello.py 文件,编写如下的内容:

from flask import Flask

app = Flask(__name__)


@app.route("/")
def hello_world():
    return "<p>Hello, World!</p>"

这段代码做了什么?

  • 首先,我们导入了 Flask 类。这个类的一个实例将成为我们的 WSGI 应用程序。

  • 接下来,我们创建了这个类的一个实例。第一个参数是应用程序的模块或包的名称。__name__ 是一个方便的快捷方式,对于大多数情况来说都是合适的。这是必需的,这样 Flask 才知道在哪里查找诸如模板和静态文件等资源。

  • 然后,我们使用 route() 装饰器告诉 Flask 应该触发我们的函数的 URL 是什么。

  • 该函数返回我们想在用户的浏览器中显示的消息。默认的内容类型是 HTML,所以字符串中的 HTML 将由浏览器渲染。

将其保存为 hello.py 或类似的名称。确保不要将你的应用程序命名为 flask.py,因为这将与 Flask 本身冲突。要运行该应用程序,使用 flask 命令或 python -m flask。你需要使用 --app 选项告诉 Flask 你的应用程序在哪里。

flask --app hello run
 * Serving Flask app 'hello'
 * Running on http://127.0.0.1:5000 (Press CTRL+C to quit)

运行完成后,通过浏览器访问 http://127.0.0.1:5000 就可以看到输出的 Hello, World! 了。

Pycharm 安装

在 Pycharm 的 Professional 版本中,可以很方便的创建 Flask 应用:

Pycharm 创建 Flask 应用

基本概念

Flask 的基本概念如下:

  • 请求-响应模型: 是 Web 应用程序的基本工作方式。客户端(通常是浏览器)向服务器发送 HTTP 请求,服务器接收请求并处理,然后返回 HTTP 响应给客户端。
  • 路由: 在 Flask 中,路由指的是将 URL 地址和特定的处理函数(视图函数)相绑定的过程。通过路由,Flask 知道在接收到特定 URL 请求时应该调用哪个视图函数来处理。
  • 视图函数: 是 Flask 应用程序中用于处理请求并生成响应的函数。当路由匹配到一个 URL 时,Flask 调用相应的视图函数来处理该请求,然后视图函数返回一个响应给客户端。
  • 模板: Flask 使用 Jinja2 模板引擎来渲染动态内容。模板是带有特殊标记的 HTML 文件,允许在其中插入动态数据或逻辑。在视图函数中,可以使用模板引擎将数据传递给模板,并生成最终的 HTML 页面。
  • 静态文件: 通常包括 CSS、JavaScript、图片等文件,这些文件不需要动态生成,而是直接从服务器端发送给客户端。在 Flask 中,可以通过 url_for('static', filename='filename') 来引用静态文件。

路由和视图函数

路由

路由(Route)是指将 URL 请求映射到相应的处理程序或视图函数的过程。在 Web 应用程序中,当用户访问特定的 URL 时,服务器需要知道如何处理该请求,并返回相应的内容。路由就是帮助服务器确定如何处理这些 URL 请求的机制。

普通路由

在大多数 Web 框架中,路由通常由开发者定义,以确定哪个 URL 触发哪个处理程序或视图函数。这种定义通常通过装饰器或配置文件来实现。在 Flask 中,使用 @app.route() 装饰器可以将 URL 与视图函数关联起来。例如,@app.route('/hello') 将告诉 Flask 当用户访问 /hello 路径时应该调用哪个视图函数来处理请求。

@app.route('/')
def index():
    return 'Index Page'


@app.route('/hello')
def hello():
    return 'Hello, World'
变量路由

变量路由可以把路由局部作为参数传递到方法中。

from markupsafe import escape


@app.route('/user/<username>')
def show_user_profile(username):
    ## show the user profile for that user
    return f'User {escape(username)}'


@app.route('/post/<int:post_id>')
def show_post(post_id):
    ## show the post with the given id, the id is an integer
    return f'Post {post_id}'


@app.route('/path/<path:subpath>')
def show_subpath(subpath):
    ## show the subpath after /path/
    return f'Subpath {escape(subpath)}'

Flask 支持如下的路由数据类型:

string (default) accepts any text without a slash
int accepts positive integers
float accepts positive floating point values
path like string but also accepts slashes
uuid accepts UUID strings
注意路由后缀

Flask 会自动处理路由 URL 末尾带斜线或不带斜线的情况。当没有主动处理末尾带斜线的情况时,Flask 的跳转可能存在问题。

from flask import Flask

app = Flask(__name__)


@app.route("/hello")
def hello_world():
    return "<p>Hello, World!</p>"

当浏览器访问 127.0.0.1/hello 时,可以得到正确的响应。但是访问 127.0.0.1/hello/ 时,则无法得到响应。

不同的理由得到的响应

想要解决这个问题,只要再多配置一个路由即可:

from flask import Flask

app = Flask(__name__)


@app.route("/hello")
@app.route("/hello/")
def hello_world():
    return "<p>Hello, World!</p>"

虽然访问 /hello/hello/ 都可以得到正确的响应,但是这样做对搜索引擎而言会导致同一个页面被搜索两次,对于 SEO 是不利的。实际上,Flask 本身就支持对 URL 路径末尾带斜线和不带斜线都可以访问到同一页面,只需要使用 /hello/ 一个路由修饰器。

from flask import Flask

app = Flask(__name__)


@app.route("/hello/")
def hello_world():
    return "<p>Hello, World!</p>"

从 Chrome 浏览器可以看到 Flask 通过 308 状态码重定向的方式实现了该功能。如果访问的是 /hello 则会自动返回 308 状态码,重定向到 /hello/ 路径。这种方式既可以实现同时支持 URL 路径末尾带斜线又支持不带斜线的两种路径,同时还不会影响搜索引擎的 SEO。

404 页面

在 Flask 中,你可以自定义 404 错误页面,以提供统一的用户体验。要实现这一点,你可以使用 errorhandler 装饰器来处理 404 错误。

下面是一个示例,展示了如何在 Flask 应用中定义一个自定义的 404 错误页面:

from flask import Flask, render_template

app = Flask(__name__)


## 自定义404错误页面
@app.errorhandler(404)
def page_not_found(error):
    return render_template('404.html'), 404


if __name__ == '__main__':
    app.run(debug=True)

在这个示例中,errorhandler 装饰器用于指定当出现 404 错误时要调用的函数。在这里,我们定义了一个名为 page_not_found 的函数来处理 404 错误。在这个函数中,我们使用 render_template 函数来渲染一个名为 404.html 的模板文件,并将 HTTP 状态码设置为 404。

接下来,你需要在你的 Flask 应用中创建一个名为 404.html 的模板文件,用来显示自定义的 404 错误页面。你可以在这个模板文件中添加任何你想要展示的内容,比如一些友好的错误提示或者导航链接。

这样,当用户访问了一个不存在的页面时,Flask 将会返回自定义的 404 错误页面,提供更好的用户体验。

获取视图参数

视图函数可以接受参数来获取用户请求中的数据。在 Flask 中常用的获取视图参数的方式有 URL 参数、查询参数、表单数据和 JSON 数据。

URL 参数

通过路由 URL 获取定义的参数,例如 /user/<username> 定义了一个 username 变量的 URL 参数

@app.route('/user/<username>')
def show_user_profile(username):
    ## show the user profile for that user
    return f'User {escape(username)}'
查询参数

查询参数通常出现在 URL 中,例如 ?key1=value1&key2=value2。可以使用 request.args 来获取查询参数。

@app.route('/query-example')
def query_example():
    ## 获取名为 'key' 的查询参数的值
    key_value = request.args.get('key')
    return 'Query Parameter Value: {}'.format(key_value)
表达数据

当客户端通过 POST 请求提交表单时,你可以使用 request.form 来获取表单数据。

@app.route('/form-example', methods=['POST'])
def form_example():
    ## 获取名为 'username' 的表单字段的值
    username = request.form.get('username')
    return 'Form Field Value: {}'.format(username)
JSON 数据

当客户端通过 POST 请求提交 JSON 数据时,你可以使用 request.json 来获取 JSON 数据。

@app.route('/json-example', methods=['POST'])
def json_example():
    ## 获取名为 'key' 的 JSON 字段的值
    key_value = request.json.get('key')
    return 'JSON Field Value: {}'.format(key_value)

模板语言

在 Flask 中使用模板可以方便地将动态数据呈现给用户,并且可以将 HTML 页面和 Python 代码分离,提高代码的可维护性。Flask 使用 Jinja2 作为其默认的模板引擎。

Hello World

  1. 创建模板文件: 首先,你需要创建一个模板文件,通常存放在应用程序目录下的 templates 文件夹中。例如,创建一个名为 index.html 的模板文件,内容如下:

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Flask Template Example</title>
    </head>
    <body>
        <h1>Hello, {{ name }}!</h1>
    </body>
    </html>
    

在这个模板中,我们使用了 Jinja2 的语法 {{ name }} 来渲染动态内容。当模板被渲染时,{{ name }} 将被替换为相应的值。

  1. 更新 Flask 应用程序: 现在,让我们更新 Flask 应用程序,以使用我们刚刚创建的模板文件。

    from flask import Flask, render_template
    
    app = Flask(__name__)
    
    @app.route('/')
    def index():
        ## 在渲染模板时,传递一个名为 'name' 的参数
        return render_template('index.html', name='World')
    
    if __name__ == '__main__':
        app.run(debug=True)
    

在这个示例中,我们使用 render_template 函数来渲染模板文件 index.html。我们还将一个名为 name 的参数传递给模板,其值为 'World'。在模板中,{{ name }} 将被替换为 'World'

  1. 运行应用程序: 运行 Flask 应用程序,访问 http://127.0.0.1:5000/,你将看到页面上显示着 "Hello, World!"。

Jinja2

Jinja2 是 Flask 中默认使用的模板引擎,也可以用于其他 Python Web 框架以及独立的 Python 应用程序中。它提供了一种灵活且功能丰富的模板语言,用于在 HTML 文件中嵌入动态内容。

变量替换

使用双大括号 {{ 变量名 }} 来在模板中嵌入变量,并在渲染时替换为相应的值。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Welcome</title>
</head>
<body>
<h1>Hello, {{ name }}!</h1>
</body>
</html>
控制结构

使用 {% ... %} 来包含控制结构,如条件语句和循环语句。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>User List</title>
</head>
<body>
<ul>
    {% for user in users %}
    <li>{{ user }}</li>
    {% endfor %}
</ul>
</body>
</html>
条件语句

条件语句用于根据特定条件在模板中渲染不同的内容。

{% if condition %}
    Content to display if condition is true.
{% elif another_condition %}
    Content to display if another_condition is true.
{% else %}
    Content to display if none of the conditions are true.
{% endif %}
循环结构

循环结构用于在模板中遍历集合或迭代器,并为每个元素渲染特定的内容。

{% for item in collection %}
    Content to display for each item in the collection.
{% endfor %}

条件语句和循环结构可以相互嵌套,以实现更复杂的逻辑。

{% for user in users %}
    {% if user.is_active %}
        <p>{{ user.username }} is active.</p>
    {% else %}
        <p>{{ user.username }} is inactive.</p>
    {% endif %}
{% endfor %}

想要获取到循环结构的索引可以使用 loop.index (索引从 1 开始)和 loop.index0 (索引从 0 开始)的方式获取。

<ul>
    {% for fruit in fruits %}
        <li>{{ loop.index }}. {{ fruit }}</li>
    {% endfor %}
</ul>
<ul>
    {% for fruit in fruits %}
        <li>{{ loop.index0 }}. {{ fruit }}</li>
    {% endfor %}
</ul>
过滤器

使用管道符 | 来应用过滤器对变量进行处理。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Formatted Date</title>
</head>
<body>
<p>Today's date is: {{ today | date('Y-m-d') }}</p>
</body>
</html>
注释

使用 {## ... #} 来添加注释,这些注释在渲染时会被忽略。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Welcome</title>
</head>
<body>
<h1>Hello, {{ name }}!</h1>
{## This is a comment #}
</body>
</html>

Jinja3

从2021年5月开始,Jinja 已经发展到 Jinja3 版本。Jinja3 在功能和性能上都有所改进,并且保留了与 Jinja2 兼容的语法和用法。以下是一些从 Jinja2 到 Jinja3 的改进和变化:

  1. 性能改进: Jinja3 在模板渲染速度方面进行了优化,提高了性能和效率,特别是在处理大型模板或频繁渲染的情况下。
  2. 新的功能和语法: Jinja3 引入了一些新的功能和改进,包括更强大的过滤器、更灵活的控制结构和更丰富的标准库,使得模板更加灵活和易于编写。
  3. Unicode 支持改进: Jinja3 在处理 Unicode 字符串方面更加健壮和灵活,可以更好地处理多语言和国际化的需求。
  4. 文档改进: Jinja3 提供了更新和改进的文档,使得用户更容易理解和使用 Jinja 模板引擎。
  5. 维护和支持: Jinja3 是 Jinja2 的持续改进版本,继续得到维护和支持,确保其在功能、性能和安全性方面都保持最新。

虽然 Jinja3 带来了一些改进和新功能,但它与 Jinja2 保持了很高的兼容性,因此从 Jinja2 迁移到 Jinja3 通常是相对平滑的。

Flask-WTF

Flask-WTF 是一个用于处理 Web 表单的 Flask 扩展,它基于 WTForms 库构建而成。它简化了在 Flask 应用中创建和验证表单的过程,提供了一种简单而强大的方式来处理用户提交的数据。主要功能包括:

  1. 表单类的定义: 使用 Flask-WTF,你可以通过创建表单类来定义表单及其字段。这些字段可以是文本输入、密码输入、复选框等等,你还可以定义验证规则来确保用户输入的有效性。
  2. CSRF 保护: Flask-WTF 自动为表单添加 CSRF 保护,以防止跨站请求伪造攻击。这意味着在提交表单时,会自动验证表单中的 CSRF 令牌。
  3. 表单验证: Flask-WTF 提供了对表单数据进行验证的功能。它可以验证字段是否为空、是否符合特定格式、是否满足自定义验证函数等。
  4. 表单渲染: Flask-WTF 提供了简单的方式来将表单对象渲染成 HTML 表单,以便在模板中显示给用户填写。
  5. 文件上传: Flask-WTF 支持文件上传功能,你可以通过在表单中添加 FileField 字段来实现文件上传。
  6. 国际化支持: Flask-WTF 支持国际化,你可以轻松地将表单字段的标签和错误消息翻译成不同的语言。

安装 Flask-WTF

使用如下的命令安装 Flask-WTF 表单库:

pip install Flask-WTF

简单使用

以下是一个简单的示例,演示了如何在 Flask 应用中使用 Flask-WTF 处理表单:

from flask import Flask, render_template, request
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your_secret_key'


class MyForm(FlaskForm):
    name = StringField('Name', validators=[DataRequired()])
    submit = SubmitField('Submit')


@app.route('/', methods=['GET', 'POST'])
def index():
    form = MyForm()
    if form.validate_on_submit():
        ## 处理表单提交逻辑
        name = form.name.data
        return f'Hello, {name}!'
    return render_template('index.html', form=form)


if __name__ == '__main__':
    app.run(debug=True)

在上面的示例中,我们定义了一个简单的表单类 MyForm,它包含一个文本输入字段和一个提交按钮。在视图函数 index 中,我们创建了表单对象并将其传递给模板 index.html 进行渲染。

然后,我们创建了一个模板 index.html,用于渲染表单:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Flask-WTF Example</title>
</head>
<body>
<h1>My Form</h1>
<form method="POST" action="/">
    {{ form.hidden_tag() }}
    {{ form.name.label }} <br>
    {{ form.name }} <br>
    {{ form.name.errors }} <br>
    {{ form.submit }}
</form>
</body>
</html>

这样,用户就可以在浏览器中访问该页面,并填写表单。提交表单后,Flask-WTF 会自动验证表单数据并执行相应的操作。

表单样式

Flask-WTF 并不直接处理表单的样式,但你可以使用自定义的 CSS 样式来美化你的表单。通常,你可以使用框架如 Bootstrap 或 Bulma 来轻松地为 Flask-WTF 表单添加样式。

下面是一些示例代码,演示了如何使用 Bootstrap 和 Flask-WTF 结合来创建样式美观的表单:

首先,确保你已经安装了 Bootstrap,并在你的 Flask 应用中引入它:

<!-- 在你的 base.html 或模板文件中引入 Bootstrap CSS 文件 -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet"
      integrity="sha384-B4gt1jrGC7Jh4AgTPSdUtOBvfO8shC1tg3S9k7Xs5SQx0qE/hzjNsk30lBV6cKD1" crossorigin="anonymous">

然后,你可以在你的 Flask-WTF 表单中使用 Bootstrap 类来为表单元素添加样式。例如:

from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired


class MyForm(FlaskForm):
    name = StringField('Name', validators=[DataRequired()], render_kw={"class": "form-control"})
    submit = SubmitField('Submit', render_kw={"class": "btn btn-primary"})

在上面的示例中,我们使用了 render_kw 参数来向字段和提交按钮添加了 Bootstrap 的类。这样,表单元素就会自动应用 Bootstrap 样式。

最后,在你的模板中,你可以简单地渲染表单元素,它们将会自动应用 Bootstrap 样式:

<form method="POST" action="/">
    {{ form.hidden_tag() }}
    <div class="mb-3">
        {{ form.name.label(class="form-label") }}
        {{ form.name(class="form-control") }}
        {% for error in form.name.errors %}
        <div class="invalid-feedback">{{ error }}</div>
        {% endfor %}
    </div>
    {{ form.submit }}
</form>

在这个示例中,我们使用了 Bootstrap 的类来添加样式,并使用了简单的条件语句来显示验证错误信息。这样,你就可以轻松地为 Flask-WTF 表单添加样式,使其看起来更加美观和专业。

表单字段

WTforms 包中包含各种表单字段的定义,WTForms 支持 HTML 的字段有:

字段 说明
BooleanField 复选框,值为True或False,相当于HTML的
DateField 文本字段, 值为datetime.date格式
DateTimeField 文本字段, 值为datetime.datetime格式
IntegerField 文本字段, 值为整数
DecimalField 用于显示带小数的数字的文本字段,值为decimal.Decimal
FloatField 文本字段, 值为浮点数
RadioField 一组单选框
FileField 文件上传字段
SelectField 下拉列表
SelectMultipleField 下拉列表, 可选择多个值
SubmitField 表单提交按钮,相当于HTML的
StringField 文本字段,相当于HTML的
TextAreaField 多行文本字段,相当于HTML的
HiddenField 隐藏文本字段,相当HTML的
FormFiled 把表单作为字段嵌入另一个表单
FieldList 子组指定类型的字段
PasswordField 密码文本字段,相当于HTML的

验证器

WTForms 支持的 validators 验证器有:

验证函数 说明
Email 验证是电子邮件地址
EqualTo 比较两个字段的值; 常用于要求输入两次信息进行确认的情况
IPAddress 验证IPv4网络地址
Length 验证输入字符串的长度
NumberRange 验证输入的值在数字范围内
Optional 无输入值时跳过其它验证函数
DataRequired 确保字段中有数据
Regexp 使用正则表达式验证输入值
URL 验证url
AnyOf 确保输入值在可选值列表中
NoneOf 确保输入值不在可选列表中

验证器的主要作用是进行表单数据的验证工作,例如需要验证 name 的数据不能为空,password 字段不能为空,且长度在 6 到 12 位数字之间。

from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField
from wtforms.validators import DataRequired, length


class MyForm(FlaskForm):
    name = StringField('name', validators=[DataRequired()])
    password = PasswordField('password', validators=[DataRequired(), length(min=6, max=12)])

此时,上述代码对应的 Html 模板代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<form action="/login" method="post">
    <p>{{ myform.name }}</p>
    <p>{{ myform.password }}</p>
    <p>{{ myform.submit }}</p>
</form>
</body>
</html>

当用户点击按钮时,就通过 POST 的方式请求,路由的接受如下:

from flask import Flask, render_template
from form import MyForm

app = Flask(__name__)


@app.route('/')
def user_form():
    myform = MyForm()
    if myform.validate_on_submit():
        return '提交成功'
    return render_template('form.html', myform=myform)


if __name__ == '__main__':
    app.run()

自定义验证器

在 Flask-WTF 中,你可以使用自定义验证器来验证表单字段中的数据。自定义验证器允许你定义自己的验证函数,并将其应用于表单字段,以确保用户输入的数据符合你的特定要求。

以下是一个示例,演示了如何在 Flask-WTF 中创建自定义验证器:

from flask import Flask, render_template
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired, ValidationError

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your_secret_key'


## 自定义验证器函数
def validate_name(form, field):
    if field.data.lower() == 'admin':
        raise ValidationError('Username cannot be "admin".')


## 定义表单类
class MyForm(FlaskForm):
    name = StringField('Username', validators=[DataRequired(), validate_name])
    submit = SubmitField('Submit')


## 定义视图函数
@app.route('/', methods=['GET', 'POST'])
def index():
    form = MyForm()
    if form.validate_on_submit():
        return f'Hello, {form.name.data}!'
    return render_template('index.html', form=form)


if __name__ == '__main__':
    app.run(debug=True)

在上面的示例中,我们定义了一个名为 validate_name 的自定义验证器函数。该函数接受表单对象和字段作为参数,并在字段数据不符合要求时引发 ValidationError 异常。然后,我们在 MyForm 类中将自定义验证器函数应用于 name 字段。当用户输入的用户名为 "admin" 时,将会触发验证错误。最后,在视图函数中,我们检查表单是否通过验证,如果通过验证,则返回一个包含用户输入的欢迎消息。

在模板中,你可以像往常一样渲染表单,Flask-WTF 将自动处理验证器并显示相应的错误消息。通过使用自定义验证器,你可以根据需要对用户输入进行更灵活和个性化的验证。

CSRF

CSRF(Cross-Site Request Forgery)是一种常见的网络安全威胁,攻击者利用用户已经认证的身份,在用户不知情的情况下执行未经授权的操作。为了防止 CSRF 攻击,Flask-WTF 提供了 CSRF 保护机制。

在 Flask-WTF 中启用 CSRF 保护非常简单,只需在应用配置中设置一个密钥。这个密钥会用于生成和验证 CSRF 令牌,以确保表单提交是来自你的应用而不是恶意网站的请求。

以下是一个示例,演示了如何在 Flask 应用中启用 CSRF 保护:

from flask import Flask, render_template
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired

app = Flask(__name__)

## 设置密钥用于 CSRF 保护
app.config['SECRET_KEY'] = 'your_secret_key'
## 启动CSRF保护
csrf = CSRFProtect(app)


## 定义表单类
class MyForm(FlaskForm):
    name = StringField('Name', validators=[DataRequired()])
    submit = SubmitField('Submit')


## 定义视图函数
@app.route('/', methods=['GET', 'POST'])
def index():
    form = MyForm()
    if form.validate_on_submit():
        ## 处理表单提交
        return f'Hello, {form.name.data}!'
    return render_template('index.html', form=form)


if __name__ == '__main__':
    app.run(debug=True)

在上面的示例中,我们设置了 app.config['SECRET_KEY'] 为一个随机字符串,这个字符串会被用于生成和验证 CSRF 令牌。确保这个密钥足够安全,不要泄露给任何人。Flask-WTF 会自动在表单中生成一个隐藏字段,用于存储 CSRF 令牌。在表单提交时,Flask-WTF 会验证这个令牌,以确保请求是合法的。通过启用 CSRF 保护,你可以增强你的 Flask 应用的安全性,防止 CSRF 攻击对用户数据和应用功能造成危害。

启用 CSRF 保护后,Flask-WTF 会自动在表单中生成一个隐藏字段,用于存储 CSRF 令牌。这个令牌将在每次表单提交时被发送到服务器,并在服务器端验证,以确保请求是合法的。

在 HTML 表单中不需要手动设置隐藏值,Flask-WTF 会在渲染表单时自动添加这个隐藏字段。你只需要按照通常的方式渲染表单即可,例如:

htmlCopy Code<form method="POST" action="/">
    {{ form.hidden_tag() }}
    {{ form.name.label }} {{ form.name() }}
    {{ form.submit() }}
</form>

{{ form.hidden_tag() }} 这一行会渲染出一个隐藏字段,用于存储 CSRF 令牌。当用户提交表单时,这个隐藏字段会自动包含在请求中,Flask-WTF 会在后台验证令牌的有效性。因此,只要使用了 Flask-WTF 提供的表单渲染方法,你就无需手动添加 CSRF 令牌的隐藏字段。

数据库

Flask 可以与多种类型的数据库集成,最常见的是关系型数据库(如 SQLite、MySQL、PostgreSQL)和非关系型数据库(如 MongoDB)。这里我会简要介绍如何集成 Flask 与 SQLite 和 MySQL 数据库。

集成 SQLite 数据库

SQLite 是一种轻量级的嵌入式数据库,适合小型项目或原型开发。在 Flask 中使用 SQLite 非常简单,因为它已经内置了对 SQLite 的支持。首先,确保你已经安装了 Flask-SQLAlchemy 扩展,它提供了对 SQLAlchemy ORM 的支持。

pip install Flask-SQLAlchemy

然后,你可以像下面这样配置你的 Flask 应用:

from flask import Flask
from flask_sqlalchemy import SQLAlchemy

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///mydatabase.db'  ## 数据库文件路径
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

db = SQLAlchemy(app)


## 定义模型类
class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True, nullable=False)

    def __repr__(self):
        return '<User %r>' % self.username


if __name__ == '__main__':
    app.run(debug=True)

集成 MySQL 数据库

如果你想要使用 MySQL 数据库,你需要安装并配置 PyMySQL 或 MySQL 客户端。

pip install pymysql

然后,你可以像下面这样配置你的 Flask 应用:

from flask import Flask
from flask_sqlalchemy import SQLAlchemy

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://username:password@localhost/db_name'  ## MySQL连接URL
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

db = SQLAlchemy(app)

## 定义模型类
class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True, nullable=False)

    def __repr__(self):
        return '<User %r>' % self.username

if __name__ == '__main__':
    app.run(debug=True)

模型类

列类型和列选项

在 Flask 中,使用 SQLAlchemy 时,模型类是用来定义数据库表结构的 Python 类。每个模型类代表数据库中的一个表,类中的属性则代表表中的列。Flask-SQLAlchemy 提供了丰富的列类型和列选项,用于定义数据库表结构。下面是一些最常用的列类型和列选项:

好的,以下是常用的列类型和列选项的表格形式:

列类型(Column Types) 描述 示例用法
Integer 整数类型 id = db.Column(db.Integer, primary_key=True)
String 字符串类型,可指定最大长度 username = db.Column(db.String(80), unique=True)
Text 长文本类型 description = db.Column(db.Text)
Boolean 布尔类型 is_active = db.Column(db.Boolean, default=True)
DateTime 日期时间类型 created_at = db.Column(db.DateTime, default=datetime.utcnow)
Float 浮点数类型 price = db.Column(db.Float)
ForeignKey 外键类型,用于定义外键关联 user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
Relationship 关系型列类型,用于定义模型之间的关联关系 user = db.relationship('User', backref='posts')
列选项(Column Options) 描述 示例用法
primary_key 设置列为主键 id = db.Column(db.Integer, primary_key=True)
unique 设置列的值必须唯一 username = db.Column(db.String(80), unique=True)
nullable 设置列的值是否允许为空 username = db.Column(db.String(80), nullable=False)
default 设置列的默认值 is_active = db.Column(db.Boolean, default=True)
index 为列创建索引,提高查询效率 db.Column(db.String(80), index=True)
autoincrement 设置列自动增长 id = db.Column(db.Integer, primary_key=True, autoincrement=True)
onupdate 设置列更新时的行为 updated_at = db.Column(db.DateTime, onupdate=datetime.utcnow)
ForeignKey 设置外键关联 user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
类关系

SQLAlchemy 提供了丰富的关系选项,用于定义模型之间的关联关系。以下是一些常用的 SQLAlchemy 关系选项:

  1. backref:在关联的另一个模型中创建反向引用,使得可以通过反向引用方便地访问关联对象。

user = db.relationship('User', backref='posts')
这将在 User 模型中创建一个名为 posts 的属性,可以访问与该用户相关联的所有帖子对象。

  1. lazy:指定关系的加载方式,可以选择 selectjoinedsubquerydynamic 等方式。

user = db.relationship('User', lazy='select')
指定关系的加载方式是为了控制 SQLAlchemy 在访问关联对象时的查询行为,以优化性能和减少查询次数。下面是各种加载方式的作用:

1. **select**:默认加载方式。当访问关联对象时,将执行一个额外的 SQL 查询来加载关联对象。这种方式简单直接,但如果一次性加载了多个对象,可能会导致
   N+1 查询问题,性能较差。
2. **joined**:在查询主对象时,同时将关联对象的数据一起加载。这样可以通过一次 SQL 查询完成关联对象的加载,避免了 N+1
   查询问题,性能较好。但是如果关联对象数量庞大,可能会导致查询结果数据量过大。
3. **subquery**:类似于 `joined`,但是在查询时会将关联对象的数据先查询出来放入子查询中,然后再与主查询进行连接。这种方式可以减少重复的数据行,适用于关联对象数据量较大的情况。
4. **dynamic**:动态加载方式。不会立即加载关联对象,而是返回一个查询对象,当真正需要访问关联对象时,才会执行额外的 SQL
   查询来加载数据。这样可以延迟加载,避免不必要的查询,提高性能。

根据具体的场景和性能需求,可以选择合适的加载方式来优化查询性能。例如,如果关联对象数量较少且经常需要访问,则可以选择 joinedsubquery;如果关联对象数量较多或访问频率较低,则可以选择 dynamic 来延迟加载,避免不必要的查询。

  1. uselist:指定关系是否使用列表形式存储多个对象,默认为 True

    comments = db.relationship('Comment', backref='post', uselist=False)
    
    这将在 Post 模型中创建一个名为 comment 的属性,用于存储与该帖子关联的评论对象,但是该属性是单个对象而不是列表。

  2. cascade:指定级联操作的行为,可以选择 save-updatedeleteall 等。

posts = db.relationship('Post', backref='author', cascade='all, delete-orphan')
cascade 是 SQLAlchemy 中用于指定级联操作行为的选项。它定义了当对父对象执行某种操作时,如何处理与其相关联的子对象。以下是一些常用的 cascade 选项:

1. **save-update**:当父对象被插入到数据库中或者其属性被修改时,级联保存相关联的子对象。这意味着当父对象被保存时,相关联的子对象也会被保存。
2. **delete**:当父对象被删除时,级联删除与其相关联的子对象。这意味着当父对象被删除时,相关联的子对象也会被删除。
3. **all**:包括了 `save-update`  `delete`,即当父对象被保存或删除时,都会级联执行相应操作。
4. **delete-orphan**:当父对象中的子对象被移除关联时,级联删除这些孤立的子对象。例如,当一个帖子的作者被更改时,原作者不再与该帖子关联,那么该作者就成为了孤立的子对象,级联操作将会删除这些孤立的子对象。

使用 cascade 选项可以简化数据库操作,并确保父对象与子对象之间的一致性。但是需要谨慎使用,以避免意外的数据修改或删除。

  1. secondary:用于多对多关系中,指定中间表。

followers = db.relationship('User', secondary=followers,
                            primaryjoin=(followers.c.follower_id == id),
                            secondaryjoin=(followers.c.followed_id == id),
                            backref=db.backref('followed_by', lazy='dynamic'),
                            lazy='dynamic')
这将指定 followers 表为多对多关系中的中间表。

  1. primaryjoinsecondaryjoin:用于自定义多对多关系中的连接条件。

primaryjoin=(followers.c.follower_id == id),
secondaryjoin=(followers.c.followed_id == id)
这将指定多对多关系中的连接条件。

这些是一些常用的 SQLAlchemy 关系选项,可以根据具体的应用需求选择合适的选项来定义模型之间的关联关系。

增删改查

定义模型(Model)

首先,定义数据模型,可以使用 SQLAlchemy 或者其他 ORM 库来实现。例如,使用 SQLAlchemy 定义一个简单的模型:

from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()


class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(50), unique=True, nullable=False)
    email = db.Column(db.String(100), unique=True, nullable=False)
创建 Flask 应用
from flask import Flask

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///example.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

db.init_app(app)
创建数据库表
## 在 Flask Shell 中执行以下命令
from your_app import db

db.create_all()
实现增删改查操作
from flask import request, jsonify


## 添加用户
@app.route('/users', methods=['POST'])
def create_user():
    data = request.get_json()
    new_user = User(username=data['username'], email=data['email'])
    db.session.add(new_user)
    db.session.commit()
    return jsonify({'message': 'User created successfully'}), 201


## 获取所有用户
@app.route('/users', methods=['GET'])
def get_users():
    users = User.query.all()
    return jsonify([user.serialize() for user in users])


## 获取单个用户
@app.route('/users/<int:user_id>', methods=['GET'])
def get_user(user_id):
    user = User.query.get_or_404(user_id)
    return jsonify(user.serialize())


## 更新用户信息
@app.route('/users/<int:user_id>', methods=['PUT'])
def update_user(user_id):
    user = User.query.get_or_404(user_id)
    data = request.get_json()
    user.username = data['username']
    user.email = data['email']
    db.session.commit()
    return jsonify({'message': 'User updated successfully'})


## 删除用户
@app.route('/users/<int:user_id>', methods=['DELETE'])
def delete_user(user_id):
    user = User.query.get_or_404(user_id)
    db.session.delete(user)
    db.session.commit()
    return jsonify({'message': 'User deleted successfully'})

这些是一个简单的 Flask 应用中进行增删改查操作的基本步骤。在实际应用中,你可能还需要添加身份验证、错误处理等功能来完善你的应用。

get_or_404

get_or_404 是 Flask 中的一个便捷方法,用于从数据库中获取对象。它的作用是尝试从数据库中获取指定主键的对象,如果找到了,则返回该对象;如果未找到,则返回一个 404 错误页面,表示请求的资源不存在。

通常情况下,get_or_404 方法用于处理 HTTP 请求中的资源获取操作,当请求的资源不存在时,它会自动返回一个 404 错误页面,告诉用户所请求的资源未找到。

在上面的 Flask 示例中,你可以看到 get_or_404 的用法:

## 获取单个用户
@app.route('/users/<int:user_id>', methods=['GET'])
def get_user(user_id):
    user = User.query.get_or_404(user_id)
    return jsonify(user.serialize())

在这个示例中,当用户访问 /users/<user_id> 路由时,Flask 将会尝试从数据库中根据提供的 user_id 获取用户对象。如果找到了对应的用户对象,则将其序列化为 JSON 格式并返回;如果未找到对应的用户对象,则会返回一个 404 错误页面。这样可以确保在请求资源不存在时,用户能够得到一个清晰的错误提示,而不是返回一个空值或者其他不明确的结果。

认证和授权

在 Flask 中进行认证(Authentication)和授权(Authorization)是确保 Web 应用安全性的重要步骤。认证通常指的是验证用户的身份,而授权则是确定用户是否有权执行特定操作或访问特定资源。

认证

使用 Flask-Login 进行用户认证。Flask-Login 是一个常用的用于管理用户会话的扩展,可以轻松地实现用户登录功能:

pip install Flask-Login

在 Flask 应用中初始化 Flask-Login:

from flask import Flask
from flask_login import LoginManager

app = Flask(__name__)
login_manager = LoginManager(app)

定义用户模型,并实现用户加载函数:

from flask_login import UserMixin


class User(UserMixin):
    pass


@login_manager.user_loader
def load_user(user_id):
    ## 根据用户ID加载用户对象
    return User.get(user_id)

创建登录视图和认证逻辑:

from flask import request, redirect, url_for
from flask_login import login_user


@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        ## 验证用户身份,比如从数据库中验证用户名和密码
        user = User.query.filter_by(username=request.form['username']).first()
        if user and check_password_hash(user.password, request.form['password']):
            login_user(user)
            return redirect(url_for('index'))
    return render_template('login.html')

授权

路由保护

通过在路由函数上使用装饰器来限制访问权限。

from flask_login import login_required


@app.route('/dashboard')
@login_required
def dashboard():
    ## 只有经过认证的用户才能访问这个页面
    return render_template('dashboard.html')
角色控制

为了在 Flask 应用中实现角色和权限控制,你可以在用户模型中添加角色和权限字段,并在路由中根据用户的角色和权限来进行访问控制。下面是一个示例:

from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash


class User(UserMixin):
    def __init__(self, id, username, password, role):
        self.id = id
        self.username = username
        self.password = generate_password_hash(password)
        self.role = role

    ## 添加方法用于验证密码
    def check_password(self, password):
        return check_password_hash(self.password, password)

    ## 添加方法用于检查用户是否有指定权限
    def has_permission(self, permission):
        ## 在实际应用中,可以根据用户的角色来判断权限
        if self.role == 'admin':
            return True
        elif self.role == 'editor' and permission == 'edit_article':
            return True
        else:
            return False

在这个示例中,用户模型 User 包含了角色(role)字段和权限验证方法(has_permission)。你可以根据实际情况扩展这些字段和方法,以满足你的需求。

接下来,你可以在路由中使用这些信息来进行访问控制。例如:

from flask_login import current_user, login_required


@app.route('/admin')
@login_required
def admin_dashboard():
    if current_user.role == 'admin':
        ## 只有管理员才能访问管理员页面
        return render_template('admin_dashboard.html')
    else:
        return 'Unauthorized', 403


@app.route('/edit_article/<int:article_id>')
@login_required
def edit_article(article_id):
    if current_user.has_permission('edit_article'):
        ## 只有具有编辑权限的用户才能编辑文章
        ## 实际操作中,你可能会根据文章作者等信息进一步验证权限
        return render_template('edit_article.html', article_id=article_id)
    else:
        return 'Unauthorized', 403

在这个示例中,通过检查用户的角色和权限,我们可以控制用户是否有权访问特定的页面或执行特定的操作。在实际应用中,你可能需要更复杂的角色和权限控制逻辑。

中间件开发

在 Flask 中,中间件通常被称为 "Middleware" ,它们是在请求到达应用程序之前或响应发送到客户端之前执行的代码。中间件可以用于执行各种任务,例如身份验证、日志记录、错误处理等。下面是一个简单的示例,演示如何在 Flask 中开发一个自定义的中间件:

from flask import Flask, request

app = Flask(__name__)


## 自定义中间件
class CustomMiddleware:
    def __init__(self, app):
        self.app = app

    def __call__(self, environ, start_response):
        ## 在请求到达应用程序之前执行的代码
        ## 可以在这里执行身份验证、日志记录等任务
        print("Middleware: Before Request")

        ## 继续请求的传递
        response = self.app(environ, start_response)

        ## 在响应发送到客户端之前执行的代码
        ## 可以在这里执行日志记录、错误处理等任务
        print("Middleware: After Request")

        return response


## 注册中间件
app.wsgi_app = CustomMiddleware(app.wsgi_app)


## 路由
@app.route('/')
def index():
    return 'Hello, World!'


if __name__ == '__main__':
    app.run(debug=True)

在这个示例中,CustomMiddleware 是一个自定义的中间件类,它接受应用程序实例作为参数,并实现了 __call__ 方法,该方法接收环境和开始响应函数作为参数。在 __call__ 方法中,我们可以编写在请求到达应用程序之前和响应发送到客户端之前执行的代码。

然后,我们将 CustomMiddleware 注册到应用程序的 WSGI 中间件列表中,这样它就会在每个请求到达应用程序之前和响应发送到客户端之前被调用。

__call__ 方法中,你可以根据需要执行各种任务,例如身份验证、日志记录、错误处理等。这只是一个简单的示例,你可以根据具体需求来扩展和定制中间件。

Python 语言 —— 新手快速入门

介绍

基本介绍

Python 是由 Guido van Rossum 于 1991 年首次发布的高级解释型编程语言,凭借其简洁易读的语法、跨平台特性(支持 Windows、macOS 和 Linux)以及支持多种编程范式(面向对象、函数式、命令式)的灵活性,成为开发者、数据科学家、教育工作者等广泛使用的语言。 其解释型特性(逐行执行)使其适合快速原型开发,而丰富的标准库和强大的第三方生态(如 PyPI 和 pip 管理工具) 使其在 Web 开发(Django/Flask)、数据科学(NumPy/Pandas)、人工智能(TensorFlow/scikit-learn)、自动化脚本、 科学计算(SciPy/SymPy)等领域占据主导地位。

Python 拥有活跃的社区(Stack Overflow、GitHub等)和丰富的学习资源(如《Python Crash Course》《Fluent Python》),同时 Python 3.x 的持续演进(类型提示、异步编程等)进一步巩固了其在新兴技术(量子计算、边缘计算)中的领先地位,成为当今最受欢迎的编程语言之一。

版本特性

Python 是一种广泛使用的高级编程语言,自 1991 年首次发布以来,经历了多个版本的演变。其中,Python 2 和 Python 3 是两个重要的主要版本,分别于 2000 年和 2008 年推出。

Python 2.x 系列以其简洁和易读性著称,尤其是在早期的 Web 开发和脚本编写中得到了广泛应用。Python 2.7 是该系列的最后一个主要版本,于 2010 年发布。它的特性包括将 print 作为语句使用,例如 print "Hello, World!",这使得初学者更容易上手。此外,在 Python 2 中,整数除法默认执行的是整数除法,比如 5 / 2 的结果是 2。对于字符串处理,Python 2 中的默认字符串类型是字节串,Unicode 字符串需要特殊标识,如 u"你好"。尽管 Python 2 在当时非常流行,但由于其设计上的一些限制以及对现代编程需求的适应性不足,Python 社区逐渐向 Python 3 转型。

Python 3.x 系列的推出标志着对语言的一次重大改进。它在许多方面进行了重构,以增强可读性和一致性。例如,print 被改为函数形式,使用方法如 print("Hello, World!"),这使得代码更具一致性和可扩展性。Python 3 还引入了真实除法的概念,即使用 / 运算符时,两个整数相除会返回浮点数,如 5 / 2 的结果为 2.5,而 // 运算符则用于执行整数除法。字符串处理方面,Python 3 默认支持 Unicode,这使得国际化和多语言处理变得更加简单和直接。

然而,Python 3 与 Python 2 在语法和功能上并不完全兼容,这意味着许多基于 Python 2 的代码和库需要进行迁移才能在 Python 3 上运行。虽然 Python 2 在一段时间内仍然得到支持,但最终于 2020 年 1 月 1 日停止更新,开发者被鼓励转向 Python 3,以获得更好的性能和安全性。

Python 2 和 Python 3 代表了 Python 语言发展的两个重要阶段。Python 3 采用了更现代的设计理念, 提供了对新兴技术和标准的更好支持。因此,对于新项目,开发者应优先选择 Python 3,以确保代码的长久维护和最佳实践。

Note

如何选择 Python 版本?

除非对 Python 2.x 强烈的需求,否则极力推荐使用 Python 3.x。

环境搭建

安装 Python

在搭建 Python 环境之前,可以先检查本地电脑是否已经安装了 Python 环境。检查方式可以在终端窗口输入:

$ python
Python 3.11.5 (tags/v3.11.5:cce6ba9, Aug 24 2023, 14:38:34) [MSC v.1936 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> 

要是已经安装了,会输出 Python 的版本号。若显示无法识别 python 或其他错误信息,则说明本地电脑没有安装 Python 环境。 安装 Python 环境的步骤大概需要如下几步:

Step 1:下载 Python 安装包

Python 的安装包地址:

https://www.python.org/downloads/

Step 2:配置环境变量

若是 Windows 电脑,先打开环境变量窗口:右键“计算机” > 属性 > 高级系统设置,然后将 Python 的路径配置到环境变量中。

因为这个步骤非常通用,网上资料很多,不清楚可以自行搜索。

Step 3:编写代码,输出 Hello World!

配置完成后,在终端窗口输入 python --versrion,能够输出 python 的版本说明安装成功。然后输出 Hello World:

python
>>> print('Hello World!')

安装多版本 Python

在一台电脑上可以搭建 Python 2.x 和 Python 3.x 版本兼容的环境。需要使用 Python 2.x 时,执行 python <your_command>。 需要使用 Python 3.x,执行 python3 <your_command>

以配置 Python 2.x 和 Python 3.x 为例,环境搭建的步骤如下:

Step 1:在同一台电脑,分别安装 Python 2.xPython 3.x

Step 2:配置环境变量

以 Windows 11 为例,先打开环境变量窗口:右键“计算机” > 属性 > 高级系统设置,然后将 Python 的路径配置到环境变量中。

Step 3:修改应用的名称

找到 Python 3.x 的安装路径,将 Python 3.x 的 python.exe 和 pythonw.exe 分别修改成:python3.exepythonw3.exe

Warning

只修改 Python 3.x 版本,Python 2.x 保持不变。

修改完成后,验证 Python 2.x 和 Python 3.x 的功能是否正常:

# 因为没有修改 Python 2.x 的,所以默认是执行 Python 2.x
$ python --version
Python 2.7.13

$ python3 --version
Python 3.11.5

若可以正确的输出 Python 的版本信息,说明已经配置成功。

Step 4:配置 PIP

因为修改了 Python 3.x 的 python.exe 和 pythonw.exe 的名字,所以会导致 Python 3.x 的 PIP 无法使用。

$ pip --version
pip 9.0.1 from d:\softwares\python27\lib\site-packages (python 2.7)

# 输出异常的信息
$ pip3 --version

此时,需要强制重新安装 PIP 工具:

python -m pip install --upgrade pip --force-reinstall
python3 -m pip install --upgrade pip --force-reinstall

验证 PIP 工具是否配置正确:

$ pip2 --version
pip 9.0.1 from d:\softwares\python27\lib\site-packages (python 2.7)

$ pip3 --version
pip 24.2 from D:\Softwares\Python311\Lib\site-packages\pip (python 3.11)

Step 5:删除多余的 PIP 文件

为了避免以后使用 PIP 工具出现版本冲突,此时可以删除 Python 2.x 和 Python 3.x 的 Scripts 中的 pip.exe 工具,注意:

  1. Python 2.x 只保留 pip.exe;
  2. Python 3.x 只保留 pip3.exe。

删除多余的 PIP 工具

包管理软件

软件包源中拥有庞大且多样化的软件包数量和版本,因此使用软件源管理工具是必不可少的。常见的管理工具包括 pip、conda、Pipenv 以及 Poetry。

  • pip 是最为常见的包管理工具,通过简洁的 pip install <package> 命令格式安装软件包,使用的是 PyPI 软件包源。
  • conda 多被用作科学计算领域的包管理工具,功能丰富而强大,使用的软件包源是 Anaconda repository 和 Anaconda Cloud。除了支持 Python 软件包外,conda 还能安装 C、C++、R 等其他语言的二进制软件包,并提供了软件环境的隔离功能。
  • Pipenv 是由 Kenneth Reitz 于 2017 年发布的 Python 依赖管理工具,现由 PyPA 维护。Pipenv 可自动管理虚拟环境和依赖文件,并提供一系列命令和选项来实现各种依赖和环境管理相关的操作。
  • Poetry 类似于 Pipenv,是一个 Python 虚拟环境和依赖管理工具,同时提供包管理功能,如打包和发布。可将其视为 Pipenv 和 Flit 的超集,使你能够同时管理 Python 库和 Python 程序。

下表是上述几种包管理软件的对比:

特性 Conda Pipenv Poetry
环境隔离 ✅ 支持 ✅ 支持 ✅ 支持
依赖解析 ✅ 较强 ✅ 基础 ✅ 最先进
非Python包 ✅ 支持 ❌ 不支持 ❌ 不支持
构建发布 ❌ 不支持 ❌ 不支持 ✅ 完整支持
配置文件 environment.yml Pipfile pyproject.toml
适用场景 数据科学 应用开发 包开发
PIP

PIP(Pip Installs Packages)是 Python 的官方包管理工具,旨在简化 Python 库的安装、升级和管理过程。作为 Python 开发者的必备工具,PIP 使用户能够轻松地从 Python 包索引(PyPI)及其他源获取各种开源库,这些库覆盖了数据科学、Web 开发、机器学习等多个领域,极大地丰富了 Python 的生态系统。

# 安装包
pip install <package>
# 升级包
pip install --upgrade <package>
# 卸载包
pip uninstall --upgrade <package>
# 查看包
pip list
# 生成需求文件
pip freeze > requirements.txt

Note

为了提升下载速度,全球范围内存在许多 PyPI 的镜像服务器,国内也有多个软件源可供选择:

  1. 阿里云:https://mirrors.aliyun.com/pypi/simple/
  2. 清华大学:https://pypi.tuna.tsinghua.edu.cn/simple
  3. 中国科学技术大学::https://pypi.mirrors.ustc.edu.cn/simple/
  4. 豆瓣:https://pypi.doubanio.com/simple/

要临时使用某个镜像源,可以在安装包时加上 -i 参数,例如临时切换成清华大学源安装某个应用:

pip install <your_package> -i https://pypi.tuna.tsinghua.edu.cn/simple

如果希望永久更改源,可以创建一个配置文件。在 Linux 或 MacOS 上,创建 ~/.pip/pip.conf 文件。 在 Windows 电脑上,创建 <用户家目录>\pip\pip.ini 文件。添加如下内容:

[global]
index-url = https://pypi.tuna.tsinghua.edu.cn/simple
conda

Conda 是一个开源的跨平台包管理与环境管理系统,由 Anaconda 公司开发。它不仅可以管理 Python 包,还能处理非 Python 的二进制依赖,特别适合科学计算和数据分析领域。Conda 通过创建独立的环境来解决不同项目间的依赖冲突问题,其强大的依赖解析能力使其成为数据科学家的首选工具。

# 创建环境
conda create -n <env_name> python=3.8
# 激活环境
conda activate <env_name>
# 安装包
conda install <package>
# 列出环境
conda env list
# 导出环境配置
conda env export > environment.yml
Pipenv

Pipenv 是 Python 官方推荐的依赖管理工具,它结合了虚拟环境管理和包管理功能,旨在替代传统的 pip+virtualenv 工作流。 Pipenv 通过自动创建虚拟环境和生成 Pipfile/Pipfile.lock 文件,实现了依赖的精确控制和可重复构建,特别适合现代 Python 应用开发。

# 初始化项目
pipenv --python 3.8
# 安装包(开发依赖加--dev)
pipenv install <package>
# 进入虚拟环境
pipenv shell
# 生成锁定文件
pipenv lock
# 安装所有依赖
pipenv install --dev
Poetry

Poetry 是新一代的 Python 依赖管理和打包工具,采用 pyproject.toml 作为配置文件。它不仅能够管理项目依赖, 还能处理项目构建和发布,提供了从项目创建到发布的完整工作流。Poetry 的依赖解析算法更为先进,能更好地处理复杂的依赖关系。

# 新建项目
poetry new <project_name>
# 添加依赖
poetry add <package>
# 安装所有依赖
poetry install
# 进入虚拟环境
poetry shell
# 构建项目包
poetry build

虚拟环境

环境变量

环境变量是操作系统和程序之间的信息交换介质,进程可共享或指定环境变量。其中PATH是关键变量,用于定义可执行文件的搜索路径。例如:

  • 若将程序a.exe存放在D:\MyProgram,直接执行a.exe会提示"找不到程序"
  • D:\MyProgram加入PATH后,系统会从PATH列出的路径中查找a.exe

创建虚拟环境

虚拟环境通过临时修改PATH实现环境隔离:

  1. 激活时,脚本将当前命令行的PATH指向虚拟环境目录;
  2. 执行命令时优先从修改后的PATH查找;
  3. 命令行提示符会显示虚拟环境名称(如(.venv) user@host)。

常用的创建虚拟环境的工具主要有:

  1. venv(Python 3.3+内置)
  2. pyenv
  3. pipenv(整合了虚拟环境和依赖管理)
venv

作为 Python 3.3+ 版本内置的虚拟环境工具,venv 以其简洁易用的特性,成为开发者入门环境管理的首选工具。 venv 最大的特点在于其"开箱即用"的特性。作为 Python 标准库的一部分,它不需要额外安装,只需在命令行中调用即可快速创建隔离环境。

venv 通过创建独立的 Python 运行环境来实现隔离。具体来说,它会:

  1. 复制基础 Python 解释器;
  2. 创建独立的 site-packages 目录;
  3. 生成激活脚本用于环境切换。

假设我们正在开发一个名为 "data_analysis" 的项目:

# 创建环境
python -m venv data_analysis_env

# 激活环境(Linux/Mac)
source data_analysis_env/bin/activate

# 激活环境(Windows)
data_analysis_env\Scripts\activate.bat

# 安装项目依赖
pip install pandas matplotlib

# 退出环境
deactivate

但是不可否认的是:venv 作为基础工具也存在一些限制:仅适用于 Python 3.3+ 版本、无法管理不同 Python 版本和缺少依赖管理的高级功能。

pyenv

Python 版本兼容性问题常常让开发者头疼。pyenv 应运而生,成为管理多版本 Python 环境的瑞士军刀,让开发者能够游刃有余地 在不同项目间切换 Python 版本。

pyenv 从根本上解决了 Python 开发中的版本碎片化问题。它通过精巧的设计实现了:

  1. 并行安装多个Python版本
  2. 按项目灵活指定运行时版本
  3. 无缝切换全局和局部Python环境

pyenv 采用 shim 机制实现版本管理。首先 pyenv 在 PATH 最前面插入 shims 目录用于拦截 Python 相关命令调用,其次是根据配置路由到指定版本 实现对 Python 多版本的管理功能。

# 查看所有可安装版本
pyenv install --list
# 安装特定Python版本
pyenv install 3.9.7
# 卸载Python版本
pyenv uninstall 3.8.12
# 查看已安装版本
pyenv versions
# 设置全局Python版本
pyenv global 3.9.7
# 设置当前目录的本地Python版本(会创建.python-version文件)
pyenv local 3.8.12
# 设置shell会话临时版本
pyenv shell 3.10.4
# 重置版本继承(使用全局设置)
pyenv local --unset

pyenv 也可以实现对虚拟环境的管理,但是需要安装 pyenv-virtualenv 插件:

# 创建虚拟环境(基于当前pyenv版本)
pyenv virtualenv 3.9.7 my-project-env
# 列出所有虚拟环境
pyenv virtualenvs
# 激活虚拟环境
pyenv activate my-project-env
# 停用虚拟环境
pyenv deactivate
# 删除虚拟环境
pyenv virtualenv-delete my-project-env
pipenv

Pipenv 是官方推荐的 Python 依赖管理工具,是一个融合了虚拟环境管理和依赖管理的智能工具:

  • 虚拟环境管理(类似 virtualenv)
  • 依赖管理(替代 requirements.txt)
  • 依赖关系解析(生成 Pipfile.lock)

Pipenv 底层实际使用的是 Python 的 virtualenv 技术,但进行了自动化封装:

  1. 自动环境创建:当首次运行 pipenv install 时,会自动在 ~/.local/share/virtualenvs/ 目录下创建虚拟环境
  2. 哈希映射机制:通过计算项目路径的哈希值来唯一标识环境(可通过 pipenv --venv 查看实际路径)
  3. 环境自动激活:使用 pipenv shell 时会通过临时修改 PATH 实现环境切换
# 创建新环境(指定Python版本)
pipenv --python 3.8
# 进入虚拟环境shell
pipenv shell
# 退出虚拟环境
exit
# 删除虚拟环境
pipenv --rm
# 安装包(并添加到Pipfile)
pipenv install requests
# 安装开发依赖
pipenv install pytest --dev
# 从Pipfile安装所有依赖
pipenv install
# 安装开发环境所有依赖
pipenv install --dev
# 卸载包
pipenv uninstall requests
# 查看依赖图
pipenv graph
# 检查安全漏洞
pipenv check
# 锁定当前环境
pipenv lock
# 验证Pipfile.lock是否最新
pipenv verify

IDE

IDE(集成开发环境)是指集成了多种开发工具的软件应用程序,旨在提供程序员开发软件所需的所有工具和功能,从编码到调试和部署。

Python 的IDE有很多种,以下是一些比较流行和常用的:

  1. PyCharm:JetBrains 公司开发的强大的 Python IDE,支持多种功能如代码自动补全、调试、版本控制等。

  2. Visual Studio Code(VS Code):由微软开发的轻量级但功能强大的代码编辑器,支持众多插件和扩展,可以通过插件实现 Python 的开发环境。

  3. Spyder:科学计算和数据分析领域常用的 Python IDE,集成了 IPython 控制台和多种数据分析工具。

  4. Jupyter Notebook:交互式环境,允许开发者创建和共享文档,支持代码、文本和数据可视化。

  5. Atom:由 GitHub 开发的另一个流行的代码编辑器,也支持 Python 开发。

  6. Sublime Text:轻量级但功能丰富的代码编辑器,通过插件支持 Python 开发。

  7. Eclipse + PyDev:Eclipse 是一个通用的开发平台,通过 PyDev 插件可以支持 Python 开发。

  8. IDLE:Python 自带的集成开发环境,简单易用,适合初学者。

推荐一款 IDE 可能因人而异,但常见的推荐包括 PyCharm 和 VS Code。如果你喜欢强大的功能和集成开发环境,PyCharm 是一个很好的选择;如果你喜欢

基础语法

注释

注释是用于说明代码的文本,不会被解释器执行。注释可以提高代码的可读性,帮助他人(或将来的自己)理解代码的意图。

  1. 单行注释 使用井号 (#) 来标记注释。井号后面的内容将被视为注释,直到行末。

    # 这是一个单行注释
    print("Hello, World!")  # 打印输出
    
  2. 多行注释
    Python 没有专门的多行注释语法,但可以通过使用多个单行注释或者三重引号('''""")来实现。

    • 使用多个单行注释:
    # 这是一个多行注释
    # 它使用了多行的单行注释
    
    • 使用三重引号:
    """
    这是一个多行注释
    可以用于较长的说明
    """
    

使用适当的注释可以大大提高代码的可维护性和可读性。注释的最佳实践:

  • 清晰简洁:注释应简洁明了,能够清楚地解释代码的目的。
  • 避免过度注释:不需要对每一行代码都进行注释,尤其是那些显而易见的操作。
  • 更新注释:确保注释与代码保持一致,修改代码时也要相应更新注释。

数据类型

在 Python 语言中,数据类型可以系统性地分为以下六大类别,每种类型都有其独特的特性和应用场景:

  1. 数字型:包含整型(int)、浮点型(float)、布尔型(bool)和复数类型(complex),是Python进行数值计算的基础
  2. 字符串str类型用于处理文本数据,支持丰富的字符串操作和方法
  3. 序列类型
    • 列表(list):可变的有序集合
    • 元组(tuple):不可变的有序集合
  4. 集合类型setfrozenset,提供高效的成员检测和集合运算
  5. 映射类型dict类型,基于键值对的数据结构
  6. 二进制类型bytesbytearray等,用于处理二进制数据
数字型

Python的数字类型提供了完整的数值计算能力:

# 整型(任意精度)
integer = 42
# 浮点型(双精度)
floating = 3.14159
# 布尔型(True/False的子类)
boolean = True
# 复数
complex_num = 2 + 3j

# 类型转换示例
x = int("10")  # 字符串转整型
y = float(3)  # 整型转浮点

Note

Python 3中不再区分intlong类型,整型自动支持大整数运算

字符串

Python字符串是不可变的Unicode字符序列,支持多种创建方式:

# 基本创建方式
s1 = '单引号字符串'
s2 = "双引号字符串"
s3 = """多行
字符串"""

# 常用操作
name = "Python"
version = 3.8
formatted = f"{name} {version}"  # f-string格式化
concatenated = "Hello" + "World"  # 字符串连接

Note

重要特性:字符串不可变性意味着所有"修改"操作实际都返回新字符串对象

列表类型

列表是Python中最常用的可变序列类型:

# 创建列表
fruits = ['apple', 'banana', 'orange']
numbers = list(range(1, 6))

# 基本操作
fruits.append('pear')  # 添加元素
fruits.insert(1, 'kiwi')  # 插入元素
last = fruits.pop()  # 移除并返回最后一个元素

# 列表推导式
squares = [x ** 2 for x in range(10)]

Note

列表可以包含不同类型的元素,且支持嵌套结构

元组类型

元组是不可变序列,常用于保证数据完整性:

# 创建元组
coordinates = (10.0, 20.0)
single_element = (42,)  # 注意逗号

# 解包操作
x, y = coordinates

# 命名元组(更高级用法)
from collections import namedtuple

Point = namedtuple('Point', ['x', 'y'])
p = Point(11, y=22)

Note

不可变优势:适合作为字典键和使用在多线程环境中

集合类型

集合提供高效的无序唯一元素存储:

# 创建集合
unique_numbers = {1, 2, 3, 2, 1}  # 自动去重
empty_set = set()  # 不能用{}创建空集合

# 集合运算
a = {1, 2, 3}
b = {2, 3, 4}
union = a | b  # 并集
intersection = a & b  # 交集
difference = a - b  # 差集

Note

应用场景:快速成员检测、数据去重、关系运算

字典类型

字典是Python中的高效键值对映射:

# 创建字典
person = {'name': 'Alice', 'age': 25}
empty_dict = {}

# 常用操作
person['email'] = 'alice@example.com'  # 添加键值对
age = person.get('age', 0)  # 安全获取
keys = person.keys()  # 获取所有键

# 字典推导式
squares = {x: x * x for x in range(5)}

Note

键必须是不可变类型(字符串、数字、元组),值可以是任意对象

运算符

在 Python 中,运算符包括了逻辑运算符和和算术运算符。它们可以也被称为算术运算和逻辑运算。

  • 算术运算:用于执行数学计算,包括加、减、乘、除等。
  • 逻辑运算:用于处理布尔逻辑,主要用于条件判断。
算术运算

算术运算用于进行数学计算,主要包括以下几种运算符:

算术运算 符号 示例 输出结果
+ 1+2 3
- 3-1 2
* 4*8 32
/ 4/2 2
乘方 ** 2**10 1024
模(余数) % 4 % 3 1

Warning

  1. 任意两个数字相除时,结果总是浮点数,即使这两个数是整数且能整除;
  2. 任意运算中,只要有一个操作数是浮点数,结果总是浮点数。
>>> 4/2
2.0
>>> 1 + 2.0
3.0
逻辑运算

逻辑运算用于处理布尔值(TrueFalse),主要包括以下运算符:

运算符 描述 示例
and 与:两个表达式都为 True 时结果为 True a and b
or 或:至少一个表达式为 True 时结果为 True a or b
not

选择结构

Python 的选择结构主要通过条件(if)语句实现。

if condition:
# 当 condition 为 True 时执行的代码

Note

1. Python 使用缩进来表示代码块,`if` 语句后面的代码必须缩进。
2. 可以使用任何返回布尔值的表达式作为 condition
3. 条件可以包括数字、字符串、列表等,Python 会自动判断其真值。

if 语句是 Python 中用于条件判断的基本结构。它允许程序根据特定条件的真值来执行不同的代码块。 if 语句有四种常见的结构:

  1. 只有一个 if 构成的选择结构。
  2. if-else 组成选择结构。
  3. if-elif-else 组成的复合选择结构,其中 elif 可以不限个数。复合结构下,else 可以根据情况省略。
  4. 嵌套的 if 结构。
# if-else
x = -5
if x > 0:
    print("x 是正数")
else:
    print("x 不是正数")

# if-elif-else
if x > 0:
    print("x 是正数")
elif x < 0:
    print("x 是负数")
else:
    print("x 是零")

# 嵌套 if 语句
if x > 0:
    print("x 是正数")
    if x % 2 == 0:
        print("x 是偶数")
    else:
        print("x 是奇数")
else:
    print("x 不是正数")

if 可以结合使用逻辑运算符 andornot 来创建更复杂的条件:

age = 20
is_student = True

if age < 18 or is_student:
    print("享受学生优惠")
else:
    print("正常票价")

对于简单的条件,Python 提供了一种更简洁的写法,称为三元表达式(或条件表达式):

x = 5
message = "正数" if x > 0 else "非正数"
print(message)  # 输出: 正数

if 还可以用于对列表进行判空(元素个数为 0)。

users = []
if users:
    print('The list of user is empty.')
else:
    print(users)

循环结构

在 Python 中,循环结构主要有两种:for 循环和 while 循环。

for 循环

for 循环用于遍历序列(如列表、元组、字典、字符串等)中的每个元素。其基本语法如下:

for item in iterable:
# 执行的代码块

以下是一个简单的示例:

# 遍历列表
fruits = ['apple', 'banana', 'cherry']
for fruit in fruits:
    print(fruit)

# 使用 range() 函数
for i in range(5):  # 0 到 4
    print(i)
while 循环

while 循环在给定条件为真时重复执行代码块。其基本语法如下:

while condition:
# 执行的代码块

以下是一个简单的示例:

count = 0
while count < 5:
    print(count)
    count += 1  # 更新计数器以防止无限循环
嵌套循环

Python 还支持嵌套循环,即在一个循环内部再使用一个循环。

for i in range(3):
    for j in range(2):
        print(f'i={i}, j={j}')
控制循环

在使用 for 循环和 while 循环的时候,可以使用 breakcontinue 控制循环的流程:

  • break: 用于终止循环。
  • continue: 跳过当前迭代,继续下一个迭代。

以下是一个简单的示例:

# 使用 break
for i in range(10):
    if i == 5:
        break
    print(i)

# 使用 continue
for i in range(10):
    if i % 2 == 0:
        continue
    print(i)  # 打印奇数

用户输入

在 Python 中,可以使用 input() 函数来获取用户输入。该函数会暂停程序的执行,等待用户输入内容,然后返回输入的字符串。

# 获取用户输入
name = input("请输入你的名字: ")
print(f"你好, {name}!")

由于 input() 返回的是字符串类型,如果你需要获取数字(如整数或浮点数),需要进行类型转换。

# 获取用户输入的数字
age = input("请输入你的年龄: ")
age = int(age)  # 转换为整数
print(f"你{age}岁了!")
# 获取浮点数
height = input("请输入你的身高(米): ")
height = float(height)  # 转换为浮点数
print(f"你的身高是{height}米.")

如果需要一次性获取多个值,可以用 split() 方法将输入的字符串分割成列表。

# 输入多个值
numbers = input("请输入几个数字,以空格分隔: ")
numbers_list = numbers.split()  # 将字符串分割为列表
numbers_list = [int(num) for num in numbers_list]  # 转换为整数
print(f"你输入的数字是: {numbers_list}")

获取用户输入时,最好进行错误处理,以防用户输入无效数据。

try:
    age = int(input("请输入你的年龄: "))
    print(f"你{age}岁了!")
except ValueError:
    print("请输入一个有效的数字!")

Warning

  • input() 在 Python 3.x 中总是返回字符串,而在 Python 2.x 中,input() 会尝试执行输入的内容,因此在 Python 3.x 中使用时要特别注意。
  • 如果不希望终端回显输入的内容,可以使用 getpass 模块。
import getpass

password = getpass.getpass("请输入密码: ")
print("密码已输入.")

在 Python 中,类是面向对象编程的基本构件,用于创建对象。类可以看作是一个蓝图,其中定义了对象的属性(数据)和方法(功能)。 通过类,可以创建多个对象,每个对象都可以拥有独立的状态和行为。 类是 Python 中组织代码、封装数据和行为的重要工具。通过类,可以实现更清晰、更结构化的程序设计。

定义类

使用 class 关键字来定义一个类。

class Dog:
    pass  # 空类示例

类名通常使用大写字母开头,采驼峰命名法定义类名。

构造方法

__init__ 是类的构造方法,用于初始化对象的属性。

class Dog:
    def __init__(self, name, age):
        self.name = name  # 实例属性
        self.age = age

__init__() 是类的构造方法,所有实例化对象时都会调用该方法。形参 self 必不可少,且必须位于所有参数前面。这是因为 Python 在实例化对象时,会自动传入 self 参数。self 参数是一个指向实例本身的引用,让实例可以访问到类中的属性和方法。

nameage 是类中的属性。属性表示的是类的状态或特征。在 Python 中,定义属性时,在可以属性名前添加下划线:

  1. 单下划线 _ 前缀。表示该属性是“保护的”,是对开发者的一种约定,建议外部代码不要直接访问。通常用于类内部实现,表示这些属性不应被外部用户直接访问,但仍然可以通过对象访问。
  2. 双下划线 __ 前缀。用于实现“私有”属性,通过名称重整(name mangling),使得属性名改变为 _ClassName__AttributeName 的形式,从而防止子类中出现同名属性或方法。当希望禁止子类访问父类的同名属性,或者想要提供一种更强的封装和隐藏机制时,可以使用双下划线。
方法

类可以包含方法,表示对象的行为或功能。

class Dog:
    def bark(self):
        return f"{self.name} says woof!"

在 Python 定义方法时,会在方法前添加单下划线 _ 或双下划线 __。它们的主要作用是表明这些方法是类内部实现的一部分,不应该被直接访问。两者的区别主要是:

  1. 单下划线 _ 表示“保护”,是对开发者的约定,表明该属性或方法不应被外部代码直接访问。该定义没有强制性,需要访问仍然可以访问。
  2. 表示“私有”,通过名称重整来避免子类中同名冲突,提供了一种更强的封装性。子类不能直接访问父类的同名方法或属性。

Note

下划线修饰属性和方法时,含义基本相同。

在 Python 中,魔法方法(也称为特殊方法或双下划线方法)是以双下划线开始和结束的方法。这些方法允许你自定义对象的行为,使你的类更加灵活和强大。常见的魔法方法见下表。

魔法方法 描述
__new__(cls, *args, **kwargs) 创建并返回一个新实例
__init__(self, *args, **kwargs) 初始化对象的属性
__str__(self) 定义 str() 的返回值,提供友好的字符串表示
__repr__(self) 定义 repr() 的返回值,用于调试
__eq__(self, other) 定义相等比较(==
__ne__(self, other) 定义不相等比较(!=
__lt__(self, other) 定义小于比较(<
__le__(self, other) 定义小于等于比较(<=
__gt__(self, other) 定义大于比较(>
__ge__(self, other) 定义大于等于比较(>=
__add__(self, other) 定义加法(+
__sub__(self, other) 定义减法(-
__mul__(self, other) 定义乘法(*
__truediv__(self, other) 定义真除法(/
__floordiv__(self, other) 定义地板除法(//
__mod__(self, other) 定义取模(%
__pow__(self, other) 定义幂运算(**
__len__(self) 定义 len() 的返回值
__getitem__(self, key) 定义索引访问(例如,obj[key]
__setitem__(self, key, value) 定义索引赋值(例如,obj[key] = value
__delitem__(self, key) 定义删除索引(例如,del obj[key]
__iter__(self) 返回迭代器对象
__next__(self) 定义迭代器的下一个值
__enter__(self) 定义进入上下文的行为
__exit__(self, exc_type, exc_value, traceback) 定义退出上下文的行为
__getattr__(self, name) 当访问不存在的属性时调用
__getattribute__(self, name) 访问属性时调用
__setattr__(self, name, value) 设置属性时调用
__delattr__(self, name) 删除属性时调用
__del__(self) 定义对象被销毁时的行为
__hash__(self) 定义对象的哈希值
__bool__(self) 定义对象在布尔上下文中的表现
封装继承和多态
封装

封装是隐藏对象的内部状态和实现细节,只允许通过特定方法访问。

class BankAccount:
    def __init__(self, balance=0):
        self.__balance = balance  # 私有属性

    def deposit(self, amount):
        if amount > 0:
            self.__balance += amount

    def withdraw(self, amount):
        if 0 < amount <= self.__balance:
            self.__balance -= amount

    def get_balance(self):
        return self.__balance


# 使用 BankAccount 类
account = BankAccount()
account.deposit(100)
print(account.get_balance())  # 输出: 100
account.withdraw(50)
print(account.get_balance())  # 输出: 50
继承

Python 支持类的继承,子类可以继承父类的属性和方法。

class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        pass  # 抽象方法


class Dog(Animal):
    def speak(self):
        return f"{self.name} says woof!"


class Cat(Animal):
    def speak(self):
        return f"{self.name} says meow!"


# 创建 Dog 和 Cat 的实例
dog = Dog("Rex")
cat = Cat("Fluffy")

print(dog.speak())  # 输出: Rex says woof!
print(cat.speak())  # 输出: Fluffy says meow!
多态

多态允许不同类的对象以相同的方式调用同一个方法。

def animal_sound(animal):
    print(animal.speak())


# 使用多态
animal_sound(dog)  # 输出: Rex says woof!
animal_sound(cat)  # 输出: Fluffy says meow!

函数

函数是带名字的代码块,使用 def 关键字定义一个函数,使用圆括号定义函数接收的参数。

# 定义函数
def greet_user():
    print("Hello!")


# 调用函数
greet_user() 
形式参数

函数定义的参数称为形式参数(形参),调用函数传递的参数称为实际参数(实参)。在函数定义时,可以先定义形式参数。

def greet_user(name):
    print(f'Hello, {name}!')


greet_user('Li Hua')

根据参数的位置可以将实参分成:位置实参和关键字实参。位置实参要求实参与形参的顺序相同:

def greet_user(first_name, last_name):
    print(f"Your name is {first_name} {last_name}")


greet_user('San', 'Zhang')

关键字实参通过让参数名与值关联,所以可以让实参与形参的顺序不同:

def greet_user(first_name, last_name):
    print(f"Your name is {first_name} {last_name}")


greet_user(first_name='San', last_name='Zhang')
greet_user(last_name='Zhang', first_name='San')
参数默认值

在定义形参时,可以为形参设置一个默认值,调用时可以传可不传:

def greet_user(first_name, last_name, school='No.2 Middle School'):
    print(f"Your name is {first_name} {last_name} and your school is {school}")


greet_user('San', 'Zhang', 'No.1 Middle School')
greet_user('Si', 'Li')
引用传递

函数的形式参数为列表、字典等时,传递的方式为引用传递。

def modify(numbers):
    numbers.append(4)  # 在函数内部修改列表


numbers = [1, 2, 3]
modify(numbers)
print(numbers)  # 输出 [1, 2, 3, 4]

通过使用切片的方式,可以实现不改变实参的列表元素:

def modify(numbers[:

]):
numbers.append(4)  # 在函数内部修改列表

numbers = [1, 2, 3]
modify(numbers)
print(numbers)  # 输出 [1, 2, 3]
任意参数

无法确认参数个数时,可以使用任意参数。使用 * 表示元组,使用 ** 表示字典。

def print_number(*numbers):
    print(numbers)


print_number(1)
print_number(1, 2)
print_number(1, 2, 3)
print_number(1, 2, 3, 4)
print_number(1, 2, 3, 4, 5)


def print_name(**names):
    print(names)


print_name(first_name='San', last_name='Zhang')

Warning

元组中的元素不可修改。

函数引用

在一个文件中引入另外一个模块的函数,有三种导入方法:

  1. 导入整个模块;
  2. 导入模块中的所有函数和变量;
  3. 导入模块中的某个函数或变量;
# 导入整个模块
import module_name

# 导入模块中的所有函数和变量
from module_name import *

# 导入模块中的某个函数或变量
from module_name import function_name, variable_name

第 1 种方式和第 2 种方式的区别在于:第 1 种方式在调用导入模块的函数时,需要使用 模块名.函数名 的方式调用函数。而第 2 种方式直接使用函数名调用。因此,第 1 种方式可以避免函数名重复定义,第 2 种则无法避免(污染了当前环境)。因此,推荐使用第 1 种方式导入函数。

导入后的函数或者模块,可以通过 as 关键字设置别名:

import module_name as a
from module_name import * as b

文件操作

打开文件使用 open() 函数,关闭文件使用 close() 函数,读文件使用 read()readline()readlines() 函数,写文件使用 write() 函数或者 writelines(),自动关闭文件使用 with-as 语句。

# 只读模式打开文件
file = open('a.txt', 'r')
# 写模式打开文件(若文件不存在,则会自动创建)
file = open('a.txt', 'w')
# 追加模式打开文件(若文件不存在,则会自动创建)
file = open('a.txt', 'a')
# 读整个文件
content = file.read()
# 读一行
line = file.readline()
# 读取所有行并存储到列表中
lines = file.readlines()
# 写入字符串到文件
file.write('Hello, World!')
# 写入多行字符串到文件
lines = ['Line 1\n', 'Line 2\n', 'Line 3\n']
file.writelines(lines)
# 关闭文件
file.close()
# 自动关闭文件
# 在 with 代码块结束时,文件会自动关闭,无需手动调用 file.close()
with open('a.txt', 'r') as file
    print(file.read())

导入 json 模块后,可以处理 json 格式的文件。使用 json.dump() 保存一个 json 文件到本地磁盘,使用 json.load() 读取一个 json 文件。

import json

numbers = [1, 2, 3, 4, 5]

filename = 'numbers.json'
with open(filename, 'w') as wf:
    json.dump(numbers, wf)

with open(filename, 'r') as rf
    new_numbers = json.load(rf)
    print(new_numbers)

异常处理

在 Python 中,异常处理是管理程序运行过程中可能出现的错误的一种机制。通过异常处理,可以避免程序因错误而崩溃,并能够优雅地处理错误情况。Python 提供了 tryexceptelsefinally 语句来实现异常处理。

try:
    # 可能会引发异常的代码
    risky_code()
except SomeException:
    # 处理异常的代码
    handle_exception()
else:
    # 如果没有异常发生,执行这部分
    no_exception_code()
finally:
    # 无论是否发生异常,都会执行这部分
    cleanup_code()

其中:

  1. try 块:放置可能引发异常的代码。如果代码没有异常,程序将继续执行 try 块后面的代码。
  2. except 块:用于捕获和处理特定类型的异常。可以指定多个 except 块来处理不同类型的异常。
  3. else 块:如果 try 块中的代码没有引发异常,将执行 else 块中的代码。这部分是可选的。
  4. finally 块:无论是否发生异常,finally 块中的代码都会执行。常用于清理资源,如关闭文件或网络连接。这部分也是可选的。

Warning

可以使用通用的 except Exception 来捕获所有异常,但通常不推荐这样做,因为它可能掩盖其他潜在问题。

try:
    # 一些代码
except Exception as e:
    print(f"An error occurred: {e}")

除了使用 Python 内置的异常外,还可以根据情况在代码中自定义异常。

# 自定义一个 CustomError 类型的异常
class CustomError(Exception):
    pass


def some_function():
    raise CustomError("This is a custom error.")


try:
    some_function()
except CustomError as e:
    print(e)

单元测试

单元测试用于测试某个函数不存在问题。通过导入 unittest 模式,测试类继承 unittest.TestCase 来执行单元测试。

import unittest

def add(a, b):
    return a + b

class CalculatorTest(unittest.TestCase):  # 继承测试类
    def test_add(self):  # 测试方法
        self.assertEqual(add(1, 2), 3)

if __name__ == '__main__':
    unittest.main()

判断程序运行的结果是否与期待的结果相等,称之为断言。

方法 含义 示例 输出结果
assertEqual(a, b) 判断是否相等 assertEqual(1, 2) False
assertNotEqual(a, b) 判断是否不相等 assertNotEqual(1, 2) True
assertTrue(x) 判断是否为 True assertTrue(1 == 2) False
assertFalse(x) 判断是否为 False assertFalse(1 == 2) True
assertIn(item, list) 判断元素是否在列表中 assertIn(3, [1, 2, 3 , 4]) True
assertNotIn(item, list) 判断元素是否不在列表中 assertNotIn(3, [1, 2, 3, 4]) False

unittest 支持在每个测试方法执行前后执行一些准备和清理操作,可以使用 setUptearDown 方法。

class TestStringMethods(unittest.TestCase):

    def setUp(self):
        # 在每个测试方法执行前调用
        pass

    def tearDown(self):
        # 在每个测试方法执行后调用
        pass

Python 语言进阶 —— 从 logging 到 loguru

认识日志

入门 Python 学习的第一个程序就是 Hello World:

print('Hello World!')

通过使用 print 语句输出 Hello World ,这就是一个最简单的日志记录。在软件开发中,日志记录是至关重要的一环,帮忙开发者、运维者了解程序是否在正常运行。通过分析日志,我们可以了解系统的运行状态,及时发现潜在问题,防患于未然。甚至日志可以记录系统的操作行为,帮助我们更好地理解用户需求,优化产品体验。

日志框架

在 Python 生态系统中,日志记录框架的选择丰富多样,每种框架都有其独特的优势和适用场景。以下介绍几种常用的日志框架,并对它们的优劣进行分析:

框架 优点 缺点 使用场景
logging 1. 无需额外安装 2. 功能强大 3. 灵活度高 1. 配置复杂 2. 功能有限(不支持异步、结构化日志等) 3. 代码冗余 1. 简单的日志记录需求 2. 依赖 Python 标准库的项目
loguru 1. 简洁易用 2. 功能强大(支持异步、结构化日志、彩色输出等) 3. 代码简洁 1. 需要额外安装 2. 灵活性相对较低 1. 需要快速上手的项目 2. 对日志功能要求较高的项目
structlog 1. 专注于结构化日志记录 2. 灵活度高(易于扩展和集成) 1. 学习成本较高 2. 配置复杂 1. 需要结构化日志记录的项目 2. 需要与其他日志框架集成的场景
Sentry 1. 错误追踪和日志记录结合 2. 实时监控和告警功能 3. 集成方便(支持多种语言和框架) 1. 需要额外安装和配置 2. 免费版功能有限,费用较高 1. 需要错误追踪和监控的项目 2. 中大型项目或对稳定性要求高的场景

其中,loggingloguru 在 Python 项目中使用得最为广泛。因此,本文着重介绍这两个库。

logging 模块

Python 语言在标准库中,提供了强大的日志记录标准库:logging。因为 logging 属于 Python 标准库中的一部分,无需安装,所以导入包就可以直接使用。

import logging

logging.debug('Hello World!')

核心概念

logging 模块的设计文档[1][2][3] 中提到 logging 模块的设计基于以下这些核心概念:

  • 日志器 Logger:Logger 是日志记录的主体,负责产生日志消息。每个 Logger 都有一个名称,通常使用模块名作为名称(如 __name__),以便区分不同模块的日志。
  • 处理器 Handler:Handler 负责将日志消息发送到指定的目的地,例如控制台、文件、网络等。一个 Logger 可以添加多个 Handler。
  • 格式化器 Formatter:Formatter 用于定义日志消息的输出格式,例如时间、日志级别、模块名、消息内容等。
  • 过滤器 Filter:Filter 用于对日志消息进行过滤,只有满足条件的日志才会被记录。
  • 日志级别 Log Level:logging 定义了6 种日志级别,从低到高分别是:
    • DEBUG:调试信息,用于开发阶段。
    • INFO:常规信息,用于记录程序运行状态。
    • WARNING:警告信息,表示潜在的问题。
    • ERROR:错误信息,表示程序出现错误。
    • CRITICAL:严重错误信息,表示程序可能无法继续运行。

快速入门

import logging

# 创建 Logger
logger = logging.getLogger(__name__)
# 设置日志级别
logger.setLevel(logging.DEBUG)

# 创建 Handler(输出到控制台)
stream_handler = logging.StreamHandler()
stream_handler.setLevel(logging.INFO)

# 创建 Formatter
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
# 将 Formatter 添加到 Handler
stream_handler.setFormatter(formatter)

# 将 Handler 添加到 Logger
logger.addHandler(stream_handler)

# 记录日志
logger.debug('这是一条调试信息')
logger.info('这是一条常规信息')
logger.warning('这是一条警告信息')
logger.error('这是一条错误信息')
logger.critical('这是一条严重错误信息')

在使用 logging 模块的时候,通过 logging.getLogger(__name__) 为每个模块创建一个独立的日志记录器,这样做的好处有两点:

  • 区分日志来源:日志输出中会包含模块名称,方便定位日志的来源。
  • 模块化配置:可以为不同的模块设置不同的日志级别、Handler 或 Formatter。

如果直接使用 logging.getLogger()而不传递名称,会返回根日志记录器(Root Logger):

  • 根日志记录器是全局的,所有模块共享同一个日志记录器。
  • 这样会导致日志输出难以区分来源,且配置可能会相互覆盖。

也就是说通过 logging.getLogger(__name__),可以避免全局冲突。

上述案例中,创建一个输出到控制台的 StreamHandler 对象,用于将日志定向输出到控制台。还创建一个 Formatter 对象对输出到控制的日志信息进行格式化。最后,将 Handler 添加到日志记录器中,完成一个自定义的日志输出的设计。

文件配置

logging 模块支持多种配置方式,除了上述使用的代码方式配置日志记录器(Logger)、处理器(Handler)、格式化器(Formatter)等组件以外,还支持使用文件的方式进行配置。文件格式支持 JSON、YAML 或字典的形式配置日志。通过文件配置的方式非常适合复杂的日志配置需求,因为它可以将配置与代码分离,使配置更易于管理和维护。

下表是这三种方式的优缺点对比:

格式 优点 缺点
JSON 1. 结构化数据,易于解析 2. 适合复杂配置 1. 可读性较差 2. 需要额外文件
YAML 1. 可读性高 2. 适合配置文件 3. 支持注释 1. 需要安装 pyyaml 库 2. 对缩进敏感,容易出错
字典 1. 无需额外文件 2. 适合直接在代码中定义配置 1. 配置与代码耦合 2. 不适合复杂配置
JSON 格式

JSON 配置文件通常包含以下部分:

  • version:配置版本,必须为 1
  • formatters:定义日志的格式化器。
  • handlers:定义日志的处理器(如输出到控制台、文件等)。
  • loggers:定义日志记录器及其配置。
  • root:根日志记录器的配置(可选)。

以下是一个完整的 JSON 配置文件示例:

{
  "version": 1,
  "disable_existing_loggers": false,
  "formatters": {
    "simple": {
      "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
    },
    "json": {
      "format": {
        "timestamp": "%(asctime)s",
        "name": "%(name)s",
        "level": "%(levelname)s",
        "message": "%(message)s"
      },
      "class": "pythonjsonlogger.jsonlogger.JsonFormatter"
    }
  },
  "handlers": {
    "console": {
      "class": "logging.StreamHandler",
      "level": "DEBUG",
      "formatter": "simple",
      "stream": "ext://sys.stdout"
    },
    "file": {
      "class": "logging.handlers.RotatingFileHandler",
      "level": "INFO",
      "formatter": "json",
      "filename": "app.log",
      "maxBytes": 10485760,
      "backupCount": 5,
      "encoding": "utf8"
    }
  },
  "loggers": {
    "my_module": {
      "level": "DEBUG",
      "handlers": [
        "console",
        "file"
      ],
      "propagate": false
    }
  },
  "root": {
    "level": "WARNING",
    "handlers": [
      "console"
    ]
  }
}

上述配置的字段含义如下:

  1. formatters 定义日志的格式化器:

  2. simple:使用简单的文本格式。

  3. json:使用 JSON 格式输出日志,需要安装 python-json-logger 库。
  4. handlers 定义日志的处理器:

  5. console:将日志输出到控制台。

  6. file:将日志输出到文件,并支持文件轮转(RotatingFileHandler)。
  7. loggers 定义模块级别的日志记录器:

  8. my_module:为 my_module 模块配置日志记录器,输出到控制台和文件。

  9. root 定义根日志记录器的配置。所有未明确配置的日志记录器会继承根日志记录器的配置。

在代码中,通过下面的方式加载配置文件:

import logging.config
import json

# 假如文件的名字为 logging_config.json,加载 JSON 配置文件
with open('logging_config.json', 'r') as f:
    config = json.load(f)

logging.config.dictConfig(config)

# 获取日志记录器
logger = logging.getLogger('my_module')

# 记录日志
logger.debug('This is a debug message')
logger.info('This is an info message')
logger.warning('This is a warning message')
logger.error('This is an error message')
YAML 格式

YAML 是一种易读的数据序列化格式,适合用于配置文件。以下是将上面 JSON 配置修改成 YAML 格式:

version: 1
disable_existing_loggers: false
formatters:
  simple:
    format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
  json:
    format:
      timestamp: "%(asctime)s"
      name: "%(name)s"
      level: "%(levelname)s"
      message: "%(message)s"
    class: "pythonjsonlogger.jsonlogger.JsonFormatter"
handlers:
  console:
    class: "logging.StreamHandler"
    level: "DEBUG"
    formatter: "simple"
    stream: "ext://sys.stdout"
  file:
    class: "logging.handlers.RotatingFileHandler"
    level: "INFO"
    formatter: "json"
    filename: "app.log"
    maxBytes: 10485760
    backupCount: 5
    encoding: "utf8"
loggers:
  my_module:
    level: "DEBUG"
    handlers: [ "console", "file" ]
    propagate: false
root:
  level: "WARNING"
  handlers: [ "console" ]

此时,需要通过如下的方式加载配置文件:

import logging.config
import yaml

# 加载 YAML 配置文件
with open('logging_config.yaml', 'r') as f:
    config = yaml.safe_load(f)

logging.config.dictConfig(config)

# 获取日志记录器
logger = logging.getLogger('my_module')

# 记录日志
logger.debug('This is a debug message')
logger.info('This is an info message')
字典格式

如果不想使用外部文件,可以直接在代码中使用 Python 字典定义配置:

import logging.config

config = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "simple": {
            "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
        },
        "json": {
            "format": {
                "timestamp": "%(asctime)s",
                "name": "%(name)s",
                "level": "%(levelname)s",
                "message": "%(message)s"
            },
            "class": "pythonjsonlogger.jsonlogger.JsonFormatter"
        }
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "level": "DEBUG",
            "formatter": "simple",
            "stream": "ext://sys.stdout"
        },
        "file": {
            "class": "logging.handlers.RotatingFileHandler",
            "level": "INFO",
            "formatter": "json",
            "filename": "app.log",
            "maxBytes": 10485760,
            "backupCount": 5,
            "encoding": "utf8"
        }
    },
    "loggers": {
        "my_module": {
            "level": "DEBUG",
            "handlers": ["console", "file"],
            "propagate": False
        }
    },
    "root": {
        "level": "WARNING",
        "handlers": ["console"]
    }
}

# 加载配置
logging.config.dictConfig(config)

# 获取日志记录器
logger = logging.getLogger('my_module')

# 记录日志
logger.debug('This is a debug message')
logger.info('This is an info message')

高阶功能

日志轮转

当日志文件过大时,可以通过文件轮转(Rotating)将日志分割成多个文件。logging提供了两种文件轮转处理器:

  • RotatingFileHandler:基于文件大小轮转。
  • TimedRotatingFileHandler:基于时间轮转。

基于文件大小的轮转:

import logging
from logging.handlers import RotatingFileHandler

# 创建日志记录器
logger = logging.getLogger('my_app')
logger.setLevel(logging.DEBUG)

# 创建 RotatingFileHandler
handler = RotatingFileHandler(
    filename='app.log',  # 日志文件名
    maxBytes=10 * 1024 * 1024,  # 每个日志文件最大 10MB
    backupCount=5,  # 保留 5 个备份文件
    encoding='utf8'
)
handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))

# 添加处理器
logger.addHandler(handler)

# 记录日志
for i in range(10000):
    logger.info(f'This is log message {i}')

基于时间的轮转:

import logging
from logging.handlers import TimedRotatingFileHandler

# 创建日志记录器
logger = logging.getLogger('my_app')
logger.setLevel(logging.DEBUG)

# 创建 TimedRotatingFileHandler
handler = TimedRotatingFileHandler(
    filename='app.log',  # 日志文件名
    when='midnight',  # 每天午夜轮转
    interval=1,  # 轮转间隔
    backupCount=7,  # 保留 7 个备份文件
    encoding='utf8'
)
handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))

# 添加处理器
logger.addHandler(handler)

# 记录日志
logger.info('This is a log message')
日志转发到网络

logging支持将日志发送到远程服务器,例如通过 HTTP 或 Socket。

使用 HTTP 发送到服务器:

import logging
import logging.handlers

# 创建日志记录器
logger = logging.getLogger('remote_logger')
logger.setLevel(logging.DEBUG)

# 创建 SocketHandler
handler = logging.handlers.SocketHandler('localhost', 9020)  # 发送到本地 9020 端口
logger.addHandler(handler)

# 记录日志
logger.info('This is a log message sent to a remote server')

使用 Socket 发送到服务器:

import logging
import logging.handlers

# 创建日志记录器
logger = logging.getLogger('remote_logger')
logger.setLevel(logging.DEBUG)

# 创建 HTTPHandler
handler = logging.handlers.HTTPHandler(
    'localhost:5000',  # 服务器地址
    '/log',  # 日志接收路径
    method='POST'
)
logger.addHandler(handler)

# 记录日志
logger.info('This is a log message sent via HTTP')
日志过滤

logging 模块的日志过滤功能允许根据特定的条件控制哪些日志应该被记录。通过过滤器,可以实现对日志更加精细的日志控制,例如:

  • 过滤特定级别的日志。
  • 过滤包含特定关键词的日志。
  • 过滤来自特定模块或日志记录器的日志。

logging 模块中的日志过滤器需要继承 logging.Filter 类,重写 filter(record) 方法。record 是一个日志对象,包含了日志的所有信息,比如日志级别、消息和模块名等。

重写的 filter(record) 方法返回 True 时,日志会被记录。返回 False 时,日志会被忽略。

一个过滤特定级别日志的过滤器实现:

import logging


# 自定义过滤器
class LevelFilter(logging.Filter):
    def __init__(self, level):
        self.level = level

    def filter(self, record):
        return record.levelno == self.level  # 只允许指定级别的日志通过


# 创建日志记录器
logger = logging.getLogger('level_filter_logger')
logger.setLevel(logging.DEBUG)

# 创建 Handler
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))

# 添加过滤器
handler.addFilter(LevelFilter(logging.ERROR))  # 只记录 ERROR 级别的日志

# 添加处理器
logger.addHandler(handler)

# 记录日志
logger.debug('This is a debug message')  # 不会被记录
logger.info('This is an info message')  # 不会被记录
logger.error('This is an error message')  # 会被记录

一个过滤不包含某些关键词的日志过滤器实现:

import logging


# 自定义过滤器
class KeywordFilter(logging.Filter):
    def __init__(self, keyword):
        self.keyword = keyword

    def filter(self, record):
        return self.keyword in record.getMessage()  # 只允许包含关键词的日志通过


# 创建日志记录器
logger = logging.getLogger('keyword_filter_logger')
logger.setLevel(logging.DEBUG)

# 创建 Handler
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))

# 添加过滤器
handler.addFilter(KeywordFilter('important'))  # 只记录包含 "important" 的日志

# 添加处理器
logger.addHandler(handler)

# 记录日志
logger.info('This is a normal message')  # 不会被记录
logger.info('This is an important message')  # 会被记录
异步日志记录

在高并发场景下,同步日志记录可能会影响性能。可以通过QueueHandlerQueueListener实现异步日志记录:

import logging
import logging.handlers
import queue
import threading

# 创建日志队列
log_queue = queue.Queue()

# 创建 QueueHandler
queue_handler = logging.handlers.QueueHandler(log_queue)

# 创建日志记录器
logger = logging.getLogger('async_logger')
logger.setLevel(logging.DEBUG)
logger.addHandler(queue_handler)

# 创建 QueueListener
file_handler = logging.FileHandler('async.log')
listener = logging.handlers.QueueListener(log_queue, file_handler)

# 启动监听器
listener.start()

# 记录日志
logger.info('This is an async log message')

# 停止监听器
listener.stop()
动态修改日志配置

在运行时动态修改日志配置,例如根据环境变量调整日志级别:

import logging
import os

# 创建日志记录器
logger = logging.getLogger('dynamic_logger')
logger.setLevel(logging.DEBUG)

# 根据环境变量设置日志级别
log_level = os.getenv('LOG_LEVEL', 'INFO').upper()
logger.setLevel(getattr(logging, log_level))

# 记录日志
logger.debug('This is a debug message')
logger.info('This is an info message')

loguru 库

介绍

loguru 是一个 Python 日志记录 第三方开源库,相比与 Python 原生的 logging 模块,loguru 提供了更简单、更强大、更优雅的日志记录方式。它是对 Python 标准库logging 的替代方案,具有开箱即用、功能丰富、配置灵活等特点。

官方文档 中,对 loguru 的介绍十分有趣:

  1. Loguru 的主要理念是存在且仅存在一个日志记录器。
  2. 没有繁琐的 Handler、Formatter 或 Filter:一个函数即可掌控全局。

真是醉翁之意不在酒,虽然 loguru 没有直接写明和谁进行对比,但是学习过 Python 日志框架的人都知道官网在说什么。读完官方文档对 loguru 的介绍,可以总结出四个字:简单好用!

安装库

pip install loguru

推荐在虚拟环境中安装依赖库文件,避免对全局环境产生污染。

快速入门

开箱即用是 loguru 最大的特点,所有的核心配置全都被分装到了 logger 对象中,全部使用 add 的方式向 loguru 添加配置:

from loguru import logger

# 开箱即用,不需要过多的配置
logger.debug("That's it, beautiful and simple logging!")

# 通过 add 方法添加配置
logger.add(sys.stderr, format="{time} {level} {message}", filter="my_module", level="INFO")

如果希望将日志信息转发到文件,也是通过 add 方法进行:

logger.add("file_{time}.log")

如果需要轮换日志、删除旧日志或者是在关闭时压缩日志文件,可以这样做:

# 文件过大时自动轮换
logger.add("file_1.log", rotation="500 MB")
# 每天中午 12 点创建新文件
logger.add("file_2.log", rotation="12:00")
# 文件过期后轮换
logger.add("file_3.log", rotation="1 week")
# 一段时间后清理旧日志
logger.add("file_X.log", retention="10 days")
# 压缩日志以节省空间
logger.add("file_Y.log", compression="zip")

看到了吧,一切关于日志的操作在 loguru 中都会变得简单!

格式化日志

Loguru 的日志方法支持类似 str.format() 的格式化方式,既可以使用位置参数,也可以使用关键字参数:

logger.info("If you're using Python {}, prefer {feature} of course!", 3.6, feature="f-strings")

这种方式比传统的 % 格式化更灵活、更易读,也符合 Python 的现代格式化风格(如 f-strings)。

日志格式

Loguru 的默认日志格式如下:

< level > {time: YYYY - MM - DD HH: mm: ss.SSS} | < level > {level} | < cyan > {name} < / cyan >: < cyan > {
    function} < / cyan >: < cyan > {line} < / cyan > - < level > {message} < / level >

其中:

  • {time}:日志记录的时间,默认格式为 YYYY-MM-DD HH:mm:ss.SSS
  • {level}:日志级别(如 INFO、ERROR 等)。
  • {name}:日志记录器的名称(通常是模块名称)。
  • {function}:记录日志的函数名称。
  • {line}:记录日志的代码行号。
  • {message}:日志消息内容。

Loguru 提供了非常灵活的日志格式配置,允许通过format参数自定义日志格式。以下是一个简单的自定义格式示例:

logger.add(sys.stderr, format="{time} | {level} | {message}")

输出的日志如下:

2023-10-10 12:00:00 | INFO | This is a log message

Loguru 支持自定义时间格式,使用{time}字段时可以指定格式:

logger.add(sys.stderr, format="{time:YYYY/MM/DD at HH:mm:ss} | {level} | {message}")

此外,Loguru 支持许多内置字段,例如:

  • {file}:记录日志的文件名。
  • {module}:记录日志的模块名称。
  • {process}:进程 ID。
  • {thread}:线程 ID。
  • {elapsed}:从程序启动到记录日志的时间间隔。
logger.add(sys.stderr, format="{time} | {level} | {file}:{line} | {message}")

输出的日志如下:

2023-10-10 12:00:00 | INFO | example.py:10 | This is a log message

若 Loguru 的内置字段不满足要求,还可以使用 bind() 方法绑定额外的上下文信息,然后通过 {extra} 字段显示这些信息。

logger.add(sys.stderr, format="{time} | {level} | {message} | {extra}")

context_logger = logger.bind(user_id=123, request_id="abc123")
context_logger.info("User logged in")

输出的日志如下:

2023-10-10 12:00:00 | INFO | User logged in | {'user_id': 123, 'request_id': 'abc123'}

Loguru 还支持根据日志级别或其他条件动态调整日志格式:

# 错误日志显示为红色。其他日志显示为默认格式。
def formatter(record):
    if record["level"].name == "ERROR":
        return "<red>{time} | {level} | {message}</red>\n"
    return "{time} | {level} | {message}\n"

logger.add(sys.stderr, format=formatter)

catch 装饰器

Loguru 支持使用装饰器的方式捕获方法可能出现的异常:

from loguru import logger


@logger.catch
def risky_function():
    return 1 / 0  # 这会引发一个异常


risky_function()

当异常发生时,Loguru 会捕获异常并记录详细的错误信息,包括堆栈跟踪。

日志颜色

Loguru 会根据日志的级别(如 INFO、WARNING、ERROR 等)自动为日志消息添加颜色。例如,错误日志可能是红色的,而信息日志可能是绿色的。同时,Loguru 也支持自定义日志样式:

from loguru import logger

# 自定义日志格式,使用标记标签定义颜色和样式
logger.add(sys.stderr, format="<green>{time}</green> <level>{message}</level>")

logger.info("This is an info message")
logger.warning("This is a warning message")
logger.error("This is an error message")

Loguru 支持的样式包括:

  • <bold>:加粗文本
  • <dim>:暗淡文本
  • <italic>:斜体文本
  • <underline>:下划线文本
  • <strike>:删除线文本
  • <black><red><green><yellow><blue><magenta><cyan><white>:文本颜色
  • <bg-black><bg-red><bg-green><bg-yellow><bg-blue><bg-magenta><bg-cyan><bg-white>:背景颜色
logger.add(sys.stderr, format="<red><bold>{time}</bold></red> <blue>{message}</blue>")

线程安全

默认情况下,Loguru 是线程安全的,但不是多线程安全的。在多线程环境中,可以将日志信息添加到队列中,确保捕获到的日志信息的完整性。

from loguru import logger

# 添加一个文件接收器,并启用队列以确保多进程安全或异步日志记录
logger.add("output.log", enqueue=True)

enqueue=True:将日志消息放入队列中,确保线程安全、多进程安全以及异步日志记录。日志消息会被后台线程处理,不会阻塞主程序的执行。

序列化

使用 serialize=True参数,Loguru 会将日志消息转换为 JSON 格式。转换后的格式保留了日志的完整结构化信息,便于后续处理和分析。

from loguru import logger

# 添加一个文件接收器,并启用序列化
logger.add("output.log", serialize=True)

# 记录一些日志
logger.info("This is a serialized log message")
logger.error("An error occurred", details={"code": 500, "message": "Internal Server Error"})

日志文件output.log中的内容将是 JSON 格式,例如:

{
  "text": "2023-10-10 12:00:00 | INFO     | __main__:<module>:1 - This is a serialized log message\n",
  "record": {
    "elapsed": {
      "repr": "0:00:00.000123",
      "seconds": 0.000123
    },
    "exception": null,
    "extra": {},
    "file": {
      "name": "example.py",
      "path": "/path/to/example.py"
    },
    "function": "<module>",
    "level": {
      "icon": "ℹ️",
      "name": "INFO",
      "no": 20
    },
    "line": 1,
    "message": "This is a serialized log message",
    "module": "example",
    "name": "__main__",
    "process": {
      "id": 1234,
      "name": "MainProcess"
    },
    "thread": {
      "id": 12345,
      "name": "MainThread"
    },
    "time": {
      "repr": "2023-10-10 12:00:00",
      "timestamp": 1696939200.0
    }
  }
}
{
  "text": "2023-10-10 12:00:01 | ERROR    | __main__:<module>:2 - An error occurred\n",
  "record": {
    "elapsed": {
      "repr": "0:00:01.000456",
      "seconds": 1.000456
    },
    "exception": null,
    "extra": {
      "details": {
        "code": 500,
        "message": "Internal Server Error"
      }
    },
    "file": {
      "name": "example.py",
      "path": "/path/to/example.py"
    },
    "function": "<module>",
    "level": {
      "icon": "❌",
      "name": "ERROR",
      "no": 40
    },
    "line": 2,
    "message": "An error occurred",
    "module": "example",
    "name": "__main__",
    "process": {
      "id": 1234,
      "name": "MainProcess"
    },
    "thread": {
      "id": 12345,
      "name": "MainThread"
    },
    "time": {
      "repr": "2023-10-10 12:00:01",
      "timestamp": 1696939201.0
    }
  }
}

惰性求值

Loguru 的惰性求值(Lazy Evaluation)是一种优化日志记录性能的技术。通过惰性求值,Loguru 可以确保只有在日志实际需要记录时,才会执行这些昂贵的操作,从而避免不必要的性能开销。

惰性求值的核心思想有两点:

  1. 延迟执行:在日志记录时,如果某些操作(如调用复杂函数或生成大量数据)非常耗时,但这些日志可能在生产环境中不会被记录(例如日志级别较高时),惰性求值可以延迟这些操作的执行。只有在日志级别允许记录时,才会真正执行这些操作。
  2. 性能优化:避免在不必要的情况下执行昂贵的操作,从而提高程序的运行效率。

Loguru 提供了opt(lazy=True)方法来实现惰性求值。通过将日志参数封装为可调用对象(如函数或 lambda 表达式),Loguru 会在需要记录日志时才执行这些操作。

from loguru import logger

# 一个耗时的函数
def expensive_operation():
    print("Expensive operation is running...")
    return "Expensive data"

# 普通日志记录(即使日志级别高于 INFO,expensive_operation() 也会被执行)
logger.info("Data: {}", expensive_operation())

# 使用 opt(lazy=True) 实现惰性求值(只有在日志级别允许时才会执行 expensive_operation())
logger.opt(lazy=True).info("Data: {}", expensive_operation)

如果日志级别高于 INFO(如 WARNING 或 ERROR)时,expensive_operation() 不会被执行,日志也不会记录。如果日志级别为 INFO 或更低,输出的结果如下:

Expensive
operation is running...
2023 - 10 - 10
12: 00:00 | INFO | Data: Expensive
data

惰性求值特别适用于需要记录复杂、耗时操作结果的场景,同时避免在生产环境中执行不必要的操作,比如:

  1. 调试场景:在开发环境中记录详细的调试信息,但在生产环境中避免执行这些操作。
  2. 复杂计算:记录某些需要复杂计算的结果,但只在需要时执行计算。
  3. 大数据生成:记录大量数据时,避免在不必要的情况下生成数据。

状态管理

Loguru 提供了disable()enable()方法,用于控制日志记录器的启用和禁用状态。在项目开发和调试过程中,状态的管理十分重要,可以灵活的配置日志的输出,使调试环境和生产环境日志的输出有所区别。

  • disable()方法用于禁用指定模块或日志记录器的日志输出。禁用后,日志函数(如logger.info())将不会执行任何操作。
  • enable()方法用于启用指定模块或日志记录器的日志输出。启用后,日志函数将正常执行并输出日志。
from loguru import logger

# 禁用当前模块的日志记录器
logger.disable(__name__)
# 这条日志不会输出
logger.info("This log message will not be displayed")

# 启用指定模块的日志记录器
logger.enable("my_library")
# 这条日志会正常输出
logger.info("This log message will be displayed")

在某些情况下,还可以根据条件动态启用或禁用日志记录器:

from loguru import logger

# 根据条件启用或禁用日志记录器
if debug_mode:
    logger.enable("my_module")
else:
    logger.disable("my_module")

# 日志输出取决于 debug_mode 的值
logger.info("This log message depends on debug_mode")

Python 语言进阶 —— 多线程

基本概念

在 Python 中,多线程是一种并发编程的技术,允许程序同时执行多个线程。多线程可以使程序在执行多个任务时更有效地利用 CPU 资源,并且可以提升程序的响应能力。线程是操作系统能够进行运算调度的最小单位 ,它被包含在进程之中,是进程中的实际运作单位。多线程指的是在同一个程序中同时运行多个线程。在Python编程中,多线程是一种常用的并发编程方式,它可以有效地提高程序的执行效率,特别是在处理I/O密集型任务时。

一个线程完整的生命周期主要由五个状态构成:创建、就绪、运行、阻塞、死亡。

新建 一个线程后,调用其 start() 方法后,线程对象就处于就绪状态。线程什么时候运行取决于何时获得 CPU 的调度资源。只有当线程获取到 CPU 的调度资源后,线程的状态才会从 就绪 状态变成 运行 状态。当处于运行的线程,会由于自身原因(比如调用了 sleep 方法)或在外部因素(阻塞式的 I/O 方法)导致线程变成 阻塞 状态。只有当 阻塞 的因素被解除,线程才会由 阻塞 状态再次变成 就绪 状态。当线程执行完毕或者异常退出,表明线程已经进入到了 死亡 状态,随后线程对象被系统销毁被释放内存。

Q:为什么是并发和并行技术?

并发(Concurrency):指多个任务在重叠的时间段内启动、运行和完成,但不一定同时进行。并发可以让程序在处理多个任务时更具响应性。 并行(Parallelism):指多个任务同时执行。在多核 CPU 上,线程可以真正地同时执行,从而提高计算性能。

主线程和子线程

通常,我们所说的多线程实际上值得就是在主线程中运行多个子线程,而主线程默认是我们 Python 编译器执行的线程。所有子线程和主线程都同属于一个进程。在未添加子线程的情况下,默认就只有一个主线程在运行。

创建一个线程

Python 标准库提供了线程模块 threading,该模块 Thread 对线程的封装,提供了简单的实现接口。想要创建一个线程,需要创建 Thread 实例,然后调用它的 .start() 方法启动这个线程。

Example 1
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import threading
import time


def my_thread_function(name):
    print(f'Thread {name}: running')
    time.sleep(5)
    print(f'Thread {name}: done')


if __name__ == '__main__':
    print('Main: before creating thread')
    x = threading.Thread(target=my_thread_function, args=('1',))
    print('Main: before running thread')
    x.start()
    time.sleep(1)
    print('Main: after running thread')
    print('Main: all done')

上述程序使用函数 my_thread_function()args=('1',) 创建了一个 Thread 实例。当执行上述程序,输出如下:

Main: before creating thread
Main: before running thread
Thread 1: running
Main: after running thread
Main: all done
Thread 1: done

Process finished with exit code 0

当主函数结束后,程序需要等待子线程结束。只有当子线程结束后,整个程序才结束。

守护线程

在计算机科学中,daemon 指的是后台运行的程序。这个后台运行的程序我们称为守护线程程序。在 Python 中,threading 模块对 daemon 线程有着具体的含义。当程序退出时的时候,daemon 线程会立刻退出。当程序没有被设置成 daemon 线程的时候,当程序在终止之前,会等待所有的子线程退出才会退出。

在 Example 1 的示例中,当主线程输出 Main: all done 时,表明线程已经结束。但是由于子线程还没有结束,所以需要等待子线程输出 Thread 1: done 表明子线程结束后,整个程序才退出 Process finished with exit code 0

如果查看 treading 模块的源码,可以看到在 threading._shutdown() 会遍历所有正在运行的线程,并在每一个没有设置 daemon 标志的线程上调用 .join() 方法。因此,程序在退出时会等待,因为线程本身正在 sleep(time.sleep(5) )中。一旦完成并打印了消息,.join() 将返回,程序才可以退出。所以,当希望主线程结束后,子线程也随之结束,只需要在创建线程时,添加 daemon 属性即可。

Example 2
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import threading
import time


def my_thread_function(name):
    print(f'Thread {name}: running')
    time.sleep(5)
    print(f'Thread {name}: done')


if __name__ == '__main__':
    print('Main: before creating thread')
    x = threading.Thread(target=my_thread_function, args=('1',), daemon=True)
    print('Main: before running thread')
    x.start()
    time.sleep(1)
    print('Main: after running thread')
    print('Main: all done')

上述代码在创建线程时,标记了 daemon 的值为 True。所以,当主线程退出后,子线程也随之退出。

Main: before creating thread
Main: before running thread
Thread 1: running
Main: after running thread
Main: all done

Process finished with exit code 0

join() 方法

当一个线程被标记为守护线程后,希望主线程可以等待这个子线程退出后才退出,那么可以使用 .join() 方法完成这个功能。

Example 3
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import threading
import time


def my_thread_function(name):
    print(f'Thread {name}: running')
    time.sleep(5)
    print(f'Thread {name}: done')


if __name__ == '__main__':
    print('Main: before creating thread')
    x = threading.Thread(target=my_thread_function, args=('1',), daemon=True)
    print('Main: before running thread')
    x.start()
    time.sleep(1)
    print('Main: after running thread')
    x.join()
    print('Main: all done')

要让一个线程等待另一个线程完成,可以调用.join(),该语句将一直等待,直到每个线程都完成。上述代码执行后,输出的结果如下:

Main: before creating thread
Main: before running thread
Thread 1: running
Main: after running thread
Thread 1: done
Main: all done

Process finished with exit code 0

使用多线程

在实际的开发中,一般会启动数个子线程来完成不同或者相同的任务。Example 4 模拟在后台启动 5 个线程:

Example 4
 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
import logging
import threading
import time


def my_thread_function(name):
    logging.info(f'Thread {name}: running')
    time.sleep(5)
    logging.info(f'Thread {name}: Exit')


if __name__ == '__main__':
    format = "%(asctime)s: %(message)s"
    logging.basicConfig(format=format, level=logging.INFO,
                        datefmt="%H:%M:%S")

    threads = list()
    for i in range(5):
        logging.info(f'Main: Creating Thread {i}')
        x = threading.Thread(target=my_thread_function, args=(i,), daemon=True)
        logging.info(f'Main: Creating Thread {i} successfully')
        threads.append(x)
        logging.info(f'Main: Starting Thread {i}')
        x.start()

    for i, thread in enumerate(threads):
        logging.info(f'Main: Call join() of Thread {i}')
        thread.join()

    logging.info('Main: Exit')

在主线程中,第 13-14 行初始化了日志信息,目的是为了方便后续代码的输出能够更加的观察到主线程和子线程的状态。第 17 行创建了一个 list 列表用于保存线程,目的是可以在主线程中调用这些子线程的 .join 方法,让主线程可以等待子线程结束。第 20 行通过遍历的方式依次创建子线程,并将它们设置成守护线程,然后把这些线程保存到 list 列表中。第 24 行调用 start 方法启动子线程。第 26-28 行,依次获取到创建的子线程,然后调用 .join 方法。

 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
12:57:54: Main: Creating Thread 0
12:57:54: Main: Creating Thread 0 successfully
12:57:54: Main: Starting Thread 0
12:57:54: Thread 0: running
12:57:54: Main: Creating Thread 1
12:57:54: Main: Creating Thread 1 successfully
12:57:54: Main: Starting Thread 1
12:57:54: Thread 1: running
12:57:54: Main: Creating Thread 2
12:57:54: Main: Creating Thread 2 successfully
12:57:54: Main: Starting Thread 2
12:57:54: Thread 2: running
12:57:54: Main: Creating Thread 3
12:57:54: Main: Creating Thread 3 successfully
12:57:54: Main: Starting Thread 3
12:57:54: Thread 3: running
12:57:54: Main: Creating Thread 4
12:57:54: Main: Creating Thread 4 successfully
12:57:54: Main: Starting Thread 4
12:57:54: Thread 4: running
12:57:54: Main: Call join() of Thread 0
12:57:59: Thread 0: Exit
12:57:59: Main: Call join() of Thread 1
12:57:59: Thread 2: Exit
12:57:59: Thread 1: Exit
12:57:59: Main: Call join() of Thread 2
12:57:59: Main: Call join() of Thread 3
12:57:59: Thread 3: Exit
12:57:59: Thread 4: Exit
12:57:59: Main: Call join() of Thread 4
12:57:59: Main: Exit

Process finished with exit code 0

仔细观察,可以发现子线程的退出似乎顺序错乱了。尤其是第 24-25 行,明明是 Thread 1 先创建,Thread 2 后创建,但是 Thread 2 却先退出,而 Thread 1 后退出。这是因为线程的运行顺序是由系统决定的,很难预测系统在调度过程中,哪个线程会被调度。

ThreadPoolExecutor

从 Python 3.2 开始,可以使用 ThreadPoolExecutor 多线程管理类降低线程创建的复杂性。创建它的最简单方法是使用上下文管理器的 with 语句,用它实现对线程池的创建和销毁。

Example 5
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import concurrent.futures
import logging
import time


def my_thread_function(name):
    logging.info(f'Thread {name}: running')
    time.sleep(5)
    logging.info(f'Thread {name}: Exit')


if __name__ == '__main__':
    format = "%(asctime)s: %(message)s"
    logging.basicConfig(format=format, level=logging.INFO,
                        datefmt="%H:%M:%S")
    with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
        executor.map(my_thread_function, range(10))

第 16-17 行,使用 ThreadPoolExecutor 作为上下文管理器,设置最大线程数量为 5 个。使用 map 方法将my_thread_function 函数应用到 range(10)生成的 10 个任务上。这会创建 10 个线程,每个线程执行一次my_thread_function,并传递从 0 到 9 的数字作为参数(即name的值)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
13:18:03: Thread 0: running
13:18:03: Thread 1: running
13:18:03: Thread 2: running
13:18:03: Thread 3: running
13:18:03: Thread 4: running
13:18:08: Thread 0: Exit
13:18:08: Thread 5: running
13:18:08: Thread 1: Exit
13:18:08: Thread 6: running
13:18:08: Thread 2: Exit
13:18:08: Thread 7: running
13:18:08: Thread 3: Exit
13:18:08: Thread 8: running
13:18:08: Thread 4: Exit
13:18:08: Thread 9: running
13:18:13: Thread 5: Exit
13:18:13: Thread 6: Exit
13:18:13: Thread 9: Exit
13:18:13: Thread 7: Exit
13:18:13: Thread 8: Exit

Process finished with exit code 0

同样的,在执行过程中线程的调度顺序是由系统决定的。

竞态条件

竞态条件(Race Condition)是指在并发编程中,当多个线程或进程对共享资源进行操作时,由于执行顺序不确定,导致程序行为不可预期的情况。特别是当操作的顺序依赖于线程的调度和执行速度时,竞态条件会引发错误或不一致的结果。

Example 6 模拟了一个竞态条件的例子。定义一个 FakeDatabase 模拟更新数据库,然后在主线程中开启 2 个线程操作数据库:

Example 6
 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
import concurrent.futures
import logging
import time


class FakeDatabase:
    def __init__(self):
        self.value = 0

    def update(self, name):
        logging.info("Thread %s: starting update", name)
        local_copy = self.value
        local_copy += 1
        time.sleep(0.1)
        self.value = local_copy
        logging.info("Thread %s: finishing update", name)


if __name__ == "__main__":
    format = "%(asctime)s: %(message)s"
    logging.basicConfig(format=format, level=logging.INFO,
                        datefmt="%H:%M:%S")

    database = FakeDatabase()
    logging.info("Testing update. Starting value is %d.", database.value)
    with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
        for index in range(2):
            executor.submit(database.update, index)
    logging.info("Testing update. Ending value is %d.", database.value)

由于两个线程几乎同时访问和修改 FakeDatabasevalue 属性,它们会读取相同的 value 值并对其进行更新。这样,线程间的竞争条件可能会导致 value 的最终值不如预期(预期的值是 2):

1
2
3
4
5
6
13:24:06: Testing update. Starting value is 0.
13:24:06: Thread 0: starting update
13:24:06: Thread 1: starting update
13:24:06: Thread 0: finishing update
13:24:06: Thread 1: finishing update
13:24:06: Testing update. Ending value is 1.

锁实现同步

为了解决竞态条件出现结果不符合预期的情况,通常需要使用同步机制,如锁(Locks)来确保对共享资源的操作是原子的和一致的。Python 中把锁使用 Lock 方法代替,在其他语言中类似的操作出称为 MutexMutex 源于MUTual EXclusion,与 Python 中的 Lock 作用相同。Lock 宛如通行证,一次只能有一个线程拥有Lock,任何其他想要 Lock 的线程都必须等到 Lock 持有者释放。

在 Python 中,可以使用 with 语句简化锁的获取 .acquire() 和 释放 .release() 操作。当 with 语句执行时自动获取锁,当 with 中的代码块全部执行完成后,自动释放锁。

Example 7
 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
import concurrent.futures
import logging
import threading
import time


class FakeDatabase:
    def __init__(self):
        self.value = 0
        self.lock = threading.Lock()

    def locked_update(self, name):
        logging.info("Thread %s: starting update", name)
        with self.lock:
            logging.info(f'Thread {name} has lock.')
            local_copy = self.value
            local_copy += 1
            time.sleep(0.1)
            self.value = local_copy
            logging.info(f'Thread {name} release lock.')

        logging.info("Thread %s: finishing update", name)


if __name__ == "__main__":
    format = "%(asctime)s: %(message)s"
    logging.basicConfig(format=format, level=logging.INFO,
                        datefmt="%H:%M:%S")

    database = FakeDatabase()
    logging.info("Testing update. Starting value is %d.", database.value)
    with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
        for index in range(2):
            executor.submit(database.locked_update, index)
    logging.info("Testing update. Ending value is %d.", database.value)

第 10 行定义了一个成员变量保存线程的锁。第 14-19 行,对操作数据的代码添加锁,避免多线程同时操作数据源。也就是说,当运行到第 14-19 行代码时,有且仅有一个进程可以获取到这个锁,另外一个线程没有获取到锁,只能够等待锁的释放后,才可以继续执行。

死锁

死锁(Deadlock)是指两个或多个线程在等待彼此释放资源,从而导致程序无法继续执行的情况。每个线程持有对方需要的资源,但这些资源都被锁住,从而形成一个循环等待的状态。

Example 8
 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
import threading
from time import sleep

lock1 = threading.Lock()
lock2 = threading.Lock()


def fun1(name):
    with lock1:
        print(f'Thread {name} acquired lock1')
        sleep(1)
        print(f'Thread {name} wait for lock2')
        with lock2:
            print(f'Thread {name} Acquired lock2')
        print(f'Thread {name} released lock2')
    print(f'Thread {name} released lock1')


def fun2(name):
    with lock2:
        print(f'Thread {name} acquired lock2')
        sleep(1)
        print(f'Thread {name} wait for lock1')
        with lock1:
            print(f'Thread {name} Acquired lock1')
        print(f'Thread {name} released lock1')
    print(f'Thread {name} released lock2')


if __name__ == "__main__":
    t1 = threading.Thread(target=fun1, args=('T1',))
    t2 = threading.Thread(target=fun2, args=('T2',))

    t1.start()
    t2.start()

    t1.join()
    t2.join()

Example 8 示例中,thread1 先获取 lock1,然后尝试获取 lock2。而 thread2 先获取 lock2,然后尝试获取 lock1 。如果两个线程几乎同时执行,可能会导致死锁,因为 thread1 等待 thread2 释放 lock2,而 thread2 等待 thread1 释放 lock1,两者形成循环等待。

Thread T1 acquired lock1
Thread T2 acquired lock2
Thread T1 wait for lock2
Thread T2 wait for lock1
// 程序无法停止,一直等待

为了避免死锁,可以从以下这些角度出发:

  1. 避免嵌套锁:尽量避免在一个锁的持有状态下请求其他锁。
  2. 锁顺序:确保所有线程以相同的顺序请求锁,以避免循环等待。
  3. 使用超时:使用锁的超时机制(如 acquire(timeout=...))来防止无限期地等待锁。

Example 8 可以修改锁的顺序,从而避免发生死锁:

Example 8
 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
import threading
from time import sleep

lock1 = threading.Lock()
lock2 = threading.Lock()


def fun1(name):
    with lock1:
        print(f'Thread {name} acquired lock1')
        sleep(1)
        print(f'Thread {name} wait for lock2')
        with lock2:
            print(f'Thread {name} Acquired lock2')
        print(f'Thread {name} released lock2')
    print(f'Thread {name} released lock1')


def fun2(name):
    with lock1:
        print(f'Thread {name} acquired lock1')
        sleep(1)
        print(f'Thread {name} wait for lock2')
        with lock2:
            print(f'Thread {name} Acquired lock2')
        print(f'Thread {name} released lock2')
    print(f'Thread {name} released lock1')


if __name__ == "__main__":
    t1 = threading.Thread(target=fun1, args=('T1',))
    t2 = threading.Thread(target=fun2, args=('T2',))

    t1.start()
    t2.start()

由于修改了获取锁的顺序,每个线程都在尝试以相同的顺序获取锁:lock1 然后 lock2 。虽然在某些情况下,可能会有线程在获取一个锁时被阻塞,但由于锁的顺序是一致的,它们不会形成循环等待的状态。

在这段代码中,如果两个线程几乎同时启动,它们可能会这样执行:

  • t1 获取 lock1
  • t2 尝试获取 lock1,但 t1 已持有此锁,因此 t2 会阻塞。
  • t1sleep 后尝试获取 lock2
  • 一旦 t1 成功获取 lock2,它继续执行并释放 lock2lock1
  • t2t1 释放 lock1 后获取 lock1,然后尝试获取 lock2
  • 由于 t1 已经释放了 lock2t2 成功获取 lock2

因此,尽管每个线程都在请求相同的锁,但是因为它们按照相同的顺序获取锁,并且没有形成循环等待,因此代码中的锁操作不会导致死锁。

Python 语言进阶 —— tempfile

介绍

tempfilePython 标准库 中的一个用于创建临时文件和临时目录的模块。该模块支持所有的平台。该模块提供了如下的高阶接口:TemporaryFileNamedTemporaryFileTemporaryDirectorySpooledTemporaryFile。 因为这些高级接口实现了 __enter____exit__ 两个特殊方法,所以它们可以和上下文管理器一同使用,用于在结束时清理这些临时文件。

同时,tempfile 模块也提供了手动清理的一些低级接口:mkstempmkdtemp

创建临时文件

tempfile 模块中可以使用 TemporaryFileNamedTemporaryFilemkstemp 创建临时文件。

TemporaryFile

TemporaryFile 的定义如下:

def TemporaryFile(mode='w+b', buffering=-1, encoding=None,
                  newline=None, suffix=None, prefix=None,
                  dir=None, errors=None):

TemporaryFile 返回的是一个类文件对象,支持文件的 I/O,用于临时数据的保存。默认模式为 w+b ,表示以二进制模式读写文件。也可以设置成 w+t 模式将文本数据写入到临时文件。TemporaryFile 函数中的 prefixsuffix 可以设置文件的前缀和后缀。TemporaryFile 生成的对象可以用作上下文管理器,当完成操作后,临时文件会从系统删除。

import tempfile


def main():
    with tempfile.TemporaryFile(dir='./', prefix='test-', suffix='_txt') as tf:
        print(tf)
        print(tf.name)

        tf.write(b'Hello World')
        tf.seek(0)
        print(tf.read())


if __name__ == '__main__':
    main()

运行代码后,输出:

<tempfile._TemporaryFileWrapper object at 0x000002A1EA66CB50>
D:\Projects\Python\PythonDemos\test-32w35j6z_txt
b'Hello World'

mode 参数默认值为 w+b,写字符串时每次都需要转换成二进制写入。可以设置为 w+ 模式,这样就可以直接写入字符串类型。

NamedTemporaryFile

NamedTemporaryFile 的定义如下:

def NamedTemporaryFile(mode='w+b', buffering=-1, encoding=None,
                       newline=None, suffix=None, prefix=None,
                       dir=None, delete=True, errors=None):

NamedTemporaryFile 的用法和 TemporaryFile 完全相同,不同的是使用 NamedTemporaryFile 创建临时文件在文件系统上是可见的。可以从返回的类文件对象的 name 属性中检索该文件名。当参数 deleteTrue 时,表示一旦文件关闭就会被删除。

import os.path
import tempfile


def main():
    with tempfile.NamedTemporaryFile(dir='./', prefix='test-', suffix='_txt', delete=False) as ntf:
        ntf.write(b'Hello World')
        ntf.seek(0)
        print(ntf.read())

    print(os.path.exists(ntf.name))


if __name__ == '__main__':
    main()

运行代码后,输出;

b'Hello World'
True

SpooledTemporaryFile

SpooledTemporaryFile 的定义如下:

class SpooledTemporaryFile(_io.IOBase):
    """Temporary file wrapper, specialized to switch from BytesIO
    or StringIO to a real file when it exceeds a certain size or
    when a fileno is needed.
    """

    def __init__(self, max_size=0, mode='w+b', buffering=-1,
                 encoding=None, newline=None,
                 suffix=None, prefix=None, dir=None, errors=None):

SpooledTemporaryFile 用法与 NamedTemporaryFileTemporaryFile 基本相同。但是,SpooledTemporaryFile 会将数据缓存在内存中,直到超过 max_size 或调用文件的 fileno() 方法时,才会将数据写入到磁盘。

mktemp

Danger

mktemp 创建文件是不安全的。因为在调用 mktemp() 和随后的第一个进程尝试创建文件之间的时间内,不同的进程可能会创建一个具有此名称的文件。 解决方法是将这两个步骤结合起来,立即创建文件。自 2.3 版起已弃用: 改用 mkstemp()

mktemp 的定义如下:

def mktemp(suffix="", prefix=template, dir=None):

mktemp 函数中的参数含义如下:

  • suffix:指定临时文件名的后缀信息。
  • prefix:指定临时文件名的前缀信息。
  • dir:指定临时文件默认保存的路径。
tmp = tempfile.mktemp(dir='./')
print(tmp)

with open(tmp, 'w+') as f:  # mktemp 只生成路径,并没有生成文件,需要手动调用 open 打开文件
    f.write('hello world')
    f.seek(0)
    print(f.read())

运行代码后,输出:

./tmpi6kodjcn
hello world

mkstemp

mkstemp 的定义如下:

def mkstemp(suffix=None, prefix=None, dir=None, text=False)

mktemp 是不安全的,但是 mkstemp 是安全的。

TemporaryFile() 不同,mkstemp() 的用户负责在完成后删除临时文件。mkstemp() 返回一个包含操作系统级句柄的元组到一个打开的文件(由 os.open() 返回)和该文件的绝对路径名,系统级句柄表示的是文件的安全级别。

temp = tempfile.mkstemp()
print(temp)

fd = temp[1]
with open(fd, 'w+') as f:
    f.write('hello world')
    f.seek(0)
    print(f.read())

运行代码后,输出:

(3, 'C:\\Users\\harve\\AppData\\Local\\Temp\\tmpk01c8r2o')
hello world

创建临时目录

TemporaryDirectory

TemporaryFilemkdtemp() 一样,都是以安全的方式创建对象。TemporaryFile 生成的对象可以用作上下文管理器,完成上下文或销毁临时目录对象后,将从文件系统中删除创建的临时目录。

通过 TemporaryFile 返回对象的 name 属性获取到临时目录的名称。 当返回的对象用作上下文管理器时,name 将分配给 with 语句中 as 子句的目标。可以通过调用 cleanup() 方法显示清理目录。

mkdtemp

以最安全的方式创建临时目录。 目录的创建中没有竞争条件。 该目录只能通过创建用户 ID 进行读取、写入和搜索。mkdtemp() 的用户负责删除临时目录及其内容。

其他方法

tempfile 模块中,还提供了一些其他方法:

  1. gettempdir():返回用于临时文件的目录的名称。这定义了此模块中所有函数的 dir 参数的默认值。
  2. gettempdirb():与 gettempdir() 相同,但返回值以字节为单位。
  3. gettempprefix():返回用于创建临时文件的文件名前缀。 这不包含目录组件。
  4. gettempprefixb():与 gettempprefix() 相同,但返回值以字节为单位。
  5. tempdir():当设置为 None 以外的值时,此变量定义了此模块中定义的函数的 dir 参数的默认值。