Flaskでmssql-pythonを使う

Flaskは軽量なPythonウェブフレームワークで、アプリケーション構造を完全にコントロールできます。 mssql-pythonと組み合わせることで、Microsoft SQLやAzure SQL Databaseを基盤としたウェブアプリケーションやREST APIを最小限のオーバーヘッドで構築できます。

前提条件

  • Python 3.10 以降。
  • mssql-python および flask パッケージ。 両方を pip install flask mssql-pythonでインストールしてください。
  • オペレーティング システム固有の 1 回限りの前提条件をインストールします。 Windowsユーザーはこのステップをスキップできます。 プラットフォームの詳細は 「Install mssql-python」をご覧ください。
    apk add libtool krb5-libs krb5-dev
    

SQL データベースを作成する

以下のいずれかのプラットフォームでSQLデータベースを作成または接続してください:

この記事の例は AdventureWorksLT のサンプルデータベース、特に SalesLT.Product テーブルを使用しています。 AdventureWorksLTをインストールしていない場合は、 AdventureWorksのサンプルデータベースをご覧ください。

プロジェクトの設定

依存関係のインストール

pipで必要なパッケージをインストールしてください:

pip install flask mssql-python

プロジェクト構造

設定、接続管理、ルート、テスト用の別モジュールでプロジェクトを整理しましょう:

my_app/
├── app.py            # Flask app and routes
├── config.py         # database settings
├── database.py       # connection lifecycle
├── test_app.py       # pytest tests
└── blueprints/       # optional: routes grouped into modules
    ├── __init__.py
    └── products.py

データベース接続管理

Flaskには組み込みのデータベースレイヤーがないため、直接接続を管理できます。 このセクションのパターンは、Flaskの g オブジェクトに対して1つのリクエストごとに1つの接続を保存し、リクエストが終了すると自動的に閉じます。

config.py を作る

データベース設定を設定クラスに集中管理しましょう。 環境変数はコードを変更せずにデフォルトを上書きできます。

# config.py
import os

class Config:
    """Application configuration."""
    DATABASE_SERVER = os.getenv("DB_SERVER", "<server>.database.windows.net")
    DATABASE_NAME = os.getenv("DB_NAME", "<database>")
    POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "10"))

database.py 作成

database.pyモジュールは接続ライフサイクルを管理します。 Flaskの g オブジェクトはリクエストごとの名前空間なので、そこに接続を保存することで、各リクエストが完了したときにクリーンアップされる独自の接続が確保されます。

get_connection_string()関数はアプリの設定から接続文字列を構築します。get_db()関数は最初の呼び出しで接続を作成し、その後のリクエストで再利用します。 close_db()関数は各リクエストの最後に自動的に動作し、例外が発生した場合はトランザクションをロールバックし、そうでなければコミットします。 init_app()機能はFlaskアプリでこの分解動作を認識します。

# database.py
import mssql_python
from flask import g, current_app

def get_connection_string() -> str:
    """Build connection string from Flask app config."""
    cfg = current_app.config
    return (
        f"Server={cfg['DATABASE_SERVER']};"
        f"Database={cfg['DATABASE_NAME']};"
        "Authentication=ActiveDirectoryDefault;"
        "Encrypt=yes"
    )

def get_db():
    """Get a database cursor for the current request.

    The connection is stored on Flask's g object so it persists
    for the duration of the request and is reused across calls.
    """
    if "db_conn" not in g:
        g.db_conn = mssql_python.connect(get_connection_string())
        g.db_cursor = g.db_conn.cursor()
    return g.db_cursor

def close_db(exception=None):
    """Close the database connection at the end of the request."""
    cursor = g.pop("db_cursor", None)
    conn = g.pop("db_conn", None)

    if cursor is not None:
        cursor.close()
    if conn is not None:
        if exception:
            conn.rollback()
        else:
            conn.commit()
        conn.close()

def init_app(app):
    """Register database teardown with the Flask app."""
    app.teardown_appcontext(close_db)

Note

ActiveDirectoryDefaultDefaultAzureCredential を使用します。これは、複数の認証情報プロバイダーを順番に試行します。 最初の接続は遅くなることがあります。なぜならSDKが動作するプロバイダーを見つけるまでチェーンを歩くからです。 本番環境では、環境がどの認証情報タイプを使っているか分かっているなら、チェーンウォークを避けるために直接指定してください(例えばマネージドIDの ActiveDirectoryMSI )。 詳細については、Microsoft Entra 認証に関するページを参照してください。

Flaskの応用

以下の例は、製品のリスト、取得、作成、更新、削除のルートを備えた完全なFlaskアプリケーションを示しています。

app.py を作る

アプリケーションモジュールはFlaskアプリを作成し、設定を読み込み、データベースの分解を登録します。 各ルート関数は get_db() を呼び出してカーソルを取得し、 パラメータ化されたSQL でクエリを実行し( %(name)s プレースホルダーと値の辞書を使用)、JSON応答を返します。

# app.py
from flask import Flask, jsonify, request, abort
from config import Config
from database import init_app, get_db

app = Flask(__name__)
app.config.from_object(Config)
init_app(app)

@app.route("/")
def index():
    return jsonify({"message": "Product API", "docs": "/products"})

@app.route("/products")
def list_products():
    """List products with pagination."""
    page = request.args.get("page", 1, type=int)
    page_size = request.args.get("page_size", 10, type=int)
    skip = (page - 1) * page_size

    cursor = get_db()

    cursor.execute("SELECT COUNT(*) FROM SalesLT.Product")
    total = cursor.fetchval()

    cursor.execute("""
        SELECT ProductID, Name, ProductNumber, ListPrice, Color, ProductCategoryID
        FROM SalesLT.Product
        ORDER BY ProductID
        OFFSET %(skip)s ROWS
        FETCH NEXT %(limit)s ROWS ONLY
    """, {"skip": skip, "limit": page_size})

    items = [{
        "id": row.ProductID,
        "name": row.Name,
        "product_number": row.ProductNumber,
        "price": float(row.ListPrice),
        "color": row.Color,
        "category_id": row.ProductCategoryID
    } for row in cursor.fetchall()]

    return jsonify({
        "items": items,
        "total": total,
        "page": page,
        "page_size": page_size,
        "pages": (total + page_size - 1) // page_size
    })

@app.route("/products/<int:product_id>")
def get_product(product_id):
    """Get a single product by ID."""
    cursor = get_db()
    cursor.execute("""
        SELECT ProductID, Name, ProductNumber, ListPrice, Color, ProductCategoryID
        FROM SalesLT.Product
        WHERE ProductID = %(id)s
    """, {"id": product_id})

    row = cursor.fetchone()
    if not row:
        abort(404)

    return jsonify({
        "id": row.ProductID,
        "name": row.Name,
        "product_number": row.ProductNumber,
        "price": float(row.ListPrice),
        "color": row.Color,
        "category_id": row.ProductCategoryID
    })

@app.route("/products", methods=["POST"])
def create_product():
    """Create a new product."""
    data = request.get_json()
    if not data:
        abort(400)

    cursor = get_db()

    # OUTPUT INSERTED returns the new row's columns in the same statement,
    # so you don't need a separate SELECT to get the generated ID and defaults.
    # ProductNumber is required and unique. StandardCost and SellStartDate are
    # also NOT NULL in SalesLT.Product, so supply values for them.
    cursor.execute("""
        INSERT INTO SalesLT.Product
            (Name, ProductNumber, ListPrice, Color, Size, ProductCategoryID, StandardCost, SellStartDate)
        OUTPUT INSERTED.ProductID, INSERTED.Name, INSERTED.ProductNumber, INSERTED.ListPrice,
               INSERTED.Color, INSERTED.ProductCategoryID
        VALUES (%(name)s, %(product_number)s, %(price)s, %(color)s, %(size)s, %(category_id)s, 0, GETDATE())
    """, {
        "name": data["name"],
        "product_number": data["product_number"],
        "price": data["price"],
        "color": data.get("color"),
        "size": data.get("size"),
        "category_id": data["category_id"]
    })

    row = cursor.fetchone()
    return jsonify({
        "id": row.ProductID,
        "name": row.Name,
        "product_number": row.ProductNumber,
        "price": float(row.ListPrice),
        "color": row.Color,
        "category_id": row.ProductCategoryID
    }), 201

@app.route("/products/<int:product_id>", methods=["PUT"])
def update_product(product_id):
    """Update an existing product."""
    data = request.get_json()
    if not data:
        abort(400)

    cursor = get_db()

    updates = []
    params = {"id": product_id}

    for field in ("name", "product_number", "price", "color", "category_id"):
        if field in data:
            col = {"name": "Name", "product_number": "ProductNumber",
                   "price": "ListPrice", "color": "Color",
                   "category_id": "ProductCategoryID"}[field]
            updates.append(f"{col} = %({field})s")
            params[field] = data[field]

    if not updates:
        abort(400)

    cursor.execute(f"""
        UPDATE SalesLT.Product SET {', '.join(updates)}
        OUTPUT INSERTED.ProductID, INSERTED.Name, INSERTED.ProductNumber, INSERTED.ListPrice,
               INSERTED.Color, INSERTED.ProductCategoryID
        WHERE ProductID = %(id)s
    """, params)

    row = cursor.fetchone()
    if not row:
        abort(404)

    return jsonify({
        "id": row.ProductID,
        "name": row.Name,
        "product_number": row.ProductNumber,
        "price": float(row.ListPrice),
        "color": row.Color,
        "category_id": row.ProductCategoryID
    })

@app.route("/products/<int:product_id>", methods=["DELETE"])
def delete_product(product_id):
    """Delete a product."""
    cursor = get_db()
    cursor.execute("DELETE FROM SalesLT.Product WHERE ProductID = %(id)s", {"id": product_id})
    if cursor.rowcount == 0:
        abort(404)
    return "", 204

@app.route("/health")
def health_check():
    """Check database connectivity."""
    try:
        cursor = get_db()
        cursor.execute("SELECT 1")
        return jsonify({"status": "healthy", "database": "connected"})
    except Exception as e:
        return jsonify({"status": "unhealthy", "error": str(e)}), 503

アプリケーションを実行する

開発サーバーを起動します。

flask --app app run --debug --port 5000

サーバーは http://localhost:5000を聞き取ります。 2つ目の端末を開き、 curl を使ってエンドポイントに呼びかけて、アプリがデータベースと通信しているか確認します:

# Check database connectivity
curl http://localhost:5000/health

# List the first page of products
curl "http://localhost:5000/products?page_size=5"

# Get a single product by ID
curl http://localhost:5000/products/680

Note

PowerShell では、 curlInvoke-WebRequestのエイリアスです。 ここでの簡単なGETコマンドは問題なく動作しますが、レスポンスは印刷されたJSONではなくオブジェクトとして返ってきます。 curl-X-Hのような-dフラグを使うコマンド(後のPOST例のように)は、書かれている通りには動作しません。 Windowsでは、curl.exeを使って示された通りのコマンドを実行するか、PowerShellのInvoke-RestMethod(例:Invoke-RestMethod http://localhost:5000/health)を使ってJSONレスポンスも解析してくれます。

各エンドポイントはJSONを返します。 また、ブラウザで http://localhost:5000/products を開くことでページ入りリストを見ることもできます。

コネクションプーリング

接続プーリングがなければ、各リクエストはMicrosoft SQLへのTCP接続を開閉し、遅延が発生します。 接続プーリングは、再利用できる状態で待機中の接続をまとめて維持します。 接続プーリングを有効にするには、モジュールレベルで mssql_python.pooling() を一度呼び出します。 プーリングを有効にすると、close_db の終了処理では conn.close() によって接続は閉じられず、代わりにプールに戻されます。

接続プールを有効にする

接続が開かれる前にモジュールレベルで mssql_python.pooling() を呼び出してプーリングを有効にします:

# database.py with connection pooling
import mssql_python
from flask import g, current_app

# Configure pool at module level
mssql_python.pooling(max_size=20, idle_timeout=300)

def get_db():
    """Get a database cursor with connection pooling."""
    if "db_conn" not in g:
        g.db_conn = mssql_python.connect(get_connection_string())
        g.db_cursor = g.db_conn.cursor()
    return g.db_cursor

エラー処理

Flaskは特定の例外タイプに対してハンドラーを登録できます。 mssql_python.DatabaseErrormssql_python.IntegrityErrorをキャッチすることで、デフォルトのHTMLエラーページではなく構造化されたJSONエラー応答を返すことができます。

エラーハンドラーを登録する

これらのハンドラーを既存の app.pyに加え、 app = Flask(__name__) ラインの後に行ってください。 ハンドラーは app オブジェクトを参照するため、アプリ作成後にハンドラーを呼び寄せなければなりません。 app.py の上部には import mssql_python が必要です。 ハンドラーはデフォルトのHTMLエラーページの代わりに構造化されたJSON応答を返します:

# app.py
import mssql_python

@app.errorhandler(mssql_python.DatabaseError)
def handle_database_error(error):
    """Handle database errors."""
    return jsonify({"error": "Database error occurred"}), 500

@app.errorhandler(mssql_python.IntegrityError)
def handle_integrity_error(error):
    """Handle integrity constraint violations."""
    error_msg = str(error)
    if "UNIQUE" in error_msg:
        return jsonify({"error": "Resource already exists"}), 409
    if "FOREIGN KEY" in error_msg:
        return jsonify({"error": "Referenced resource not found"}), 400
    return jsonify({"error": "Data integrity error"}), 400

@app.errorhandler(404)
def not_found(error):
    return jsonify({"error": "Resource not found"}), 404

@app.errorhandler(400)
def bad_request(error):
    return jsonify({"error": "Bad request"}), 400

Blueprints

アプリケーションが成長するにつれて、すべてのルートを1つのファイルにまとめるのは維持が難しくなります。 Flask Blueprintsでは、関連するルートをアプリに登録する別々のモジュールにまとめることができます。

設計図でルートを整理する

製品ルート用のブループリントモジュールを作成し、 get_db をインポートし、共有URLプレフィックスの下でエンドポイントを定義します:

# blueprints/products.py
from flask import Blueprint, jsonify, request, abort
from database import get_db

products_bp = Blueprint("products", __name__, url_prefix="/api/products")

@products_bp.route("/")
def list_products():
    """List all products."""
    cursor = get_db()
    cursor.execute("""
        SELECT ProductID, Name, ListPrice, Color, ProductCategoryID
        FROM SalesLT.Product ORDER BY ProductID
    """)
    return jsonify([{
        "id": row.ProductID,
        "name": row.Name,
        "price": float(row.ListPrice),
        "color": row.Color,
        "category_id": row.ProductCategoryID
    } for row in cursor.fetchall()])

@products_bp.route("/<int:product_id>")
def get_product(product_id):
    """Get a product by ID."""
    cursor = get_db()
    cursor.execute(
        "SELECT ProductID, Name, ListPrice, Color FROM SalesLT.Product WHERE ProductID = %(id)s",
        {"id": product_id}
    )
    row = cursor.fetchone()
    if not row:
        abort(404)
    return jsonify({"id": row.ProductID, "name": row.Name, "price": float(row.ListPrice), "color": row.Color})

設計図を登録してください

ブループリントをblueprints/products.pyとして保存し、空のblueprints/__init__.pyファイルを追加してフォルダPythonパッケージとして扱うようにしてください。 その後、 app.pyで他のインポートと共にブループリントをインポートし、 app = Flask(__name__) 行の後に登録します:

# app.py
from blueprints.products import products_bp

app.register_blueprint(products_bp)

ブループリントで url_prefix="/api/products" が設定されているため、そのルートはそのプレフィックスの下で提供されます。 例えば、リストルートはhttp://localhost:5000/api/products/で利用可能であり、/productsで直接定義されたapp.pyルートとは別です。

Testing

Flaskは、実際のHTTPサーバーを起動せずにアプリケーションにリクエストを送信するテストクライアントを提供します。 pytestフィクスチャーを使ってクライアントを作成し、テスト間で再利用します。

pytestによるテスト設定

pytestフィクスチャーを作成してテストクライアントを提供し、ルートの挙動を検証するテストを書きます:

# test_app.py
import uuid

import pytest
from app import app

@pytest.fixture
def client():
    app.config["TESTING"] = True
    with app.test_client() as client:
        yield client

def test_health_check(client):
    response = client.get("/health")
    assert response.status_code == 200
    data = response.get_json()
    assert data["status"] == "healthy"

def test_list_products(client):
    response = client.get("/products")
    assert response.status_code == 200
    data = response.get_json()
    assert "items" in data
    assert "total" in data

def test_create_product(client):
    suffix = uuid.uuid4().hex[:8]
    name = f"Test Product {suffix}"
    response = client.post("/products", json={
        "name": name,
        "product_number": f"TEST-{suffix}",
        "price": 19.99,
        "category_id": 18
    })
    assert response.status_code == 201
    data = response.get_json()
    assert data["name"] == name

def test_get_product_not_found(client):
    response = client.get("/products/99999")
    assert response.status_code == 404

これらのテストはモックではなくライブデータベースに対して実行されるため、test_create_productSalesLT.Productに実際の行を挿入します。 AdventureWorksLTでは、 NameProductNumber もそれぞれ固有の制約があるため、テストは各ランでそれぞれ固有の値を生成します。 もしそれらの値をハードコードすると、最初にその行を削除しない限り、2回目の実行時に競合が発生してテストは失敗します。

テストの実行

テストはプロジェクトフォルダに test_app.py として保存してください。 仮想環境を有効にした状態で pytest をインストールし、そのフォルダから実行してください。 pytestflaskと同じ仮想環境内でmssql-pythonをインストールして実行することで、テストがアプリで使うパッケージをインポートすることが可能になります。 pytest 自動的に test_app.py を発見し、結果を報告します:

pip install pytest
pytest

pytest 自動的に test_app.py を発見し、結果を報告します:

==================== test session starts ====================
collected 4 items

test_app.py ....                                       [100%]

===================== 4 passed in 3.21s =====================