Azure Static Web Apps & Azure Functions によるサーバーレスアプリケーション構築ガイド

はじめに

Microsoft Azure は、エンタープライズグレードのクラウドプラットフォームとして、豊富なサービスと高い信頼性を提供しています。本記事では、Azure Static Web Apps と Azure Functions を組み合わせたモダンなサーバーレスアプリケーションの構築方法を詳しく解説します。

Azure Static Web Apps の概要

Azure Static Web Apps は、静的サイトホスティングとサーバーレス API を統合したサービスです。GitHub Actions との深い統合により、CI/CD パイプラインを自動構築できます。

主な特徴

  • 自動 CI/CD: GitHub/Azure DevOps との統合
  • グローバル配信: CDN による高速配信
  • カスタムドメイン: SSL 証明書の自動管理
  • プレビュー環境: PR ごとの自動デプロイ
  • 認証統合: AAD、GitHub、Twitter などに対応

プロジェクトのセットアップ

1. Next.js アプリケーションの作成

# プロジェクトの作成
npx create-next-app@latest enhanced-azure-app --typescript --tailwind --app

cd enhanced-azure-app

# 必要なパッケージのインストール
npm install @azure/functions @azure/storage-blob @azure/cosmos
npm install -D @types/node

2. プロジェクト構造

enhanced-azure-app/
├── src/
│   ├── app/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── api/
│   ├── components/
│   └── lib/
├── api/              # Azure Functions
│   ├── GetProducts/
│   ├── CreateOrder/
│   └── host.json
├── next.config.js
├── package.json
└── staticwebapp.config.json

Azure Functions の実装

基本的な Function の作成

// api/GetProducts/index.ts
import { AzureFunction, Context, HttpRequest } from "@azure/functions"
import { CosmosClient } from "@azure/cosmos"

const cosmosClient = new CosmosClient({
  endpoint: process.env.COSMOS_ENDPOINT!,
  key: process.env.COSMOS_KEY!,
})

const httpTrigger: AzureFunction = async function (
  context: Context,
  req: HttpRequest
): Promise<void> {
  context.log('GetProducts function processed a request.')
  
  try {
    const database = cosmosClient.database('ProductDB')
    const container = database.container('Products')
    
    // クエリパラメータの取得
    const category = req.query.category || req.body?.category
    
    let querySpec = {
      query: "SELECT * FROM c WHERE c.isActive = true",
      parameters: [] as any[]
    }
    
    if (category) {
      querySpec.query += " AND c.category = @category"
      querySpec.parameters.push({
        name: "@category",
        value: category
      })
    }
    
    // Cosmos DB からデータ取得
    const { resources: products } = await container.items
      .query(querySpec)
      .fetchAll()
    
    context.res = {
      status: 200,
      headers: {
        'Content-Type': 'application/json',
      },
      body: {
        products,
        count: products.length,
        timestamp: new Date().toISOString()
      }
    }
  } catch (error) {
    context.log.error('Error fetching products:', error)
    
    context.res = {
      status: 500,
      body: {
        error: 'Internal Server Error',
        message: 'Failed to fetch products'
      }
    }
  }
}

export default httpTrigger

// api/GetProducts/function.json
{
  "bindings": [
    {
      "authLevel": "anonymous",
      "type": "httpTrigger",
      "direction": "in",
      "name": "req",
      "methods": ["get", "post"]
    },
    {
      "type": "http",
      "direction": "out",
      "name": "res"
    }
  ],
  "scriptFile": "../dist/GetProducts/index.js"
}

高度な Function の実装

// api/ProcessOrder/index.ts
import { AzureFunction, Context, HttpRequest } from "@azure/functions"
import { BlobServiceClient } from "@azure/storage-blob"
import { CosmosClient } from "@azure/cosmos"
import { v4 as uuidv4 } from 'uuid'

interface OrderItem {
  productId: string
  quantity: number
  price: number
}

interface Order {
  id?: string
  customerId: string
  items: OrderItem[]
  totalAmount: number
  status: 'pending' | 'processing' | 'completed' | 'cancelled'
  createdAt?: string
  updatedAt?: string
}

const cosmosClient = new CosmosClient({
  endpoint: process.env.COSMOS_ENDPOINT!,
  key: process.env.COSMOS_KEY!,
})

const blobServiceClient = BlobServiceClient.fromConnectionString(
  process.env.STORAGE_CONNECTION_STRING!
)

const httpTrigger: AzureFunction = async function (
  context: Context,
  req: HttpRequest
): Promise<void> {
  // リクエストの検証
  if (req.method !== 'POST') {
    context.res = {
      status: 405,
      body: 'Method Not Allowed'
    }
    return
  }
  
  try {
    const order: Order = req.body
    
    // 入力検証
    if (!order.customerId || !order.items || order.items.length === 0) {
      context.res = {
        status: 400,
        body: {
          error: 'Bad Request',
          message: 'Invalid order data'
        }
      }
      return
    }
    
    // 在庫確認
    const inventoryCheck = await checkInventory(order.items)
    if (!inventoryCheck.success) {
      context.res = {
        status: 400,
        body: {
          error: 'Insufficient Inventory',
          items: inventoryCheck.unavailableItems
        }
      }
      return
    }
    
    // 注文の作成
    const orderId = uuidv4()
    const orderData: Order = {
      ...order,
      id: orderId,
      status: 'pending',
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
      totalAmount: calculateTotal(order.items)
    }
    
    // Cosmos DB に保存
    const database = cosmosClient.database('OrderDB')
    const container = database.container('Orders')
    
    const { resource: createdOrder } = await container.items.create(orderData)
    
    // 注文確認書を Blob Storage に保存
    await saveOrderConfirmation(orderId, orderData)
    
    // 在庫を更新
    await updateInventory(order.items)
    
    // イベントをトリガー(Service Bus、Event Grid など)
    await triggerOrderProcessing(orderId)
    
    context.res = {
      status: 201,
      headers: {
        'Content-Type': 'application/json',
        'Location': `/api/orders/${orderId}`
      },
      body: {
        orderId,
        status: 'Order created successfully',
        order: createdOrder
      }
    }
    
  } catch (error) {
    context.log.error('Error processing order:', error)
    
    context.res = {
      status: 500,
      body: {
        error: 'Internal Server Error',
        message: 'Failed to process order'
      }
    }
  }
}

async function checkInventory(items: OrderItem[]): Promise<{
  success: boolean
  unavailableItems?: string[]
}> {
  const database = cosmosClient.database('ProductDB')
  const container = database.container('Inventory')
  
  const unavailableItems: string[] = []
  
  for (const item of items) {
    const { resource: inventory } = await container
      .item(item.productId, item.productId)
      .read()
    
    if (!inventory || inventory.availableQuantity < item.quantity) {
      unavailableItems.push(item.productId)
    }
  }
  
  return {
    success: unavailableItems.length === 0,
    unavailableItems
  }
}

async function updateInventory(items: OrderItem[]): Promise<void> {
  const database = cosmosClient.database('ProductDB')
  const container = database.container('Inventory')
  
  const promises = items.map(async (item) => {
    const { resource: inventory } = await container
      .item(item.productId, item.productId)
      .read()
    
    if (inventory) {
      inventory.availableQuantity -= item.quantity
      inventory.lastUpdated = new Date().toISOString()
      
      await container
        .item(item.productId, item.productId)
        .replace(inventory)
    }
  })
  
  await Promise.all(promises)
}

async function saveOrderConfirmation(
  orderId: string,
  order: Order
): Promise<void> {
  const containerClient = blobServiceClient
    .getContainerClient('order-confirmations')
  
  const blobName = `${orderId}/confirmation.json`
  const blockBlobClient = containerClient.getBlockBlobClient(blobName)
  
  const orderConfirmation = {
    ...order,
    confirmationNumber: generateConfirmationNumber(),
    generatedAt: new Date().toISOString()
  }
  
  await blockBlobClient.upload(
    JSON.stringify(orderConfirmation, null, 2),
    JSON.stringify(orderConfirmation).length,
    {
      blobHTTPHeaders: {
        blobContentType: 'application/json'
      }
    }
  )
}

function calculateTotal(items: OrderItem[]): number {
  return items.reduce((total, item) => {
    return total + (item.price * item.quantity)
  }, 0)
}

function generateConfirmationNumber(): string {
  return `ORD-${Date.now()}-${Math.random().toString(36).substr(2, 9).toUpperCase()}`
}

async function triggerOrderProcessing(orderId: string): Promise<void> {
  // Service Bus、Event Grid、またはその他のメッセージングサービスを使用
  // ここでは例として Service Bus を使用
  const { ServiceBusClient } = require("@azure/service-bus")
  
  const sbClient = new ServiceBusClient(process.env.SERVICE_BUS_CONNECTION_STRING!)
  const sender = sbClient.createSender('order-processing-queue')
  
  try {
    await sender.sendMessages({
      body: {
        orderId,
        action: 'process_order',
        timestamp: new Date().toISOString()
      }
    })
  } finally {
    await sender.close()
    await sbClient.close()
  }
}

export default httpTrigger

フロントエンドの実装

API クライアントの作成

// src/lib/api-client.ts
interface ApiConfig {
  baseUrl: string
  headers?: Record<string, string>
}

class ApiClient {
  private config: ApiConfig
  
  constructor(config: ApiConfig) {
    this.config = {
      ...config,
      headers: {
        'Content-Type': 'application/json',
        ...config.headers,
      },
    }
  }
  
  private async request<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<T> {
    const url = `${this.config.baseUrl}${endpoint}`
    
    const response = await fetch(url, {
      ...options,
      headers: {
        ...this.config.headers,
        ...options.headers,
      },
    })
    
    if (!response.ok) {
      throw new ApiError(response.status, await response.text())
    }
    
    return response.json()
  }
  
  async get<T>(endpoint: string): Promise<T> {
    return this.request<T>(endpoint, { method: 'GET' })
  }
  
  async post<T>(endpoint: string, data: any): Promise<T> {
    return this.request<T>(endpoint, {
      method: 'POST',
      body: JSON.stringify(data),
    })
  }
  
  async put<T>(endpoint: string, data: any): Promise<T> {
    return this.request<T>(endpoint, {
      method: 'PUT',
      body: JSON.stringify(data),
    })
  }
  
  async delete<T>(endpoint: string): Promise<T> {
    return this.request<T>(endpoint, { method: 'DELETE' })
  }
}

class ApiError extends Error {
  constructor(public status: number, message: string) {
    super(message)
    this.name = 'ApiError'
  }
}

// API クライアントのインスタンス作成
const apiClient = new ApiClient({
  baseUrl: process.env.NEXT_PUBLIC_API_URL || '/api',
})

// 型定義
export interface Product {
  id: string
  name: string
  description: string
  price: number
  category: string
  imageUrl: string
  inStock: boolean
}

export interface Order {
  id: string
  customerId: string
  items: OrderItem[]
  totalAmount: number
  status: string
  createdAt: string
}

export interface OrderItem {
  productId: string
  quantity: number
  price: number
}

// API 関数
export const productApi = {
  async getAll(category?: string): Promise<Product[]> {
    const params = category ? `?category=${category}` : ''
    const response = await apiClient.get<{ products: Product[] }>(
      `/products${params}`
    )
    return response.products
  },
  
  async getById(id: string): Promise<Product> {
    return apiClient.get<Product>(`/products/${id}`)
  },
  
  async create(product: Omit<Product, 'id'>): Promise<Product> {
    return apiClient.post<Product>('/products', product)
  },
  
  async update(id: string, product: Partial<Product>): Promise<Product> {
    return apiClient.put<Product>(`/products/${id}`, product)
  },
  
  async delete(id: string): Promise<void> {
    return apiClient.delete<void>(`/products/${id}`)
  },
}

export const orderApi = {
  async create(order: Omit<Order, 'id' | 'createdAt'>): Promise<Order> {
    return apiClient.post<Order>('/orders', order)
  },
  
  async getById(id: string): Promise<Order> {
    return apiClient.get<Order>(`/orders/${id}`)
  },
  
  async getByCustomer(customerId: string): Promise<Order[]> {
    const response = await apiClient.get<{ orders: Order[] }>(
      `/orders?customerId=${customerId}`
    )
    return response.orders
  },
  
  async updateStatus(
    id: string,
    status: Order['status']
  ): Promise<Order> {
    return apiClient.put<Order>(`/orders/${id}/status`, { status })
  },
}

React コンポーネントの実装

// src/app/products/page.tsx
'use client'

import { useState, useEffect } from 'react'
import { productApi, Product } from '@/lib/api-client'
import ProductCard from '@/components/ProductCard'
import LoadingSpinner from '@/components/LoadingSpinner'
import ErrorMessage from '@/components/ErrorMessage'

export default function ProductsPage() {
  const [products, setProducts] = useState<Product[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)
  const [selectedCategory, setSelectedCategory] = useState<string>('')
  
  useEffect(() => {
    loadProducts()
  }, [selectedCategory])
  
  const loadProducts = async () => {
    try {
      setLoading(true)
      setError(null)
      const data = await productApi.getAll(selectedCategory)
      setProducts(data)
    } catch (err) {
      setError('商品の読み込みに失敗しました')
      console.error('Error loading products:', err)
    } finally {
      setLoading(false)
    }
  }
  
  const categories = ['Electronics', 'Clothing', 'Books', 'Home & Garden']
  
  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-8">商品一覧</h1>
      
      {/* カテゴリフィルター */}
      <div className="mb-6">
        <select
          value={selectedCategory}
          onChange={(e) => setSelectedCategory(e.target.value)}
          className="px-4 py-2 border rounded-lg"
        >
          <option value="">すべてのカテゴリ</option>
          {categories.map((category) => (
            <option key={category} value={category}>
              {category}
            </option>
          ))}
        </select>
      </div>
      
      {/* コンテンツ */}
      {loading && <LoadingSpinner />}
      {error && <ErrorMessage message={error} onRetry={loadProducts} />}
      
      {!loading && !error && (
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
          {products.map((product) => (
            <ProductCard key={product.id} product={product} />
          ))}
        </div>
      )}
      
      {!loading && !error && products.length === 0 && (
        <p className="text-center text-gray-500 mt-8">
          商品が見つかりませんでした
        </p>
      )}
    </div>
  )
}

// src/components/ProductCard.tsx
import Image from 'next/image'
import Link from 'next/link'
import { Product } from '@/lib/api-client'

interface ProductCardProps {
  product: Product
}

export default function ProductCard({ product }: ProductCardProps) {
  return (
    <Link href={`/products/${product.id}`}>
      <div className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
        <div className="relative h-48">
          <Image
            src={product.imageUrl}
            alt={product.name}
            fill
            className="object-cover"
          />
          {!product.inStock && (
            <div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center">
              <span className="text-white font-bold">在庫切れ</span>
            </div>
          )}
        </div>
        
        <div className="p-4">
          <h3 className="font-semibold text-lg mb-2">{product.name}</h3>
          <p className="text-gray-600 text-sm mb-2 line-clamp-2">
            {product.description}
          </p>
          <div className="flex justify-between items-center">
            <span className="text-xl font-bold text-blue-600">
              ¥{product.price.toLocaleString()}
            </span>
            <span className="text-sm text-gray-500">{product.category}</span>
          </div>
        </div>
      </div>
    </Link>
  )
}

Azure へのデプロイ

staticwebapp.config.json の設定

{
  "navigationFallback": {
    "rewrite": "/index.html",
    "exclude": ["/api/*", "/_next/*", "/images/*"]
  },
  "routes": [
    {
      "route": "/api/*",
      "allowedRoles": ["anonymous"]
    },
    {
      "route": "/admin/*",
      "allowedRoles": ["authenticated", "admin"]
    }
  ],
  "responseOverrides": {
    "404": {
      "rewrite": "/404.html",
      "statusCode": 404
    }
  },
  "globalHeaders": {
    "X-Frame-Options": "DENY",
    "X-Content-Type-Options": "nosniff",
    "X-XSS-Protection": "1; mode=block",
    "Referrer-Policy": "strict-origin-when-cross-origin",
    "Permissions-Policy": "camera=(), microphone=(), geolocation=()"
  },
  "mimeTypes": {
    ".json": "application/json",
    ".js": "application/javascript",
    ".mjs": "application/javascript"
  },
  "platform": {
    "apiRuntime": "node:16"
  }
}

GitHub Actions ワークフロー

# .github/workflows/azure-static-web-apps.yml
name: Azure Static Web Apps CI/CD

on:
  push:
    branches:
      - main
  pull_request:
    types: [opened, synchronize, reopened, closed]
    branches:
      - main

jobs:
  build_and_deploy_job:
    if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed')
    runs-on: ubuntu-latest
    name: Build and Deploy Job
    steps:
      - uses: actions/checkout@v3
        with:
          submodules: true

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test
        env:
          CI: true

      - name: Build application
        run: npm run build
        env:
          NEXT_PUBLIC_API_URL: ${{ secrets.API_URL }}

      - name: Build And Deploy
        id: builddeploy
        uses: Azure/static-web-apps-deploy@v1
        with:
          azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
          repo_token: ${{ secrets.GITHUB_TOKEN }}
          action: "upload"
          app_location: "/"
          api_location: "api"
          output_location: "out"
          app_build_command: "npm run build && npm run export"
          api_build_command: "npm run build:api"
        env:
          COSMOS_ENDPOINT: ${{ secrets.COSMOS_ENDPOINT }}
          COSMOS_KEY: ${{ secrets.COSMOS_KEY }}
          STORAGE_CONNECTION_STRING: ${{ secrets.STORAGE_CONNECTION_STRING }}
          SERVICE_BUS_CONNECTION_STRING: ${{ secrets.SERVICE_BUS_CONNECTION_STRING }}

  close_pull_request_job:
    if: github.event_name == 'pull_request' && github.event.action == 'closed'
    runs-on: ubuntu-latest
    name: Close Pull Request Job
    steps:
      - name: Close Pull Request
        id: closepullrequest
        uses: Azure/static-web-apps-deploy@v1
        with:
          azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
          action: "close"

モニタリングとログ

Application Insights の統合

// src/lib/monitoring.ts
import { ApplicationInsights } from '@microsoft/applicationinsights-web'
import { ReactPlugin } from '@microsoft/applicationinsights-react-js'

let appInsights: ApplicationInsights | null = null
let reactPlugin: ReactPlugin | null = null

export const initializeMonitoring = () => {
  if (typeof window === 'undefined' || appInsights) return
  
  reactPlugin = new ReactPlugin()
  
  appInsights = new ApplicationInsights({
    config: {
      connectionString: process.env.NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING,
      extensions: [reactPlugin],
      enableAutoRouteTracking: true,
      disableFetchTracking: false,
      enableCorsCorrelation: true,
      enableRequestHeaderTracking: true,
      enableResponseHeaderTracking: true,
      correlationHeaderExcludedDomains: ['localhost'],
      disableAjaxTracking: false,
      autoTrackPageVisitTime: true,
      enableUnhandledPromiseRejectionTracking: true,
    },
  })
  
  appInsights.loadAppInsights()
  
  // カスタムプロパティの設定
  appInsights.addTelemetryInitializer((envelope) => {
    envelope.tags = envelope.tags || {}
    envelope.data = envelope.data || {}
    
    // 環境情報の追加
    envelope.tags['ai.cloud.role'] = 'frontend'
    envelope.data['environment'] = process.env.NODE_ENV
    
    return true
  })
}

// カスタムイベントのトラッキング
export const trackEvent = (name: string, properties?: Record<string, any>) => {
  if (!appInsights) return
  
  appInsights.trackEvent({
    name,
    properties: {
      ...properties,
      timestamp: new Date().toISOString(),
    },
  })
}

// エラートラッキング
export const trackException = (error: Error, severityLevel?: number) => {
  if (!appInsights) return
  
  appInsights.trackException({
    error,
    severityLevel: severityLevel || 3, // Error level
  })
}

// パフォーマンス測定
export const trackMetric = (
  name: string,
  average: number,
  properties?: Record<string, any>
) => {
  if (!appInsights) return
  
  appInsights.trackMetric({
    name,
    average,
    properties,
  })
}

// React エラーバウンダリー用フック
export const useErrorHandler = () => {
  return (error: Error, errorInfo: React.ErrorInfo) => {
    trackException(error, 3)
    
    // 追加情報の記録
    trackEvent('React Error Boundary', {
      componentStack: errorInfo.componentStack,
      errorMessage: error.message,
      errorStack: error.stack,
    })
  }
}

// src/app/layout.tsx
'use client'

import { useEffect } from 'react'
import { initializeMonitoring } from '@/lib/monitoring'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  useEffect(() => {
    initializeMonitoring()
  }, [])
  
  return (
    <html lang="ja">
      <body>{children}</body>
    </html>
  )
}

Function のロギング

// api/shared/logger.ts
import { Context } from "@azure/functions"

export interface Logger {
  info(message: string, ...args: any[]): void
  warn(message: string, ...args: any[]): void
  error(message: string, error?: Error, ...args: any[]): void
  metric(name: string, value: number, properties?: Record<string, any>): void
}

export class AzureLogger implements Logger {
  constructor(private context: Context) {}
  
  info(message: string, ...args: any[]): void {
    this.context.log.info(message, ...args)
    
    // Application Insights にも送信
    if (this.context.bindings.telemetry) {
      this.context.bindings.telemetry.push({
        name: 'CustomLog',
        properties: {
          level: 'INFO',
          message,
          args,
          timestamp: new Date().toISOString(),
        },
      })
    }
  }
  
  warn(message: string, ...args: any[]): void {
    this.context.log.warn(message, ...args)
    
    if (this.context.bindings.telemetry) {
      this.context.bindings.telemetry.push({
        name: 'CustomLog',
        properties: {
          level: 'WARN',
          message,
          args,
          timestamp: new Date().toISOString(),
        },
      })
    }
  }
  
  error(message: string, error?: Error, ...args: any[]): void {
    this.context.log.error(message, error, ...args)
    
    if (this.context.bindings.telemetry) {
      this.context.bindings.telemetry.push({
        name: 'CustomException',
        properties: {
          level: 'ERROR',
          message,
          error: error ? {
            name: error.name,
            message: error.message,
            stack: error.stack,
          } : null,
          args,
          timestamp: new Date().toISOString(),
        },
      })
    }
  }
  
  metric(name: string, value: number, properties?: Record<string, any>): void {
    if (this.context.bindings.telemetry) {
      this.context.bindings.telemetry.push({
        name: 'CustomMetric',
        properties: {
          metricName: name,
          value,
          ...properties,
          timestamp: new Date().toISOString(),
        },
      })
    }
  }
}

// 使用例
const httpTrigger: AzureFunction = async function (
  context: Context,
  req: HttpRequest
): Promise<void> {
  const logger = new AzureLogger(context)
  const startTime = Date.now()
  
  try {
    logger.info('Processing request', {
      method: req.method,
      url: req.url,
      headers: req.headers,
    })
    
    // 処理...
    
    const duration = Date.now() - startTime
    logger.metric('RequestDuration', duration, {
      endpoint: req.url,
      method: req.method,
    })
    
  } catch (error) {
    logger.error('Request processing failed', error as Error)
    throw error
  }
}

セキュリティのベストプラクティス

認証と認可

// src/lib/auth.ts
import { PublicClientApplication } from '@azure/msal-browser'
import { msalConfig } from './auth-config'

class AuthService {
  private msalInstance: PublicClientApplication
  
  constructor() {
    this.msalInstance = new PublicClientApplication(msalConfig)
  }
  
  async login(): Promise<void> {
    try {
      const loginResponse = await this.msalInstance.loginPopup({
        scopes: ['openid', 'profile', 'email', 'api://your-api-id/access_as_user'],
      })
      
      if (loginResponse) {
        this.msalInstance.setActiveAccount(loginResponse.account)
      }
    } catch (error) {
      console.error('Login failed:', error)
      throw error
    }
  }
  
  async logout(): Promise<void> {
    await this.msalInstance.logoutPopup()
  }
  
  async getToken(): Promise<string | null> {
    const account = this.msalInstance.getActiveAccount()
    
    if (!account) {
      throw new Error('No active account')
    }
    
    try {
      const response = await this.msalInstance.acquireTokenSilent({
        scopes: ['api://your-api-id/access_as_user'],
        account,
      })
      
      return response.accessToken
    } catch (error) {
      // トークンの更新が必要な場合
      const response = await this.msalInstance.acquireTokenPopup({
        scopes: ['api://your-api-id/access_as_user'],
      })
      
      return response.accessToken
    }
  }
  
  isAuthenticated(): boolean {
    return this.msalInstance.getActiveAccount() !== null
  }
  
  getAccount() {
    return this.msalInstance.getActiveAccount()
  }
}

export const authService = new AuthService()

// API クライアントでの認証トークン使用
export class AuthenticatedApiClient extends ApiClient {
  async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
    const token = await authService.getToken()
    
    const headers = {
      ...options.headers,
      Authorization: token ? `Bearer ${token}` : '',
    }
    
    return super.request<T>(endpoint, {
      ...options,
      headers,
    })
  }
}

環境変数の管理

// src/lib/config.ts
interface Config {
  api: {
    baseUrl: string
    timeout: number
  }
  auth: {
    clientId: string
    authority: string
    redirectUri: string
  }
  storage: {
    accountName: string
    containerName: string
  }
  monitoring: {
    connectionString: string
    enabled: boolean
  }
}

const getConfig = (): Config => {
  // 環境変数の検証
  const requiredEnvVars = [
    'NEXT_PUBLIC_API_URL',
    'NEXT_PUBLIC_AUTH_CLIENT_ID',
    'NEXT_PUBLIC_AUTH_AUTHORITY',
  ]
  
  for (const envVar of requiredEnvVars) {
    if (!process.env[envVar]) {
      throw new Error(`Missing required environment variable: ${envVar}`)
    }
  }
  
  return {
    api: {
      baseUrl: process.env.NEXT_PUBLIC_API_URL!,
      timeout: parseInt(process.env.NEXT_PUBLIC_API_TIMEOUT || '30000'),
    },
    auth: {
      clientId: process.env.NEXT_PUBLIC_AUTH_CLIENT_ID!,
      authority: process.env.NEXT_PUBLIC_AUTH_AUTHORITY!,
      redirectUri: process.env.NEXT_PUBLIC_AUTH_REDIRECT_URI || window.location.origin,
    },
    storage: {
      accountName: process.env.NEXT_PUBLIC_STORAGE_ACCOUNT!,
      containerName: process.env.NEXT_PUBLIC_STORAGE_CONTAINER!,
    },
    monitoring: {
      connectionString: process.env.NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING!,
      enabled: process.env.NEXT_PUBLIC_MONITORING_ENABLED === 'true',
    },
  }
}

export const config = getConfig()

パフォーマンス最適化

CDN とキャッシング戦略

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'export',
  
  // 画像最適化
  images: {
    loader: 'custom',
    loaderFile: './src/lib/image-loader.ts',
    domains: ['yourstorageaccount.blob.core.windows.net'],
  },
  
  // ヘッダー設定
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=31536000, immutable',
          },
        ],
      },
      {
        source: '/api/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'no-store, must-revalidate',
          },
        ],
      },
    ]
  },
  
  // 環境変数の型安全性
  env: {
    NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
    NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL,
  },
}

module.exports = nextConfig

// src/lib/image-loader.ts
export default function azureImageLoader({
  src,
  width,
  quality,
}: {
  src: string
  width: number
  quality?: number
}) {
  // Azure CDN の画像変換パラメータ
  const params = new URLSearchParams({
    w: width.toString(),
    q: (quality || 75).toString(),
    fm: 'webp',
    fit: 'cover',
  })
  
  // CDN URL の構築
  const cdnUrl = process.env.NEXT_PUBLIC_CDN_URL || 'https://yourcdn.azureedge.net'
  
  return `${cdnUrl}${src}?${params.toString()}`
}

まとめ

Azure Static Web Apps と Azure Functions を組み合わせることで、スケーラブルで高性能なサーバーレスアプリケーションを構築できます。

重要なポイント:

  • GitHub Actions との統合による自動 CI/CD
  • Azure Functions でのサーバーレス API 実装
  • Application Insights による包括的なモニタリング
  • Azure AD による認証・認可
  • CDN を活用したグローバル配信

エンハンスド株式会社では、Azure を活用したエンタープライズアプリケーション開発を支援しています。お気軽にお問い合わせください。


タグ: #Azure #StaticWebApps #AzureFunctions #Serverless #NextJS #TypeScript

執筆者: エンハンスド株式会社 クラウドソリューション部

公開日: 2024年12月20日