├── .circleci
└── config.yml
├── .gitea
└── workflows
│ ├── build-python-wheels_on_linux.yml
│ └── build-python-wheels_on_win.yml
├── .github
└── workflows
│ ├── build_wheels_centos8.yml
│ ├── build_wheels_linux(musl).yml
│ ├── build_wheels_linux.yml
│ ├── build_wheels_macos.yml
│ └── build_wheels_windows.yml
├── .gitignore
├── Cargo.toml
├── README.md
├── build.sh
├── img
├── architecture.png
├── atomic-bomb-engine-logo.png
└── img.png
├── pyproject.toml
├── python
└── atomic_bomb_engine
│ ├── __init__.py
│ ├── __init__.pyi
│ ├── dist
│ └── .gitkeep
│ ├── middleware.py
│ └── server.py
└── src
├── lib.rs
├── py_lib
├── assert_option_func.rs
├── batch_runner.rs
├── endpoint_func.rs
├── jsonpath_extract_func.rs
├── mod.rs
├── multipart_option_func.rs
├── setup_option_func.rs
├── step_option_func.rs
└── think_time_option_func.rs
└── utils
├── create_api_results_dict.rs
├── create_assert_err_dict.rs
├── create_http_err_dict.rs
├── mod.rs
├── parse_api_endpoints.rs
├── parse_assert_options.rs
├── parse_multipart_options.rs
├── parse_setup_options.rs
└── parse_step_options.rs
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2.1
2 |
3 | workflows:
4 | version: 2
5 | build_and_deploy:
6 | jobs:
7 | - build-wheels:
8 | filters:
9 | branches:
10 | only:
11 | - release
12 |
13 | jobs:
14 | build-wheels:
15 | macos:
16 | xcode: "11.3.0"
17 | steps:
18 | - checkout
19 |
20 | - run:
21 | name: Checkout frontend
22 | command: |
23 | git clone https://github.com/GiantAxeWhy/atomic-bomb-engine-front vue-project
24 |
25 | - run:
26 | name: Start Alpine container
27 | command: |
28 | docker run --name python-alpine-container -d -v "${PWD}:/__w" -w "/__w" alpine:latest sleep 3600
29 |
30 | - run:
31 | name: Install Node.js and build tools
32 | command: |
33 | docker exec python-alpine-container apk add --no-cache nodejs npm build-base
34 |
35 | - run:
36 | name: Build frontend
37 | command: |
38 | docker exec python-alpine-container sh -c "cd vue-project && npm install && npm run build"
39 |
40 | - run:
41 | name: Install pyenv and specific Python versions
42 | command: |
43 | docker exec python-alpine-container apk add --no-cache bash git curl openssl-dev bzip2-dev zlib-dev readline-dev sqlite-dev
44 | docker exec python-alpine-container sh -c "curl https://pyenv.run | bash"
45 | docker exec python-alpine-container sh -c 'export PATH="/root/.pyenv/bin:$PATH" && eval "$(pyenv init --path)" && for version in $(echo 3.8 3.9 3.10 3.11 3.12); do pyenv install $version; pyenv global $version; curl https://bootstrap.pypa.io/get-pip.py | python; done'
46 |
47 | - run:
48 | name: Install Rust, maturin, and patchelf
49 | command: |
50 | docker exec python-alpine-container apk add --no-cache rust cargo patchelf
51 | docker exec python-alpine-container sh -c 'export PATH="/root/.pyenv/shims:$PATH" && pip install maturin'
52 |
53 | - run:
54 | name: Build wheels using maturin
55 | command: |
56 | docker exec python-alpine-container sh -c '
57 | export PATH="/root/.pyenv/bin:$PATH" && eval "$(pyenv init --path)"
58 | for version in $(echo 3.8 3.9 3.10 3.11 3.12); do
59 | pyenv global $version
60 | maturin build --release
61 | done'
62 |
63 | - run:
64 | name: Copy wheels to Runner
65 | command: docker cp python-alpine-container:/__w/target/wheels ./
66 |
67 | - run:
68 | name: Install Twine
69 | command: pip install twine
70 |
71 | - run:
72 | name: Upload wheels to PyPI
73 | command: twine upload --skip-existing --repository-url https://upload.pypi.org/legacy/ wheels/*.whl
74 | environment:
75 | TWINE_USERNAME: __token__
76 | TWINE_PASSWORD: $PYPI_PASSWORD
77 |
--------------------------------------------------------------------------------
/.gitea/workflows/build-python-wheels_on_linux.yml:
--------------------------------------------------------------------------------
1 | name: Build Python Wheels on Linux
2 | on:
3 | push:
4 | branches:
5 | - release
6 | jobs:
7 | build:
8 | runs-on: linux_amd64
9 |
10 | env:
11 | http_proxy: http://10.0.0.54:1080
12 | https_proxy: http://10.0.0.54:1080
13 | all_proxy: socks5://10.0.0.54:1080
14 |
15 | steps:
16 | - name: 签出代码
17 | uses: actions/checkout@v4
18 |
19 | - name: 设置cargo环境变量
20 | run: echo "PATH=$PATH:/home/qyzhg/.cargo/bin" >> $GITHUB_ENV
21 |
22 | - name: 构建python包
23 | run: /home/qyzhg/maturin-builder/bin/maturin build --release -i python3.8 -i python3.9 -i python3.10 -i python3.11 -i python3.12
24 |
25 | - name: 上传构建的whl包到PyPI
26 | run: |
27 | /home/qyzhg/maturin-builder/bin/twine upload --repository pypi --config-file /home/qyzhg/.pypirc ./target/wheels/*
28 |
--------------------------------------------------------------------------------
/.gitea/workflows/build-python-wheels_on_win.yml:
--------------------------------------------------------------------------------
1 | name: Build Python Wheels on Windows
2 | on:
3 | push:
4 | branches:
5 | - release
6 | jobs:
7 | build:
8 | runs-on: win_amd64
9 | # Start-Process -FilePath "C:\Users\Administrator\act_runner.exe" -ArgumentList "daemon -c C:\Users\Administrator\.runner"
10 |
11 | env:
12 | http_proxy: http://10.0.0.54:1080
13 | https_proxy: http://10.0.0.54:1080
14 | all_proxy: socks5://10.0.0.54:1080
15 |
16 | steps:
17 | - name: 签出代码
18 | uses: actions/checkout@v3
19 | - name: 构建python包
20 | run: |
21 | cargo --version
22 | C:\Users\Administrator\rust-build\Scripts\maturin.exe build --release --interpreter python3.8 --interpreter python3.9 --interpreter python3.10 --interpreter python3.11 --interpreter python3.12
23 | env:
24 | PYTHONIOENCODING: utf-8
25 | - name: 上传构建的whl包到PyPI
26 | run: |
27 | C:\Users\Administrator\rust-build\Scripts\twine.exe upload --repository pypi --config-file C:\Users\Administrator\.pypirc .\target\wheels\*
28 | env:
29 | PYTHONIOENCODING: utf-8
30 |
--------------------------------------------------------------------------------
/.github/workflows/build_wheels_centos8.yml:
--------------------------------------------------------------------------------
1 | name: Build and publish Python wheels for CentOS 8
2 |
3 | on:
4 | push:
5 | branches:
6 | - build-centos8
7 |
8 | jobs:
9 | build-wheels:
10 | name: Build wheels on CentOS 8
11 | runs-on: ubuntu-latest
12 | strategy:
13 | matrix:
14 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
15 |
16 | steps:
17 | - name: 签出代码
18 | uses: actions/checkout@v2
19 |
20 | - name: 签出前端
21 | uses: actions/checkout@v2
22 | with:
23 | repository: GiantAxeWhy/atomic-bomb-engine-front
24 | path: vue-project
25 |
26 | - name: 启动 Node.js 容器
27 | run: |
28 | docker run --name node-container -d -v "${{ github.workspace }}:/workspace" -w "/workspace/vue-project" node:18 sleep 3600
29 |
30 | - name: 构建前端
31 | run: |
32 | docker exec node-container npm install
33 | docker exec node-container npm run build
34 |
35 | - name: 停止并删除 Node.js 容器
36 | run: |
37 | docker stop node-container
38 | docker rm node-container
39 |
40 | - name: 启动 AlmaLinux 容器
41 | run: |
42 | docker run --name python-almalinux-container -d -v "${{ github.workspace }}:/workspace" -w "/workspace" almalinux:8 sleep 3600
43 |
44 | - name: 安装构建工具
45 | run: |
46 | docker exec python-almalinux-container yum install -y epel-release
47 | docker exec python-almalinux-container yum install -y gcc gcc-c++ make
48 |
49 | - name: 安装 pyenv 和指定版本的 Python
50 | run: |
51 | docker exec python-almalinux-container yum install -y git curl openssl-devel bzip2-devel zlib-devel readline-devel sqlite-devel
52 | docker exec python-almalinux-container sh -c "curl https://pyenv.run | bash"
53 | docker exec python-almalinux-container sh -c 'export PATH="/root/.pyenv/bin:$PATH" && eval "$(pyenv init --path)" && for version in ${{ join(matrix.python-version, ' ') }}; do pyenv install $version; pyenv global $version; curl https://bootstrap.pypa.io/get-pip.py | python; done'
54 |
55 | - name: 安装 Rust、maturin 和 patchelf
56 | run: |
57 | docker exec python-almalinux-container yum remove -y patchelf
58 | docker exec python-almalinux-container yum install -y epel-release
59 | docker exec python-almalinux-container yum install -y https://download-ib01.fedoraproject.org/pub/epel/8/Everything/x86_64/Packages/p/patchelf-0.12-1.el8.x86_64.rpm
60 | docker exec python-almalinux-container patchelf --version
61 | docker exec python-almalinux-container yum install -y rust cargo
62 | docker exec python-almalinux-container sh -c 'export PATH="/root/.pyenv/shims:$PATH" && pip install maturin'
63 |
64 | - name: 使用 maturin 构建 wheels
65 | run: |
66 | docker exec python-almalinux-container sh -c '
67 | export PATH="/root/.pyenv/bin:$PATH" && eval "$(pyenv init --path)"
68 | for version in ${{ join(matrix.python-version, ' ') }}; do
69 | pyenv global $version
70 | maturin build --release --skip-auditwheel
71 | done'
72 |
73 | - name: 复制 wheels 到 Runner
74 | run: docker cp python-almalinux-container:/workspace/target/wheels ./
75 |
76 | - name: 安装 Twine
77 | run: pip install twine
78 |
79 | - name: 上传 wheels 到 PyPI
80 | env:
81 | TWINE_USERNAME: __token__
82 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
83 | run: twine upload --skip-existing --repository-url https://upload.pypi.org/legacy/ wheels/*.whl
84 |
--------------------------------------------------------------------------------
/.github/workflows/build_wheels_linux(musl).yml:
--------------------------------------------------------------------------------
1 | name: Build and publish Python wheels for musl/Linux
2 |
3 | on:
4 | push:
5 | branches:
6 | - release
7 | - release-rc.*
8 |
9 | jobs:
10 | build-wheels:
11 | name: Build wheels on Alpine
12 | runs-on: ubuntu-latest
13 | strategy:
14 | matrix:
15 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
16 |
17 | steps:
18 | - name: 签出代码
19 | uses: actions/checkout@v2
20 |
21 | - name: 签出前端
22 | uses: actions/checkout@v2
23 | with:
24 | repository: GiantAxeWhy/atomic-bomb-engine-front
25 | path: vue-project
26 |
27 | - name: 启动 Alpine 容器
28 | run: |
29 | docker run --name python-alpine-container -d -v "${{ github.workspace }}:/__w" -w "/__w" alpine:latest sleep 3600
30 |
31 | - name: 安装 Node.js 和构建工具
32 | run: |
33 | docker exec python-alpine-container apk add --no-cache nodejs npm build-base
34 |
35 | - name: 构建前端
36 | run: |
37 | docker exec python-alpine-container sh -c "cd vue-project && npm install && npm run build"
38 |
39 | - name: 安装 pyenv 和指定版本的 Python
40 | run: |
41 | docker exec python-alpine-container apk add --no-cache bash git curl openssl-dev bzip2-dev zlib-dev readline-dev sqlite-dev
42 | docker exec python-alpine-container sh -c "curl https://pyenv.run | bash"
43 | docker exec python-alpine-container sh -c 'export PATH="/root/.pyenv/bin:$PATH" && eval "$(pyenv init --path)" && for version in ${{ join(matrix.python-version, ' ') }}; do pyenv install $version; pyenv global $version; curl https://bootstrap.pypa.io/get-pip.py | python; done'
44 |
45 | - name: 安装 Rust、maturin 和 patchelf
46 | run: |
47 | docker exec python-alpine-container apk add --no-cache rust cargo patchelf
48 | docker exec python-alpine-container sh -c 'export PATH="/root/.pyenv/shims:$PATH" && pip install maturin'
49 |
50 | - name: 使用 maturin 构建 wheels
51 | run: |
52 | docker exec python-alpine-container sh -c '
53 | export PATH="/root/.pyenv/bin:$PATH" && eval "$(pyenv init --path)"
54 | for version in ${{ join(matrix.python-version, ' ') }}; do
55 | pyenv global $version
56 | maturin build --release
57 | done'
58 |
59 | - name: 复制 wheels 到 Runner
60 | run: docker cp python-alpine-container:/__w/target/wheels ./
61 |
62 | - name: 安装 Twine
63 | run: pip install twine
64 |
65 | - name: 上传 wheels 到 PyPI
66 | env:
67 | TWINE_USERNAME: __token__
68 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
69 | run: twine upload --skip-existing --repository-url https://upload.pypi.org/legacy/ wheels/*.whl
70 |
--------------------------------------------------------------------------------
/.github/workflows/build_wheels_linux.yml:
--------------------------------------------------------------------------------
1 | name: Build and publish Python wheels for Linux
2 |
3 | on:
4 | push:
5 | branches:
6 | - release
7 | - release-rc.*
8 |
9 | jobs:
10 | build-wheels:
11 | name: Build wheels on Ubuntu
12 | runs-on: ubuntu-latest
13 | strategy:
14 | matrix:
15 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
16 |
17 | steps:
18 | - name: 签出rust代码
19 | uses: actions/checkout@v2
20 |
21 | - name: 签出前端
22 | uses: actions/checkout@v2
23 | with:
24 | repository: GiantAxeWhy/atomic-bomb-engine-front
25 | path: vue-project
26 |
27 | - name: 安装Node.js
28 | uses: actions/setup-node@v2
29 | with:
30 | node-version: '18'
31 |
32 | - name: 构建前端
33 | run: |
34 | cd vue-project
35 | npm install
36 | npm run build
37 |
38 | - name: 删除原有dist
39 | run: rm -rf python/atomic_bomb_engine/dist/*
40 |
41 | - name: 复制dist到Python项目
42 | run: cp -r vue-project/dist/* python/atomic_bomb_engine/dist/
43 |
44 | - name: 设置python环境
45 | uses: actions/setup-python@v2
46 | with:
47 | python-version: ${{ matrix.python-version }}
48 |
49 | - name: 安装Rust
50 | uses: actions-rs/toolchain@v1
51 | with:
52 | profile: minimal
53 | toolchain: stable
54 | override: true
55 |
56 | - name: 安装maturin
57 | run: pip install maturin
58 |
59 | - name: 构建wheels
60 | run: maturin build --release --interpreter python${{ matrix.python-version }}
61 |
62 | - name: 安装Twine
63 | run: pip install twine
64 |
65 | - name: 上传wheels到PyPI
66 | env:
67 | TWINE_USERNAME: __token__
68 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
69 | run: twine upload --skip-existing --repository-url https://upload.pypi.org/legacy/ target/wheels/*.whl
70 |
--------------------------------------------------------------------------------
/.github/workflows/build_wheels_macos.yml:
--------------------------------------------------------------------------------
1 | name: Build and publish Python wheels for macOS
2 |
3 | on:
4 | push:
5 | branches:
6 | - release
7 | - release-rc.*
8 |
9 | jobs:
10 | build-wheels-macos:
11 | # runs-on: macos-latest
12 | runs-on: macos-12
13 | strategy:
14 | matrix:
15 | arch: ['x86_64-apple-darwin', 'aarch64-apple-darwin']
16 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
17 |
18 | steps:
19 | - name: 签出rust代码
20 | uses: actions/checkout@v2
21 |
22 | - name: 签出前端
23 | uses: actions/checkout@v2
24 | with:
25 | repository: GiantAxeWhy/atomic-bomb-engine-front
26 | path: vue-project
27 |
28 | - name: 安装Node.js
29 | uses: actions/setup-node@v2
30 | with:
31 | node-version: '18'
32 |
33 | - name: 构建前端
34 | run: |
35 | cd vue-project
36 | npm install
37 | npm run build
38 |
39 | - name: 删除原有dist
40 | run: rm -rf python/atomic_bomb_engine/dist/*
41 |
42 | - name: 复制dist到Python项目
43 | run: cp -r vue-project/dist/* python/atomic_bomb_engine/dist/
44 |
45 | - name: 设置python环境
46 | uses: actions/setup-python@v5
47 | with:
48 | python-version: ${{ matrix.python-version }}
49 |
50 | - name: 安装Rust
51 | uses: actions-rs/toolchain@v1
52 | with:
53 | profile: minimal
54 | toolchain: stable
55 | override: true
56 |
57 | - name: 为目标架构添加Rust目标
58 | run: |
59 | rustup target add x86_64-apple-darwin
60 | rustup target add aarch64-apple-darwin
61 |
62 | - name: 安装maturin
63 | run: pip install maturin
64 |
65 | - name: 构建wheels
66 | run: maturin build --release --target ${{ matrix.arch }} -i python${{ matrix.python-version }}
67 |
68 | - name: 安装Twine
69 | run: pip install twine
70 |
71 | - name: 上传wheels到PyPI
72 | env:
73 | TWINE_USERNAME: __token__
74 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
75 | run: twine upload --skip-existing --repository-url https://upload.pypi.org/legacy/ target/wheels/*.whl
--------------------------------------------------------------------------------
/.github/workflows/build_wheels_windows.yml:
--------------------------------------------------------------------------------
1 | name: Build and publish Python wheels for Windows
2 |
3 | on:
4 | push:
5 | branches:
6 | - release
7 | - release-rc.*
8 |
9 | jobs:
10 | build-wheels:
11 | name: Build wheels on Windows
12 | runs-on: windows-latest
13 | strategy:
14 | matrix:
15 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
16 |
17 | steps:
18 | - name: 签出rust代码
19 | uses: actions/checkout@v2
20 |
21 | - name: 签出前端
22 | uses: actions/checkout@v2
23 | with:
24 | repository: GiantAxeWhy/atomic-bomb-engine-front
25 | path: vue-project
26 |
27 | - name: 安装Node.js
28 | uses: actions/setup-node@v2
29 | with:
30 | node-version: '18'
31 |
32 | - name: 构建前端
33 | run: |
34 | cd vue-project
35 | npm install
36 | npm run build
37 |
38 | - name: 清空目标路径
39 | run: Remove-Item -Path python\atomic_bomb_engine\dist\* -Recurse -Force
40 |
41 | - name: 复制dist到Python项目
42 | run: Copy-Item vue-project/dist/* python\atomic_bomb_engine\dist\ -Recurse
43 |
44 | - name: 设置python环境
45 | uses: actions/setup-python@v2
46 | with:
47 | python-version: ${{ matrix.python-version }}
48 |
49 | - name: 安装Rust
50 | uses: actions-rs/toolchain@v1
51 | with:
52 | profile: minimal
53 | toolchain: stable
54 | override: true
55 |
56 | - name: 安装maturin
57 | run: pip install maturin
58 |
59 | - name: 构建wheels
60 | run: maturin build --release --interpreter python${{ matrix.python-version }}
61 |
62 | - name: 安装Twine
63 | run: pip install twine
64 |
65 | - name: 上传wheels到PyPI
66 | env:
67 | TWINE_USERNAME: __token__
68 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
69 | run: twine upload --skip-existing --repository-url https://upload.pypi.org/legacy/ target/wheels/*.whl
70 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /target
2 | /.venv
3 | node_modules
4 | .DS_Store
5 |
6 | .idea/
7 |
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "atomic-bomb-engine-py"
3 | version = "0.41.3"
4 | edition = "2021"
5 |
6 | [lib]
7 | name = "atomic_bomb_engine"
8 | crate-type = ["cdylib"]
9 |
10 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
11 |
12 | [dependencies]
13 | atomic-bomb-engine = "0.41.1"
14 | #atomic-bomb-engine = { git = "https://github.com/we-lsp/atomic-bomb-engine.git", branch = "ExponentialMovingAverage"}
15 | tokio = "1.36.0"
16 | pyo3-asyncio = { version = "0.20.0", features = ["tokio-runtime", "async-std"] }
17 | serde = { version = "1.0", features = ["derive"] }
18 | serde_json = { version = "1.0", features = [] }
19 | serde-pyobject = "0.2.1"
20 | async-std = "1.12.0"
21 | async-stream = "0.3.5"
22 | futures = "0.3.30"
23 | tokio-stream = "0.1.15"
24 | anyhow = "1.0.83"
25 |
26 | [build]
27 | rustflags = ["-C", "target-feature=+crt-static"]
28 |
29 | [dependencies.pyo3]
30 | version = "0.20.3"
31 | features = ["extension-module", "auto-initialize"]
32 |
33 | [tool.maturin]
34 | name = "atomic_bomb_engine"
35 | scripts = "python/atomic_bomb_engine"
36 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # atomic-bomb-engine-py
2 | #### [atomic-bomb-engine](https://github.com/we-lsp/atomic-bomb-engine)的python包装实现
3 |
4 |
5 |
6 |
7 | ## 前端仓库
8 | #### [atomic-bomb-engine-front](https://github.com/GiantAxeWhy/atomic-bomb-engine-front)
9 |
10 | ## 使用条件:
11 | - python版本 >= 3.8
12 | - windows(x86), linux(x86), mac
13 |
14 | ## 使用方法:
15 | - ### 准备开始
16 | 通过pip安装 (0.5.0版本之前)
17 | ```shell
18 | pip install atomic-bomb-engine-py
19 | ```
20 | 在python中引用时注意,需要引用atomic_bomb_engine, 而不是atomic_bomb_engine_py
21 |
为了避免混淆,0.5.0版本之后,pip更换了包名,更改为atomic-bomb-engine,
22 | ```shell
23 | pip install atomic-bomb-engine
24 | ```
25 | 在python中导入
26 | ```python
27 | import atomic_bomb_engine
28 | ```
29 | 异步使用的时候,还需要引用asyncio
30 | ```python
31 | import asyncio
32 | ```
33 | - ### 开始压测
34 | - ~~单接口压测~~ (功能与多接口压测重叠,已废除)
35 |
36 | - 多接口压测
37 |
38 | 多接口压测可以先使用
39 | ```python
40 | runner = atomic_bomb_engine.BatchRunner()
41 | ```
42 | 实例化一个runner类
43 | 通过runner类中的run方法开启压测
44 | run方法函数签名如下
45 | ```python
46 | def run(
47 | self,
48 | test_duration_secs: int,
49 | concurrent_requests: int,
50 | api_endpoints:List[Dict],
51 | step_option:Dict[str, int]|None=None,
52 | setup_options:List[Dict[str, Any]]|None=None,
53 | verbose:bool=False,
54 | should_prevent:bool=False,
55 | assert_channel_buffer_size:int=1024,
56 | timeout_secs=0,
57 | cookie_store_enable=True
58 | ) -> None:
59 | """
60 | 批量压测
61 | :param test_duration_secs: 测试持续时间
62 | :param concurrent_requests: 并发数
63 | :param api_endpoints: 接口信息
64 | :param step_option: 阶梯加压选项
65 | :param setup_options: 初始化选项
66 | :param verbose: 打印详细信息
67 | :param should_prevent: 是否禁用睡眠
68 | :param assert_channel_buffer_size: 断言队列buffer大小
69 | :param timeout_secs: http超时时间
70 | :param cookie_store_enable: 是否为客户端启用持久性cookie存储。
71 | """
72 | ```
73 |
74 | 使用assert_option方法可以返回断言选项字典
75 | ```python
76 | assert_options=[
77 | atomic_bomb_engine.assert_option("$.code", 429),
78 | atomic_bomb_engine.assert_option("$.code", 200)
79 | ])
80 | ```
81 | jsonpath如果不会用的话,建议去[jsonpath](https://jsonpath.com/)学习
82 |
83 | 使用step_option方法可以返回阶梯加压选项字典
84 | ```python
85 | def step_option(increase_step: int, increase_interval: int) -> Dict[str, int]:
86 | """
87 | 生成step option
88 | :param increase_step: 阶梯步长
89 | :param increase_interval: 阶梯间隔
90 | """
91 | ```
92 |
93 | 同样的本包中也包含了一个对api_endpoint的包装:endpoint方法,方便调用,endpoint中的assert_options中也可以套用assert_option方法
94 | ```python
95 | async def run_batch():
96 | result = await atomic_bomb_engine.batch_async(
97 | # 测试持续时间
98 | test_duration_secs=60,
99 | # 并发量
100 | concurrent_requests=200,
101 | # 阶梯设置(每5秒增加30个并发)
102 | step_option=atomic_bomb_engine.step_option(increase_step=30, increase_interval=5),
103 | # 接口超时时间
104 | timeout_secs=10,
105 | # 是否开启客户端启用持久性cookie存储
106 | cookie_store_enable=True,
107 | # 全局初始化
108 | setup_options=[
109 | atomic_bomb_engine.setup_option(
110 | name="初始化-1",
111 | url="http://localhost:8080/setup",
112 | method="get",
113 | jsonpath_extract=[
114 | atomic_bomb_engine.jsonpath_extract_option(key="test-msg", jsonpath="$.msg"),
115 | atomic_bomb_engine.jsonpath_extract_option(key="test-code", jsonpath="$.code"),
116 | ]
117 | )],
118 | # 是否开启详细日志
119 | verbose=False,
120 | # 被压接口设置
121 | api_endpoints=[
122 | atomic_bomb_engine.endpoint(
123 | # 接口任务命名
124 | name="test-1",
125 | # 针对每个接口初始化
126 | setup_options=[
127 | atomic_bomb_engine.setup_option(
128 | name="api-初始化-1",
129 | url="http://localhost:8080/api_setup",
130 | method="get",
131 | jsonpath_extract=[
132 | atomic_bomb_engine.jsonpath_extract_option(key="api-test-msg-1", jsonpath="$.msg"),
133 | atomic_bomb_engine.jsonpath_extract_option(key="api-test-code-1", jsonpath="$.code"),
134 | ]
135 | )
136 | ],
137 | # 被压接口url
138 | url="http://localhost:8080/direct",
139 | # 请求方式
140 | method="POST",
141 | # 权重
142 | weight=1,
143 | # 发送json请求
144 | json={"name": "{{api-test-msg-1}}", "number": 1},
145 | # 断言选项
146 | assert_options=[
147 | atomic_bomb_engine.assert_option(jsonpath="$.number", reference_object=1),
148 | ],
149 | # 思考时间选项(在最大和最小之间随机,单位毫秒)
150 | think_time_option=atomic_bomb_engine.think_time_option(min_millis=500, max_millis=1200),
151 | ),
152 | ])
153 | print(result)
154 | return result
155 | ```
156 |
157 | 监听时可以在使用完run方法后,继续迭代runner即可
158 |
159 | 压测+同时监听
160 |
161 | ```python
162 | import asyncio
163 |
164 |
165 | async def batch_async():
166 | runner = atomic_bomb_engine.BatchRunner()
167 | runner.run(
168 | test_duration_secs=30,
169 | concurrent_requests=30,
170 | step_option=atomic_bomb_engine.step_option(increase_step=3, increase_interval=3),
171 | timeout_secs=15,
172 | cookie_store_enable=True,
173 | verbose=False,
174 | api_endpoints=[
175 | atomic_bomb_engine.endpoint(
176 | name="test-1",
177 | url="http://127.0.0.1:8080/direct",
178 | method="POST",
179 | json={"name": "test-1", "number": 1},
180 | weight=100,
181 | assert_options=[
182 | atomic_bomb_engine.assert_option(jsonpath="$.msg", reference_object="操作成功"),
183 | ],
184 | ),
185 | ])
186 | return runner
187 |
188 |
189 | async def main():
190 | results = await batch_async()
191 | for res in results:
192 | if res.get("should_wait"):
193 | await asyncio.sleep(0.1)
194 | print(res)
195 |
196 |
197 | if __name__ == '__main__':
198 | asyncio.run(main())
199 | ```
200 |
201 | # 压测时使用ui界面监控
202 |
203 | 0.5.0版本后,添加了ui页面,支持批量压测方法
204 |
导入
205 | ```python
206 | from atomic_bomb_engine import server
207 | ```
208 | 使用
209 | ```python
210 | import asyncio
211 |
212 | import atomic_bomb_engine
213 | from atomic_bomb_engine import server
214 |
215 |
216 | @server.ui(port=8000)
217 | async def batch_async():
218 | runner = atomic_bomb_engine.BatchRunner()
219 | runner.run(
220 | # 测试持续时间
221 | test_duration_secs=60,
222 | # 并发量
223 | concurrent_requests=200,
224 | # 阶梯设置(每5秒增加30个并发)
225 | step_option=atomic_bomb_engine.step_option(increase_step=30, increase_interval=5),
226 | # 接口超时时间
227 | timeout_secs=10,
228 | # 是否开启客户端启用持久性cookie存储
229 | cookie_store_enable=True,
230 | # 全局初始化
231 | setup_options=[
232 | atomic_bomb_engine.setup_option(
233 | name="初始化-1",
234 | url="http://localhost:8080/setup",
235 | method="get",
236 | jsonpath_extract=[
237 | atomic_bomb_engine.jsonpath_extract_option(key="test-msg", jsonpath="$.msg"),
238 | atomic_bomb_engine.jsonpath_extract_option(key="test-code", jsonpath="$.code"),
239 | ]
240 | )],
241 | # 是否开启详细日志
242 | verbose=False,
243 | # 被压接口设置
244 | api_endpoints=[
245 | atomic_bomb_engine.endpoint(
246 | # 接口任务命名
247 | name="test-1",
248 | # 针对每个接口初始化
249 | setup_options=[
250 | atomic_bomb_engine.setup_option(
251 | name="api-初始化-1",
252 | url="http://localhost:8080/api_setup",
253 | method="get",
254 | jsonpath_extract=[
255 | atomic_bomb_engine.jsonpath_extract_option(key="api-test-msg-1", jsonpath="$.msg"),
256 | atomic_bomb_engine.jsonpath_extract_option(key="api-test-code-1", jsonpath="$.code"),
257 | ]
258 | )
259 | ],
260 | # 被压接口url
261 | url="http://localhost:8080/direct",
262 | # 请求方式
263 | method="POST",
264 | # 权重
265 | weight=1,
266 | # 发送json请求
267 | json={"name": "{{api-test-msg-1}}", "number": 1},
268 | # 断言选项
269 | assert_options=[
270 | atomic_bomb_engine.assert_option(jsonpath="$.number", reference_object=1),
271 | ],
272 | # 思考时间选项(在最大和最小之间随机,单位毫秒)
273 | think_time_option=atomic_bomb_engine.think_time_option(min_millis=500, max_millis=1200),
274 | ),
275 | ])
276 | return runner
277 |
278 |
279 | if __name__ == '__main__':
280 | asyncio.run(batch_async())
281 | ```
282 |
283 | 使用server.ui装饰器,可以给批量压测方法启动一个简单的web服务器,不需要再手动监听BatchListenIter生成器
284 |
285 | ## 内部架构图
286 | 
287 |
288 | ## [0.19.0] - 2024-04-16
289 | ### Added
290 | - 增加了初始化和参数模版功能
291 | ```python
292 | setup_options=[
293 | atomic_bomb_engine.setup_option(
294 | name="初始化-1",
295 | url="http://localhost:8080/setup",
296 | method="get",
297 | jsonpath_extract=[
298 | atomic_bomb_engine.jsonpath_extract_option(key="test-msg", jsonpath="$.msg"),
299 | atomic_bomb_engine.jsonpath_extract_option(key="test-code", jsonpath="$.code"),
300 | ]
301 | )]
302 | ```
303 | 上述实例展示了如何在初始化的时候调用某个接口,并且通过jsonpath将数据提取出来,保存在全局变量test-msg和test-code中
304 | 提取完全局变量后,就可以在后续的api_endpoints中使用
305 | ```python
306 | api_endpoints=[
307 | atomic_bomb_engine.endpoint(
308 | # 接口任务命名
309 | name="test-1",
310 | # 针对每个接口初始化
311 | setup_options=[
312 | atomic_bomb_engine.setup_option(
313 | name="api-初始化-1",
314 | url="http://localhost:8080/api_setup",
315 | method="get",
316 | jsonpath_extract=[
317 | atomic_bomb_engine.jsonpath_extract_option(key="api-test-msg-1", jsonpath="$.msg"),
318 | atomic_bomb_engine.jsonpath_extract_option(key="api-test-code-1", jsonpath="$.code"),
319 | ]
320 | )
321 | ],
322 | # 被压接口url
323 | url="http://localhost:8080/direct",
324 | # 请求方式
325 | method="POST",
326 | # 权重
327 | weight=1,
328 | # 发送json请求
329 | json={"name": "{{api-test-msg-1}}", "number": 1},
330 | # 断言选项
331 | assert_options=[
332 | atomic_bomb_engine.assert_option(jsonpath="$.number", reference_object=1),
333 | ],
334 | # 思考时间选项(在最大和最小之间随机,单位毫秒)
335 | think_time_option=atomic_bomb_engine.think_time_option(min_millis=500, max_millis=1200),
336 | ),
337 | ]
338 | ```
339 | 上述实例展示了如何在请求中使用全局变量,使用双大括号即可使用
340 |
341 | ### Fixed
342 | - 修复了如果http状态码错误时,不会记录
343 | - 修复了json反序列化的问题
344 |
345 | ## [0.20.0] - 2024-04-17
346 | ### Added
347 | 断言更改为异步生产消费,提升性能
348 |
349 | ## bug和需求
350 | - 如果发现了bug,把复现步骤一起写到Issus中哈
351 | - 如果有需求也可以在Issues中讨论
352 | - 本程序是本人业余时间开发,不太准备保证时效性,但是如果有时间,一定第一时间回复和修改bug
353 |
354 | ## [0.22.0] - 2024-04-18
355 | ### Added
356 | 前端进行了性能优化
357 |
358 | ## [0.24.0] - 2024-04-22
359 | ### Added
360 | 异步断言使用了补偿消息,保证消息的一致性
361 |
362 | ## [0.25.0] - 2024-04-23
363 | ### Added
364 | 在endpoints中增加思考时间,模拟用户行为
365 | ```python
366 | think_time_option(min_millis=200, max_millis=300)
367 | ```
368 | - min_millis:最小思考时间(毫秒)
369 | - max_millis:最大思考时间(毫秒)
370 |
371 | 使用时在endpoint中增加think_time_option参数
372 |
373 | ```python
374 | api_endpoints=[
375 | atomic_bomb_engine.endpoint(
376 | name="test-1",
377 | url="http://localhost:8080/a",
378 | method="POST",
379 | weight=1,
380 | timeout_secs=10,
381 | json={"name": "{{test-msg}}", "number": "{{test-code}}"},
382 | think_time_option=atomic_bomb_engine.think_time_option(min_millis=200, max_millis=300),
383 | ),
384 | ]
385 | ```
386 |
387 | ## [0.26.0] - 2024-04-24
388 | ### Added
389 | - 增加endpoint中的setup,在并发中可以做接口断言
390 | - 增加有关联条件下的cookie自动管理功能
391 | ```python
392 | atomic_bomb_engine.endpoint(
393 | # 接口任务命名
394 | name="test-1",
395 | # 针对每个接口初始化
396 | setup_options=[
397 | atomic_bomb_engine.setup_option(
398 | name="api-初始化-1",
399 | url="http://localhost:8080/api_setup",
400 | method="get",
401 | jsonpath_extract=[
402 | atomic_bomb_engine.jsonpath_extract_option(key="api-test-msg-1", jsonpath="$.msg"),
403 | atomic_bomb_engine.jsonpath_extract_option(key="api-test-code-1", jsonpath="$.code"),
404 | ]
405 | )
406 | ],
407 | # 被压接口url
408 | url="http://localhost:8080/direct",
409 | # 请求方式
410 | method="POST",
411 | # 权重
412 | weight=1,
413 | # 发送json请求
414 | json={"name": "{{api-test-msg-1}}", "number": 1},
415 | # 断言选项
416 | assert_options=[
417 | atomic_bomb_engine.assert_option(jsonpath="$.number", reference_object=1),
418 | ],
419 | # 思考时间选项(在最大和最小之间随机,单位毫秒)
420 | think_time_option=atomic_bomb_engine.think_time_option(min_millis=500, max_millis=1200),
421 | )
422 | ```
423 | - 参数cookie_store_enable控制是否自动管理cookie,前置条件的cookie会带入到最终的压测接口中
424 | - 在endpoint中使用setup_options可以传入多个接口,并且提取参数
425 | - 提取到的参数如果和全局的setup的key冲突,会覆盖全局提取到的参数
426 | - 接口中提取的参数只能在本线程(v-user)中使用
427 | - ⚠️ 使用时注意:setup_options是顺序执行的,没有并发,但是相当于添加了think time
428 |
429 | ## [0.28.0] - 2024-04-25
430 | ### Added
431 | - 将持久化cookie添加到全局选项中
432 | - 复用http client
433 | - 选择性开启断言任务
434 | - 接口初始化时出现错误等待后重试##
435 |
436 | ## [0.29.0] - 2024-04-25
437 | ### Added
438 | - 优化并发逻辑
439 | - 前端更改为web worker发送心跳
440 |
441 | ## [0.38.0] - 2024-05-7
442 | ### Added
443 | - 增加附件上传功能
444 | - 在初始化和每个接口中增加了multipart_options参数用于附件上传
445 | - 增加multipart_option方法封装附件参数
446 | - form_key: form表单的key
447 | - path: 附件路径
448 | - file_name: 附件名
449 | - mime: 附件类型 (类型可以参考[这里](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types))
450 | ```python
451 | api_endpoints=[
452 | atomic_bomb_engine.endpoint(
453 | name="test-file",
454 | url="http://127.0.0.1:8888/upload",
455 | method="post",
456 | weight=100,
457 | multipart_options=[atomic_bomb_engine.multipart_option(form_key="file", path="./ui.py", file_name="ui.py", mime="text/plain")],
458 | assert_options=[
459 | atomic_bomb_engine.assert_option(jsonpath="$.message", reference_object="File uploaded successfully!"),
460 | ],
461 | think_time_option=atomic_bomb_engine.think_time_option(min_millis=500, max_millis=1200),
462 | ),]
463 | ```
464 |
465 | ## [0.39.0] - 2024-05-15
466 | ### Added
467 | - 启用BatchRunner类,每次执行可以返回一个迭代器
468 | - 废除run_batch方法
469 | - 废除ResultsIter迭代器
470 |
471 | ## [0.40.0] - 2024-05-16
472 | ### Added
473 | - 将rps统计改为滑动窗口的形式
474 |
475 | ## [0.41.0] - 2024-05-20
476 | ### Added
477 | - run方法增加指数滑动平均参数: ema_alpha
478 | - 参数为0-1之间的一个浮点数
479 | - 参数为0时不启用
480 | - 数值越大越平滑,但是失真越多
481 | - 建议使用0.1以下
482 |
483 | ## bug和需求
484 | - 如果发现了bug,把复现步骤一起写到Issus中哈
485 | - 如果有需求也可以在Issues中讨论
486 | - 本程序是本人业余时间开发,不太准备保证时效性,但是如果有时间,一定第一时间回复和修改bug
487 |
488 | ## TODO
489 | - [x] 前端展示页面 ✅
490 | - [x] 接口关联 ✅
491 | - [x] 每个接口可以配置思考时间 ✅
492 | - [x] 增加form支持 ✅
493 | - [ ] 增加代理支持
494 | - [x] 增加附件支持 ✅
495 | - [ ] 断言支持不等于等更多表达方式
496 |
497 | ## 联系方式
498 | - 邮箱:[qyzhg@qyzhg.com](mailto:qyzhg@qyzhg.com)
499 | - 微信:qy-zhg
500 |
501 | ## 👏🏻👏🏻👏🏻欢迎加群交流
502 | 
503 |
--------------------------------------------------------------------------------
/build.sh:
--------------------------------------------------------------------------------
1 | maturin build --release --target x86_64-apple-darwin -i python3.8 -i python3.9 -i python3.10 -i python3.11 -i python3.12 &&
2 | maturin build --release -i python3.8 -i python3.9 -i python3.10 -i python3.11 -i python3.12 &&
3 | twine upload --repository pypi --config-file ~/.pypirc ./target/wheels/* &&
4 | rm ./target/wheels/*
5 |
--------------------------------------------------------------------------------
/img/architecture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/we-lsp/atomic-bomb-engine-py/817ccbc9c8fa20c972e2108c097a34712a697764/img/architecture.png
--------------------------------------------------------------------------------
/img/atomic-bomb-engine-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/we-lsp/atomic-bomb-engine-py/817ccbc9c8fa20c972e2108c097a34712a697764/img/atomic-bomb-engine-logo.png
--------------------------------------------------------------------------------
/img/img.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/we-lsp/atomic-bomb-engine-py/817ccbc9c8fa20c972e2108c097a34712a697764/img/img.png
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["maturin>=1.4"]
3 | build-backend = "maturin"
4 |
5 | [project]
6 | name = "atomic_bomb_engine"
7 | version = "0.41.3"
8 | description = "使用rust开发的高性能python压测工具"
9 | license = "MIT"
10 | readme = "README.md"
11 | repository = "https://github.com/qyzhg/atomic-bomb-engine-py"
12 | keywords = ["rust", "python", "binding"]
13 | classifiers = [
14 | "Programming Language :: Python :: 3",
15 | "Programming Language :: Rust",
16 | "Operating System :: OS Independent",
17 | ]
18 | authors = [{name = "qyzhg", email = "qyzhg@qyzhg.com"}]
19 | dependencies = ["aiohttp", "aiosqlite"]
20 |
21 |
22 | [tool.maturin]
23 | name = "atomic_bomb_engine"
24 | python-source = "python"
25 |
26 | [tool.poetry.include]
27 | include = ["python/atomic_bomb_engine/dist/**/*"]
28 |
--------------------------------------------------------------------------------
/python/atomic_bomb_engine/__init__.py:
--------------------------------------------------------------------------------
1 | # SPDX-License-Identifier: (Apache-2.0 OR MIT)
2 |
3 | from .atomic_bomb_engine import *
4 |
5 | __doc__ = atomic_bomb_engine.__doc__
6 | if hasattr(atomic_bomb_engine, "__all__"):
7 | __all__ = atomic_bomb_engine.__all__
8 |
9 |
--------------------------------------------------------------------------------
/python/atomic_bomb_engine/__init__.pyi:
--------------------------------------------------------------------------------
1 | from typing import Iterator, Optional, List, Dict, Any
2 |
3 | def assert_option(jsonpath: str, reference_object: any) -> Dict[str, Any]:
4 | """
5 | 生成assert option
6 | :param jsonpath: jsonpath取值地址
7 | :param reference_object: 断言的值
8 | """
9 |
10 |
11 | def step_option(increase_step: int, increase_interval: int) -> Dict[str, int]:
12 | """
13 | 生成step option
14 | :param increase_step: 阶梯步长
15 | :param increase_interval: 阶梯间隔
16 | """
17 |
18 | def think_time_option(min_millis: int, max_millis: int) -> Dict[str, int]:
19 | """
20 | 思考时间选项
21 | :param min_millis:
22 | :param max_millis:
23 | :return:
24 | """
25 |
26 | def endpoint(
27 | name: str,
28 | url: str,
29 | method: str,
30 | weight: int,
31 | json: Dict | None = None,
32 | form_data: Dict | None = None,
33 | multipart_options: List[Dict]| None = None,
34 | headers: Dict | None = None,
35 | cookies: str | None = None,
36 | assert_options: List | None = None,
37 | think_time_option: Dict[str, int] | None = None,
38 | setup_options: List| None = None,
39 | ) -> Dict[str, Any]:
40 | """
41 | 生成endpoint
42 | :param assert_options:
43 | :param form_data:
44 | :param name: 接口名称
45 | :param url: 接口地址
46 | :param method: 请求方法
47 | :param weight 权重
48 | :param json: 请求json
49 | :param form_data: 请求form表单
50 | :multipart_options: 附件
51 | :param headers: 请求头
52 | :param cookies: cookie
53 | :param assert_options: 断言参数
54 | :param think_time_option: 思考时间
55 | :param setup_options: 接口初始化选项
56 | """
57 |
58 |
59 | def setup_option(
60 | name: str,
61 | url: str,
62 | method: str,
63 | json: Dict| None = None,
64 | form_data: Dict| None = None,
65 | multipart_options: List[Dict]| None = None,
66 | headers: Dict| None = None,
67 | cookies: str | None = None,
68 | jsonpath_extract: List| None = None) ->Dict[str, Any]:
69 | """
70 | 初始化选项
71 | :param name: 接口名称
72 | :param url: 接口地址
73 | :param method: 请求方法
74 | :param json: 请求json
75 | :param form_data: 请求form表单
76 | :multipart_options: 附件
77 | :param headers: 请求头
78 | :param cookies: cookie
79 | :param jsonpath_extract: 通过jsonpath提取参数
80 | :return:
81 | """
82 |
83 | def jsonpath_extract_option(key: str, jsonpath: str) -> Dict[str, str]:
84 | """
85 | jsonpath提取参数设置
86 | :param key: 全局key
87 | :param jsonpath: 提取jsonpath路径
88 | :return:
89 | """
90 |
91 | def multipart_option(
92 | form_key: str,
93 | path: str,
94 | file_name: str,
95 | mime: str) -> Dict:
96 | """
97 | 上传附件选项
98 | :param form_key: form表单的key,根据服务端选择,e.g: file, file1
99 | :param path: 文件路径
100 | :param file_name: 文件名
101 | :param mime: 文件类型,e.g: application/octet-stream,可以参考:https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
102 | """
103 |
104 | class BatchRunner:
105 | def __init__(self) -> None:
106 | ...
107 |
108 | def run(
109 | self,
110 | test_duration_secs: int,
111 | concurrent_requests: int,
112 | api_endpoints:List[Dict],
113 | step_option:Dict[str, int]|None=None,
114 | setup_options:List[Dict[str, Any]]|None=None,
115 | verbose:bool=False,
116 | should_prevent:bool=False,
117 | assert_channel_buffer_size:int=1024,
118 | timeout_secs=0,
119 | cookie_store_enable=True,
120 | ema_alpha: float=0,
121 | ) -> None:
122 | """
123 | 批量压测
124 | :param test_duration_secs: 测试持续时间
125 | :param concurrent_requests: 并发数
126 | :param api_endpoints: 接口信息
127 | :param step_option: 阶梯加压选项
128 | :param setup_options: 初始化选项
129 | :param verbose: 打印详细信息
130 | :param should_prevent: 是否禁用睡眠
131 | :param assert_channel_buffer_size: 断言队列buffer大小
132 | :param timeout_secs: http超时时间
133 | :param cookie_store_enable: 是否为客户端启用持久性cookie存储。
134 | :param ema_alpha: 指数滑动平均参数,0-1之间,0为不使用,值越大曲线越平滑,但是越失真,建议使用0.1以下
135 | """
136 | ...
137 |
138 | def __iter__(self) -> 'BatchRunner':
139 | ...
140 |
141 | def __next__(self) -> Optional[Any]:
142 | ...
--------------------------------------------------------------------------------
/python/atomic_bomb_engine/dist/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/we-lsp/atomic-bomb-engine-py/817ccbc9c8fa20c972e2108c097a34712a697764/python/atomic_bomb_engine/dist/.gitkeep
--------------------------------------------------------------------------------
/python/atomic_bomb_engine/middleware.py:
--------------------------------------------------------------------------------
1 | async def cors_middleware(app, handler):
2 | async def cors_handler(request):
3 | response = await handler(request)
4 | response.headers['Access-Control-Allow-Origin'] = '*'
5 | response.headers['Access-Control-Allow-Methods'] = 'POST, GET, OPTIONS'
6 | response.headers['Access-Control-Allow-Headers'] = 'X-Requested-With, Content-Type'
7 | return response
8 | return cors_handler
--------------------------------------------------------------------------------
/python/atomic_bomb_engine/server.py:
--------------------------------------------------------------------------------
1 | import atomic_bomb_engine
2 | import os
3 | import sys
4 | import asyncio
5 | import webbrowser
6 | import time
7 | import aiohttp
8 | import aiosqlite
9 | import json
10 | from typing import Dict
11 | from aiohttp import web
12 | from atomic_bomb_engine import middleware
13 |
14 |
15 | def ui(port: int = 8000, auto_open: bool = True):
16 | if port > 65535 or port < 0:
17 | raise ValueError(f"端口必须为0-65535")
18 | # 数据库连接
19 | db_connection = None
20 |
21 | # 运行锁
22 | class RunningState:
23 | def __init__(self):
24 | self._running = False
25 | self._lock = asyncio.Lock()
26 |
27 | async def set_running(self):
28 | async with self._lock:
29 | self._running = True
30 |
31 | async def is_running(self) -> bool:
32 | async with self._lock:
33 | return self._running
34 |
35 | running = RunningState()
36 |
37 | async def get_db_connection():
38 | nonlocal db_connection
39 | if db_connection is None:
40 | db_connection = await aiosqlite.connect(":memory:")
41 | await db_connection.execute('CREATE TABLE results (id INTEGER PRIMARY KEY, data JSON)')
42 | return db_connection
43 |
44 | async def create_table():
45 | db = await get_db_connection()
46 | await db.commit()
47 |
48 | async def insert_result_data(data):
49 | db = await get_db_connection()
50 | json_data = json.dumps(data)
51 | await db.execute('INSERT INTO results (data) VALUES (?)', (json_data,))
52 | await db.commit()
53 |
54 | async def fetch_all_result_data():
55 | db = await get_db_connection()
56 | cursor = await db.execute('SELECT data FROM results ORDER BY id ASC')
57 | rows = await cursor.fetchall()
58 | results = [json.loads(row[0]) for row in rows]
59 | return results
60 |
61 | class WsConn:
62 | """连接池对象"""
63 |
64 | def __init__(self, ws: aiohttp.web_ws.WebSocketResponse, heartbeat_time: float):
65 | self.ws = ws
66 | self.heartbeat_time = heartbeat_time
67 |
68 | # ws连接池
69 | connections: Dict[str, WsConn] = dict()
70 |
71 | # 结果迭代器
72 | res_iter = None
73 |
74 | def decorator(func):
75 | async def start_service(*args, **kwargs):
76 | # 建表
77 | await create_table()
78 |
79 | # 定义ws接口
80 | async def websocket_handler(request):
81 | # 获取id
82 | if (client_id := request.match_info.get("id", None)) is None:
83 | return web.Response(status=400, text="缺少id参数")
84 |
85 | ws = web.WebSocketResponse()
86 | await ws.prepare(request)
87 | # 将id加入连接池
88 | connections[client_id] = WsConn(ws, time.time())
89 |
90 | # 心跳检测
91 | async def check_heartbeat():
92 | while True:
93 | await asyncio.sleep(0.5)
94 | if (ws_conn := connections.get(client_id)) is None:
95 | break
96 | if time.time() - ws_conn.heartbeat_time > 5:
97 | sys.stderr.write(f"{time.ctime()}客户端{client_id} 未发送心跳,断开连接\n")
98 | sys.stderr.flush()
99 | connections.pop(client_id, None)
100 | await ws_conn.ws.close()
101 | break
102 |
103 | async def push_result():
104 | nonlocal res_iter
105 |
106 | while res_iter is None:
107 | await asyncio.sleep(0.1)
108 |
109 | for item in res_iter:
110 | if item:
111 | # 插入数据
112 | await insert_result_data(item)
113 | # 推送result
114 | for cid, conn in list(connections.items()):
115 | try:
116 | await conn.ws.send_json(item)
117 | except ConnectionResetError:
118 | sys.stderr.write(f'{time.ctime()}-WebSocket ID {cid} 断开, 无法推送\n')
119 | sys.stderr.flush()
120 | # 从连接池中移除断开的连接
121 | connections.pop(cid, None)
122 | return
123 |
124 | for cid, conn in list(connections.items()):
125 | try:
126 | await conn.ws.send_str("DONE")
127 | except ConnectionResetError:
128 | sys.stderr.write(f'{time.ctime()}-WebSocket ID {cid} 断开, 无法推送\n')
129 | sys.stderr.flush()
130 | # 从连接池中移除断开的连接
131 | connections.pop(cid, None)
132 | return
133 |
134 | # 推送任务
135 | push_task = asyncio.create_task(push_result())
136 | # 心跳任务
137 | check_heartbeat_task = asyncio.create_task(check_heartbeat())
138 |
139 | async for msg in ws:
140 | if msg.type is web.WSMsgType.TEXT:
141 | if msg.data.upper() == "PING":
142 | # 更新心跳时间
143 | if (ws_conn := connections.get(client_id, None)) is not None:
144 | ws_conn.heartbeat_time = time.time()
145 | await ws.send_str("PONG")
146 | elif msg.type is web.WSMsgType.ERROR:
147 | sys.stderr.write(f'WebSocket连接错误{ws.exception()}\n')
148 | sys.stderr.flush()
149 |
150 | await push_task
151 | sys.stderr.write('WebSocket连接关闭\n')
152 | sys.stderr.flush()
153 |
154 | await check_heartbeat_task
155 | connections.pop(client_id, None)
156 | return ws
157 |
158 | # 定义run接口
159 | async def run_decorated_function(request):
160 | if await running.is_running():
161 | return web.json_response({"message": "任务正在运行中", "success": False})
162 | await running.set_running()
163 | nonlocal res_iter
164 | res_iter = await func(*args, **kwargs)
165 | return web.json_response({"message": "压测任务已启动", "success": True})
166 |
167 | # 定义history接口
168 | async def history(request):
169 | results = await fetch_all_result_data()
170 | return web.json_response(results)
171 |
172 | # 重定向到首页
173 | async def redirect_to_index(request):
174 | response = web.HTTPFound('/static/index.html')
175 | # 禁用缓存
176 | response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
177 | response.headers['Pragma'] = 'no-cache'
178 | response.headers['Expires'] = '0'
179 | return response
180 |
181 | app = web.Application(middlewares=[middleware.cors_middleware])
182 | # 静态页面
183 | app.router.add_static('/static', path=os.path.join(os.path.dirname(__file__), 'dist'), name='dist')
184 | # 路由
185 | app.add_routes([web.get('/', redirect_to_index),
186 | web.get('/ws/{id}', websocket_handler),
187 | web.get('/run', run_decorated_function),
188 | web.get('/history', history),
189 | ])
190 | runner = web.AppRunner(app)
191 | await runner.setup()
192 | site = web.TCPSite(runner, '0.0.0.0', port)
193 | await site.start()
194 | # 等待协程运行完成
195 | await asyncio.Event().wait()
196 |
197 | sys.stderr.write(f"服务启动成功: http://localhost:{port}\n")
198 | sys.stderr.flush()
199 | if auto_open:
200 | webbrowser.open(f"http://localhost:{port}")
201 | return start_service
202 |
203 | return decorator
204 |
--------------------------------------------------------------------------------
/src/lib.rs:
--------------------------------------------------------------------------------
1 | use pyo3::prelude::*;
2 | mod py_lib;
3 | mod utils;
4 |
5 | #[pymodule]
6 | #[pyo3(name = "atomic_bomb_engine")]
7 | fn atomic_bomb_engine(_py: Python, m: &PyModule) -> PyResult<()> {
8 | m.add_function(wrap_pyfunction!(
9 | py_lib::assert_option_func::assert_option,
10 | m
11 | )?)?;
12 | m.add_function(wrap_pyfunction!(py_lib::endpoint_func::endpoint, m)?)?;
13 | m.add_function(wrap_pyfunction!(py_lib::step_option_func::step_option, m)?)?;
14 | m.add_function(wrap_pyfunction!(
15 | py_lib::setup_option_func::setup_option,
16 | m
17 | )?)?;
18 | m.add_function(wrap_pyfunction!(
19 | py_lib::jsonpath_extract_func::jsonpath_extract_option,
20 | m
21 | )?)?;
22 | m.add_function(wrap_pyfunction!(
23 | py_lib::think_time_option_func::think_time_option,
24 | m
25 | )?)?;
26 | m.add_function(wrap_pyfunction!(
27 | py_lib::multipart_option_func::multipart_option,
28 | m
29 | )?)?;
30 | m.add_class::()?;
31 | Ok(())
32 | }
33 |
--------------------------------------------------------------------------------
/src/py_lib/assert_option_func.rs:
--------------------------------------------------------------------------------
1 | use pyo3::types::PyDict;
2 | use pyo3::{pyfunction, PyObject, PyResult, Python, ToPyObject};
3 |
4 | #[pyfunction]
5 | #[pyo3(signature=(
6 | jsonpath,
7 | reference_object,
8 | ))]
9 | pub(crate) fn assert_option(
10 | py: Python,
11 | jsonpath: String,
12 | reference_object: PyObject,
13 | ) -> PyResult {
14 | let dict = PyDict::new(py);
15 | dict.set_item("jsonpath", jsonpath)?;
16 | dict.set_item("reference_object", reference_object)?;
17 | Ok(dict.to_object(py))
18 | }
19 |
--------------------------------------------------------------------------------
/src/py_lib/batch_runner.rs:
--------------------------------------------------------------------------------
1 | use crate::utils;
2 | use atomic_bomb_engine::models::result::BatchResult;
3 | use futures::stream::BoxStream;
4 | use futures::StreamExt;
5 | use pyo3::types::{PyDict, PyList};
6 | use pyo3::{pyclass, pymethods, PyObject, PyRefMut, PyResult, Python, ToPyObject};
7 | use std::sync::Arc;
8 | use tokio::sync::Mutex;
9 |
10 | #[pyclass]
11 | pub(crate) struct BatchRunner {
12 | runtime: tokio::runtime::Runtime,
13 | stream: Arc, anyhow::Error>>>>>,
14 | is_done: Arc>,
15 | }
16 |
17 | #[pymethods]
18 | impl BatchRunner {
19 | #[new]
20 | fn new() -> Self {
21 | BatchRunner {
22 | runtime: tokio::runtime::Runtime::new().unwrap(),
23 | stream: Arc::new(Mutex::new(None)),
24 | is_done: Arc::new(Mutex::new(false)),
25 | }
26 | }
27 |
28 | #[pyo3(signature = (
29 | test_duration_secs,
30 | concurrent_requests,
31 | api_endpoints,
32 | step_option=None,
33 | setup_options=None,
34 | verbose=false,
35 | should_prevent=false,
36 | assert_channel_buffer_size=1024,
37 | timeout_secs=0,
38 | cookie_store_enable=true,
39 | ema_alpha=0f64,
40 | ))]
41 | fn run(
42 | &self,
43 | py: Python,
44 | test_duration_secs: u64,
45 | concurrent_requests: usize,
46 | api_endpoints: &PyList,
47 | step_option: Option<&PyDict>,
48 | setup_options: Option<&PyList>,
49 | verbose: bool,
50 | should_prevent: bool,
51 | assert_channel_buffer_size: usize,
52 | timeout_secs: u64,
53 | cookie_store_enable: bool,
54 | ema_alpha: f64,
55 | ) -> PyResult {
56 | let stream_clone = self.stream.clone();
57 | let endpoints = utils::parse_api_endpoints::new(py, api_endpoints)
58 | .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
59 | let step_opt = utils::parse_step_options::new(step_option)
60 | .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
61 | let setup_opts = utils::parse_setup_options::new(py, setup_options)
62 | .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
63 |
64 | let fut = async move {
65 | let stream = atomic_bomb_engine::core::run_batch::run_batch(
66 | test_duration_secs,
67 | concurrent_requests,
68 | timeout_secs,
69 | cookie_store_enable,
70 | verbose,
71 | should_prevent,
72 | endpoints,
73 | step_opt,
74 | setup_opts,
75 | assert_channel_buffer_size,
76 | ema_alpha,
77 | )
78 | .await;
79 | *stream_clone.lock().await = Some(stream);
80 | Ok::<(), pyo3::PyErr>(())
81 | };
82 |
83 | Python::with_gil(|py| {
84 | pyo3_asyncio::tokio::future_into_py(py, fut).map(|py_any| py_any.to_object(py))
85 | })
86 | }
87 |
88 | fn __iter__(slf: PyRefMut) -> PyResult> {
89 | Ok(slf)
90 | }
91 |
92 | fn __next__(slf: PyRefMut<'_, Self>, py: Python) -> PyResult