乐闻世界logo
搜索文章和话题

服务端面试题手册

Service Worker 中的 Background Sync 是什么?如何使用?

Service Worker Background Sync 详解Background Sync(后台同步)是 Service Worker 的重要功能,允许在网络恢复后自动执行延迟的任务,特别适用于离线场景下的数据同步。核心概念Background Sync 解决了什么问题:用户在离线状态下提交表单或操作网络恢复后自动同步数据到服务器无需用户手动重试操作使用场景表单提交:离线时保存表单,联网后自动提交消息发送:离线时保存消息,联网后自动发送数据同步:离线操作的数据在联网后同步日志上传:离线日志在网络恢复后批量上传实现步骤1. 注册同步事件// main.jsasync function registerBackgroundSync(tag) { const registration = await navigator.serviceWorker.ready; try { await registration.sync.register(tag); console.log(`后台同步已注册: ${tag}`); } catch (error) { console.error('后台同步注册失败:', error); }}// 使用示例async function submitForm(data) { // 先保存到本地数据库 await saveToIndexedDB('pending-forms', data); // 注册后台同步 await registerBackgroundSync('sync-forms'); // 如果在线,立即尝试发送 if (navigator.onLine) { try { await sendToServer(data); await removeFromIndexedDB('pending-forms', data.id); } catch (error) { console.log('发送失败,将在网络恢复后重试'); } }}2. Service Worker 处理同步// sw.js// 监听 sync 事件self.addEventListener('sync', event => { console.log('后台同步触发:', event.tag); if (event.tag === 'sync-forms') { event.waitUntil(syncPendingForms()); } else if (event.tag === 'sync-messages') { event.waitUntil(syncPendingMessages()); }});// 同步待提交的表单async function syncPendingForms() { const pendingForms = await getFromIndexedDB('pending-forms'); for (const form of pendingForms) { try { await fetch('/api/submit-form', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) }); // 同步成功,从待处理列表移除 await removeFromIndexedDB('pending-forms', form.id); // 通知用户 self.registration.showNotification('同步成功', { body: '您的表单已成功提交', icon: '/icons/success.png' }); } catch (error) { console.error('表单同步失败:', error); // 失败后会自动重试 throw error; } }}// 同步待发送的消息async function syncPendingMessages() { const messages = await getFromIndexedDB('pending-messages'); const syncPromises = messages.map(async message => { try { await fetch('/api/send-message', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(message) }); await removeFromIndexedDB('pending-messages', message.id); } catch (error) { console.error('消息同步失败:', error); throw error; } }); return Promise.all(syncPromises);}3. IndexedDB 辅助函数// db.js - IndexedDB 操作封装const DB_NAME = 'background-sync-db';const DB_VERSION = 1;function openDB() { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); request.onupgradeneeded = event => { const db = event.target.result; // 创建存储对象 if (!db.objectStoreNames.contains('pending-forms')) { db.createObjectStore('pending-forms', { keyPath: 'id' }); } if (!db.objectStoreNames.contains('pending-messages')) { db.createObjectStore('pending-messages', { keyPath: 'id' }); } }; });}async function saveToIndexedDB(storeName, data) { const db = await openDB(); return new Promise((resolve, reject) => { const transaction = db.transaction(storeName, 'readwrite'); const store = transaction.objectStore(storeName); const request = store.put(data); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); });}async function getFromIndexedDB(storeName) { const db = await openDB(); return new Promise((resolve, reject) => { const transaction = db.transaction(storeName, 'readonly'); const store = transaction.objectStore(storeName); const request = store.getAll(); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); });}async function removeFromIndexedDB(storeName, id) { const db = await openDB(); return new Promise((resolve, reject) => { const transaction = db.transaction(storeName, 'readwrite'); const store = transaction.objectStore(storeName); const request = store.delete(id); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); });}完整示例:离线表单提交// form-handler.jsclass OfflineFormHandler { constructor(formId) { this.form = document.getElementById(formId); this.setupEventListeners(); } setupEventListeners() { this.form.addEventListener('submit', async (e) => { e.preventDefault(); await this.handleSubmit(); }); } async handleSubmit() { const formData = new FormData(this.form); const data = { id: Date.now().toString(), timestamp: Date.now(), ...Object.fromEntries(formData) }; // 显示提交中状态 this.showStatus('保存中...'); try { // 保存到本地 await saveToIndexedDB('pending-forms', data); // 注册后台同步 await this.registerSync('sync-forms'); // 尝试立即发送 if (navigator.onLine) { await this.sendImmediately(data); } else { this.showStatus('已离线保存,联网后自动同步'); } this.form.reset(); } catch (error) { this.showStatus('保存失败: ' + error.message); } } async registerSync(tag) { const registration = await navigator.serviceWorker.ready; if ('sync' in registration) { await registration.sync.register(tag); } else { // 降级方案:立即发送或提示用户 console.log('浏览器不支持后台同步'); } } async sendImmediately(data) { try { await fetch('/api/submit-form', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); await removeFromIndexedDB('pending-forms', data.id); this.showStatus('提交成功!'); } catch (error) { this.showStatus('提交失败,将在后台重试'); } } showStatus(message) { const statusEl = document.getElementById('form-status'); statusEl.textContent = message; setTimeout(() => { statusEl.textContent = ''; }, 3000); }}// 初始化const formHandler = new OfflineFormHandler('my-form');定期后台同步(Periodic Background Sync)// 请求定期同步权限async function requestPeriodicSync() { const registration = await navigator.serviceWorker.ready; try { await registration.periodicSync.register('daily-sync', { minInterval: 24 * 60 * 60 * 1000 // 最少 24 小时 }); console.log('定期同步已注册'); } catch (error) { console.error('定期同步注册失败:', error); }}// sw.js 处理定期同步self.addEventListener('periodicsync', event => { if (event.tag === 'daily-sync') { event.waitUntil(performDailySync()); }});async function performDailySync() { // 执行每日同步任务 const data = await fetch('/api/daily-data').then(r => r.json()); await caches.open('daily-cache').then(cache => { cache.put('/daily-data', new Response(JSON.stringify(data))); });}浏览器兼容性| 功能 | Chrome | Firefox | Safari | Edge ||------|--------|---------|--------|------|| Background Sync | ✅ | ❌ | ❌ | ✅ || Periodic Sync | ✅ | ❌ | ❌ | ✅ |最佳实践数据持久化:使用 IndexedDB 存储待同步数据错误处理:同步失败时抛出错误以触发重试幂等性设计:确保重复执行不会产生副作用用户反馈:同步完成后通知用户降级方案:为不支持的浏览器提供替代方案// 降级方案示例async function syncWithFallback() { const registration = await navigator.serviceWorker.ready; if ('sync' in registration) { // 使用后台同步 await registration.sync.register('sync-data'); } else { // 降级:立即尝试或提示用户 if (navigator.onLine) { await syncData(); } else { alert('您处于离线状态,请联网后重试'); } }}
阅读 0·3月6日 22:01

Service Worker 的浏览器兼容性如何?如何处理兼容性问题?

Service Worker 浏览器兼容性详解Service Worker 作为现代 Web 技术,在不同浏览器中的支持程度不同。了解兼容性情况并提供降级方案是开发 PWA 的重要环节。浏览器支持情况主流浏览器支持度| 浏览器 | 版本 | 支持情况 ||--------|------|----------|| Chrome | 45+ | ✅ 完全支持 || Firefox | 44+ | ✅ 完全支持 || Safari | 11.1+ | ✅ 支持(部分功能受限) || Edge | 17+ | ✅ 完全支持 || IE | 所有版本 | ❌ 不支持 || Opera | 32+ | ✅ 完全支持 || iOS Safari | 11.3+ | ✅ 支持(部分功能受限) || Android Chrome | 45+ | ✅ 完全支持 || Samsung Internet | 4+ | ✅ 完全支持 |功能兼容性详细对比| 功能 | Chrome | Firefox | Safari | Edge ||------|--------|---------|--------|------|| Service Worker 基础 | ✅ | ✅ | ✅ | ✅ || Cache API | ✅ | ✅ | ✅ | ✅ || Push API | ✅ | ✅ | ✅ (16.4+) | ✅ || Background Sync | ✅ | ❌ | ❌ | ✅ || Periodic Background Sync | ✅ | ❌ | ❌ | ✅ || Notification | ✅ | ✅ | ✅ | ✅ || Add to Home Screen | ✅ | ✅ | ✅ (部分) | ✅ |兼容性检测基础功能检测// 检测 Service Worker 支持function isServiceWorkerSupported() { return 'serviceWorker' in navigator;}// 检测 Cache API 支持function isCacheAPISupported() { return 'caches' in window;}// 检测 Push API 支持function isPushAPISupported() { return 'PushManager' in window;}// 检测 Background Sync 支持function isBackgroundSyncSupported() { return 'sync' in ServiceWorkerRegistration.prototype;}// 检测 Notification 支持function isNotificationSupported() { return 'Notification' in window;}综合兼容性检测// 详细的兼容性检测function checkServiceWorkerCompatibility() { const features = { serviceWorker: 'serviceWorker' in navigator, cacheAPI: 'caches' in window, pushAPI: 'PushManager' in window, backgroundSync: 'sync' in ServiceWorkerRegistration.prototype, periodicSync: 'periodicSync' in ServiceWorkerRegistration.prototype, notification: 'Notification' in window, addToHomeScreen: 'BeforeInstallPromptEvent' in window, backgroundFetch: 'BackgroundFetchManager' in window }; const supportedFeatures = Object.entries(features) .filter(([_, supported]) => supported) .map(([name]) => name); const unsupportedFeatures = Object.entries(features) .filter(([_, supported]) => !supported) .map(([name]) => name); console.log('支持的特性:', supportedFeatures); console.log('不支持的特性:', unsupportedFeatures); return { isFullySupported: features.serviceWorker && features.cacheAPI, features, supportedFeatures, unsupportedFeatures };}// 使用示例const compatibility = checkServiceWorkerCompatibility();if (!compatibility.isFullySupported) { console.warn('当前浏览器不完全支持 Service Worker');}渐进增强策略1. 基础降级方案// 主线程代码if ('serviceWorker' in navigator) { // 支持 Service Worker,正常注册 navigator.serviceWorker.register('/sw.js') .then(registration => { console.log('Service Worker 注册成功:', registration); }) .catch(error => { console.error('Service Worker 注册失败:', error); // 启用降级方案 enableFallbackMode(); });} else { // 不支持 Service Worker,启用降级方案 console.log('浏览器不支持 Service Worker'); enableFallbackMode();}// 降级模式function enableFallbackMode() { // 1. 使用传统的 localStorage/sessionStorage 缓存 // 2. 禁用离线功能 // 3. 显示提示信息 document.body.classList.add('no-sw-support'); // 显示提示 const banner = document.createElement('div'); banner.className = 'compatibility-banner'; banner.innerHTML = ` <p>您的浏览器不支持离线功能,请使用现代浏览器获得最佳体验</p> <button onclick="this.parentElement.remove()">知道了</button> `; document.body.appendChild(banner);}2. 功能分级支持// 根据支持的功能级别提供不同体验class PWACompatManager { constructor() { this.level = this.detectSupportLevel(); this.init(); } detectSupportLevel() { if (!('serviceWorker' in navigator)) { return 'basic'; // 基础模式 } if (!('sync' in ServiceWorkerRegistration.prototype)) { return 'standard'; // 标准模式(无后台同步) } if (!('periodicSync' in ServiceWorkerRegistration.prototype)) { return 'advanced'; // 高级模式(无定期同步) } return 'full'; // 完整模式 } init() { switch (this.level) { case 'full': this.enableAllFeatures(); break; case 'advanced': this.enableAdvancedFeatures(); break; case 'standard': this.enableStandardFeatures(); break; case 'basic': this.enableBasicFeatures(); break; } } enableAllFeatures() { console.log('启用所有功能'); this.registerServiceWorker(); this.enablePushNotifications(); this.enableBackgroundSync(); this.enablePeriodicSync(); } enableAdvancedFeatures() { console.log('启用高级功能(无定期同步)'); this.registerServiceWorker(); this.enablePushNotifications(); this.enableBackgroundSync(); } enableStandardFeatures() { console.log('启用标准功能(无后台同步)'); this.registerServiceWorker(); this.enablePushNotifications(); // 使用 setTimeout 模拟后台同步 this.simulateBackgroundSync(); } enableBasicFeatures() { console.log('启用基础功能(仅在线模式)'); // 使用 localStorage 缓存 this.enableLocalStorageCache(); // 显示升级提示 this.showUpgradePrompt(); } registerServiceWorker() { navigator.serviceWorker.register('/sw.js'); } enablePushNotifications() { if ('Notification' in window) { Notification.requestPermission(); } } enableBackgroundSync() { // 实现后台同步 } enablePeriodicSync() { // 实现定期同步 } simulateBackgroundSync() { // 使用 setInterval 模拟 setInterval(() => { if (navigator.onLine) { this.syncPendingData(); } }, 60000); } enableLocalStorageCache() { // 使用 localStorage 实现简单缓存 } showUpgradePrompt() { // 显示浏览器升级提示 }}// 初始化const pwaManager = new PWACompatManager();3. Polyfill 方案// Cache API Polyfill(简化版)if (!('caches' in window)) { window.caches = { _cacheStorage: new Map(), open(cacheName) { if (!this._cacheStorage.has(cacheName)) { this._cacheStorage.set(cacheName, new Map()); } const cache = this._cacheStorage.get(cacheName); return Promise.resolve({ match(request) { const url = typeof request === 'string' ? request : request.url; const item = cache.get(url); if (item && Date.now() - item.timestamp < 3600000) { return Promise.resolve(new Response(item.body)); } return Promise.resolve(undefined); }, put(request, response) { const url = typeof request === 'string' ? request : request.url; return response.text().then(body => { cache.set(url, { body, timestamp: Date.now() }); }); }, delete(request) { const url = typeof request === 'string' ? request : request.url; return Promise.resolve(cache.delete(url)); }, keys() { return Promise.resolve(Array.from(cache.keys()).map(url => new Request(url))); } }); }, keys() { return Promise.resolve(Array.from(this._cacheStorage.keys())); }, delete(cacheName) { return Promise.resolve(this._cacheStorage.delete(cacheName)); }, match(request) { const promises = Array.from(this._cacheStorage.values()).map(cache => { const url = typeof request === 'string' ? request : request.url; const item = cache.get(url); return item ? new Response(item.body) : undefined; }); return Promise.all(promises).then(results => { return results.find(response => response !== undefined); }); } };}特定浏览器处理Safari 特殊处理// Safari 有一些特殊限制function handleSafariSpecifics() { const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); if (isSafari) { // Safari 需要用户交互才能显示通知 document.addEventListener('click', () => { if (Notification.permission === 'default') { Notification.requestPermission(); } }, { once: true }); // Safari 的 Service Worker 有一些限制 // 例如:某些情况下不会自动更新 setInterval(() => { navigator.serviceWorker.ready.then(registration => { registration.update(); }); }, 60 * 60 * 1000); // 每小时检查更新 }}iOS 特殊处理// iOS 有一些特殊限制function handleIOSSpecifics() { const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); if (isIOS) { // iOS 的存储限制更严格 // 需要更积极地清理缓存 // iOS 的 Service Worker 在后台运行时间有限 // 需要优化同步策略 // iOS 的 Add to Home Screen 需要特殊处理 if ('standalone' in navigator) { // 已经在主屏幕模式运行 console.log('Running in standalone mode'); } }}测试策略1. 浏览器测试矩阵// 测试不同浏览器和版本const testMatrix = [ { browser: 'Chrome', version: 'latest', os: 'Windows' }, { browser: 'Chrome', version: 'latest', os: 'macOS' }, { browser: 'Chrome', version: 'latest', os: 'Android' }, { browser: 'Firefox', version: 'latest', os: 'Windows' }, { browser: 'Safari', version: 'latest', os: 'macOS' }, { browser: 'Safari', version: 'latest', os: 'iOS' }, { browser: 'Edge', version: 'latest', os: 'Windows' }];2. 功能检测测试// 自动化兼容性测试async function runCompatibilityTests() { const tests = { 'Service Worker 注册': async () => { if (!('serviceWorker' in navigator)) return 'skipped'; const reg = await navigator.serviceWorker.register('/sw.js'); return reg ? 'passed' : 'failed'; }, 'Cache API 使用': async () => { if (!('caches' in window)) return 'skipped'; const cache = await caches.open('test'); await cache.put('/test', new Response('test')); const response = await cache.match('/test'); return response ? 'passed' : 'failed'; }, '推送通知': async () => { if (!('Notification' in window)) return 'skipped'; const permission = await Notification.requestPermission(); return permission === 'granted' ? 'passed' : 'denied'; } }; const results = {}; for (const [name, test] of Object.entries(tests)) { try { results[name] = await test(); } catch (error) { results[name] = `error: ${error.message}`; } } console.table(results); return results;}最佳实践渐进增强:基础功能在所有浏览器工作,高级功能渐进启用功能检测:使用特性检测而非浏览器检测优雅降级:为不支持的功能提供替代方案用户提示:告知用户浏览器支持情况持续测试:定期测试不同浏览器兼容性监控上报:收集用户浏览器的兼容性数据
阅读 0·3月6日 22:01

Service Worker 中常用的缓存策略有哪些?

Service Worker 缓存策略详解缓存策略是 Service Worker 的核心功能,决定了如何处理网络请求和缓存资源。1. Cache First(缓存优先)策略描述:优先从缓存获取,缓存不存在则请求网络。适用场景:静态资源(CSS、JS、图片等),不经常变化的内容。self.addEventListener('fetch', event => { event.respondWith( caches.match(event.request).then(response => { return response || fetch(event.request); }) );});优点:加载速度快,离线可用缺点:可能返回过期内容2. Network First(网络优先)策略描述:优先请求网络,失败时回退到缓存。适用场景:实时性要求高的数据(API 请求、用户数据)。self.addEventListener('fetch', event => { event.respondWith( fetch(event.request).catch(() => { return caches.match(event.request); }) );});优点:获取最新数据缺点:网络慢时体验差3. Cache Only(仅缓存)策略描述:只从缓存获取,不请求网络。适用场景:预缓存的静态资源。self.addEventListener('fetch', event => { event.respondWith(caches.match(event.request));});4. Network Only(仅网络)策略描述:只请求网络,不使用缓存。适用场景:实时性要求极高的数据(支付、敏感操作)。self.addEventListener('fetch', event => { event.respondWith(fetch(event.request));});5. Stale While Revalidate(过期时重新验证)策略描述:先返回缓存,同时后台更新缓存。适用场景:需要快速响应但又要保持更新的内容。self.addEventListener('fetch', event => { event.respondWith( caches.open('dynamic-cache').then(cache => { return cache.match(event.request).then(response => { const fetchPromise = fetch(event.request).then(networkResponse => { cache.put(event.request, networkResponse.clone()); return networkResponse; }); return response || fetchPromise; }); }) );});优点:快速响应 + 后台更新缺点:用户可能看到旧数据6. 自定义策略组合根据请求类型选择不同策略:self.addEventListener('fetch', event => { const { request } = event; const url = new URL(request.url); // 静态资源 - Cache First if (request.destination === 'style' || request.destination === 'script' || request.destination === 'image') { event.respondWith(cacheFirst(request)); } // API 请求 - Network First else if (url.pathname.startsWith('/api/')) { event.respondWith(networkFirst(request)); } // 页面导航 - Stale While Revalidate else if (request.mode === 'navigate') { event.respondWith(staleWhileRevalidate(request)); }});缓存策略对比表| 策略 | 速度 | 实时性 | 离线可用 | 适用场景 ||------|------|--------|----------|----------|| Cache First | ⭐⭐⭐ | ⭐ | ✅ | 静态资源 || Network First | ⭐ | ⭐⭐⭐ | ✅ | 实时数据 || Stale While Revalidate | ⭐⭐⭐ | ⭐⭐ | ✅ | 平衡场景 || Cache Only | ⭐⭐⭐ | ⭐ | ✅ | 预缓存资源 || Network Only | ⭐ | ⭐⭐⭐ | ❌ | 敏感操作 |最佳实践分层缓存:静态资源 Cache First,API 请求 Network First缓存版本控制:使用版本号管理缓存,便于更新缓存大小限制:定期清理过期缓存,避免存储溢出错误处理:网络失败时提供优雅的降级方案
阅读 0·3月6日 22:01

如何在 Zustand 中使用 TypeScript 进行类型定义?

在 Zustand 中使用 TypeScript 的方法:基本类型定义:import { create } from 'zustand';// 定义状态和动作的类型interface StoreState { // 状态 count: number; user: { id: string; name: string; email: string; } | null; // 动作 increment: () => void; decrement: () => void; setUser: (user: StoreState['user']) => void; reset: () => void;}// 创建带类型的 storeconst useStore = create<StoreState>((set) => ({ // 状态 count: 0, user: null, // 动作 increment: () => set((state) => ({ count: state.count + 1 })), decrement: () => set((state) => ({ count: state.count - 1 })), setUser: (user) => set({ user }), reset: () => set({ count: 0, user: null }),}));使用 get 的类型定义:import { create } from 'zustand';interface StoreState { count: number; user: { id: string; name: string; } | null; increment: () => void; incrementBy: (value: number) => void; fetchUser: (userId: string) => Promise<void>;}const useStore = create<StoreState>((set, get) => ({ count: 0, user: null, increment: () => set((state) => ({ count: state.count + 1 })), incrementBy: (value) => set((state) => ({ count: state.count + value })), // 使用 get 获取最新状态 fetchUser: async (userId) => { try { const response = await fetch(`/api/users/${userId}`); const userData = await response.json(); set({ user: userData }); // 使用 get 获取最新状态 console.log('Current count:', get().count); } catch (error) { console.error('Error fetching user:', error); } },}));使用中间件时的类型定义:import { create } from 'zustand';import { persist, PersistOptions } from 'zustand/middleware';interface StoreState { count: number; user: { id: string; name: string; } | null; increment: () => void; setUser: (user: StoreState['user']) => void;}// 定义 persist 中间件的类型type StorePersist = { persist: { clearStorage: () => void; };};// 完整的 store 类型type Store = StoreState & StorePersist;// persist 配置类型type PersistConfig = PersistOptions<StoreState, StorePersist>;const useStore = create<Store>( persist<StoreState, StorePersist>( (set) => ({ count: 0, user: null, increment: () => set((state) => ({ count: state.count + 1 })), setUser: (user) => set({ user }), }), { name: 'my-storage', } as PersistConfig ));使用 selectors 的类型定义:import { create } from 'zustand';import { StoreState } from './store';// 在组件中使用function Counter() { // 类型安全的 selector const count = useStore((state: StoreState) => state.count); const increment = useStore((state: StoreState) => state.increment); return ( <div> <h1>Count: {count}</h1> <button onClick={increment}>Increment</button> </div> );}关键点:使用 TypeScript 接口定义状态和动作的类型在 create 函数中指定泛型类型为 get 和 set 函数提供正确的类型在使用中间件时,需要正确定义中间件的类型确保 selector 函数的返回类型与使用场景匹配TypeScript 可以帮助捕获状态更新和使用中的类型错误
阅读 0·3月6日 22:00

如何在 Zustand 中使用中间件?

安装必要的依赖(如果使用 persist 中间件):npm install zustand persist# 或yarn add zustand persist在 store 中使用中间件:import { create } from 'zustand';import { persist } from 'zustand/middleware';const useStore = create( persist( (set, get) => ({ // 状态 count: 0, user: null, // 操作状态的方法 increment: () => set((state) => ({ count: state.count + 1 })), decrement: () => set((state) => ({ count: state.count - 1 })), setUser: (user) => set({ user }), reset: () => set({ count: 0, user: null }), // 使用 get 获取当前状态 incrementBy: (value) => set((state) => ({ count: state.count + value })) }), { // persist 配置 name: 'my-storage', // 存储名称 storage: localStorage, // 存储方式(默认 localStorage) // 可选:部分持久化 partialize: (state) => ({ user: state.user }), // 可选:转换函数 serialize: (state) => JSON.stringify(state), deserialize: (str) => JSON.parse(str) } ));export default useStore;使用 devtools 中间件:import { create } from 'zustand';import { devtools } from 'zustand/middleware';const useStore = create( devtools( (set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })) }), { name: 'my-store', // 在 Redux DevTools 中的名称 enabled: true // 是否启用 } ));组合多个中间件:import { create } from 'zustand';import { persist, devtools } from 'zustand/middleware';const useStore = create( devtools( persist( (set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })) }), { name: 'my-storage' } ), { name: 'my-store' } ));关键点:Zustand 中间件通过函数组合的方式使用persist 中间件可以将状态持久化到 localStorage、sessionStorage 等devtools 中间件可以在 Redux DevTools 中查看状态变化中间件的顺序很重要,通常 devtools 在外层,persist 在内层可以自定义中间件来实现特定的功能
阅读 0·3月6日 21:59

Spring Boot 中如何实现安全认证(Spring Security)?

Spring Boot + Spring Security 安全认证详解Spring Security 核心功能认证(Authentication):验证用户身份授权(Authorization):控制用户访问权限防护(Protection):CSRF、会话固定等攻击防护加密(Encryption):密码加密存储基础集成1. 添加依赖<dependencies> <!-- Spring Security Starter --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!-- JWT 支持(可选) --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.12.3</version> </dependency> <!-- OAuth2 支持(可选) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> </dependency></dependencies>2. 基础安全配置@Configuration@EnableWebSecurity@EnableMethodSecurity(prePostEnabled = true)public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 禁用 CSRF(仅用于 API 开发,Web 应用需启用) .csrf(csrf -> csrf.disable()) // 配置授权规则 .authorizeHttpRequests(auth -> auth // 公开路径 .requestMatchers("/", "/login", "/register", "/public/**").permitAll() // 静态资源 .requestMatchers("/css/**", "/js/**", "/images/**").permitAll() // API 文档 .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() // 管理员路径 .requestMatchers("/admin/**").hasRole("ADMIN") // 用户路径 .requestMatchers("/user/**").hasAnyRole("USER", "ADMIN") // 其他需要认证 .anyRequest().authenticated() ) // 表单登录配置 .formLogin(form -> form .loginPage("/login") .loginProcessingUrl("/login") .defaultSuccessUrl("/home", false) .failureUrl("/login?error=true") .permitAll() ) // 注销配置 .logout(logout -> logout .logoutUrl("/logout") .logoutSuccessUrl("/login?logout=true") .invalidateHttpSession(true) .deleteCookies("JSESSIONID") .permitAll() ) // 会话管理 .sessionManagement(session -> session .maximumSessions(1) .maxSessionsPreventsLogin(false) ); return http.build(); } @Bean public PasswordEncoder passwordEncoder() { // 使用 BCrypt 加密 return new BCryptPasswordEncoder(); }}基于内存的用户认证@Configurationpublic class InMemoryUserConfig { @Bean public UserDetailsService userDetailsService() { UserDetails user = User.builder() .username("user") .password(new BCryptPasswordEncoder().encode("password")) .roles("USER") .build(); UserDetails admin = User.builder() .username("admin") .password(new BCryptPasswordEncoder().encode("admin")) .roles("ADMIN", "USER") .build(); return new InMemoryUserDetailsManager(user, admin); }}基于数据库的用户认证1. 用户实体类@Entity@Table(name = "users")@Datapublic class User implements UserDetails { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(unique = true, nullable = false) private String username; @Column(nullable = false) private String password; @Column(unique = true, nullable = false) private String email; private boolean enabled = true; @ManyToMany(fetch = FetchType.EAGER) @JoinTable( name = "user_roles", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "role_id") ) private Set<Role> roles = new HashSet<>(); @Override public Collection<? extends GrantedAuthority> getAuthorities() { return roles.stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName())) .collect(Collectors.toList()); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; }}@Entity@Table(name = "roles")@Datapublic class Role { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(unique = true, nullable = false) private String name;}2. 自定义 UserDetailsService@Service@RequiredArgsConstructorpublic class CustomUserDetailsService implements UserDetailsService { private final UserRepository userRepository; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userRepository.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); return org.springframework.security.core.userdetails.User.builder() .username(user.getUsername()) .password(user.getPassword()) .roles(user.getRoles().stream() .map(Role::getName) .toArray(String[]::new)) .disabled(!user.isEnabled()) .build(); }}3. 更新 Security 配置@Configuration@EnableWebSecurity@RequiredArgsConstructorpublic class SecurityConfig { private final CustomUserDetailsService userDetailsService; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers("/login", "/register").permitAll() .anyRequest().authenticated() ) .formLogin(form -> form .loginPage("/login") .defaultSuccessUrl("/dashboard") .permitAll() ) .logout(logout -> logout.permitAll()) .userDetailsService(userDetailsService); return http.build(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public AuthenticationManager authenticationManager( AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); }}JWT 认证实现1. JWT 工具类@Componentpublic class JwtTokenProvider { @Value("${jwt.secret}") private String jwtSecret; @Value("${jwt.expiration}") private long jwtExpiration; private final SecretKey key; public JwtTokenProvider() { this.key = Keys.secretKeyFor(SignatureAlgorithm.HS256); } /** * 生成 JWT Token */ public String generateToken(Authentication authentication) { UserDetails userDetails = (UserDetails) authentication.getPrincipal(); Date now = new Date(); Date expiryDate = new Date(now.getTime() + jwtExpiration); return Jwts.builder() .setSubject(userDetails.getUsername()) .claim("roles", userDetails.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList())) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(key) .compact(); } /** * 从 Token 获取用户名 */ public String getUsernameFromToken(String token) { Claims claims = Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); return claims.getSubject(); } /** * 验证 Token */ public boolean validateToken(String token) { try { Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token); return true; } catch (JwtException | IllegalArgumentException e) { return false; } }}2. JWT 认证过滤器@Component@RequiredArgsConstructorpublic class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider tokenProvider; private final CustomUserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { String jwt = getJwtFromRequest(request); if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) { String username = tokenProvider.getUsernameFromToken(jwt); UserDetails userDetails = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities() ); authentication.setDetails( new WebAuthenticationDetailsSource().buildDetails(request) ); SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (Exception e) { logger.error("Cannot set user authentication: {}", e.getMessage()); } filterChain.doFilter(request, response); } private String getJwtFromRequest(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { return bearerToken.substring(7); } return null; }}3. JWT Security 配置@Configuration@EnableWebSecurity@RequiredArgsConstructorpublic class JwtSecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; private final CustomUserDetailsService userDetailsService; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public AuthenticationManager authenticationManager( AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); }}4. 认证 Controller@RestController@RequestMapping("/api/auth")@RequiredArgsConstructorpublic class AuthController { private final AuthenticationManager authenticationManager; private final JwtTokenProvider tokenProvider; private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; @PostMapping("/login") public ResponseEntity<?> authenticateUser(@RequestBody LoginRequest request) { Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( request.getUsername(), request.getPassword() ) ); SecurityContextHolder.getContext().setAuthentication(authentication); String jwt = tokenProvider.generateToken(authentication); return ResponseEntity.ok(new JwtAuthResponse(jwt)); } @PostMapping("/register") public ResponseEntity<?> registerUser(@RequestBody RegisterRequest request) { if (userRepository.existsByUsername(request.getUsername())) { return ResponseEntity.badRequest() .body(new ApiResponse(false, "Username already taken")); } User user = new User(); user.setUsername(request.getUsername()); user.setEmail(request.getEmail()); user.setPassword(passwordEncoder.encode(request.getPassword())); userRepository.save(user); return ResponseEntity.ok(new ApiResponse(true, "User registered successfully")); }}方法级安全控制@RestController@RequestMapping("/api/users")@RequiredArgsConstructorpublic class UserController { private final UserService userService; @GetMapping("/me") @PreAuthorize("isAuthenticated()") public ResponseEntity<User> getCurrentUser(@AuthenticationPrincipal UserDetails userDetails) { return ResponseEntity.ok(userService.findByUsername(userDetails.getUsername())); } @GetMapping("/{id}") @PreAuthorize("hasRole('ADMIN') or @userSecurity.hasUserId(authentication, #id)") public ResponseEntity<User> getUserById(@PathVariable Long id) { return ResponseEntity.ok(userService.findById(id)); } @DeleteMapping("/{id}") @PreAuthorize("hasRole('ADMIN')") public ResponseEntity<?> deleteUser(@PathVariable Long id) { userService.deleteUser(id); return ResponseEntity.ok().build(); } @PostMapping @PreAuthorize("hasRole('ADMIN')") public ResponseEntity<User> createUser(@RequestBody User user) { return ResponseEntity.ok(userService.createUser(user)); }}// 自定义权限判断@Component("userSecurity")public class UserSecurity { public boolean hasUserId(Authentication authentication, Long userId) { UserDetails userDetails = (UserDetails) authentication.getPrincipal(); User user = userRepository.findByUsername(userDetails.getUsername()) .orElse(null); return user != null && user.getId().equals(userId); }}OAuth2 / OIDC 集成@Configuration@EnableWebSecuritypublic class OAuth2SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/public/**").permitAll() .anyRequest().authenticated() ) .oauth2Login(oauth2 -> oauth2 .loginPage("/login") .defaultSuccessUrl("/home") .userInfoEndpoint(userInfo -> userInfo .userAuthoritiesMapper(userAuthoritiesMapper()) ) ) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt .jwtAuthenticationConverter(jwtAuthenticationConverter()) ) ); return http.build(); } @Bean public JwtAuthenticationConverter jwtAuthenticationConverter() { JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter(); authoritiesConverter.setAuthorityPrefix("ROLE_"); authoritiesConverter.setAuthoritiesClaimName("roles"); JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter); return converter; }}安全配置spring: security: user: name: admin password: admin roles: ADMIN oauth2: client: registration: google: client-id: your-client-id client-secret: your-client-secret scope: - email - profilejwt: secret: your-secret-key-here-must-be-at-least-256-bits-long expiration: 86400000 # 24 hours测试安全配置@SpringBootTest@AutoConfigureMockMvcpublic class SecurityTest { @Autowired private MockMvc mockMvc; @Test @WithMockUser(username = "user", roles = "USER") public void testUserAccess() throws Exception { mockMvc.perform(get("/api/user/profile")) .andExpect(status().isOk()); } @Test @WithMockUser(username = "admin", roles = "ADMIN") public void testAdminAccess() throws Exception { mockMvc.perform(get("/api/admin/users")) .andExpect(status().isOk()); } @Test @WithMockUser(username = "user", roles = "USER") public void testUserCannotAccessAdmin() throws Exception { mockMvc.perform(get("/api/admin/users")) .andExpect(status().isForbidden()); } @Test public void testPublicAccess() throws Exception { mockMvc.perform(get("/public/info")) .andExpect(status().isOk()); } @Test public void testProtectedRequiresAuth() throws Exception { mockMvc.perform(get("/api/protected")) .andExpect(status().isUnauthorized()); }}总结| 认证方式 | 适用场景 | 优点 | 缺点 ||---------|---------|------|------|| Session | 传统 Web 应用 | 简单、成熟 | 不适合分布式 || JWT | REST API、移动端 | 无状态、可扩展 | Token 无法撤销 || OAuth2 | 第三方登录 | 标准化、安全 | 实现复杂 || LDAP/AD | 企业环境 | 集中管理 | 需要 LDAP 服务器 |安全建议:始终使用 HTTPS密码使用 BCrypt 加密启用 CSRF 防护(Web 应用)设置合理的会话超时实现密码强度校验添加登录失败锁定机制
阅读 0·3月6日 21:59

Spring Boot Starter 依赖的作用和原理是什么?

Spring Boot Starter 详解什么是 StarterSpring Boot Starter 是一组预定义的依赖描述符,它整合了某个功能所需的全部依赖,开发者只需引入一个 starter,即可获得完整的功能支持。Starter 的命名规范| 类型 | 命名规则 | 示例 ||------|---------|------|| 官方 Starter | spring-boot-starter-* | spring-boot-starter-web || 第三方 Starter | *-spring-boot-starter | mybatis-spring-boot-starter |常见官方 Starter 列表<!-- Web 应用 --><dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId></dependency><!-- 数据访问 --><dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId></dependency><!-- 安全框架 --><dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId></dependency><!-- 测试 --><dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope></dependency>Starter 的工作原理1. 依赖传递机制Starter 本质上是一个 Maven/Gradle 项目,通过 pom.xml 定义了功能所需的全部依赖:<!-- spring-boot-starter-web 的 pom.xml --><dependencies> <!-- Spring MVC --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-json</artifactId> </dependency> <!-- Tomcat --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> </dependency> <!-- Spring Web --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> </dependency></dependencies>2. 版本管理Spring Boot 通过 Parent POM 统一管理依赖版本:<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.0</version></parent>在 spring-boot-dependencies 中定义了所有依赖的版本号:<properties> <spring-framework.version>6.1.1</spring-framework.version> <tomcat.version>10.1.16</tomcat.version> <jackson.version>2.16.0</jackson.version></properties>3. 自动配置激活Starter 通常配合自动配置使用,在 META-INF/spring.factories 中注册:org.springframework.boot.autoconfigure.EnableAutoConfiguration=\org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration自定义 Starter 开发步骤一:创建 Maven 项目<artifactId>my-spring-boot-starter</artifactId><dependencies> <!-- 自动配置依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-autoconfigure</artifactId> </dependency> <!-- 配置处理器 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-configuration-processor</artifactId> <optional>true</optional> </dependency></dependencies>步骤二:创建属性配置类@ConfigurationProperties(prefix = "my.service")public class MyServiceProperties { private String host = "localhost"; private int port = 8080; private boolean enabled = true; // getters and setters}步骤三:创建服务类public class MyService { private final MyServiceProperties properties; public MyService(MyServiceProperties properties) { this.properties = properties; } public String connect() { return "Connecting to " + properties.getHost() + ":" + properties.getPort(); }}步骤四:创建自动配置类@Configuration@ConditionalOnClass(MyService.class)@EnableConfigurationProperties(MyServiceProperties.class)@ConditionalOnProperty(prefix = "my.service", value = "enabled", matchIfMissing = true)public class MyServiceAutoConfiguration { @Bean @ConditionalOnMissingBean public MyService myService(MyServiceProperties properties) { return new MyService(properties); }}步骤五:注册自动配置在 src/main/resources/META-INF/spring.factories 中添加:org.springframework.boot.autoconfigure.EnableAutoConfiguration=\com.example.mystarter.MyServiceAutoConfigurationSpring Boot 2.7+ 推荐使用 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports:com.example.mystarter.MyServiceAutoConfiguration步骤六:打包发布mvn clean installStarter 使用示例<dependency> <groupId>com.example</groupId> <artifactId>my-spring-boot-starter</artifactId> <version>1.0.0</version></dependency>my: service: host: 192.168.1.100 port: 9090@Servicepublic class MyApplicationService { @Autowired private MyService myService; public void doSomething() { String result = myService.connect(); System.out.println(result); }}Starter 的优势简化依赖管理:一个依赖引入全部所需版本兼容性:Spring Boot 统一管理版本开箱即用:配合自动配置,零配置启动可扩展性:易于自定义和扩展总结Spring Boot Starter 是依赖聚合和自动配置的结合体,它通过 Maven/Gradle 的依赖传递机制简化依赖引入,配合自动配置实现功能的即插即用,是 Spring Boot 简化开发的核心机制之一。
阅读 0·3月6日 21:58

Spring Boot 中如何实现全局异常处理?

Spring Boot 全局异常处理详解为什么需要全局异常处理在 Web 应用中,异常处理是必不可少的:用户体验:友好的错误提示而非堆栈信息系统安全:隐藏内部实现细节代码整洁:避免每个方法都写 try-catch统一规范:统一的错误响应格式实现方式一:@ControllerAdvice + @ExceptionHandler(推荐)1. 基础异常处理类@RestControllerAdvice@Slf4jpublic class GlobalExceptionHandler { /** * 处理业务异常 */ @ExceptionHandler(BusinessException.class) public ResponseEntity<Result<Void>> handleBusinessException(BusinessException e) { log.warn("业务异常: {}", e.getMessage()); return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(Result.error(e.getCode(), e.getMessage())); } /** * 处理参数校验异常 */ @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<Result<Void>> handleValidationException(MethodArgumentNotValidException e) { String message = e.getBindingResult().getFieldErrors().stream() .map(error -> error.getField() + ": " + error.getDefaultMessage()) .collect(Collectors.joining(", ")); log.warn("参数校验失败: {}", message); return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(Result.error(400, message)); } /** * 处理参数绑定异常 */ @ExceptionHandler(BindException.class) public ResponseEntity<Result<Void>> handleBindException(BindException e) { String message = e.getFieldErrors().stream() .map(error -> error.getField() + ": " + error.getDefaultMessage()) .collect(Collectors.joining(", ")); return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(Result.error(400, message)); } /** * 处理缺少请求参数异常 */ @ExceptionHandler(MissingServletRequestParameterException.class) public ResponseEntity<Result<Void>> handleMissingParam(MissingServletRequestParameterException e) { String message = "缺少必要参数: " + e.getParameterName(); return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(Result.error(400, message)); } /** * 处理请求方法不支持异常 */ @ExceptionHandler(HttpRequestMethodNotSupportedException.class) public ResponseEntity<Result<Void>> handleMethodNotSupported(HttpRequestMethodNotSupportedException e) { String message = "请求方法不支持: " + e.getMethod(); return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED) .body(Result.error(405, message)); } /** * 处理资源未找到异常 */ @ExceptionHandler(NoHandlerFoundException.class) public ResponseEntity<Result<Void>> handleNoHandlerFound(NoHandlerFoundException e) { return ResponseEntity.status(HttpStatus.NOT_FOUND) .body(Result.error(404, "请求路径不存在")); } /** * 处理空指针异常 */ @ExceptionHandler(NullPointerException.class) public ResponseEntity<Result<Void>> handleNullPointer(NullPointerException e) { log.error("空指针异常", e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(Result.error(500, "系统内部错误")); } /** * 处理其他所有异常 */ @ExceptionHandler(Exception.class) public ResponseEntity<Result<Void>> handleException(Exception e) { log.error("系统异常", e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(Result.error(500, "系统繁忙,请稍后重试")); }}2. 统一响应结果类@Data@NoArgsConstructor@AllArgsConstructorpublic class Result<T> { private Integer code; private String message; private T data; private Long timestamp; public Result() { this.timestamp = System.currentTimeMillis(); } public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> success() { return success(null); } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.setCode(code); result.setMessage(message); return result; } public static <T> Result<T> error(String message) { return error(500, message); }}3. 自定义业务异常@Getterpublic class BusinessException extends RuntimeException { private final Integer code; public BusinessException(String message) { super(message); this.code = 500; } public BusinessException(Integer code, String message) { super(message); this.code = code; } public BusinessException(ErrorCode errorCode) { super(errorCode.getMessage()); this.code = errorCode.getCode(); }}4. 错误码枚举@Getter@AllArgsConstructorpublic enum ErrorCode { SUCCESS(200, "操作成功"), PARAM_ERROR(400, "参数错误"), UNAUTHORIZED(401, "未授权"), FORBIDDEN(403, "禁止访问"), NOT_FOUND(404, "资源不存在"), INTERNAL_ERROR(500, "系统内部错误"), // 业务错误码 USER_NOT_FOUND(1001, "用户不存在"), USER_ALREADY_EXISTS(1002, "用户已存在"), PASSWORD_ERROR(1003, "密码错误"), ACCOUNT_LOCKED(1004, "账户已锁定"), ORDER_NOT_FOUND(2001, "订单不存在"), ORDER_STATUS_ERROR(2002, "订单状态错误"), INSUFFICIENT_STOCK(2003, "库存不足"); private final Integer code; private final String message;}实现方式二:实现 HandlerExceptionResolver 接口@Component@Order(Ordered.HIGHEST_PRECEDENCE)public class CustomExceptionResolver implements HandlerExceptionResolver { @Override public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { try { response.setContentType("application/json;charset=UTF-8"); response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); PrintWriter writer = response.getWriter(); Result<Void> result; if (ex instanceof BusinessException) { BusinessException be = (BusinessException) ex; response.setStatus(HttpServletResponse.SC_BAD_REQUEST); result = Result.error(be.getCode(), be.getMessage()); } else { result = Result.error(500, "系统错误"); } writer.write(JSON.toJSONString(result)); writer.flush(); writer.close(); } catch (IOException e) { e.printStackTrace(); } return new ModelAndView(); }}实现方式三:@ResponseStatus 注解@ResponseStatus(HttpStatus.NOT_FOUND)public class ResourceNotFoundException extends RuntimeException { public ResourceNotFoundException(String message) { super(message); }}@ResponseStatus(HttpStatus.BAD_REQUEST)public class InvalidParameterException extends RuntimeException { public InvalidParameterException(String message) { super(message); }}参数校验异常处理(详细)1. 实体类添加校验注解@Datapublic class UserCreateDTO { @NotBlank(message = "用户名不能为空") @Size(min = 3, max = 20, message = "用户名长度必须在3-20之间") private String username; @NotBlank(message = "密码不能为空") @Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).{8,}$", message = "密码必须包含大小写字母和数字,且至少8位") private String password; @NotBlank(message = "邮箱不能为空") @Email(message = "邮箱格式不正确") private String email; @Min(value = 0, message = "年龄不能小于0") @Max(value = 150, message = "年龄不能大于150") private Integer age; @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") private String phone;}2. 分组校验public interface CreateGroup {}public interface UpdateGroup {}@Datapublic class UserDTO { @NotNull(groups = UpdateGroup.class, message = "ID不能为空") private Long id; @NotBlank(groups = CreateGroup.class, message = "用户名不能为空") private String username; @NotBlank(groups = CreateGroup.class, message = "密码不能为空") private String password;}3. Controller 使用@RestController@RequestMapping("/users")public class UserController { @PostMapping public Result<Void> create(@Validated(CreateGroup.class) @RequestBody UserDTO dto) { // 创建用户 return Result.success(); } @PutMapping public Result<Void> update(@Validated(UpdateGroup.class) @RequestBody UserDTO dto) { // 更新用户 return Result.success(); }}4. 详细的校验异常处理@RestControllerAdvice@Slf4jpublic class ValidationExceptionHandler { /** * 处理 @Valid 校验失败 */ @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<Result<Void>> handleMethodArgumentNotValid(MethodArgumentNotValidException e) { Map<String, String> errors = new HashMap<>(); e.getBindingResult().getFieldErrors().forEach(error -> { String fieldName = error.getField(); String errorMessage = error.getDefaultMessage(); errors.put(fieldName, errorMessage); }); log.warn("参数校验失败: {}", errors); return ResponseEntity.badRequest() .body(Result.error(400, "参数校验失败: " + errors)); } /** * 处理 @Validated 校验失败(@RequestParam) */ @ExceptionHandler(ConstraintViolationException.class) public ResponseEntity<Result<Void>> handleConstraintViolation(ConstraintViolationException e) { String message = e.getConstraintViolations().stream() .map(ConstraintViolation::getMessage) .collect(Collectors.joining(", ")); return ResponseEntity.badRequest() .body(Result.error(400, message)); } /** * 处理参数类型不匹配 */ @ExceptionHandler(MethodArgumentTypeMismatchException.class) public ResponseEntity<Result<Void>> handleTypeMismatch(MethodArgumentTypeMismatchException e) { String message = String.format("参数 '%s' 类型不匹配,期望类型: %s", e.getName(), e.getRequiredType().getSimpleName()); return ResponseEntity.badRequest() .body(Result.error(400, message)); } /** * 处理缺少请求体 */ @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity<Result<Void>> handleMissingBody(HttpMessageNotReadableException e) { return ResponseEntity.badRequest() .body(Result.error(400, "请求体不能为空或格式错误")); }}404 异常处理配置Spring Boot 默认不会抛出 404 异常,需要手动开启:spring: web: resources: add-mappings: false mvc: throw-exception-if-no-handler-found: true@RestControllerAdvicepublic class NotFoundExceptionHandler { @ExceptionHandler(NoHandlerFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public Result<Void> handleNoHandlerFound(NoHandlerFoundException e) { return Result.error(404, "请求路径不存在: " + e.getRequestURL()); }}日志记录最佳实践@Slf4j@RestControllerAdvicepublic class GlobalExceptionHandler { @ExceptionHandler(BusinessException.class) public ResponseEntity<Result<Void>> handleBusinessException(BusinessException e, WebRequest request) { // 记录警告日志 log.warn("业务异常 [{}] - URL: {}, Message: {}", e.getCode(), request.getDescription(false), e.getMessage()); return ResponseEntity.badRequest() .body(Result.error(e.getCode(), e.getMessage())); } @ExceptionHandler(Exception.class) public ResponseEntity<Result<Void>> handleException(Exception e, WebRequest request) { // 生成错误追踪ID String traceId = UUID.randomUUID().toString(); // 记录错误日志,包含堆栈信息 log.error("系统异常 [TraceId: {}] - URL: {}", traceId, request.getDescription(false), e); // 返回给客户端时隐藏详细错误信息 return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(Result.error(500, "系统繁忙,请稍后重试 [TraceId: " + traceId + "]")); }}国际化错误消息@Configurationpublic class MessageConfig { @Bean public MessageSource messageSource() { ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource(); messageSource.setBasename("classpath:messages"); messageSource.setDefaultEncoding("UTF-8"); return messageSource; } @Bean public LocalValidatorFactoryBean validator(MessageSource messageSource) { LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean(); bean.setValidationMessageSource(messageSource); return bean; }}# messages.propertieserror.user.notfound=User not founderror.user.exists=User already exists# messages_zh_CN.propertieserror.user.notfound=用户不存在error.user.exists=用户已存在@ExceptionHandler(BusinessException.class)public ResponseEntity<Result<Void>> handleBusinessException(BusinessException e, Locale locale) { String message = messageSource.getMessage(e.getMessageKey(), null, locale); return ResponseEntity.badRequest() .body(Result.error(e.getCode(), message));}测试验证@SpringBootTest@AutoConfigureMockMvcpublic class GlobalExceptionHandlerTest { @Autowired private MockMvc mockMvc; @Test public void testBusinessException() throws Exception { mockMvc.perform(get("/test/business-error")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(400)) .andExpect(jsonPath("$.message").value("业务错误")); } @Test public void testValidationException() throws Exception { mockMvc.perform(post("/users") .contentType(MediaType.APPLICATION_JSON) .content("{\"username\": \"ab\", \"email\": \"invalid\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(400)); } @Test public void testNotFound() throws Exception { mockMvc.perform(get("/non-existent-path")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value(404)); }}总结| 方式 | 适用场景 | 优点 | 缺点 ||------|---------|------|------|| @ControllerAdvice | 大多数场景 | 集中管理、灵活 | 无 || HandlerExceptionResolver | 需要完全控制 | 最高优先级 | 较复杂 || @ResponseStatus | 简单异常 | 简洁 | 不够灵活 |全局异常处理是构建健壮 REST API 的基础,建议:统一错误响应格式定义清晰的错误码体系合理记录日志(区分 warn/error)生产环境隐藏敏感信息
阅读 0·3月6日 21:58

Spring Boot 中如何实现多环境配置?

Spring Boot 多环境配置详解为什么需要多环境配置在实际项目开发中,通常需要部署到不同环境:开发环境 (dev):本地开发调试测试环境 (test):QA 测试验证预发布环境 (staging):上线前最终验证生产环境 (prod):正式对外服务不同环境需要不同的配置:数据库连接、日志级别、服务端点等。实现方式一:Profile-specific 配置文件1. 配置文件命名规则application.yml # 默认配置(所有环境共享)application-dev.yml # 开发环境配置application-test.yml # 测试环境配置application-staging.yml # 预发布环境配置application-prod.yml # 生产环境配置2. 配置文件示例application.yml(默认配置)spring: application: name: my-service profiles: active: dev # 默认激活 dev 环境server: port: 8080# 公共配置logging: level: root: INFOapplication-dev.yml(开发环境)# 开发环境特定配置server: port: 8081spring: datasource: url: jdbc:mysql://localhost:3306/dev_db username: dev_user password: dev_pass driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update show-sql: truelogging: level: com.example: DEBUG org.springframework.jdbc: DEBUGapplication-prod.yml(生产环境)# 生产环境特定配置server: port: 80spring: datasource: url: jdbc:mysql://prod-db.example.com:3306/prod_db username: ${DB_USERNAME} password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 50 minimum-idle: 10 jpa: hibernate: ddl-auto: validate show-sql: falselogging: level: root: WARN com.example: INFO file: name: /var/log/my-service/app.log实现方式二:单文件多文档块Spring Boot 2.4+ 支持在一个 YAML 文件中定义多个 Profile:# application.ymlspring: application: name: my-service---spring: config: activate: on-profile: devserver: port: 8081spring: datasource: url: jdbc:mysql://localhost:3306/dev_db username: dev_user password: dev_pass---spring: config: activate: on-profile: prodserver: port: 80spring: datasource: url: jdbc:mysql://prod-db.example.com:3306/prod_db username: ${DB_USERNAME} password: ${DB_PASSWORD}激活 Profile 的方式方式1:配置文件指定(application.yml)spring: profiles: active: dev方式2:命令行参数# 启动时指定环境java -jar myapp.jar --spring.profiles.active=prod# 或者java -jar myapp.jar -Dspring.profiles.active=prod方式3:环境变量# Linux/Macexport SPRING_PROFILES_ACTIVE=prodjava -jar myapp.jar# Windowsset SPRING_PROFILES_ACTIVE=prodjava -jar myapp.jar方式4:JVM 系统属性java -Dspring.profiles.active=prod -jar myapp.jar方式5:IDE 配置IntelliJ IDEA:Run → Edit Configurations → Active profiles: devProfile 分组(Spring Boot 2.4+)可以将多个 Profile 组合成一个组:spring: profiles: group: local: - dev - local-db - local-cache production: - prod - prod-db - prod-cache - prod-mq使用:java -jar myapp.jar --spring.profiles.active=local# 实际激活: dev, local-db, local-cache条件化 Bean 配置使用 @Profile 注解根据环境创建不同的 Bean:@Configurationpublic class DataSourceConfig { @Bean @Profile("dev") public DataSource devDataSource() { HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/dev_db"); config.setUsername("dev_user"); config.setPassword("dev_pass"); return new HikariDataSource(config); } @Bean @Profile("prod") public DataSource prodDataSource() { HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://prod-db.example.com:3306/prod_db"); config.setUsername(System.getenv("DB_USERNAME")); config.setPassword(System.getenv("DB_PASSWORD")); config.setMaximumPoolSize(50); return new HikariDataSource(config); } @Bean @Profile("!prod") // 非生产环境 public MockService mockService() { return new MockService(); }}在代码中获取当前 Profile@Componentpublic class EnvironmentChecker implements CommandLineRunner { @Autowired private Environment environment; @Override public void run(String... args) { // 获取所有激活的 profiles String[] activeProfiles = environment.getActiveProfiles(); System.out.println("Active profiles: " + String.join(", ", activeProfiles)); // 判断是否包含某个 profile if (environment.acceptsProfiles(Profiles.of("dev"))) { System.out.println("Development mode"); } // 判断是否生产环境 boolean isProd = Arrays.asList(activeProfiles).contains("prod"); }}配置文件加载顺序与优先级Spring Boot 配置文件加载优先级(从高到低):命令行参数java:comp/env JNDI 属性Java 系统属性 (System.getProperties())操作系统环境变量RandomValuePropertySourcejar 包外部的 application-{profile}.propertiesjar 包内部的 application-{profile}.propertiesjar 包外部的 application.propertiesjar 包内部的 application.properties@PropertySource 注解SpringApplication 默认属性重要规则:application-{profile}.yml 会覆盖 application.yml 中的同名配置高优先级的配置会覆盖低优先级的同名配置敏感信息加密生产环境密码等敏感信息不应明文存储:方案1:环境变量spring: datasource: password: ${DB_PASSWORD}方案2:Jasypt 加密<dependency> <groupId>com.github.ulisesbocchio</groupId> <artifactId>jasypt-spring-boot-starter</artifactId> <version>3.0.5</version></dependency>spring: datasource: password: ENC(加密后的密文)jasypt: encryptor: password: ${JASYPT_ENCRYPTOR_PASSWORD}最佳实践1. 配置分层结构# application.yml - 只放真正公共的配置spring: application: name: my-service jackson: date-format: yyyy-MM-dd HH:mm:ss---# 各环境特定配置放在单独文件2. 使用配置属性类@ConfigurationProperties(prefix = "app")@Componentpublic class AppProperties { private String name; private String version; private Map<String, String> metadata; // getters and setters}3. 生产环境配置检查清单[ ] 关闭调试模式:debug: false[ ] 关闭 SQL 打印:show-sql: false[ ] 设置合适的日志级别[ ] 配置连接池参数[ ] 使用环境变量存储敏感信息[ ] 配置健康检查端点[ ] 设置合理的超时参数4. 本地开发便利配置# application-local.ymlspring: devtools: restart: enabled: true livereload: enabled: true h2: console: enabled: true path: /h2-console常见问题Q1: Profile 不生效?检查配置文件命名是否正确:application-{profile}.yml确认 Profile 已正确激活检查是否有拼写错误Q2: 多个 Profile 如何同时激活?--spring.profiles.active=dev,local-dbQ3: 如何设置默认 Profile?spring: profiles: default: dev # 当没有指定时默认使用总结| 特性 | 说明 ||------|------|| 配置文件分离 | application-{profile}.yml || 单文件多文档 | 使用 --- 分隔 || 激活方式 | 配置、命令行、环境变量、JVM 参数 || 条件 Bean | @Profile 注解 || Profile 分组 | spring.profiles.group || 优先级 | 命令行 > 环境变量 > 配置文件 |
阅读 0·3月6日 21:58