🎄 科学と神々株式会社 アドベントカレンダー 2025
License System Day 15: サーバーサイドの実装(Nim/Jester)
📖 今日のテーマ
今日は、Nimプログラミング言語とJesterフレームワークを使ったサーバーサイド実装を学びます。
高性能でメモリ効率に優れたNimと、シンプルなJesterフレームワークの組み合わせで、本番環境で使えるライセンスサーバーを構築します。
🎯 なぜNimとJesterなのか?
Nimの利点
✅ ネイティブコード生成 → C/C++並みの高速動作
✅ メモリ安全性 → ARCによる自動メモリ管理
✅ 低メモリ使用量 → 数MB以下で起動
✅ 小さいバイナリ → 1MB以下にコンパイル可能
✅ クロスプラットフォーム → Windows/macOS/Linux対応
✅ WebAssembly対応 → ブラウザ拡張機能に最適
✅ GC-safe保証 → スレッドセーフな非同期処理(Nim 2.2.6+)
✅ グレースフルシャットダウン → 本番環境での安全な停止
本実装のGC-safe設計(Nim 2.2.6対応)
{.threadvar.} プラグマの使用:
Jesterの非同期ルートハンドラ内でグローバル変数にアクセスする際、GC-safeチェックを満たすため、{.threadvar.} プラグマを使用しています。
# ❌ 古い実装(GC-unsafe警告が出る)
let db = newDatabase("path")
let cryptoService = newCryptoService()
# ✅ 新しい実装(GC-safe対応)
var db {.threadvar.}: Database
var cryptoService {.threadvar.}: CryptoService
{.threadvar.} の効果:
- スレッドローカル変数として宣言
- 非同期コンテキストからの安全なアクセスを保証
- Jesterはシングルスレッド (
--threads:off) なので、実質的にグローバル変数と同じ動作 - GC-safeチェックをパス(コンパイル警告なし)
Jesterフレームワークの特徴
✅ シンプルなルーティング → Sinatraライクな記法
✅ 非同期処理対応 → async/awaitサポート
✅ HTTPサーバー内蔵 → 追加サーバー不要
✅ ミドルウェア対応 → 認証・ロギング統合
✅ JSONサポート → REST APIに最適
🏗️ プロジェクト構造
server/
├── src/
│ ├── main.nim # メインエントリーポイント(ユーティリティ関数含む)
│ ├── crypto.nim # 暗号化・署名機能(パスワードハッシュ含む)
│ └── database.nim # データベース操作
├── tests/
│ └── test_server.nim # テストコード
├── bin/
│ └── license_server # コンパイル済みバイナリ
├── license_system_server.nimble # プロジェクト設定
└── nim.cfg # コンパイラ設定
注: 本実装では、ユーティリティ関数(UUID生成、パスワードハッシュなど)を個別ファイルに分離せず、機能ごとに統合しています:
-
hashPassword(),verifyPassword()→ crypto.nim に統合 -
generateUUID()→ main.nim に統合
💻 メインサーバー実装
main.nim(完全版)
# server/src/main.nim
import std/[json, strutils, times, random, os, posix]
import pkg/jester
import crypto, database
import ../../shared/types
randomize()
# Global instances (thread-local for GC-safe async access)
# Note: Jester runs in single-threaded mode (--threads:off), so threadvar behaves like global
var db {.threadvar.}: Database
var cryptoService {.threadvar.}: CryptoService
var isShuttingDown {.threadvar.}: bool
# === ユーティリティ関数(main.nim統合版) ===
proc generateUUID(): string =
## シンプルなUUID生成
randomize()
result = ""
for i in 0..<32:
if i in [8, 12, 16, 20]:
result.add("-")
result.add(toHex(rand(15), 1).toLowerAscii())
# 起動メッセージ
echo """
🚀 License Server Starting
============================================================
📍 Server: http://localhost:3000
🔐 Crypto: ECDSA P-256 (placeholder)
📊 Database: SQLite3
🌐 Environment: development
============================================================
📝 Demo Accounts:
Free Tier: free@example.com / password123
Premium Tier: premium@example.com / password123
Enterprise Tier: enterprise@example.com / password123
"""
# ルーティング定義
routes:
# ========================================
# ヘルスチェック(グレースフルシャットダウン対応)
# ========================================
get "/health":
## サーバーヘルスチェック
## シャットダウン中は503を返す(Kubernetes/Docker対応)
if isShuttingDown:
resp Http503, $(%*{
"status": "shutting_down",
"timestamp": $now(),
"message": "Server is gracefully shutting down"
})
else:
resp Http200, $(%*{
"status": "ok",
"timestamp": $now()
})
# ========================================
# ライセンス認証(アクティベーション)
# ========================================
post "/api/v1/license/activate":
## ライセンス認証エンドポイント
##
## Request:
## {
## "email": "user@example.com",
## "password": "password123",
## "client_id": "browser-extension-001"
## }
##
## Response:
## {
## "status": "activated",
## "activation_key": "JWT_TOKEN",
## "plan_type": "premium_monthly",
## "signature": "ECDSA_SIGNATURE"
## }
try:
# リクエストボディのパース
let body = parseJson(request.body)
let email = body["email"].getStr()
let password = body["password"].getStr()
let clientId = body["client_id"].getStr()
# ユーザー認証
let userOpt = db.getUserByEmail(email)
if userOpt.isNone:
resp Http401, %*{
"error": "Invalid credentials",
"message": "Email or password is incorrect"
}
let user = userOpt.get()
let userId = user[0]
let storedHash = user[2]
let isActive = user[3] == "1"
# パスワード検証(crypto.nimのverifyPassword使用)
if not cryptoService.verifyPassword(password, storedHash):
resp Http401, %*{
"error": "Invalid credentials",
"message": "Email or password is incorrect"
}
# アカウント有効性チェック
if not isActive:
resp Http403, %*{
"error": "Account disabled",
"message": "Your account has been disabled. Please contact support."
}
# アクティブなサブスクリプション取得
let subscriptionOpt = db.getActiveSubscription(userId)
if subscriptionOpt.isNone:
resp Http402, %*{
"error": "No active subscription",
"message": "Please subscribe to a plan to activate license"
}
let subscription = subscriptionOpt.get()
let subscriptionId = subscription[0]
let planType = parseEnum[PlanType](subscription[1])
let endDate = parse(subscription[2], "yyyy-MM-dd HH:mm:ss")
# サブスクリプション期限チェック
if now() > endDate:
resp Http402, %*{
"error": "Subscription expired",
"message": "Your subscription has expired. Please renew."
}
# ライセンス作成または取得(generateUUID使用)
let licenseId = generateUUID()
let activationKey = cryptoService.generateJWT(
userId = userId,
email = email,
planType = $planType,
expiresIn = 365 # 1年間有効
)
# ライセンスをDBに保存
let success = db.createLicense(
licenseId = licenseId,
subscriptionId = subscriptionId,
clientId = clientId,
activationKey = activationKey
)
if not success:
resp Http500, %*{"error": "Failed to create license"}
# レスポンス作成
let responseData = %*{
"status": "activated",
"activation_key": activationKey,
"license_id": licenseId,
"plan_type": $planType,
"expires_at": endDate.format("yyyy-MM-dd HH:mm:ss"),
"features": getPlanFeatures(planType)
}
# デジタル署名を追加
let signature = cryptoService.signData(responseData)
responseData["signature"] = %signature
resp Http200, responseData
except JsonParsingError:
resp Http400, %*{
"error": "Invalid JSON",
"message": "Request body must be valid JSON"
}
except Exception as e:
resp Http500, %*{
"error": "Internal server error",
"message": e.msg
}
# ========================================
# ライセンス検証
# ========================================
get "/api/v1/license/validate":
## ライセンス検証エンドポイント
##
## Headers:
## X-Activation-Key: JWT_TOKEN
##
## Response:
## {
## "status": "valid",
## "premium": true,
## "plan_type": "premium_monthly",
## "limits": {...},
## "features": {...}
## }
# アクティベーションキー取得
if not request.headers.hasKey("X-Activation-Key"):
resp Http401, %*{
"error": "Missing activation key",
"message": "X-Activation-Key header is required"
}
let token = request.headers["X-Activation-Key"]
# JWT検証
let claimsOpt = cryptoService.verifyJWT(token)
if claimsOpt.isNone:
resp Http401, %*{
"error": "Invalid token",
"message": "Activation key is invalid or expired"
}
let claims = claimsOpt.get()
let userId = claims["user_id"].getStr()
let planType = parseEnum[PlanType](claims["plan_type"].getStr())
let exp = claims["exp"].getInt()
# 有効期限チェック
if epochTime().int > exp:
resp Http401, %*{
"error": "Token expired",
"message": "Activation key has expired. Please reactivate."
}
# ライセンス情報取得
let licenseOpt = db.getLicenseByKey(token)
if licenseOpt.isNone:
resp Http401, %*{
"error": "License not found",
"message": "License has been revoked or does not exist"
}
let license = licenseOpt.get()
let isActive = license[5] == "1"
if not isActive:
resp Http403, %*{
"error": "License revoked",
"message": "This license has been revoked"
}
# バリデーションカウント更新
db.updateLicenseValidation(token)
# プラン設定取得
let limits = getPlanLimits(planType)
let features = getPlanFeatures(planType)
# レスポンス作成
let responseData = %*{
"status": "valid",
"premium": planType != ptFree,
"plan_type": $planType,
"user_id": userId,
"limits": limits,
"features": features,
"validated_at": now().format("yyyy-MM-dd HH:mm:ss")
}
# 署名追加
let signature = cryptoService.signData(responseData)
responseData["signature"] = %signature
resp Http200, responseData
# ========================================
# エコーサービス(レート制限付き)
# ========================================
post "/api/v1/service/echo":
## エコーサービス - レートリミット適用
##
## Headers:
## X-Activation-Key: JWT_TOKEN (optional)
##
## Request:
## {"message": "Hello, World!"}
##
## Response:
## {
## "echo": "[PREMIUM] HELLO, WORLD!",
## "plan": "premium_monthly",
## "rate_limit": {...}
## }
var userId = "anonymous"
var planType = ptFree
var limits = getPlanLimits(ptFree)
# JWT検証(任意)
if request.headers.hasKey("X-Activation-Key"):
let token = request.headers["X-Activation-Key"]
let claimsOpt = cryptoService.verifyJWT(token)
if claimsOpt.isSome:
let claims = claimsOpt.get()
userId = claims["user_id"].getStr()
planType = parseEnum[PlanType](claims["plan_type"].getStr())
limits = getPlanLimits(planType)
# レートリミットチェック
if not db.checkRateLimit(userId, "echo", limits.maxRequestsPerHour):
let status = db.getRateLimitStatus(userId, "echo", limits.maxRequestsPerHour)
resp Http429, [
("X-RateLimit-Limit", $limits.maxRequestsPerHour),
("X-RateLimit-Remaining", "0"),
("X-RateLimit-Reset", status.resetAt.format("yyyy-MM-dd'T'HH:mm:ss'Z'"))
], %*{
"error": "Rate limit exceeded",
"message": "Too many requests. Please upgrade your plan or try again later.",
"rate_limit": {
"limit": limits.maxRequestsPerHour,
"remaining": 0,
"reset_at": status.resetAt.format("yyyy-MM-dd HH:mm:ss")
},
"upgrade_url": "https://example.com/upgrade"
}
# リクエスト処理
let body = parseJson(request.body)
let message = body["message"].getStr()
# メッセージ長チェック
if message.len > limits.maxMessageLength:
resp Http413, %*{
"error": "Message too long",
"message": "Message exceeds plan limit",
"limit": limits.maxMessageLength,
"received": message.len
}
# エコー処理
var echoResult = message
if planType == ptEnterprise:
echoResult = "[ENTERPRISE] " & message.toUpperAscii() & " [PRIORITY]"
elif planType == ptPremium:
echoResult = "[PREMIUM] " & message.toUpperAscii()
else:
echoResult = "[FREE] " & message
# レート制限情報を取得
let status = db.getRateLimitStatus(userId, "echo", limits.maxRequestsPerHour)
# レスポンス
resp Http200, [
("X-RateLimit-Limit", $limits.maxRequestsPerHour),
("X-RateLimit-Remaining", $status.remaining),
("X-RateLimit-Reset", status.resetAt.format("yyyy-MM-dd'T'HH:mm:ss'Z'"))
], %*{
"echo": echoResult,
"plan": $planType,
"rate_limit": {
"limit": limits.maxRequestsPerHour,
"remaining": status.remaining,
"reset_at": status.resetAt.format("yyyy-MM-dd HH:mm:ss")
}
}
# ========================================
# プラン一覧
# ========================================
get "/api/v1/plans":
## プラン一覧取得
let plans = comparePlans()
resp Http200, plans
# グレースフルシャットダウン実装
proc cleanup() =
## Cleanup resources before shutdown
echo ""
echo "🛑 Shutting down gracefully..."
stdout.flushFile()
# Close database connection
try:
echo "📦 Closing database connection..."
stdout.flushFile()
db.close()
echo "✅ Database connection closed"
stdout.flushFile()
except Exception as e:
echo "⚠️ Error closing database: " & e.msg
stdout.flushFile()
echo "✅ Cleanup completed"
stdout.flushFile()
proc handleSignal(sig: cint) {.noconv.} =
## Signal handler for SIGTERM and SIGINT
if isShuttingDown:
echo ""
echo "⚠️ Force shutdown requested, exiting immediately..."
stdout.flushFile()
quit(1)
isShuttingDown = true
echo ""
echo "📡 Received shutdown signal (", sig, ")"
stdout.flushFile()
cleanup()
quit(0)
proc setupSignalHandlers() =
## Setup signal handlers for graceful shutdown
# Setup SIGINT handler (Ctrl+C)
setControlCHook(proc() {.noconv.} =
handleSignal(SIGINT)
)
# Setup signal handlers
setupSignalHandlers()
echo "✅ Signal handlers configured (Ctrl+C for graceful shutdown)"
stdout.flushFile()
# サーバー起動
runForever()
🔐 暗号化モジュール(crypto.nim)
注: このファイルにはパスワードハッシュ関数(hashPassword, verifyPassword)も統合されています。
# server/src/crypto.nim
import std/[json, times, base64, hmac, sha, options]
import nimcrypto
import bcrypt # パスワードハッシュ用
type
CryptoService* = ref object
privateKey: string
publicKey: string
jwtSecret: string
proc newCryptoService*(privateKeyPath, publicKeyPath: string): CryptoService =
result = CryptoService(
privateKey: readFile(privateKeyPath),
publicKey: readFile(publicKeyPath),
jwtSecret: "your-secret-key-replace-in-production"
)
# === パスワードハッシュ関数(crypto.nim統合版) ===
proc hashPassword*(password: string): string =
## bcryptでパスワードハッシュ化
result = hash(password, generateSalt(10))
proc verifyPassword*(password, hash: string): bool =
## パスワード検証
result = compare(password, hash)
# === JWT関連機能 ===
proc generateJWT*(self: CryptoService, userId, email, planType: string, expiresIn: int = 365): string =
## JWT生成(有効期限: 日数)
let now = epochTime().int
let exp = now + (expiresIn * 24 * 3600)
# ヘッダー
let header = %*{
"alg": "HS256",
"typ": "JWT"
}
# ペイロード
let payload = %*{
"user_id": userId,
"email": email,
"plan_type": planType,
"iat": now,
"exp": exp
}
# Base64URL エンコード
let headerEncoded = encode($header).replace("+", "-").replace("/", "_").replace("=", "")
let payloadEncoded = encode($payload).replace("+", "-").replace("/", "_").replace("=", "")
# 署名生成
let message = headerEncoded & "." & payloadEncoded
var hmacCtx: HMAC[sha256]
hmacCtx.init(self.jwtSecret)
hmacCtx.update(message)
let signature = encode(hmacCtx.finish().data).replace("+", "-").replace("/", "_").replace("=", "")
# JWT完成
result = message & "." & signature
proc verifyJWT*(self: CryptoService, token: string): Option[JsonNode] =
## JWT検証
try:
let parts = token.split(".")
if parts.len != 3:
return none(JsonNode)
# 署名検証
let message = parts[0] & "." & parts[1]
var hmacCtx: HMAC[sha256]
hmacCtx.init(self.jwtSecret)
hmacCtx.update(message)
let expectedSignature = encode(hmacCtx.finish().data).replace("+", "-").replace("/", "_").replace("=", "")
if parts[2] != expectedSignature:
return none(JsonNode)
# ペイロード復号化
let payloadDecoded = decode(parts[1].replace("-", "+").replace("_", "/"))
let payload = parseJson(payloadDecoded)
# 有効期限チェック
let exp = payload["exp"].getInt()
if epochTime().int > exp:
return none(JsonNode)
return some(payload)
except:
return none(JsonNode)
proc signData*(self: CryptoService, data: JsonNode): string =
## データ署名(ECDSA P-256プレースホルダー)
##
## 本番環境では実際のECDSA署名を実装してください
let canonical = $data
var hmacCtx: HMAC[sha256]
hmacCtx.init("server-secret-key-replace-with-ecdsa")
hmacCtx.update(canonical)
let digest = hmacCtx.finish()
result = encode(digest.data)
🛑 グレースフルシャットダウン
本番環境での安全な停止を実現するため、シグナルハンドラとクリーンアップ処理を実装しています(上記 main.nim のコード内に統合済み)。
グレースフルシャットダウンの動作:
-
SIGINT (Ctrl+C) 受信時:
-
isShuttingDownフラグをtrueに設定 -
/healthエンドポイントが 503 を返すようになる - データベース接続を安全に閉じる
- サーバーを終了
-
-
Kubernetes/Docker対応:
- ヘルスチェックで 503 を返すことで、トラフィックを他のインスタンスへ流す
- クリーンな停止によりデータ損失を防ぐ
🌟 まとめ
Nim/Jester サーバー実装の要点:
-
Nimの利点
- ネイティブコード性能
- 低メモリ使用量(~10MB)
- WebAssembly対応
- GC-safe保証(Nim 2.2.6+)
-
Jesterフレームワーク
- シンプルなルーティング
- 非同期対応
- RESTful API
-
{.threadvar.}プラグマでGC-safe対応
-
実装機能
- JWT認証
- レートリミット
- プラン管理
- グレースフルシャットダウン
-
セキュリティ
- bcryptパスワード(crypto.nimに統合)
- ECDSA署名(プレースホルダー)
- トークン検証
-
本番環境対応
- シグナルハンドリング(SIGINT/Ctrl+C)
- データベース接続の安全なクローズ
- Kubernetes/Docker対応のヘルスチェック
-
コード構成
- ユーティリティ関数を個別ファイルに分離せず機能ごとに統合
-
hashPassword(),verifyPassword()→ crypto.nim -
generateUUID()→ main.nim - シンプルで保守しやすい3ファイル構成(main.nim, crypto.nim, database.nim)
前回: Day 14: 階層型プランの設計
次回: Day 16: クライアントサイドの実装(WebAssembly)
Happy Learning! 🎉