├── .gitignore ├── LICENSE ├── README.md ├── image ├── otter.sql └── pip.txt ├── logs └── .gitkeep ├── otter ├── __init__.py ├── conf.py ├── model_base.py ├── model_message.py ├── pool_db.py ├── util_json.py ├── util_log.py ├── util_resp.py ├── util_time.py └── view_message.py └── tests ├── get_messages.py └── get_messages_thread.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # Development 107 | *.db 108 | .idea 109 | logs/* 110 | !logs/.gitkeep 111 | uploads/* 112 | !uploads/.gitkeep 113 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Sun Geer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Otter 2 | 3 | *A lightweight, thread-safe MySQL connection pool for Python, designed for simplicity and reliability.* 4 | 5 | ## Installation 6 | 7 | clone: 8 | ``` 9 | $ git clone https://github.com/sungeer/otter.git 10 | $ cd otter 11 | ``` 12 | create & activate virtual env then install dependency: 13 | 14 | with venv + pip: 15 | ``` 16 | $ python -m venv venv 17 | $ source venv/bin/activate # use `venv\Scripts\activate` on Windows 18 | $ pip install -r requirements.txt 19 | ``` 20 | 21 | run: 22 | ``` 23 | $ granian --interface wsgi otter:app 24 | * Running on http://127.0.0.1:8000/ 25 | ``` 26 | 27 | ## License 28 | 29 | This project is licensed under the GPLv3 License (see the 30 | [LICENSE](LICENSE) file for details). 31 | -------------------------------------------------------------------------------- /image/otter.sql: -------------------------------------------------------------------------------- 1 | 2 | 3 | CREATE TABLE message ( 4 | id INT AUTO_INCREMENT, 5 | name VARCHAR(20) NOT NULL COMMENT '用户名', 6 | body VARCHAR(200) NOT NULL COMMENT '留言', 7 | is_deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已删除 0-未删除 1-已删除', 8 | create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', 9 | update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', 10 | 11 | PRIMARY KEY (id) 12 | ) COMMENT='留言表'; 13 | 14 | 15 | 16 | -- 初始化数据 17 | INSERT INTO message (name, body, create_time) VALUES 18 | ('Alice', 'Hello, this is Alice!', '2024-06-01 09:05:00'), 19 | ('Bob', 'Just checking in.', '2024-06-01 10:15:00'), 20 | ('Charlie', 'How is everyone?', '2024-06-01 11:25:00'), 21 | ('David', 'Good morning!', '2024-06-01 12:35:00'), 22 | ('Eve', 'Ready for the meeting?', '2024-06-01 13:45:00'), 23 | ('Frank', 'Let’s start the project.', '2024-06-01 14:55:00'), 24 | ('Grace', 'Can anyone help me?', '2024-06-01 15:05:00'), 25 | ('Heidi', 'I will be late today.', '2024-06-01 16:15:00'), 26 | ('Ivan', 'Check out this link.', '2024-06-01 17:25:00'), 27 | ('Judy', 'Lunch break?', '2024-06-01 18:35:00'), 28 | ('Mallory', 'See you all tomorrow.', '2024-06-02 09:05:00'), 29 | ('Niaj', 'This is a test message.', '2024-06-02 10:15:00'), 30 | ('Olivia', 'Meeting postponed.', '2024-06-02 11:25:00'), 31 | ('Peggy', 'Project completed!', '2024-06-02 12:35:00'), 32 | ('Quentin', 'Review the documents.', '2024-06-02 13:45:00'), 33 | ('Rupert', 'Great job, team!', '2024-06-02 14:55:00'), 34 | ('Sybil', 'Who is available now?', '2024-06-02 15:05:00'), 35 | ('Trudy', 'Deadline extended.', '2024-06-02 16:15:00'), 36 | ('Uma', 'Any questions?', '2024-06-02 17:25:00'), 37 | ('Victor', 'Let’s celebrate!', '2024-06-02 18:35:00'); 38 | 39 | -------------------------------------------------------------------------------- /image/pip.txt: -------------------------------------------------------------------------------- 1 | pip cache purge # 清除缓存 2 | 3 | pip freeze > requirements.txt 4 | pip install -r requirements.txt 5 | 6 | 7 | python -m pip install mysqlclient flask python-dotenv requests loguru granian 8 | 9 | -------------------------------------------------------------------------------- /logs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sungeer/otter/544aa554a1c40b227547037d549d2390c03e6126/logs/.gitkeep -------------------------------------------------------------------------------- /otter/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | 3 | from otter.conf import settings 4 | from otter.util_log import logger 5 | from otter.util_resp import abort 6 | from otter.view_message import route as route_message 7 | 8 | 9 | def create_app(): 10 | app = Flask('otter') # noqa 11 | 12 | register_errors(app) 13 | register_blueprints(app) 14 | return app 15 | 16 | 17 | def register_errors(app): # noqa 18 | @app.errorhandler(404) 19 | def not_found(error): 20 | return abort(404) 21 | 22 | @app.errorhandler(405) 23 | def not_found(error): 24 | return abort(405) 25 | 26 | @app.errorhandler(Exception) 27 | def global_exception_handler(error): 28 | logger.exception(error) 29 | return abort(500) 30 | 31 | 32 | def register_blueprints(app): # noqa 33 | app.register_blueprint(route_message) 34 | 35 | 36 | app = create_app() 37 | -------------------------------------------------------------------------------- /otter/conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from dotenv import load_dotenv 5 | 6 | load_dotenv() 7 | config_name = os.getenv('FLASK_CONFIG', 'production') 8 | 9 | BASE_DIR = Path(__file__).resolve().parent.parent 10 | 11 | 12 | class BaseConfig: 13 | LOG_DIR = BASE_DIR / 'logs' 14 | 15 | DB_USER = os.getenv('DB_USER') 16 | DB_PASS = os.getenv('DB_PASS') 17 | DB_HOST = os.getenv('DB_HOST') 18 | DB_PORT = os.getenv('DB_PORT') 19 | DB_NAME = os.getenv('DB_NAME') 20 | 21 | 22 | class DevelopmentConfig(BaseConfig): 23 | DEBUG = True 24 | 25 | 26 | class TestingConfig(BaseConfig): 27 | DEBUG = False 28 | TESTING = True 29 | 30 | 31 | class ProductionConfig(BaseConfig): 32 | DEBUG = False 33 | 34 | 35 | config = { 36 | 'development': DevelopmentConfig, 37 | 'production': ProductionConfig, 38 | 'testing': TestingConfig 39 | } 40 | 41 | settings = config[config_name] 42 | 43 | 44 | if __name__ == '__main__': 45 | print(settings.DB_HOST) 46 | -------------------------------------------------------------------------------- /otter/model_base.py: -------------------------------------------------------------------------------- 1 | from otter.pool_db import create_dbpool_conn 2 | 3 | 4 | class BaseModel: 5 | 6 | def __init__(self): 7 | self.cursor = None 8 | self._conn = None 9 | 10 | def conn(self): 11 | if not self.cursor: 12 | self._conn = create_dbpool_conn() 13 | self.cursor = self._conn.cursor() 14 | 15 | def rollback(self): 16 | self._conn.rollback() 17 | 18 | def commit(self): 19 | try: 20 | self._conn.commit() 21 | except Exception: 22 | self.rollback() 23 | raise 24 | 25 | def begin(self): 26 | self._conn.begin() 27 | 28 | def close(self): 29 | try: 30 | if self.cursor: 31 | self.cursor.execute('UNLOCK TABLES;') 32 | self.cursor.close() 33 | if self._conn: 34 | self._conn.close() # conn_obj.close() 释放连接 35 | finally: 36 | self.cursor = None 37 | self._conn = None 38 | 39 | def execute(self, sql_str, values=None): 40 | try: 41 | self.cursor.execute(sql_str, values) 42 | except Exception: 43 | self.rollback() 44 | self.close() 45 | raise 46 | 47 | def executemany(self, sql_str, values=None): 48 | try: 49 | self.cursor.executemany(sql_str, values) 50 | except Exception: 51 | self.rollback() 52 | self.close() 53 | raise 54 | -------------------------------------------------------------------------------- /otter/model_message.py: -------------------------------------------------------------------------------- 1 | from otter.model_base import BaseModel 2 | 3 | 4 | class MessageModel(BaseModel): 5 | 6 | def get_messages(self): 7 | sql_str = ''' 8 | SELECT 9 | id, name, body, is_deleted, create_time, update_time 10 | FROM 11 | message 12 | ''' 13 | self.conn() 14 | self.execute(sql_str) 15 | messages = self.cursor.fetchall() 16 | self.close() 17 | return messages 18 | -------------------------------------------------------------------------------- /otter/pool_db.py: -------------------------------------------------------------------------------- 1 | import queue 2 | 3 | import MySQLdb 4 | from MySQLdb.cursors import DictCursor 5 | 6 | from otter.conf import settings 7 | 8 | 9 | def get_db_conn(): 10 | conn = MySQLdb.connect( 11 | host=settings.DB_HOST, 12 | port=int(settings.DB_PORT), 13 | db=settings.DB_NAME, 14 | user=settings.DB_USER, 15 | passwd=settings.DB_PASS, 16 | charset='utf8mb4', 17 | cursorclass=DictCursor 18 | ) 19 | return conn 20 | 21 | 22 | class MySQLConnection: 23 | 24 | def __init__(self, raw_conn, pool): 25 | self.conn = raw_conn 26 | self.pool = pool 27 | self.in_use = True # 防止重复归还 28 | 29 | def cursor(self, *args, **kwargs): 30 | return self.conn.cursor(*args, **kwargs) 31 | 32 | def begin(self): 33 | return self.conn.begin() 34 | 35 | def commit(self): 36 | return self.conn.commit() 37 | 38 | def rollback(self): 39 | return self.conn.rollback() 40 | 41 | def close(self): 42 | self.pool.release(self) 43 | 44 | def is_usable(self): 45 | try: 46 | self.conn.ping(True) 47 | return True 48 | except (Exception,): 49 | return False 50 | 51 | def real_close(self): 52 | try: 53 | self.conn.close() 54 | except (Exception,): 55 | pass 56 | 57 | 58 | class MySQLPool: 59 | 60 | def __init__(self): 61 | self.pool = queue.Queue(5) # 最多5个空闲连接 62 | 63 | def create_new_conn(self): 64 | raw_conn = get_db_conn() 65 | conn_obj = MySQLConnection(raw_conn, self) 66 | return conn_obj 67 | 68 | def connection(self): 69 | try: 70 | conn = self.pool.get(block=False) 71 | if not conn.is_usable(): # 不可用 72 | conn.real_close() # 关闭 73 | conn = self.create_new_conn() 74 | except queue.Empty: 75 | conn = self.create_new_conn() 76 | conn.in_use = True 77 | return conn 78 | 79 | def release(self, conn): 80 | if conn.in_use: 81 | conn.in_use = False 82 | try: 83 | self.pool.put_nowait(conn) 84 | except queue.Full: 85 | conn.real_close() 86 | 87 | 88 | db_pool = MySQLPool() 89 | 90 | 91 | def create_dbpool_conn(): 92 | conn = db_pool.connection() # 从连接池中获取一个连接 93 | return conn 94 | -------------------------------------------------------------------------------- /otter/util_json.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime, date 3 | from decimal import Decimal 4 | 5 | 6 | def dict_to_json(data): 7 | return json.dumps(data, cls=JsonExtendEncoder, ensure_ascii=False) 8 | 9 | 10 | def dict_to_json_stream(data): 11 | return json.dumps(data, cls=JsonExtendEncoder, ensure_ascii=False).encode('utf-8') 12 | 13 | 14 | def json_to_dict(json_data): 15 | return json.loads(json_data) 16 | 17 | 18 | class JsonExtendEncoder(json.JSONEncoder): 19 | 20 | def default(self, obj): 21 | if isinstance(obj, (tuple, list, datetime)): 22 | return obj.strftime('%Y-%m-%d %H:%M:%S') 23 | elif isinstance(obj, date): 24 | return obj.strftime('%Y-%m-%d') 25 | elif isinstance(obj, Decimal): 26 | return float(obj) 27 | elif isinstance(obj, bytes): 28 | return obj.decode('utf-8') 29 | return super().default(obj) 30 | -------------------------------------------------------------------------------- /otter/util_log.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from loguru import logger 4 | 5 | from otter.conf import settings 6 | from otter.util_time import get_now 7 | 8 | LOG_FILE = settings.LOG_DIR.joinpath(f'{get_now('date')}_otter.log') 9 | 10 | logger.remove() 11 | 12 | logger.add( 13 | LOG_FILE, 14 | rotation='100MB', 15 | format='{time:YYYY-MM-DD HH:mm:ss.SSS} - {level} - {message}', # 日志格式 16 | encoding='utf-8', 17 | enqueue=True, # 启用异步日志处理 18 | level='INFO', 19 | diagnose=False, # 关闭变量值 20 | backtrace=False, # 关闭完整堆栈跟踪 21 | colorize=False 22 | ) 23 | 24 | if settings.DEBUG: 25 | logger.add( 26 | sink=sys.stdout, # 输出到标准输出流 27 | format='{time:YYYY-MM-DD HH:mm:ss.SSS} - {level} - {message}', 28 | enqueue=True, 29 | level='DEBUG', 30 | diagnose=False, 31 | backtrace=False, 32 | colorize=False 33 | ) 34 | -------------------------------------------------------------------------------- /otter/util_resp.py: -------------------------------------------------------------------------------- 1 | from flask import Response 2 | from werkzeug.http import HTTP_STATUS_CODES 3 | 4 | from otter.util_json import dict_to_json_stream 5 | 6 | 7 | class BaseResponse: 8 | 9 | def __init__(self): 10 | self.status = True 11 | self.error_code = None 12 | self.message = None 13 | self.data = None 14 | 15 | def to_dict(self): 16 | resp_dict = { 17 | 'status': self.status, 18 | 'error_code': self.error_code, 19 | 'message': self.message, 20 | 'data': self.data 21 | } 22 | return resp_dict 23 | 24 | 25 | class JsonExtendResponse(Response): 26 | 27 | def __init__(self, response, **kwargs): 28 | json_response = dict_to_json_stream(response) 29 | super().__init__(json_response, mimetype='application/json', **kwargs) 30 | 31 | 32 | def jsonify(*args, **kwargs): 33 | if args and kwargs: 34 | raise TypeError('jsonify() behavior undefined when passed both args and kwargs') 35 | elif len(args) == 1: 36 | content = args[0] 37 | else: 38 | content = args or kwargs 39 | response = BaseResponse() 40 | response.data = content 41 | response = response.to_dict() 42 | return JsonExtendResponse(response) 43 | 44 | 45 | def abort(error_code, message=None): 46 | if not message: 47 | message = HTTP_STATUS_CODES.get(error_code) 48 | response = BaseResponse() 49 | response.status = False 50 | response.error_code = error_code 51 | response.message = message 52 | response = response.to_dict() 53 | return JsonExtendResponse(response) 54 | -------------------------------------------------------------------------------- /otter/util_time.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import datetime 3 | 4 | 5 | def get_now(format_type: str) -> str: 6 | now = datetime.now() 7 | format_map = { 8 | 'datetime': lambda: now.strftime('%Y-%m-%d %H:%M:%S'), 9 | 'date': lambda: now.strftime('%Y-%m-%d'), 10 | 'time': lambda: now.strftime('%H:%M:%S'), 11 | 'timestamp': lambda: str(int(time.time() * 1000)), # 毫秒级时间戳 12 | 'filename': lambda: now.strftime('%Y%m%d_%H%M%S'), # 文件名用下划线分隔 13 | } 14 | func = format_map.get(format_type, '') 15 | if not func: 16 | raise ValueError(f'Unsupported format_type: {format_type}') 17 | return func() 18 | 19 | 20 | # 时间戳 转 日期 21 | def timestamp_to_datetime(ts: int) -> str: 22 | return datetime.fromtimestamp(ts / 1000).strftime('%Y-%m-%d %H:%M:%S') 23 | 24 | 25 | if __name__ == '__main__': 26 | print(get_now('datetime')) # 2025-04-13 21:33:12 27 | print(get_now('date')) # 2025-04-13 28 | print(get_now('time')) # 21:33:12 29 | print(get_now('timestamp')) # 1713028392000 30 | print(get_now('filename')) # 20250413_213312 31 | 32 | print(timestamp_to_datetime(1713028392000)) # 2024-04-14 01:13:12 33 | -------------------------------------------------------------------------------- /otter/view_message.py: -------------------------------------------------------------------------------- 1 | from flask import request, Blueprint 2 | 3 | from otter.model_message import MessageModel 4 | from otter.util_resp import jsonify, abort 5 | 6 | route = Blueprint('message', __name__) 7 | 8 | 9 | @route.post('/messages') 10 | def get_messages(): 11 | messages = MessageModel().get_messages() 12 | return jsonify(messages) 13 | -------------------------------------------------------------------------------- /tests/get_messages.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | def get_messages(): 5 | url = 'http://127.0.0.1:8000/messages' 6 | data = { 7 | 'content': '你好,这是一个测试消息', 8 | 'sender': 'user' 9 | } 10 | 11 | response = requests.post(url, json=data) 12 | return response 13 | 14 | 15 | if __name__ == '__main__': 16 | import time 17 | 18 | start_time = time.time() 19 | ret = get_messages() 20 | end_time = time.time() 21 | 22 | print('状态码:', ret.status_code) 23 | print('响应内容:', ret.text) 24 | 25 | elapsed_ms = (end_time - start_time) * 1000 26 | print('耗时:{:.2f} 毫秒'.format(elapsed_ms)) 27 | -------------------------------------------------------------------------------- /tests/get_messages_thread.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import threading 3 | 4 | url = "http://127.0.0.1:8000/messages" 5 | data = { 6 | "content": "你好,这是一个测试消息", 7 | "sender": "user" 8 | } 9 | 10 | 11 | def send_request(number): 12 | response = requests.post(url, json=data) 13 | print(f'线程{number} 状态码:{response.status_code} 响应内容:{response.text}') 14 | 15 | 16 | # 配置并发数和请求总数 17 | concurrent_num = 20 # 并发线程数 18 | request_total = 100 # 请求总次数 19 | 20 | threads = [] 21 | for i in range(request_total): 22 | t = threading.Thread(target=send_request, args=(i,)) 23 | threads.append(t) 24 | t.start() 25 | if (i + 1) % concurrent_num == 0: 26 | for t in threads: 27 | t.join() 28 | threads = [] 29 | 30 | # 等待剩余线程 31 | for t in threads: 32 | t.join() 33 | --------------------------------------------------------------------------------