首页
在线工具
搜索
1
如何将Virtualbox和VMware虚拟机相互转换
2
Markdown正确使用姿势
3
Typora+Picgo图床使用
4
使用Metrics指标度量工具监控Java应用程序性能(Gauges, Counters, Histograms, Meters和 Timers实例)
5
Kuboard与KubeSphere的区别:Kubernetes管理平台对比
杂谈与随笔
工具与效率
源码阅读
技术管理
运维
数据库
前端开发
后端开发
AI人工智能
Search
标签搜索
Angular
Docker
Phabricator
SpringBoot
Java
Chrome
SpringSecurity
Agent
SpringCloud
DDD
Git
Mac
K8S
Kubernetes
ESLint
SSH
高并发
Eclipse
Javascript
Vim
Jonathan
累计撰写
92
篇文章
累计收到
0
条评论
首页
栏目
杂谈与随笔
工具与效率
源码阅读
技术管理
运维
数据库
前端开发
后端开发
AI人工智能
页面
搜索到
19
篇与
的结果
2026-03-19
从 0 到 1 架构 Web + Mobile 端到端自动化测试平台:Playwright + Appium + TypeScript
从 0 到 1 架构 Web + Mobile 端到端自动化测试平台:Playwright + Appium + TypeScript 当一个业务同时存在 Web、Android 和 iOS 三个客户端时,自动化测试最容易走向两个极端: 一种是三套测试工程各自发展,最终基础设施重复、测试数据重复、CI 重复; 另一种是为了“统一”而设计一个万能自动化框架,试图用同一套 Driver、Element API 同时操作浏览器和移动端,最后抽象层反而比业务代码更复杂。 本文介绍一种更适合中大型项目的架构: 执行引擎分开,业务能力共享,测试基础设施统一。 一、我们到底要解决什么问题? 假设一个产品同时拥有: Web 管理后台 Web 用户端 Android App iOS App 测试团队希望自动完成下面这些业务流程: 登录 ↓ 搜索商品 ↓ 加入购物车 ↓ 创建订单 ↓ 支付 ↓ 验证订单 这时候最直接的想法通常是: Web 自动化 Android 自动化 iOS 自动化 然后分别建立三套工程。 短期没有问题。 但当测试 Case 从几十条增长到几百甚至几千条后,就会逐渐出现这些问题: 自动化测试规模扩大 │ ┌────────────┼────────────┐ │ │ │ Web Android iOS │ │ │ 一套账号管理 一套账号管理 一套账号管理 一套测试数据 一套测试数据 一套测试数据 一套 API 一套 API 一套 API 一套日志 一套日志 一套日志 一套报告 一套报告 一套报告 问题本质不是: Playwright 和 Appium 怎么一起用? 真正的问题其实是: 如何让不同终端的自动化测试共享业务能力和基础设施,但又保持各自最合适的执行模型? 二、技术选型 这套架构采用: 语言 └── TypeScript Web └── Playwright Mobile ├── WebdriverIO ├── Appium ├── Android → UiAutomator2 └── iOS → XCUITest 工程管理 └── pnpm workspace CI ├── Jenkins ├── GitLab CI └── GitHub Actions 报告 ├── Allure ├── JUnit └── 自建测试报告平台 整体关系如下: flowchart LR A[TypeScript E2E Platform] A --> B[Web Tests] A --> C[Mobile Tests] B --> D[Playwright] D --> E[Chrome] D --> F[Firefox] D --> G[WebKit] C --> H[WebdriverIO] H --> I[Appium] I --> J[UiAutomator2] I --> K[XCUITest] J --> L[Android] K --> M[iOS] 为什么统一使用 TypeScript? 并不是因为 TypeScript 一定比其他语言更适合测试。 真正的价值是: Web 和 Mobile 可以共享类型、配置、API Client、测试数据、账号池和业务模型。 三、不要构建“万能 Driver” 架构设计中最重要的一个原则是: 不要把 Playwright 和 Appium 强行抽象成同一个底层 Driver。 很多自动化框架一开始都会尝试做类似这样的东西: class Element { async click() { if (platform === 'web') { // Playwright click } if (platform === 'android') { // Appium click } if (platform === 'ios') { // XCUITest click } } } 刚开始看起来非常优雅: element.click(); element.input(); element.wait(); 似乎 Web、Android、iOS 都统一了。 但随着业务复杂度提高,很快就会变成: if Web if Android if iOS if WebView if Native if Chrome if Safari if Element Scrollable if System Permission if Keyboard Visible if Android Version if iOS Version ... 最终得到的不是一个“统一自动化框架”。 而是: 一个比 Playwright 和 Appium 本身还复杂的中间层。 四、正确的抽象边界在哪里? 真正值得共享的是: 业务场景 测试数据 账号体系 API Client 环境配置 日志 报告 测试标签 CI 调度 而不应该强行共享: Locator Element Page Driver Gesture Browser Context Mobile Session 整体架构应该是: flowchart TD A[Business Scenario] A --> B[Web Flow] A --> C[Mobile Flow] B --> D[Page Object] C --> E[Screen Object] D --> F[Playwright] E --> G[WebdriverIO / Appium] F --> H[Browser] G --> I[Android / iOS] 一句话概括: 业务层统一,执行层隔离。 五、整体工程架构 推荐将整个自动化测试平台组织成 Monorepo。 e2e-automation/ │ ├── package.json ├── pnpm-workspace.yaml ├── tsconfig.base.json │ ├── packages/ │ │ ├── core/ │ │ │ │ └── src/ │ │ ├── config/ │ │ ├── api/ │ │ ├── data/ │ │ ├── accounts/ │ │ ├── logger/ │ │ ├── retry/ │ │ ├── assertions/ │ │ └── types/ │ │ │ ├── scenarios/ │ │ │ │ └── src/ │ │ ├── login/ │ │ ├── register/ │ │ ├── search/ │ │ ├── checkout/ │ │ └── profile/ │ │ │ ├── web-tests/ │ │ │ │ ├── playwright.config.ts │ │ │ │ │ ├── src/ │ │ │ ├── pages/ │ │ │ ├── components/ │ │ │ ├── fixtures/ │ │ │ └── helpers/ │ │ │ │ │ └── tests/ │ │ │ └── mobile-tests/ │ │ │ ├── wdio.shared.conf.ts │ ├── wdio.android.conf.ts │ ├── wdio.ios.conf.ts │ │ │ ├── src/ │ │ ├── screens/ │ │ ├── components/ │ │ ├── gestures/ │ │ ├── devices/ │ │ └── helpers/ │ │ │ └── tests/ │ ├── config/ │ ├── env/ │ ├── devices/ │ └── test-suites/ │ ├── scripts/ │ ├── run-web.ts │ ├── run-mobile.ts │ ├── prepare-test-data.ts │ └── cleanup-test-data.ts │ ├── artifacts/ │ ├── screenshots/ │ ├── videos/ │ ├── traces/ │ └── logs/ │ └── reports/ 这里最关键的是: core scenarios 这两个包不会关心: Browser 是 Chrome 还是 Safari 手机是 Android 还是 iPhone 底层是 Playwright 还是 Appium 它们主要负责: 环境 账号 测试数据 API 业务模型 日志 报告 六、Web:Page Object Web 使用 Playwright。 例如登录页: import { Page } from '@playwright/test'; export class LoginPage { constructor(private readonly page: Page) {} username = this.page.getByTestId('username'); password = this.page.getByTestId('password'); submit = this.page.getByTestId('login-button'); async login(username: string, password: string) { await this.username.fill(username); await this.password.fill(password); await this.submit.click(); } } 页面对象主要负责: Locator UI Operation Page Behavior 而不要承担业务流程。 例如下面这种代码就不建议放入 LoginPage: async loginAndCreateOrder() { // ... } 因为: Page Object 表示页面能力,不应该表示完整业务流程。 七、Mobile:Screen Object Mobile 使用: WebdriverIO ↓ Appium ↓ Android / iOS 登录页面可以写成: export class LoginScreen { get username() { return $('~username'); } get password() { return $('~password'); } get submit() { return $('~login-button'); } async login(username: string, password: string) { await this.username.setValue(username); await this.password.setValue(password); await this.submit.click(); } } Android 和 iOS 如果 UI 结构高度一致,可以共享部分 Screen。 但如果两端差异比较大,建议直接拆开: screens/ ├── android/ │ ├── LoginScreen.ts │ └── CheckoutScreen.ts │ └── ios/ ├── LoginScreen.ts └── CheckoutScreen.ts 不要为了“代码复用率”牺牲可维护性。 八、真正需要共享的是 Business Flow 假设我们有一个登录流程: export interface LoginFlow { login(user: TestUser): Promise<void>; } Web 实现: export class WebLoginFlow implements LoginFlow { constructor( private readonly loginPage: LoginPage ) {} async login(user: TestUser) { await this.loginPage.login( user.username, user.password ); } } Mobile 实现: export class MobileLoginFlow implements LoginFlow { constructor( private readonly loginScreen: LoginScreen ) {} async login(user: TestUser) { await this.loginScreen.login( user.username, user.password ); } } 于是业务测试可以变成: await loginFlow.login(user); await searchFlow.search('MacBook'); await cartFlow.addProduct(); const order = await checkoutFlow.checkout(); expect(order.success).toBe(true); 测试代码关注的是: 用户做了什么 而不是: 元素怎么点 元素怎么查 页面怎么滚 九、一条测试到底是怎么运行的? 整个执行链路如下。 flowchart TD A[开始测试] A --> B[加载运行环境] B --> C[创建测试账号] C --> D[准备测试数据] D --> E{测试平台} E -->|Web| F[启动 Playwright] E -->|Android| G[启动 Appium Android Session] E -->|iOS| H[启动 Appium iOS Session] F --> I[执行业务场景] G --> I H --> I I --> J[UI Assertion] J --> K[API / DB 验证] K --> L{是否成功} L -->|成功| M[记录测试结果] L -->|失败| N[截图 / 视频 / 日志 / Trace] N --> M M --> O[清理测试数据] O --> P[生成测试报告] 这里有一个非常重要的理念: 自动化测试不是单纯的 UI 操作。 完整测试实际上是: API + Test Data + UI + Assertion + Logging + Reporting 十、测试数据必须成为一等公民 很多 E2E 自动化项目最后不稳定,并不是 Playwright 不稳定。 也不是 Appium 不稳定。 而是: 测试数据不稳定。 例如: test('用户可以购买商品', async () => { await login('test001', '123456'); }); 这种代码隐藏了大量问题: 这个账号还存在吗? 余额够吗? 是否被其他测试占用? 是否已经购买过? 有没有被封? 环境数据是否一致? 更合理的方式是: const user = await testData.createUser({ balance: 10000, status: 'active' }); 然后: await loginFlow.login(user); 测试完成以后: await testData.cleanup(user); 测试生命周期应该变成: flowchart LR A[Test Case] A --> B[API 创建用户] B --> C[API 创建商品] C --> D[API 创建测试订单数据] D --> E[UI 执行核心业务] E --> F[API / DB 验证结果] F --> G[清理测试数据] 这样 UI 自动化只验证: 必须通过 UI 才能验证的内容。 十一、账号池设计 当 CI 开始并行运行以后,会出现另一个问题: Test 01 ─┐ Test 02 ─┼── test001 Test 03 ─┘ 三个 Case 同时使用一个账号。 后果通常是: 状态污染 订单冲突 购物车冲突 登录 Session 冲突 数据互相覆盖 因此建议设计: Account Pool 运行时: flowchart LR A[Test Worker 1] --> D[Account Pool] B[Test Worker 2] --> D C[Test Worker 3] --> D D --> E[test001] D --> F[test002] D --> G[test003] 基本接口可以是: const account = await accountPool.acquire({ type: 'normal-user' }); try { await runTest(account); } finally { await accountPool.release(account); } 这样才能真正支持稳定并发。 十二、Web 浏览器矩阵 Web 可以通过 Playwright Projects 定义浏览器矩阵: export default defineConfig({ projects: [ { name: 'chrome', use: { ...devices['Desktop Chrome'] } }, { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, { name: 'webkit', use: { ...devices['Desktop Safari'] } } ] }); 执行关系: flowchart LR A[Web Test] A --> B[Chrome] A --> C[Firefox] A --> D[WebKit] B --> E[Result] C --> E D --> E 但并不意味着所有 PR 都要跑所有浏览器。 测试策略应该分层。 十三、Mobile 设备矩阵 Mobile 与 Web 最大的区别是: Web 并发资源通常是: CPU Memory Browser Worker 而 Mobile 真正受限制的是: Device Simulator Emulator 因此应该构建设备池: flowchart TD A[Mobile Test Queue] A --> B[Device Scheduler] B --> C[Android 01] B --> D[Android 02] B --> E[iPhone 01] B --> F[iPhone 02] C --> G[Test Case] D --> G E --> G F --> G 设备元数据建议维护: interface Device { id: string; platform: 'android' | 'ios'; version: string; model: string; status: 'idle' | 'busy' | 'offline'; } 以后可以进一步扩展成 Device Farm。 十四、测试标签体系 当 Case 达到几百条以后,不能再依靠目录决定执行策略。 建议采用 Tag。 例如: 运行级别 @smoke @regression 优先级 @p0 @p1 @p2 业务 @login @checkout @payment @search 平台 @web @android @ios 环境 @staging @production-safe 例如: test( '@p0 @smoke @checkout 用户可以完成购买', async () => { // ... } ); 然后执行: pnpm test --tag @smoke 或者: pnpm test --tag "@p0 && @checkout" 进一步还可以实现: pnpm test:web --tag @regression 以及: pnpm test:android --tag @smoke 十五、CI 怎么设计? 不要每次 Pull Request 都执行全部 E2E。 假设: Web Case 300 Android Case 200 iOS Case 200 如果全部执行: 700 Cases × 多浏览器 × 多设备 整个 Pipeline 会非常昂贵。 建议采用三级测试策略。 flowchart TD A[Pull Request] A --> B[P0 Smoke] B -->|Pass| C[Merge] B -->|Fail| D[Block Merge] C --> E[Core Regression] E --> F[Nightly] F --> G[Full Matrix] G --> H[Chrome] G --> I[Firefox] G --> J[WebKit] G --> K[Android Devices] G --> L[iOS Devices] 推荐: Pull Request P0 Smoke 核心链路 例如: 登录 搜索 下单 支付 目标: 5 ~ 15 分钟 Merge 执行: Core Regression 例如: 核心业务 核心页面 主要设备 Nightly 每天晚上执行: Full Regression 包括: Chrome Firefox Safari Android 多版本 iOS 多版本 十六、报告体系 报告不能只有: Passed Failed 一个成熟的 E2E 平台应该记录: Test Name Platform Environment Browser Device App Version Git Commit Start Time Duration Screenshot Video Trace Console Log Appium Log Network Log Error Stack 最终报告可以呈现: Run #10231 ────────────────────────────────── Web Chrome 145 / 147 Firefox 142 / 147 WebKit 144 / 147 Android Pixel 81 / 83 Samsung 79 / 83 iOS iPhone 15 82 / 83 ────────────────────────────────── Passed 573 Failed 11 Skipped 6 整体流程: flowchart LR A[Playwright Result] --> D[Report Aggregator] B[Android Result] --> D C[iOS Result] --> D D --> E[JUnit] D --> F[Allure] D --> G[Database] G --> H[Trend Dashboard] 十七、失败的时候到底保存什么? 一个失败 Case 至少应该留下: Screenshot Video Error Stack Console Log Device Log Web 还可以保存: Playwright Trace Network Browser Console DOM Snapshot Mobile 可以保存: Appium Server Log Device Log Screen Recording Page Source 理想状态是开发者看到: Checkout failed 之后,不需要 QA 手工复现。 而是可以直接看到: 哪一步失败 失败时页面长什么样 前端报了什么错 接口有没有失败 设备有没有异常 十八、Flaky Test 怎么处理? E2E 自动化最大的敌人不是 Fail。 而是: Flaky。 也就是: 第一次失败 第二次通过 第三次又失败 如果简单粗暴地设置: retry = 3 很多问题会被掩盖。 更合理的方法: flowchart TD A[Test Failed] A --> B[Retry] B --> C{Retry Result} C -->|Fail| D[Real Failure] C -->|Pass| E[Mark Flaky] E --> F[Flaky Database] F --> G[Trend Analysis] G --> H{超过阈值?} H -->|Yes| I[创建治理任务] H -->|No| J[继续观察] 建议给每一个 Case 维护: Owner Failure Rate Flaky Rate Average Duration Last Failure 甚至可以设置: Flaky > 5% 自动通知负责人。 十九、完整自动化测试平台 随着能力逐步增加,最终整个测试平台会演进成: flowchart TB A[Developer / QA] A --> B[Test CLI] B --> C[Test Orchestrator] C --> D[Web Runner] C --> E[Mobile Scheduler] D --> F[Playwright] E --> G[Android Device Pool] E --> H[iOS Device Pool] F --> I[Business Scenario] G --> I H --> I I --> J[Test Infrastructure] J --> K[Account Pool] J --> L[Test Data] J --> M[API Client] J --> N[Config] J --> O[Logger] I --> P[Test Artifacts] P --> Q[Screenshot] P --> R[Video] P --> S[Trace] P --> T[Logs] Q --> U[Report Platform] R --> U S --> U T --> U U --> V[Trend Analysis] U --> W[Flaky Analysis] U --> X[Alert] 这时候它已经不再只是一个: 自动化脚本仓库 而是一个真正的: E2E Automation Platform 二十、推荐实施路线 如果从 0 开始,不建议一上来就做: 设备云 测试管理后台 账号中心 报表中心 调度中心 Flaky AI 分析 第一阶段最重要的是: 跑通闭环。 Phase 1:基础能力 先实现: Playwright Appium TypeScript pnpm workspace 然后选三个 Case: Login Search Checkout 要求: Web ✓ Android ✓ iOS ✓ Phase 2:基础设施 增加: Test Data Factory Account Pool API Client Environment Config Logging Artifacts Phase 3:CI 实现: PR Smoke Merge Regression Nightly Full Regression Phase 4:设备池 逐渐支持: Android Emulator Android Real Device iOS Simulator iOS Real Device Phase 5:平台化 最后才进入: Report Platform Device Farm Flaky Dashboard Trend Analysis Quality Dashboard Alert Case Owner 整个演进路线: flowchart LR A[脚本] --> B[工程化] --> C[基础设施] --> D[CI] --> E[设备池] --> F[报告平台] --> G[质量平台] 二十一、三个最值得坚持的设计原则 最后总结一下这套架构中最重要的三个原则。 1. 执行引擎分开 Web ↓ Playwright Mobile ↓ Appium 不要为了统一而统一。 2. 业务能力共享 共享: Scenario Test Data API Account Config Logger Report 而不是: Element Driver Locator 3. UI 只负责验证 UI 能够通过 API 完成的: 创建用户 创建订单 创建商品 初始化数据 清理数据 尽量不要通过 UI 做。 UI 自动化应该把时间投入到: 用户真正看到和操作的关键路径 二十二、最终架构 最终推荐的结构可以总结为: E2E Automation Platform │ ┌───────────┴───────────┐ │ │ Playwright Appium │ │ Web WebdriverIO │ ┌──────┴──────┐ │ │ Android iOS UiAutomator2 XCUITest ────────────────────────────────────────────── Shared Infrastructure ────────────────────────────────────────────── TypeScript Business Scenario Test Data Factory Account Pool API Client Environment Config Logging Reporting Tagging Artifacts CI Orchestrator Device Scheduler 架构原则可以浓缩成一句话: 执行引擎分开,业务能力共享,基础设施统一。 当项目只有十几条自动化 Case 时,架构可能并不重要。 但当 Case 达到: 100 500 1000+ 真正决定这个项目能不能继续维护的,已经不是: 某一个 Locator 怎么写 而是: 测试数据如何管理? 设备如何调度? 测试如何并发? 失败如何定位? Flaky 如何治理? 多平台如何共享业务能力? CI 如何控制执行成本? 这也是自动化测试从: “写脚本” 走向: “工程化” 再走向: “平台化” 最关键的一步。
2026年03月19日
2026-02-04
WebDriverIO与Appium本地开发环境部署(随手记)
WebDriverIO与Appium本地开发环境部署 WebDriverIO V8 Appium 需要环境 浏览器:FireFox、Chrome Android环境:Android Studio、adb、Andorid SDK 配置Android环境变量 配置ANDROID_HOME ,Android SDK根据自己本地的地址来 配置环境变量 %ANDROID_HOME%\platform-tools %ANDROID_HOME%\emulator %ANDROID_HOME%\tools\bin 创建一个虚拟机在控制台验证 控制台输入adb devices 安装appium server 客户端 地址:https://github.com/appium/appium-desktop 安装完运行 默认Port:4723 配置Host:127.0.0.1 点击Edit Configurations 配置ANDROID_HOME 保存并重启 根据自己的Android SDK目录配置,如果这里没有配置,系统环境变量也是没有用的 安装appium cli node version: >20.18.1 npm i -g appium WebDriverIO 各个浏览器需要的驱动 chromeDriver各个版本的驱动下载地址 https://www.cnblogs.com/aiyablog/articles/17948703 最新版本的chromeDriver的下载地址 https://googlechromelabs.github.io/chrome-for-testing/ 在浏览器chrome测试,需要浏览器的版本跟chromeDriver对应 edge 的驱动无法下载,需要离线下载 小程序自动化测试 环境配置:开启微信开发者工具服务端口 点击=》设置=》安全设置
2026年02月04日
2024-12-18
Tauri2.0尝鲜-正式版脚手架搭建
Tauri 2.0正式版脚手架搭建 Tauri已经迅速成为使用Web技术构建轻量级、安全桌面应用的最受欢迎框架之一。随着Tauri 2.0正式版的发布,现在是探索如何搭建一个生产就绪的脚手架的最佳时机,该脚手架提供了专业桌面应用开发所需的所有工具。 在本文中,我将介绍一个完整的Tauri启动模板,它支持Web和桌面双环境运行,采用现代React + TypeScript技术栈,并包含构建精美、生产就绪应用程序所需的所有基本工具。 为什么选择Tauri 2.0? 在深入探讨我们的脚手架之前,让我们快速回顾一下Tauri 2.0的特点: 更小的打包体积:Tauri应用比Electron替代方案显著更小 增强的安全性:基于Rust的安全原则,采用严格的权限模型 更好的性能:更低的内存消耗和更快的启动时间 多平台支持:从单一代码库为Windows、macOS和Linux构建应用 Web/桌面统一:相同的代码库可以同时作为Web应用和桌面应用运行 随着Tauri 2.0的发布,这些优势得到了增强,包括改进的API、更好的开发体验和更强大的跨平台兼容性。 Tauri桌面Web启动模板介绍 我们将要探索的脚手架为Tauri 2.0应用程序提供了全面的基础。它结合了现代前端技术和Tauri的Rust后端能力,为Web和桌面环境提供了优化的开发体验。 技术栈 我们的启动模板汇集了一流的技术: 核心框架:Tauri 2.0 + Vite + React + TypeScript UI组件:Tailwind CSS + Shadcn UI + Lucide Icons 状态管理:Redux Toolkit 国际化:i18n 后端:Rust 开发工具:用于API模拟的Mock服务 这种组合提供了性能、开发体验和功能完整性的完美平衡。 主要特性 该脚手架具有多项功能,使开发更加顺畅高效: 开箱即用的工程配置:跳过繁琐的设置过程 深色/浅色主题切换:内置主题支持,包括系统主题检测 自定义窗口标题栏:Windows和macOS风格选项 状态管理最佳实践:组织良好的Redux实现 类型安全:完整的TypeScript集成 国际化:开箱即用的多语言支持 Mock数据支持:开发过程中不依赖后端服务 项目结构 了解项目结构对于有效开发至关重要。我们的脚手架遵循清晰合理的组织方式: tauri-desktop-web-starter/ ├── components/ # 公共UI组件 ├── hooks/ # 自定义React Hooks ├── lib/ # 工具库和配置 ├── src/ # 前端源代码 │ ├── assets/ # 静态资源 │ ├── models/ # 数据模型定义 │ ├── pages/ # 页面组件 │ ├── store/ # Redux状态管理 │ │ ├── common/ # 通用状态 │ │ └── index.ts # Store配置 │ ├── translations/ # 国际化文件 │ ├── utils/ # 工具函数 │ │ └── mock/ # Mock服务 │ ├── Home.tsx # 主页组件 │ └── main.tsx # 入口文件 ├── src-tauri/ # Tauri/Rust后端代码 ├── public/ # 公共资源 ├── package.json # 项目依赖配置 ├── postcss.config.js # PostCSS配置 ├── tailwind.config.ts # Tailwind CSS配置 ├── tsconfig.json # TypeScript配置 ├── tsconfig.node.json # Node环境TypeScript配置 └── vite.config.ts # Vite配置 这种组织方式促进了关注点的清晰分离,使应用程序的不同部分易于定位和操作。 开始使用 让我们来看看如何为您自己的项目设置和使用这个脚手架。 环境准备 在开始之前,确保已安装以下工具: Rust:Tauri后端的基础 curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh Node.js:前端JavaScript环境 pnpm:我们选择的包管理器,提供更快、更高效的依赖管理 npm install -g pnpm Tauri CLI:Tauri开发必备 cargo install tauri-cli 开发工作流 安装好先决条件后,可以开始开发: 安装依赖: pnpm install 启动开发服务器: 桌面应用开发: pnpm dev Web开发: pnpm wdev 这种双环境支持允许您在两种环境中开发和测试应用程序。 构建应用程序: 桌面应用打包: pnpm tauri build Web应用构建: pnpm build 功能展示 启动模板开箱即带有精美的UI,包括对不同主题的支持: 浅色主题,提供清晰明亮的界面 深色主题,减少眼睛疲劳,呈现现代美感 系统主题检测,匹配用户偏好 自定义标题栏,符合各操作系统的设计指南 IDE推荐配置 为获得最佳开发体验,我们推荐使用: VS Code 并安装以下扩展: Tauri扩展,增强Tauri开发 rust-analyzer,提供Rust代码智能 这种组合提供了智能代码补全、内联错误检测以及其他特定于Tauri和Rust开发的生产力功能。 自定义和扩展 脚手架设计为易于自定义。以下是您可能想要进行的一些常见自定义: 添加新UI组件:放置在components目录中 创建新页面:添加到src/pages目录 扩展状态管理:向Redux存储添加新的切片 添加Rust功能:使用自定义Rust代码增强src-tauri后端 设置新主题:在Tailwind中修改主题配置
2024年12月18日
2024-08-06
基于 Taro 技术栈搭建的跨端客户端脚手架
基于 Taro 技术栈搭建的跨端客户端脚手架 一、项目背景 接手的项目各个技术栈不同,有原生微信小程序,有flutter开发的客户端,有uniapp开发的客户端,现在想统一技术栈,为什么选择Taro不是uniapp,主要是因为ReactNative。 二、技术栈选型 Taro 3.x:京东凹凸实验室开源的跨端开发框架 React 18:用于构建用户界面的 JavaScript 库 TypeScript:添加了类型系统的 JavaScript 超集 NutUI:京东风格的轻量级移动端组件库 Redux Toolkit:Redux 官方推荐的工具集 Pnpm:高性能的包管理工具 三、项目结构设计 ├── config # 项目配置文件 │ ├── dev.js # 开发环境配置 │ ├── index.js # 基础配置 │ └── prod.js # 生产环境配置 ├── src # 源码目录 │ ├── api # API 接口 │ ├── assets # 静态资源 │ ├── components # 公共组件 │ ├── constants # 常量定义 │ ├── models # 模型定义 │ ├── hooks # 自定义 Hooks │ ├── pages # 页面文件 │ ├── service # 业务处理 │ ├── store # 状态管理 │ ├── types # TypeScript 类型定义 │ ├── utils # 工具函数 │ └── app.tsx # 应用入口 四、关键技术实现 1. 多端适配策略 // 条件编译示例 import { Platform } from '@tarojs/service' // #ifdef WEAPP console.log('微信小程序环境') // #endif // #ifdef TT console.log('抖音小程序环境') // #endif 2. 状态管理方案 // store/index.ts import { configureStore } from '@reduxjs/toolkit' import userReducer from './slices/user' export const store = configureStore({ reducer: { user: userReducer } }) 3. 网络请求封装 // utils/request.ts import Taro from '@tarojs/taro' const request = async (options) => { try { const response = await Taro.request({ url: BASE_URL + options.url, method: options.method, data: options.data, header: { 'content-type': 'application/json', ...options.header } }) return response.data } catch (error) { console.error('请求错误:', error) throw error } } 五、最佳实践与注意事项 1. 组件库使用规范 NutUI 图标组件引入顺序问题 // ✅ 正确示例 import { ArrowRight } from '@nutui/icons-react-taro' import { Button } from '@nutui/nutui-react-taro' // ❌ 错误示例 import { Button } from '@nutui/nutui-react-taro' import { ArrowRight } from '@nutui/icons-react-taro' 2. 平台差异处理 抖音小程序 AbortController 兼容性问题 微信小程序热重载配置注意事项 3.注意点 如果报如下警告 despite it was not able to fulfill desired ordering with these modules: * css ./node_modules/.pnpm/css-loader@7.1.2_webpack@5.78.0_@swc+core@1.3.96_/node_modules/css-loader/dist/cjs.js ??ruleSet[1].rules[4].oneOf[0] .use[1]!./node_modules/.pnpm/postcss-loader@8.1.1_postcss@8.4.49_typescript@5.7.3_webpack@5.78.0_@swc+core@1.3.96_/node_modules/postcss-loader/dist/cjs.js ??ruleSet[1].rules[4].oneOf[0] .use[2]!./node_modules/.pnpm/@nutui+icons-react-taro@2.0.1/node_modules/@nutui/icons-react-taro/dist/style_icon.css - couldn't fulfill desired order of chunk group(s) pages/user/index - while fulfilling desired order of chunk group(s) pages/index/index, pages/category/index, pages/order/index 原因是 import { ArrowRight } from '@nutui/icons-react-taro' import引入顺序有问题,因为在这个语句的前面有引入其他的组件,其他的组件里也使用了@nutui/icons-react-taro,所以会报这个警告,只需要把import { ArrowRight } from '@nutui/icons-react-taro'放到最前面就可以了。 4.坑点 taro的mock插件没有适配最新版,不能新建mock目录,已经适配了最新版的插件可自行编译https://github.com/javajeans/taro-plugin-mock 六、构建与部署 # 安装依赖 pnpm install # 开发环境 pnpm dev:weapp # 微信小程序 pnpm dev:tt # 抖音小程序 pnpm dev:h5 # H5 # 生产环境 pnpm build:weapp pnpm build:tt pnpm build:h5 七、性能优化建议 合理使用条件编译 组件按需加载 图片资源压缩 避免不必要的重渲染 八、总结 通过 Taro 技术栈,我们成功搭建了一套可维护、可扩展的跨端开发脚手架。该脚手架具有以下特点: 统一的开发体验 完善的工程化配置 规范的代码风格 丰富的组件库支持 灵活的状态管理
2024年08月06日
2019-07-21
Spring Security OAuth2在微前端架构下实现统一退出
Spring Security OAuth2在微前端架构下实现统一退出 在微前端架构中,当一个应用退出登录时,实现所有应用同步退出的功能是一个常见需求。这个问题主要源于微前端架构下各个子应用之间的分离特性,导致一个应用注销后,其他应用仍保持登录状态。下面我将详细介绍如何解决这个问题。 问题分析 微前端架构下,登出问题主要有以下几个原因: 各子应用可能使用独立的会话管理 OAuth2 token可能在各应用中独立存储 缺乏统一的退出机制来协调各个子应用 解决方案 1. 实现单点登出(Single Sign-Out) @RestController public class LogoutController { private final TokenStore tokenStore; private final ClientRegistrationRepository clientRegistrationRepository; public LogoutController(TokenStore tokenStore, ClientRegistrationRepository clientRegistrationRepository) { this.tokenStore = tokenStore; this.clientRegistrationRepository = clientRegistrationRepository; } @PostMapping("/api/logout") public ResponseEntity<Map<String, String>> logout( @RequestParam(value = "token", required = false) String token, HttpServletRequest request, HttpServletResponse response, @AuthenticationPrincipal OAuth2User principal) { // 获取当前认证信息 Authentication auth = SecurityContextHolder.getContext().getAuthentication(); // 如果存在认证信息,则进行处理 if (auth != null) { // 撤销token if (auth instanceof OAuth2AuthenticationToken) { OAuth2AuthenticationToken oauthToken = (OAuth2AuthenticationToken) auth; String clientRegistrationId = oauthToken.getAuthorizedClientRegistrationId(); // 根据需要调用OAuth2服务器的撤销接口 revokeToken(clientRegistrationId, token); } // 清除安全上下文 SecurityContextHolder.clearContext(); // 使session失效 HttpSession session = request.getSession(false); if (session != null) { session.invalidate(); } // 清除相关cookie deleteCookies(request, response); } Map<String, String> result = new HashMap<>(); result.put("message", "成功退出登录"); return ResponseEntity.ok(result); } private void revokeToken(String clientRegistrationId, String token) { try { ClientRegistration clientRegistration = clientRegistrationRepository.findByRegistrationId(clientRegistrationId); if (clientRegistration != null && token != null) { // 根据OAuth2服务器提供的撤销端点进行token撤销 String revokeEndpoint = clientRegistration.getProviderDetails().getConfigurationMetadata().get("revocation_endpoint").toString(); RestTemplate restTemplate = new RestTemplate(); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); MultiValueMap<String, String> map = new LinkedMultiValueMap<>(); map.add("token", token); map.add("client_id", clientRegistration.getClientId()); map.add("client_secret", clientRegistration.getClientSecret()); HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(map, headers); restTemplate.postForEntity(revokeEndpoint, request, String.class); } } catch (Exception e) { // 处理可能的异常 logger.error("撤销token失败", e); } } private void deleteCookies(HttpServletRequest request, HttpServletResponse response) { Cookie[] cookies = request.getCookies(); if (cookies != null) { for (Cookie cookie : cookies) { // 删除认证相关的cookie if (cookie.getName().startsWith("OAUTH2_") || cookie.getName().equals("JSESSIONID") || cookie.getName().equals("SESSION")) { Cookie newCookie = new Cookie(cookie.getName(), null); newCookie.setPath("/"); newCookie.setMaxAge(0); newCookie.setHttpOnly(true); response.addCookie(newCookie); } } } } } 2. 创建统一的登出前端服务 // auth-service.js export class AuthService { constructor() { this.logoutListeners = []; this.setupLogoutListener(); } // 添加登出监听器 addLogoutListener(callback) { this.logoutListeners.push(callback); return () => { this.logoutListeners = this.logoutListeners.filter(cb => cb !== callback); }; } // 发送登出事件到所有注册的应用 notifyLogout() { // 通过BroadcastChannel发送消息 const bc = new BroadcastChannel('auth-logout-channel'); bc.postMessage({ type: 'LOGOUT' }); bc.close(); // 同时触发监听器 this.logoutListeners.forEach(callback => callback()); } // 监听其他应用的登出事件 setupLogoutListener() { const bc = new BroadcastChannel('auth-logout-channel'); bc.onmessage = (event) => { if (event.data.type === 'LOGOUT') { // 执行本地登出操作 this.logout(false); // 不广播,避免循环 } }; } // 登出方法 async logout(broadcast = true) { try { // 调用后端登出API const response = await fetch('/api/logout', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' } }); if (response.ok) { // 清除本地存储 localStorage.removeItem('oauth2_token'); sessionStorage.removeItem('oauth2_token'); // 清除相关cookie this.clearAuthCookies(); // 通知其他应用 if (broadcast) { this.notifyLogout(); } return true; } else { console.error('登出失败:', await response.text()); return false; } } catch (error) { console.error('登出过程中发生错误:', error); return false; } } // 清除认证相关cookie clearAuthCookies() { const cookies = document.cookie.split(';'); for (let i = 0; i < cookies.length; i++) { const cookie = cookies[i]; const eqPos = cookie.indexOf('='); const name = eqPos > -1 ? cookie.substring(0, eqPos).trim() : cookie.trim(); if (name.startsWith('OAUTH2_') || name === 'JSESSIONID' || name === 'SESSION') { document.cookie = `${name}=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT`; } } } // 检查是否已登录 isAuthenticated() { // 根据实际存储方式检查认证状态 return localStorage.getItem('oauth2_token') !== null || sessionStorage.getItem('oauth2_token') !== null; } } // 导出单例 export const authService = new AuthService(); 3. 创建主应用的登出组件 // LogoutButton.jsx (React组件示例) import React from 'react'; import { authService } from './auth-service'; const LogoutButton = () => { const handleLogout = async () => { const success = await authService.logout(); if (success) { // 重定向到登录页或首页 window.location.href = '/login'; } else { alert('登出失败,请重试'); } }; return ( <button className="logout-button" onClick={handleLogout} > 退出登录 </button> ); }; export default LogoutButton; 4. 为各个微前端应用添加登出监听 // 在每个子应用的入口文件中添加 import { authService } from 'shared-lib/auth-service'; class MicroApp { constructor() { this.init(); } init() { // 注册登出监听器 this.unsubscribeLogout = authService.addLogoutListener(() => { // 当收到登出事件时,清理本地状态 this.handleLogout(); }); // 监听卸载事件,移除监听器 window.addEventListener('unload', () => { if (this.unsubscribeLogout) { this.unsubscribeLogout(); } }); // 设置全局登出处理方法 window.microAppLogout = () => { authService.logout(); }; } handleLogout() { // 清理本地存储 localStorage.removeItem('app_state'); sessionStorage.removeItem('app_state'); // 重置应用状态 this.resetAppState(); // 可选:重定向到登录页 window.location.href = '/login'; } resetAppState() { // 重置应用状态逻辑 // ... } } // 初始化应用 const app = new MicroApp(); 5. 使用Redis实现会话共享 为了确保后端会话的统一管理,应使用Redis存储会话: @Configuration @EnableRedisHttpSession public class RedisSessionConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); // 使用Jackson2为序列化和反序列化 Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class); ObjectMapper mapper = new ObjectMapper(); mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); mapper.activateDefaultTyping( LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY ); serializer.setObjectMapper(mapper); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(serializer); template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); return template; } @Bean public HttpSessionIdResolver httpSessionIdResolver() { // 使用Cookie和Header双重策略,提高灵活性 return new CookieAndHeaderHttpSessionIdResolver(); } } // 自定义会话ID解析器,支持Cookie和Header class CookieAndHeaderHttpSessionIdResolver implements HttpSessionIdResolver { private static final String HEADER_NAME = "X-Auth-Token"; private final CookieHttpSessionIdResolver cookieResolver = new CookieHttpSessionIdResolver(); private final HeaderHttpSessionIdResolver headerResolver = HeaderHttpSessionIdResolver.xAuthToken(); @Override public List<String> resolveSessionIds(HttpServletRequest request) { List<String> sessionIds = cookieResolver.resolveSessionIds(request); if (sessionIds.isEmpty()) { sessionIds = headerResolver.resolveSessionIds(request); } return sessionIds; } @Override public void setSessionId(HttpServletRequest request, HttpServletResponse response, String sessionId) { cookieResolver.setSessionId(request, response, sessionId); headerResolver.setSessionId(request, response, sessionId); } @Override public void expireSession(HttpServletRequest request, HttpServletResponse response) { cookieResolver.expireSession(request, response); headerResolver.expireSession(request, response); } } 6. 实现OAuth2统一退出端点 为了完全支持OAuth2退出,还需要实现一个注销端点连接到OAuth2服务器: @Configuration public class OAuth2LogoutConfig { private final ClientRegistrationRepository clientRegistrationRepository; public OAuth2LogoutConfig(ClientRegistrationRepository clientRegistrationRepository) { this.clientRegistrationRepository = clientRegistrationRepository; } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize -> authorize .requestMatchers("/api/public/**", "/login", "/oauth2/authorization/**").permitAll() .anyRequest().authenticated() ) .oauth2Login(oauth2 -> oauth2 .loginPage("/login") .defaultSuccessUrl("/dashboard") ) .logout(logout -> logout .logoutUrl("/api/logout") .logoutSuccessHandler(this.oidcLogoutSuccessHandler()) .invalidateHttpSession(true) .clearAuthentication(true) .deleteCookies("JSESSIONID", "SESSION") ); return http.build(); } private LogoutSuccessHandler oidcLogoutSuccessHandler() { OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler = new OidcClientInitiatedLogoutSuccessHandler(this.clientRegistrationRepository); // 设置退出后的重定向URL oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}/login?logout=success"); return (request, response, authentication) -> { // 获取会话中的信息用于撤销token等操作 HttpSession session = request.getSession(false); if (session != null) { String idToken = (String) session.getAttribute("id_token"); if (idToken != null && authentication instanceof OAuth2AuthenticationToken) { OAuth2AuthenticationToken oauthToken = (OAuth2AuthenticationToken) authentication; ClientRegistration clientRegistration = this.clientRegistrationRepository .findByRegistrationId(oauthToken.getAuthorizedClientRegistrationId()); // 尝试撤销令牌 try { revokeTokens(clientRegistration, request); } catch (Exception e) { // 记录错误但继续退出流程 e.printStackTrace(); } } } // 调用OIDC退出处理器 oidcLogoutSuccessHandler.onLogoutSuccess(request, response, authentication); }; } private void revokeTokens(ClientRegistration clientRegistration, HttpServletRequest request) { // 实现令牌撤销逻辑 HttpSession session = request.getSession(false); if (session != null && clientRegistration != null) { String accessToken = (String) session.getAttribute("access_token"); String refreshToken = (String) session.getAttribute("refresh_token"); if (accessToken != null || refreshToken != null) { // 检查是否有撤销端点配置 Object revocationUri = clientRegistration.getProviderDetails() .getConfigurationMetadata().get("revocation_endpoint"); if (revocationUri != null) { RestTemplate restTemplate = new RestTemplate(); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); headers.setBasicAuth(clientRegistration.getClientId(), clientRegistration.getClientSecret()); // 撤销访问令牌 if (accessToken != null) { MultiValueMap<String, String> body = new LinkedMultiValueMap<>(); body.add("token", accessToken); body.add("token_type_hint", "access_token"); HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(body, headers); restTemplate.postForEntity(revocationUri.toString(), entity, String.class); } // 撤销刷新令牌 if (refreshToken != null) { MultiValueMap<String, String> body = new LinkedMultiValueMap<>(); body.add("token", refreshToken); body.add("token_type_hint", "refresh_token"); HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(body, headers); restTemplate.postForEntity(revocationUri.toString(), entity, String.class); } } } } } } 实现要点解析 统一后端登出端点 清除服务器端会话 撤销OAuth2令牌 删除相关Cookie 通知OAuth2授权服务器 前端统一登出机制 使用BroadcastChannel进行跨应用通信 共享认证服务实现登出状态同步 提供全局登出方法 使用Redis共享会话 确保所有后端服务访问相同的会话存储 支持会话的快速失效 前端存储清理 清除localStorage和sessionStorage中的认证信息 删除认证相关Cookie 最佳实践 使用OpenID Connect(OIDC)标准 利用OIDC的标准登出流程 实现前后端退出的协调 使用统一的会话ID 为所有微前端应用使用相同的会话标识 避免使用多种会话管理方式 实现健壮的错误处理 当部分退出流程失败时仍能完成整体退出 提供清晰的用户反馈 安全考虑 确保退出端点的安全访问 防止CSRF攻击 结论 通过上述方案的实现,可以确保微前端架构下的统一退出机制,使得用户在任一子应用退出后,所有应用都同步退出登录状态。这种方案既保证了用户体验的一致性,也提高了系统的安全性,避免了部分应用仍保持登录状态可能带来的安全风险。 刚好再学react,就用react写前端了
2019年07月21日
1
2
...
4