C

Cytrogen 的个人博客

万圣节恶魔的领地

在 Hexo 项目中添加 Webmention

Webmention 是 IndieWeb 的一个核心的开放标准,允许网站之间像社交平台一样互动:评论、点赞、转发等。重点是它去中心化、跨站点实现这些功能。 要知道,我的博客是用「老掉牙」的 Hexo 构建的。Hexo 的插件列表里,可是没有任何一款插件是和 Webmention 相关的。假设我想要在自己的网站里添加 Webmention 的发送和接收功能,只能自己写。更悲伤的是,我的「强迫症」要求我在自己开发的主题里也添加上即拿即用的 Webmention 功能。 和过去一样,我会将 Hexo 项目根目录的 _config.yml 称呼为 Hexo 配置项、将 themes/主题名/ 目录下的 _config.yml 称呼为主题配置项。 我用的是 Markdown + Pug。如果你用的是其他引擎,请自行改动代码,逻辑应当都是相同的。 该文章默认你是 Hexo 插件 & 主题开发者!如果你不是、又或者说只是想要即拿即用的方案,见 这里。 接收 要想接收 Webmention,非常简单。你只需要在网站里添加这样的标签: <link rel="me" href="https://github.com/yourusername" /> 这不需要是 GitHub 链接。实际上大多数社交平台的链接都可以,像 GitLab 啦、推特啦、长毛象啦…… 我写了一个辅助函数来检测一个链接是否是社交平台的链接: - function isSocialMediaUrl(url) { if (!url) return false; const socialDomains = [ 'github.com', 'gitlab.com', 'bitbucket.org', 'twitter.com', 'x.com', 'mastodon.social', 'mastodon.online', 'linkedin.com', 'facebook.com', 'instagram.com', 'weibo.com', 'zhihu.com', 'bilibili.com', 'youtube.com', 'youtu.be', 'tiktok.com', 'discord.gg', 'telegram.me', 't.me' ]; try { const domain = new URL(url).hostname.toLowerCase(); return socialDomains.some(socialDomain => domain === socialDomain || domain.endsWith('.' + socialDomain) ); } catch { return false; } } 这个 <link> 呢,放在哪里都可以。像我就偷懒了,直接在我的二级菜单里添加了这个判断条件: mixin nav-link(item) if item.type === 'internal' // 省略 else if item.type === 'external' - var relAttr = "noopener noreferrer" + (isSocialMediaUrl(item.path) ? " me" : "") a.nav-link(href=item.path target="_blank" rel=relAttr) != __(`menu.${item.key}`) +icon-external-link() 我的主题可以手动标记二级菜单里的链接是否是内部还是外部的(其实自动识别更好,但我偷懒了)。假设是外部链接,就让主题判断它是否是社交平台链接。如果是的话,就添加 rel="me" 这个属性。因为二级菜单里的链接大概率都是自己的社交平台链接,所以我认为这么写完全没问题~ 这样做的目的是身份验证。现在来到 Webmention.io 里便可以注册该网站、获得你的接收网址: <link rel="webmention" href="https://webmention.io/你的域名/webmention" /> 再将其添加网站的 <head> 里: if theme.webmention && theme.webmention.domain link(rel="webmention", href=`https://webmention.io/${theme.webmention.domain}/webmention`) webmention: domain: 你的域名 为了让使用到 Hexo-Theme-Ares 的用户也可以立即使用上 Webmention,我写成了需要在主题配置项里配置、才能自动添加接收链接的逻辑。 收到的所有 Webmention 都可以在 Webmention.io 的仪表盘 里查看。 发送 我参考的是 Webmention.app 文档 里的做法。 我建议还在开发该功能的朋友们,先使用手动发送。待功能完善后,再进行自动化。 问的话,只能说都是泪。我在功能还未完善时就采用了 Netlify 自动请求 Webhook 的方案。结果向接收方发送了数个不完善的 Webmention。这些 Webmention 还无法撤回,只能等待对方自行移除。不是什么大问题,但心里会想着给对方添了麻烦、实在是抱歉。 再就是手动发送时请一定要注意自己网页的 URL 规范。例如我现在这个文章,你可以用 /posts/1de 访问,也可以用 /posts/1de.html 访问。看似是一样的,但是对于 Webmention.io 而言它们是完全不同的源地址。 假设你先用了不带 .html 的 URL 作为源地址发送,之后又用了带 .html 的 URL 作为源地址发送。即时它们内容一样、目标一样,服务器也会创建两条独立的记录。 想知道我是如何知道的吗?就算你问我这个问题,我也只能看着接收方数个重复的 Webmention 记录、笑笑不说话了。 我个人建议用后者发送,因为是 Permalink。 Webmention.app 提供了数个发送的方法,其核心都是通过检查指定 URL 的内容并自动发现其中的链接目标,然后向这些目标发送 Webmention 通知: 基础发送方式 命令行工具 使用 curl -X POST 向 https://webmention.app/check?url=你的网页地址 发起请求 支持一次性发送单条或多条条目,会自动发现 .h-entry 等 Microformats 标记(见 Microformats) 可通过 GET 请求进行 dry-run 预览,也就是检查将要发送的 Webmention 但不会实际发送 建议在 Webmention.app 内领取令牌并配合使用,避免速率限制。如果不添加令牌的话,每个独立的 URL 每四个小时只能请求一次。用法很简单,只需要在 API 里添加 token 参数 Node.JS 本地工具 安装 @remy/webmention 包后,可以使用 webmention 或者简写命令 wm 支持读取多种内容格式,包括 HTML、RSS、Atom 使用方法:npx webmention 你的网站的feed地址 --limit 数字 --send --limit 默认为 10 条。假设设置成 1,就是发送 feed 里最新的文章的 Webmention 默认执行 dry-run,加上 --send 参数才会实际发送 Webmention Webmention.app 网页 该方案没有在文档中提及。简单来说便是在 Webmention.app 的 Test a URL 页 手动填写网页地址、手动点击 Send All Webmentions 按钮发送 其实就是「命令行工具」方案的网页版,更好看些 自动化发送方案 Netlify 部署通知 步骤 在 Netlify 项目中访问 Deploys 页 点击 Notifications 按钮 在 Deploy notifications 块旁找到 Add notification 选项 点击后选择 HTTP POST request Event to listen for 选择 Deploy succeeded URL to notify 输入该 URL:https://webmention.app/check?token=令牌&limit=限制发送多少条&url=你的网站feed地址 每次部署成功后,Netlify 都会自动向 Webmention.app 发送 POST 请求 其实该方法就是最先说的「命令行工具」方案的进化版,并不意味着必须要使用 Netlify 部署网站才能自动化。你只要能确保每次部署成功后都可以发送一次该 POST 请求即可 IFTTT + RSS Feed 该方案需要付费。我个人不推荐该方案,你可以找找 IFTTT 的免费代替品,或者使用其他方案。和我上面说的一样,这些方案的本质都是向 Webmention.app 发送 POST 请求 IFTTT + 定时触发 同上,不推荐 Microformats Microformats 是一组基于 HTML 的轻量级语义标记规范,用于在网页中嵌入结构化数据,使人类和机器都能更好地理解和使用网页内容。 它的核心概念是: 不需要额外的语法或嵌套语言,直接在网页中使用标准 HTML 元素和 class 属性 通过添加特定 class 名称来标记文章、人物、事件等信息 搜索引擎、浏览器、社交平台等可以自动识别这些标记并提取结构化数据 标记内容仍然是普通网页的一部分,不影响用户阅读体验 为了让 Webmention 功能更完整地显示我们的内容,Microformats 是十分建议添加的。重点在于第二条:标记文章、人物、事件等信息。 文章:h-entry 用于标记网页内容条目的语义类名 用纯 HTML 作为例子,用法是这样的: <div class="h-entry"> <p>世界你好~</p></div> 要想添加标题、摘要、正文等信息,需要在 h-entry 所属的标签内部添加子标签。这些是广泛使用的属性: p-name:条目标题或名称 <div class="h-entry"> <p class="p-name">我是标题</p> <p>世界你好~</p></div> p-summary:简短摘要 <div class="h-entry"> <p class="p-name">我是标题</p> <p class="p-summary">我是摘要</p> <p>世界你好~</p></div> e-content:正文内容 <div class="h-entry"> <p class="p-name">我是标题</p> <p class="p-summary">我是摘要</p> <p class="e-content">世界你好~</p></div> dt-published:发布时间(建议使用 <time datetime>) 就不举例了,总之和上面的例子一样,都是标签里加类名。 dt-updated:更新时间 p-author:分类或标签 u-url:条目的永久链接 u-uid:唯一标识符,通常与 u-url 相同 p-location:发布地点(可嵌套 h-card、h-adr、h-geo) u-syndication:条目的分发副本链接 u-in-reply-to:所回应的原始条目链接(可嵌套 h-cite) p-rsvp:RSVP 状态 RSVP 指的是法语短语「Répondez s'il vous plaît」的缩写,意思是「请回复」。在活动邀请中,它用于请求受邀者告知是否会出席。虽然来源是法语,但这个缩写已经被英语和其他语言广泛采用,尤其在正式或半正式的场合中 在 Microformats2 的语义标记中,p-rsvp 用来表示你对某个活动(h-event)的回应状态。支持的值包括: yes:会参加 no:不会参加 maybe:尚未决定 interested:感兴趣但未承诺参加 u-like-of:所点赞的条目链接 u-repost-of:所转发的条目链接 虽然没有强制要求,但一个典型的 h-entry 应至少包含: u-url dt-published e-content 或 p-summary p-name p-author:稍后在 h-card 提到 人物:h-card 用于在网页中嵌入人物或组织的结构化信息 和 h-entry 一样的用法。核心属性是这些,它们都是可选的,并且支持多值: p-name:姓名或组织名称 u-url:主页或代表链接 u-photo:头像或代表图像 u-email:邮箱地址 p-org:所属组织 p-job-title:职位名称 p-note:附加说明 p-category:标签或分类 dt-bday:生日日期 p-tel:电话号码 p-adr:地址信息,可嵌套 h-adr p-geo / u-geo:地理位置,可嵌套 h-adr h-card 不强制放入 h-entry 内,但我 推荐 嵌套使用。这样有助于 Webmention 接收方或 Microformats 解析器识别作者是谁,并展示头像、名称、主页等信息 现在我们回到 Hexo。我建议在渲染文章的 <article> 标签里添加 h-entry 类。 我个人的写法是这样的: mixin post(item) .post article.post-block.h-entry .post-meta.p-author.h-card img.author-avatar.u-photo(src=url_for(theme.logo), alt=config.author) a.author-name.p-name.u-url(href=config.url)= config.author a.post-permalink.u-url(href=full_url_for(item.path)) 永久链接 if item.excerpt .p-summary != item.excerpt h1.post-title.p-name != item.title +postInfo(item) if item.in_reply_to .post-reply != __('reply_to') + ': ' a.u-in-reply-to(href=item.in_reply_to)= item.in_reply_to .post-content.e-content != item.content 此处我直接照搬的主题源码。实际传参用的是 page。 需要注意的是,我没有使用到全部的属性。未来我会慢慢扩展这些内容。 mixin postInfo(item) .post-info time.post-date.dt-published(datetime=date_xml(item.date)) != full_date(item.date, 'l') if item.from && (is_home() || is_post()) a.post-from(href=item.from target="_blank" title=item.from)!= __('translated') 在文章中的 Front Matter 里添加该属性来指定目标地址: ---in_reply_to: 目标地址--- 不过我的主题有些特殊: 导航栏会渲染我的头像作为 logo,我不想要文章又一次显示该头像 部分内容显示出来会导致整体页面看上去比较臃肿 item.content 包含了 item.excerpt 在内。直接从 item.content 里取摘要会很麻烦,再显示一次 item.excerpt 又很奇怪 解决方案也很简单。既然这部分内容只是给机器看的,我们将其隐藏就好了。直接全部加上 display: none 样式。 进阶玩法 仅发送一部分内容 有时候,你一个文章里只有小部分内容是你想要发送的;有时候,接收方的 Webmention 显示处并没有写字数限制,你的文章会原封不动地全部显示在接收方的网页里。至少我是想要一个更灵活的发送方法,因此我写了个脚本。 效果如下: {% reply 目标地址 %}这是会被发送给目标地址的内容。目标地址是 xxx。{% endreply %}这是不会被发送给目标地址的内容。 首先我们需要注册标签 reply: hexo.extend.tag.register('reply', function(args, content) { const targetUrl = args[0]; // ...}, {ends: true}); 我们先前的主题模板写法,会默认给文章最外部包上 h-entry、发送整个文章。既然有些文章我们只想在内部包含多个小的 h-entry 回复块,我们可以在内存中添加一个属性 entry。 如果该文章我们希望整个打包送走,那就在文章的 Front Matter 里添加: ---entry: true--- 并在主题模板里添加条件判断: mixin post(item) .post //- 如果 entry 为 true,包裹整个 article if item.entry === true article.post-block.h-entry .post-meta.p-author.h-card(style="display: none;") img.author-avatar.u-photo(src=url_for(theme.logo), alt=config.author) a.author-name.p-name.u-url(href=config.url)= config.author a.post-permalink.u-url(href=full_url_for(item.path), style="display: none;") 永久链接 if item.excerpt .p-summary(style="display: none;") != item.excerpt h1.post-title.p-name != item.title +postInfo(item) if item.in_reply_to .post-reply != __('reply_to') + ': ' a.u-in-reply-to(href=item.in_reply_to)= item.in_reply_to .post-content.e-content != item.content else article.post-block h1.post-title != item.title +postInfo(item) if item.in_reply_to .post-reply != __('reply_to') + ': ' a.u-in-reply-to(href=item.in_reply_to)= item.in_reply_to .post-content != item.content 由于会使用到 reply 标签的文章都不希望发送整个文章,所以在脚本内我们要将 entry 设置为 false: if (this.page) { this.page.entry = false;} 接着验证 URL: // 1. 检查用户是否忘记提供 URLif (!targetUrl) { hexo.log.warn('Reply tag: Missing target URL'); return '<div class="reply-error">回复标签缺少目标URL</div>';}// 2. 确保是绝对URLlet absoluteUrl;try { new URL(targetUrl); absoluteUrl = targetUrl;} catch (e) { try { // 3. 如果失败,尝试作为相对路径处理 absoluteUrl = hexo.extend.helper.get('full_url_for').call({config: hexo.config}, targetUrl); } catch (e2) { // 4. 如果再次失败,则判定为无效 URL 并报错 hexo.log.warn(`Reply tag: Invalid URL - ${targetUrl}`); return '<div class="reply-error">回复标签包含无效URL</div>'; }} 渲染 Markdown 内容: let renderedContent;try { renderedContent = hexo.render.renderSync({ text: content.trim(), engine: 'markdown' });} catch (e) { hexo.log.warn('Reply tag: Failed to render markdown content'); renderedContent = content; // 降级到纯文本} 国际化支持: // 获取当前语言的“回复”翻译const currentLang = this.page ? this.page.lang : hexo.config.language;let replyText = '回复';// 尝试从主题语言文件获取翻译try { const langHelper = hexo.extend.helper.get('__'); if (langHelper) { replyText = langHelper.call({page: {lang: currentLang}}, 'reply_to') || replyText; }} catch (e) { // 使用默认文本} 获取并处理作者信息: // 获取作者信息和网站配置const config = hexo.config;const theme = hexo.theme.config;const authorName = config.author || 'Anonymous';const siteUrl = config.url || '';// 简单构造绝对 URL,确保 logo 路径正确let authorAvatar = '';if (theme.logo) { // 如果 logo 已经是绝对 URL,直接使用 if (theme.logo.startsWith('http')) { authorAvatar = theme.logo; } else { // 确保路径以/开头,构造网站根路径 const logoPath = theme.logo.startsWith('/') ? theme.logo : '/' + theme.logo; authorAvatar = (config.url || '').replace(/\/$/, '') + logoPath; }} 最后生成 HTML 结构: return ` <div class="reply-block h-entry"> <!-- 作者信息(h-card) --> <div class="post-meta p-author h-card" style="display: none"> ${authorAvatar ? `<img class="author-avatar u-photo" src="${authorAvatar}" alt="${authorName}">` : ''} <a class="author-name p-name u-url" href="${siteUrl}">${authorName}</a> </div> <!-- 回复内容 --> <div class="reply-content e-content"> ${renderedContent} </div> <!-- 回复元信息 - 通过 CSS 在列表页隐藏 --> <div class="reply-meta"> <span class="reply-label">${replyText}:</span> <a class="reply-target u-in-reply-to" href="${absoluteUrl}">${absoluteUrl}</a> </div> </div>`; 这里我声明了一些类,需要自定义样式: reply-block post-meta author-avatar author-name reply-content reply-meta reply-label reply-target 不用这些样式也可以。我个人更希望回复块和普通文本们可以区分开来。 和先前做的一样,我也为 h-card 标签添加了 display: none 样式。这个样式可以根据你们的需求、选择保留或者删除。 显示 要想在博客内显示其他人向我们发送的 Webmention,就需要用到 Webmention.io 提供的 Mentions Feed API。Webmention.io 会提供给我们一个 API 密钥,只需要将其添加在以下任意一个 URL 中即可: https://webmention.io/api/mentions.html?token=API密钥 https://webmention.io/api/mentions.atom?token=API密钥 https://webmention.io/api/mentions.jf2?token=API密钥 这些链接会根据自己的格式,显示网站所有的 Webmention。但要是用在我们的博客网站上的话,就遭殃了:读者只想阅读你的一篇文章,但你的网站已经获取了全部一千篇文章的 Webmention。请不要这么做。 正确做法是使用 url 参数,指定获取哪个文章的 Webmention。我建议使用 jf2 来获取数据,它是纯粹的、为机器而生的数据格式: https://webmention.io/api/mentions.jf2?url=文章URL 如果你认为数据获取可以在客户端上进行、希望 Webmention 可以实时显示,你完全可以在主题的 /source/js/ 目录下添加一个 JavaScript 文件执行网络请求和数据处理。不过纯动态有些劣势: 对 SEO 不友好。其他人发送的 Webmention 不会被搜索引擎索引 可能在内容出现时导致页面布局发生跳动 浏览器需要发起一次额外的网络请求 如果 Webmention.io 的 API 出现故障或者相应缓慢,就什么都加载不出来了 如果你认为这四点很重要,你可以考虑采用纯静态构建。这需要你在项目根目录的 /scripts/ 目录下创建 Hexo 脚本: hexo.extend.generator.register('webmentions', async function() { const hexo = this; const webmentionConfig = hexo.config.webmention; let allMentions = []; if (webmentionConfig && webmentionConfig.enable) { try { // 拼接 Webmention.io API URL 并且发起请求 const newWebmentions = await fetchWebmentions(hexo.config); // 更新本地的缓存文件 // 缓存功能非必需,可以无视掉! allMentions = updateWebmentionsCache(hexo, newWebmentions); } catch (error) { hexo.log.error(error.message); hexo.log.warn('[Webmention] Falling back to local cache due to fetch error.'); allMentions = loadWebmentionsCache(hexo); } } else { allMentions = loadWebmentionsCache(hexo); } // 将获取到的数据注入到 Hexo 的本地变量 hexo.locals.set('all_webmentions', allMentions); if (webmentionConfig && webmentionConfig.debug) { hexo.log.info(`[Webmention] Injected ${allMentions.length} total mentions into hexo.locals.`); } return;}); 我假设了项目配置项里有这些内容: webmention: enable: true domain: 域名 api_endpoint: https://webmention.io/api/mentions.jf2 token: 令牌 要想要在主题模板里渲染这些变量,有两种方法: 直接在主题模板里用 site 变量渲染 关于 Hexo 的局部变量,建议看以下文档: 自定义 - 变量 - 《Hexo 博客框架文档》 - 书栈网・BookStack:列出了模板中所有可用的变量 局部变量 | Hexo:官方文档。主要是说明如何在 JavaScript 里操作 hexo.locals 总之,脚本里 hexo.locals.get("posts") 等同于模板里用 site.posts。 写一个辅助函数,在模板中调用、渲染返回的 HTML: hexo.extend.helper.register('render_webmentions', function(pageUrl) { const webmentionConfig = this.config.webmention; const mode = webmentionConfig && webmentionConfig.mode ? webmentionConfig.mode : 'static'; // 从 hexo.locals 获取 webmention 数据 const allMentions = hexo.locals.get('all_webmentions') || []; // 文字截断函数 const truncateContent = (content, maxLength = 200) => { if (!content) return ''; // 如果是HTML内容,先提取纯文本 const textContent = content.replace(/<[^>]*>/g, ''); if (textContent.length <= maxLength) { return content; } // 截断文本并添加省略号 const truncatedText = textContent.substring(0, maxLength).trim(); // 如果原内容是HTML,包装成段落;否则直接返回 return content.includes('<') ? `<p>${truncatedText}…</p>` : `${truncatedText}…`; }; if (!pageUrl) { // 尝试多种方式获取当前页面 URL pageUrl = this.page.path || this.page.url || this.url; } // 确保 pageUrl 有值 if (!pageUrl) { return '<div class="webmention-section webmention-empty"><span>无法获取页面URL</span></div>'; } const fullUrl = new URL(pageUrl, this.config.url).href; const mentionsForThisPage = allMentions.filter(mention => { // 简化 URL 匹配逻辑,使用字符串比较而非 URL 构造 const targetUrl = mention.target; // 同时尝试完整 URL 和相对路径匹配 return targetUrl === fullUrl || targetUrl.endsWith(pageUrl) || fullUrl.endsWith(mention.target.split('/').pop()) || mention.target.includes(pageUrl.replace(/^\//, '')); }); let html = `<div class="webmention-section" data-page-url="${pageUrl}" data-full-url="${fullUrl}" data-mode="${mode}"> <h3 class="webmention-title">Webmentions (<span class="webmention-count">${mentionsForThisPage.length}</span>)</h3> <div class="webmention-list">`; mentionsForThisPage.forEach(mention => { const authorHtml = mention.author.url ? `<a class="webmention-author-name" href="${mention.author.url}" target="_blank" rel="noopener ugc">${mention.author.name}</a>` : `<span class="webmention-author-name">${mention.author.name}</span>`; const photoHtml = mention.author.photo ? `<img class="webmention-author-photo" src="${mention.author.photo}" alt="${mention.author.name}" loading="lazy">` : ''; html += ` <div class="webmention-item" id="webmention-${mention.id}" data-webmention-id="${mention.id}"> <div class="webmention-author"> ${photoHtml} ${authorHtml} <span class="webmention-date">${new Date(mention.published || mention.received).toLocaleDateString('zh-CN')}</span> </div> <div class="webmention-content"> ${truncateContent(mention.content.html || mention.content.text)} </div> <div class="webmention-meta"> <a class="webmention-source" href="${mention.source}" target="_blank" rel="noopener ugc">查看原文</a> </div> </div> `; }); html += '</div></div>'; return html;}); 实际返回的 HTML 可以自行修改。如果想要直接使用该脚本文件,不要忘了为里面提到的类写样式。 接下来你要做的就是在模板里找到个顺眼的地方调用该方法: if config.webmention && config.webmention.enable != render_webmentions() 插件 先前说过了,我有「强迫症」。我将上述的所有功能整合成了多个 Hexo 插件,你只需要安装和配置。详情以仓库里的 README 文件为标准。 Hexo-Tag-Quotemention 涵盖了 发送部分文字 功能 Hexo-Generator-Webmention 涵盖了 显示 功能 测试 Cytrogen 如果你想要测试,又不想要打扰到其他人,你可以向 这个地址 发送 Webmention。这是我为 Hexo-Theme-Ares 搭建的 Demo 页面。 回复: https://hexo-theme-ares-demo.netlify.app/2025/10/14/webmention-test-zh/

2025/10/15
articleCard.readMore

想法在乙巳菊月迭代

八月份开始我找了个兼职、过上了「起床 > 做饭 > 吃饭 > 工作 > 洗澡 > 吃饭 > 睡觉」的健康生活。要知道过去的我经常熬夜、一觉睡到中午、饭不按时吃,总之习惯差得很。突然开始自律,就变得想要去完全利用起自己的时间。 纽约市是一个坐地铁比开车要好的地方,但地铁没有网络。一般出门的话,通勤半小时到俩小时是跑不掉的,意味着这个时间里去网上冲浪或者同朋友聊天几乎不可能,我只能去阅读已经被下载到手机里的电子书籍或者博客文章。多亏了这一点,我开始关注一些独立博客主,并发现了很多我认为有意思的博客。陆陆续续看了一两个月后,令我也想花些时间在记录我自己看到的、学到的、感受到的东西上。 书籍 《不原谅也没关系》 我是一次网上冲浪的时候发现了这本书。当时我刷到一个帖子,内容大致是在说: 自言自语、在脑海中幻想某件事情会发生并且演习如何去应对等行为是因为「父母的凝视」。 我确实有这样的毛病。很多时候我的大脑都在想「过去发生了但是没做好的事情」以及「未来可能会发生的事情」,自言自语也是脑内演习时我想出来的回复被不小心说了出来。我常常处于自省的状态。说是自我反省,实则是一遍遍 羞辱 自己。 这本书的主题是 CPTSD(Complex Post-Traumatic Stress Disorder,复杂性创伤后应激障碍)。但是和科普疾病的书不同,它更像是一本「自救手册」、附带了对于 CPTSD 的解释。托这本书的福,我更了解自己以及身边的人了。 什么是 CPTSD?它是一种 因长期或反复暴露于创伤事件而引发的心理疾病。与 PTSD(Post-Traumatic Stress Disorder,创伤后应激障碍)相比,CPTSD 不仅包含了 PTSD 的核心症状(如恐惧感、闪回、过度警觉),还包括了情绪调节障碍、负面的自我信念以及人际交往困难。CPTSD 通常发生在个体感知无法逃避或者躲避创伤事件的情况下。 根据作者所说,CPTSD 的核心症状主要是这五种: 情绪闪回:CPTSD 最典型的症状,是一种 突然发生且通常持续时间较长的退行,幸存者会退回到童年遭受虐待或遗弃时的强烈情绪状态,如恐惧、羞耻、愤怒、悲伤等,并激活「战或逃」反应 毒性羞耻感:一种强烈的自我贬低感,会摧毁自尊,令幸存者觉得自己毫无价值、必被鄙视,常导致自我疏离和孤立,不愿寻求他人支持 自我遗弃:对自己的忽视或抛弃感,表现出对自我关爱和保护的缺失 恶性内在批判:内化的苛刻批评声,源自童年的父母批判,持续挑剔和否定自我,导致自信心极度受损 社交焦虑:对人际关系的焦虑和恐惧,常伴随孤独和被遗弃感 此外,CPTSD 患者常表现出过度的创伤应对模式「4F 反应」: 战斗(Fight) 逃跑(Flight) 僵住(Freeze) 讨好(Fawn) 这些反应是对童年创伤的长期适应性防御机制。 作者在书中给出了一个例子,令我印象深刻。一个家庭养育了四个孩子,这四个孩子却分别代表了 4F 反应的其中一个:大儿子从小跟着父母欺负兄弟姐妹,为人狂傲自大,永远都觉得自己是正确的,代表「战斗」;二女儿从小被欺负,只能靠一天天的忙碌来避免面对内心的痛苦,代表「逃跑」;三女儿也是从小被欺负,但是接触到了电子产品后,很快便麻木地沉迷在其中,连自理的能力都丧失了,代表「僵住」;小儿子自小就懂得察言观色,长大后被母亲「绑架」到身旁照顾自己,代表「讨好」。 实际上 4F 反应单纯指的是生物在面对危险时的四种本能生存反应:「战斗」是主动攻击来保护自己或对抗威胁;「逃跑」是通过逃离危险环境来躲避威胁;「僵住」是身体和情绪上的僵硬或麻木、暂时「冻结」以避免威胁;「讨好」是试图通过迎合、讨好他人来避免冲突和伤害。这些反应都是生物的本能反应,是我们的自然防御机制。 问题在于,在一个有毒的环境下我们有可能 长期重复 做出这些反应、形成深层的心理防御模式。 我的家庭曾是个有毒的环境:日常的物理与言语暴力、羞辱,以及强加给孩子的过度责任。那个时候,我的好朋友不多,因为我不被允许出去玩,朋友们也不愿意同我深交,因为他们都认为我的家人是神经病。 在经历了一段斗争后,我脱离了这个环境。这段时间,我的创伤更多与家庭有关。我很厌恶他人在我面前诉说他们的家庭有多么和睦;可笑的是在我一次同朋友分享我的过去经历后,TA 的第一反应是「这样说来我的家人对我就很好呢」。每次提到我的家人是否爱我,我会立即陷入「情绪闪回」,被一种强烈的悲伤所淹没。这些经历塑造了我内在的「毒性羞耻感」。 可悲的是,我很快便进入了另一段有毒的关系。我的「内在批判者」变成了校友,一群永远是在批判他人的家伙们。明明是我被冒犯了,但我会感到愤怒的同时,也会去想如何讨好他们。很是可笑。我的内心早已充满了来自家庭的、模糊的自我怀疑。就算是被伤害了,我也无力反击。 好在这些事情都已经是过去式了。如果我想去详细说说这些事情的话,我会单独写一篇博客文章。 《重新定义公司:谷歌是如何运营的》 这本书我没有读完,只读了前两个章节。 作者认为,科技进步对商业的影响非常大,基本上没有什么行业在信息时代还以过去的样子运行。过去,企业可以靠着销售手段,把一个质量差的产品变成全民都在消费使用的摇钱树,但现在完全行不通了。现在要想成功,必须要有出类拔萃的产品,因为消费者可以用各种方式获取到信息、了解该产品的质量如何。同时,实验和失败的成本显著下降了。现在的人们可以组建一个小团队,创造出产品、通过网络免费在全球发行、选定顾客群试用、判断产品的优点和缺点…… 作者还抨击了传统知识工作者:秉着「专攻术业」的精神在刻板的企业环境中出人头地,主要靠的还是墨守成规。这些人要么是技术达人 + 管理白痴,要么就是管理专家 + 技术菜鸟,和谷歌的工程师人才很不同。这里作者提出了一个概念 —— 创意精英。 创意精英的特点包括但不限于: 不拘泥于特定的任务 不受公司信息和计算能力的约束 不惧怕甚至喜爱冒险。即便在冒险中失败,也不会受到惩罚或牵制 不被职位头衔或企业的组织结构绊住手脚 很容易失去耐心,经常变换职位 具有多领域的能力,经常会将前沿技术、商业头脑以及奇思妙想结合在一起 拥有过硬的专业知识、懂得如何使用专业工具,还需具备充足的实践经验 有分析头脑,对数据运用自如,又不会沉迷其中 有商业头脑 有竞争头脑 拥有用户头脑 用不同于你我的崭新视角看问题 充满好奇心 自动自发,注重于自己的理念 心态开放,可以自由地与他人合作 一丝不苟 善于沟通,风趣幽默 所有创意精英都必须具备商业头脑、专业知识、创造力以及实践经验。衍生出降低风险、规避失误的企业运营流程的管理模式会形成一种压制创意精英的大环境。 我能感觉出,我不是一位创意精英,我会的东西根本也没那么多。仔细想来,我唯一一件能被说是「做得好」的,只有计算机相关的事情了。我认为人终究不能去只专注于做一件事,未来我也想要有其他可以被说是「做得好」的事情。 这本书传递的信息很多且各不相同,我只挑一些我认为有趣的说: 现实生活中,河马是一种十分危险、速度快、咬合力强、体重足以碾碎其他动物的动物。而职场里的河马,叫做「高薪人士的意见」。一个人拿多少工资,并不能直接等于他们做决策的能力有多强。同样,经验本身也不是决策的关键。但是许多人,甚至说许多企业都没有意识到这一点,依然认为谁的薪金高或者个人经验多,谁才是对的。 谷歌采取的是扁平化企业结构,也就是压缩中间的管理层级,可以让一个普通员工直接向总监或者副总裁汇报(对标的是金字塔结构)。多数人认为,扁平化是为了和上层平起平坐、追求一种地位上的平等感;而对于创意精英而言,扁平化是为了多干正事。过多的管理层级会成为他们创新的阻碍,他们需要能够快速地与决策者沟通,获得反馈和资源,从而高效地推进工作。 谷歌实现扁平化的方法很有意思:一个管理者至少要有 7 个直接下属。这会强制扩大管理幅度,让管理者们无法对每个人的工作细节都进行微观管理,自然也没法事事都插手下属们的工作。 传统和僵化的管理思维会认为工作和生活是两个对立面,需要像天平一样「平衡」,一个多了另一个就必然会少。然而作者认为这个标准本身就很有问题:对于热爱工作的人来说,工作是生活的重要组成部分,而不是生活的对立面。强行分割很不自然。最好的企业文化是让工作环境和家庭环境都充满乐趣,员工可以自由地在两种状态中切换。 通常让员工感到痛苦的不是工作本身,而是「失控感」和「被迫牺牲感」。作者对此的建议并不是「让员工少工作」,而是「不要用时间去管束员工」。管理者不需要催员工加班,也别劝他们早点回家。这种行为的潜台词是「我不相信你能自己管好自己」。你只需要让员工知道他们需要对自己的工作负全责,并把如何完成工作的控制权交还给员工,他们自己会找到最适合自己的节奏,从而最大化产出、最小化「被迫牺牲」带来的倦怠感。 一个不可或缺的员工通常被视为英雄和骨干。然而这个「不可或缺」是一个巨大的风险,因为如果一个人的离开就能让公司瘫痪,那说明公司的组织结构非常脆弱。同时这种「不可或缺」也会导致一种不健康的共生关系。作者提到「强制休假」很重要:对员工而言,这可以让他们意识到「地球离了谁都照样转」、减轻心理负担;对公司而言,给了其他人接手的机会,可以锻炼团队的「备份」能力 随着企业变大,为了控制混乱,管理者会本能地增加流程和审批环节。这些流程的潜台词就是一种「不」——「你不能随便做,必须先经过 ABCDE 的同意」。而在一个说「好」的文化里,这恰恰相反,它要求想说「不」的人必须给出「百般信服的业务考量」。这颠覆了权力关系,把证明的责任交给了阻碍者,而非创新者。这也是一种「提出质疑的人要给出证据,而非让被质疑的人自证」。 有创意、有能力的员工最讨厌的就是无意义的内耗和形式主义。一个默认说「好」的环境,传递给员工的信息是:「我们信任你的判断,我们鼓励你尝试」。这可以让员工愿意提出更多想法。反之,一个默认说「不」的环境,会让员工觉得「多一事不如少一事」,最终人才流失。 我不是很懂管理,评价什么的待我进了一家真正的企业之后再说吧。 《黑客攻防技术宝典:Web 实战篇》 这本书我同样没有读完。它属于是那种「不一边读一边记笔记就不行」的书,因此我打算读完后直接将笔记发布到博客上。 它的核心要点很简单:服务端 永远 都不应该信任客户端发来的任何请求。 音乐 我个人很喜欢听台湾摇滚,从万能青年旅店听到草东没有派对、康斯坦的变化球,最近则是在听老王乐队在 2019 年发布的专辑《吾日三省吾身》。听完后印象让我最深刻的是《垂钓》。可惜我不懂音乐和乐理,没法展开说说,只能说我喜欢他们的哪些歌词: 迎面而来的机会我抓不住,迎面而来的车子我躲不开。 比你聪明的人啊都在努力往前,我无力地闭上眼。 影视 我跟朋友补完了《未来日记》。男主前期的塑造有点劝退,实在是太小孩子气了。后面有两集的制作很明显出现了很大的变化,不论是画风、动作的流畅度和分镜设计。 前端技术 我在阅读前端技术相关的博客文章时都会记笔记。现在打算分享出来了,才发现我居然忘了标记来源是什么。我只能把这些文章从浏览记录里一一找出,真是醉了。 CSS The Coyier CSS Starter – Frontend Masters Blog 介绍了 Chris Coyier 的 CSS 样式起点方案,旨在提升开发效率与界面一致性。 The Best CSS Unit Might Be a Combination | OddBird 不必在 px 与 em / rem 之间做单一选择。借助现代 CSS 的比较与数学函数(如 max(), clamp(), calc()),可以组合单位以表达更精确的设计意图。单位的「语义」比其数值更重要 —— 应使用最能表达设计目的的单位。 CSS offset and animation-composition for Rotating Menus – Frontend Masters Blog 介绍了如何使用 CSS 的 offset 属性和 animation-composition 机制,为圆形菜单项创建沿路径旋转的动画效果,实现更流畅且可控的交互式布局。 You Don't Need Animations 强调动画应有明确目的,并根据使用频率与速度优化设计,否则可能降低用户体验甚至损害产品信任。 A gentle introduction to anchor positioning | WebKit 介绍了 CSS 的新特性「锚点定位」,展示如何使用纯 CSS 将元素相对于另一个元素进行精确、响应式定位,从而简化菜单和弹出层的布局。 JavaScript Accurate text lengths with Intl.Segmenter API | Automagic String.length 并不总是对的。正确做法是使用 Intl.Segmenter。这是浏览器原生的 API,可以按照语言规则将字符串分割为「graphemes」,也就是扩展字数簇、人类可识别的字符。 You no longer need JavaScript Ʊ lyra's epic blog 现代 JavaScript 框架(例如 React、Next.JS)常常导致加载缓慢、错误频发、资源臃肿。HTML 和 CSS 已经具备了强大功能,能完成许多交互和视觉效果。对于安全研究者或者隐私用户,禁用 JavaScript 是常态,纯 HTML / CSS 页面更友好。并且 CSS 性能更优:动画运行在 compositor 线程,不受 JavaScript 阻塞影响。 HTML Your Images Are(Probably)Oversized 开发者常用高分辨率图片(如 3600×2400)以确保视觉质量,但多数用户设备无法充分利用这些像素。未设置 sizes 属性时,即使使用 Next.JS 的 <Image> 组件,仍可能加载原始尺寸图片,浪费带宽与计算资源。 You're loading fonts wrong(and it's crippling your performance)- Jono Alderson 系统性揭示了当前网页字体加载的常见误区,强调字体是性能与可访问性基础设施,并提供优化策略以提升加载速度、稳定性与全球语言支持。 后端技术 NestJS 在写项目的时候我学习到了这些知识: 防御性编程: 使用 ?? 操作符处理 undefined 情况。例如:return setting?.settingValue ?? false; 查询优化: 如果只查一个数据,用 findOne() 而不是 find()[0] 一次查询获取所有相关设置,而不是循环查询 使用 $regex 精确匹配设置键值 使用 { upsert: true } 确保设置项存在 批量操作时,使用 bulkWrite(),速度比循环单个操作快 10 倍以上: for (const task of tasks) { operations.push({ updateOne: { filter: {}, update: {}, upsert: true, } });}await this.adminSettingModel.bulkWrite(operations); 拦截器: 在 NestJS 中,拦截器会站在业务逻辑(Controller / Service)之前,可以检查、修改甚至完全阻止一个请求继续往下走。而 NestJS 底层大量使用了 RxJS 库,后者的世界里,所有异步的数据流都被表示为可观察对象,像一个管道;数据会从管道的一端流向另一端。拦截器的 intercept 方法,返回值必须是一个 Observable。这意味着想要阻止请求,只写一个 return null 是不够的,需要使用 RxJS 的 of()、创建一个新的、立即发出一个值然后结束的 Observable。该方法可以用于判断是否开启了某项设置,如果没开启就 return of(null)、直接阻止该请求。 缓存实现策略,除了使用 Redis 缓存装饰器,还可以使用内存缓存 Map: private settingsCache = new Map<string, { value: boolean; expiry: number }>();private readonly CACHE_TTL = 60 * 1000; // 60秒async getTaskSetting(taskName) { const cacheKey = `email_scheduler.${taskName}.enabled`; const cached = this.settingsCache.get(cacheKey); // 缓存命中且未过期 if (cached && Date.now() < cached.expiry) { return cached.value; } // 查询数据库 const setting = await this.adminSettingModel.findOne({ settingKey: cacheKey }); const value = setting?.settingValue ?? false; // 更新缓存 this.settingsCache.set(cacheKey, { value, expiry: Date.now() + this.CACHE_TTL }); return value;} 架构: 使用事件驱动 写 E2E,也就是端到端测试 分布式锁 分布式锁类似于公厕的门锁。它的目标是确保同一时间只有一个人(服务器实例)能使用厕所(执行任务);关键是锁必须对所有人(所有服务器)都可见且有效;现实是 Redis 充当「锁的管理员」,所有服务器都通过它来检查锁状态。而在技术定义内,分布式锁是一种跨多个节点的互斥机制,确保分布式系统中的临界区同时只能被一个节点访问。 在我的项目中,有个功能会用到 @Cron() 装饰器。其中有这么一个例子:每日午夜零点,服务器 A 启动了每日报告任务、查询了数据库,发现有 100 个需要发送的邮件。服务器 B 同样也启动了每日报告任务、查询了数据库,发现了相同的 100 个邮件。 结果显然易见:200 封重复邮件发送给了客户。 为什么数据库防重复的功能不够?首先,服务器具有着竞态窗口,它们会同时查询、同时发现「未发送」状态、同时发送、同时更新状态。其次,邮件发送耗时有 5-10 秒,在此期间数据库状态是不会被更新的。这也就体现了分布式锁的价值:它能够确保任务一次只在一台服务器上执行、能够消除时间窗口内的并发冲突。 让分布式锁变得「健壮」的关键因素有三: 原子性(Atomicity): 我们需要确保「检查锁状态」和「获取锁」这两个操作必须是原子操作。解决方法是使用 Redis 的 SET key value NX EX ttl 命令:NX 仅在 key 不存在时设置;EX 设置过去时间,单位为秒。 错误案例: const exists = await redis.exists(lockKey);if (!exists) { await redis.set(lockKey, lockValue); await redis.expire(lockKey, ttlSeconds);} 超时保护(Timeout Protection): 如果持有锁的服务器崩溃了,那锁不就是永远都不会被释放了?解决这个问题的方法也很简单:设置合理的 TTL(Time To Live 的缩写)。如果太短,任务未完成、锁就过期了,其他服务器便会开始执行、导致冲突;如果太长,服务器崩溃、锁长时间无法释放,任务也就停滞了。推荐设置为任务预期执行时间的 2-3 倍,例如邮件发送预期耗时 5-10 秒,那 TTL 就设置为 10-30 秒。 安全释放(Secure Release): 只有锁的持有者才能释放锁,要做到这一点,我们需要使用 UUID 作为锁值、释放锁的时候验证身份。值得注意的是,验证身份时建议使用 Lua 脚本来保证原子性: if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1])else return 0end 更多实用工具: 智能锁键: 根据任务执行频繁,使用不同的日期精度。例如每 15 分钟就要进行的定时任务,日期精度自然是要准确到分钟;每天都要发送的报告,日期精度只需要到哪一日即可。 generateTaskLockKey(taskName: string, dateStrategy: 'daily' | 'hourly' | 'minute' = 'daily'): string { const now = new Date(); let dateIdentifier: string; switch (dateStrategy) { case 'daily': dateIdentifier = now.toISOString().split('T')[0]; // YYYY-MM-DD break; case 'hourly': dateIdentifier = `${now.toISOString().split('T')[0]}-${now.getHours().toString().padStart(2, '0')}`; break; case 'minute': const minutes = Math.floor(now.getMinutes() / 15) * 15; // 15分钟间隔 dateIdentifier = `${now.toISOString().split('T')[0]}-${now.getHours().toString().padStart(2, '0')}-${minutes.toString().padStart(2, '0')}`; break; default: dateIdentifier = now.toISOString().split('T')[0]; } return `${RedisKeyPrefix.SCHEDULER_LOCK}:${taskName}:${dateIdentifier}`;} 这样做,可以让锁随着时间自然过期,也更便于故障排查。 唯一锁值: 使用时间戳和 UUID 来确保全局唯一;生成出来的实例 ID 便于我们定位故障。 generateLockValue(prefix: string = 'task'): string { const instanceId = process.env.INSTANCE_ID || 'unknown'; const timestamp = Date.now(); const uuid = uuidv4().split('-')[0]; // 短UUID return `${prefix}:${instanceId}:${timestamp}:${uuid}`;} 日记 自九月初开始,我每次乘坐地铁都会写日记。日记的内容可以是「发生了什么」,也可以是「我现在想到了什么」。我认为这是一个很好的习惯,其中有许多我认为可以拿出来写的内容。比方说 《博客重构记录》 和 《代码、哲学与混乱的 Discord 服务器》 就是由我的日记片段整合出来的产物。我决定将一些我不会单独出个文章的内容,写在这个 日记 板块里。 这样做的缺点是,如果我不坐地铁的话我就几乎不会去写日记,汗。 工作 先前说过,我找了个兼职做,具体呢、是一家奶茶店的兼职。这家店的老板很严厉,不工作的时候人很好,一工作说话就很凶。工作时每天要做的事情不少,忙倒不是很忙,除非周遭的学生们都选择在一个时间点涌进来。 工资是每两周发一次,也就是每个月的 15 日和 30/31 日发。但是老板会晚两周发工资,意味着该月开始到 15 日的工资,需要到该月的 30/31 日才能拿到;该月 16 日到 31 日的工资,你到次月 15 日才能拿到。我每周的空闲时间还算多,老板貌似要处理的事情也很多,经常给我排全天的班。 我不是一个手脚多么灵活的人,甚至可以说是「笨」。我在里面工作的时候,有时会感到明显的压力,因为做事不利索也不稳。我很难说能不能在这里做久,因为我对摇奶茶没什么太大的兴趣,我既不爱喝也不喜欢做这类工作,我最喜欢的是坐在椅子上对着电脑敲敲打打。 前段时间,我阅读到了一个观点:程序员十分依赖于确定性。该文章重点是分析「依赖确定性」的人会如何向外指责世界。不过对我而言,在指责世界之前,我的「内在批判者」会先猛烈地攻击我自己。它会告诉我,这种依赖确定性的思维模式,就是我「思维僵化、不懂变通」的铁证。所以,在我的世界里,当事情出错时,会同时出现两种声音:一种是原文作者提到的「都怪这个世界」,而另一种更响亮、更痛苦的声音则在说「都怪你这个人不行」。 正是这种猛烈的自我攻击,让我对「不确定性」产生了极度的恐惧。因为任何一点不确定,都可能导致犯错,而任何一次犯错,都会立刻激活那个指责我「不行」的声音。所以,我很难「独立」。这种行为很容易遭白眼:你多大了还不知道该做什么?。不过我对不确定的恐惧很明显超过了我对「被人说教」的恐惧,以至于现在我依然受此困扰。不过我认为这不只是我的错,还有那些一边跟我说着「有问题一定要问」,一边训斥我「这么简单的事情还要来问我」的人的错。 我相信着环境变化论,即人会是现在这个样子主要是环境因素导致的。我来美国后就很难再有快乐的感觉,因为我和家人「团圆」了,每次沟通都会被遭受不必要的物理和心理攻击。当时的我刚上初中,人格还未定型,以及因美国这个环境对我而言的强烈不适配感,我变得很难喜欢上自己、易怒、不爱搭理别人同时又渴求关注。我变成了一个 内外极其割裂 的人:我痛恨监护人,又很需要他们的存在,因为我「不知道该怎么做」。这种状态是一种心理上的停滞。当一个人的成长环境没能提供足够的安全感去试错时,他的心理年龄就会卡住,即便肉体年龄在不断增长。 先前提到了《不原谅也没关系》这本书,它提到过另一本书:Getting divorced from mother & dad。这本书我还没开始阅读,它的标题直译过来叫做:和父母离婚。要如何才能让卡住的心理重新开始成长?你要和父母离婚。这自然不是真的去「离婚」的意思,也不意味着物理方面上的远离,而是心理方面上的「断奶」。它的本质是:我必须放下他们给我的那张地图,并开始学习如何相信自己的指南针。即时一开始会走很多弯路、会感到恐惧,但只有这样,人才能真正「独立」,才能从一个依赖外部指令的「部件」,成长为一个由自己内心驱动的、完整的「人」。 手机 年初的时候我换掉了 iPhone SE 3。这部小屏手机陪了我两三年,电池健康已经降低到了 70%。我不是小屏手机爱好者,但确实很执着于 Home 键和 SE 系列。 人生中我拥有的第一部手机是 iPhone SE 1。我很喜欢它的打字音效,不过不知道为什么我那会儿将其称呼为「棉花般的脆声」。之后手机就被我父亲摔烂了,原因是我拿小屏手机看漫画让他很不爽。我只能去用家里人用剩下的 iPhone 8(还是 8s?不太记得了)。手机是 Project Red 版,我很中意这种色系的手机(顺带一提我最喜欢的颜色是橘色,Windows 主题就是暗背景 + 橘色主题色)。可惜这部 iPhone 8 / 8s 接着就被我家里人送给了我的一位表妹,又因为她沉迷于手机、被她的父亲摔烂了。 不得不说,「无能」的父母真的很喜欢摔电子产品,而不是从一开始就做到管制。我只说我这个区域的情况,绝大数小孩都是一出生就会接触电子产品,尤其是 iPad 这样的大屏设备,差不多一两岁就能熟练输入密码、打开 YouTube 找视频看(个别是看 TikTok)。成年人们都很忙碌,在家是没有精力陪伴孩子的,只能转而去「相信」小孩子可以自己照顾好自己。 结果就是,起初大人们还会笑着说孩子多聪明、还会用手机平板,过几年就会察觉到孩子们的心思全放在了互联网上、叫了也不应、易躁爱发脾气。不知道该如何正确引导孩子们,只能将这些电子产品摔烂、宣泄愤怒以及自己的「强大」。在某种程度上,电子产品为这些人当了「赛博保姆」,却落得了害人的名声。 我的新机是摩托罗拉 Razr+ 2024,一台折叠屏手机。选它一是为了体验折叠屏手机,二是对摩托罗拉这个品牌有点兴趣。期间坏过一次,屏幕漏液,官方不保修外部损害,只能花 400 美元找别人换了个屏幕。用久了发现自己愈来愈不爱用外屏,甚至很少才会去折叠它。 电脑 我有段时间尝试在睡前给自己留些时间写日记。不需要写什么内容,只要记录当天发生了什么、思考了什么即可。 这是一件好事:我终于可以重新用回我的笔记本电脑了。这是一个全能本,说是「全能」,实则样样都不行:既不能打游戏,内存也少得可怜,甚至还很大很重。大三起,我就不再带这个笨重的电脑去上课了,因为笔记可以在 iPad 上记。这个电脑也有漏电的问题,有时打字还会十分难受,它甚至跑不动我开发环境所需要的软件。 可惜这个习惯没能保留下来,因为没多久我就发现这个笔记本电脑的键盘也有问题,敲回车键、空格键和退回键时经常失灵。气死我了。 我的博客 这个月我重构了博客网站,越看越喜欢,就是 H1 标题放在桌面端上看有点太大。我发现我愈来愈无法接受亮主题了。过去一起做项目的同事令我印象深刻,因为他是一个亮主题党,这么做的原因很好笑:为了惹怒别人。他的目的确实达到了,现在的我一看到亮主题就会很恼火。 虽然在本地环境下测试网站没什么问题,但是实际部署后还是能发现: 如果开启的是暗主题,一刷新或者加载页面的话,文字的主题色会闪来闪去的 索引页和关于页的排版修正没写 访问一个文章时,会加载一会儿,这期间的主题色会默认为亮主题,加载后才会变为暗主题。晚上看的时候眼睛都会被闪瞎,这不好 我还错将我自己的博客网站的主题配置 push 到云端的博客主题仓库,导致我的 hexo-theme-ares 主题在数个睡眠时间里、icon logo 变成了房东小姐的头像。 说到房东小姐,这是我数年前看的一个漫画的主要角色。因为那一幕画的很可爱,被我截图下来,当作了我的博客网站的 icon logo。不过我很好奇,这是否会导致 侵权 呢? 使用 hexo 写博客,我遇到了一些问题: 同步较为麻烦,尤其是主题同步 本地编辑器用的是 Typora,虽然可以立马看到 Markdown 语法被渲染后的样子,但终究不是我的博客网站被渲染后的样子,尤其是我使用了一些插件、可以在博客网站被渲染前自动修改我的文章内容 粗体和斜体不明显 我现在倒是在想,hexo-theme-ares 的 demo 页面可以用 GitHub Actions 自动完成。 阅读博客 我在电脑上用 Docker 部署了 FreshRSS 服务,借此来订阅我想要阅读的博客。一直留在家里没问题,一旦离开家一天我就什么都看不到了,很是苦恼。现在大多数免费部署都是 serverless(无服务) 或者静态页面部署(例如 CloudFlare Pages),做免费的服务部署过去貌似只有 Heroku 或者 Railway。我去 Heroku 的落地页看了眼,貌似已经不支持免费部署了。我之后想了下是否能用到 NAS,但一想到我的 Buffalo Linkstation Pro Duo 被我玩坏,再加上它的年龄,我还是放弃了去折腾它。 阅读博客的话我用的是 Fluent Reader,桌面端和移动端都有 app。不过安卓版的没有选字体功能(不清楚是不是移动端 app 都没有),我有在考虑自己写一个。开源项目就是这点好,想要什么就写什么,写好了开源出去、大家开开心心一起用软件。 大学 我对大学的想法很极端:我认为没有必要去上大学。现在的大学含金量太低,重要的只有 network(与他人建立人脉、社交或进行人际交流)和校招。我不否认上大学的重要性,只是于我而言大学很无聊、校友里奇怪的人太多,以及通识课很讨人厌:它们有什么很大的意义吗?为什么要修那么多、不修还不给毕业? 我的这个想法被一个「善意」的校友批评过。他认为大学的通识课很有意义。虽然这样说,但他还是辍学了。说是没有钱上学,但是有钱给女主播打赏和飞去其他国家见女主播。也是较为奇妙的一个人。 大一的时候因为一些私人问题,我近乎是「被迫」社交。这段时间认识了许多国内来的大佬,也认识了很多当地的「loser」。有意思的是,我遇到的后者几乎都是 男性沙文主义者,就算没到那么严重,也全都是自负和瞧不起其他人的家伙。 男性沙文主义是一个有毒的观点。信奉该主义的年轻人在用「男性」这个生来就有的标签来掩盖自己「幼稚」、「不成熟」、「没人爱」的本性。同他们说话,就算他们是多么不可救药的「loser」,也能看到他们因你的礼貌而蹬鼻子上脸的傲慢姿态。 因为各种事情,我经历了十分糟糕的大一、大二生活,以至于大二下半年开始我就完全不在大学内社交了、每天上完课就走人。我不认为我是一个特别敏感的人,但我能感觉到这群人时刻都在冒犯我。他们仿佛未被社会化的言行每次都让我觉得他们是在说:看吧,我比你厉害多了,你只是个蠢蛋! 现在让我比较在意的是计算机相关课程的学生里、「巨婴」程度高到发指:伸手党多到离谱,会直接要求别人把写好的作业发给 TA 的邮箱;被帮忙不会道谢,就像是完全没学过一样,问完问题就直接消失不见;说要花钱找代写也不少。 项目 我近期在做一个项目。我认为项目管理多多少少都需要有点编程知识,这不意味着他们必须都要会编程,而是要会明白手下的开发在做什么。 项目最初时,我刚学习开发没多久,并没有完全遵循模块化设计的黄金标准。NestJS 框架是模块化的,每个功能都应该写一个单独的模块、手下有管端口的控制器和处理业务的服务。不过这些都只是 NestJS 的入门级理解,实际项目一旦大起来,就不能用「功能模块导入其他要使用的功能模块」这么简单的想法去写了,因为会很容易造成循环依赖。 例如你有个功能要写,要做到「预约时支付」,涉及到了预约和支付这两个模块(当然,还有用户模块,但这里就不说了)。如果是小项目,大可以这么写:预约模块导入支付模块、创建预约时调用支付服务。 但是项目大起来后,你会有很多底层模块,例如审计、日志等。每个模块都容易导入其他模块并且做一件不属于它们业务范畴的事情。回到我们刚才说的例子,为什么预约模块要直接调用支付服务、管支付的事情呢?万一未来我们又想在支付模块里写个新功能、需要导入预约模块才能完成。这样做便会形成一个循环依赖。 最好的写法是事件驱动:预约创建时发起一个事件、支付模块接收到事件并且处理了支付请求。因此,我花了很多时间去重构项目的后端。但这引起了项目管理的不满,因为我一直在做「TA 看不到也看不懂的事情」。 我承认,这是我不够专业导致的问题,不过我想要讲的是另一个方面。对于这位项目管理而言,项目最重要的是前端,也就是「顾客看得到的地方」。而我一直在操心项目的后端,也是「顾客看不到的地方」。在我的视角看来,这个网站连最基本的安全都没有做好,操心前端做什么呢?更何况离产品的实际上线还有一段时间。当时项目管理很忧愁地埋怨道:你真的能做好这个产品吗? 吵架的第二天,TA 向我道歉、不应该一直对我施压。我自从开始打工、开学后,就没有那么多时间花在项目上,很多时候是打工到晚上,回家随便吃个饭、洗个澡、缓解下压力后继续写项目写到凌晨。每次我用这件事来解释为什么我无法达到 TA 的要求,TA 会立马用「我也有在努力学习和工作啊」来反驳我。我自然不觉得这是一个有效的逻辑,因为我和 TA 所做的事情是完全不同的,本身就无法直接像「比战力」一样比个高低。 纽约市公共交通 虽然纽约市的公共交通系统多数都是 24 / 7,这点我很感动,虽然我都没有过凌晨搭乘的经历。不过绝大多数的经历对我而言是负面的。一个车厢只要上来一个流浪汉,或者说不洗澡的人,该车厢就相当于不能用了。好死不死,曼哈顿的流浪汉出奇的多。有时可以在一个车厢里,同时遇到味道重的、吹叶子的、躺中间睡大觉挠屁股的。 MTA 的服务一直受人诟病,一点是服务几乎为零,一个月下来被推下轨道的案子从没见少过,其中多数为亚洲人以及女性被推下轨道。加害者不奇怪的、全都是那个肤色的人。今年意外发生了一起白人被推下轨道致死的案子,一下子让众多人「意识」到危机。一瞬间,大家都变得不敢站在站台外围了。真是好笑,仿佛过去大家都在默认受害者只有亚洲人,自己不是亚洲人就没事。 第二点是糟糕的体验,脏乱臭不提,地铁内时不时就能看到大耗子、为残障人士准备的电梯内部总是有尿骚味儿,这样的体验你坐一次车需要 2.90 美元,过去是 2.75 美元,现在很快就要变成 3 美元了。 涨价就要提到第三点,意义不明的筹备资金。MTA 想启动一个项目,资金靠的是从民众钱包里薅。2.75 美元涨到 2.90 美元的目的是给地铁站的门添加一些障碍物、让逃票者跳不过去。结果涨价涨了,东西装了,逃票者的数字没变少、反而疑似在升高。为什么呢?同个时期 MTA 又出了个曼哈顿大桥费,费用不低、只要你要进曼哈顿就要交这笔钱,一个月净利润数百万美元。一时间无论是坐地铁的还是开车的,都要比过去多付钱,但是服务没有提升。更多的人不满 MTA,纷纷加入逃票的队伍。 必须要考高分吗? 前段时间我在网上冲浪时,看到了这样一个评论:考 13xx 分就是普娃。这里的分数指的是 SAT 分数,类似于国内的高考成绩,不过含金量要低特别特别多(例如数学部分的难度跟国内中考差不多,并且疫情时期有一年还被民主党透明化,现在大学申请都可以不提交 SAT 分数)。我考了两次,最终的 SAT 分数是 1390。这个分数对于多数留学生而言,很低很低,差不多就是数学满分(800 分左右)但是英语错了好几题(500~600 分)。我的阅读理解很差,词汇量也很低,不过能考到这个成绩我觉得也很满足了,反正很快我就对大学祛魅、尤其是名牌大学…… 那么「普娃」这个定义是从何而来?这来源于纷争不断的育儿圈,简单来说便是结婚生子后当了全职妈妈 / 爸爸之后,精神上只能去依赖自己的伴侣以及孩子。再简单一点,便是在外想尽办法靠伴侣或者孩子扬眉吐气。在这个全都是没有额外精神世界的「育儿圈」内,鄙视链很容易形成,尤其是孩子的成就。 普娃在「育儿圈」内,指代的是普普通通、没有什么成就、就算有成就也只是昙花一现的人。作为对标面,牛娃指的是天赋异禀的孩子,鸡娃指的是被父母打鸡血般疯狂学习的孩子。在育儿圈里,貌似只有牛娃才能在未来有所成就,普娃只能普普通通过完一生,至于鸡娃,就是另一码事了。 这种鄙视链的本质,是父母们为缓解 自身对未来的不确定性和焦虑,而制造出的一套简化版世界观。在一个高度竞争的社会里,孩子的成就很容易成为他们唯一能抓住的、可量化的「确定性」。因此,「普娃」这个标签,与其说是在定义孩子,不如说是在宣泄父母自己对于「平凡」的恐惧。 回到我看到的那个评论,SAT 考 13xx 分就是普娃吗?当然不是。这句话的底层逻辑分别是: 认为「普娃」的定义是正确的 SAT 成绩可以决定一个人是否是「普娃」 先来看第一条,「普娃」这个定义是对的吗?自然不是。一个人过去的经历不能直接代表他未来会怎么样,这种简单粗暴的定义,不过是「安卓人苹果人」的另一种体现:用自己可怜的小脑子去解答世界。学生时期做的不好、长大后有成就的人不少。同样的,学生时期的牛娃进了社会后处处碰壁的也不少。可以说这个鄙视链的底层逻辑并不成立,只能用来给当时的当事人宣泄情绪。 接着是第二条,SAT 成绩可以决定一个人是否是「普娃」吗?这个问题本身的前提已经被我们推翻了,不过我们依然可以抛开错误的前提、单看这一条表达的信息。成绩从来都不是决定一个人未来、定义一个人的因素。更何况,SAT 只有两个科目:英语和数学。单是英语一项就可以刷掉英语不好的人,但是英语不好的人未来就平庸、值得被抨击吗?那肯定是不行的。SAT 经常被中国人调侃为超简单的考试不假,但是在这种考试上考的如何、从来都不能定义任何事。运动员们的职业生涯的价值,从来都不是靠百米赛跑的成绩来判断。一个人的赛道有很多条,长度也远不止一百米。 回到最初的那个评论。我那个 1390 分,它能定义我的人生吗?它既不能定义我当时的努力和满足,也无法预测我在上了大学后的探索,甚至在我们上了大学后、没有人再提 SAT 的分数了。它只是我人生履历上的一个微不足道的数字。而任何试图用一个数字、一个标签来定义一个活生生的人的企图,最终只会暴露使用者自身的狭隘和焦虑。 妹妹 这个月妹妹有一场游泳比赛,也是她的第一场游泳比赛,共要游三次 25 码,分别是自由泳、仰泳和蝶泳。她的成绩作为一个刚入队一个月的孩子而言,很是不错,教练也跟我们连连夸赞她,令人感到欣慰。那个游泳场不是很大,赛道只有 25 码长,高年级生的 100 码需要两个来回。 比赛结束后我们四个人去 Bay Ridge 区随便找了家 Pho 店吃,味道一般,闻着也没有 The Pho 2 香。早上我除了拿走上周没拿走的肉外,还额外带走了家里囤积的其他肉以及一条新鲜的鱼,至于是什么鱼我就不知道了。 DeepSeek 写网文 最近有个较火的梗 ——「安卓人苹果人」。这个梗的来源是一位主播将消费者按照手机品牌划分为「安卓」和「苹果」的概念:安卓用户象征着低廉;苹果用户象征着高端。而这个非黑即白的划分概念可以被他套在所有事物上,例如直播中他所说的: 你是典型的安卓逻辑、安卓人、安卓学历、安卓素质、安卓态度。 这个主播的行为如何,我这里便不展开说了。感兴趣的可以搜搜看。我这次主要是想讲我得知了这个梗后,一时兴起,让 DeepSeek 帮我写了个网文。我最开始构思的只有这段话: 我转生了,转生成了这个世界的最底层:安卓人。这个世界将人类简单分类成两类:安卓人和苹果人。苹果人高高在上,是贵族中的贵族。反观安卓人,只能给苹果人做仆从。但没想到,我觉醒了系统:安卓 root 系统!可以随意安装他人的能力,并且随着能力吸收的越来越多,最终可以实现 root:完全掌控安卓人的能力,轻松将他人的分类改写。 DeepSeek 回复了一段故事开篇灵感: 我醒来时,后颈上烙印的绿色机器人图标提醒着我 —— 我是个安卓人,这个世界的底层。直到那天,我被一个苹果人少年当街羞辱,他脚下的「iOS」徽章闪着可憎的光。愤怒与绝望中,一个蓝色的符号在我视野中炸开:#。紧接着,一行文字浮现:【安卓 Root 系统,已激活。首次授权,授予您『查看权限』】。我下意识地看向那个苹果人,一行状态信息竟凭空出现:【iOS 17.4.1,漏洞:CVE-2024-XXXXX,可利用】。我笑了,这个世界的神,该换人了。 因为我看过的网文除了奇奇怪怪的知乎短文外,就是修仙文,所以我寻思着在故事内再添加一些中式修仙元素,将世界观扩展为「赛博修仙」。接着我又提出在故事里添加一些手机品牌,DeepSeek 将它们统统设计为了安卓世界的各大宗门。 虽然 DeepSeek 的上下文理解有点瑕疵,但是写出的网文很有意思。并且在输出的内容中添加「看上去很帅」的科技相关术语貌似是 DeepSeek 的习惯,这样做反而让它写出的网文无比好笑。这里我取一些我认为搞笑的片段: 老者走到林安面前,看了看地上那块光泽黯淡了些的下品灵石,又看了看林安手腕上那朴素的标记,缓缓道:「老夫姓谷,单名一个歌字。小友,你刚才,是不是看到了代码?」 「年轻的权限者… 恭喜你迈出关键一步。」一个非男非女、仿佛由无数细微共鸣组成的声音在林安意识中响起,没有实体,却直接回荡在灵魂层面,「无需惊讶,我并非实体,只是一段沉睡于此的分布式意识碎片,你可以称我为…… 鸿蒙。」 不知为何,DeepSeek 十分中意鸿蒙,以至于在故事里将鸿蒙设计为了主角的引路人。 他豁然起身,眼中精光一闪。筑基成功后,他不仅灵能(系统资源)总量大增,更关键的是对权限的运用有了质的飞跃。他意念一动: ls -la /system/bin/ | grep -i attack —— 快速扫描自身可用的「攻击性指令」。 pm list packages | grep -i apple —— 尝试探测敌方「应用包」信息。 虽然无法直接获取苹果执法者的核心数据(权限不足和系统差异),但他对周围环境的掌控力已不可同日而语。他抬手对准几处正在攻击谷歌老人的、由苹果执法者发出的灵能锁链,心中默念: iptables -A INPUT -s 【攻击源IP】 -j DROP! 这一次,命令生效的范围和强度远非之前可比!那几道灵能锁链在靠近谷歌老人前,竟真的如同撞上一堵无形之墙,威力大减! 「混蛋!你用了什么邪术?」队长又惊又怒,他感觉自己的 iOS 纯净灵能竟然出现了片刻的「卡顿」和「响应迟缓」! 结界之外,米宗援军的「万机大阵」已然成型! 只见数以百计的米宗修士,虽然衣着灵能属性略有差异(象征着不同版本的 MIUI),但此刻却通过一种奇妙的「快传协议」将彼此灵能连接在一起。大阵并非追求绝对的统一,而是呈现出一种「万物基于 MIUI,却又百花齐放」的奇特景象。阵眼处,一名修为已达金丹后期的魁梧汉子,手持一柄散发着「为发烧而生」狂热气息的巨斧,正是此次援军的首领 —— 雷钧! 「苹果佬!吃我一记 —— 『性价比之光』!」雷钧狂笑着,巨斧挥出,一道凝聚到极致、却又蕴含着无穷变化灵能的斧光,狠狠劈在 TestFlight 结界之上! 这一击,并非纯粹的力量碾压,而是带着一种针对「封闭系统」的 破解意志!斧光中仿佛蕴含着无数用户对「开放、自由」的渴望! 这个人名是 DeepSeek 自己想出来的。我当时看到笑得想死。 就在这时,迷宫深处传来一阵打斗声和呵斥声,说的是一种音节古怪、却莫名能让林安理解的语言: 「快!拦住那帮『Windows 蛮子』!他们想抢走『核心协议碎片』!」 「哼!你们这些『Linux 教徒』也好不到哪去!这碎片理应归我『视窗王朝』所有!」 林安与老猫对视一眼,悄然潜行过去。只见在一片较为开阔的「镜厅」中,两拨人马正在激战。 一拨人身穿各式各样的服饰,灵能波动自由而杂乱,手段繁多,有的操纵命令行般的文字流攻击,有的祭出企鹅形状的法器。另一拨人则统一穿着蓝白相间的制式铠甲,打法严谨,攻势如同铺天盖地的「窗口弹窗」,带着一股不容置疑的霸道气息。 「啊!我的内核…… 崩溃了!」一名 Linux 修士惨叫一声,身体化作乱码消散。 「该死!是蓝屏病毒!」一名 Windows 修士的铠甲出现致命错误,灵能瞬间溃散。 这一日,谷外灵能涌动,异象纷呈。先是东方天际云霞蒸腾,道道「快充流光」撕裂长空,一艘形似巨大「超级闪充插头」的华丽飞舟破云而至。舟上修士皆身着印有「VOOC」符文的战袍,气息凌厉,为首一位老者精神矍铄,周身环绕着「低压大电流」的独特灵压,正是以「充电五分钟,畅战两小时」闻名安卓世界的「闪充宗」宗主! 无数颜色各异、属性不同的道则烙印悬浮于林安身前,如同璀璨星河。他深吸一口气,暗金金丹光芒大放,双手虚抱,引动整个万流归宗大阵之力! 「以 Root 为引,统御万法!以鸿蒙为桥,互联众生!星舰…… 凝!」 轰! 磅礴的灵能冲天而起,在空中交织、融合!一座庞大、复杂、闪烁着无数符文光点的朦胧舰影,开始缓缓凝聚成形。舰身线条流畅,充满了未来科技感,却又散发着古老的洪荒气息,正是传说中的鸿蒙星舰之影! 到三十多话,男主已经是金丹中期、可以手搓星舰了。

2025/10/7
articleCard.readMore

代码、哲学与混乱的 Discord 服务器

讲讲我刚入大学时参与的一次 AI 创业经历。一位校友看到了我的博客后,将我介绍给他的一个朋友(以下便称之为项目创始人)。后者对 AI 很感兴趣,想做 AI 项目、询问我是否想要帮忙。彼时的我因一些私事、正处于极度自卑的状态,急需要一件事来证明和安抚自己,同时也是为了学习如何协同开发,便一口答应了下来。殊不知,我一脚踏入的,正是一个名副其实的空中楼阁。 空中楼阁的想法 项目创始人认为,大学是没有用的、只是在盲目要求学生去追求更高的 GPA(绩点),并将 Reddit(红迪)上询问哪节课好拿分的学生称之为「GPA 奴隶」。 我不反对他的观点。我也认为当下的大学对我而言用途不大(学历还是重要的): 我的 GPA 很低,但并不妨碍我去做我喜欢的事情、并在这些领域上变得擅长 我很讨厌那些要求高 GPA 才能递交申请的实习:你这份实习又不需要用到这些必修课教的知识(我大三的时候才开始修专业课,因为进学院就要上一定数量的必修课) 我认为维持高 GPA 需要我花费更多精力在大学课程上,但它们很无聊、只会让我感觉浪费时间 但我认为将追求高 GPA 的学生群体简单地称呼为「GPA 奴隶」,属实有点过了。 这种行为与其说是「奴隶式」的盲从,不如说是在现有教育体制下的一种理性策略。 首先,当大学的课程设置充斥着大量与专业无关、却又必须完成的必修课时,「如何以最小的时间成本,换取最高的 GPA 收益」就成了一个非常现实的议题。学生们主动去寻找「水课」,恰恰是他们认识到自己时间宝贵、不愿将其浪费在低价值课程上的体现。这是一种主动的资源管理,而非被动的思想禁锢。 其次,这种行为也是被「唯 GPA 论」的外部环境(如某些实习和研究生申请)所逼迫的一种无奈之举。当系统用单一的标准来衡量你时,学生们自然会想办法在系统规则内最大化自己的优势。这种情况下,他们又一定是「奴隶」吗? 他当时说的是:「现在的大学不会教你你工作上需要的知识,也缺少这方面的指导。」我认为他这句话说的没问题,一开始认为他是一个印象不错的人。他也是个「怪人」,他喜欢抨击手机对人们的危害、喜欢宣传自己的哲学、喜欢用折叠手机(不是折叠屏手机,而是几十年前的折叠手机)、因为过往经历的霸凌而坚持健身数年…… 这些「怪」不会让人觉得必须去远离他才行,只会觉得他是一个颇有想法的人、对他产生兴趣(至少我当时是这样的)。 正是他这套基于对大学教育的批判,使他想去做 AI 学习辅导 项目。问题在于,他选择了让 AI 去辅导数学。 这个想法从一开始就存在致命的技术缺陷。首先,让 AI 去做严谨的逻辑工作在技术上极为困难,更何况团队里压根就 没有 AI 领域的专家。他本人虽是数学专业,但机器学习项目经验都在视觉方面。其次,他所谓的项目实现,也只是在简单调取 GPT API(顺便还把 API Key 上传到云端的 .env 文件里了),完全谈不上核心技术。 结果就是,我这边的客户端网站都做好了 MVP,他的模型也无法处理一些「理应可以处理」的数学问题。 再之后,他声称「赞助商不满意该结果」,决定转型。虽然做的还是 AI 相关,但这次他想要彻底贯穿自己的哲学:人们要少用电子设备、多花时间在现实中培养出好习惯。他决定做一个「习惯培养和管理」App,然后在里面添加类似宝可梦养成的游戏元素。也就是说,他希望用户多花时间在使用这个 App 上,又希望用户可以少使用电子产品。很明显的左右脑互搏。在他提出这个想法之后,我们就否决了这个方案、把他赶到了角落里去反省。 其实你可以略微看出来,他其实是一个做事容易 想一出是一出 的人。如果你是一个员工,你绝对不想要遇到这样的上司。而我作为他的「员工」,很快便遇到了我的「苦果」。 不专业的代码管理 在我加入团队的第一天,便发现了该项目的「不对劲」。项目的维护糟糕透顶,它甚至跑不起来,理由很傻:服务端的代码被导入了一堆既不会被用到、也早就过时的依赖包。这群人只是在自己的本地环境里删去了这些导入代码,却没有任何人将这个变更 push 到云端仓库。我那时候检查客户端代码发现了一个 bug,提出后还有人问我是不是服务端导入依赖的 bug。合着你们都心知肚明啊! 不仅如此,项目创始人对着工具也有着离谱的执着。当时我们在讨论如何统一代码风格、编写两端的 README。我提出「客户端和服务端的代码都可以使用 lint 检查代码格式」,他却拒绝了,理由照样很傻:人类不能依赖工具。我事后将此事告诉了我的朋友,他回复「猜猜人类和动物的最大区别是什么」,引得我哈哈大笑。 我理解他那种希望回归「纯粹」编程的理想主义情怀,但在一个需要多人协作的现代化项目中,这种情怀不仅不切实际,而且是对团队成员时间的不尊重。作为团队中负责前端实现的核心成员,当我看到这些业余的管理方式时,我的心情也从最初的期待跌入了谷底。 尽管他的哲学是「不依赖工具」,但是他花费了大量时间在 Obsidian 上写服务端每个文件的作用和依赖链。他不仅自己在写,还要求我们前端组也写组件之间的联系,美其名曰:帮助新人加入团队后快速了解项目结构(记住这个「新人」)。可是我们的项目本来就没多大,并且这个的优先度也不高,明明还有更重要的事情要做吧! 要记住这个时候,项目的核心服务功能是 完全不合格 的。 除此之外,我在客户端的代码里添加过一个新的依赖项。他虽然拉取了最新版本的代码,但是没有运行下载所有依赖项的命令,导致客户端没有运行成功。他认为这是我们前端组写的 bug,擅自在代码里加了奇怪的字符(我事后才意识到他把 GitHub 显示冲突用的字符添加进来了,原因不明且很迷),还发了个自信满满的「fixed」。这么做怎么可能跑得动呢?这次他把错误日志直接贴到了 bug 频道里,便再也不管了(可恶的是那个 bug 频道仅他可发送消息,我每次都只能复制消息链接然后在 general 频道里回复)。 有名无实的团队 最后便要讲讲该项目极其糟糕的人员管理。这样一个 AI 项目,Discord 服务器里名义上足足有近二十个人,但实际的构成却一言难尽。具体来说: 前端组(4 人):除开我,有一个只在频道里聊天、从未贡献过代码的;一个留下一堆 bug 后就因学术繁忙消失的;以及一个热衷于「闭门造车」、拒绝任何建议的 其他职能组:所谓的 UI / UX 组和市场营销组,从我加入到离开,从未在公开频道里发过一次言,形同虚设 项目创始人的朋友们:不参与开发,日常就是帮他找资料、写论文 这个团队为什么会变成这样?根源在于项目创始人极其随意的招聘和完全缺失的管理。讽刺的是,至于他心心念念的那些「新人」,招来的方式更是随意得令人震惊。他习惯在 LinkedIn(领英)上刷到一个看着顺眼的个人资料,便立刻发出邀请,既不审核对方的真实能力,也不考虑项目是否真的需要这个岗位。 人员臃肿的直接后果就是管理成本剧增。然而这个团队根本没有项目管理(创始人自己就是所谓的 PM),导致大部分人进团队后完全不知道该做什么,最终变成了列表里「挂机」的成员。 这种管理的恶果,在一次线下会议中暴露无遗:一个近二十人的线上团队,最终到场的,只有五六个人。 糟糕 Git 协作的另一个精彩故事 以下是我个人的观点。 我由衷地建议参与项目开发的学生们去花时间学习 Git。并不是会用 git pull、git push 就行了,而是能够理解在一个多人协作的情况下,你要如何拉取、推送代码变更的同时,又不会影响到别人。 我先前说过,我在的那个前端组有四人,有两人后续不参与实际开发,所以就只有我和另一个哥们。他对 Git 的理解简直差到离谱,经常不更新本地环境的代码便去写一大堆新代码,接着闭眼 force push 到 main 分支。 是的,该项目的团队当然是 没有 审查代码的环节。项目创始人最初就要我不用 fork 再提 PR,而是直接对着 main 分支推送就好了。 当时项目创始人要求我写一个谷歌登录,我写了足足三次,每次 push 上去后就又会被他用新写的、用不了的代码覆盖。 这一点也槽点满满。同我协作的哥们本来已经用 firebase 做好了登录注册功能,但是项目创始人觉得他「没做完」,认为用谷歌登录更快。 不知道为什么,很多刚开始写项目的人都喜欢先钻研怎么登陆注册(我也是这样)。实际上不先写登陆也没关系,因为用户本身就稀少得可怜,重点应当是实现核心技术。 结尾 说了这么多,该项目又怎么样了呢?答案是在线下会议结束的一个月后,我发现 Discord 服务器被删除了,问了其他组员才知道项目创始人 直接放弃 了该项目。 这不是他第一次「没有告知所有人便擅自决定某事」了。例如在某个频道进行了讨论后,他会在晚上或者次日直接将该频道删除,不留任何记录。这些讨论包含了争论、转型等各大重要的事件。如果你不是第一时间在现场,那你就完全不知道项目发生了什么改动,因为他是绝对不会写通告的。 题外话,项目的半年后他突然找到我,问我近期过得如何,还做出老前辈的样子劝我转行干别的。那个时候的他已经因为「大学无用论」带着另一个朋友辍学了。真是让人感到恼火。 经历了这场混乱,我对创业之路有了几点清晰的思考: 关于团队:人员规划必须精准。团队的每一个岗位都应该与实际需求对应。作为创始人,你必须清楚地知道你需要什么样的人才,以及需要多少人来完成任务 关于创始人:自我认知至关重要。创始人不仅是想法的源头,更是项目的核心管理者。你必须对自己的能力边界有清晰的认识,你的资历能否支撑你的野心? 关于想法:拒绝异想天开。任何一个好的想法,都必须经得起现实和市场的考验。一拍脑袋就往前冲,往往是悲剧的开始 一个好的想法,需要一个同样好的团队来实现。否则,它永远只能是一座空中楼阁。

2025/9/29
articleCard.readMore

Hexo 博客的中文排版自动修正插件

Cytrogen 近期阅读一篇名为 《我的博客设计》 的文章时,我深受启发。不只是极简设计的诸多考量(见 《博客重构记录》),还让我第一次接触到 《中文文案排版指北》 这套详尽的规范。 回复: https://taxodium.ink/my-blog-design.HTML 看来我对排版的一些零散的执念,早就被社区总结成一套系统性的方法论:比方说手动在中英文之间添加空格的习惯。 然而,《指北》中提到的许多其他规范 —— 比如使用直角引号 —— 我就无法坚持下去了。我用的是搜狗输入法,输入直角引号会有点繁琐(最常见的方案是设置自定义短语,但我不喜欢)。不过这种问题,对于一个开发者而言并不是一个难题:既然手动操作繁琐且不全面,何尝不直接让程序为我自动格式化这一切呢? 方案 我的博客网站是使用 Hexo 框架搭建的。作为一个静态网站生成器,它会在我写完 Markdown 文章后,通过 hexo g 命令将它们转换成最终的 HTML 页面。我的目标很明确:在运行 hexo g 命令时,让 Hexo 能自动扫描我的所有文章,并应用《指北》中的排版规则。 幸运的是,Hexo 提供了过滤器系统,它允许开发者在生成过程的特定节点上挂载自定义脚本,对内容进行处理。经过一番研究,我将目标锁定在了 after_post_render 这个钩子上(关于 hexo g 命令更详细的内部流程,我曾在 这篇文章 中进行过探讨)。 after_post_render 会在一篇文章从 Markdown 渲染成 HTML 之后、但写入最终文件之前触发,这正是我们对内容进行拦截和润色的完美时机。确定了技术路径只是第一步。要打造一个可靠的自动化脚本,必须解决两个关键问题: 自动化脚本绝对不能破坏文章的结构,尤其是代码块。const message = "你好world" 这样的代码里就不能添加空格 规则必须足够智能,能够正确处理中英文混合的复杂上下文,避免将纯英文内容里的标点也错误地汉化 为了解决这两个问题,我最终采用了一个多阶段、基于 DOM 解析的方案: 我先前尝试参考 hexo-pangu 的写法,用 jsdom。但是这种小任务用 jsdom 实在是大材小用,纯正则表达式又太难以维护(我也不太会写),最终还是选择了 cheerio 来操作 HTML 内容 在进行任何处理之前,脚本会首先识别并保护所有 <pre> 和 <code> 标签,确保它们内部的内容不会被后续任何规则触碰到 对所有被确认为安全的文本内容,脚本会依次执行 专有名词修正、标点符号转换 和 空格添加 等一系列处理流程,确保规则之间不会互相干扰 成果 为了将这个解决方案固化下来,并方便自己和其他人使用,我将其打包成了一个独立的 Hexo 插件:hexo-filter-copywriting。 它是一个轻量、高效且完全可配置的过滤器,可以帮助我们自动完成绝大部分中文排版工作。使用起来也很简单,你可以直接从 npm 安装它(说来这也是我第一次在 npm 上发布包),然后在博客根目录的 _config.yml 中进行配置即可。 自然,这个工具还未完善,有些排版规则没有被添加进去。日后我会逐渐完善它,最近还是太忙了,只能挤出一点点时间在写博客和开发开源小玩意儿上。毋庸置疑,该项目欢迎任何 Issue 或者 PR。 效果展示 为了直观展示插件的效果,并持续验证其健壮性,我创建了一个「排版自动化测试页面」。这个页面包含了各种常见的排版错误,你可以清除地看到插件修正前后的区别。 查看最终效果:排版自动化测试页面 查看原始 Markdown 文件:README.md on GitHub

2025/9/17
articleCard.readMore

博客重构记录

重构博客网站,也就是该网站的记录。 多用 CSS 近期订阅了一些英语的周刊,主要是前端相关,里面收集了很多外网博客平台上的优秀文章。其中不乏有一篇文章吸引到了我:很多时候你根本不需要使用 JavaScript。 吸引我的理由很简单。我的前端技术栈主要是 React.JS。相较于传统的「网页三剑客」,React.JS 这类现代前端框架需要客户端下载并执行更多的 JavaScript 代码。其核心的虚拟 DOM 技术虽然在过去带来了性能优势,但在现代浏览器性能已大幅提升的今天,其初始化和运行时成本有时反而不如原生方法来得直接高效。 但是,对过去的我而言,功能就是要用 JavaScript 才能做到。纯 HTML 和 CSS 能做到什么?它们又不是脚本语言。 不过在那个文章里,这个想法是片面的。现代的 CSS 技术进化很快、有着性能优异的各类方法,完全可以代替 JavaScript 来实现一些功能。 举个例子,我们想要实现主题切换功能。 如果是网页三剑客的话,我们可能会想到用 JavaScript 监听切换按钮,该按钮被点击了我们就改变被监听的类或者元素的样式。 在 React.JS 上的话,那就是存一个主题状态:如果是 light 模式就怎么怎么样;如果是 dark 模式就怎么怎么样。如果用的还是 Material-UI 或者其他 UI 框架,那大概率还会有个 theme 配置文件。 这些方案对我们而言应该都很熟悉:对啊,怎么了,这样做不是很正常吗。 其实可以更简单一些,用 CSS 的 color-mix() 方法就可以办到: :root { // 核心品牌色 --color-primary: #5454f8; --color-secondary: #267B54; --color-accent: #4088b8; // 使用 color-mix() 生成交互状态 --color-primary-hover: color-mix(in srgb, var(--color-primary) 85%, black); --color-secondary-hover: color-mix(in srgb, var(--color-secondary) 85%, black); --surface-interactive-hover: color-mix(in srgb, var(--surface-interactive) 80%, var(--color-primary));}// 深色主题[data-theme="dark"] { --color-primary: #818cf8; --color-secondary: #34d399; --color-accent: #60a5fa; // 深色模式下的交互状态 --color-primary-hover: color-mix(in srgb, var(--color-primary) 80%, white); --color-secondary-hover: color-mix(in srgb, var(--color-secondary) 80%, white); --surface-interactive-hover: color-mix(in srgb, var(--surface-interactive) 70%, var(--color-primary));} 又或者,我们想要创建模态框。通常我们会写一个 <div>,然后写 JavaScript 来控制它的显示和隐藏、背景遮罩、锁定用户的键盘焦点、处理 Esc 键退出等等等等。实际上,可以直接用 <dialog> 来写、用 ::backdrop 伪元素给背景遮罩添加样式和动画、用 @starting-style 来实现更流畅的入场动画。 其他交互效果,例如按钮的颜色变化就更不用说了。这里还有一个很有意思的考量:JavaScript 很可能带来安全问题,而 CSS 是完全安全的。 带着这些全新的知识点,我重构了我的博客网站。其实之前我对于网站设计是没有一丝考量的,也不在乎 CSS 这些让我觉得「不如 React.JS 优雅高级」的技术,因此我的博客网站性能一般,对比原本主题的设计还添加了许多非必需的东西。我的顶部菜单栏出现过明显的元素堆积过多的情况,一些按钮很丑很奇怪、像是四不像、哪儿哪儿都不挨上,所以我决定先在博客网站上将部分功能撤下。其中就有搜索功能:首先它的样式我一直没有设计、难看得很;其次是这个功能我认为没必要留、不够精简。未来如果需要的话,我会想一个更好的方案。 不过在 Hexo Theme Ares 里,搜索功能还会保留,并且进行了一次小重构。 我还撤走了评论区的 Disqus 评论区,因为这东西多多少少会给我带来一些影响:今天有没有人发评论?今天有没有人作反应? 其实很没必要,本身看的人就不多。 字体 正确导入字体 我不只是将一些功能用 CSS 重构了,我还改了一下字体的导入方式,参考的是另一篇 优质的文章,具体讲了现代网站是如何错误地导入字体的。 我的博客主题原先会导入足足四个字体: 英语的 Open Sans 简体中文的 Noto Sans(其实就是思源黑体,不过我在考虑换成其他的;我个人阅读时喜欢用霞鹜文楷,但还没尝试换过,有可能和我的主题不搭配) 用于特殊标题的 Dosis 代码块用的 JetBrains Mono 这些字体都是用 Google Fonts 的 CDN 导入的。其实这种方式并非最佳实践,很容易导致性能问题:浏览器会发起两次请求,第一次用于请求 fonts.googleapis.com 这个地址、获取一个 CSS 文件,第二次则是请求 CSS 文件内的真实字体文件地址。这个过程至少会有两次网络往返,第一次请求的 CSS 文件会阻塞渲染,这意味着在它下载完成之前,页面可能是一片空白或者没有应用任何样式。为了解决这些问题,我是这么做的: // 1. 加载真正的 Open Sans 字体@font-face { font-family: 'Open Sans'; src: url('...') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;}// 2. 创建“替身”字体@font-face { font-family: 'Open Sans Fallback'; src: local('Arial');// 使用人人都有的 Arial 字体 font-weight: 400; font-style: normal; font-display: swap; // 强行让 Arial 的尺寸变得和 Open Sans 一样 size-adjust: 107%; ascent-override: 97%; descent-override: 25%; line-gap-override: 0%;} // 3. 在字体栈里,让“替身”字体紧跟在真正的 Open Sans 字体之后$base-font-family: 'Open Sans', 'Open Sans Fallback', 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; 这个思路很简单:先让浏览器用这个调整好尺寸的 Arial 字体把字体显示出来,因为是本地字体,所以速度会很快、文字的位置会迅速固定。当真正的 Open Sans 字体下载好之后,浏览器就会立刻用它替换掉临时的 Arial「替身」。由于位置和尺寸早就被 Arial 字体固定好,所以整个替换过程在视觉上是无缝的,页面完全不会跳动。 再就是字体格式,相较于 TFF 或 OTF 等传统格式,用 WOFF2 更好。WOFF2 的压缩率极高,兼容性也特别好,尽量开发网页时都采取这个字体格式。不过有些字体不让转换成这个格式,需要特别注意一下。 你也可以看出来,我用了 unicode-range 控制了字体的字符范围、只让页面加载它实际需要的字符。比如说 Open Sans 只加载基本拉丁字符、标点符号和货币符号,Noto Sans 则只包含中文汉字字符范围(unicode-range: U+4E00-9FFF, U+3400-4DBF, U+20000-2A6DF;)。 不同字体的视觉对齐 作为一个中文技术博客,文章中难免会出现英文单词以及代码片段。为了达到最佳的阅读体验,我会为中文字符、西文(拉丁)字符以及代码用的字符分别指定一个字体。然而这里会面临一个问题:不同的字体,即时 font-size 是一致的,但它们在视觉上的大小、重心和基线位置往往是不一样的。当这些中英字符同时出现在一行时,就会显得大小不一、高低错落,破坏了排版的和谐感。 此处令我想到高中时、一位我很喜欢的老师。当时她还不是正式教师,考核时给我们上的课讲的就是排版和字体。当时并没有认真听讲。 CSS 提供了一些属性,恰好可以让我们解决这个问题: @font-face { font-family: 'JetBrains Mono'; // ... size-adjust: 94%; ascent-override: 92%; descent-override: 22%; line-gap-override: 0%;} 我通过 size-adjust: 94% 将 JetBrains Mono 的整体视觉大小缩小了一点,然后用 ascent-override 等属性微调了它的垂直对齐基线,最终让它和我的正文字体放在一起时,看起来没那么突兀了。 流式排版 每个人阅读博客的设备都各不相同,有用电脑的,有用平板的,有用手机的,有用小天才手表的(有吗?)。不同设备的屏幕尺寸不同,我们通常需要在多个端点处使用媒体查询来手动调整 font-size。例如: @media (min-width: 768px) { body: font-size: 17px; } } 这很繁琐,要知道现在世界上有多少种屏幕尺寸,一个个适配过去会累死掉。更优雅的解决方案是采用流式排版,即让字体大小像液体一样,随着屏幕宽度的变化而平滑、无缝地缩放,确保在任意设备宽度下都有最佳的视觉表现。实现这种效果,要用到 clamp() 方法。 :root { --font-size-body: clamp(1rem, 0.9rem + 0.5vw, 1.125rem); /* 从16px平滑过渡到18px */ --font-size-h1: clamp(2rem, 1.5rem + 2.5vw, 3rem); /* 从32px平滑过渡到48px */ // ...} clamp() 的三个参数分别是: 最小值 首选值 最大值 告别 jQuery 和 Font Awesome 优化完字体系统后,可以想想图标字体该怎么办。我的原先方案是用 Font Awesome,实际上也不是一个很好的选择。Font Awesome 是用传统的网络请求加载的,也会导致不必要的性能和阻塞渲染问题。最好的方案是使用 SVG。很多时候我们的网站只会用到一些图标字体,为此下载一整个完整字体库很没必要,SVG 的可访问性也更好。 我同时又检查了一遍博客的代码,发现主题的所有功能都被原生 JavaScript 实现。因此我移除了 jQuery 依赖。其实我都不是很明白这个主题哪里用到了 jQuery,可能是主题作者先前留下的。 提升交互体验 Cytrogen 在阅读了 这篇文章 之后,我为我的网站添加了一些可以交互的内容。主要添加的是一个「返回顶部」按钮,移动端上它会显示在顶部居中的位置,桌面端则固定显示在右下角。 回复: https://taxodium.ink/my-blog-design.HTML 先前在 这一节 里的例子里提到的动画、基于 <details> 的菜单我也实现了。 无障碍增强 我几乎是不会考虑「无障碍」的,过去的时候。在阅读了一些博客网站的设计想法时我意识到,实际上用着「不寻常设备」的读者可能比想象中还要多,总是要确保所有的用户都能良好地使用博客功能。当然,不只是博客,未来开发的网站也要想到这一点才行。这次重构中,我添加了一个跳过导航的按钮,它平日会被藏在屏幕视图的上方,需要使用 Tab 才可以将其唤出。Tab 也可以用来快速导航到下一个标题、链接、代码块等。 我也为正文链接添加了下划线,有用到 text-decoration-skip-ink: auto 这个属性,可以自动跳过字形,例如拉丁字符 g、j 等。

2025/9/14
articleCard.readMore

Svg2SvgIcon:将 SVG 转为 Material-UI SvgIcon

我有时候会根据自己当前的需求,写一些简单的小工具。现在希望将这些工具背后的思考和成果整理成博客文档,分享给有着同样需求的朋友们。 前言 作为一名常用 Material-UI 的开发者,我经常需要将各种 SVG 图标集成到项目中。虽然 Material-UI 本身提供了丰富的图标库,但很多时候我们还是会遇到需要自定义图标的情况。例如在网站的落地页上加一个大大的 logo,把它设置成五彩斑斓的黑色…… 过去要这么做,得手动封装 SVG、将其转换为 React 组件。这么搞很浪费时间,我因此萌生出将其自动化的想法、写了 Svg2SvgIcon。 Svg2SvgIcon Svg2SvgIcon 是一个纯客户端的在线转换工具,它可以将任意 SVG 代码或文件、快速转换为可以直接在你的 React 项目中使用的 Material-UI SvgIcon 组件。 点击这里传送。 或者自行复制到浏览器内: https://svg2svgicon.cytrogen.icu/ 核心功能 该工具的核心设计理念是高效、易用和安全。纯客户端意味着你上传或粘贴的任何 SVG 数据都只会在你的浏览器中进行处理、不会发送到任何服务器,最大限度地保障了你的隐私和数据安全。 以下是 Svg2SvgIcon 的主要功能: 智能的 viewBox 解析: 自动读取 SVG 中已有的 viewBox 属性 若 viewBox 不存在,会使用 SVG 的 width 和 height 属性来生成 若以上全无,则默认使用 0 0 24 24,确保图标能正确显示 实时预览与控制器: 在输入或修改 SVG 后,你可以立即在预览窗口中看到转换后的图标效果 提供实时控制器,方便你精确调整 viewBox 的 min-x、min-y、width 和 height 值,所有调整都会立刻反映在预览图标上 提供「重置」按钮,一键恢复到原始的 viewBox 值 全面的 SVG 解析能力: 支持解析嵌套元素,例如 <g> 标签 正确处理并转换行内的 style 属性为 React 的 style 对象 内置错误控制台: 内置一个可抽拉的错误控制台,当发生解析错误时会自动滑出提示 右下角的浮动按钮会用红点标记未读错误,方便你随时查看 一键复制 自动生成可以直接在项目中使用的 React 组件代码,并提供「一键复制」功能 如何使用 使用 Svg2SvgIcon 非常简单,只需几步: 输入 SVG 或者上传一个 .svg 文件 为即将生成的 React 组件起一个合适的名称 点击「转换 SVG」按钮,右侧会立刻显示图标预览和生成的代码 (可选)调整 viewBox 点击「复制代码」按钮,然后将代码粘贴到你的 React 项目中即可使用 技术栈与开源 Svg2SvgIcon 的核心技术栈非常精简: React Material-UI DOMParser 这个项目已在 GitHub 上开源,并采用 MIT 许可证。

2025/8/11
articleCard.readMore

Raspberry Pi Pico Plays Music Via Active Buzzer

How to use MicroPython to play music via the active buzzer on a Raspberry Pi Pico. Introduction I got my Raspberry Pi Pico earlier this year, but did not have the time to play with it, until recently I got this weird disease called senioritis. When I bought this Raspberry Pi Pico, I purchased the pre-soldered version because I'm lazy, shown as this image below: However, this article focuses on MicroPython, so it won't go into detail about the hardware. The same goes for basic topics like configuring the Raspberry Pi Pico or installing an IDE; you can search for that information online. The IDE that this article is using is Thonny All the music scores are from Online Sequencer Preparation Importing Libraries import timefrom machine import PWM, Pin The time library needs no introduction; it's used for creating delays machine library contains methods related to the hardware on a specific board. For example, PWM and Pin that we just imported. For details you can check MicroPython's official documentation Notes and Frequencies tones = { 'C0': 16, 'C#0': 17, 'D0': 18, 'D#0': 19, 'E0': 21, 'F0': 22, 'F#0': 23, 'G0': 24, 'G#0': 26, 'A0': 28, 'A#0': 29, 'B0': 31, 'C1': 33, 'C#1': 35, 'D1': 37, 'D#1': 39, 'E1': 41, 'F1': 44, 'F#1': 46, 'G1': 49, 'G#1': 52, 'A1': 55, 'A#1': 58, 'B1': 62, 'C2': 65, 'C#2': 69, 'D2': 73, 'D#2': 78, 'E2': 82, 'F2': 87, 'F#2': 92, 'G2': 98, 'G#2': 104, 'A2': 110, 'A#2': 117, 'B2': 123, 'C3': 131, 'C#3': 139, 'D3': 147, 'D#3': 156, 'E3': 165, 'F3': 175, 'F#3': 185, 'G3': 196, 'G#3': 208, 'A3': 220, 'A#3': 233, 'B3': 247, 'C4': 262, 'C#4': 277, 'D4': 294, 'D#4': 311, 'E4': 330, 'F4': 349, 'F#4': 370, 'G4': 392, 'G#4': 415, 'A4': 440, 'A#4': 466, 'B4': 494, 'C5': 523, 'C#5': 554, 'D5': 587, 'D#5': 622, 'E5': 659, 'F5': 698, 'F#5': 740, 'G5': 784, 'G#5': 831, 'A5': 880, 'A#5': 932, 'B5': 988, 'C6': 1047, 'C#6': 1109, 'D6': 1175, 'D#6': 1245, 'E6': 1319, 'F6': 1397, 'F#6': 1480, 'G6': 1568, 'G#6': 1661, 'A6': 1760, 'A#6': 1865, 'B6': 1976, 'C7': 2093, 'C#7': 2217, 'D7': 2349, 'D#7': 2489, 'E7': 2637, 'F7': 2794, 'F#7': 2960, 'G7': 3136, 'G#7': 3322, 'A7': 3520, 'A#7': 3729, 'B7': 3951, 'C8': 4186, 'C#8': 4435, 'D8': 4699, 'D#8': 4978, 'E8': 5274, 'F8': 5588, 'F#8': 5920, 'G8': 6272, 'G#8': 6645, 'A8': 7040, 'A#8': 7459, 'B8': 7902, 'C9': 8372, 'C#9': 8870, 'D9': 9397, 'D#9': 9956, 'E9': 10548, 'F9': 11175, 'F#9': 11840, 'G9': 12544, 'G#9': 13290, 'A9': 14080, 'A#9': 14917, 'B9': 15804} This dictionary contains all the notes' frequencies, from C0 to B9, in the unit of Hz. These frequencies will be used to set up the frequency of PWM. Aside If you don't know notes, but still wish to play using the actual music score in reality, what should you do? (Of course, if you do know, you can jump to this section) Let's see what a simple music score looks like. Welcome to our guest -- Twinkle Twinkle Little Star: First, in a music score every line has multiple measures, separated by the vertical lines. At the beginning of the staff, there's a symbol representing the time signature. For instance, this music score has the time signature of 4/4, which means every measure has 4 beats, every beat is one quarter note. What is a quarter note? We have to know the duration of notes. Notes' durations are divided into whole note, half note, quarter note, eighth note, sixteenth note, thirty-second note, etc. To make them easier to tell apart, take a look at this image: Now back to the music score of Twinkle Twinkle Little Star, we can tell the first measure has exactly 4 quarter notes, and the second measure has 2 quarter notes and 1 half note. In the actual play, what is the difference between these two measures? 4 quarter notes be like: Ah Ah Ah Ah; 2 quarter notes & 1 half note be like: Ah Ah Ah--. That being said, in this score 1 half note is the same as combining two quarter notes, making one long sound. Now that we have covered duration, let's look at the position of the notes on the musical staff. You might have noticed, some notes are above the line, some are below the lines, some are in the middle, some are in between... why? This brings us to the pitch of notes. To be clear, Twinkle Twinkle Little Star's music score has marked 1=C, which is C major. C major is composed by C, D, E, F, G, A, B these 7 notes, also the only major that has neither sharp nor flat. If you ever know how a piano looks like, it has those white and black keys. White keys are typically divided into C, D, E, F, G, A, B 7 notes; black keys in general are divided into C#, D#, F#, G#, A# 5 notes. The # symbol represents a sharp, which raises a note's pitch by a half step. Examples, C# is a half step above C, also the note that is between C and D. There is also a b symbol, representing a flat. Opposite to the # symbol, the b symbol lowers a note's pitch by a half step. Db is the half step below D, also the note between C and D, also C# itself. We have discussed so many about C, D, E, F, G, A, B, but in music score of Twinkle Twinkle Little Star above, we only saw numbers. This is because in simplified notation, Arabic numerals are used to represent the pitch of notes. 1 is the same as C, 2 is the same as D, and so on. While in reality, the most commonly used ones to represent the pitch of notes are solfège, which are do, re, mi, fa, so, la, si. do means C, re means D, and so on. We now know how to distinguish the pitch of notes using Arabic numbers, but this method only works in simplified notation. We still have to learn how to read the musical staff. C major's scale looks like this: Let's check the first measure of Twinkle Twinkle Little Star. You don't have to read the Arabic numbers, by referring to the image above, you can tell it's CCGG: But in our previous note-to-frequency-dictionary, C, D, E, F, G, A, B are followed by other numbers. For instance, C4, C5, etc. these numbers are representing the frequency of the notes, which is also the pitch of the sound. C4 is normally known as the Middle C; C5 is the C that's one octave higher. An octave represents a doubling of frequency. For example, the frequency of C5 (523.25 Hz) is double the frequency of C4 (261.63 Hz). However, we don't need such precise decimals; we can just round them to the nearest integer. For convenience, we will use the Middle C (C4) as standard. This way, we can combine the music score and notes of Twinkle Twinkle Little Star: C4 C4 G4 G4 A4 A4 G4- F4 F4 E4 E4 D4 D4 C4- G4 G4 F4 F4 E4 E4 D4- G4 G4 F4 F4 E4 E4 D4- C4 C4 G4 G4 A4 A4 G4- F4 F4 E4 E4 D4 D4 C4- This is not the final version of music scores we will be using, it's for easier understanding. Setting Up the Buzzer Let's set up the GPIO pin of the buzzer. I typically use GP15 (as shown in the previous picture, the GP15 pin is already connected to the buzzer). Of course, you can change the pin to suit your own needs: buzzer = PWM(Pin(15))buzzer.freq(50000)buzzer.duty_u16(int(65536 * 0.2)) At first, we create a PWM object, set the PWM frequency to 50,000 Hz (meaning no sound at all), and set the duty cycle to 20%. Writing the Code Getting the Music Score I chose to use the classic music from Super Mario for demonstration. Online Sequencer's music score can be copied and pasted, like below: Online Sequencer:616413:0 F#5 1 7;0 E6 1 7;2 F#5 1 7;2 E6 1 7;6 F#5 1 7;6 E6 1 7;10 F#5 1 7;10 C6 1 7;12 F#5 1 7;12 E6 1 7;16 G5 1 7;16 B5 1 7;16 G6 1 7;24 G5 1 7;32 E5 1 7;32 C6 1 7;38 C5 1 7;38 G5 1 7;44 G4 1 7;44 E5 1 7;50 C5 1 7;50 A5 1 7;54 D5 1 7;54 B5 1 7;58 C#5 1 7;58 A#5 1 7;60 C5 1 7;60 A5 1 7;64 C5 1 7;64 G5 1 7;66 G5 1 7;66 E6 1 7;69 B5 1 7;69 G6 1 7;72 C6 1 7;72 A6 1 7;76 A5 1 7;76 F6 1 7;78 B5 1 7;78 G6 1 7;82 A5 1 7;82 E6 1 7;86 E5 1 7;86 C6 1 7;88 F5 1 7;88 D6 1 7;90 D5 1 7;90 B5 1 7;96 E5 1 7;96 C6 1 7;102 C5 1 7;102 G5 1 7;108 G4 1 7;108 E5 1 7;114 C5 1 7;114 A5 1 7;118 D5 1 7;118 B5 1 7;122 C#5 1 7;122 A#5 1 7;124 C5 1 7;124 A5 1 7;128 C5 1 7;128 G5 1 7;130 G5 1 7;130 E6 1 7;133 B5 1 7;133 G6 1 7;136 C6 1 7;136 A6 1 7;140 A5 1 7;140 F6 1 7;142 B5 1 7;142 G6 1 7;146 A5 1 7;146 E6 1 7;150 E5 1 7;150 C6 1 7;152 F5 1 7;152 D6 1 7;154 D5 1 7;154 B5 1 7;164 E6 1 7;164 G6 1 7;166 D#6 1 7;166 F#6 1 7;168 D6 1 7;168 F6 1 7;170 B5 1 7;170 D#6 1 7;174 C6 1 7;174 E6 1 7;178 E5 1 7;178 G#5 1 7;180 F5 1 7;180 A5 1 7;182 G5 1 7;182 C6 1 7;186 C5 1 7;186 A5 1 7;188 E5 1 7;188 C6 1 7;190 F5 1 7;190 D6 1 7;196 E6 1 7;196 G6 1 7;198 D#6 1 7;198 F#6 1 7;200 D6 1 7;200 F6 1 7;202 B5 1 7;202 D#6 1 7;206 C6 1 7;206 E6 1 7;210 F6 1 7;210 G6 1 7;210 C7 1 7;214 F6 1 7;214 G6 1 7;214 C7 1 7;216 F6 1 7;216 G6 1 7;216 C7 1 7;228 E6 1 7;228 G6 1 7;230 D#6 1 7;230 F#6 1 7;232 D6 1 7;232 F6 1 7;234 B5 1 7;234 D#6 1 7;238 C6 1 7;238 E6 1 7;242 E5 1 7;242 G#5 1 7;244 F5 1 7;244 A5 1 7;246 G5 1 7;246 C6 1 7;250 C5 1 7;250 A5 1 7;252 E5 1 7;252 C6 1 7;254 F5 1 7;254 D6 1 7;260 G#5 1 7;260 D#6 1 7;266 F5 1 7;266 D6 1 7;272 C5 1 7;272 E5 1 7;272 C6 1 7;292 E6 1 7;292 G6 1 7;294 D#6 1 7;294 F#6 1 7;296 D6 1 7;296 F6 1 7;298 B5 1 7;298 D#6 1 7;302 C6 1 7;302 E6 1 7;306 E5 1 7;306 G#5 1 7;308 F5 1 7;308 A5 1 7;310 G5 1 7;310 C6 1 7;314 C5 1 7;314 A5 1 7;316 E5 1 7;316 C6 1 7;318 F5 1 7;318 D6 1 7;324 E6 1 7;324 G6 1 7;326 D#6 1 7;326 F#6 1 7;328 D6 1 7;328 F6 1 7;330 B5 1 7;330 D#6 1 7;334 C6 1 7;334 E6 1 7;338 F6 1 7;338 G6 1 7;338 C7 1 7;342 F6 1 7;342 G6 1 7;342 C7 1 7;344 F6 1 7;344 G6 1 7;344 C7 1 7;356 E6 1 7;356 G6 1 7;358 D#6 1 7;358 F#6 1 7;360 D6 1 7;360 F6 1 7;362 B5 1 7;362 D#6 1 7;366 C6 1 7;366 E6 1 7;370 E5 1 7;370 G#5 1 7;372 F5 1 7;372 A5 1 7;374 G5 1 7;374 C6 1 7;378 C5 1 7;378 A5 1 7;380 E5 1 7;380 C6 1 7;382 F5 1 7;382 D6 1 7;388 G#5 1 7;388 D#6 1 7;394 F5 1 7;394 D6 1 7;400 E5 1 7;400 C6 1 7;416 G#5 1 7;416 C6 1 7;418 G#5 1 7;418 C6 1 7;422 G#5 1 7;422 C6 1 7;426 G#5 1 7;426 C6 1 7;428 A#5 1 7;428 D6 1 7;432 G5 1 7;432 E6 1 7;434 E5 1 7;434 C6 1 7;438 E5 1 7;438 A5 1 7;440 C5 1 7;440 G5 1 7;448 G#5 1 7;448 C6 1 7;450 G#5 1 7;450 C6 1 7;454 G#5 1 7;454 C6 1 7;458 G#5 1 7;458 C6 1 7;460 A#5 1 7;460 D6 1 7;462 G5 1 7;462 E6 1 7;480 G#5 1 7;480 C6 1 7;482 G#5 1 7;482 C6 1 7;486 G#5 1 7;486 C6 1 7;490 G#5 1 7;490 C6 1 7;492 A#5 1 7;492 D6 1 7;496 G5 1 7;496 E6 1 7;498 E5 1 7;498 C6 1 7;502 E5 1 7;502 A5 1 7;504 C5 1 7;504 G5 1 7;512 F#5 1 7;512 E6 1 7;514 F#5 1 7;514 E6 1 7;518 F#5 1 7;518 E6 1 7;522 F#5 1 7;522 C6 1 7;524 F#5 1 7;524 E6 1 7;528 G5 1 7;528 B5 1 7;528 G6 1 7;536 G5 1 7;544 E5 1 7;544 C6 1 7;550 C5 1 7;550 G5 1 7;556 G4 1 7;556 E5 1 7;562 C5 1 7;562 A5 1 7;566 D5 1 7;566 B5 1 7;570 C#5 1 7;570 A#5 1 7;572 C5 1 7;572 A5 1 7;576 C5 1 7;576 G5 1 7;578 G5 1 7;578 E6 1 7;581 B5 1 7;581 G6 1 7;584 C6 1 7;584 A6 1 7;588 A5 1 7;588 F6 1 7;590 B5 1 7;590 G6 1 7;594 A5 1 7;594 E6 1 7;598 E5 1 7;598 C6 1 7;600 F5 1 7;600 D6 1 7;602 D5 1 7;602 B5 1 7;608 E5 1 7;608 C6 1 7;614 C5 1 7;614 G5 1 7;620 G4 1 7;620 E5 1 7;626 C5 1 7;626 A5 1 7;630 D5 1 7;630 B5 1 7;634 C#5 1 7;634 A#5 1 7;636 C5 1 7;636 A5 1 7;640 C5 1 7;640 G5 1 7;642 G5 1 7;642 E6 1 7;645 B5 1 7;645 G6 1 7;648 C6 1 7;648 A6 1 7;652 A5 1 7;652 F6 1 7;654 B5 1 7;654 G6 1 7;658 A5 1 7;658 E6 1 7;662 E5 1 7;662 C6 1 7;664 F5 1 7;664 D6 1 7;666 D5 1 7;666 B5 1 7;672 C6 1 7;672 E6 1 7;674 A5 1 7;674 C6 1 7;678 E5 1 7;678 G5 1 7;684 E5 1 7;684 G#5 1 7;688 F5 1 7;688 A5 1 7;690 C6 1 7;690 F6 1 7;694 C6 1 7;694 F6 1 7;696 F5 1 7;696 A5 1 7;704 G5 1 7;704 B5 1 7;706 F6 1 7;706 A6 1 7;709 F6 1 7;709 A6 1 7;712 F6 1 7;712 A6 1 7;714 E6 1 7;714 G6 1 7;717 D6 1 7;717 F6 1 7;720 C6 1 7;720 E6 1 7;722 A5 1 7;722 C6 1 7;726 F5 1 7;726 A5 1 7;728 E5 1 7;728 G5 1 7;736 C6 1 7;736 E6 1 7;738 A5 1 7;738 C6 1 7;742 E5 1 7;742 G5 1 7;748 E5 1 7;748 G#5 1 7;752 F5 1 7;752 A5 1 7;754 C6 1 7;754 F6 1 7;758 C6 1 7;758 F6 1 7;760 F5 1 7;760 A5 1 7;768 G5 1 7;768 B5 1 7;770 D6 1 7;770 F6 1 7;774 D6 1 7;774 F6 1 7;776 D6 1 7;776 F6 1 7;778 C6 1 7;778 E6 1 7;781 B5 1 7;781 D6 1 7;784 G5 1 7;784 C6 1 7;786 E5 1 7;790 E5 1 7;792 C5 1 7;800 C6 1 7;800 E6 1 7;802 A5 1 7;802 C6 1 7;806 E5 1 7;806 G5 1 7;812 E5 1 7;812 G#5 1 7;816 F5 1 7;816 A5 1 7;818 C6 1 7;818 F6 1 7;822 C6 1 7;822 F6 1 7;824 F5 1 7;824 A5 1 7;832 G5 1 7;832 B5 1 7;834 F6 1 7;834 A6 1 7;837 F6 1 7;837 A6 1 7;840 F6 1 7;840 A6 1 7;842 E6 1 7;842 G6 1 7;845 D6 1 7;845 F6 1 7;848 C6 1 7;848 E6 1 7;850 A5 1 7;850 C6 1 7;854 F5 1 7;854 A5 1 7;856 E5 1 7;856 G5 1 7;864 C6 1 7;864 E6 1 7;866 A5 1 7;866 C6 1 7;870 E5 1 7;870 G5 1 7;876 E5 1 7;876 G#5 1 7;880 F5 1 7;880 A5 1 7;882 C6 1 7;882 F6 1 7;886 C6 1 7;886 F6 1 7;888 F5 1 7;888 A5 1 7;896 G5 1 7;896 B5 1 7;898 D6 1 7;898 F6 1 7;902 D6 1 7;902 F6 1 7;904 D6 1 7;904 F6 1 7;906 C6 1 7;906 E6 1 7;909 B5 1 7;909 D6 1 7;912 G5 1 7;912 C6 1 7;914 E5 1 7;918 E5 1 7;920 C5 1 7;928 G#5 1 7;928 C6 1 7;930 G#5 1 7;930 C6 1 7;934 G#5 1 7;934 C6 1 7;938 G#5 1 7;938 C6 1 7;940 A#5 1 7;940 D6 1 7;944 G5 1 7;944 E6 1 7;946 E5 1 7;946 C6 1 7;950 E5 1 7;950 A5 1 7;952 C5 1 7;952 G5 1 7;960 G#5 1 7;960 C6 1 7;962 G#5 1 7;962 C6 1 7;966 G#5 1 7;966 C6 1 7;970 G#5 1 7;970 C6 1 7;972 A#5 1 7;972 D6 1 7;974 G5 1 7;974 E6 1 7;992 G#5 1 7;992 C6 1 7;994 G#5 1 7;994 C6 1 7;998 G#5 1 7;998 C6 1 7;1002 G#5 1 7;1002 C6 1 7;1004 A#5 1 7;1004 D6 1 7;1008 G5 1 7;1008 E6 1 7;1010 E5 1 7;1010 C6 1 7;1014 E5 1 7;1014 A5 1 7;1016 C5 1 7;1016 G5 1 7;1024 F#5 1 7;1024 E6 1 7;1026 F#5 1 7;1026 E6 1 7;1030 F#5 1 7;1030 E6 1 7;1034 F#5 1 7;1034 C6 1 7;1036 F#5 1 7;1036 E6 1 7;1040 G5 1 7;1040 B5 1 7;1040 G6 1 7;1048 G5 1 7;1056 C6 1 7;1056 E6 1 7;1058 A5 1 7;1058 C6 1 7;1062 E5 1 7;1062 G5 1 7;1068 E5 1 7;1068 G#5 1 7;1072 F5 1 7;1072 A5 1 7;1074 C6 1 7;1074 F6 1 7;1078 C6 1 7;1078 F6 1 7;1080 F5 1 7;1080 A5 1 7;1088 G5 1 7;1088 B5 1 7;1090 F6 1 7;1090 A6 1 7;1093 F6 1 7;1093 A6 1 7;1096 F6 1 7;1096 A6 1 7;1098 E6 1 7;1098 G6 1 7;1101 D6 1 7;1101 F6 1 7;1104 C6 1 7;1104 E6 1 7;1106 A5 1 7;1106 C6 1 7;1110 F5 1 7;1110 A5 1 7;1112 E5 1 7;1112 G5 1 7;1120 C6 1 7;1120 E6 1 7;1122 A5 1 7;1122 C6 1 7;1126 E5 1 7;1126 G5 1 7;1132 E5 1 7;1132 G#5 1 7;1136 F5 1 7;1136 A5 1 7;1138 C6 1 7;1138 F6 1 7;1142 C6 1 7;1142 F6 1 7;1144 F5 1 7;1144 A5 1 7;1152 G5 1 7;1152 B5 1 7;1154 D6 1 7;1154 F6 1 7;1158 D6 1 7;1158 F6 1 7;1160 D6 1 7;1160 F6 1 7;1162 C6 1 7;1162 E6 1 7;1165 B5 1 7;1165 D6 1 7;1168 G5 1 7;1168 C6 1 7;1170 E5 1 7;1174 E5 1 7;1176 C5 1 7;0 D4 1 7;2 D4 1 7;6 D4 1 7;10 D4 1 7;12 D4 1 7;24 G4 1 7;32 G4 1 7;38 E4 1 7;44 C4 1 7;50 F4 1 7;54 G4 1 7;58 F#4 1 7;60 F4 1 7;64 E4 1 7;66 C5 1 7;69 E5 1 7;72 F5 1 7;76 D5 1 7;78 E5 1 7;82 C5 1 7;86 A4 1 7;88 B4 1 7;90 G4 1 7;96 G4 1 7;102 E4 1 7;108 C4 1 7;114 F4 1 7;118 G4 1 7;122 F#4 1 7;124 F4 1 7;128 E4 1 7;130 C5 1 7;133 E5 1 7;136 F5 1 7;140 D5 1 7;142 E5 1 7;146 C5 1 7;150 A4 1 7;152 B4 1 7;154 G4 1 7;160 C4 1 7;166 G4 1 7;172 C5 1 7;176 F4 1 7;182 C5 1 7;184 C5 1 7;188 F4 1 7;192 C4 1 7;198 E4 1 7;204 G4 1 7;206 C5 1 7;220 G4 1 7;224 C4 1 7;230 G4 1 7;236 C5 1 7;240 F4 1 7;246 C5 1 7;248 C5 1 7;252 F4 1 7;256 C4 1 7;260 G#4 1 7;266 A#4 1 7;278 G4 1 7;280 G4 1 7;284 C4 1 7;288 C4 1 7;294 G4 1 7;300 C5 1 7;304 F4 1 7;310 C5 1 7;312 C5 1 7;316 F4 1 7;320 C4 1 7;326 E4 1 7;332 G4 1 7;334 C5 1 7;348 G4 1 7;352 C4 1 7;358 G4 1 7;364 C5 1 7;368 F4 1 7;374 C5 1 7;376 C5 1 7;380 F4 1 7;384 C4 1 7;388 G#4 1 7;394 A#4 1 7;400 C5 1 7;406 G4 1 7;408 G4 1 7;412 C4 1 7;416 G#3 1 7;422 D#4 1 7;428 G#4 1 7;432 G4 1 7;438 C4 1 7;444 G3 1 7;448 G#3 1 7;454 D#4 1 7;460 G#4 1 7;464 G4 1 7;470 C4 1 7;476 G3 1 7;480 G#3 1 7;486 D#4 1 7;492 G#4 1 7;496 G4 1 7;502 C4 1 7;508 G3 1 7;512 D4 1 7;514 D4 1 7;518 D4 1 7;522 D4 1 7;524 D4 1 7;536 G4 1 7;544 G4 1 7;550 E4 1 7;556 C4 1 7;562 F4 1 7;566 G4 1 7;570 F#4 1 7;572 F4 1 7;576 E4 1 7;578 C5 1 7;581 E5 1 7;584 F5 1 7;588 D5 1 7;590 E5 1 7;594 C5 1 7;598 A4 1 7;600 B4 1 7;602 G4 1 7;608 G4 1 7;614 E4 1 7;620 C4 1 7;626 F4 1 7;630 G4 1 7;634 F#4 1 7;636 F4 1 7;640 E4 1 7;642 C5 1 7;645 E5 1 7;648 F5 1 7;652 D5 1 7;654 E5 1 7;658 C5 1 7;662 A4 1 7;664 B4 1 7;666 G4 1 7;672 C4 1 7;678 F#4 1 7;680 G4 1 7;684 C5 1 7;688 F4 1 7;692 F4 1 7;696 C5 1 7;698 C5 1 7;700 F4 1 7;704 D4 1 7;710 F4 1 7;712 G4 1 7;716 B4 1 7;720 G4 1 7;724 G4 1 7;728 C5 1 7;730 C5 1 7;732 G4 1 7;736 C4 1 7;742 F#4 1 7;744 G4 1 7;748 C5 1 7;752 F4 1 7;756 F4 1 7;760 C5 1 7;762 C5 1 7;764 F4 1 7;768 G4 1 7;774 G4 1 7;776 G4 1 7;778 A4 1 7;781 B4 1 7;784 C5 1 7;788 G4 1 7;792 C4 1 7;800 C4 1 7;806 F#4 1 7;808 G4 1 7;812 C5 1 7;816 F4 1 7;820 F4 1 7;824 C5 1 7;826 C5 1 7;828 F4 1 7;832 D4 1 7;838 F4 1 7;840 G4 1 7;844 B4 1 7;848 G4 1 7;852 G4 1 7;856 C5 1 7;858 C5 1 7;860 G4 1 7;864 C4 1 7;870 F#4 1 7;872 G4 1 7;876 C5 1 7;880 F4 1 7;884 F4 1 7;888 C5 1 7;890 C5 1 7;892 F4 1 7;896 G4 1 7;902 G4 1 7;904 G4 1 7;906 A4 1 7;909 B4 1 7;912 C5 1 7;916 G4 1 7;920 C4 1 7;928 G#3 1 7;934 D#4 1 7;940 G#4 1 7;944 G4 1 7;950 C4 1 7;956 G3 1 7;960 G#3 1 7;966 D#4 1 7;972 G#4 1 7;976 G4 1 7;982 C4 1 7;988 G3 1 7;992 G#3 1 7;998 D#4 1 7;1004 G#4 1 7;1008 G4 1 7;1014 C4 1 7;1020 G3 1 7;1024 D4 1 7;1026 D4 1 7;1030 D4 1 7;1034 D4 1 7;1036 D4 1 7;1048 G4 1 7;1056 C4 1 7;1062 F#4 1 7;1064 G4 1 7;1068 C5 1 7;1072 F4 1 7;1076 F4 1 7;1080 C5 1 7;1082 C5 1 7;1084 F4 1 7;1088 D4 1 7;1094 F4 1 7;1096 G4 1 7;1100 B4 1 7;1104 G4 1 7;1108 G4 1 7;1112 C5 1 7;1114 C5 1 7;1116 G4 1 7;1120 C4 1 7;1126 F#4 1 7;1128 G4 1 7;1132 C5 1 7;1136 F4 1 7;1140 F4 1 7;1144 C5 1 7;1146 C5 1 7;1148 F4 1 7;1152 G4 1 7;1158 G4 1 7;1160 G4 1 7;1162 A4 1 7;1165 B4 1 7;1168 C5 1 7;1172 G4 1 7;1176 C4 1 7;: That's a lot~ Let's break down the format of Online Sequencer music score: Online Sequencer:616413: is useless in our situation, therefore should be removed 0 F#5 1 7; is the key. 0 means the time position, or the beat, of the note; F#5 is the pitch of the note; 1 is the duration of the note; 7 is the type of instrument when playing. Every note in Online Sequencer's music score is in this format, separated by semicolon Also, note that there's one :, it should be removed as well Then let's write the code to convert the Online Sequencer music score to Python dictionary. We have to remove the beginning and the ending part from the Online Sequencer music score first, then split every note in the music score regarding to the ;. song = song_file.split(":")[2]notes_list = song.split(";")[:-1] Next, we extract the beat of the note, setting it to the key of the dictionary. Note's pitch, duration and instrument type will be set as the value: notes_dict = {}for note in notes_list: note_parts = note.split() beat = float(note_parts[0]) # If the beat is added to the dictionary for the first time if beat not in notes_dict: # Add the note information to the dictionary notes_dict[beat] = note_parts[1:] # If the beat has already been added to the dictionary else: # If the current note's pitch is higher than the note's pitch in the dictionary if tones[note_parts[1]] > tones[notes_dict[beat][0]]: # Replace the previous note by the current note notes_dict[beat] = note_parts[1:] Since there is only one buzzer playing, it cannot play multiple notes at the same time. When there's more than one note at the same time position, we can only choose to play the note with the highest pitch. At this point, we have a Python dictionary as our music score. Playing Audio Making the buzzer produce sound is simple. The code is as follows: buzzer.freq(tones[tone])time.sleep(duration) buzzer.freq() will set the frequency of the buzzer, and time.sleep() pauses the program for a moment (in our case, it's used to hold the note for its duration). Playing the Score Combining the previous two points together, we can play the notes from the dictionary one by one. But before that, we have to set the playing speed of the music score. bpm = 199 # beat per minutetime_signature = [4, 4]beat_duration = (60 / bpm) / time_signature[0] # duration per beat As we discussed in the Aside on music theory, the time signature determines how many beats are in each measure and which kind of note gets one beat. From the website that we get Super Mario music, we can tell it have the bpm of 199 and time signature of 4/4. With a quick calculation, we can determine that each beat's duration is (60 / 199) / 4. Continuing with the code to play the score: # Start playing from the 0th beatcurrent_beat = float(0)# Play until the time position of the last notewhile current_beat <= max(notes_dict.keys()): # Extract the duration (of beats) of the current note, multiply it with the duration of every beat, get the duration (of seconds) of the current note note_duration = float(notes_dict[current_beat][1]) * beat_duration # If the current time position has a note, then play it if current_beat in notes_dict: note_name = notes_dict[current_beat][0] print(str(current_beat) + "th beat: playing note " + note_name + ", duration is " + str(note_duration) + " seconds") buzzer.freq(tones[note_name]) time.sleep(note_duration) current_beat += float(notes_dict[current_beat][1]) buzzer.freq(50000) # If the current time position has no note, then pause else: print(str(current_beat) + "th beat: pause, duration is " + str(note_duration) + " seconds") buzzer.freq(50000) time.sleep(beat_duration) current_beat += 1print("Finished playing")buzzer.freq(50000) And with that, our simple program for playing a music score is complete. Final version of code can be found in this repo.

2025/7/25
articleCard.readMore

Hexo 源代码分析【2】:「generate」的奇幻漂流

使用 Hexo,痛骂 Hexo,理解 Hexo,成为 Hexo。 这篇文章是用来记录我阅读 Hexo 源代码的过程和分析。 其实这篇文章去年暑假就在筹备写,但是当时的思维被局限在了线性的代码导览。 看过 上一篇文章 的朋友应该能明显感觉出,一个小小的 index.js 都能 水 写那么多内容,很多还是占篇幅的代码。实际上根本不需要把代码一次又一次贴出来,只要贴出来最核心的几行代码就好了,难道真的会有人去跟着我的文章去一个一个对 Hexo 的源代码吗 2333。重点还得是如何用文字描述内容、让读者更聚焦。 那么我又为什么没有继续写第二篇了呢?自然不是因为我又鸽了 (当然可能性不是零),而是我当时计划写 Hexo 的 extend 目录,即扩展(官方文档 /api 路由下就能在左侧边栏看到蓝蓝的 扩展 二字,它包含了控制台、部署器、过滤器等 Hexo 很核心的内容。而这就是问题所在,它并不是一个线性的代码、和第一篇的入口文件 index.js 不一样。extend 目录下的模块那么多,逐一分析过去非常耗时并且容易失去焦点,我那样写又是折磨自己还折磨读者,花费那么多精力根本不讨好。 问题又来了:我为什么又跑来写这个第二篇,并且在开头加了这么长一个警告块? 答案很简单,因为我在睡梦中受高人指点。高人曰:「不要做代码的导游,要做故事的讲述者。」 我顿时恍然大悟,之前那种线性分析方法,就像是拿着一本字典,一页一页地给读者讲定义、枯燥且乏味。而一个优秀的技术分享,应该像一部引人入胜的侦探小说、有一条清晰的主线。巴拉巴拉,这个这个,那个那个。开玩笑的。 所以这篇文章的风格将会和上一篇不同。我们不再按部就班地解刨 extend 目录下的每一个文件,而是会以一个核心的用户行为为线索,将这些分散的扩展模块串联起来,讲述一个它们如何协同工作的完整故事。这意味着本文会更侧重于流程、数据流和模块间的交互,而不是孤立地分析某个函数的实现。 实际上写完后感觉风格没变多少,晕。 在 上一篇文章 中,我们了解了 Hexo 源代码中的入口文件,并且在没有讲太多细节的情况下过了一遍 Hexo 运行的流程: class Hexo extends events_1.EventEmitter { constructor(base = process.cwd(), args = {}) { ... } _bindLocals() { ... } init() { ... } call(name, args, callback) { ... } model(name, schema) { ... } resolvePlugin(name, basedir) { ... } loadPlugin(path, callback) { ... } _showDrafts() { ... } load(callback) { ... } watch(callback) { ... } unwatch() { ... } _generateLocals() { ... } _runGenerators() { ... } _routerRefresh(runningGenerators, useCache) { ... } _generate(options = {}) { ... } exit(err) { ... } execFilter(type, data, options) { ... } execFilterSync(type, data, options) { ... }} 简单来说是: 初始化了各种目录路径、设置环境变量、初始化各种扩展,设置配置、日志、渲染器、路由等。 _bindLocals 方法将数据库中的数据绑定到 locals 对象上。 init 方法初始化 Hexo,加载插件和配置。 call 方法调用控制台命令。 model 方法创建或获取数据库模型。 resolvePlugin 和 loadPlugin 方法用于解析和加载插件。 load 和 watch 方法加载数据并处理源文件,watch 方法还会设置文件监听。 _generate 方法生成静态文件。 exit 方法退出程序,执行清理工作。 但一个静态的类如何响应我们的命令,并将一堆散乱的 Markdown 文件变成一个精美的网站的呢? 熟悉 Hexo 的朋友都知道,我们最常用的命令之一 —— hexo generate —— 就是用来做这个的,而这篇文章要剥下 hexo generate 的皮,看看 Hexo 的核心是如何运作的。 1. 解析命令 无奖竞猜:当我们在终端敲下 hexo generate 时,Hexo 中第一个被激活的部件是什么? 答案是 Console 扩展。 我们可以把它想象成 Hexo 的「总机」或者「前台」。它的核心职责就是:接收你输入的命令,然后把它转接给正确的内部处理函数。这个过程分为两步:注册 和 调用 一个命令能被调用,前提是它得先被「注册在案」。这个注册过程发生在 Hexo 的初始化阶段,也就是 hexo.init() 方法中。记性很好的朋友应该可以瞬间想起这行关键代码: require('../plugins/console')(this); 这行代码会加载 Hexo 内置的所有控制台命令。对于 generate 命令,它会执行 /plugins/console/index.js 文件中的代码,该文件负责注册包括 generate 在内的多个核心命令。我们来看 generate 的注册部分: console.register('generate', 'Generate static files.', { options: [ { name: '-d, --deploy', desc: 'Deploy after generated' }, { name: '-f, --force', desc: 'Force regenerate' }, { name: '-w, --watch', desc: 'Watch file changes' }, { name: '-b, --bail', desc: 'Raise an error if any unhandled exception is thrown during generation' }, { name: '-c, --concurrency', desc: 'Maximum number of files to be generated in parallel. Default is infinity' } ]}, require('./generate')); register 方法在这里接收了四个参数: name:命令名称 desc:命令描述 options:定义了所有 hexo g 支持的命令行参数,比如我们 -d(生成完成后部署)和 -w(监视文件变动) fn:命令的执行函数。这里通过 require('./generate') 加载了同目录下的 generate.js 文件,该文件导出的 generateConsole 函数就是 generate 命令的真正入口: function generateConsole(args = {}) { const generator = new Generater(this, args); // 这个类之后会提起 if (generator.watch) { return generator.execWatch(); } return this.load().then(() => generator.firstGenerate()).then(() => { if (generator.deploy) { return generator.execDeploy(); } });}module.exports = generateConsole; 这样一来,generate 命令、它的描述、它的所有参数,以及它的执行函数,就通过这几行代码被清晰地关联起来,并存放在 hexo.extend.console 这个登记簿内。 注册完成后,当 hexo-cli 工具解析到你的 generate 命令时,它就会调用我们在 Hexo 类中看到的方法:hexo.call('generate', args)。 也就是这一段: call(name, args, callback) { if (!callback && typeof args === 'function') { callback = args; args = {}; } const c = this.extend.console.get(name); // 1. 从登记簿里查找命令 if (c) return Reflect.apply(c, this, [args]).asCallback(callback); // 2. 执行找到的函数 return bluebird_1.default.reject(new Error(`Console \`${name}\` has not been registered yet!`));} 就这样,通过注册和调用这两步简单的操作,Hexo 的 Console 扩展干净利落地完成了它的使命…… 可喜可贺、可喜可贺。 现在 generateConsole 函数接过了指挥棒,而它要做的第一件事,就是调用 this.load()。 2. 原材料加工 我们来看 this.load 方法的核心: load(callback) { return (0, load_database_1.default)(this).then(() => { // 确保数据库已加载 this.log.info('Start processing'); return bluebird_1.default.all([ this.source.process(), // 我们的主角 this.theme.process() ]); }).then(() => { mergeCtxThemeConfig(this); return this._generate({ cache: false }); }).asCallback(callback);} this.load() 的任务很明确:将 source 文件夹里所有零散的文件,加工成结构化、可供后续使用的内存数据。 this.source.process() 是故事的起点。那么 this.source 是什么?它的 process() 方法又是什么? 2.1. Box 类 在 Hexo 类的结构函数中,this.source 被实例化: const source_1 = __importDefault(require("./source"));this.source = new source_1.default(this); 而 Source 类的定义出奇地简单: "use strict";var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod };};const box_1 = __importDefault(require("../box"));class Source extends box_1.default { constructor(ctx) { super(ctx, ctx.source_dir); this.processors = ctx.extend.processor.list(); }}module.exports = Source;//# sourceMappingURL=source.js.map 原因也很简单:Source 类继承自 Box 类。记性很好的朋友此时就要一拍脑袋了:上一篇文章 提到过。 当我们调用 this.source.process() 时,我们实际上是在调用 Box 类中定义的 process 方法: process(callback) { const { base, Cache, context: ctx } = this; return (0, hexo_fs_1.stat)(base).then(stats => { if (!stats.isDirectory()) return; // Check existing files in cache const relativeBase = escapeBackslash(base.substring(ctx.base_dir.length)); const cacheFiles = Cache.filter(item => item._id.startsWith(relativeBase)).map(item => item._id.substring(relativeBase.length)); // Handle deleted files return this._readDir(base) .then((files) => cacheFiles.filter((path) => !files.includes(path))) .map((path) => this._processFile(file_1.default.TYPE_DELETE, path)); }).catch(err => { if (err && err.code !== 'ENOENT') throw err; }).asCallback(callback);} Box 类是 Hexo 通用的文件处理引擎。它的 process 方法负责扫描指定目录(这里的话扫描的是 source/)、与缓存对比、找出被删除/新增/修改的文件,然后为每个文件调用 _processFile 方法: _processFile(type, path) { if (this._processingFiles[path]) { return bluebird_1.default.resolve(); } this._processingFiles[path] = true; const { base, File, context: ctx } = this; this.emit('processBefore', { type, path }); return bluebird_1.default.reduce(this.processors, (count, processor) => { const params = processor.pattern.match(path); if (!params) return count; const file = new File({ // source is used for filesystem path, keep backslashes on Windows source: (0, path_1.join)(base, path), // path is used for URL path, replace backslashes on Windows path: escapeBackslash(path), params, type }); return Reflect.apply(bluebird_1.default.method(processor.process), ctx, [file]) .thenReturn(count + 1); }, 0).then(count => { if (count) { ctx.log.debug('Processed: %s', (0, picocolors_1.magenta)(path)); } this.emit('processAfter', { type, path }); }).catch(err => { ctx.log.error({ err }, 'Process failed: %s', (0, picocolors_1.magenta)(path)); }).finally(() => { this._processingFiles[path] = false; }).thenReturn(path);} _processFile 则是处理单个文件的核心,它会遍历一个名为 this.processors 的数组,用数组中的每个 processor 的 pattern 与文件路径进行匹配。如果匹配成功,就执行该 processor 的 process 方法。 那么,this.processors 这个关键的数组是从哪里来的呢?这引出了 Processor 的注册机制。 2.2. Processor 扩展 Processor 扩展遵循一个清晰的注册与加载流程。 首先是注册,在 hexo.init() 阶段,Hexo 会加载 /plugins/processor/index.js 文件,该文件负责注册所有内置的 Processor: "use strict";module.exports = (ctx) => { // 1. 从 Hexo 上下文中获取 processor 扩展的注册器实例 const { processor } = ctx.extend; // 2. 定义一个内部的 register 辅助函数 function register(name) { // 2.1. 加载指定 processor 的定义文件 const obj = require(`./${name}`)(ctx); // 2.2. 调用注册器实例的 register 方法 // 将 processor 的 pattern 和 process 函数正式注册到 Hexo 的扩展管理器中 processor.register(obj.pattern, obj.process); } // 3. 为每一个内置的 processor 调用 register 函数 register('asset'); // 静态资源处理器 register('data'); // _data 目录处理器 register('post'); // 文章处理器};//# sourceMappingURL=index.js.map 接着是加载。当 new source_1.default(this) 执行时,Source 类的构造函数会从 Processor 扩展的注册器中,获取所有已注册的 Processor 列表,并赋值给自身的 this.processors 属性。 class Source extends box_1.default { constructor(ctx) { super(ctx, ctx.source_dir); // 从 ctx.extend.processor.list() 获取完整的 processor 列表 this.processors = ctx.extend.processor.list(); }} 至此,调用链完全闭合: init 阶段,/plugins/processor/index.js 将 post 等处理器注册到 hexo.extend.processor。 Source 实例将 hexo.extend.processor 的完整列表复制到自身的 this.processors 数组中。 source.process() 调用 _processFile,_processFile 遍历 this.processors 数组,从而找到了 post 处理器来处理匹配的文件。 2.3. post 处理器 现在我们以 _posts/hello-world.md 为例,看看 post 处理器是如何工作的。 2.3.1. 资格审查 当 hello-world.md 开始要被 Box 引擎处理时,它首先会遇到 post 的资格审查。 post 会通过其 pattern 函数来决定是否处理一个文件。这个函数非常智能,它不仅仅是匹配路径,更是进行了一系列的检查: module.exports = (ctx) => { return { pattern: new hexo_util_1.Pattern(path => { // 1. 临时的文件直接拒绝 if ((0, common_1.isTmpFile)(path)) return; // 2. 文件必须来自 _posts/ 或者 /_drafts 目录 let result; if (path.startsWith(postDir)) { result = { published: true, path: path.substring(postDir.length) }; } else if (path.startsWith(draftDir)) { result = { published: false, path: path.substring(draftDir.length) }; } // 3. 隐藏的文件直接拒绝 if (!result || (0, common_1.isHiddenFile)(result.path)) return; // 4. 判断其可渲染性(ctx.render 会在下一章提起) result.renderable = ctx.render.isRenderable(path) && !(0, common_1.isMatch)(path, ctx.config.skip_render); // 5. 处理文章资源文件夹 // 如果开启了 post_asset_folder,那么只有和默认文章扩展名相同的文件才被认为是可渲染的文章 if (result.renderable && ctx.config.post_asset_folder) { result.renderable = ((0, path_1.extname)(ctx.config.new_post_name) === (0, path_1.extname)(path)); } return result; }), };}; 这个函数返回的 result 对象,会被 Box 附加到 File 实例的 params 属性上,供 process 函数使用。 2.3.2. 分流与处理 post 处理器的 process 函数会读取 file.params.renderable 的值,决定下一步操作: module.exports = (ctx) => { return { process: function postProcessor(file) { // 如果可渲染,就作为文章处理 if (file.params.renderable) { return processPost(ctx, file); } // 否则,如果开启了 post_asset_folder,则作为文章的静态资源处理 else if (ctx.config.post_asset_folder) { return processAsset(ctx, file); } } }} 2.3.3. 精加工与入库 当 process 函数决定一个文件是 可渲染的 文章时,它就会把 file 对象交给 processPost 函数。这个函数使整个 Processor 中最复杂的部分,它的任务是 读取文件、解析并融合所有元数据,最后将结构化的文章数据存入数据库。 整个过程可以分解为以下几个关键步骤: 准备与前置处理 函数首先会进行一些准备工作,并处理几种简单的文件状态。 function processPost(ctx, file) { const Post = ctx.model('Post'); const { path } = file.params; const doc = Post.findOne({ source: file.path }); // 1. 检查数据中是否已存在该文章 const { config } = ctx; const { timezone: timezoneCfg, updated_option, use_slug_as_post_title } = config; let categories, tags; // 2. 根据文件类型进行处理 if (file.type === 'skip' && doc) { return; // 文件未修改,直接跳过 } if (file.type === 'delete') { if (doc) { return doc.remove(); // 文件已删除,从数据库移除 } return; } 异步读取与初步解析 对于 create 或 update 状态的文件,处理正式开始。Hexo 会并行地执行两个异步操作:读取文件内容和获取文件系统状态。 return bluebird_1.default.all([ file.stat(), // 获取文件系统信息,例如创建、修改时间 file.read() // 读取文件内容]).spread((stats, content) => { // 1. 解析 Front-matter const data = (0, hexo_front_matter_1.parse)(content); // 2. 解析文件名 const info = parseFilename(config.new_post_name, path); hexo_front_matter_1.parse(content) 会将文件头部的 YAML Front-matter(--- 开头)解析成一个 JavaScript 对象 data。文件的正文内容会存放在 data._content 属性中: function parse(str, options) { if (typeof str !== 'string') throw new TypeError('str is required!'); const splitData = split(str); const raw = splitData.data; if (!raw) return { _content: str }; let data; if (splitData.separator.startsWith(';')) { data = parseJSON(raw); } else { data = parseYAML(raw, options); } if (!data) return { _content: str }; // Convert timezone Object.keys(data).forEach(key => { const item = data[key]; if (item instanceof Date) { data[key] = new Date(item.getTime() + (item.getTimezoneOffset() * 60 * 1000)); } }); data._content = splitData.content; return data;}exports.parse = parse; parseFilename 函数则会根据我们在 _config.yml 中配置的 new_post_name 格式,从文件名中尝试提取 year、month、day、title 等信息,存放在 info 对象中: function parseFilename(config, path) { config = config.substring(0, config.length - (0, path_1.extname)(config).length); path = path.substring(0, path.length - (0, path_1.extname)(path).length); if (!permalink || permalink.rule !== config) { permalink = new hexo_util_1.Permalink(config, { segments: { year: /(\d{4})/, month: /(\d{2})/, day: /(\d{2})/, i_month: /(\d{1,2})/, i_day: /(\d{1,2})/, hash: /([0-9a-f]{12})/ } }); } const data = permalink.parse(path); if (data) { if (data.title !== undefined) { return data; } return Object.assign(data, { title: (0, hexo_util_1.slugize)(path) }); } return { title: (0, hexo_util_1.slugize)(path) };} 数据融合与标准化 这是 processPost 最核心的部分。它会将上一步得到的 data 和 info,以及 stats 这三个来源的数据进行智能融合,并进行标准化处理,最终形成一篇完整、规范的文章数据。 这个融合过程遵循明确的优先级:Front-matter > 文件名 > 文件系统信息。 元数据确定逻辑: slug 和 source:直接从文件名解析结果或文件路径中获取 title:优先使用 Front-matter 中的 title。如果没有,并且 use_slug_as_post_title 配置为 true,则会使用从文件名中解析出的 slug 作为标题 date: 优先使用 Front-matter 中的 date 其次尝试从文件名中解析(info.year 等) 最后使用文件的创建时间 stats.birthtime updated: 优先使用 Front-matter 里的 其次根据 _config.yml 的 updated_options 配置,可能使用 date 的值或者留空 最后默认使用文件的修改时间 stats.mtime tags 和 categories:函数会进行标准化处理,确保它们最终都是数组格式,并处理了 tag(单数)-> tags(复数)这种别名情况 const keys = Object.keys(info);data.source = file.path;data.raw = content;data.slug = info.title;if (file.params.published) { if (!Object.prototype.hasOwnProperty.call(data, 'published')) data.published = true;}else { data.published = false;}for (let i = 0, len = keys.length; i < len; i++) { const key = keys[i]; if (!preservedKeys[key]) data[key] = info[key];}// use `slug` as `title` of post when `title` is not specified.// https://github.com/hexojs/hexo/issues/5372if (use_slug_as_post_title && !('title' in data)) { data.title = info.title;}if (data.date) { data.date = (0, common_1.toDate)(data.date);}else if (info && info.year && (info.month || info.i_month) && (info.day || info.i_day)) { data.date = new Date(info.year, parseInt(info.month || info.i_month, 10) - 1, parseInt(info.day || info.i_day, 10));}if (data.date) { if (timezoneCfg) data.date = (0, common_1.timezone)(data.date, timezoneCfg);}else { data.date = stats.birthtime;}data.updated = (0, common_1.toDate)(data.updated);if (data.updated) { if (timezoneCfg) data.updated = (0, common_1.timezone)(data.updated, timezoneCfg);}else if (updated_option === 'date') { data.updated = data.date;}else if (updated_option === 'empty') { data.updated = undefined;}else { data.updated = stats.mtime;}if (data.category && !data.categories) { data.categories = data.category; data.category = undefined;}if (data.tag && !data.tags) { data.tags = data.tag; data.tag = undefined;}categories = data.categories || [];tags = data.tags || [];if (!Array.isArray(categories)) categories = [categories];if (!Array.isArray(tags)) tags = [tags];if (data.photo && !data.photos) { data.photos = data.photo; data.photo = undefined;}if (data.photos && !Array.isArray(data.photos)) { data.photos = [data.photos];}if (data.permalink) { data.__permalink = data.permalink; data.permalink = undefined;} 数据库持久化 当所有数据都准备就绪后,便进入了最终的数据库操作阶段。 if (doc) { // 如果在步骤一中找到了记录 if (file.type !== 'update') { ctx.log.warn(`Trying to "create" ${(0, picocolors_1.magenta)(file.path)}, but the file already exists!`); } // 文件已存在,执行更新操作 return doc.replace(data); } // 文件是新增的,执行插入操作 return Post.insert(data);}) 建立关联 数据入库后,工作还没有完全结束。 insert/replace 操作会返回一个 Promise,其结果是处理后的数据库文档 doc。接下来的 .then() 中,Hexo 会完成最后一步:建立模型之间的关联。 .then(doc => bluebird_1.default.all([ doc.setCategories(categories), // 将文章与分类模型关联 doc.setTags(tags), // 将文章与标签模型关联 scanAssetDir(ctx, doc) // 如果开启,处理文章的资源文件夹 ]));} doc.setCategories 和 doc.setTags 方法会处理 categories 和 tags 数组,在 Category、Tag 以及中间关联表中创建或更新记录。这使得我们之后可以轻松地通过 post.tags 或 category.posts 来查询关联数据。 至此,processPost 的全部工作完成。一篇原始的 Markdown 文件,经过这一系列精密的加工,已经变成了一条结构完整、关系清晰的数据库记录。 3. 从 Markdown 到 HTML 上一章,我们看到 post 处理器将 Markdown 源文件加工成了包含 _content(原始 Markdown 文本)的数据库记录。但此时,文章的 content 属性仍然是空的。从 _content 到 content(渲染后的 HTML),是整个生成过程中最关键的「炼金」步骤。 这个转换不是在 source.process() 阶段发生的,而是在稍后的一个特殊时机,由 Filter 机制驱动。 3.1. before_generate 过滤器 在 _generate 方法的核心逻辑中,_runGenerators 执行之前,有一个关键的前置步骤: _generate(options = {}) { if (this._isGenerating) return; const useCache = options.cache; this._isGenerating = true; this.emit('generateBefore'); return this.execFilter('before_generate', null, { context: this }) // 在这里 .then(() => this._routerRefresh(this._runGenerators(), useCache)).then(() => { this.emit('generateAfter'); return this.execFilter('after_generate', null, { context: this }); }).finally(() => { this._isGenerating = false; });} execFilter 方法会执行所有注册在 before_generate 这个钩子上的函数。Hexo 默认在这里注册了一个名为 renderPostFilter 的过滤器,它的任务就是:确保所有文章在生成页面蓝图之前,都已经被渲染成 HTML。 这个过滤器的注册和定义如下: "use strict";module.exports = (ctx) => { const { filter } = ctx.extend; // 注册 render_post.js 到 before_generate 钩子 filter.register('before_generate', require('./render_post'));};//# sourceMappingURL=index.js.map "use strict";var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod };};const bluebird_1 = __importDefault(require("bluebird"));function renderPostFilter() { // 定义一个处理指定模型的函数 const renderPosts = model => { // 1. 找出所有 content 属性为 null 的文章/页面 const posts = model.toArray().filter(post => post.content == null); // 2. 遍历这些未渲染的条目 return bluebird_1.default.map(posts, (post) => { // 2.1. 将原始 Markdown 内容赋值给 content post.content = post._content; // 2.2. 调用核心的渲染方法 return this.post.render(post.full_source, post).then(() => post.save()); }); }; // 并行处理 Post 和 Page 两个模型 return bluebird_1.default.all([ renderPosts(this.model('Post')), renderPosts(this.model('Page')) ]);}module.exports = renderPostFilter;//# sourceMappingURL=render_post.js.map 3.2. post.render this.post.render 是整个内容转换过程的核心。它确保了 Markdown 在转换为 HTML 的过程中,内嵌的特殊标签(例如 Swig 模版标签)不会被破坏,并且能被正确地执行。 那么 this.post.render 从哪里来的?回到 Hexo 实例: const post_1 = __importDefault(require("./post"));this.post = new post_1.default(this); 我们再来深入 Post 类的 render 方法: render(source, data = {}, callback) { const ctx = this.context; const { config } = ctx; const { tag } = ctx.extend; // 文件读取和非文章类型文件的处理逻辑 const ext = data.engine || (source ? (0, path_1.extname)(source) : ''); let promise; if (data.content != null) { promise = bluebird_1.default.resolve(data.content); } else if (source) { promise = (0, hexo_fs_1.readFile)(source); } else { return bluebird_1.default.reject(new Error('No input file or string!')).asCallback(callback); } const isPost = !data.source || ['html', 'htm'].includes(ctx.render.getOutput(data.source)); if (!isPost) { return promise.then(content => { data.content = content; ctx.log.debug('Rendering file: %s', (0, picocolors_1.magenta)(source)); return ctx.render.render({ text: data.content, path: source, engine: data.engine, toString: true }); }).then(content => { data.content = content; return data; }).asCallback(callback); } let disableNunjucks = ext && ctx.render.renderer.get(ext) && !!ctx.render.renderer.get(ext).disableNunjucks; if (typeof data.disableNunjucks === 'boolean') disableNunjucks = data.disableNunjucks; const cacheObj = new PostRenderEscape(); // 1. 创建转义工具实例 return promise.then(content => { // promise 在之前已读取文件内容 data.content = content; // 2. 执行 before_post_render 过滤器 return ctx.execFilter('before_post_render', data, { context: ctx }); }).then(() => { // 3. 转义代码和 Swig 标签 data.content = cacheObj.escapeCodeBlocks(data.content); if (disableNunjucks === false) { data.content = cacheObj.escapeAllSwigTags(data.content); } const options = data.markdown || {}; if (!config.syntax_highlighter) options.highlight = null; ctx.log.debug('Rendering post: %s', (0, picocolors_1.magenta)(source)); // 4. 调用核心渲染器 return ctx.render.render({ text: data.content, path: source, engine: data.engine, toString: true, onRenderEnd(content) { // 5. 回调 // 5.1. 恢复 Swig 标签 data.content = cacheObj.restoreAllSwigTags(content); if (disableNunjucks) return data.content; // 5.2. 执行 Swig 标签 return tag.render(data.content, data); } }, options); }).then(content => { // 6. 恢复代码块 data.content = cacheObj.restoreCodeBlocks(content); // 7. 执行 after_post_render 过滤器 return ctx.execFilter('after_post_render', data, { context: ctx }); }).asCallback(callback);} 这个过程可以分解为七个步骤: 实例化 PostRenderEscape 对象,专门用来「保护」和「恢复」特殊标签 执行一轮 before_post_render 过滤器,允许插件对原始 Markdown 内容进行修改 保护性转义 escapeCodeBlocks 和 escapeAllSwigTags 方法会用正则表达式查找所有的代码块和 Nunjucks / Swig 标签(例如 {% post_link %} 或 {{ post.title }}) 它会将这些找出的内容从字符串中「挖」出来,存入 cacheObj.stored 数组,然后在原文中留下一个无害的占位符 这么做的理由是为了让下一步的 Markdown 渲染器知道,这一块是 Nunjucks 语法,不要把它当做普通文本并转义 调用 ctx.render.render()。它会根据文件扩展名(.md)找到对应的渲染器插件,然后调用其 render 方法,将已经被保护起来的 Markdown 文本转换成 HTML。此时生成的 HTML 中依然包含着占位符 Markdown 渲染器完成工作后,onRenderEnd 回调函数会立刻执行,开始逆向工程 restoreAllSwigTags 方法被调用。它会查找 HTML 中的所有占位符,并用之前存储在 cacheObj.stored 数组中的原始标签替换它们。现在我们得到了一个混合了 HTML 和 Nunjucks 标签的字符串 tag.render() 被调用、用于处理这个混合字符串,执行其中的 {& post_link &} 等标签,并将其替换为最终的 HTML 片段 经过 Nunjucks 渲染后,restoreCodeBlocks 被调用,以同样的方式将代码块占位符恢复成原始的代码块 HTML 所有渲染和模版执行都已完成。这最后一轮 after_post_render 过滤器让插件有机会对最终生成的 HTML 内容进行处理,例如添加 target="_blank"、图片懒加载等 3.3. Render 类 ctx.render 是 Render 类的一个实例,它扮演着渲染任务「总调度员」的角色。它不关心具体如何渲染,只负责找到正确的渲染器并把任务交给它。 class Render { render(data, options, callback) { // 参数处理和文件读取 if (!callback && typeof options === 'function') { callback = options; options = {}; } const ctx = this.context; let ext = ''; let promise; if (!data) return bluebird_1.default.reject(new TypeError('No input file or string!')); if (data.text != null) { promise = bluebird_1.default.resolve(data.text); } else if (!data.path) { return bluebird_1.default.reject(new TypeError('No input file or string!')); } else { promise = (0, hexo_fs_1.readFile)(data.path); } return promise.then(text => { data.text = text; // 1. 获取文件扩展名 ext = data.engine || getExtname(data.path); if (!ext || !this.isRenderable(ext)) return text; // 2. 根据扩展名查找对应的渲染器 const renderer = this.getRenderer(ext); // 3. 执行渲染器 return Reflect.apply(renderer, ctx, [data, options]); }).then(result => { result = toString(result, data); // 4. 检查并执行 onRenderEnd 回调 if (data.onRenderEnd) { return data.onRenderEnd(result); } return result; }).then(result => { // 5. 执行 after_render 过滤器 const output = this.getOutput(ext) || ext; return ctx.execFilter(`after_render:${output}`, result, { context: ctx, args: [data] }); }).asCallback(callback); }}module.exports = Render;//# sourceMappingURL=render.js.map 也就是说,核心渲染是由具体的渲染器插件完成的。 3.3.1. hexo-renderer-marked hexo-renderer-marked 是 Hexo 默认的 Markdown 渲染器,也是 Renderer 类调度的完美范例。它本身不是一个简单的函数,而是一个集配置、扩展和定制于一体的小系统。 module.exports = function(data, options) { const { post_asset_folder, marked: markedCfg, source_dir } = this.config; const { prependRoot, postAsset, dompurify } = markedCfg; const { path, text } = data; marked.defaults.extensions = null; marked.defaults.tokenizer = null; marked.defaults.renderer = null; marked.defaults.hooks = null; marked.defaults.walkTokens = null; // exec filter to extend marked this.execFilterSync('marked:use', marked.use, { context: this }); // exec filter to extend renderer this.execFilterSync('marked:renderer', renderer, { context: this }); // exec filter to extend tokenizer this.execFilterSync('marked:tokenizer', tokenizer, { context: this }); const extensions = []; this.execFilterSync('marked:extensions', extensions, { context: this }); marked.use({ extensions }); let postPath = ''; if (path && post_asset_folder && prependRoot && postAsset) { const Post = this.model('Post'); // Windows compatibility, Post.findOne() requires forward slash const source = path.substring(this.source_dir.length).replace(/\\/g, '/'); const post = Post.findOne({ source }); if (post) { const { source: postSource } = post; postPath = join(source_dir, dirname(postSource), basename(postSource, extname(postSource))); } } let sanitizer = function(html) { return html; }; if (dompurify) { if (createDOMPurify === undefined && JSDOM === undefined) { createDOMPurify = require('dompurify'); JSDOM = require('jsdom').JSDOM; } const window = new JSDOM('').window; const DOMPurify = createDOMPurify(window); let param = {}; if (dompurify !== true) { param = dompurify; } sanitizer = function(html) { return DOMPurify.sanitize(html, param); }; } marked.use({ renderer, tokenizer }); return sanitizer(marked.parse(text, Object.assign({ // headerIds was removed in marked v8.0.0, but we still need it headerIds: true }, markedCfg, options, { postPath, hexo: this, _headingId: {} })));}; 该渲染器用了 marked 库。 它的入口函数接收从 Renderer 类传来的 data 和 options,然后执行以下步骤: 为了保证每次渲染的纯粹性,它首先会清空 marked 库的默认扩展。然后,它立刻通过 this.execFilterSync 触发三个专属于 marked 的过滤器: marked:use marked:renderer marked:tokenizer 允许其他插件来进一步修改和扩展 marked 的行为,体现了 Hexo 插件系统的层次性。 定义一个 renderer 对象,重写 marked 库的默认渲染行为,以实现 Hexo 的特有功能: heading:为标题标签自动生成 id 属性,用于页面内锚点跳转,并添加一个可点击的「链接」图标。因此我们能在 Hexo 文章标题旁看到那个小链接图标 link:根据 _config.yml 中的 external_link 配置、自动为外部链接添加 target="_blank" 和 rel="noopener" 等属性 image:智能处理图片路径,可以根据配置自动在图片路径前加上根目录(root),并处理与文章关联的资源文件夹中的图片 paragraph:增加了一个小功能,可以识别特定的语法(Term<br>: Definition)并将其转换为定义列表(<dl>) const renderer = { // Add id attribute to headings heading({ tokens, depth: level }) { let text = this.parser.parseInline(tokens); const { anchorAlias, headerIds, modifyAnchors, _headingId } = this.options; if (!headerIds) { return `<h${level}>${text}</h${level}>`; } const transformOption = modifyAnchors; let id = anchorId(text, transformOption); const headingId = _headingId; const anchorAliasOpt = anchorAlias && text.startsWith('<a href="#'); if (anchorAliasOpt) { const customAnchor = text.match(rATag)[1]; id = anchorId(customAnchor, transformOption); } // Add a number after id if repeated if (headingId[id]) { id += `-${headingId[id]++}`; } else { headingId[id] = 1; } if (anchorAliasOpt) { text = text.replace(rATag, (str, alias) => { return str.replace(alias, id); }); } // add headerlink return `<h${level} id="${id}"><a href="#${id}" class="headerlink" title="${stripHTML(text)}"></a>${text}</h${level}>`; }, link({ tokens, href, title }) { const text = this.parser.parseInline(tokens); const { external_link, sanitizeUrl, hexo, mangle } = this.options; const { url: urlCfg } = hexo.config; if (sanitizeUrl) { if (href.startsWith('javascript:') || href.startsWith('vbscript:') || href.startsWith('data:')) { href = ''; } } if (mangle) { if (href.startsWith('mailto:')) { const email = href.substring(7); const mangledEmail = mangleEmail(email); href = `mailto:${mangledEmail}`; } } let out = '<a href="'; try { out += encodeURL(href); } catch (e) { out += href; } out += '"'; if (title) { out += ` title="${escape(title)}"`; } if (external_link) { const target = ' target="_blank"'; const noopener = ' rel="noopener"'; const nofollowTag = ' rel="noopener external nofollow noreferrer"'; if (isExternalLink(href, urlCfg, external_link.exclude)) { if (external_link.enable && external_link.nofollow) { out += target + nofollowTag; } else if (external_link.enable) { out += target + noopener; } else if (external_link.nofollow) { out += nofollowTag; } } } out += `>${text}</a>`; return out; }, // Support Basic Description Lists paragraph({ tokens }) { const text = this.parser.parseInline(tokens); const { descriptionLists = true } = this.options; if (descriptionLists && text.includes('<br>:')) { if (rDlSyntax.test(text)) { return text.replace(rDlSyntax, '<dl><dt>$1</dt><dd>$2</dd></dl>'); } } return `<p>${text}</p>\n`; }, // Prepend root to image path image({ href, title, text }) { const { options } = this; const { hexo } = options; const { relative_link } = hexo.config; const { lazyload, figcaption, prependRoot, postPath } = options; if (!/^(#|\/\/|http(s)?:)/.test(href) && !relative_link && prependRoot) { if (!href.startsWith('/') && !href.startsWith('\\') && postPath) { const PostAsset = hexo.model('PostAsset'); // findById requires forward slash const asset = PostAsset.findById(join(postPath, href.replace(/\\/g, '/'))); // asset.path is backward slash in Windows if (asset) href = asset.path.replace(/\\/g, '/'); } href = url_for.call(hexo, href); } let out = `<img src="${encodeURL(href)}"`; if (text) out += ` alt="${escape(text)}"`; if (title) out += ` title="${escape(title)}"`; if (lazyload) out += ' loading="lazy"'; out += '>'; if (figcaption && text) { return `<figure>${out}<figcaption aria-hidden="true">${text}</figcaption></figure>`; } return out; }}; 定制 tokenizer,用于影响 marked 如何解析原始的 Markdown 文本 const tokenizer = { // Support autolink option url(src) { const { autolink } = this.options; if (!autolink) return; // return false to use original url tokenizer return false; }, // Override smartypants inlineText(src) { const { options, rules } = this; const { quotes, smartypants: isSmarty } = options; // https://github.com/markedjs/marked/blob/b6773fca412c339e0cedd56b63f9fa1583cfd372/src/Tokenizer.js#L643-L658 const cap = rules.inline.text.exec(src); if (cap) { let text; if (this.lexer.state.inRawBlock || this.rules.inline.url.exec(src)) { text = cap[0]; } else { text = escape(isSmarty ? smartypants(cap[0], quotes) : cap[0]); } return { type: 'text', raw: cap[0], text }; } }}; inlineText 主要是实现了 smartypants 功能,可以将普通的直引号自动转换成更美观的弯引号,以及将多个连字符转换成破折号 const smartypants = (str, quotes) => { const [openDbl, closeDbl, openSgl, closeSgl] = typeof quotes === 'string' && quotes.length === 4 ? quotes : ['\u201c', '\u201d', '\u2018', '\u2019']; return str // em-dashes .replace(/---/g, '\u2014') // en-dashes .replace(/--/g, '\u2013') // opening singles .replace(/(^|[-\u2014/([{"\s])'/g, '$1' + openSgl) // closing singles & apostrophes .replace(/'/g, closeSgl) // opening doubles .replace(/(^|[-\u2014/([{\u2018\s])"/g, '$1' + openDbl) // closing doubles .replace(/"/g, closeDbl) // ellipses .replace(/\.{3}/g, '\u2026');}; 在准备好所有定制的 renderer 和 tokenizer 之后,插件通过 marked.use() 将它们应用到 marked 实例上。最后调用 marked.parse(),传入原始文本和所有配置,得到最终的 HTML 如果用户开启了 dompurify 选项,它还会在返回结果前对 HTML 进行一次安全过滤,防止恶意的脚本注入 4. 页面的生成 还记得第二章最开始出现的那段代码吗? load(callback) { return (0, load_database_1.default)(this).then(() => { this.log.info('Start processing'); return bluebird_1.default.all([ this.source.process(), this.theme.process() ]); }).then(() => { mergeCtxThemeConfig(this); return this._generate({ cache: false }); }).asCallback(callback);} hexo.load() 方法成功地将所有 source 目录下的源文件处理完毕,并将结构化的数据存入了内存数据库(this.database)。也就是说,我们拥有了所有文章、独立页面、分类和标签的原子数据。但是,一个完整的网站还包含许多由这些原子数据聚合而成的页面,例如:首页的文章列表、归档页、分类列表页等。这些页面在 source 目录中并没有对应的源文件。 那么这些「无源之页」是如何被创建的呢?答案就在 Generator 扩展中。上一章节讲到了 this.source.process(),本章节则重点探讨之后的 _generate() 方法,看 Hexo 是如何调用所有 Generator 插件,并生产出网站所有页面的「蓝图」。 眼尖的朋友应该发现了,我跳过了 this.theme.process()。搭建过个人博客的朋友都知道主题这一大山有多么重要,而这段代码就是调用了主题的处理方法。不过本文并不注重于主题,而是注重于「Markdown 文件如何变成网页」这条主线。之后的文章就会讲一讲主题(挖坑)(说实话 Hexo 开发主题也是一堆坑)。 4.1. _runGenerators 方法解析 Generator 的调用发生在 _generate 方法内部。_generate 会调用 _runGenerators,并将该方法的返回值 —— 一个包含了所有页面蓝图的数组 —— 传递给下一阶段的 _routerRefresh。 _generate(options = {}) { if (this._isGenerating) return; const useCache = options.cache; this._isGenerating = true; this.emit('generateBefore'); // Run before_generate filters return this.execFilter('before_generate', null, { context: this }) .then(() => this._routerRefresh(this._runGenerators(), useCache)).then(() => { this.emit('generateAfter'); // Run after_generate filters return this.execFilter('after_generate', null, { context: this }); }).finally(() => { this._isGenerating = false; });} _runGenerators 的职责非常专一:执行所有已注册的 Generator 插件,并收集它们的返回结果。 _runGenerators() { this.locals.invalidate(); // 1. 准备 Generator 的输入数据(site 对象) const siteLocals = this.locals.toObject(); // 2. 获取所有已注册的 Generator 函数列表 const generators = this.extend.generator.list(); const { log } = this; // 3. 遍历并执行所有的 Generator return bluebird_1.default.map(Object.keys(generators), key => { const generator = generators[key]; log.debug('Generator: %s', (0, picocolors_1.magenta)(key)); // 4. 执行单个 Generator 函数 return Reflect.apply(generator, this, [siteLocals]); }).reduce((result, data) => { // 5. 将所有返回结果合并成一个数组 return data ? result.concat(data) : result; }, []);} 这里的 this.locals 是 Hexo 实例的一个属性。第一篇文章 中,它被我们分析过的 _bindLocals 方法初始化。_bindLocals 将数据库查询函数与 posts, pages, tags, categories 等键名绑定。 而 this.locals.toObject() 方法的作用,就是执行所有这些绑定的函数,从数据库中查询出最新的数据,并组装成一个巨大的 site 对象。这个对象包含了: site.posts:一个 Warehouse 模型集合,包含了所有文章 site.pages:包含了所有独立页面 site.categories 和 site.tags:分别包含了所有分类和标签 site.data:包含了 source/_data 目录下的所有数据 这个 site 对象,就是所有 Generator 赖以生存的「食材库」。 看过 Hexo 官方文档 的朋友或许会回忆起,site 对象实际上就是用于模板渲染的局部变量。 4.2. 页面蓝图 { path, layout, data } Generator 的核心产出是一种标准化的数据结构,我们可以称之为「页面蓝图」。它不是最终的 HTML,而是描述了如何生成一个页面的指令。 每个蓝图对象通常包含三个核心属性: path: String - 页面最终生成的文件路径,例如 archives/index.html layout: String | Array - 渲染这个页面所使用的主题布局文件。例如,'archive' 会对应到主题的 layout/archive.ejs 文件。可以是一个数组,Hexo 会依次查找并使用第一个找到的布局 data: Object - 渲染布局时需要的数据 示例: 一篇文章的蓝图: { "path": "2025/07/23/hello-world/index.html", "layout": "post", "data": { "post": { ...文章数据 } }} 归档页的蓝图: { "path": "archives/index.html", "layout": "archive", "data": { "posts": site.posts, "page": { "current": 1, ... } }} 4.3. Generator 的来源 Hexo 默认注册了几个核心的 Generator 来生成网站的基础部分: post & page:这两个 Generator 负责处理「有源文件」的页面。它们遍历 site.posts 和 site.pages 集合,为每篇文章和每个独立页面生成对应的页面蓝图 archive, category, tag:这三个 Generator 负责处理「无源之页」。它们分别遍历 site.posts, site.categories, site.tags 集合,生成归档页、所有分类页和所有标签页的蓝图 我们可以深入一个具体的 Generator,来了解它是如何利用 site 对象创建页面蓝图的。我们以 archive 生成器为例。 不过在分析之前,有一个关键点需要明确:并非所有核心的生成功能都内置在 Hexo 的主代码库中。Hexo 遵循「保持核心精简」的设计哲学,许多基础功能都是通过 默认插件 提供的。 当我们 hexo init 一个新项目时,package.json 会自动包含 hexo-generator-archive, hexo-generator-category, hexo-generator-tag 等插件。 这意味着: post & page 生成器位于 Hexo 核心代码库(/plugins/generator/)中,负责处理有源文件的页面 archive、category、tag 等生成器,虽然是默认安装,但它们是独立的 npm 包。它们负责处理无源页面 archive 生成器的核心代码可以分解为三个主要步骤: 生成主归档页 Generator 首先会处理根归档页,也就是 /archives/ 目录。 'use strict';const pagination = require('hexo-pagination');const fmtNum = num => num.toString().padStart(2, '0');module.exports = function(locals) { const { config } = this; let archiveDir = config.archive_dir; const paginationDir = config.pagination_dir || 'page'; // 1. 获取所有文章,并根据配置排序 const allPosts = locals.posts.sort(config.archive_generator.order_by || '-date'); const perPage = config.archive_generator.per_page; const result = []; if (!allPosts.length) return; if (archiveDir[archiveDir.length - 1] !== '/') archiveDir += '/'; // 2. 定义一个通用的页面蓝图生成函数 function generate(path, posts, options = {}) { options.archive = true; // 3. 调用 hexo-pagination 工具生成分页数据 result.push(...pagination(path, posts, { perPage, layout: ['archive', 'index'], // 指定布局 format: paginationDir + '/%d/', // 分页路径格式 data: options })); } // 4. 为根归档页调用 generate 函数 generate(archiveDir, allPosts); 按日期对文章进行分组 如果用户在 _config.yml 中开启了 yearly 或 monthly 归档,Generator 接下来会执行一个精巧的数据预处理步骤:将所有文章按年、月、日进行分组。 if (!config.archive_generator.yearly) return result; // 如果没开启,直接返回const posts = {};// 遍历所有文章allPosts.forEach(post => { const date = post.date; const year = date.year(); const month = date.month() + 1; // 创建一个嵌套结构来存储文章 if (!Object.prototype.hasOwnProperty.call(posts, year)) { posts[year] = [ [], [], [], [], [], [], [], [], [], [], [], [], [] ]; } posts[year][0].push(post); // 按年分组 posts[year][month].push(post); // 按月分组 if (config.archive_generator.daily) { const day = date.date(); // 按日分组 if (!Object.prototype.hasOwnProperty.call(posts[year][month], 'day')) { posts[year][month].day = {}; } (posts[year][month].day[day] || (posts[year][month].day[day] = [])).push(post); }}); 这段代码执行完毕后,会得到一个类似 posts[2025][7] 的数据结构,其中存储了 2025 年 7 月的所有文章。 生成年、月、日归档页 最后,代码会遍历上一步中分组好的 posts 对象,为每个时间单位生成对应的归档页面。 const { Query } = this.model('Post'); const years = Object.keys(posts); let year, data, month, monthData, url; // 遍历年份 for (let i = 0, len = years.length; i < len; i++) { year = +years[i]; data = posts[year]; url = archiveDir + year + '/'; if (!data[0].length) continue; // 为该年份生成归档页,例如 /archives/2025/ generate(url, new Query(data[0]), { year }); if (!config.archive_generator.monthly && !config.archive_generator.daily) continue; // 遍历月份 for (month = 1; month <= 12; month++) { monthData = data[month]; if (!monthData.length) continue; if (config.archive_generator.monthly) { // 为该月份生成归档页,例如 /archives/2025/07/ generate(url + fmtNum(month) + '/', new Query(monthData), { year, month }); } if (!config.archive_generator.daily) continue; // 同样逻辑处理按日归档 for (let day = 1; day <= 31; day++) { const dayData = monthData.day[day]; if (!dayData || !dayData.length) continue; generate(url + fmtNum(month) + '/' + fmtNum(day) + '/', new Query(dayData), { year, month, day }); } } } return result;}; 通过嵌套循环,Generator 复用了第一步中定义的 generate 辅助函数,为所有存在文章的年、月、日都创建了对应的分页归档页面蓝图。 最终,这个 Generator 返回一个巨大的 result 数组,其中包含了主归档页、以及所有年、月、日归档页的完整页面蓝图。 5. 路由的建立 _runGenerators 方法为我们产出了一份包含了网站所有页面生成指令的「蓝图清单」。但这些蓝图还只是数据,系统需要一个机制来管理它们,并将它们与最终的文件路径关联起来。 这个机制就是 Hexo 的 Router。 5.1. Router 类:内存中的 public 目录 在分析 _routerRefresh 之前,我们必须先理解它的工作对象:this.route。这个属性是 Router 类的一个实例。 需要明确的是,Hexo 的 Router 不是 一个网络服务器中的路由(用于匹配 URL 请求),而是一个 内存中的键值对集合,可以理解为一个虚拟的文件系统。 键:将要生成的文件路径,例如 archives/index.html 值:该路径对应的文件内容 Router 类(位于 /lib/hexo/router.js)提供了一系列方法来管理这个集合,核心方法包括 list()、get(path)、set(path, data) 和 remove(path)。 class Router extends events_1.EventEmitter { constructor() { super(); this.routes = {}; // 核心数据结构 } list() {} format(path) {} get(path) {} isModified(path) {} set(path, data) {} remove(path) {}} _routerRefresh 的核心任务便是清空并重新填充这个 this.routes 对象。 5.2. _routerRefresh _routerRefresh 方法接收 _runGenerators 返回的蓝图数组作为输入、遍历这个数组、为每一个蓝图在 this.route 中创建一条对应的记录。 _routerRefresh(runningGenerators, useCache) { const { route } = this; const routeList = route.list(); // 1. 获取旧的路由列表,用于后续对比 const Locals = this._generateLocals(); // 2. 准备一个 Locals 类的构造器 Locals.prototype.cache = useCache; // 3. 遍历所有由 Generator 生成的页面蓝图 return runningGenerators.map((generatorResult) => { if (typeof generatorResult !== 'object' || generatorResult.path == null) return undefined; const path = route.format(generatorResult.path); const { data, layout } = generatorResult; // 4. 根据蓝图是否有 layout 属性,进行分流处理 if (!layout) { // 4.1. 没有 layout:视为静态资源 route.set(path, data); return path; } // 4.2. 有 layout:视为待渲染页面 // 这里的 createLoadThemeRoute 是用于将主题的布局文件和页面的具体数据打包成一个待执行的渲染任务、通过 route.set 存入路由系统的 return this.execFilter('template_locals', new Locals(path, data), { context: this }) .then(locals => { route.set(path, createLoadThemeRoute(generatorResult, locals, this)); }) .thenReturn(path); }).then(newRouteList => { // 5. 刷新操作:移除所有过时的路由 for (let i = 0, len = routeList.length; i < len; i++) { const item = routeList[i]; if (!newRouteList.includes(item)) { route.remove(item); } } });} _generateLocals 方法见 这里。 这个方法的逻辑非常清晰,但要真正理解,我们需要回到 Router 类。 5.3. Router 类深度解析 set 方法负责将内容存入路由。它的实现非常巧妙,能够区分处理不同类型的数据。 set(path, data) { if (typeof path !== 'string') throw new TypeError('path must be a string!'); if (data == null) throw new TypeError('data is required!'); let obj; if (typeof data === 'object' && data.data != null) { obj = data; } else { obj = { data, modified: true }; } // 检查 data 是否为函数 if (typeof obj.data === 'function') { // 使用 bluebird 将其包装成一个标准的 Promise-returning 函数 if (obj.data.length) { // 有回调参数的函数 obj.data = bluebird_1.default.promisify(obj.data); } else { // 无参数的函数 obj.data = bluebird_1.default.method(obj.data); } } path = this.format(path); // 格式化路径 this.routes[path] = { data: obj.data, modified: obj.modified == null ? true : obj.modified }; this.emit('update', path); return this;} set 方法的逻辑揭示了路由内容的两种形态: 成品(静态资源):如果传入的 data 是 Buffer 或字符串(比如一张图片的内容),它会被直接存入 this.routes 半成品(待渲染页面):如果传入的 data 是一个函数(由 createLoadThemeRoute 创建的那个),set 方法会用 bluebird 将其包装成一个标准的异步函数。存入路由的,是这个被包装后的函数本身 这种设计就是 Hexo 高性能的 延迟执行(Lazy Evaluation) 策略的核心。它避免了在生成路由时就渲染所有页面,而是将渲染工作推迟到最后一刻。 如果说 set 方法是把「速冻料理包」(待执行的渲染函数)放入冰箱,那么 get 方法就是从冰箱里取出料理包并准备加热。 get 方法本身很简单,但它返回的东西却大有文章: get(path) { if (typeof path !== 'string') throw new TypeError('path must be a string!'); const data = this.routes[this.format(path)]; if (data == null) return; // 返回一个 RouteStream 实例 return new RouteStream(data);} 它并不直接返回值,而是将从 this.routes 中取出的数据(无论是 Buffer 还是那个待执行的函数)传递给 RouteStream 类的构造函数,并返回一个 RouteStream 实例。 RouteStream 是一个 Node.JS 的可读流(Readable Stream)。它的魔法藏在 _read() 方法中: class RouteStream extends Readable { constructor(data) { super({ objectMode: true }); this._data = data.data; this._ended = false; this.modified = data.modified; } _toBuffer(data) { if (data instanceof Buffer) { return data; } if (typeof data === 'object') { data = JSON.stringify(data); } if (typeof data === 'string') { return Buffer.from(data); } return null; } _read() { const data = this._data; // 情况一:内容是“成品”(Buffer、String 等) if (typeof data !== 'function') { const bufferData = this._toBuffer(data); if (bufferData) { this.push(bufferData); // 直接将数据推入流中 } this.push(null); // 结束流 return; } // 情况二:内容是“半成品”(待执行函数) if (this._ended) return false; this._ended = true; // 执行函数 data().then(data => { if (data instanceof stream_1.default && data.readable) { data.on('data', d => { this.push(d); }); data.on('end', () => { this.push(null); }); data.on('error', err => { this.emit('error', err); }); } else { const bufferData = this._toBuffer(data); if (bufferData) { this.push(bufferData); // 将渲染后的 HTML 推入流中 } this.push(null); // 结束流 } }).catch(err => { this.emit('error', err); this.push(null); }); }} 之后我们讲到「文件写入」时,模块会从这个流中读取数据,_read() 方法会被触发。它会检查 this._data: 如果是普通数据,就直接推入流 如果是一个函数,它就会执行这个函数(data())。渲染过程在这一刻才真正发生。函数执行完毕后(.then()),_read 会将返回的 HTML 结果推入流中,供文件写入模块消费 6. 从内存到硬盘 经过前面四个阶段的精密运作,Hexo 已经在内存中构建了一个完整的虚拟网站(this.route)。现在,我们来到了这趟旅程的终点站:将这个虚拟世界实体化,写入到硬盘的 public 文件夹中。 这个最终的执行任务,由 generate 命令的总指挥 —— Generater 类 —— 来完成。 6.1. 怎么又是你 Generator 类 回顾第一章,generateConsole 函数在最开始就实例化了 Generater 类: function generateConsole(args = {}) { const generator = new Generater(this, args); // 我在这儿 if (generator.watch) { return generator.execWatch(); } return this.load().then(() => generator.firstGenerate()).then(() => { if (generator.deploy) { return generator.execDeploy(); } });}module.exports = generateConsole; generator.firstGenerate() 就是文件写入流程的入口。这个方法负责比对文件差异、创建任务队列,并最终调用 writeFile 和 deleteFile 来同步 public 目录。 6.2. firstGenerate firstGenerate 方法做的第一件大事,不是写入,而是 比对,以确定一份精准的「施工清单」。 firstGenerate() { const { concurrency } = this; const { route, log } = this.context; const publicDir = this.context.public_dir; const Cache = this.context.model('Cache'); const interval = (0, pretty_hrtime_1.default)(process.hrtime(this.start)); log.info('Files loaded in %s', (0, picocolors_1.cyan)(interval)); this.start = process.hrtime(); // 确保 public 文件夹存在 return (0, hexo_fs_1.stat)(publicDir).then(stats => { if (!stats.isDirectory()) { throw new Error(`${(0, picocolors_1.magenta)((0, tildify_1.default)(publicDir))} is not a directory`); } }).catch(err => { if (err && err.code === 'ENOENT') { return (0, hexo_fs_1.mkdirs)(publicDir); } throw err; }).then(() => { const task = (fn, path) => () => fn.call(this, path); const doTask = fn => fn(); const routeList = route.list(); // 1. 获取本次需要生成的文件清单 const publicFiles = Cache.filter(item => item._id.startsWith('public/')).map(item => item._id.substring(7)); // 2. 获取上次已生成的文件清单 // 3. 生成任务队列 const tasks = publicFiles.filter(path => !routeList.includes(path)) .map(path => task(this.deleteFile, path)) // 需要删除的文件 .concat(routeList.map(path => task(this.generateFile, path))); // 需要生成/更新的文件 // 4. 并发执行所有任务 return bluebird_1.default.all(bluebird_1.default.map(tasks, doTask, { concurrency: parseFloat(concurrency || 'Infinity') })); }).then(result => { const interval = (0, pretty_hrtime_1.default)(process.hrtime(this.start)); const count = result.filter(Boolean).length; log.info('%d files generated in %s', count.toString(), (0, picocolors_1.cyan)(interval)); });} 这里的逻辑非常清晰: 获取新蓝图:从 this.context.route.list() 获取我们在第四章构建的内存虚拟网站的文件列表 获取旧清单:从 Cache 模型中读取上次生成时写入 public/ 的所有文件记录 计算差异并生成任务: 如果一个文件在「旧清单」里,但不在「新蓝图」里,就为它创建一个 deleteFile 任务 所有「新蓝图」里的文件,都为它们创建一个 generateFile 任务 并发执行:使用 bluebird.map 并发处理所有任务,以提高生成效率 6.3. 触发延迟执行 writeFile 方法是整个流程中最关键的「临门一脚」,它负责触发第四章中我们埋下的「延迟执行」机制。 writeFile(path, force) { const { route, log } = this.context; const publicDir = this.context.public_dir; const Cache = this.context.model('Cache'); // 1. 从路由中获取内容,返回的是一个 RouteStream 实例 const dataStream = this.wrapDataStream(route.get(path)); const buffers = []; const hasher = (0, hexo_util_1.createSha1Hash)(); const finishedPromise = new bluebird_1.default((resolve, reject) => { dataStream.once('error', reject); dataStream.once('end', resolve); }); // 2. 消费这个流 dataStream.on('data', chunk => { buffers.push(chunk); hasher.update(chunk); }); 当我们开始监听 data 事件,也就是开始从流中「拉取」数据时,RouteStream 内部的 _read() 方法就会被触发。 如果这个路由的内容是一个「待执行的渲染函数」,这个函数在此时此刻才会被执行,渲染出最终的 HTML,然后将结果推入流中,被这里的 data 事件监听器接收。 6.4. 哈希值对比 在 writeFile 方法的后半部分,还隐藏着 Hexo 再次生成时速度飞快的秘密 —— 增量生成。 return finishedPromise.then(() => { const dest = (0, path_1.join)(publicDir, path); const cacheId = `public/${path}`; const cache = Cache.findById(cacheId); // 1. 查找上一次的缓存 const hash = hasher.digest('hex'); // 2. 拿到本次内容的哈希值 // 3. 如果哈希值未变,且不是强制生成,则跳过 if (!force && cache && cache.hash === hash) { return; } // 4. 哈希值变了,才执行真正的写入操作 return Cache.save({ _id: cacheId, hash }).then(() => (0, hexo_fs_1.writeFile)(dest, Buffer.concat(buffers))).then(() => { log.info('Generated: %s', (0, picocolors_1.magenta)(path)); return true; }); });} 在将文件内容写入磁盘之前,writeFile 会计算出内容的 SHA1 哈希值,并与缓存中的旧哈希值进行对比。如果两者相同,意味着文件内容没有变化,Hexo 就会聪明地跳过这次文件写入操作,从而极大地提升了二次生成的效率。 7. 总结 现在,让我们回到最初的起点,以 _posts/hello-world.md 的视角,快速回顾它在 hexo generate 命令下经历的完整旅程: 我们在终端敲下回车,Console 扩展接收到 generate 命令 Box 引擎扫描 source 目录,发现了 hello-world.md。post 处理器接管了它,解析其文件名和 Front-matter,将标题、日期等元数据连同原始 Markdown 内容(_content)一起,存入了数据库的 Post 模型中 在生成页面之前,before_generate 过滤器被触发。renderPostFilter 找到了数据库中 content 字段尚为空的 hello-world.md,并调用 post.render 对其进行「炼金」: post.render 暂时将文中的 Nunjucks 标签(如果有的话)替换为占位符 ctx.render 调度 hexo-renderer-marked 将 Markdown 转换为 HTML 在 onRenderEnd 回调中,post.render 恢复 Nunjucks 标签并立刻执行它们。最终,一篇完整的 HTML 被存入数据库记录的 content 字段 _runGenerators 开始执行。post 生成器遍历数据库,找到了我们已渲染好的 hello-world.md,为它创建了一张包含最终路径、主题布局(layout: 'post')和所有页面数据的「页面蓝图」 _routerRefresh 接收到这张蓝图。它没有立即渲染整个页面,而是创建了一个「待办任务」—— 一个包含了「使用 post 布局」和「填充 hello-world.md 内容」指令的函数,并将这个任务存入了内存中的 Router 里,键名为最终的文件路径 posts/hello-world/index.html Generater 类的 firstGenerate 方法开始工作。它对比 Router 和 public 目录的缓存,确定 posts/hello-world/index.html 是一个需要生成的文件,并为其创建了一个 writeFile 任务 当 writeFile 任务执行时,它向 Router 请求 posts/hello-world/index.html 的内容。RouteStream 机制被触发,直到这一刻,第五步中创建的那个「待办任务」才被真正执行。它读取主题的 post 布局文件,将 hello-world.md 的 content(HTML)填充进去,生成了包含页头、页脚的完整页面 HTML 最终的 HTML 内容通过流被写入 public/posts/hello-world/index.html 文件。hello-world.md 的旅程至此结束,它已成为网站上一个可被访问的真实页面

2025/7/24
articleCard.readMore

用 JavaScript 自制 GameBoy 模拟器(上)

近期有些百无聊赖。因为工作,先前的个人项目全都不得不暂时搁置。为了找点乐子,我决定做一些与全栈开发不同的事情 (主要是最近玩宝可梦玩的有点多)。 这期的文章参考了 Imre Nazar 在 2010 年写的一系列关于 用 JavaScript 实现 GameBoy 模拟器 的教程。虽然这个教程有些年头了,但还是提供了一个很好的起点和思路。自然,这期文章不会直接去 Ctrl C + V 他的实现,而是会使用更现代的 ES6 语法。 前言 要模拟一台 GameBoy,我们至少需要模拟以下核心组件: CPU(Z80 兼容处理器),也就是「大脑」,负责执行游戏代码 内存管理单元(MMU),用于处理内存的读写,包括 ROM、RAM 和各种硬件寄存器 图形处理单元(PPU),负责渲染游戏画面 输入设备,处理用户按键操作 定时器,提供精确的时间控制 声音处理单元,生成游戏音效和音乐 1. 模拟 Z80 CPU GameBoy 的 CPU 是一个修改过的 Zilog Z80 处理器。要模拟它,就得理解它的工作方式。 核心概念很简单:取指 → 解码 → 执行循环。 取指:从内存中获取下一条指令 解码:解析指令的含义 执行:执行指令指定的操作 这个循环在 GameBoy 上电后就开始运行,直到关机。为了跟踪程序执行到哪里,CPU 会使用一个特殊的寄存器 —— 程序计数器。每当一条指令被取出后,程序计数器就会根据指令的长度向前移动,指向下一条要执行的指令。 Z80 CPU 是一个 8 位芯片,意味着它一次可以处理一个字节的数据。它也能访问多达 6,5536 字节的内存空间,程序代码和普通数据都被存储在同一个内存地址空间中,而一条指令的长度可以在 1 到 3 个字节之间。 除了程序计数器,Z80 CPU 还有一组内存寄存器,用于存储数据和执行计算: 8 位通用寄存器(A、B、C、D、E、H、L),每个可以存储一个字节(0 到 255)的值。大多数 Z80 指令都围绕着操作这些寄存器,例如将内存中的值加载到寄存器中,或者对寄存器中的值进行算术运算 标志寄存器(F)是一个特殊的 8 位寄存器,其中每个位都代表一个「标志」,用于存储上一次运算的结果状态 栈指针(SP)是一个 16 位寄存器,用于指向内存中的「栈顶」位置 栈是一种后进先出的数据结构 CPU 是什么?(用大白话说) 不要被「中央处理器」这个名字吓到。 把 CPU 想象成一个超快的计算器工人: 他有一张小纸条(寄存器),记录当前的数字 他有一本操作手册(指令集),告诉他怎么计算 他一次只能做一件事,但做得很快 假设有一个指令是「把 A 和 B 加起来」,那么对于这个工人而言就是: 看看 A 纸条(3) 看看 B 纸条(5) 算出结果(8) 写到 A 纸条上 基本上就是一个只会加减乘除但超级勤快的员工 基于以上理解,我们的 Z80 CPU 模拟器需要包含以下核心组件: 内部状态,需要保存所有寄存器的当前值、执行上一条指令所花费的时间,以及 CPU 总共运行了多长时间 指令模拟函数 指令映射表 内存接口 1.1. CPU 骨架 下面是我们 GameBoyCPU 类的初步骨架,包含了 CPU 的时钟系统和所有重要的寄存器: /** * GameBoy Z80 CPU 类 */class GameBoyCPU { constructor() { // 时钟系统,跟踪 CPU 执行时间 this.clock = { m: 0, // 机器周期计数器(主时钟) t: 0, // 时钟周期计数器,用于精确计时 }; // CPU 寄存器组 this.registers = { // === 8位通用寄存器 === a: 0, // 累加器,主要用于算术运算 // BC寄存器对(可作为16位使用) b: 0, c: 0, // DE寄存器对(同上) d: 0, e: 0, // HL寄存器对(同上;常用作内存指针) h: 0, // 高位 l: 0, // 低位 f: 0, // 标志寄存器,存储运算结果的状态标志 // === 16位专用寄存器 === pc: 0, // 程序计数器,指向下一条要执行的指令 sp: 0, // 栈指针,指向栈顶位置 // === 指令执行时间记录 === m: 0, t: 0, }; }} CPU 的时钟系统是用来精确跟踪模拟时间的,m 代表机器周期,t 代表时钟周期,两者之间存在固定关系。 1.2. 标志寄存器与基本指令 标志寄存器是 CPU 运算中一个非常关键的部分,它会根据上一条指令的执行结果自动设置某些位。在 GameBoy 的 Z80 CPU 中,有四个重要的标志位: 零标志(0x80):如果上一次运算的结果为 0,则设置此位 减法标志(0x40):如果上一次运算是减法操作,则设置此位 半进位标志(0x20):如果上一次运算在字节的低 4 位发生了溢出(即结果的第 3 位向第 4 位进位),则设置此位 进位标志(0x10):如果上一次加法运算结果超过 255(8 位最大值),或者减法运算结果低于 0(发生借位),则设置此位 看不懂的话…… 想象你刚做完一道数学题,你的大脑会自动记住一些「状态」: 「咦,答案是 0?」→ 零标志 「我刚才是在做减法吗?」→ 减法标志 「有没有进位?」→ 进位标志 GameBoy 的 CPU 也是如此。每次计算完,它都会在标志寄存器里记下这些「感想」。 为什么需要这些标志?因为后面的指令可能会问:「上次计算结果是 0 吗?如果是的话,跳转到别的地方。」 为了更好地管理这些标志位,我们首先定义一个常量对象 CPU_FLAGS: /** * 标志位常量定义 */const CPU_FLAGS = { ZERO: 0x80, OPERATION: 0x40, HALF_CARRY: 0x20, CARRY: 0x10,}; 接着,我们为 GameBoyCPU 类添加 reset、setFlag 和 getFlag 方法: reset 方法用于将 CPU 的所有寄存器和时钟状态复位到初始值,这对于模拟器启动或重新加载游戏非常有用 setFlag 和 getFlag 则分别用于设置和检查标志位,方便我们根据运算结果来操作标志寄存器 f // ... 之前的 constructor .../** * CPU 重置,将所有寄存器和时钟复位为初始状态 */reset() { // 重置所有8位寄存器 this.registers.a = 0; this.registers.b = 0; this.registers.c = 0; this.registers.d = 0; this.registers.e = 0; this.registers.h = 0; this.registers.l = 0; this.registers.f = 0; // 重置16位寄存器 this.registers.pc = 0; // 程序从地址0开始执行 this.registers.sp = 0; // 重置时钟 this.clock.m = 0; this.clock.t = 0; console.log('主人~ CPU 已重置到初始状态喵!');}/** * 设置标志位 * @param {number} flag 要设置的标志位 * @param {boolean} condition 是否设置该标志位 */setFlag(flag, condition) { if(condition) { this.registers.f |= flag; // 设置位:通过按位或操作将指定标志位设置为 1 } else { // 否则就清除位:通过按位与操作与指定标志位的补码,将其设置为 0 this.registers.f &= ~flag; }}/** * 检查标志位是否设置 * @param {number} flag 要检查的标志位 */getFlag(flag) { return (this.registers.f & flag) !== 0;} 为了演示 CPU 如何执行指令并影响寄存器和标志位,我们来模拟几个基本的 Z80 指令: ADD A, E(加法指令):将寄存器 e 的值加到寄存器 a 中,结果存回 a。这个函数需要更新 a 寄存器的值,并根据结果设置零标志和进位标志。 注意,我们将结果限制在 8 位范围内(&= 255),并更新指令执行所花费的机器周期 m 和时钟周期 t。 /** * 将 E 寄存器的值加到 A 寄存器 * 算术指令用:ADD A, E */addRegisterE() { this.registers.a += this.registers.e; // 清除所有标志位并准备设置新的 this.registers.f = 0; // 检查零标志和进位标志 this.setFlag(CPU_FLAGS.ZERO, !(this.registers.a & 255)); // 结果为0则设置零标志 this.setFlag(CPU_FLAGS.CARRY, this.registers.a > 255); // 结果溢出255则设置进位标志 // 将结果限制为 8 位 this.registers.a &= 255; // 设置指令执行时间(1 机器周期,4 时钟周期) this.registers.m = 1; this.registers.t = 4;} CP A, B(比较指令):将寄存器 a 的值与寄存器 b 的值进行比较。这个指令实际上是执行 a - b 的操作,但不保存结果,只根据结果设置标志位。我们需要设置减法标志,并根据比较结果设置零标志(如果 a == b)和进位标志(如果 a < b)。 /** * 比较 B 寄存器和 A 寄存器 * 用于条件判断和循环控制:CP A, B */compareRegisterB() { // 创建 A 的副本并模拟 A - B let result = this.registers.a; result -= this.registers.b; // 设置减法标志 this.registers.f = CPU_FLAGS.OPERATION; // 检查 A 是否等于 B(结果为 0)以及 A 是否小于 B(结果为负,产生借位) this.setFlag(CPU_FLAGS.ZERO, !(result & 255)); // 结果为0则设置零标志 this.setFlag(CPU_FLAGS.CARRY, result < 0); // 结果为负(下溢)则设置进位标志 // 设置指令执行时间(1 机器周期,4 时钟周期) this.registers.m = 1; this.registers.t = 4;} NOP(无操作指令):这个指令不做任何事情,仅仅消耗 CPU 周期,用来延时或指令对齐。 /** * 无操作指令,不执行任何操作只是浪费时间 * 延时或指令对齐用:NOP */noOperation() { this.registers.m = 1; this.registers.t = 4;} 最后弄个调试工具,要打印出当前所有寄存器的十六进制值、标志位的状态以及时钟技术。 /** * 获取当前 CPU 状态的字符串表示,用于调试 */getStatusString() { const r = this.registers; return `CPU状态: 寄存器: A=${r.a.toString(16).padStart(2,'0')} B=${r.b.toString(16).padStart(2,'0')} C=${r.c.toString(16).padStart(2,'0')} D=${r.d.toString(16).padStart(2,'0')} E=${r.e.toString(16).padStart(2,'0')} H=${r.h.toString(16).padStart(2,'0')} L=${r.l.toString(16).padStart(2,'0')} 标志位: F=${r.f.toString(16).padStart(2,'0')} [Z:${this.getFlag(CPU_FLAGS.ZERO)?1:0} N:${this.getFlag(CPU_FLAGS.OPERATION)?1:0} H:${this.getFlag(CPU_FLAGS.HALF_CARRY)?1:0} C:${this.getFlag(CPU_FLAGS.CARRY)?1:0}] PC=${r.pc.toString(16).padStart(4,'0')} SP=${r.sp.toString(16).padStart(4,'0')} 时钟: M=${this.clock.m} T=${this.clock.t}`;} 1.3. 完整指令集与执行循环 在前面我们实现了几个示例指令,但真正的 GameBoy CPU 需要支持完整的 Z80 指令集。现代模拟器的核心是建立一个高效的「取值 → 解码 → 执行」循环,这个循环每秒要执行数百万次。 /** * 指令时序常量(T 周期) */const INSTRUCTION_TIMINGS = { // 基础指令时序 NOP: 4, // 0x00: NOP LD_BC_nn: 12, // 0x01: LD BC,nn LD_MEM_BC_A: 8, // 0x02: LD (BC),A INC_BC: 8, // 0x03: INC BC INC_B: 4, // 0x04: INC B DEC_B: 4, // 0x05: DEC B LD_B_n: 8, // 0x06: LD B,n // 跳转指令 JR_s8: 12, // 0x18: JR s8 JR_NZ_s8: 8, // 0x20: JR NZ,s8 (8 if not taken, 12 if taken) JR_Z_s8: 8, // 0x28: JR Z,s8 // 算术指令 ADD_A_r: 4, // 0x80-0x87: ADD A,r SUB_r: 4, // 0x90-0x97: SUB r AND_r: 4, // 0xA0-0xA7: AND r CP_r: 4, // 0xB8-0xBF: CP r // 其他 HALT: 4, // 0x76: HALT RET: 16, // 0xC9: RET}; 指令映射是现代模拟器的核心技术。我们创建了一个 256 元素的数组,每个元素对应一个操作码(opcode),直接指向对应的函数。这避免了复杂的 switch-case 语句,大幅提升执行效率: /** * 构建指令映射表 */buildInstructionMap() { const map = new Array(256); // 基础指令集 map[0x00] = this.nop.bind(this); // NOP map[0x01] = this.ld_bc_nn.bind(this); // LD BC,nn map[0x02] = this.ld_mem_bc_a.bind(this); // LD (BC),A map[0x03] = this.inc_bc.bind(this); // INC BC map[0x04] = this.inc_b.bind(this); // INC B map[0x05] = this.dec_b.bind(this); // DEC B map[0x06] = this.ld_b_n.bind(this); // LD B,n // 跳转指令 map[0x18] = this.jr_s8.bind(this); // JR s8 map[0x20] = this.jr_nz_s8.bind(this); // JR NZ,s8 map[0x28] = this.jr_z_s8.bind(this); // JR Z,s8 // 算术指令 - ADD A,r(批量注册) for (let i = 0x80; i <= 0x87; i++) { map[i] = this.add_a_r.bind(this, i & 0x07); } // 比较指令 - CP r(批量注册) for (let i = 0xB8; i <= 0xBF; i++) { map[i] = this.cp_r.bind(this, i & 0x07); } // 填充未实现的指令 for (let i = 0; i < 256; i++) { if (!map[i]) { map[i] = this.unimplemented.bind(this, i); } } return map;} CPU 执行的核心是 step() 方法。每次调用它会执行一条指令,这个方法每秒会被调用数百万次: /** * 执行单条指令 */step() { if (this.halted || this.stopped) { // CPU 处于暂停状态,只更新时钟 this.registers.t = 4; this.registers.m = 1; this.updateClocks(); return; } if (!this.mmu) { throw new Error('CPU 未连接到 MMU'); } try { // 取指令:从 PC 指向的内存地址读取操作码 const opcode = this.mmu.readByte(this.registers.pc); this.registers.pc = (this.registers.pc + 1) & 0xFFFF; // 记录指令(用于调试) this.stats.lastInstruction = opcode; // 解码并执行:直接通过映射表调用对应函数 this.instructionMap[opcode](); // 更新统计 this.stats.instructionsExecuted++; this.stats.lastCycles = this.registers.t; this.stats.totalCycles += this.registers.t; // 更新时钟 this.updateClocks(); } catch (error) { console.error(`❌ CPU 执行错误 PC=0x${this.registers.pc.toString(16).padStart(4, '0')}:`, error); throw error; }} 为了简化指令实现,我们提供了一组辅助方法来处理常见的寄存器操作: /** * 获取寄存器值(按编号) */getRegisterValue(regNum) { switch (regNum) { case 0: return this.registers.b; case 1: return this.registers.c; case 2: return this.registers.d; case 3: return this.registers.e; case 4: return this.registers.h; case 5: return this.registers.l; case 6: return this.mmu.readByte(this.getHL()); // (HL) case 7: return this.registers.a; default: return 0; }}/** * 获取 16 位寄存器组合 */getBC() { return (this.registers.b << 8) | this.registers.c; }getDE() { return (this.registers.d << 8) | this.registers.e; }getHL() { return (this.registers.h << 8) | this.registers.l; }setBC(value) { this.registers.b = (value >> 8) & 0xFF; this.registers.c = value & 0xFF;} 1.4. 导出 在文件的最下方、GameBoyCPU 类的外部添加导出代码: if (typeof module !== 'undefined' && module.exports) { // Node.js 环境导出 module.exports = { GameBoyCPU, CPU_FLAGS, INSTRUCTION_TIMINGS };} else if (typeof window !== 'undefined') { // 浏览器环境导出到全局对象 window.GameBoyCPU = GameBoyCPU; window.CPU_FLAGS = CPU_FLAGS; window.INSTRUCTION_TIMINGS = INSTRUCTION_TIMINGS;} 2. 模拟内存管理单元 我们的 GameBoy CPU 骨架虽然能够执行指令并管理内存寄存器,但是一个没有内存的 CPU 就跟一个没有书的图书馆差不多,光有管理员也没啥用。CPU 必需能够与外部内存交互,才能读取程序代码、存取数据,并与各种硬件组件通信。 这就是 内存管理单元(MMU)的作用。MMU 负责管理 GameBoy 的整个 64KB 的地址空间,并将 CPU 的内存访问请求路由到正确的物理内存区域。 为什么需要 MMU?(大白话 + 1) 想象你在管理一个大仓库,有多大呢?GameBoy 的 64KB 内存相当于有 65536 个格子,你的大仓库也有这 65536 个格子: 有些格子放游戏程序(ROM) 有些格子放临时数据(RAM) 有些格子放图片数据(VRAM) 有些格子是控制按钮(I / O 寄存器) CPU 依然是那个工人。假设他想要 0x8000 地址的数据。 如果没有 MMU 的话,CPU 就得自己跑到 0x8000 格子、搞清楚这个格子到底存的是什么、自行处理各种复杂情况。 有了 MMU 的话,CPU 只需要说他要 0x8000 的数据,MMU 就会: 「0x8000 是图片数据区域。」 「这个区域的数据在 VRAM 里。」 「给你!」 这样看,MMU 就像是一个很靠谱的仓库管理员。 2.1. GameBoy 内存映射 与现代计算机复杂的内存管理不同,GameBoy 的内存映射相对直观,但仍然包含了多个不同功能的区域。理解这个内存映射对于正确模拟 GameBoy 至关重要。 GameBoy 的 64KB 地址空间被划分为以下主要区域: 0x0000 - 0x3FFF:卡带 ROM 银行 0 这是游戏卡带程序的第一个 16KB 区域,始终可访问。 0x0000 - 0x00FF:BIOS:GameBoy 启动时,CPU 从 0x0000 地址开始执行 BIOS 代码。一旦 BIOS 运行完毕,这片区域就会被卡带 ROM 覆盖,不再可访问。 0x0100 - 0x014F:卡带头部:这部分包含游戏的名称、制造商、ROM / RAM 大小等关键信息。 0x4000 - 0x7FFF:卡带 ROM 其他银行 对于大于 32KB 的游戏,卡带会包含多个 16KB 的 ROM 银行。MMU 需要通过 内存银行控制器(MBC)来切换这些银行,以便 CPU 能够访问整个游戏程序。对于 32KB 或更小的游戏,这个区域也直接是 ROM 的一部分,无需银行切换。 0x8000 - 0x9FFF:视频 RAM 存储用于渲染游戏背景和精灵图形数据。这部分内存对 CPU 可读写,但主要由图形处理单元(GPU)使用。 0xA000 - 0xBFFF:卡带外部 RAM 部分游戏卡带会包含额外的可读写内存,用于保存游戏进度或临时数据。 0xC000 - 0xDFFF:工作 RAM 这是 GameBoy 内部的主工作内存,CPU 可以自由读写,用于存储程序变量、栈等。 0xE000 - 0xFDFF:工作 RAM 镜像 由于硬件接线的设计,工作 RAM 在内存映射中有一个完全相同的镜像区域。这意味着对 0xE000-0xFDFF 的读写实际上是对 0xC000-0xDFFF 的读写。 0xFE00 - 0xFE9F:精灵属性表 存储屏幕上所有精灵(例如角色、敌人)的位置、大小、图形数据索引等属性信息。 0xFF00 - 0xFF7F:内存映射 I / O 寄存器 这个区域包含了控制 GameBoy 各个硬件子系统(如图形、声音、定时器、输入)的寄存器。CPU 通过读写这些地址来控制硬件行为。 0xFF80 - 0xFFFF:零页 RAM 又称「高速 RAM」,这片区域是 GameBoy 最顶部的内存,CPU 访问它们的速度最快。虽然地址很高,但因其在汇编编程中的常用性,常被称为「零页」。 太复杂了看不懂、一图胜千言版本: GameBoy 的 64KB 内存就像一栋 8 层楼的公寓: 🏢 GameBoy 内存公寓(64KB) ├── 8 楼(0xFF00-0xFFFF)控制中心(按钮、音量调节等) ├── 7 楼(0xFE00-0xFEFF)精灵属性(角色信息) ├── 6 楼(0xE000-0xFDFF)工作区镜像(楼下的复印件) ├── 5 楼(0xC000-0xDFFF)工作区(临时文件柜) ├── 4 楼(0xA000-0xBFFF)卡带存档(游戏进度) ├── 3 楼(0x8000-0x9FFF)图片仓库(所有图像数据) ├── 2 楼(0x4000-0x7FFF)游戏程序(第 2 部分) └── 1 楼(0x0000-0x3FFF)游戏程序(第 1 部分) └── 地下室(0x0000-0x00FF)开机程序(BIOS) 每次 CPU 说他想要 0x8000 的数据,MMU 就知道:「哦,你要 3 楼图片仓库的东西。」 为了在代码中清晰地表示这些区域,我们定义一个 MEMORY_REGIONS 常量对象: /** * 内存区域常量定义 */const MEMORY_REGIONS = { // ROM 区域 ROM_BANK_0_START: 0x0000, ROM_BANK_0_END: 0x3FFF, ROM_BANK_1_START: 0x4000, ROM_BANK_1_END: 0x7FFF, // BIOS 区域(在 ROM 银行 0 内) BIOS_START: 0x0000, BIOS_END: 0x00FF, BIOS_EXIT_POINT: 0x0100, // 卡带头部(在 ROM 银行 0 内) CARTRIDGE_HEADER_START: 0x0100, CARTRIDGE_HEADER_END: 0x014F, // 视频 RAM VRAM_START: 0x8000, VRAM_END: 0x9FFF, VRAM_SIZE: 0x2000, // 8KB // 外部 RAM ERAM_START: 0xA000, ERAM_END: 0xBFFF, ERAM_SIZE: 0x2000, // 8KB // 工作 RAM WRAM_START: 0xC000, WRAM_END: 0xDFFF, WRAM_SIZE: 0x2000, // 8KB // 工作 RAM 镜像 WRAM_SHADOW_START: 0xE000, WRAM_SHADOW_END: 0xFDFF, // 精灵属性内存 OAM_START: 0xFE00, OAM_END: 0xFE9F, OAM_SIZE: 0xA0, // 160 字节 // I/O 寄存器 IO_START: 0xFF00, IO_END: 0xFF7F, // 零页 RAM ZRAM_START: 0xFF80, ZRAM_END: 0xFFFF, ZRAM_SIZE: 0x80 // 128 字节}; 2.2. MMU 类结构与初始化 我们的 GameBoyMMU 类会负责创建和管理这些内存区域的实际存储(用 Unit8Array),并提供读写内存的接口。 在构造函数里,首先要调用 initializeMemoryRegions 来分配各个内存区域的存储空间,然后调用 reset 方法将它们清空并设置初始状态。 /** * GameBoy 内存管理单元类 */class GameBoyMMU { constructor() { this.initializeMemoryRegions(); this.reset(); } /** * 初始化所有内存区域 */ initializeMemoryRegions() { // BIOS 数据(256 字节),GameBoy 启动代码 this.bios = new Uint8Array(256); // 卡带 ROM 数据(最大 32KB 基础 ROM) this.rom = new Uint8Array(0x8000); // 32KB 初始空间 // 视频 RAM(8KB),存储背景和精灵图形数据 this.vram = new Uint8Array(MEMORY_REGIONS.VRAM_SIZE); // 外部 RAM(8KB),卡带上的额外可写内存 this.eram = new Uint8Array(MEMORY_REGIONS.ERAM_SIZE); // 工作 RAM(8KB),GameBoy 内部 RAM this.wram = new Uint8Array(MEMORY_REGIONS.WRAM_SIZE); // 零页 RAM(128 字节),高速访问内存 this.zram = new Uint8Array(MEMORY_REGIONS.ZRAM_SIZE); // 精灵属性内存(160 字节),存储精灵位置和属性 this.oam = new Uint8Array(MEMORY_REGIONS.OAM_SIZE); // I/O 寄存器映射(128 字节),硬件控制寄存器 this.ioRegisters = new Uint8Array(0x80); } /** * 重置 MMU 到初始状态 */ reset() { // BIOS 映射标志,控制是否显示 BIOS 区域 this.biosEnabled = true; // 清空所有可写内存区域 this.vram.fill(0); this.eram.fill(0); this.wram.fill(0); this.zram.fill(0); this.oam.fill(0); this.ioRegisters.fill(0); console.log('MMU 已重置到初始状态'); }} 2.3. 内存读取 MMU 最核心的功能就是根据 CPU 请求的地址,将其路由到正确的内存区域并返回数据。readByte(address) 方法实现了这一逻辑:它根据 16 位地址的不同范围,返回对应 Uint8Array 中的字节。 这里需要特别注意 BIOS 区域的逻辑:当 CPU 程序计数器 PC 达到 0x0100 时,表明 BIOS 已经执行完毕,此时我们会禁用 BIOS 映射(this.biosEnabled = false;),让该地址范围(0x0000-0x00FF)切换到显示卡带 ROM。 // ... 之前写的方法 .../** * 从指定地址读取 8 位字节 * 根据地址范围路由到相应的内存区域 * @param {number} address 16 位内存地址(0x0000-0xFFFF) * @returns {number} 8 位数据值(0x00-0xFF) */readByte(address) { // 确保地址在 16 位范围内 address &= 0xFFFF; // 根据地址高 4 位进行初步分类,提升性能 switch (address & 0xF000) { // 0x0000-0x0FFF: BIOS/ROM 银行 0 区域 case 0x0000: { // 检查是否在 BIOS 区域且 BIOS 已启用 if (this.biosEnabled && address < MEMORY_REGIONS.BIOS_EXIT_POINT) { return this.bios[address]; } // BIOS 退出检查:当 PC 到达 0x0100 时禁用 BIOS // TODO: 这里依赖于全局的 cpu 实例,后续可以考虑通过依赖注入优化 if (address === MEMORY_REGIONS.BIOS_EXIT_POINT && window.cpu?.registers?.pc === MEMORY_REGIONS.BIOS_EXIT_POINT) { this.biosEnabled = false; console.log('BIOS已退出,切换到卡带ROM'); } return this.rom[address]; // 否则读取 ROM } // 0x1000-0x3FFF: ROM 银行 0 的其余部分 case 0x1000: case 0x2000: case 0x3000: return this.rom[address]; // 0x4000-0x7FFF: ROM 银行 1(可切换) - 基础模拟中直接从 rom 读取 case 0x4000: case 0x5000: case 0x6000: case 0x7000: return this.rom[address]; // 0x8000-0x9FFF: 视频 RAM case 0x8000: case 0x9000: return this.vram[address & 0x1FFF]; // 限制在 8KB(0x2000)范围内 // 0xA000-0xBFFF: 外部 RAM case 0xA000: case 0xB000: return this.eram[address & 0x1FFF]; // 限制在 8KB 范围内 // 0xC000-0xDFFF: 工作 RAM case 0xC000: case 0xD000: return this.wram[address & 0x1FFF]; // 限制在 8KB 范围内 // 0xE000-0xEFFF: 工作 RAM 镜像 case 0xE000: return this.wram[address & 0x1FFF]; // 映射到工作 RAM // 0xF000-0xFFFF: 复杂区域 - 包含 RAM 镜像、OAM、I/O、零页 RAM case 0xF000: return this.readHighMemoryRegion(address); // 调用辅助函数处理高地址区域 default: console.warn(`未处理的内存读取地址: 0x${address.toString(16).padStart(4, '0')}`); return 0xFF; // 返回未连接总线的典型值(全1) }}/** * 处理高内存区域的读取(0xF000-0xFFFF) * 这个区域包含多个不同的内存映射,需要进一步细分 * @param {number} address 内存地址 * @returns {number} 读取的数据 */readHighMemoryRegion(address) { // 根据地址的中间 4 位进一步分类 switch (address & 0x0F00) { // 0xF000-0xFDFF: 工作 RAM 镜像的剩余部分 case 0x000: case 0x100: case 0x200: case 0x300: case 0x400: case 0x500: case 0x600: case 0x700: case 0x800: case 0x900: case 0xA00: case 0xB00: case 0xC00: case 0xD00: return this.wram[address & 0x1FFF]; // 0xFE00-0xFEFF: 精灵属性内存区域(OAM) case 0xE00: if (address < 0xFEA0) { // 有效的 OAM 区域(0xFE00-0xFE9F) return this.oam[address & 0xFF]; // 限制在 160 字节(0xA0)范围内 } else { // 0xFEA0-0xFEFF: 未使用区域,读取通常返回 0 或 0xFF,这里统一返回 0 return 0; } // 0xFF00-0xFFFF: I/O 寄存器和零页 RAM case 0xF00: if (address >= MEMORY_REGIONS.ZRAM_START) { // 0xFF80-0xFFFF: 零页 RAM return this.zram[address & 0x7F]; // 限制在 128 字节(0x80)范围内 } else { // 0xFF00-0xFF7F: I/O 寄存器 return this.readIORegister(address); // 调用辅助函数处理 I/O 寄存器读取 } default: return 0xFF; // 未知区域返回 0xFF }}/** * 读取 I/O 寄存器 * 这些寄存器控制 GameBoy 的各个硬件子系统。 * TODO: 目前只是返回其在内部数组中的值,具体硬件逻辑将在后续章节实现。 * @param {number} address I/O 寄存器地址(0xFF00-0xFF7F) * @returns {number} 寄存器值 */readIORegister(address) { const registerIndex = address - MEMORY_REGIONS.IO_START; // 返回 I/O 寄存器数组中的值,如果超出范围则返回 0(或 0xFF) return this.ioRegisters[registerIndex] || 0;}/** * 从指定地址读取 16 位字(小端序 - Little Endian) * GameBoy CPU 使用小端序存储多字节数据,即低位字节在前。 * @param {number} address 起始地址 * @returns {number} 16位值 */readWord(address) { const lowByte = this.readByte(address); const highByte = this.readByte(address + 1); return lowByte | (highByte << 8); // 将高位字节左移 8 位后与低位字节合并} 2.4. 内存写入 内存写入的逻辑与读取类似,writeByte(address, value) 方法根据地址将数据写入对应的内存区域。需要注意的是,ROM 区域通常是只读的,任何写入操作都会被忽略(除了用于内存银行切换的特殊地址,这将在后面讨论)。 // ... 之前写的方法 .../** * 向指定地址写入 8 位字节 * 根据地址范围路由到相应的内存区域 * @param {number} address 16 位内存地址 * @param {number} value 8 位数据值 */writeByte(address, value) { // 确保地址和值在有效范围内 address &= 0xFFFF; value &= 0xFF; // 根据地址高 4 位进行分类 switch (address & 0xF000) { // 0x0000-0x7FFF: ROM 区域 - 通常只读,但可能有银行切换逻辑 case 0x0000: case 0x1000: case 0x2000: case 0x3000: case 0x4000: case 0x5000: case 0x6000: case 0x7000: this.handleROMWrite(address, value); // 专门处理 ROM 区域的写入 break; // 0x8000-0x9FFF: 视频 RAM - 可写 case 0x8000: case 0x9000: this.vram[address & 0x1FFF] = value; break; // 0xA000-0xBFFF: 外部 RAM - 可写 case 0xA000: case 0xB000: this.eram[address & 0x1FFF] = value; break; // 0xC000-0xDFFF: 工作 RAM - 可写 case 0xC000: case 0xD000: this.wram[address & 0x1FFF] = value; break; // 0xE000-0xEFFF: 工作 RAM 镜像 - 写入映射到工作 RAM case 0xE000: this.wram[address & 0x1FFF] = value; break; // 0xF000-0xFFFF: 高内存区域 case 0xF000: this.writeHighMemoryRegion(address, value); // 调用辅助函数处理高地址区域写入 break; default: console.warn(`未处理的内存写入地址: 0x${address.toString(16).padStart(4, '0')}`); }}/** * 处理 ROM 区域的写入 * ROM 通常是只读的。在基础模拟中,任何对 ROM 区域的写入都会被忽略。 * TODO: 在未来的内存银行章节中,这里将包含处理卡带内存银行控制器的逻辑。 * @param {number} address 地址 * @param {number} value 值 */handleROMWrite(address, value) { console.log(`ROM写入被忽略: 地址=0x${address.toString(16).padStart(4, '0')}, 值=0x${value.toString(16).padStart(2, '0')}`);}/** * 处理高内存区域的写入(0xF000-0xFFFF) * @param {number} address 地址 * @param {number} value 值 */writeHighMemoryRegion(address, value) { switch (address & 0x0F00) { // 0xF000-0xFDFF: 工作 RAM 镜像 - 写入映射到工作 RAM case 0x000: case 0x100: case 0x200: case 0x300: case 0x400: case 0x500: case 0x600: case 0x700: case 0x800: case 0x900: case 0xA00: case 0xB00: case 0xC00: case 0xD00: this.wram[address & 0x1FFF] = value; break; // 0xFE00-0xFEFF: 精灵属性内存(OAM) case 0xE00: if (address < 0xFEA0) { this.oam[address & 0xFF] = value; } // 0xFEA0-0xFEFF 区域的写入通常被忽略 break; // 0xFF00-0xFFFF: I/O 寄存器和零页 RAM case 0xF00: if (address >= MEMORY_REGIONS.ZRAM_START) { // 零页 RAM this.zram[address & 0x7F] = value; } else { // I/O 寄存器 this.writeIORegister(address, value); } break; }}/** * 写入 I/O 寄存器 * @param {number} address I/O 寄存器地址 * @param {number} value 要写入的值 */writeIORegister(address, value) { const registerIndex = address - MEMORY_REGIONS.IO_START; this.ioRegisters[registerIndex] = value; // TODO: 在后续章节中,这里将处理特定 I/O 寄存器写入的副作用 // 例如:当写入显示控制寄存器时触发屏幕更新,或写入声音寄存器时播放声音}/** * 向指定地址写入 16 位字(小端序) * @param {number} address 起始地址 * @param {number} value 16 位值 */writeWord(address, value) { this.writeByte(address, value & 0xFF); // 写入低位字节 this.writeByte(address + 1, (value >> 8) & 0xFF); // 写入高位字节} 2.5. 加载 ROM 与 BIOS 为了让模拟器能够运行游戏,MMU 还需要加载 BIOS 和游戏 ROM 的功能: loadBIOS(biosData):加载 GameBoy 的 BIOS 文件 BIOS 是启动时运行的一小段程序,通常用于初始化硬件和显示任天堂的标志 loadROM(romData):加载游戏卡带的 ROM 数据。这个方法会将 ROM 数据复制到 MMU 内部的 this.rom 数组中 loadROMFromURL(url):直接从给定的 URL 下载 ROM 文件并加载 displayCartridgeInfo() 方法则会从 ROM 的卡带头部读取并打印出游戏的标题、类型、ROM 大小和 RAM 大小等信息,这对于验证 ROM 是否正确加载非常有用。 // ... 之前写的方法 .../** * 加载 BIOS 数据 * @param {Uint8Array|Array} biosData BIOS 数据数组(应为 256 字节) */loadBIOS(biosData) { if (biosData.length !== 256) { throw new Error('BIOS 数据必须正好是 256 字节'); } this.bios.set(biosData); // 将传入的 BIOS 数据复制到内部存储 this.biosEnabled = true; // 确保 BIOS 映射处于启用状态 console.log('BIOS 已加载');}/** * 加载 ROM 文件 * @param {Uint8Array|ArrayBuffer} romData ROM 数据 */loadROM(romData) { // 转换为 Uint8Array(如果传入的是 ArrayBuffer) const rom = romData instanceof ArrayBuffer ? new Uint8Array(romData) : romData; // 确保 ROM 不超过 MMU 分配的最大支持大小 const maxSize = this.rom.length; if (rom.length > maxSize) { console.warn(`ROM 大小 ${rom.length} 字节超过最大支持大小 ${maxSize} 字节,将被截断`); } // 复制 ROM 数据到内部存储,只复制有效部分 const copySize = Math.min(rom.length, maxSize); this.rom.set(rom.slice(0, copySize)); console.log(`ROM 已加载,大小: ${copySize} 字节`); // 显示卡带信息,验证 ROM 是否加载正确 this.displayCartridgeInfo();}/** * 显示卡带头部信息 * 从 ROM 的特定地址读取游戏元数据 */displayCartridgeInfo() { // 读取卡带标题(0x0134-0x0143) let title = ''; for (let i = 0x0134; i <= 0x0143; i++) { const char = this.rom[i]; if (char === 0) break; // 标题以空字符结束 title += String.fromCharCode(char); } // 读取卡带类型(0x0147) const cartridgeType = this.rom[0x0147]; // 读取 ROM 大小(0x0148) const romSizeCode = this.rom[0x0148]; // 根据 GameBoy 规范计算 ROM 实际大小,32KB * (2^romSizeCode) const romSize = 32 * (1 << romSizeCode); // KB // 读取 RAM 大小(0x0149) const ramSizeCode = this.rom[0x0149]; // 根据 GameBoy 规范定义 RAM 大小映射 const ramSizes = [0, 2, 8, 32, 128, 64]; // KB const ramSize = ramSizes[ramSizeCode] || 0; // 处理未知编码 console.log('=== 卡带信息 ==='); console.log(`标题: ${title}`); console.log(`卡带类型: 0x${cartridgeType.toString(16).padStart(2, '0')}`); console.log(`ROM 大小: ${romSize}KB`); console.log(`RAM 大小: ${ramSize}KB`);}/** * 异步加载 ROM 文件(从 URL) * @param {string} url ROM 文件 URL */async loadROMFromURL(url) { try { console.log(`正在加载ROM: ${url}`); const response = await fetch(url); // 发起网络请求 if (!response.ok) { throw new Error(`HTTP错误: ${response.status}`); } const romData = await response.arrayBuffer(); // 获取二进制数据 this.loadROM(romData); // 调用 loadROM 进行处理 } catch (error) { console.error('ROM加载失败:', error); throw error; // 重新抛出错误以便外部捕获 }}/** * 获取内存使用情况统计(用于调试) * @returns {Object} 内存使用统计对象 */getMemoryStats() { return { biosEnabled: this.biosEnabled, romLoaded: this.rom.some(byte => byte !== 0), // 检查 ROM 是否包含数据 memoryRegions: { bios: { size: this.bios.length, used: this.bios.some(byte => byte !== 0) }, rom: { size: this.rom.length, used: this.rom.some(byte => byte !== 0) }, vram: { size: this.vram.length, used: this.vram.some(byte => byte !== 0) }, eram: { size: this.eram.length, used: this.eram.some(byte => byte !== 0) }, wram: { size: this.wram.length, used: this.wram.some(byte => byte !== 0) }, zram: { size: this.zram.length, used: this.zram.some(byte => byte !== 0) }, oam: { size: this.oam.length, used: this.oam.some(byte => byte !== 0) } } };}/** * 获取内存转储(用于调试) * 以十六进制和 ASCII 形式显示内存内容 * @param {number} startAddr 起始地址 * @param {number} length 要转储的长度 * @returns {string} 十六进制转储字符串 */getMemoryDump(startAddr, length = 256) { let dump = `内存转储 (0x${startAddr.toString(16).padStart(4, '0')} - 0x${(startAddr + length - 1).toString(16).padStart(4, '0')}):\n`; // 每行显示 16 个字节 for (let i = 0; i < length; i += 16) { const addr = startAddr + i; let line = `${addr.toString(16).padStart(4, '0')}: `; // 地址部分 let ascii = ''; // ASCII 可视化部分 for (let j = 0; j < 16 && (i + j) < length; j++) { const byte = this.readByte(addr + j); line += `${byte.toString(16).padStart(2, '0')} `; // 十六进制字节 // 将可打印字符转换为 ASCII,否则显示 '.' ascii += (byte >= 32 && byte <= 126) ? String.fromCharCode(byte) : '.'; } line = line.padEnd(50, ' ') + ascii; // 填充空格对齐 dump += line + '\n'; } return dump;} 2.6. MMU 与 CPU 的连接 现在我们有了 GameBoyCPU 和 GameBoyMMU 两个类。CPU 需要一个方式来与 MMU 交互,才能真正实现「取指」和「读写内存」的功能。最简单的方式就是将 MMU 实例作为参数传递给 CPU,或者让 CPU 拥有一个 MMU 的引用。 不过也别忘了在 mmu.js 中导出 GameBoyMMU 类和常量: if (typeof module !== 'undefined' && module.exports) { // Node.js 环境导出 module.exports = { GameBoyMMU, MEMORY_REGIONS };} else if (typeof window !== 'undefined') { // 浏览器环境导出到全局对象 window.GameBoyMMU = GameBoyMMU; window.MEMORY_REGIONS = MEMORY_REGIONS;} 然后在 GameBoyCPU 类中,我们可以添加一个对 MMU 的引用: class GameBoyCPU { constructor(mmu) { // 接收 MMU 实例 this.mmu = mmu; // 存储 MMU 引用 // ... 其他寄存器和时钟初始化 ... } // 示例:实现一个 LD A, (HL) 指令 // 将 HL 寄存器指向的内存地址的值加载到 A 寄存器 loadAToHL() { const address = (this.registers.h << 8) | this.registers.l; // 合并 H 和 L 得到 16 位地址 this.registers.a = this.mmu.readByte(address); // 通过 MMU 读取内存 this.registers.m = 2; // 假设这条指令需要 2 机器周期 this.registers.t = 8; // 假设这条指令需要 8 时钟周期 } // ... 其他指令和方法 ...} 这样,CPU 就可以通过 this.mmu.readByte()、this.mmu.writeByte() 等方法来访问内存了。 3. GPU 时序:让 GameBoy 屏幕动起来 先前我们已经构建了 GameBoy 模拟器的 CPU 骨架和 MMU,让模拟器能够加载游戏 ROM 并开始执行指令。但是一个没有图像输出的模拟器是没有灵魂的!现在我们得引入 GameBoy 的主要输出设备 —— 图形处理单元 也就是我们经常说到的 GPU 了。 GameBoy 的官方内部名称是「点阵式游戏机」(Dot Matrix Game),这是因为它靠着一块 160x144 像素的单色 LCD 屏幕来显示内容。为了模拟这个屏幕,我们可以在 Web 页面中使用一个 HTML5 <canvas> 元素。这个 Canvas 将作为我们的「帧缓冲区」,其中每个像素的颜色都可以被直接操作。 3.1. 模拟屏幕与帧缓冲区 要将 GameBoy 的图形输出呈现在网页上,最直接的方法就是创建一个与 GameBoy 屏幕尺寸相同的 Canvas。我们可以通过 Canvas 的 2D 渲染上下文来操作它的像素数据。 GameBoy 的显示分辨率是 160 像素宽和 140 像素高。Canvas 的像素数据通常以 RGBA(红、绿、蓝、透明度)的 4 字节序列存储。这意味着每个像素需要 4 个字节来表示其颜色。我们可以通过 getImageData 或 createImageData 方法获取或创建这个帧缓冲区。 首先,在 HTML 文件中添加一个 Canvas 元素: <canvas id="gameboy-screen" width="160" height="144"></canvas> 在 GameBoyGPU 类中,我们将负责初始化这个 Canvas 并创建帧缓冲区。我们还设置了 imageSmoothingEnabled = false 和 image-rendering: pixelated 样式,以确保图像在放大时保持像素艺术的清晰度,而不是变得模糊。 // ... 其他常量定义,之后写 .../** * GameBoy GPU 类 * 负责管理显示时序、渲染管线和帧缓冲区 */class GameBoyGPU { constructor() { this.initializeTimingState(); this.initializeCanvas(); this.initializeFrameBuffer(); console.log('GameBoy GPU 已初始化'); } /** * 初始化 Canvas 画布 */ initializeCanvas() { // 尝试获取已存在的 canvas 元素 this.canvas = document.getElementById('gameboy-screen'); // 如果不存在,创建新的 canvas 并添加到页面 if (!this.canvas) { this.canvas = this.createCanvas(); } // 获取 2D 渲染上下文 this.context = this.canvas.getContext('2d'); if (!this.context) { throw new Error('无法获取 Canvas 2D 渲染上下文'); } // 禁用图像平滑,保持像素艺术风格 this.context.imageSmoothingEnabled = false; console.log('Canvas 画布已初始化'); } /** * 创建新的 Canvas 元素 * @returns {HTMLCanvasElement} 创建的 canvas 元素 */ createCanvas() { const canvas = document.createElement('canvas'); canvas.id = 'gameboy-screen'; canvas.width = DISPLAY_CONFIG.WIDTH; canvas.height = DISPLAY_CONFIG.HEIGHT; canvas.style.border = '2px solid #333'; canvas.style.imageRendering = 'pixelated'; // 保持像素完美缩放 canvas.style.imageRendering = '-moz-crisp-edges'; // Firefox canvas.style.imageRendering = 'crisp-edges'; // Chrome/Edge canvas.style.backgroundColor = '#9BBC0F'; // GameBoy 绿色背景 // 为了方便在 HTML 中演示,这里直接添加到 body document.body.appendChild(canvas); console.log('已创建新的 Canvas 元素'); return canvas; } /** * 初始化帧缓冲区 * 使用 ImageData 对象作为屏幕的内存表示 */ initializeFrameBuffer() { // 创建图像数据对象(帧缓冲区) this.frameBuffer = this.context.createImageData( DISPLAY_CONFIG.WIDTH, DISPLAY_CONFIG.HEIGHT ); // 初始化为白色背景 this.clearFrameBuffer(); console.log('帧缓冲区已初始化'); } /** * 清空帧缓冲区(设置为白色,或 GameBoy 默认背景色) */ clearFrameBuffer() { const data = this.frameBuffer.data; // 设置所有像素为白色(255, 255, 255, 255) for (let i = 0; i < DISPLAY_CONFIG.BUFFER_SIZE; i += 4) { data[i] = 255; // R data[i + 1] = 255; // G data[i + 2] = 255; // B data[i + 3] = 255; // A (透明度) } } /** * 设置像素颜色(辅助函数) * @param {number} x - X 坐标(0-159) * @param {number} y - Y 坐标(0-143) * @param {number} r - 红色分量(0-255) * @param {number} g - 绿色分量(0-255) * @param {number} b - 蓝色分量(0-255) * @param {number} a - 透明度(0-255),默认 255 */ setPixel(x, y, r, g, b, a = 255) { if (x < 0 || x >= DISPLAY_CONFIG.WIDTH || y < 0 || y >= DISPLAY_CONFIG.HEIGHT) { return; // 越界检查 } const pixelIndex = (y * DISPLAY_CONFIG.WIDTH + x) * 4; const data = this.frameBuffer.data; data[pixelIndex] = r; data[pixelIndex + 1] = g; data[pixelIndex + 2] = b; data[pixelIndex + 3] = a; }} 现在,我们有一个可以操作的帧缓冲区。通过修改 this.frameBuffer.data 数组中的 RGBA 值,我们可以改变屏幕上任何像素的颜色。修改完成后,调用 this.context.putImageData(this.frameBuffer, 0, 0) 就可以将更新后的帧缓冲区内容绘制到 Canvas 上。 3.2. 栅格图形与显示时序 GPU 工作原理: 我们可以看一下老式电视是如何显示画面的: 电子枪从左到右扫描 →→→→→→→→→→→(一行画完)然后跳到下一行 ↓再从左到右扫描 →→→→→→→→→→→(下一行)...重复 144次 ...最后回到顶部 ↑↑↑↑↑↑↑↑↑(准备下一帧) GameBoy 的 GPU 完全模仿了这个过程。 1989 年的硬件很慢,一次只能处理一行像素,这样做也能省电省内存。当年的程序员很聪明,用最少的资源做最多的事。 GameBoy 的显示硬件模拟了阴极射线管(CRT)的工作方式。在 CRT 显示器中,电子束逐行扫描屏幕,并在扫描完成后返回到屏幕顶部。这个扫描过程不是瞬间完成的,它包含了消隐期: 水平消隐:电子束从一行的末尾移动到下一行的开头所需的时间 垂直消隐:在一帧结束后,电子束从屏幕底部回到屏幕左上角所需的时间。垂直消隐期通常比水平消隐期长得多,因为它需要移动更远的距离 GameBoy 的显示器也遵循类似的模式,并将其工作周期细分为四个不同的 GPU 模式。这对于精确模拟非常重要,因为某些硬件操作只能在特定的 GPU 模式下进行。 周期GPU 模式号时间消耗(t 时钟周期)描述 OAM 搜索280GPU 扫描 OAM 获取当前扫描线上的精灵信息。 像素传输3172GPU 从 VRAM 读取图块和地图数据并渲染像素。 水平消隐0204当前扫描线渲染完成后的等待期。 一条扫描线总时间456(80 + 172 + 204) 垂直消隐14560(10 行)所有可见扫描线渲染完成后的等待期。 一帧总时间70224(456 * 154 行) 为什么是这些数字呢? GPU 像是一个画家,每天画一行像素。 准备阶段(80):看看要画什么角色(OAM 搜索) 绘画阶段(172):专心画这一行(像素传输) 休息阶段(204):喝口水,准备下一行(水平消隐) 长休息(4560):一张画完了,休息下(垂直消隐) 因此我们的游戏不能随时访问 GPU。 为了在模拟器中保持这些时序,我们需要一个 step 函数,它会在 CPU 每执行一条指令后被调用,并根据 CPU 消耗的时钟周期来推进 GPU 的内部时序状态。 我们定义 GPU 模式和时序常量: /** * GPU 模式常量定义 * GameBoy GPU 有 4 种不同的工作模式,模拟 CRT 显示器的扫描过程 */const GPU_MODES = { HBLANK: 0, // 水平消隐期(扫描线结束后) VBLANK: 1, // 垂直消隐期(帧结束后) OAM_SEARCH: 2, // OAM 搜索期(精灵属性扫描) PIXEL_TRANSFER: 3 // 像素传输期(VRAM 读取和渲染)};/** * GPU 时序常量(以 CPU 的 T 时钟周期为单位) * CPU 频率:4194304 Hz */const GPU_TIMINGS = { // 扫描线时序 OAM_SEARCH_CYCLES: 80, // 模式 2:OAM 访问时间 PIXEL_TRANSFER_CYCLES: 172, // 模式 3:VRAM 访问时间 HBLANK_CYCLES: 204, // 模式 0:水平消隐时间 // 计算得出的时序 SCANLINE_CYCLES: 456, // 一条扫描线总时间(80 + 172 + 204) VBLANK_LINE_CYCLES: 456, // 垂直消隐期每线时间 VBLANK_TOTAL_CYCLES: 4560, // 垂直消隐总时间(456 * 10) // 帧时序 VISIBLE_LINES: 144, // 可见扫描线数量 VBLANK_LINES: 10, // 垂直消隐扫描线数量 TOTAL_LINES: 154, // 总扫描线数量(144 + 10) FRAME_CYCLES: 70224 // 完整帧时间(456 * 154)};/** * 显示常量 */const DISPLAY_CONFIG = { WIDTH: 160, // 屏幕宽度 HEIGHT: 144, // 屏幕高度 BYTES_PER_PIXEL: 4, // RGBA 格式,每像素 4 字节 TOTAL_PIXELS: 160 * 144, // 总像素数 BUFFER_SIZE: 160 * 144 * 4 // 帧缓冲区大小}; 接下来,我们在 GameBoyGPU 类中初始化时序状态,并实现 step 方法: // ... 之前写的其他方法 .../** * 初始化时序状态 */initializeTimingState() { this.mode = GPU_MODES.OAM_SEARCH; // 初始 GPU 模式 this.modeClock = 0; // 当前模式的时钟计数器 this.currentLine = 0; // 当前扫描线号(0-153) this.totalClock = 0; // GPU 总时钟计数(累积) this.frameCount = 0; // 帧计数器 this.lastFrameTime = performance.now(); // 上次帧渲染时间(用于计算 FPS)}/** * 重置 GPU 到初始状态 */reset() { this.initializeTimingState(); this.clearFrameBuffer(); this.updateDisplay(); // 更新 Canvas 以显示清空后的画面 console.log('GPU 已重置');}/** * GPU 时序步进函数 * 每次 CPU 执行指令后调用,根据 CPU 消耗的时钟周期推进 GPU 状态 * @param {number} cycles CPU 消耗的时钟周期数 */step(cycles) { this.modeClock += cycles; // 累加当前模式下的时钟 this.totalClock += cycles; // 累加总时钟 // 根据当前模式处理时序逻辑 switch (this.mode) { case GPU_MODES.OAM_SEARCH: this.handleOAMSearchMode(); break; case GPU_MODES.PIXEL_TRANSFER: this.handlePixelTransferMode(); break; case GPU_MODES.HBLANK: this.handleHBlankMode(); break; case GPU_MODES.VBLANK: this.handleVBlankMode(); break; default: console.warn(`未知的 GPU 模式:${this.mode}`); this.mode = GPU_MODES.OAM_SEARCH; // 恢复到默认模式 }}/** * 处理 OAM 搜索模式(模式 2) * 当模式时钟达到 OAM_SEARCH_CYCLES 时,切换到像素传输模式 */handleOAMSearchMode() { if (this.modeClock >= GPU_TIMINGS.OAM_SEARCH_CYCLES) { this.modeClock = 0; this.mode = GPU_MODES.PIXEL_TRANSFER; // TODO: 在这个阶段,会进行 OAM 搜索逻辑,在第 7 章(精灵)中实现 this.searchOAM(); }}/** * 处理像素传输模式(模式 3) * 当模式时钟达到 PIXEL_TRANSFER_CYCLES 时,切换到水平消隐模式,并渲染当前扫描线 */handlePixelTransferMode() { if (this.modeClock >= GPU_TIMINGS.PIXEL_TRANSFER_CYCLES) { this.modeClock = 0; this.mode = GPU_MODES.HBLANK; // 渲染当前扫描线 this.renderScanline(); }}/** * 处理水平消隐模式(模式 0) * 当模式时钟达到 HBLANK_CYCLES 时,递增扫描线,并根据扫描线数量决定进入 VBLANK 或下一条扫描线 */handleHBlankMode() { if (this.modeClock >= GPU_TIMINGS.HBLANK_CYCLES) { this.modeClock = 0; this.currentLine++; // 扫描线递增 if (this.currentLine === GPU_TIMINGS.VISIBLE_LINES) { // 所有可见扫描线完成(0-143),进入垂直消隐模式 this.mode = GPU_MODES.VBLANK; this.onFrameComplete(); // 通知一帧渲染完成 } else { // 继续下一条扫描线,回到 OAM 搜索模式 this.mode = GPU_MODES.OAM_SEARCH; } }}/** * 处理垂直消隐模式(模式 1) * VBLANK 持续 10 条扫描线的时间。当模式时钟达到 VBLANK_LINE_CYCLES 时,递增扫描线, * 直到所有 VBLANK 扫描线完成,然后开始新的一帧。 */handleVBlankMode() { if (this.modeClock >= GPU_TIMINGS.VBLANK_LINE_CYCLES) { this.modeClock = 0; this.currentLine++; // 扫描线递增 if (this.currentLine > GPU_TIMINGS.TOTAL_LINES - 1) { // 垂直消隐结束(总共 154 条扫描线),开始新的一帧 this.currentLine = 0; this.mode = GPU_MODES.OAM_SEARCH; this.onNewFrameStart(); // 通知新帧开始 } }}/** * OAM 搜索逻辑(占位函数) * 在第 7 章(精灵)中会实现完整的精灵处理逻辑 */searchOAM() { // TODO: 实现精灵搜索逻辑 // 1. 扫描 OAM 表中的 40 个精灵 // 2. 找出在当前扫描线上的精灵(最多 10 个) // 3. 按 X 坐标排序准备渲染}/** * 渲染当前扫描线 * TODO: 目前是一个简单的测试渲染,在第 4 章(图形)中会实现完整的背景和精灵渲染 */renderScanline() { this.renderTestPattern(); // 渲染一个测试图案}/** * 渲染测试图案(用于验证 GPU 时序) * 在 Canvas 上显示彩色条纹,以验证扫描线渲染是否正常工作 */renderTestPattern() { const data = this.frameBuffer.data; const y = this.currentLine; // 当前要渲染的扫描线 // 为每条扫描线生成不同颜色的测试图案 for (let x = 0; x < DISPLAY_CONFIG.WIDTH; x++) { const pixelIndex = (y * DISPLAY_CONFIG.WIDTH + x) * 4; // 创建彩色测试图案,使之随帧动画 const colorPhase = (x + y + this.frameCount) % 64; if (colorPhase < 16) { // 红色渐变 data[pixelIndex] = 255; data[pixelIndex + 1] = colorPhase * 16; data[pixelIndex + 2] = colorPhase * 16; } else if (colorPhase < 32) { // 绿色渐变 data[pixelIndex] = (32 - colorPhase) * 16; data[pixelIndex + 1] = 255; data[pixelIndex + 2] = (colorPhase - 16) * 16; } else if (colorPhase < 48) { // 蓝色渐变 data[pixelIndex] = (colorPhase - 32) * 16; data[pixelIndex + 1] = (48 - colorPhase) * 16; data[pixelIndex + 2] = 255; } else { // 白色渐变 const brightness = (64 - colorPhase) * 16; data[pixelIndex] = brightness; data[pixelIndex + 1] = brightness; data[pixelIndex + 2] = brightness; } data[pixelIndex + 3] = 255; // Alpha(完全不透明) }}/** * 帧渲染完成回调 * 当一帧的所有可见扫描线渲染完成时调用。在这里我们将帧缓冲区显示到 Canvas。 */onFrameComplete() { // 将帧缓冲区内容显示到 Canvas this.updateDisplay(); // 更新帧计数和性能统计 this.frameCount++; this.updatePerformanceStats(); // TODO: 触发垂直消隐中断(在第 8 章中断中实现) this.triggerVBlankInterrupt(); console.log(`帧 ${this.frameCount} 渲染完成`);}/** * 新帧开始回调 * 当垂直消隐期结束,准备开始新的一帧渲染时调用。 */onNewFrameStart() { // TODO: 为新帧做准备,例如: // 1. 重置精灵计数器 // 2. 更新背景/窗口滚动寄存器 // 3. 处理显示控制寄存器变化}/** * 将帧缓冲区内容更新到 Canvas */updateDisplay() { this.context.putImageData(this.frameBuffer, 0, 0);}/** * 更新性能统计,例如 FPS */updatePerformanceStats() { const currentTime = performance.now(); const frameTime = currentTime - this.lastFrameTime; this.lastFrameTime = currentTime; // 计算 FPS const fps = 1000 / frameTime; // 每 60 帧输出一次性能信息,避免频繁日志输出 if (this.frameCount % 60 === 0) { console.log(`性能统计 - FPS: ${fps.toFixed(2)}, 帧时间: ${frameTime.toFixed(2)}ms`); }}/** * 触发垂直消隐中断(占位函数) * 在第 8 章(中断)中会实现完整的中断系统,通知 CPU 进行 VBLANK 相关操作 */triggerVBlankInterrupt() { // TODO: 实现 V-Blank 中断 // 1. 设置中断标志位(例如在 MMU 中设置 IE/IF 寄存器) // 2. 如果中断使能,通知 CPU 暂停当前执行并跳转到中断处理程序}/** * 获取当前 GPU 状态信息(用于调试) * @returns {Object} GPU 状态对象,包含模式、时钟、扫描线等 */getStatus() { return { mode: this.mode, modeName: this.getModeName(this.mode), modeClock: this.modeClock, currentLine: this.currentLine, totalClock: this.totalClock, frameCount: this.frameCount, fps: this.calculateFPS() };}/** * 获取 GPU 模式名称的辅助函数 * @param {number} mode - 模式号 * @returns {string} 模式名称 */getModeName(mode) { const modeNames = { [GPU_MODES.HBLANK]: '水平消隐', [GPU_MODES.VBLANK]: '垂直消隐', [GPU_MODES.OAM_SEARCH]: 'OAM 搜索', [GPU_MODES.PIXEL_TRANSFER]: '像素传输' }; return modeNames[mode] || '未知模式';}/** * 计算当前 FPS * @returns {number} 当前 FPS 值 */calculateFPS() { const currentTime = performance.now(); const timeDiff = currentTime - this.lastFrameTime; return timeDiff > 0 ? 1000 / timeDiff : 0;}/** * 获取调试信息字符串 * @returns {string} 格式化的调试信息 */getDebugInfo() { const status = this.getStatus(); return `GPU 状态:模式:${status.modeName}(${status.mode})模式时钟:${status.modeClock}当前扫描线:${status.currentLine}总时钟:${status.totalClock}帧计数:${status.frameCount}FPS:${status.fps.toFixed(2)}`;} 4. 图形渲染:绘制背景与瓦片 与现代显卡动辄数 GB 的显存不同,早期游戏机(如 GameBoy)的内存极其有限,无法在内存中直接存储完整的屏幕像素(即帧缓冲区)。为了解决这个问题,工程师们采用了一种非常聪明的技术 —— 瓦片系统。 想象一下你在用瓷砖来铺地板。你不需要为每一块地面都设计一个独一无二的图案,而是可以使用一组预先设计好的瓷砖(瓦片),通过不同的排列组合来创造出丰富的地面样式。GameBoy 的图形渲染就是基于这个原理。我们只需要存储一份「模板」,然后在需要的地方「引用」它即可。这个「模板」就是瓦片。 瓦片:一个 8x8 像素的基本图形单元。游戏中的所有背景和角色(精灵)都是由这些小小的瓦片拼接而成的 瓦片地图:一个 32x32 的二维数组,其中每个元素都是一个指向特定瓦片的索引。它就像一张设计图纸,规定了哪个位置应该使用哪个瓦片 视图:GameBoy 屏幕只有 160x144 像素,而整个背景地图却有 256x256 像素(32 个瓦片 × 8 像素 / 瓦片)。屏幕就像一个「摄像机」,我们只能通过这个 160x144 的窗口看到庞大背景地图的一部分 做过游戏开发的,尤其是做像素风格的应该对这个都很熟悉吧。 还是不太理解的,可以想一下拼图。 你要拼一幅大画,瓦片是你的拼图块(8x8 像素的小方块)、瓦片地图是拼图说明书、视图是拼图框。 游戏背景制作过程: 美术画了很多 8x8 的小图块 策划说:「草地用 1 号块、石头用 2 号块……」 程序:「我按照说明书拼。」(GPU 渲染) 玩家在游戏中看到森林 1989 年光是 1MB 内存就动不动要几千块钱,直接存储一张完整图片作为背景实在是过于奢侈。使用瓦片系统不仅可以节省内存,也可以节省空间。整个游戏卡带也才 32KB,必须得精打细算。 VRAM(视频内存)中,瓦片数据和瓦片地图的布局如下: 地址区域用途 0x8000 - 0x8FFF瓦片集 #1 0x9000 - 0x97FF瓦片集 #2 0x9800 - 0x9BFF瓦片地图 #0 0x9C00 - 0x9FFF瓦片地图 #1 有趣的是,瓦片集 #0 和 #1 有一部分是重叠的。游戏可以通过设置寄存器来选择使用哪个瓦片集和哪个瓦片地图,从而实现不同的显示效果。 4.1. 背景滚动与调色板 既然背景地图比屏幕大,我们自然就可以通过移动「摄像机」的位置来实现背景滚动的效果。这在平台跳跃或飞行射击游戏中非常常见。 GameBoy 提供了两个特殊的 GPU 寄存器来控制滚动: SCY(Scroll Y,地址 0xFF42):定义了屏幕顶边对应在 256x256 背景地图上的垂直坐标 SCX(Scroll X,地址 0xFF43):定义了屏幕左边对应在 256x256 背景地图上的水平坐标 通过在每一帧之间改变 SCX 和 SCY 的值,游戏就能让背景平滑地滚动起来,创造出动态的世界。 或许大家都会以为 GameBoy 是纯黑白的,但实际上它可以显示四种深浅不同的「灰色」(或者说是绿色,取决于屏幕材质)。这四种颜色是通过调色板系统实现的。 一个瓦片中的每个像素用 2 个比特来表示,可以表示四种值(00, 01, 10, 11)。但这四个值具体对应哪种颜色,是由背景调色板寄存器(BGP,地址 0xFF47)决定的。 BGP 是一个 8 位寄存器,每 2 位定义一个颜色的映射关系: 位 1-0:映射像素值 00 位 3-2:映射像素值 01 位 5-4:映射像素值 10 位 7-6:映射像素值 11 例如,如果 BGP 的值是 0xE4(二进制 11100100),那么映射关系就是: 00 → 00(颜色 0) 01 → 01(颜色 1) 10 → 10(颜色 2) 11 → 11(颜色 3) 这是最常见的默认调色板。但如果游戏将 BGP 的值改为 0x1E(二进制 00011011),映射就会变成: 00 → 11(颜色 3) 01 → 10(颜色 2) 10 → 01(颜色 1) 11 → 00(颜色 0) 这样整个屏幕的颜色就瞬间反转了,能够很高效地实现特殊视觉效果(比方说闪烁、水下效果)的方式。 在我们的模拟器中,我们将这四种颜色定义为经典的 GameBoy 绿色调: const DEFAULT_PALETTE = [ [155, 188, 15, 255], // 颜色 0: 最亮 (白) [139, 172, 15, 255], // 颜色 1: 亮灰 (浅绿) [48, 98, 48, 255], // 颜色 2: 暗灰 (深绿) [15, 56, 15, 255] // 颜色 3: 最暗 (黑)]; 4.2. 瓦片数据结构与缓存管理 在 GameBoy 中,每个瓦片的像素数据以一种特殊的方式存储。每个像素需要 2 位来表示(因为有 4 种颜色),但这 2 位并不是连续存储的。相反,一个瓦片行的 8 个像素的低位全部存储在一个字节中,高位存储在下一个字节中。 例如,如果一个瓦片行的像素值是 [3, 2, 1, 0, 0, 1, 2, 3],那么: 低位字节:11100001(每个像素值的最低位) 高位字节:11000011(每个像素值的最高位) 这种存储方式虽然看起来复杂,但符合 GameBoy 硬件的读取方式。 这一部分不需要完全理解,只要知道「GameBoy 有自己的存储格式,我们需要转换一下」就够了。 为了提高模拟器的性能,我们需要将这种原始格式转换为更易于处理的格式,并建立缓存机制: /** * 瓦片管理器类 * 负责瓦片数据的存储、更新和缓存管理 */class TileManager { constructor() { // 扩展瓦片数据缓存 - 每个瓦片预计算为 8x8 的像素数组 this.tileCache = new Array(GRAPHICS_CONSTANTS.TOTAL_TILES); for (let i = 0; i < GRAPHICS_CONSTANTS.TOTAL_TILES; i++) { this.tileCache[i] = new Array(GRAPHICS_CONSTANTS.TILE_HEIGHT); for (let y = 0; y < GRAPHICS_CONSTANTS.TILE_HEIGHT; y++) { this.tileCache[i][y] = new Uint8Array(GRAPHICS_CONSTANTS.TILE_WIDTH); } } // 标记哪些瓦片需要更新 this.dirtyTiles = new Set(); } /** * 更新单个瓦片的缓存数据 */ updateTileCache(tileIndex) { // 计算瓦片在 VRAM 中的起始地址 const baseAddr = tileIndex * GRAPHICS_CONSTANTS.TILE_SIZE_BYTES; // 逐行处理瓦片数据 for (let y = 0; y < GRAPHICS_CONSTANTS.TILE_HEIGHT; y++) { const rowAddr = baseAddr + (y * 2); // 每行占 2 字节 if (rowAddr + 1 < this.vram.length) { const lowByte = this.vram[rowAddr]; // 像素低位 const highByte = this.vram[rowAddr + 1]; // 像素高位 // 从两个字节中提取 8 个像素 for (let x = 0; x < GRAPHICS_CONSTANTS.TILE_WIDTH; x++) { const bitMask = 1 << (7 - x); const lowBit = (lowByte & bitMask) ? 1 : 0; const highBit = (highByte & bitMask) ? 2 : 0; // 组合成 2 位颜色值 this.tileCache[tileIndex][y][x] = lowBit + highBit; } } } }} 这个缓存系统的核心思想是「按需更新」—— 只有当 VRAM 中的瓦片数据发生变化时,我们才重新计算对应的缓存。这样可以避免每次渲染时都进行耗时的位操作。 4.3. 调色板系统实现 调色板管理器负责处理颜色映射。它不仅存储当前的调色板设置,还提供了动态更新调色板的能力: /** * 调色板管理器类 * 处理 GameBoy 的 4 色调色板系统 */class PaletteManager { constructor() { // 背景调色板(4 种颜色,每种颜色 RGBA 4 字节) this.backgroundPalette = new Array(GRAPHICS_CONSTANTS.PALETTE_COLORS); // 原始调色板寄存器值(用于模拟硬件寄存器) this.paletteRegister = 0xFC; // 默认值:11 10 01 00 this.setDefaultPalette(); } /** * 更新调色板寄存器 */ updatePaletteRegister(value) { this.paletteRegister = value & 0xFF; // 从寄存器值提取 4 个 2 位颜色映射 for (let i = 0; i < GRAPHICS_CONSTANTS.PALETTE_COLORS; i++) { const colorIndex = (this.paletteRegister >> (i * 2)) & 0x03; this.backgroundPalette[i] = [...DEFAULT_PALETTE[colorIndex]]; } } /** * 获取映射后的颜色 */ getColor(paletteIndex) { if (paletteIndex < 0 || paletteIndex >= GRAPHICS_CONSTANTS.PALETTE_COLORS) { return DEFAULT_PALETTE[0]; } return this.backgroundPalette[paletteIndex]; }} 例如,当游戏写入调色板寄存器 0xFF47 时,updatePaletteRegister 方法会被调用,自动重新映射所有颜色。这意味着游戏可以通过一个简单的寄存器写入操作瞬间改变整个屏幕的色调。 4.4. 背景滚动控制 滚动控制器管理背景的偏移位置,这是实现动态背景效果的关键: /** * 滚动控制器类 * 管理背景的滚动位置 */class ScrollController { constructor() { this.scrollX = 0; // 水平滚动位置 this.scrollY = 0; // 垂直滚动位置 } /** * 设置滚动位置 */ setScroll(x, y) { this.scrollX = x & 0xFF; this.scrollY = y & 0xFF; } /** * 获取当前滚动位置 */ getScrollPosition() { return { x: this.scrollX, y: this.scrollY }; }} 这个简单的类封装了 GameBoy 的 SCX 和 SCY 寄存器的功能。当游戏修改这些寄存器时,滚动位置会立即更新,下一帧的渲染就会反映出新的滚动位置。 4.5. 扫描线级背景渲染 背景渲染器是整个图形系统的核心,它负责将瓦片地图转换为实际的像素数据。渲染是按扫描线进行的,这模拟了 GameBoy 的真实渲染方式: /** * 背景渲染器类 * 负责将瓦片地图渲染到帧缓冲区 */class BackgroundRenderer { /** * 渲染单条扫描线的背景 */ renderBackgroundScanline(lineNumber, frameBuffer) { // 获取滚动位置 const scroll = this.scrollController.getScrollPosition(); // 计算当前扫描线在瓦片地图中的 Y 位置 const mapY = (lineNumber + scroll.y) & 0xFF; const tileY = Math.floor(mapY / GRAPHICS_CONSTANTS.TILE_HEIGHT); const pixelY = mapY % GRAPHICS_CONSTANTS.TILE_HEIGHT; // 计算瓦片地图行的起始偏移 const mapBaseAddr = this.backgroundMapSelect ? VRAM_LAYOUT.TILEMAP_1_START : VRAM_LAYOUT.TILEMAP_0_START; const mapOffset = mapBaseAddr - VRAM_LAYOUT.TILESET_1_START; const mapLineOffset = mapOffset + (tileY * GRAPHICS_CONSTANTS.MAP_WIDTH_TILES); // 计算起始瓦片的 X 位置 let mapX = scroll.x; let tileX = Math.floor(mapX / GRAPHICS_CONSTANTS.TILE_WIDTH); let pixelX = mapX % GRAPHICS_CONSTANTS.TILE_WIDTH; // 计算帧缓冲区的起始位置 let canvasOffset = lineNumber * GRAPHICS_CONSTANTS.SCREEN_WIDTH * 4; // 获取第一个瓦片 let tileIndex = this.getTileIndex(mapLineOffset + tileX); let tileData = this.tileManager.getTileData(tileIndex); // 渲染整条扫描线(160 像素) for (let screenX = 0; screenX < GRAPHICS_CONSTANTS.SCREEN_WIDTH; screenX++) { // 获取当前像素的调色板索引 const paletteIndex = tileData ? tileData[pixelY][pixelX] : 0; // 通过调色板获取最终颜色 const color = this.paletteManager.getColor(paletteIndex); // 写入帧缓冲区 frameBuffer.data[canvasOffset] = color[0]; // R frameBuffer.data[canvasOffset + 1] = color[1]; // G frameBuffer.data[canvasOffset + 2] = color[2]; // B frameBuffer.data[canvasOffset + 3] = color[3]; // A canvasOffset += 4; // 移动到下一个像素 pixelX++; if (pixelX >= GRAPHICS_CONSTANTS.TILE_WIDTH) { // 移动到下一个瓦片 pixelX = 0; tileX = (tileX + 1) & 31; // 瓦片地图是 32x32,循环 tileIndex = this.getTileIndex(mapLineOffset + tileX); tileData = this.tileManager.getTileData(tileIndex); } } }} 这个渲染过程的关键在于理解坐标转换: 从屏幕坐标转换为背景地图坐标(考虑滚动偏移) 从背景地图坐标转换为瓦片坐标和瓦片内像素坐标 从瓦片地图读取瓦片索引 从瓦片缓存读取像素的调色板索引 通过调色板获取最终的 RGBA 颜色值 4.6. 图形系统集成 最后,图形系统主类将所有组件协调起来工作: /** * 图形系统主类 * 协调所有图形组件的工作 */class GameBoyGraphicsSystem { constructor() { // 创建各个组件 this.tileManager = new TileManager(); this.paletteManager = new PaletteManager(); this.scrollController = new ScrollController(); this.backgroundRenderer = new BackgroundRenderer( this.tileManager, this.paletteManager, this.scrollController ); } /** * 连接到 MMU 的 VRAM */ connectVRAM(vramData) { this.tileManager.setVRAM(vramData); this.backgroundRenderer.setVRAM(vramData); } /** * 处理图形寄存器写入 */ writeGraphicsRegister(register, value) { switch (register) { case 0xFF42: // SCY - 垂直滚动 this.scrollController.setScrollY(value); break; case 0xFF43: // SCX - 水平滚动 this.scrollController.setScrollX(value); break; case 0xFF47: // BGP - 背景调色板 this.paletteManager.updatePaletteRegister(value); break; } } /** * 渲染单条扫描线 */ renderScanline(lineNumber, frameBuffer) { if (!this.enabled || !this.backgroundEnabled) { return; } this.backgroundRenderer.renderBackgroundScanline(lineNumber, frameBuffer); }} 这个集成系统的设计遵循了模块化原则 —— 每个组件都有清晰的职责,通过明确的接口进行通信。当 GPU 需要渲染一条扫描线时,它只需调用 renderScanline 方法,图形系统会自动协调所有子组件完成复杂的渲染过程。 4.7. 图形系统集成与性能优化 在完成了瓦片管理、调色板和背景渲染等核心组件后,我们需要将图形系统与现有的 GPU 时序系统集成起来,并进行性能优化以确保流畅的渲染效果(当时测试的时候 FPS 发现连 1 都不到……)。 首先,我们需要在 GPU 类中添加图形系统的初始化和管理逻辑: /** * 初始化图形系统 */initializeGraphicsSystem() { // 图形系统实例(如果可用) this.graphicsSystem = null; this.renderMode = 'test'; // 'test' 或 'graphics' // 尝试初始化图形系统 if (typeof GameBoyGraphicsSystem !== 'undefined') { try { this.graphicsSystem = new GameBoyGraphicsSystem(); this.renderMode = 'graphics'; console.log('图形系统已集成到 GPU'); } catch (error) { console.warn('图形系统初始化失败,使用测试模式:', error); } } else { console.log('图形系统未加载,使用测试渲染模式'); }} 接下来,我们需要建立 GPU 与图形系统之间的数据连接: /** * 连接到 MMU 的 VRAM */connectVRAM(vramData) { if (this.graphicsSystem) { this.graphicsSystem.connectVRAM(vramData); console.log('GPU 已连接到 VRAM'); }}/** * 处理 VRAM 写入事件 */onVRAMWrite(address, value) { if (this.graphicsSystem) { this.graphicsSystem.onVRAMWrite(address, value); }}/** * 处理图形寄存器写入 */writeRegister(register, value) { if (this.graphicsSystem) { this.graphicsSystem.writeGraphicsRegister(register, value); }} 为了提供更好的调试体验和性能,我们要实现渲染模式切换: /** * 渲染真实的 GameBoy 背景图形 */renderGameBoyGraphics() { if (window.GameBoyGraphicsSystem && this.graphicsSystem) { this.graphicsSystem.renderScanline(this.currentLine, this.frameBuffer); } else { // 否则使用测试图案 this.renderTestPattern(); }}/** * 切换渲染模式 */setRenderMode(mode) { if (mode === 'graphics' && !this.graphicsSystem) { console.warn('图形系统未初始化,无法切换到图形模式'); return; } this.renderMode = mode; console.log(`渲染模式已切换到:${mode}`);}/** * 渲染当前扫描线 */renderScanline() { // 根据渲染模式选择渲染方式 if (this.renderMode === 'graphics' && this.graphicsSystem) { this.renderGameBoyGraphics(); } else { this.renderTestPattern(); }} 最重要的性能优化是引入了简化的帧级别渲染,避免了复杂的周期级别计算: /** * 简化的帧级别步进 * 每次调用渲染一整帧,避免复杂的周期计算 */stepFrame() { // 1. 渲染所有可见扫描线 (0-143) for (let line = 0; line < GPU_TIMINGS.VISIBLE_LINES; line++) { this.currentLine = line; this.mode = GPU_MODES.PIXEL_TRANSFER; this.renderScanline(); } // 2. 模拟垂直消隐期间 (144-153) this.mode = GPU_MODES.VBLANK; this.currentLine = GPU_TIMINGS.VISIBLE_LINES; // 3. 完成帧渲染 this.onFrameComplete(); // 4. 重置到下一帧开始 this.currentLine = 0; this.mode = GPU_MODES.OAM_SEARCH; this.modeClock = 0;} 5. 系统集成:构建完整的模拟器架构 在前面的章节中,我们分别实现了 CPU 指令处理、MMU 内存管理、GPU 时序控制和图形渲染系统。虽然这些组件各自功能完善,但它们就跟分散的乐器差不多,需要一个指挥家来协调,不然是无法演奏出和谐的交响乐的。这一章的核心任务就是建立这样一个统一的系统架构,让所有硬件组件无缝协作。 5.1. 硬件抽象层与组件连接 在真实的 GameBoy 中,各个硬件组件通过物理总线连接。在我们的模拟器中,我们需要建立一个软件层面的「总线系统「,让组件间能够进行通信。MMU 作为内存管理的中心,天然地成为了这个连接中心。 我们为 MMU 添加硬件组件连接能力: /** * 初始化硬件组件引用 */initializeHardwareReferences() { // 硬件组件引用,由外部注入 this.gpu = null; this.inputController = null; // 第6章添加 this.timer = null; // 第10章添加 this.interruptController = null; // 第8章添加 console.log('📡 MMU 硬件接口已初始化');}/** * 连接硬件组件 * @param {string} componentType 组件类型('gpu', 'input', 'timer', 'interrupt') * @param {Object} component 硬件组件实例 */connectHardware(componentType, component) { switch (componentType) { case 'gpu': this.gpu = component; console.log('✅ GPU 已连接到 MMU'); break; case 'input': this.inputController = component; console.log('✅ 输入控制器已连接到 MMU'); break; // ... 其他组件 }} 这种设计的优势在于 松耦合:每个组件都不需要知道其他组件的具体实现,只需要通过 MMU 这个「中介」进行通信。当我们要添加新的硬件组件时,只需要在 MMU 中注册即可。 5.2. I / O 寄存器路由系统 GameBoy 的硬件组件通过内存映射的 I / O 寄存器进行控制。这些寄存器位于 0xFF00-0xFF7F 地址范围内,不同的地址控制不同的硬件功能。我们需要建立一个路由系统,将对这些地址的读写操作转发给相应的硬件组件。 首先,定义一个完整的寄存器地址映射: /** * I/O 寄存器映射常量 */const IO_REGISTERS = { // GPU 寄存器范围(0xFF40-0xFF7F) GPU_START: 0xFF40, GPU_END: 0xFF7F, // 具体的 GPU 寄存器 LCDC: 0xFF40, // LCD 控制寄存器 STAT: 0xFF41, // LCD 状态寄存器 SCY: 0xFF42, // 垂直滚动 SCX: 0xFF43, // 水平滚动 LY: 0xFF44, // 当前扫描线(只读) BGP: 0xFF47, // 背景调色板 // 其他硬件寄存器 JOYPAD: 0xFF00, // 按键输入(第6章实现) IF: 0xFF0F, // 中断标志 IE: 0xFFFF // 中断使能}; MMU 中的寄存器读写方法会根据地址范围自动将请求路由到对应的硬件组件: /** * 连接硬件组件 * @param {string} componentType - 组件类型 ('gpu', 'input', 'timer', 'interrupt') * @param {Object} component - 硬件组件实例 */connectHardware(componentType, component) { switch (componentType) { case 'gpu': this.gpu = component; console.log('✅ GPU 已连接到 MMU'); break; case 'input': this.inputController = component; console.log('✅ 输入控制器已连接到 MMU'); break; case 'timer': this.timer = component; console.log('✅ 定时器已连接到 MMU'); break; case 'interrupt': this.interruptController = component; console.log('✅ 中断控制器已连接到 MMU'); break; default: console.warn(`⚠️ 未知的硬件组件类型: ${componentType}`); }} 不同的寄存器地址控制不同的硬件功能,MMU 需要将读写操作正确路由到对应的硬件组件: /** * 读取I/O寄存器 - 支持硬件组件路由 */readIORegister(address) { // 🎨 GPU 寄存器范围 (0xFF40-0xFF7F) if (address >= IO_REGISTERS.GPU_START && address <= IO_REGISTERS.GPU_END) { if (this.gpu && typeof this.gpu.readRegister === 'function') { return this.gpu.readRegister(address); } else { console.warn(`⚠️ GPU 未连接,无法读取寄存器 0x${address.toString(16)}`); return 0xFF; } } // TODO: 以下都会在未来的章节中实现 // 🎮 按键输入寄存器 (0xFF00) if (address === IO_REGISTERS.JOYPAD) { if (this.inputController && typeof this.inputController.readJoypadRegister === 'function') { return this.inputController.readJoypadRegister(); } return 0xFF; // 默认:所有按键未按下 } // ⏰ 定时器寄存器 (0xFF04-0xFF07) if (address >= IO_REGISTERS.TIMER_DIV && address <= IO_REGISTERS.TIMER_TAC) { if (this.timer && typeof this.timer.readRegister === 'function') { return this.timer.readRegister(address); } return 0x00; } // 🔔 中断寄存器 (0xFF0F, 0xFFFF) if (address === IO_REGISTERS.IF || address === IO_REGISTERS.IE) { if (this.interruptController && typeof this.interruptController.readRegister === 'function') { return this.interruptController.readRegister(address); } return 0x00; } // 默认处理 const registerIndex = address - MEMORY_REGIONS.IO_START; return this.ioRegisters[registerIndex] || 0xFF;} 对应的写入路由逻辑确保了每个硬件组件都能及时收到控制指令: /** * 写入I/O寄存器 - 支持硬件组件路由 * @param {number} address - I/O寄存器地址 * @param {number} value - 要写入的值 */writeIORegister(address, value) { // 🎨 GPU 寄存器范围 (0xFF40-0xFF7F) if (address >= IO_REGISTERS.GPU_START && address <= IO_REGISTERS.GPU_END) { if (this.gpu && typeof this.gpu.writeRegister === 'function') { this.gpu.writeRegister(address, value); } else { console.warn(`⚠️ GPU 未连接,无法写入寄存器 0x${address.toString(16)}`); } // 同时保存到本地数组(用于后备) const registerIndex = address - MEMORY_REGIONS.IO_START; if (registerIndex >= 0 && registerIndex < this.ioRegisters.length) { this.ioRegisters[registerIndex] = value; } return; } // 🎮 按键输入寄存器 (0xFF00) - 第6章实现 if (address === IO_REGISTERS.JOYPAD) { if (this.inputController && typeof this.inputController.writeJoypadRegister === 'function') { this.inputController.writeJoypadRegister(value); } // 保存到本地数组 this.ioRegisters[address - MEMORY_REGIONS.IO_START] = value; return; } // ⏰ 定时器寄存器 (0xFF04-0xFF07) - 第10章实现 if (address >= IO_REGISTERS.TIMER_DIV && address <= IO_REGISTERS.TIMER_TAC) { if (this.timer && typeof this.timer.writeRegister === 'function') { this.timer.writeRegister(address, value); } this.ioRegisters[address - MEMORY_REGIONS.IO_START] = value; return; } // 🔔 中断寄存器 (0xFF0F, 0xFFFF) - 第8章实现 if (address === IO_REGISTERS.IF || address === IO_REGISTERS.IE) { if (this.interruptController && typeof this.interruptController.writeRegister === 'function') { this.interruptController.writeRegister(address, value); } // 特殊处理:IE 寄存器在零页RAM中 if (address === IO_REGISTERS.IE) { this.zram[0x7F] = value; // 0xFFFF -> ZRAM[0x7F] } else { this.ioRegisters[address - MEMORY_REGIONS.IO_START] = value; } return; } // 默认处理:保存到本地寄存器数组 const registerIndex = address - MEMORY_REGIONS.IO_START; if (registerIndex >= 0 && registerIndex < this.ioRegisters.length) { this.ioRegisters[registerIndex] = value; } else { console.warn(`⚠️ 无效的I/O寄存器写入: 0x${address.toString(16).padStart(4, '0')}`); }} 这样,当游戏代码写入 0xFF40(LCD 控制寄存器)时,MMU 会自动将这个写入操作转发给 GPU,GPU 就能实时更新其内部状态。 GPU 是 I / O 寄存器最密集的硬件组件,拥有十多个不同功能的寄存器。这些寄存器不仅控制显示行为,还影响中断、DMA 传输等关键功能。 /** * GPU 寄存器常量定义 */const GPU_REGISTERS = { // LCD 控制和状态 LCDC: 0xFF40, // LCD 控制寄存器 STAT: 0xFF41, // LCD 状态寄存器 // 滚动位置 SCY: 0xFF42, // 垂直滚动 SCX: 0xFF43, // 水平滚动 // 扫描线相关 LY: 0xFF44, // 当前扫描线(只读) LYC: 0xFF45, // 扫描线比较值 // DMA 传输 DMA: 0xFF46, // DMA 传输寄存器 // 调色板 BGP: 0xFF47, // 背景调色板 OBP0: 0xFF48, // 精灵调色板 0 OBP1: 0xFF49, // 精灵调色板 1 // 窗口位置 WY: 0xFF4A, // 窗口 Y 位置 WX: 0xFF4B // 窗口 X 位置};/** * LCD 控制寄存器标志位 */const LCDC_FLAGS = { LCD_ENABLE: 0x80, // 位 7:LCD 开关 WINDOW_TILEMAP: 0x40, // 位 6:窗口瓦片地图选择 WINDOW_ENABLE: 0x20, // 位 5:窗口开关 BG_TILESET: 0x10, // 位 4:背景瓦片数据选择 BG_TILEMAP: 0x08, // 位 3:背景瓦片地图选择 SPRITE_SIZE: 0x04, // 位 2:精灵大小(0=8x8, 1=8x16) SPRITE_ENABLE: 0x02, // 位 1:精灵开关 BG_ENABLE: 0x01 // 位 0:背景开关}; GPU 内部维护所有寄存器的当前状态: /** * 初始化寄存器状态 */initializeRegisters() { // GPU 寄存器状态 this.registers = { // LCD 控制寄存器 (0xFF40) lcdc: 0x00, // LCD 状态寄存器 (0xFF41) stat: 0x00, // 滚动寄存器 scy: 0x00, // 垂直滚动 (0xFF42) scx: 0x00, // 水平滚动 (0xFF43) // 扫描线寄存器 ly: 0x00, // 当前扫描线 (0xFF44) - 只读 lyc: 0x00, // 扫描线比较值 (0xFF45) // 调色板寄存器 bgp: 0xFC, // 背景调色板 (0xFF47) - 默认值 // 窗口位置寄存器 wy: 0x00, // 窗口 Y 位置 (0xFF4A) wx: 0x00 // 窗口 X 位置 (0xFF4B) }; // 解析控制标志位到独立变量(向后兼容) this.updateControlFlags();} LCD 控制寄存器(LCDC)是一个特殊的寄存器,它的每一位都控制不同的显示功能: /** * 从寄存器值更新控制标志位 */updateControlFlags() { const lcdc = this.registers.lcdc; // LCD 控制标志 this._lcdEnabled = (lcdc & LCDC_FLAGS.LCD_ENABLE) !== 0; this._windowEnabled = (lcdc & LCDC_FLAGS.WINDOW_ENABLE) !== 0; this._spritesEnabled = (lcdc & LCDC_FLAGS.SPRITE_ENABLE) !== 0; this._backgroundEnabled = (lcdc & LCDC_FLAGS.BG_ENABLE) !== 0; // 瓦片和地图选择 this._backgroundTileSet = (lcdc & LCDC_FLAGS.BG_TILESET) !== 0 ? 1 : 0; this._backgroundTileMap = (lcdc & LCDC_FLAGS.BG_TILEMAP) !== 0 ? 1 : 0; this._windowTileMap = (lcdc & LCDC_FLAGS.WINDOW_TILEMAP) !== 0 ? 1 : 0; this._spriteSize = (lcdc & LCDC_FLAGS.SPRITE_SIZE) !== 0 ? 16 : 8;} GPU 提供完整的寄存器读写接口: /** * 读取 GPU 寄存器 * @param {number} address - 寄存器地址 * @returns {number} 寄存器值 */readRegister(address) { switch (address) { // LCD 控制寄存器 (0xFF40) case GPU_REGISTERS.LCDC: return this.registers.lcdc; // LCD 状态寄存器 (0xFF41) case GPU_REGISTERS.STAT: // 组合状态标志位和当前模式 let stat = this.registers.stat & 0xF8; // 保留高 5 位 stat |= (this.mode & 0x03); // 添加当前模式 // 检查 LYC=LY 标志 if (this.registers.ly === this.registers.lyc) { stat |= STAT_FLAGS.LYC_FLAG; } return stat; // 滚动寄存器 case GPU_REGISTERS.SCY: return this.registers.scy; case GPU_REGISTERS.SCX: return this.registers.scx; // 当前扫描线 (只读) case GPU_REGISTERS.LY: return this.registers.ly; // 扫描线比较值 case GPU_REGISTERS.LYC: return this.registers.lyc; // DMA 寄存器 (写入专用,读取返回上次写入值) case GPU_REGISTERS.DMA: return this.registers.dma; // 调色板寄存器 case GPU_REGISTERS.BGP: return this.registers.bgp; case GPU_REGISTERS.OBP0: return this.registers.obp0; case GPU_REGISTERS.OBP1: return this.registers.obp1; // 窗口位置寄存器 case GPU_REGISTERS.WY: return this.registers.wy; case GPU_REGISTERS.WX: return this.registers.wx; default: console.warn(`⚠️ 尝试读取未知的 GPU 寄存器: 0x${address.toString(16).padStart(4, '0')}`); return 0xFF; }}/** * 写入 GPU 寄存器 * @param {number} address - 寄存器地址 * @param {number} value - 要写入的值 */writeRegister(address, value) { // 确保值在 8 位范围内 value &= 0xFF; switch (address) { // LCD 控制寄存器 (0xFF40) case GPU_REGISTERS.LCDC: this.registers.lcdc = value; this.updateControlFlags(); // 当 LCD 被禁用时,重置一些状态 if (!this._lcdEnabled) { this.currentLine = 0; this.registers.ly = 0; this.mode = GPU_MODES.HBLANK; this.modeClock = 0; } console.log(`🎛️ LCD 控制寄存器更新: 0x${value.toString(16).padStart(2, '0')}`); break; // LCD 状态寄存器 (0xFF41) - 只有高 5 位可写 case GPU_REGISTERS.STAT: this.registers.stat = (value & 0xF8) | (this.registers.stat & 0x07); break; // 滚动寄存器 case GPU_REGISTERS.SCY: this.registers.scy = value; // 通知图形系统滚动更新 if (this.graphicsSystem && this.graphicsSystem.scrollController) { this.graphicsSystem.scrollController.setScrollY(value); } break; case GPU_REGISTERS.SCX: this.registers.scx = value; // 通知图形系统滚动更新 if (this.graphicsSystem && this.graphicsSystem.scrollController) { this.graphicsSystem.scrollController.setScrollX(value); } break; // 当前扫描线 (只读寄存器,写入被忽略) case GPU_REGISTERS.LY: console.warn(`⚠️ 尝试写入只读寄存器 LY: 0x${value.toString(16)}`); break; // 扫描线比较值 case GPU_REGISTERS.LYC: this.registers.lyc = value; break; // DMA 传输寄存器 (0xFF46) case GPU_REGISTERS.DMA: this.registers.dma = value; this.performDMATransfer(value); break; // 背景调色板 (0xFF47) case GPU_REGISTERS.BGP: this.registers.bgp = value; this.updateBackgroundPalette(value); console.log(`🎨 背景调色板更新: 0x${value.toString(16).padStart(2, '0')}`); break; // 精灵调色板寄存器 case GPU_REGISTERS.OBP0: this.registers.obp0 = value; // TODO: 实现精灵调色板更新 (第7章) break; case GPU_REGISTERS.OBP1: this.registers.obp1 = value; // TODO: 实现精灵调色板更新 (第7章) break; // 窗口位置寄存器 case GPU_REGISTERS.WY: this.registers.wy = value; break; case GPU_REGISTERS.WX: this.registers.wx = value; break; default: console.warn(`⚠️ 尝试写入未知的 GPU 寄存器: 0x${address.toString(16).padStart(4, '0')} = 0x${value.toString(16).padStart(2, '0')}`); }} DMA(直接内存访问)是 GameBoy 的重要功能,允许快速复制精灵数据到 OAM: /** * 执行 DMA 传输 * @param {number} sourceHigh - 源地址高字节 */performDMATransfer(sourceHigh) { // DMA 传输:将 256 字节从 sourceHigh*0x100 复制到 OAM (0xFE00-0xFE9F) const sourceAddr = sourceHigh << 8; console.log(`🚚 DMA 传输: 0x${sourceAddr.toString(16).padStart(4, '0')} -> OAM`); // TODO: 实现完整的 DMA 传输逻辑 // 需要从 MMU 读取数据并写入 OAM // 在真实硬件中,这会锁定总线并需要特定的时序} /** * 更新背景调色板 * @param {number} paletteValue - 调色板寄存器值 */updateBackgroundPalette(paletteValue) { if (this.graphicsSystem && this.graphicsSystem.paletteManager) { this.graphicsSystem.paletteManager.updatePaletteRegister(paletteValue); }} 5.4. 系统调度器架构 现在我们有了能够相互通信的硬件组件,但还需要一个「指挥家」来协调它们的工作。这就是系统调度器 gameboy.js 的作用。 系统调度器作为顶层管理器,负责创建、连接和管理所有硬件组件: /** * 系统常量定义 */const SYSTEM_CONSTANTS = { // 时序常量 CPU_FREQUENCY: 4194304, // 4.194304 MHz TARGET_FPS: 59.7, // GameBoy 目标帧率 FRAME_CYCLES: 70224, // 每帧的 CPU 周期数 // 系统状态 STATE_STOPPED: 'stopped', STATE_RUNNING: 'running', STATE_PAUSED: 'paused', STATE_ERROR: 'error', // 调度模式 SCHEDULE_FRAME: 'frame', // 帧级别调度(默认) SCHEDULE_CYCLE: 'cycle', // 周期级别调度(精确) SCHEDULE_BURST: 'burst' // 突发调度(性能优先)};/** * GameBoy 主系统类 * 协调 CPU、MMU、GPU 和其他硬件组件的工作 */class GameBoySystem { constructor() { // 系统状态 this.state = SYSTEM_CONSTANTS.STATE_STOPPED; this.scheduleMode = SYSTEM_CONSTANTS.SCHEDULE_FRAME; // 硬件组件 this.cpu = null; this.mmu = null; this.gpu = null; this.graphics = null; // 调度器状态 this.runningInterval = null; this.frameRequestId = null; // 性能统计 this.stats = { framesRendered: 0, cyclesExecuted: 0, startTime: 0, currentFPS: 0, averageFPS: 0, errors: 0 }; }} 系统调度器的初始化过程展示了完整的组件连接流程: /** * 创建硬件组件实例 */async createHardwareComponents() { // 创建 MMU(内存管理单元) this.mmu = new GameBoyMMU(); console.log('✅ MMU 已创建'); // 创建 GPU(图形处理单元) this.gpu = new GameBoyGPU(); console.log('✅ GPU 已创建'); // 获取图形系统引用 if (this.gpu.graphicsSystem) { this.graphics = this.gpu.graphicsSystem; console.log('✅ 图形系统已连接'); } // 创建 CPU(中央处理器) this.cpu = new GameBoyCPU(); console.log('✅ CPU 已创建');}/** * 连接硬件组件 */connectComponents() { // 连接 GPU 到 MMU this.mmu.connectHardware('gpu', this.gpu); // 连接 GPU 到 VRAM if (this.mmu.vram) { this.gpu.connectVRAM(this.mmu.vram); // 连接图形系统到 VRAM if (this.graphics) { this.graphics.connectVRAM(this.mmu.vram); } } console.log('🔗 硬件组件连接完成');} 这个连接过程确保了: GPU 能够通过 MMU 接收寄存器访问 GPU 能够直接访问 VRAM 进行渲染 图形系统能够接收 VRAM 更新通知 5.5. 帧级别时序调度 先前我们实现了精确的周期级别时序,但在实际运行中这种精确度往往会带来性能负担,因此我们当时引入了帧级别调度,在保持足够精度的同时大幅提升性能。现在我们完善一下当时只是简化版本的 stepFrame()。 /** * 每次调用渲染一整帧,避免复杂的周期计算 */stepFrame() { // 如果 LCD 被禁用,不渲染 if (!this._lcdEnabled) { return; } // 1. 渲染所有可见扫描线 (0-143) for (let line = 0; line < GPU_TIMINGS.VISIBLE_LINES; line++) { this.currentLine = line; this.registers.ly = line; this.mode = GPU_MODES.PIXEL_TRANSFER; this.renderScanline(); } // 2. 模拟垂直消隐期间 (144-153) this.mode = GPU_MODES.VBLANK; this.currentLine = GPU_TIMINGS.VISIBLE_LINES; this.registers.ly = GPU_TIMINGS.VISIBLE_LINES; // 3. 完成帧渲染 this.onFrameComplete(); // 4. 重置到下一帧开始 this.currentLine = 0; this.registers.ly = 0; this.mode = GPU_MODES.OAM_SEARCH; this.modeClock = 0;} 系统调度器支持多种调度模式以适应不同的需求: /** * 帧级别调度循环(默认模式) */startFrameLoop() { const frameLoop = (currentTime) => { if (this.state !== SYSTEM_CONSTANTS.STATE_RUNNING) { return; } try { // 计算时间差 const deltaTime = currentTime - this.lastFrameTime; // 如果达到目标帧时间,执行一帧 if (deltaTime >= this.targetFrameTime) { this.executeFrame(); this.lastFrameTime = currentTime; this.updateFPS(deltaTime); } // 请求下一帧 this.frameRequestId = requestAnimationFrame(frameLoop); } catch (error) { this.handleError(error); } }; // 启动循环 this.frameRequestId = requestAnimationFrame(frameLoop);}/** * 执行一帧的渲染 */executeFrame() { // TODO: 简化的帧执行:直接让 GPU 渲染一帧 if (this.gpu) { this.gpu.stepFrame(); } // 更新统计 this.stats.framesRendered++; this.stats.cyclesExecuted += SYSTEM_CONSTANTS.FRAME_CYCLES;} 这种设计的优势是: 性能优化:避免了复杂的周期级别计算 稳定的帧率:使用 requestAnimationFrame 确保流畅的 60 FPS 灵活性:可以根据需要切换到其他调度模式 5.6. 错误处理与系统监控 一个健壮的模拟器必须能够优雅地处理错误并提供丰富的监控信息。系统调度器实现了多层次的错误处理: /** * 错误处理 */handleError(error) { this.stats.errors++; console.error(`🚨 系统错误 #${this.stats.errors}:`, error); // 如果错误过多,停止系统 if (this.stats.errors > 10) { console.error('❌ 错误过多,停止系统运行'); this.stop(); this.state = SYSTEM_CONSTANTS.STATE_ERROR; return; } // 尝试恢复 this.retryCount++; if (this.retryCount < this.maxRetries) { console.log(`🔄 尝试恢复系统 (${this.retryCount}/${this.maxRetries})`); setTimeout(() => { if (this.state === SYSTEM_CONSTANTS.STATE_ERROR) { this.reset(); } }, 1000); } else { console.error('❌ 恢复失败,系统进入错误状态'); this.state = SYSTEM_CONSTANTS.STATE_ERROR; }} 系统提供了丰富的性能统计信息: /** * 更新 FPS 计算 * @param {number} deltaTime - 帧时间差 */updateFPS(deltaTime) { this.stats.currentFPS = deltaTime > 0 ? 1000 / deltaTime : 0;}/** * 计算平均 FPS */calculateAverageFPS() { const totalTime = (performance.now() - this.stats.startTime) / 1000; this.stats.averageFPS = totalTime > 0 ? this.stats.framesRendered / totalTime : 0;}/** * 获取系统状态 */getStatus() { const status = { state: this.state, scheduleMode: this.scheduleMode, statistics: { ...this.stats }, components: { cpu: this.cpu ? this.cpu.getStatusString() : '未初始化', mmu: this.mmu ? this.mmu.getMemoryStats() : '未初始化', gpu: this.gpu ? this.gpu.getStatus() : '未初始化' } }; // 计算实时统计 if (this.stats.startTime > 0) { const totalTime = (performance.now() - this.stats.startTime) / 1000; status.statistics.totalRunTime = totalTime; status.statistics.averageFPS = totalTime > 0 ? this.stats.framesRendered / totalTime : 0; } return status;}/** * 获取调试信息 * @returns {string} 格式化的调试信息 */getDebugInfo() { const status = this.getStatus(); let debugInfo = `🎮 === GameBoy 系统状态 ===系统状态:${status.state}调度模式:${status.scheduleMode}运行时间:${status.statistics.totalRunTime?.toFixed(2) || 0}秒已渲染帧数:${status.statistics.framesRendered}执行周期数:${status.statistics.cyclesExecuted}当前 FPS:${status.statistics.currentFPS?.toFixed(2) || 0}平均 FPS:${status.statistics.averageFPS?.toFixed(2) || 0}错误计数:${status.statistics.errors}📱 硬件组件状态:`; // CPU 状态 if (this.cpu) { debugInfo += `\n\n💻 CPU 状态:\n${this.cpu.getStatusString()}`; } // GPU 状态 if (this.gpu) { debugInfo += `\n\n🎨 GPU 状态:\n${this.gpu.getDebugInfo()}`; } // MMU 状态 if (this.mmu) { const mmuStats = this.mmu.getMemoryStats(); debugInfo += `\n\n💾 MMU 状态:BIOS 启用:${mmuStats.biosEnabled ? '是' : '否'}ROM 已加载:${mmuStats.romLoaded ? '是' : '否'}硬件连接:GPU: ${mmuStats.hardwareConnections.gpu ? '✅' : '❌'}输入: ${mmuStats.hardwareConnections.input ? '✅' : '❌'}定时器: ${mmuStats.hardwareConnections.timer ? '✅' : '❌'}中断: ${mmuStats.hardwareConnections.interrupt ? '✅' : '❌'}`; } return debugInfo;}/** * 获取内存转储 * @param {number} address - 起始地址 * @param {number} length - 长度 * @returns {string} 内存转储 */getMemoryDump(address, length = 256) { if (!this.mmu) { return '❌ MMU 未初始化'; } return this.mmu.getMemoryDump(address, length);}/** * 设置调试模式 * @param {boolean} enabled - 是否启用调试模式 */setDebugMode(enabled) { window.DEBUG_MODE = enabled; console.log(`🔧 调试模式${enabled ? '已启用' : '已禁用'}`);}/** * 销毁系统(清理资源) */destroy() { console.log('💥 销毁 GameBoy 系统...'); // 停止运行 this.stop(); // 清理组件引用 this.cpu = null; this.mmu = null; this.gpu = null; this.graphics = null; // 清理全局引用 if (window.MMU) delete window.MMU; if (window.GPU) delete window.GPU; if (window.CPU) delete window.CPU; if (window.Graphics) delete window.Graphics; console.log('✅ 系统已销毁');} 5.7. 现代化用户界面 展开以查看 <!DOCTYPE html><html lang="zh-CN"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>GameBoy 模拟器 - JavaScript 版本</title> <style> /* 全局样式重置 */ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: 'Courier New', monospace; background: linear-gradient(135deg, #2c5530, #4a7c59); color: #000; min-height: 100vh; padding: 20px; display: flex; justify-content: center; align-items: center; } /* 主容器 */ .gameboy-container { background: linear-gradient(145deg, #8b956d, #9bb583); border-radius: 20px 20px 60px 20px; padding: 40px; box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3), inset 0 2px 4px rgba(255, 255, 255, 0.1); max-width: 700px; width: 100%; } /* 顶部标题区域 */ .header { text-align: center; margin-bottom: 30px; } .title { font-size: 28px; font-weight: bold; color: #2c5530; text-shadow: 1px 1px 0 rgba(255, 255, 255, 0.3), 2px 2px 4px rgba(0, 0, 0, 0.2); margin-bottom: 10px; letter-spacing: 2px; } .subtitle { font-size: 14px; color: #5a6b4d; margin-bottom: 5px; } .version { font-size: 12px; color: #6b7a5e; font-style: italic; } /* 屏幕区域 */ .screen-container { background: linear-gradient(145deg, #4a5c3a, #5a6c4a); border-radius: 15px; padding: 25px; margin: 30px auto; box-shadow: inset 0 4px 8px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(255, 255, 255, 0.1); position: relative; width: fit-content; } .screen-bezel { background: linear-gradient(145deg, #2c3624, #3c4634); border-radius: 10px; padding: 15px; box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.5); } /* GameBoy 屏幕 Canvas */ #screen { display: block; width: 320px; height: 288px; background: #9bbc0f; border-radius: 5px; image-rendering: pixelated; image-rendering: -moz-crisp-edges; image-rendering: crisp-edges; box-shadow: inset 0 0 0 2px #8bac0f, inset 0 0 0 4px #5a6b4d; } /* 屏幕标签 */ .screen-label { position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%); font-size: 10px; color: #6b7a5e; letter-spacing: 1px; } /* 系统状态指示器 */ .status-indicator { position: absolute; top: 15px; right: 15px; display: flex; gap: 8px; font-size: 10px; } .indicator { width: 12px; height: 12px; border-radius: 50%; border: 1px solid #333; } .indicator.running { background: #00ff00; } .indicator.stopped { background: #ff0000; } .indicator.paused { background: #ffff00; } .indicator.error { background: #ff8800; } /* 控制面板 */ .controls { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 30px; } .control-section { background: linear-gradient(145deg, #7a8a6d, #8a9a7d); border-radius: 12px; padding: 20px; box-shadow: inset 0 2px 4px rgba(255, 255, 255, 0.1), 0 4px 8px rgba(0, 0, 0, 0.2); } .control-title { font-size: 14px; font-weight: bold; color: #2c5530; margin-bottom: 15px; text-align: center; border-bottom: 1px solid #5a6b4d; padding-bottom: 8px; } /* 按钮样式 */ .btn { background: linear-gradient(145deg, #a5b588, #95a578); border: none; border-radius: 8px; padding: 12px 20px; color: #2c5530; font-weight: bold; font-size: 12px; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 3px 6px rgba(0, 0, 0, 0.2), inset 0 1px 2px rgba(255, 255, 255, 0.2); margin: 5px; width: calc(100% - 10px); position: relative; } .btn:hover { background: linear-gradient(145deg, #b5c598, #a5b588); transform: translateY(-1px); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3), inset 0 1px 2px rgba(255, 255, 255, 0.3); } .btn:active { transform: translateY(1px); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2), inset 0 2px 4px rgba(0, 0, 0, 0.1); } .btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; } .btn.primary { background: linear-gradient(145deg, #5a7c4a, #4a6c3a); color: #fff; } .btn.primary:hover:not(:disabled) { background: linear-gradient(145deg, #6a8c5a, #5a7c4a); } .btn.danger { background: linear-gradient(145deg, #a56565, #955555); color: #fff; } .btn.danger:hover:not(:disabled) { background: linear-gradient(145deg, #b57575, #a56565); } .btn.success { background: linear-gradient(145deg, #5a8a5a, #4a7a4a); color: #fff; } .btn.success:hover:not(:disabled) { background: linear-gradient(145deg, #6a9a6a, #5a8a5a); } /* 按钮状态指示 */ .btn.loading::after { content: ''; position: absolute; right: 10px; top: 50%; transform: translateY(-50%); width: 12px; height: 12px; border: 2px solid transparent; border-top: 2px solid currentColor; border-radius: 50%; animation: spin 1s linear infinite; } @keyframes spin { to { transform: translateY(-50%) rotate(360deg); } } /* 状态显示区域 */ .status-panel { margin-top: 30px; background: linear-gradient(145deg, #3c4634, #4c5644); border-radius: 12px; padding: 20px; box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.3); } .status-title { color: #9bb583; font-size: 14px; font-weight: bold; margin-bottom: 15px; text-align: center; } .status-content { background: #1a1a1a; color: #00ff00; font-family: 'Courier New', monospace; font-size: 11px; padding: 15px; border-radius: 6px; height: 250px; overflow-y: auto; border: 1px solid #333; white-space: pre-wrap; } /* 性能面板 */ .performance-panel { margin-top: 20px; background: linear-gradient(145deg, #2c3624, #3c4634); border-radius: 12px; padding: 20px; box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.3); } .performance-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 15px; margin-top: 15px; } .performance-item { text-align: center; padding: 10px; background: rgba(0, 0, 0, 0.3); border-radius: 8px; } .performance-value { display: block; font-size: 18px; font-weight: bold; color: #00ff00; margin-bottom: 5px; } .performance-label { font-size: 10px; color: #9bb583; text-transform: uppercase; } /* 响应式设计 */ @media (max-width: 768px) { .gameboy-container { padding: 20px; margin: 10px; } #screen { width: 240px; height: 216px; } .controls { grid-template-columns: 1fr; gap: 15px; } .title { font-size: 24px; } .performance-grid { grid-template-columns: repeat(2, 1fr); } } /* 信息提示 */ .info-box { background: linear-gradient(145deg, #6a7c5a, #7a8c6a); border-radius: 8px; padding: 15px; margin: 20px 0; border-left: 4px solid #4a6c3a; color: #2c5530; font-size: 13px; line-height: 1.5; } .info-box h4 { margin-bottom: 8px; color: #1a3520; } /* 键盘快捷键显示 */ .keyboard-hint { font-size: 10px; color: #6b7a5e; text-align: center; margin-top: 10px; } .key { background: #5a6b4d; color: #fff; padding: 2px 6px; border-radius: 3px; font-weight: bold; margin: 0 2px; } </style></head><body><div class="gameboy-container"> <!-- 顶部标题区域 --> <div class="header"> <h1 class="title">GAME BOY</h1> <div class="subtitle">JavaScript 模拟器</div> <div class="version">版本 0.5.0 - 系统集成</div> </div> <!-- 屏幕区域 --> <div class="screen-container"> <div class="status-indicator"> <div class="indicator stopped" id="system-indicator" title="系统状态"></div> </div> <div class="screen-bezel"> <canvas id="screen" width="160" height="144"></canvas> <div class="screen-label">DOT MATRIX WITH STEREO SOUND</div> </div> </div> <!-- 控制面板 --> <div class="controls"> <!-- 系统控制 --> <div class="control-section"> <div class="control-title">🎮 系统控制</div> <button class="btn primary" id="init-btn">初始化系统</button> <button class="btn success" id="start-btn" disabled>开始运行</button> <button class="btn" id="pause-btn" disabled>暂停/恢复</button> <button class="btn danger" id="stop-btn" disabled>停止运行</button> <button class="btn" id="reset-btn" disabled>重置系统</button> <div class="keyboard-hint"> 快捷键:<span class="key">Space</span> 开始/暂停 <span class="key">R</span> 重置 </div> </div> <!-- 程序加载 --> <div class="control-section"> <div class="control-title">📦 程序加载</div> <button class="btn" id="load-demo-btn" disabled>加载演示程序</button> <button class="btn" id="load-rom-btn" disabled>加载 ROM 文件</button> <input type="file" id="rom-file-input" accept=".gb,.gbc" style="display: none;"> <button class="btn" id="step-btn" disabled>单步执行</button> <div class="keyboard-hint"> 快捷键:<span class="key">L</span> 加载演示 <span class="key">S</span> 单步 </div> </div> </div> <!-- 调试和图形控制 --> <div class="controls"> <!-- 调试工具 --> <div class="control-section"> <div class="control-title">🔧 调试工具</div> <button class="btn" id="show-status-btn">显示系统状态</button> <button class="btn" id="show-memory-btn">内存转储</button> <button class="btn" id="show-registers-btn">CPU 寄存器</button> <button class="btn" id="toggle-debug-btn">切换调试模式</button> <div class="keyboard-hint"> 快捷键:<span class="key">D</span> 调试 <span class="key">M</span> 内存 </div> </div> <!-- 图形控制 --> <div class="control-section"> <div class="control-title">🎨 图形控制</div> <button class="btn" id="toggle-render-btn">切换渲染模式</button> <button class="btn" id="show-graphics-btn">图形系统状态</button> <button class="btn" id="test-graphics-btn">测试图形渲染</button> <button class="btn danger" id="clear-console-btn">清空控制台</button> <div class="keyboard-hint"> 快捷键:<span class="key">G</span> 图形模式 <span class="key">T</span> 测试 </div> </div> </div> <!-- 性能监控面板 --> <div class="performance-panel"> <div class="status-title">📊 实时性能监控</div> <div class="performance-grid"> <div class="performance-item"> <span class="performance-value" id="fps-value">0.0</span> <span class="performance-label">FPS</span> </div> <div class="performance-item"> <span class="performance-value" id="frames-value">0</span> <span class="performance-label">帧数</span> </div> <div class="performance-item"> <span class="performance-value" id="instructions-value">0</span> <span class="performance-label">指令数</span> </div> <div class="performance-item"> <span class="performance-value" id="cycles-value">0</span> <span class="performance-label">周期数</span> </div> <div class="performance-item"> <span class="performance-value" id="errors-value">0</span> <span class="performance-label">错误数</span> </div> <div class="performance-item"> <span class="performance-value" id="uptime-value">0s</span> <span class="performance-label">运行时间</span> </div> </div> </div> <!-- 状态显示面板 --> <div class="status-panel"> <div class="status-title">📋 系统控制台 & 调试输出</div> <div id="console-output" class="status-content">GameBoy 模拟器 v0.5.0 已准备就绪...🎮 第五章:系统集成✨ 新功能:完整的系统架构、CPU 指令集、寄存器控制等待系统初始化...使用说明:1. 点击"初始化系统"启动模拟器2. 点击"加载演示程序"或加载 ROM 文件3. 点击"开始运行"启动系统4. 观察实时性能监控和调试输出5. 使用调试工具查看系统状态组件状态:- CPU: 等待初始化...- MMU: 等待初始化...- GPU: 等待初始化...- 图形系统: 等待初始化...- 系统调度器: 等待初始化... </div> </div></div><!-- JavaScript 文件导入 --><script src="mmu.js"></script><script src="graphics.js"></script><script src="gpu.js"></script><script src="cpu.js"></script><script src="gameboy.js"></script><!-- 主程序脚本 --><script>(function() { 'use strict'; // 全局变量 let systemInstance = null; // 避免与 gameboy.js 中的变量冲突 let performanceUpdateInterval = null; let isDebugMode = false; // UI 元素引用 const elements = { // 按钮 initBtn: document.getElementById('init-btn'), startBtn: document.getElementById('start-btn'), pauseBtn: document.getElementById('pause-btn'), stopBtn: document.getElementById('stop-btn'), resetBtn: document.getElementById('reset-btn'), loadDemoBtn: document.getElementById('load-demo-btn'), loadRomBtn: document.getElementById('load-rom-btn'), stepBtn: document.getElementById('step-btn'), showStatusBtn: document.getElementById('show-status-btn'), showMemoryBtn: document.getElementById('show-memory-btn'), showRegistersBtn: document.getElementById('show-registers-btn'), toggleDebugBtn: document.getElementById('toggle-debug-btn'), toggleRenderBtn: document.getElementById('toggle-render-btn'), showGraphicsBtn: document.getElementById('show-graphics-btn'), testGraphicsBtn: document.getElementById('test-graphics-btn'), clearConsoleBtn: document.getElementById('clear-console-btn'), // 状态指示器 systemIndicator: document.getElementById('system-indicator'), // 性能显示 fpsValue: document.getElementById('fps-value'), framesValue: document.getElementById('frames-value'), instructionsValue: document.getElementById('instructions-value'), cyclesValue: document.getElementById('cycles-value'), errorsValue: document.getElementById('errors-value'), uptimeValue: document.getElementById('uptime-value'), // 控制台输出 consoleOutput: document.getElementById('console-output'), // 文件输入 romFileInput: document.getElementById('rom-file-input') }; // 添加日志输出功能 function addLog(message, type = 'info') { const timestamp = new Date().toLocaleTimeString(); const prefix = { 'info': '📝', 'success': '✅', 'warning': '⚠️', 'error': '❌', 'debug': '🔧' }[type] || '📝'; elements.consoleOutput.textContent += `[${timestamp}] ${prefix} ${message}\n`; elements.consoleOutput.scrollTop = elements.consoleOutput.scrollHeight; } // 重写 console.log 以在页面上显示 const originalConsoleLog = console.log; const originalConsoleError = console.error; const originalConsoleWarn = console.warn; console.log = function(...args) { originalConsoleLog.apply(console, args); const message = args.join(' '); // 只有重要消息才显示到页面 const importantKeywords = ['✅', '❌', '⚠️', '🎮', '📊', '🔧', '初始化', '加载', '错误', '完成']; if (importantKeywords.some(keyword => message.includes(keyword)) || isDebugMode) { addLog(message); } }; console.error = function(...args) { originalConsoleError.apply(console, args); addLog(args.join(' '), 'error'); }; console.warn = function(...args) { originalConsoleWarn.apply(console, args); addLog(args.join(' '), 'warning'); }; // 更新系统状态指示器 function updateSystemIndicator(state) { const indicator = elements.systemIndicator; indicator.className = `indicator ${state}`; indicator.title = `系统状态: ${state}`; } // 更新按钮状态 function updateButtonStates(systemState) { const isInitialized = systemInstance !== null; const isRunning = systemState === 'running'; const isStopped = systemState === 'stopped'; elements.initBtn.disabled = isInitialized; elements.startBtn.disabled = !isInitialized || isRunning; elements.pauseBtn.disabled = !isInitialized || isStopped; elements.stopBtn.disabled = !isInitialized || isStopped; elements.resetBtn.disabled = !isInitialized; elements.loadDemoBtn.disabled = !isInitialized; elements.loadRomBtn.disabled = !isInitialized; elements.stepBtn.disabled = !isInitialized || isRunning; } // 系统初始化 async function initializeSystem() { try { addLog('正在初始化 GameBoy 系统...', 'info'); elements.initBtn.classList.add('loading'); // 使用系统控制器 systemInstance = await window.GameBoySystemController.init(); updateSystemIndicator('stopped'); updateButtonStates('stopped'); // 启动性能监控 startPerformanceMonitoring(); addLog('✅ GameBoy 系统初始化完成', 'success'); } catch (error) { addLog(`❌ 系统初始化失败: ${error.message}`, 'error'); updateSystemIndicator('error'); } finally { elements.initBtn.classList.remove('loading'); } } // 系统控制函数 function startSystem() { if (!systemInstance) { addLog('❌ 系统未初始化', 'error'); return; } try { window.GameBoySystemController.start(); updateSystemIndicator('running'); updateButtonStates('running'); addLog('🚀 系统开始运行', 'success'); } catch (error) { addLog(`❌ 启动失败: ${error.message}`, 'error'); } } function pauseSystem() { if (!systemInstance) return; try { window.GameBoySystemController.togglePause(); const status = window.GameBoySystemController.getStatus(); updateSystemIndicator(status.state); updateButtonStates(status.state); addLog(`⏸️ 系统${status.state === 'paused' ? '已暂停' : '继续运行'}`, 'info'); } catch (error) { addLog(`❌ 暂停操作失败: ${error.message}`, 'error'); } } function stopSystem() { if (!systemInstance) return; try { window.GameBoySystemController.stop(); updateSystemIndicator('stopped'); updateButtonStates('stopped'); addLog('⏹️ 系统已停止', 'info'); } catch (error) { addLog(`❌ 停止操作失败: ${error.message}`, 'error'); } } function resetSystem() { if (!systemInstance) return; try { window.GameBoySystemController.reset(); updateSystemIndicator('stopped'); updateButtonStates('stopped'); addLog('🔄 系统已重置', 'info'); } catch (error) { addLog(`❌ 重置失败: ${error.message}`, 'error'); } } function stepSystem() { if (!systemInstance) return; try { // TODO: 实现单步执行 addLog('👣 执行单步操作...', 'debug'); } catch (error) { addLog(`❌ 单步执行失败: ${error.message}`, 'error'); } } // 程序加载函数 async function loadDemo() { if (!systemInstance) { addLog('❌ 系统未初始化', 'error'); return; } try { addLog('📦 正在加载演示程序...', 'info'); const success = window.GameBoySystemController.loadDemo(); if (success) { addLog('✅ 演示程序加载完成', 'success'); addLog('💡 现在可以点击"开始运行"启动系统', 'info'); } else { addLog('❌ 演示程序加载失败', 'error'); } } catch (error) { addLog(`❌ 加载演示程序失败: ${error.message}`, 'error'); } } function loadROMFile() { elements.romFileInput.click(); } async function handleROMFile(event) { const file = event.target.files[0]; if (!file) return; try { addLog(`📦 正在加载 ROM 文件: ${file.name}`, 'info'); const arrayBuffer = await file.arrayBuffer(); const success = await window.GameBoySystemController.loadROM(arrayBuffer); if (success) { addLog(`✅ ROM 文件加载完成: ${file.name}`, 'success'); } else { addLog(`❌ ROM 文件加载失败: ${file.name}`, 'error'); } } catch (error) { addLog(`❌ 文件读取失败: ${error.message}`, 'error'); } } // 调试工具函数 function showSystemStatus() { if (!systemInstance) { addLog('❌ 系统未初始化', 'error'); return; } try { const debugInfo = window.GameBoySystemController.getDebugInfo(); addLog('📊 === 系统状态 ===', 'debug'); addLog(debugInfo, 'debug'); addLog('=================', 'debug'); } catch (error) { addLog(`❌ 获取系统状态失败: ${error.message}`, 'error'); } } function showMemoryDump() { if (!systemInstance) { addLog('❌ 系统未初始化', 'error'); return; } try { const system = window.GameBoySystemController.getInstance(); if (system && system.mmu) { addLog('💾 === 内存转储 (0x0000-0x00FF) ===', 'debug'); const dump = system.getMemoryDump(0x0000, 256); addLog(dump, 'debug'); } } catch (error) { addLog(`❌ 内存转储失败: ${error.message}`, 'error'); } } function showCPURegisters() { if (!systemInstance) { addLog('❌ 系统未初始化', 'error'); return; } try { const system = window.GameBoySystemController.getInstance(); if (system && system.cpu) { addLog('💻 === CPU 寄存器状态 ===', 'debug'); addLog(system.cpu.getStatusString(), 'debug'); } } catch (error) { addLog(`❌ 获取 CPU 状态失败: ${error.message}`, 'error'); } } function toggleDebugMode() { isDebugMode = !isDebugMode; window.DEBUG_MODE = isDebugMode; addLog(`🔧 调试模式${isDebugMode ? '已启用' : '已禁用'}`, 'debug'); } // 图形控制函数 function toggleRenderMode() { try { const system = window.GameBoySystemController.getInstance(); if (system && system.gpu) { const currentMode = system.gpu.renderMode || 'test'; const newMode = currentMode === 'test' ? 'graphics' : 'test'; system.gpu.setRenderMode(newMode); addLog(`🎨 渲染模式已切换到:${newMode === 'test' ? '测试模式' : '图形模式'}`, 'info'); } } catch (error) { addLog(`❌ 切换渲染模式失败: ${error.message}`, 'error'); } } function showGraphicsStatus() { try { const system = window.GameBoySystemController.getInstance(); if (system && system.gpu) { addLog('🎨 === 图形系统状态 ===', 'debug'); addLog(system.gpu.getDebugInfo(), 'debug'); } } catch (error) { addLog(`❌ 获取图形状态失败: ${error.message}`, 'error'); } } function testGraphicsSystem() { try { const system = window.GameBoySystemController.getInstance(); if (system && system.gpu) { addLog('🧪 运行图形系统测试...', 'debug'); // 创建测试数据 if (system.mmu && system.mmu.vram) { const vram = system.mmu.vram; // 创建测试瓦片 for (let i = 0; i < 16; i += 2) { vram[0x8000 + i] = Math.floor(Math.random() * 256); vram[0x8000 + i + 1] = Math.floor(Math.random() * 256); } addLog('✅ 测试数据已生成', 'debug'); } // 测试渲染 system.gpu.stepFrame(); addLog('✅ 图形系统测试完成', 'success'); } } catch (error) { addLog(`❌ 图形系统测试失败: ${error.message}`, 'error'); } } function clearConsoleLog() { elements.consoleOutput.textContent = 'GameBoy 模拟器控制台\n已清空...\n\n'; } // 性能监控 function startPerformanceMonitoring() { performanceUpdateInterval = setInterval(updatePerformanceDisplay, 500); } function updatePerformanceDisplay() { if (!systemInstance) return; try { const status = window.GameBoySystemController.getStatus(); if (!status) return; const stats = status.statistics; // 更新显示 elements.fpsValue.textContent = (stats.currentFPS || 0).toFixed(1); elements.framesValue.textContent = stats.framesRendered || 0; elements.instructionsValue.textContent = formatNumber(stats.cyclesExecuted / 4 || 0); elements.cyclesValue.textContent = formatNumber(stats.cyclesExecuted || 0); elements.errorsValue.textContent = stats.errors || 0; elements.uptimeValue.textContent = formatTime(stats.totalRunTime || 0); } catch (error) { // 静默处理性能监控错误 } } function formatNumber(num) { if (num >= 1000000) { return (num / 1000000).toFixed(1) + 'M'; } else if (num >= 1000) { return (num / 1000).toFixed(1) + 'K'; } return num.toString(); } function formatTime(seconds) { if (seconds >= 60) { const minutes = Math.floor(seconds / 60); const remainingSeconds = Math.floor(seconds % 60); return `${minutes}m${remainingSeconds}s`; } return `${Math.floor(seconds)}s`; } // 绑定事件监听器 function bindEventListeners() { elements.initBtn.addEventListener('click', initializeSystem); elements.startBtn.addEventListener('click', startSystem); elements.pauseBtn.addEventListener('click', pauseSystem); elements.stopBtn.addEventListener('click', stopSystem); elements.resetBtn.addEventListener('click', resetSystem); elements.loadDemoBtn.addEventListener('click', loadDemo); elements.loadRomBtn.addEventListener('click', loadROMFile); elements.stepBtn.addEventListener('click', stepSystem); elements.showStatusBtn.addEventListener('click', showSystemStatus); elements.showMemoryBtn.addEventListener('click', showMemoryDump); elements.showRegistersBtn.addEventListener('click', showCPURegisters); elements.toggleDebugBtn.addEventListener('click', toggleDebugMode); elements.toggleRenderBtn.addEventListener('click', toggleRenderMode); elements.showGraphicsBtn.addEventListener('click', showGraphicsStatus); elements.testGraphicsBtn.addEventListener('click', testGraphicsSystem); elements.clearConsoleBtn.addEventListener('click', clearConsoleLog); elements.romFileInput.addEventListener('change', handleROMFile); } // 键盘快捷键 document.addEventListener('keydown', function(event) { // 防止在输入框中触发快捷键 if (event.target.tagName === 'INPUT') return; switch(event.code) { case 'Space': event.preventDefault(); if (systemInstance) { const status = window.GameBoySystemController.getStatus(); if (status.state === 'running') { pauseSystem(); } else { startSystem(); } } break; case 'KeyR': event.preventDefault(); resetSystem(); break; case 'KeyL': event.preventDefault(); loadDemo(); break; case 'KeyS': event.preventDefault(); stepSystem(); break; case 'KeyD': event.preventDefault(); showSystemStatus(); break; case 'KeyM': event.preventDefault(); showMemoryDump(); break; case 'KeyG': event.preventDefault(); toggleRenderMode(); break; case 'KeyT': event.preventDefault(); testGraphicsSystem(); break; } }); // 页面加载完成后的初始化 window.addEventListener('load', function() { bindEventListeners(); addLog('🎮 GameBoy 模拟器 v0.5.0 已准备就绪', 'success'); addLog('📚 当前版本:第五章 - 系统集成', 'info'); addLog('✨ 新功能:完整系统架构、CPU 指令集、寄存器控制', 'info'); addLog('💡 点击"初始化系统"开始使用', 'info'); // 检查组件加载状态 setTimeout(() => { const components = [ { name: 'MMU', check: () => typeof GameBoyMMU !== 'undefined' }, { name: 'GPU', check: () => typeof GameBoyGPU !== 'undefined' }, { name: 'CPU', check: () => typeof GameBoyCPU !== 'undefined' }, { name: '图形系统', check: () => typeof GameBoyGraphicsSystem !== 'undefined' }, { name: '系统控制器', check: () => typeof GameBoySystem !== 'undefined' } ]; components.forEach(comp => { if (comp.check()) { addLog(`✅ ${comp.name} 组件已加载`, 'success'); } else { addLog(`❌ ${comp.name} 组件加载失败`, 'error'); } }); addLog('🔧 所有组件检查完成,可以开始初始化', 'info'); }, 500); }); // 页面卸载时清理 window.addEventListener('beforeunload', function() { if (performanceUpdateInterval) { clearInterval(performanceUpdateInterval); } if (systemInstance) { window.GameBoySystemController.stop(); } });})();</script></body></html> 效果(点击 初始化系统 → 切换渲染模式 → 测试图形渲染 → 加载演示程序 → 开始运行): 5.8. 全局系统控制接口 为了方便外部访问和调试,我们写一个统一的全局的系统控制接口: /** * 全局系统控制函数 */window.GameBoySystemController = {/** * 初始化系统 */async init() {try {if (!gameBoySystem) {gameBoySystem = new GameBoySystem();}await gameBoySystem.initialize();window.GameBoySystem = gameBoySystem; // 全局引用return gameBoySystem;} catch (error) {console.error('❌ 系统初始化失败:', error);throw error;}},/** * 获取系统实例 */getInstance() {return gameBoySystem;},/** * 重置系统 */reset() {if (gameBoySystem) {gameBoySystem.reset();}},/** * 启动系统 */start(mode) {if (gameBoySystem) {gameBoySystem.start(mode);}},/** * 停止系统 */stop() {if (gameBoySystem) {gameBoySystem.stop();}},/** * 暂停/恢复 */togglePause() {if (gameBoySystem) {gameBoySystem.togglePause();}},/** * 加载 ROM */async loadROM(romData) {if (gameBoySystem) {return await gameBoySystem.loadROM(romData);}return false;},/** * 加载演示 */loadDemo() {if (gameBoySystem) {return gameBoySystem.loadDemo();}return false;},/** * 获取状态 */getStatus() {if (gameBoySystem) {return gameBoySystem.getStatus();}return null;},/** * 获取调试信息 */getDebugInfo() {if (gameBoySystem) {return gameBoySystem.getDebugInfo();}return '系统未初始化';}}; 这个控制器接口使得前端可以通过简洁的 API 来操作整个模拟器系统,同时在浏览器控制台中也可以直接调用这些方法进行调试: // 在浏览器控制台中const system = window.GameBoySystemController.getInstance();// 查看系统状态console.log(system.getStatus());// 测试寄存器system.gpu.writeRegister(0xFF43, 50); // 设置水平滚动console.log(system.gpu.readRegister(0xFF43)); // 读取确认

2025/7/1
articleCard.readMore

CMP 2800 笔记

世界文学经典学笔记,仅作为个人学习记录,不保证正确性。 这节课以 The Norton Anthology: World Literature 为基础。 1. 古代美索不达米亚与近东文学 1.1. 创世与宇宙观 古代近东地区的多个文明都有自己的创世神话和宇宙观念。 美索不达米亚(两河流域,即底格里斯河和幼发拉底河之间的区域)的创世神话《埃努玛・埃利什》描述了马尔杜克神击败提亚玛特(混沌之海)创造世界。 马尔杜克是巴比伦的主神,又称「太阳牛犊」、雷暴之神。 提亚玛特又被称为龙、咸海,同甜海阿普苏诞生出众神,却又因吵闹决定消灭后代,最终被后代马尔杜克所杀、尸骸被拿去创造了天和地。 这个神话反映了秩序战胜混沌的主题,马尔杜克用提亚玛特的身体创造天地,建立了宇宙的秩序和规则。《埃努玛・埃利什》以「太初没有天地」开始,描述了诸神间的权力斗争,最终马尔杜克成为众神之王,确立了巴比伦作为宗教中心的合法性。 埃及人则相信世界起源于努恩(原初之水),由阿图姆神创造。在太初只有无边无际的黑暗水域,阿图姆从中自我创生,随后创造了空气之神舒和湿气女神泰芙努特,开启了世界的形成。 埃及的创世神话与尼罗河的季节性泛滥密切相关,反映了水在埃及文明中的核心地位。他们相信世界开始于一座从原初之水中升起的小山,就像尼罗河泛滥退去后露出的肥沃土地。 这些早期的创世叙事不仅解释了世界的起源,也为各自文明提供了宇宙秩序的基础架构,人类在其中找到了自己的位置。它们结合了观察自然现象的经验与对超自然力量的信仰,形成了复杂的世界解释系统。 1.2. 《吉尔伽美什史诗》 约公元前 2100 年,美索不达米亚(现今伊拉克)用楔形文字将《吉尔伽美什史诗》记录在泥板上。这部作品被公认为人类最早的史诗,见证了文字如何成为跨越时空的记忆载体。 最完整版本出土于亚述国王阿舒尔巴尼拔(公元前 668 到 627 年)的尼尼微图书馆,包含 12 块泥板。这位对知识有着强烈热情的国王派人收集和抄录了大量文献,无意中保存了这部珍贵的史诗,使它得以在数千年后重见天日。19 世纪的考古学家乔治・史密斯在大英博物馆发现并首次解读了其中描述洪水的片段,震惊了学术界。 故事讲述了乌鲁克城邦王吉尔伽美什的冒险。 吉尔伽美什是一位半神半人的国王(三分之二神,三分之一人),力大无穷却暴虐无道。他强迫臣民为他建造城墙,还实行初夜权,引起民众不满。神明创造了野人恩奇都与他抗衡,两人却在搏斗后成为挚友。他们共同击败了森林守护者胡瓦瓦(又称洪巴巴)和天牛,因此激怒了诸神。诸神决定惩罚他们,使恩奇都生病死亡。 恩奇都死后,吉尔伽美什对死亡产生恐惧,决心寻找不死的秘密。他穿越危险的马苏山,遇到了酒馆女主人希杜丽,渡过死亡之水,最终找到了洪水幸存者乌特纳皮什提姆。乌特纳皮什提姆讲述了大洪水的故事,告诉吉尔伽美什有一种能让人恢复青春的水下植物。吉尔伽美什获得了这种植物,但在回程中却被蛇偷走。最终,他带着对生命有限性的理解回到乌鲁克,接受了人类的命运。 无论是谁或许都会对永生产生渴望,面对挚友的离世,我们不禁思考自己的命运。吉尔伽美什的不朽追求正反映了人类面对死亡的普遍恐惧与对永恒的渴望,这一主题在后世文学中不断回响。当恩奇都临死前诅咒让他文明化的女祭司,却被太阳神沙玛什提醒他因文明而获得的好处时,史诗直接提出了关于人类文明本质的深刻问题。 故事中,恩奇都从野人变为文明人的过程揭示了古代美索不达米亚人对文明与自然关系的复杂思考。最初的恩奇都与野兽一同生活,「他的身体覆盖着浓密的毛发…… 他与瞪羚一起吃草,与野兽一起在水边饮水」。当女祭司沙姆哈特引诱他,与她同住七天七夜后,野兽们不再接纳他:「羚羊见到他就跑开了…… 恩奇都的身体变得迟钝,他的腿无法像从前那样快速奔跑。」这一转变既表明了文明的益处,也暗示了其代价。文明为恩奇都带来了友谊、美食、美酒和智慧:「他洗净身体,用油膏抹身…… 他穿上衣服,变得像个战士」,沙姆哈特告诉他:「恩奇都啊,你已经变得像神一样,为何还要与野兽一起在荒野游荡?」 文明使他获得了人性的高贵特质,但也失去了与自然的和谐共处和某种原始的纯真。更深层次上,这反映了农业文明兴起后人类对狩猎采集生活方式的某种怀念,暗示城市文明虽然带来了物质繁荣和知识进步,却也导致了与自然的疏离,并伴随着社会阶层分化和劳动强化。这种二元性在史诗末尾达到高潮 —— 吉尔伽美什虽然没能获得永生,却通过建造城墙(文明的象征)获得了某种形式的不朽。 乌特纳皮什提姆向吉尔伽美什讲述的洪水故事与圣经中的诺亚方舟有着惊人的相似之处,这表明古代近东地区可能确实经历过大洪水,或者这些文明间存在文化交流与影响。在两个故事中,神明都决定毁灭人类,只有一位义人及其家人(和动物)得以幸存。 《吉尔伽美什史诗》确立了英雄叙事的基本模式,探讨的命运、死亡、友谊等主题构成了人类共同的精神遗产。史诗中的重要象征如城墙(文明的边界)、森林(未知与野性)和水(生命与死亡)在后世无数作品中被重新诠释。透过这部作品,我们不仅看到古代美索不达米亚的世界观,也看到了人类自古以来就共有的情感与思考。 什么是史诗?史诗是一种长篇叙事诗,通常描述英雄的冒险或具有民族文化重要性的重大事件。史诗中的主角往往具有超凡品质,面对超越常人的挑战,其行动关乎整个民族或人类的命运。这种文学形式最初通过口头传统流传,由专业吟游诗人在宴会或庆典上演唱,通常伴有乐器伴奏。史诗不仅是娱乐,也是传承文化价值观和集体记忆的重要方式,在文字普及前,口头传统是知识传递的主要渠道。《吉尔伽美什史诗》作为最早被记录的史诗,成为了连接口头传统和书面文学的桥梁。 2. 古希腊与罗马文学 2.1. 荷马史诗 2.1.1. 荷马其人 「荷马」是西方文学传统的奠基者,却也是最神秘的作家之一。传统上认为他是一位生活在公元前 8 世纪的盲诗人,但现代学者对其真实身份存在诸多争议。「荷马问题」涉及《伊利亚特》和《奥德赛》是否由同一人创作,甚至这个「荷马」是否真实存在。许多学者认为这两部史诗可能是几个世纪的口头传统结晶,最终由多位诗人编撰而成。 无论如何,这两部作品奠定了西方文学的基础。它们最初通过口头传统流传,由专业的吟游诗人在贵族宴会和公共节日上吟诵,伴有七弦琴(lyre)伴奏。直到公元前 6 世纪,雅典暴君庇西特拉图斯才下令将它们记录成文。 2.1.2. 《伊利亚特》 《伊利亚特》成书于约公元前 8 世纪的希腊黑暗时代末期或古风时期初期,书名源自「伊利昂」,即特洛伊城的另一名称。 全诗共 24 卷,以十六音节的六步格(hexameter)写成,描述了特洛伊战争第十年的几周内发生的事件。虽然战争持续了十年,但史诗聚焦于「阿喀琉斯的愤怒」及其后果,而非整个战争过程。开篇即点明主题:「女神啊,请歌唱佩琉斯之子阿喀琉斯的愤怒,这致命的愤怒给阿开亚人带来了无尽的痛苦……」 故事背景是特洛伊王子帕里斯在女神阿佛洛狄忒的帮助下,诱拐了斯巴达国王墨涅拉俄斯的妻子海伦,引发了希腊联军(阿开亚人)对特洛伊的战争。史诗开始时,希腊联军统帅阿伽门农(墨涅拉俄斯的兄长)夺走了阿喀琉斯的女奴布里塞伊斯,激怒了阿喀琉斯,使他退出战斗。 没有阿喀琉斯,希腊军队节节败退。特洛伊英雄赫克托尔(普里阿摩斯国王的长子)率军攻破希腊人的防线,几乎烧毁了他们的船只。阿喀琉斯拒绝亲自出战,但允许他的挚友帕特洛克罗斯穿上自己的盔甲上阵。帕特洛克罗斯奋勇作战,但最终被赫克托尔杀死。 失去挚友的阿喀琉斯悲痛欲绝,重返战场,杀死赫克托尔并亵渎其尸体,拒绝归还。最终,特洛伊国王普里阿摩斯冒险潜入希腊营地,恳求归还儿子的遗体。阿喀琉斯被这位父亲的勇气所感动,同意归还赫克托尔的遗体,史诗以特洛伊人为赫克托尔举行隆重葬礼结束。 《伊利亚特》对古希腊文化产生了无法估量的影响,成为希腊教育的基础和民族身份的核心元素。亚历山大大帝据说随身携带一本《伊利亚特》,视阿喀琉斯为自己的榜样。 《伊利亚特》中的战争是特洛伊人与阿开亚人(早期希腊人)之间的冲突。这场战争不仅是人类间的冲突,诸神也介入其中,支持不同的阵营:阿波罗、阿佛洛狄忒、阿瑞斯支持特洛伊;赫拉、雅典娜、波塞冬则支持希腊人。这种神人交织的叙事反映了古希腊人的宇宙观,其中人类命运受到神明的影响,但个人选择仍具有决定性意义。 诗中充满了对战斗场景的生动描写,其中阵亡士兵的死法往往详细到令人不适的程度:「矛尖刺穿他的颈部,从咽喉穿出,他仰面倒地,铠甲在身上哐当作响」,展现了生命的脆弱与战争的残酷。 史诗也通过展示敌对双方的人性来突破民族界限,最感人的场景之一发生在赫克托尔与妻子安德洛玛刻告别时。赫克托尔脱下头盔抱起幼子,祈祷他将来能超越自己,安德洛玛刻则「含着泪微笑」。这种对敌人的人性化描写,使《伊利亚特》超越了单纯的战争史诗,成为探索人性的永恒经典。 2.1.3. 《奥德赛》 《奥德赛》创作于《伊利亚特》之后,可能约在公元前 725 到 675 年间。全诗同样分为 24 卷,讲述了伊萨卡国王奥德修斯(拉丁名:尤利西斯)在特洛伊战争结束后历经十年才返回家乡的冒险旅程。 与《伊利亚特》不同,《奥德赛》的故事结构更为复杂,采用了非线性叙事: 史诗开始时,奥德修斯已在女神卡吕普索的岛上被囚禁七年。他的妻子珀涅罗佩在伊萨卡被 108 位求婚者围困,他们消耗着奥德修斯的财富,争夺他的王位。众神决定让奥德修斯回家,只有海神波塞冬因奥德修斯曾伤害其子独眼巨人波吕斐摩斯而继续反对。 女神雅典娜鼓励奥德修斯的儿子特勒马科斯(已二十岁)外出探寻父亲的下落。与此同时,赫耳墨斯奉宙斯之命前往卡吕普索岛,命她释放奥德修斯。后者乘木筏航行,但遭波塞冬掀起的风暴,漂流至费阿基亚。在那里,他向国王阿尔喀诺俄斯讲述了自己的旅程:从特洛伊出发后,他与船员遇到了食莲人、独眼巨人、女巫喀耳刻,他们甚至前往冥界拜访先知泰瑞西阿斯,又遭遇了塞壬女妖、海怪斯库拉和卡律布狄斯,最终全员遇难,只有奥德修斯漂流到卡吕普索岛。 阿尔喀诺俄斯国王被他的故事打动,提供船只送他回伊萨卡。到家后,雅典娜将他变装成老乞丐。他先拜访了忠诚的猪倌尤迈俄斯,与回国的特勒马科斯重逢,然后进入自己的宫殿。在那里,没人认出他,求婚者们还侮辱了他。 珀涅罗佩宣布将举行比赛,谁能用奥德修斯的弓射中目标,她就嫁给谁。求婚者们都无法拉开这把弓,但变装的奥德修斯轻松完成,随后与特勒马科斯和两位忠仆一起杀死了所有求婚者。最后,他向珀涅罗佩证明了自己的身份,两人重聚。 《奥德赛》与《伊利亚特》共同构成了希腊史诗传统的双峰,但两者在主题和风格上有明显区别。如果说《伊利亚特》聚焦战争中的荣耀与悲剧,《奥德赛》则探索返乡、家庭与身份认同的主题。《伊利亚特》中的阿喀琉斯追求不朽的荣耀,宁死不屈;《奥德赛》中的奥德修斯则凭借智慧和坚韧,努力生存并回归家庭。这种转变反映了希腊人价值观的演变,从崇尚战争荣耀到重视智慧与家庭纽带。 《奥德赛》中的奥德修斯展现了希腊人推崇的多才多艺(polymētis)特质:他不仅是战士,更是航海家、说故事的能手、工匠和战略家。当卡吕普索提供永生的诱惑时,他仍选择回到凡间的家庭,这反映了希腊人对人的尊严和选择的重视。 这两部史诗展现了众多半神半人英雄的形象。阿喀琉斯是海洋女神忒提斯与凡人佩琉斯的儿子,海伦则是宙斯与勒达所生。这种半神血统反映了古希腊宗教中神与人之间没有绝对界限的观念,也为英雄的超凡能力提供了解释。 2.2. 《变形记》 《变形记》完成于约公元 8 年,作者是罗马诗人奥维德(Publius Ovidius Naso,约公元前 43 年 到公元 17 或 18 年)。这部作品是古典文学晚期的巅峰之作,也是奥维德最著名的代表作。 奥维德生活在罗马从共和国转变为帝国的关键时期,是奥古斯都统治下的「黄金时代」诗人。然而,他的《变形记》完成不久后便因不明原因被奥古斯都流放到黑海边缘的小镇托米斯(今罗马尼亚康斯坦察),度过了生命的最后十年。 《变形记》是一部包含约 250 个变形故事的长诗,共 15 卷,约 12,000 行,以六步格写成。不同于传统史诗聚焦单一英雄或事件,《变形记》以「变形」为线索,编织了一个从世界创生到凯撒转变为星辰的连续性叙事。 作品以宇宙从混沌中形成开始:「在大地、海洋和覆盖万物的天空出现之前,自然在整个宇宙中只有一副面孔,人们称之为混沌……」然后描述了四个时代(黄金、白银、青铜和黑铁)的演变,以及普罗米修斯创造人类。 接下来的故事涵盖了众多著名神话:阿波罗追求达芙妮,她变成月桂树;伊卡洛斯飞向太阳,坠入大海;纳西索斯爱上自己的倒影,变成水仙花;俄耳浦斯下冥界寻妻,却因回头一望失去她;皮格马利翁爱上自己雕刻的象牙少女加拉忒亚,维纳斯使其复活;阿拉克涅与雅典娜比赛织布,被变成蜘蛛…… 后半部分转向特洛伊战争和罗马的起源:阿喀琉斯之死、奥德修斯的冒险、特洛伊的陷落,以及埃涅阿斯逃离特洛伊并最终到达意大利,成为罗马人的祖先。最后,作品以凯撒之死和奥古斯都赞美结束,暗示历史循环与罗马的永恒。 「变形」(Metamorphosis)一词源自希腊语,意为彻底的形态、结构或性质转变。在奥维德的作品中,变形往往是情感极端表达的结果:因爱而变形(达芙妮),因傲慢而变形(阿拉克涅),因悲伤而变形(俄耳浦斯之妻)。这些变形既是惩罚也是解脱,既是神圣干预也是情感的自然延伸。 奥维德对这些神话采取了一种精致、甚至有些嬉戏的态度,这反映了罗马帝国早期的文化氛围 —— 古老的神话已不再是严肃的宗教内容,而更多地成为艺术创作的素材。他以生动的细节和心理描写赋予这些故事以新的活力:「达芙妮的脸上苍白褪去,取而代之的是树皮;她的头发变成树叶;她的手臂长成枝桠;她那曾跑得如此迅捷的双脚变成固定的树根……」 《变形记》对后世艺术与文学的影响难以估量。它成为中世纪和文艺复兴时期对古典神话的主要来源,启发了但丁、乔叟、莎士比亚等作家,也为无数画家、雕塑家提供了创作题材。以拉斐尔、提香、伦勃朗、鲁本斯为首的艺术大师都创作过基于《变形记》故事的作品。 对于奥维德本人而言,《变形记》也具有某种预言性意义。在结尾他写道:「我已完成了一部不朽的作品,它将超越朱庇特的愤怒和火焰、利剑和时间的腐蚀…… 无论命运对我的身体有何安排,我最好的部分将永存,我的名字将永不消亡。」这位被流放的诗人确实通过自己的作品获得了永生,《变形记》成为将希腊 - 罗马神话传递给后世的最重要桥梁之一。 3. 印度文学与宗教 3.1. 印度教背景 在深入探索印度两大史诗之前,了解它们的宗教背景至关重要。印度教是世界上最古老的宗教之一,其历史可追溯至公元前 1500 年左右的吠陀时期。与西方的一神教不同,印度教是一个复杂多元的宗教体系,包含多种哲学观点、崇拜方式和神祇。 印度教的核心经典是《吠陀》(Vedas),共四部:《梨俱吠陀》(赞歌)、《夜柔吠陀》(祭祀歌)、《娑摩吠陀》(旋律)和《阿闼婆吠陀》(咒语)。这些经典最初通过口头传统传承,由婆罗门祭司阶层世代记忆,约在公元前 500 年才开始记录成文。 印度教的神系庞大而复杂。主要神祇包括: 梵天(Brahma):创造神,宇宙的创造者 毗湿奴(Vishnu):维护神,宇宙的保护者,通过化身(阿凡达)多次降临人间 湿婆(Shiva):破坏神,负责宇宙的更新与转变 女神(Devi):以多种形式出现,如雪山神女(Parvati)、杜尔迦(Durga)和迦梨(Kali) 印度文化中最重要的概念之一是「达摩」(Dharma),这个词难以用单一西方术语翻译。达摩同时指宇宙法则、社会秩序、个人道德义务和正确行为的规范。在不同语境下,它可以理解为「职责」、「美德」、「道德」、「宗教」或「正义」。每个人的达摩取决于其社会地位(瓦尔那)、人生阶段(阿什拉玛)和个人情况,因此构成了一种情境伦理学。 印度史诗不仅是文学作品,也是达摩的实践指南,通过生动的故事说明抽象的宗教概念,向各阶层民众传授价值观。两大史诗《罗摩衍那》和《摩诃婆罗多》早已超越了纯文学范畴,成为宗教典籍,至今仍在寺庙仪式、家庭祭祀和日常生活中发挥重要作用。 3.2. 《罗摩衍那》 《罗摩衍那》(Ramayana)意为「罗摩的旅程」,约成书于公元前 5 到 4 世纪,传统上认为是由圣人瓦尔米基(Valmiki)创作。原文以梵语写成,包含约 24,000 首诗偈(shloka),分为七卷(kanda)。这部作品被视为阿迪卡维亚(Adikavya),即「第一诗」,标志着梵语诗歌的正式诞生。 《罗摩衍那》的故事广为流传,在东南亚各国有不同版本,并通过各种艺术形式表现,如皮影戏、舞蹈、绘画和现代影视作品。 故事发生在古代印度阿约提亚王国,国王达沙拉塔有三位王后,却一直没有子嗣。他举行祭祀后,四个儿子相继出生:长子罗摩(Rama)出生于王后考萨利亚,次子婆罗多(Bharata)出生于王后凯凯伊,双胞胎弟弟拉克什曼那(Lakshmana)和沙特鲁格纳(Shatrughna)出生于王后苏米特拉。 罗摩成年后,在一次比武大会上拉开了湿婆神的神弓,赢得了邻国国王之女悉多(Sita)的芳心。他们成婚后,老国王准备立罗摩为王储,却被凯凯伊阻止。凯凯伊要求国王实现他曾许下的两个愿望:立婆罗多为王储,并流放罗摩到森林十四年。国王不得不遵守诺言,罗摩欣然接受命运,前往森林。悉多和拉克什曼那坚持跟随他。 在森林生活期间,魔王罗波那(Ravana)对悉多产生了贪念。他派手下将罗摩引开,变身成苦行僧接近悉多,然后绑架她带回自己的王国楞伽岛(今斯里兰卡)。罗摩在猴神哈努曼(Hanuman)的帮助下得知悉多的下落,与猴族结盟,建造连接印度和楞伽岛的大桥,率军攻打楞伽。 经过激烈战斗,罗摩杀死罗波那,解救了悉多。然而,由于悉多在罗波那处滞留多时,民众对她的贞洁产生怀疑。为证明自己的清白,悉多经受了火的考验,安然无恙地从火中走出,证明了自己的纯洁。 流放期满,罗摩返回阿约提亚,婆罗多欣然交还王位。罗摩登基后,依然有人怀疑悉多的贞洁。为了平息谣言,罗摩不得不将已怀孕的悉多再次流放。她在圣人瓦尔米基的庇护下生下双胞胎儿子拉瓦(Lava)和库沙(Kusha)。多年后,罗摩认出了自己的儿子,请悉多回宫,但悉多选择回归大地母亲的怀抱。最终,罗摩完成在世使命,回归毗湿奴的本体。 罗摩作为毗湿奴的第七个化身(avatar),代表了理想君主、完美丈夫和尽职兄长的典范。他的特质被概括为「玛莉亚达・普鲁肖塔马」(Maryada Purushottama),意为「最尊崇道德的人」。在印度文化中,「罗摩拉吉亚」(Ram-Rajya)成为理想王国的代名词,象征公正、繁荣与和谐的统治。 悉多则体现了印度传统中理想女性的特质:忠贞、坚韧、牺牲和对达摩的坚守。她被视为大地女神拉克什米的化身,与罗摩的结合象征着神圣婚姻。然而,现代印度女性主义者对悉多被迫证明贞洁的情节提出了批评,认为这反映了父权制对女性的不公正要求。 《罗摩衍那》对达摩概念的探索尤为深刻。罗摩面临着多重达摩的冲突:作为儿子(尊重父亲的诺言)、作为国王(保护子民)、作为丈夫(保护妻子)。当这些职责相互冲突时,如何抉择成为道德困境。罗摩最终选择了遵守父亲的诺言和履行王者职责,即使这意味着个人幸福的牺牲。这种抉择反映了印度传统价值观中,社会达摩(社会秩序)高于个人达摩(个人情感)的原则。 《罗摩衍那》在印度文化中的影响难以估量。每年的排灯节(Diwali)庆祝罗摩战胜罗波那、回归阿约提亚的胜利,象征光明战胜黑暗。在整个东南亚,罗摩的故事成为了道德教育和文化认同的核心元素。 3.3. 《摩诃婆罗多》 《摩诃婆罗多》(Mahabharata)意为「伟大的婆罗多(族)史诗」,是世界上最长的史诗,约成书于公元前 4 世纪到公元 4 世纪之间。传统上认为是由圣人毗耶娑(Vyasa)编撰,他也是史诗中的角色。全诗约 100,000 颂(约 1.8 百万字),分为 18 卷和一个附录(哈里旺沙)。 《摩诃婆罗多》的规模和复杂性远超《罗摩衍那》,它不仅讲述了中心故事,还包含了无数支线、伦理讨论、哲学论述和独立故事。正如史诗中所言:「在此未曾提及的事物,世上别处不会有;而此处所言,处处可闻。」 核心故事围绕库卢族两支 —— 般度(Pandava)家族和俱卢(Kaurava)家族 —— 之间的继承权争夺和最终战争展开。 盲王持国(Dhritarashtra)与其弟弟般度(Pandu)共同统治哈斯提那布尔王国。般度早逝,留下五个儿子:长子阿周那(Arjuna)是雷神因陀罗之子,箭术无双;次子悉底米(Bhima)力大无穷;长兄阿修(Yudhishthira)贤明公正;双胞胎弟弟那库罗(Nakula)和萨哈提瓦(Sahadeva)分别擅长医术和占星术。持国则有一百个儿子,为首的是难敌(Duryodhana)。 般度五兄弟与表兄弟们一起长大,接受德罗纳(Drona)大师的武艺训练。阿周那成为最杰出的弟子,引起难敌的嫉妒。长大后,般度五兄弟共同娶了一位妻子德鲁帕蒂(Draupadi),一起统治因陀罗普拉斯塔王国。 难敌设计邀请阿修进行赌博,骗取了般度家族的全部财产和自由。五兄弟被迫流放十三年,其中最后一年需隐姓埋名。期满后,他们要求归还王国,但难敌拒绝。 在黑天(Krishna,毗湿奴的化身)的支持下,般度五兄弟向俱卢家族宣战。战前,黑天成为阿周那的战车御者。当阿周那看到对面阵营中有许多亲友和尊者,不忍作战时,黑天向他讲述了关于达摩、业力和解脱的哲理,这段对话即著名的《薄伽梵歌》(Bhagavad Gita)。 库卢之战持续十八天,双方都使用了神赐武器和非凡战术。般度兄弟最终获胜,但双方均损失惨重,几乎所有主要战士都阵亡。阿修登上王位,统治了三十六年后,五兄弟和德鲁帕蒂决定放弃王位,前往喜马拉雅山寻求解脱。在艰难的旅程中,除阿修外,其他人都因自身缺点而倒下。阿修最终以肉身进入天界。 《摩诃婆罗多》最著名的部分是《薄伽梵歌》,这段约 700 首诗的对话被视为印度教最重要的经典之一。当阿周那在战场上犹豫不决时,黑天揭示了自己作为至高神的身份,讲述了关于行动(karma yoga)、知识(jnana yoga)和虔诚(bhakti yoga)三条解脱之路的教义,强调不执着于行动结果的无私行动(nishkama karma)。 《摩诃婆罗多》对达摩的探讨比《罗摩衍那》更加复杂和微妙。史诗中充满了达摩困境,没有简单的对错答案。例如: 德罗纳作为老师,是否应该在战场上对抗自己的学生? 黑天使用欺骗战术击败俱卢将领,这是否违背战士达摩? 阿修明知赌局有诈,却因王者达摩不得不参与,这种选择是否正确? 这些复杂的伦理问题展示了印度思想对道德相对性的深刻理解,暗示在复杂情境中,达摩的应用需要智慧而非教条。《摩诃婆罗多》的一个核心信息是:有时候任何选择都会导致某种达摩的违背,关键是在冲突中做出最小损害的决定。 阿周那作为《摩诃婆罗多》中最著名的半神英雄,代表了印度传统中理想战士的形象。他兼具超凡的武艺和深刻的精神觉悟,即使在战场上,也不忘思考行动的伦理意义。作为因陀罗之子,他拥有半神的血统,但仍然面临人类的情感和道德困境,这种二元性使他成为深受读者喜爱的复杂角色。 《摩诃婆罗多》对印度文化和宗教的影响无处不在。它不仅提供了伦理行为的范例,也为后世印度哲学和文学提供了思想资源。印度的政治理念、法律概念和社会规范都能在这部史诗中找到根源。在现代印度,《摩诃婆罗多》的改编版本不断出现在电影、电视剧和漫画中,证明这部古老史诗的生命力和现代意义。 4. 中世纪文学 4.1. 但丁的《神曲》 4.1.1. 时代背景与作者生平 《神曲》(Divina Commedia)创作于 1308 至 1320 年,正值欧洲中世纪晚期。这一时期,中世纪神学世界观达到顶峰,同时文艺复兴的曙光已初现意大利。佛罗伦萨作为新兴的城邦共和国,商业繁荣,艺术发达,但也陷入激烈的政治派系斗争中。 作者但丁・阿利吉耶里(Dante Alighieri, 1265-1321)出生于佛罗伦萨一个中等贵族家庭。他 9 岁时邂逅贝雅特丽齐・波尔蒂纳里(Beatrice Portinari),对她产生了柏拉图式的爱慕,这成为他诗歌创作的永恒灵感。贝雅特丽齐 24 岁时去世,但她的形象在但丁心中升华为神圣爱情的象征。 但丁积极参与佛罗伦萨政治,担任过城邦议员和使节。当时意大利陷入教皇派(Guelph)和皇帝派(Ghibelline)的争斗,佛罗伦萨内部教皇派又分为白派(主张城邦自治)和黑派(支持教皇干预)。1302 年,当但丁出使罗马期间,黑派夺权,他被流放并被判处缺席死刑。此后,他流亡北意大利各地,再未能返回故乡。 正是在流亡期间,但丁完成了《神曲》的创作。这部作品不仅反映了他个人的痛苦经历,也是对当时意大利和整个基督教世界精神状况的反思。他于 1321 年 9 月在拉文纳去世,至今长眠于此,而非他深爱的佛罗伦萨。 4.1.2. 作品结构与内容 《神曲》原名《喜剧》(Commedia),「神曲」(Divina)的称号是后人加上的。全诗采用意大利方言而非当时学术界通用的拉丁语写成,这一大胆创举奠定了意大利文学语言的基础。 诗歌结构严谨,分为三个部分:《地狱篇》(Inferno)、《炼狱篇》(Purgatorio)和《天堂篇》(Paradiso),每部各 33 篇(canto),加上《地狱篇》的序篇,共 100 篇。这种数字象征主义贯穿全诗:3 代表三位一体,9(3×3)代表神圣完美,10 代表宇宙秩序。诗歌采用三行韵(terza rima)形式,即 ABA BCB CDC…… 的押韵模式,创造出前进不息的韵律感。 故事始于 1300 年的圣周五前夜(耶稣受难日之前),35 岁的但丁迷失在「黑暗的森林」中,象征人生中途的精神迷茫。他试图攀登光明之山,却被三只野兽(豹、狮、母狼,象征情欲、骄傲和贪婪)阻挡。此时,古罗马诗人维吉尔(Virgil)出现,告诉但丁必须通过地狱和炼狱才能到达天堂,并提出担任他的向导。 在《地狱篇》中,但丁和维吉尔穿过地狱的九个同心圆层,每层惩罚一种罪行,越往下罪行越重:第一层是未受洗礼的美德之人;第二至第五层是放纵、贪食、贪婪和愤怒;第六层是异端;第七层是暴力;第八层是欺诈;最深的第九层是背叛,其中最恶劣的叛徒犹大、布鲁图斯和卡修斯被路西法(Satan)亲自啃噬。地狱入口铭刻着著名警句:「进入此门,离弃所有希望。」 《炼狱篇》描述两人穿过地球中心,来到南半球的炼狱山。炼狱分为七个层级,对应七宗罪(傲慢、嫉妒、愤怒、懒惰、贪婪、贪食、淫欲),灵魂在此净化罪恶,为进入天堂做准备。在炼狱的顶端是伊甸园,但丁在此与贝雅特丽齐重逢,维吉尔则离去(作为异教徒,他不能进入天堂)。 在《天堂篇》中,贝雅特丽齐引导但丁穿越九层天:月亮、水星、金星、太阳、火星、木星、土星、恒星天和原动天,最后到达超越时空的天国玫瑰(Empyrean),在那里他得见上帝的荣光,体验到终极的神圣之爱。 整部作品构成了一次精神朝圣,从罪恶和迷失,经过忏悔和净化,最终达到救赎和启示。这反映了基督教神学中灵魂得救的历程,也象征人类普遍的精神成长路径。 4.1.3. 思想内涵与影响 《神曲》最显著的特点是将基督教神学与古典人文主义融为一体。维吉尔代表人类理性和古典智慧,贝雅特丽齐则象征神圣启示和信仰。但丁虽然是虔诚的基督徒,却给予古典文化(尤其是维吉尔)崇高地位,甚至将许多古代哲人安置在地狱的较轻微区域,这预示了文艺复兴对古典文化的重新尊崇。 作品充满了丰富的象征主义。地狱的漏斗形状象征远离上帝的堕落;炼狱的螺旋山路表示艰难但充满希望的攀登;天堂的同心圆体现了完美的神圣秩序。三位主要向导(维吉尔、贝雅特丽齐和圣贝尔纳多)分别代表理性、信仰和神秘体验,构成了中世纪认识论的三个层次。 《神曲》展现了中世纪宇宙观的完整图景。根据托勒密体系,但丁的宇宙以地球为中心,地狱位于地下,天堂环绕地球,形成一个层级分明的有机整体。这种秩序化的宇宙观反映了中世纪思想中,物质世界与精神世界、自然秩序与道德秩序的对应关系。 作品中充满了对当时政治、宗教和社会问题的评判。但丁大胆地将许多教皇、政治人物和同时代人安置在地狱中,表达了他对教会腐败和意大利政治混乱的强烈批判。他理想中的世界秩序是教皇领导精神事务,皇帝管理世俗事务,两者相互独立又相互配合。 在文学技巧上,《神曲》创造了前所未有的叙事深度。但丁不仅是故事的主角,也是叙述者和评论者,时而参与情节,时而抽身反思,创造出多层次的叙事视角。他对人物心理的细腻描写和对复杂情感的捕捉,超越了同时代的大多数作品。 《神曲》对后世的影响难以估量。在文学上,它启发了从薄伽丘到乔叟,从弥尔顿到艾略特的众多作家;在美术上,波提切利、米开朗基罗和多雷等艺术家为它创作了著名插图;在音乐上,李斯特、柴可夫斯基和普契尼等作曲家受其启发创作了重要作品。更广泛地说,《神曲》塑造了西方文化对来世、罪恶与美德的想象,成为连接中世纪与现代的关键桥梁。 4.1.4. 与但丁相遇的重要人物和故事 但丁在冥界旅途中遇到众多历史和当代人物,每个故事都深具象征意义。在地狱第二环,他遇到了保罗和弗兰切斯卡,两人因通奸而被杀,永远被风暴卷在一起,象征情欲之罪;在第三环,他认出了贪食者恰科,后者预言佛罗伦萨的政治斗争;在第八环,他与曾任教皇的尼古拉三世交谈,后者因贪污被倒插在岩洞中。 特别值得一提的是尤利西斯(奥德修斯)的故事。但丁在第八环遇到这位希腊英雄,他因欺骗之罪被惩罚。尤利西斯讲述了自己如何不满足于平静的家庭生活,渴望探索未知世界,最终率领船员越过海克里斯柱(直布罗陀海峡),航向禁区,导致全船覆没。这个故事被许多学者解读为对人类求知欲的双重态度:既钦佩其勇气,又警惕其傲慢。 在《炼狱篇》中,但丁遇到许多艺术家和诗人,包括音乐家卡塞拉和诗人索尔德洛。在第五层,他看到教皇阿德里安五世因贪婪而俯卧在地;在第七层,他与古罗马诗人斯塔提乌斯交谈,后者刚完成净化,准备上升天堂。 《天堂篇》中,他在太阳天遇到神学家托马斯・阿奎那和圣方济各;在火星天,他遇到了十字军战士和佛罗伦萨的先祖卡恰圭达;在土星天,他与本笃会修士彼得・达米安交谈,后者谴责教会腐败。 《神曲》通过这些活灵活现的角色和引人入胜的故事,将抽象的神学概念具象化,展现了人类命运的多样性和生命抉择的重要性。但丁的天才在于,他创造的不是教条式说教,而是充满人性光辉的复杂世界,即使在最严厉的道德判断中,也流露出对人类弱点的深刻理解和同情。 自战后至今,《神曲》仍是西方文学最具影响力的作品之一,被翻译成世界上几乎所有主要语言。2021 年是但丁逝世 700 周年,世界各地举行了纪念活动,证明这位「神圣诗人」的作品超越了时空限制,继续对人类精神世界产生深远影响。 5. 宗教经典文学 5.1. 《圣经》 《圣经》(Bible)一词源自希腊语「biblia」,意为「书籍」,是犹太教和基督教的核心经典。它不是单一作品,而是由多位作者在长达千年的时间里(约公元前 1200 年至公元 2 世纪)创作的文集。《圣经》原文主要使用希伯来语、亚兰语和希腊语写成,如今已被翻译成 3000 多种语言,是世界上发行量最大、影响最广的文本。 《圣经》分为《旧约》(Old Testament)和《新约》(New Testament)两大部分。《旧约》被犹太教和基督教共同承认,《新约》则仅为基督教经典。 5.1.1. 旧约概述 《旧约》(犹太教称为《塔纳赫》)包含 39 卷书,可分为四部分: 摩西五经(Torah / Pentateuch):《创世记》《出埃及记》《利未记》《民数记》《申命记》,记录世界创造、族长故事、以色列人出埃及和摩西律法。 历史书:从《约书亚记》到《以斯帖记》,描述以色列人占领迦南地、建立王国、分裂、被掳与归回的历史。 智慧文学:《约伯记》《诗篇》《箴言》《传道书》《雅歌》,探讨生命意义、苦难、爱情等普遍人类问题。 先知书:从《以赛亚书》到《玛拉基书》,记录先知对以色列民族和周边国家的预言和劝诫。 《旧约》通过神权史观展示了上帝(雅威)与以色列民族的立约关系。在这一框架中,历史事件被解释为上帝对以色列人忠诚或背叛的回应。《旧约》同时探讨了普世伦理问题,如公正、怜悯和人类在宇宙中的位置。 5.1.2. 《约伯记》 《约伯记》可能完成于公元前 6 到 4 世纪,是《旧约》中最引人深思的智慧文学作品之一。它以散文序幕和结尾,辅以长篇诗歌对话,探讨了「为何义人受苦」这一永恒难题。 故事开始于上帝与撒旦关于义人约伯的对话。撒旦质疑约伯的虔诚只是因为他蒙福,上帝允许撒旦考验约伯,但不可取他性命。接着,约伯失去所有财产、子女,并染上恶疾,但他仍然持守信仰:「赏赐的是耶和华,收取的也是耶和华。」 约伯的三个朋友以利法、比勒达和琐法前来安慰,但逐渐转向指责,认为约伯一定犯了隐秘罪行才受此惩罚。他们代表了传统神学观点:义人受福,恶人遭殃。约伯则坚持自己的无辜,质疑上帝的公正:「我今日发出悲叹,我的创伤沉重,不得休息。」他要求与上帝直接对质。 年轻的以利户插入讨论,提出苦难可能是上帝的管教和教育。最终,上帝从旋风中显现,但并未直接回答约伯的问题,而是通过展示创造的奇妙与复杂,质问约伯是否有资格审判上帝的行为:「我立大地根基的时候,你在哪里呢?」 约伯认识到人类理解的局限,承认自己「说了自己不明白的事」。上帝恢复约伯的财富,赐给他新的儿女,约伯得享长寿。 《约伯记》的核心问题是「神义论」(theodicy):如果上帝全能且全善,为何允许恶和苦难存在?尤其是,为何义人也会受苦?文本并未提供简单答案,而是呈现了几种可能的解释: 苦难是对信仰的考验 苦难是上帝的管教 上帝的计划超出人类理解范围 与上帝的相遇本身,而非理性解释,才是苦难的终极回应 这种对简单解释的拒绝,以及对宗教经验复杂性的坦诚,使《约伯记》成为世界文学中最深刻的作品之一。其中第四个解释 ——「与上帝的相遇本身,而非理性解释,才是苦难的终极回应」—— 尤其具有存在主义式的突破性:这一解释突破了传统神义论的框架,指向一种存在主义式的宗教体验。当上帝从旋风中显现时(《约伯记》38-41 章),祂并未像法庭上的被告般回答约伯的质询,反而以一连串 137 个反问构建出恢弘的宇宙图景: 「你能系住昴星的结吗?能解开参星的带吗?」 「山岩间的野山羊几时生产,你知道吗?」 「马的大力是你所赐的吗?它颈项上挓挲的鬃是你披上的吗?」 这种充满十星张力的「非答案」,实则蕴含着深刻的解构: 超越交易式信仰的灵性觉醒 传统宗教常将苦难视为「罪与罚」的因果链条(如约伯三友的立场),但上帝亲自否定了这种简化的功利主义逻辑。约伯的终极安慰不在于恢复双倍财产(42:10),而在于从「听见传说」(42:5)到「亲眼见你」(42:5)的认知跃迁 —— 这暗示真正的信仰不是利益交换,而是在与绝对他者的相遇中重塑存在维度。 认知暴力的消解与谦卑的重构 上帝的反问本质上是认知论革命:当人类试图用理性框定神圣意志时,实则是将无限者降格为可量化的对象。风暴中的上帝显现(theophany)以压倒性的美学体验,瓦解了约伯先前法庭辩论式的逻辑框架 —— 正如犹太哲学家马丁・布伯在《我与你》中指出,神圣相遇发生在「我 - 你」关系而非「我 - 它」的分析中。 苦难作为启示的催化剂 值得注意的是,约伯在顺境中从未要求见上帝。正是极端的苦难打破了他对「正义世界假设」(just-world hypothesis)的执念,迫使其进入更深层的灵性追问。这呼应了克尔凯郭尔所言:「只有当个人被推向极端处境时,才能与永恒建立真实关系。」苦难在此成为解构表象、触及本真的契机。 现代性的先知预表 卡夫卡在《审判》中让 K 至死未见「法」的真容,而约伯却获得了戏剧性的反转 —— 但这恰恰形成双重讽喻:卡夫卡展现现代人神性缺位的荒诞,而《约伯记》早在两千年前就预言,即便上帝显现,人类依然无法获得明晰答案。两者共同指向存在主义的核心命题:在无意义中创造意义,在沉默中聆听启示。 这种「答案即非答案」的悖论,在犹太教卡巴拉神秘主义中发展为「神圣收缩」(Tzimtzum)理论 —— 上帝必须隐藏自身才能创造空间让世界存在。正如 13 世纪诗人鲁米所言:「伤口是光进入你内心的地方。」《约伯记》最终揭示的,或许正是苦难作为灵性裂痕,让神圣之光得以照入人性的深渊。 5.1.3. 新约 《新约》由 27 卷书组成,包括四福音书、《使徒行传》、保罗书信、其他书信和《启示录》。它记录了耶稣基督的生平、教导、死亡与复活,以及早期基督教的发展。 5.1.4. 四福音书概述 「福音」(Gospel)一词源自希腊语「euangelion」,意为「好消息」,指耶稣基督带来的救恩。四福音书分别是《马太福音》《马可福音》《路加福音》和《约翰福音》,均成书于公元 1 世纪后半叶,用希腊语(Koine Greek,通用希腊语)写成。 前三部福音书(马太、马可、路加)因内容相近而被称为「对观福音」(Synoptic Gospels)。学者普遍认为《马可福音》最早成书,《马太》和《路加》在此基础上扩展,并加入被称为「Q 资料」的共同口传材料。《约翰福音》则独立于对观福音传统,提供了不同的神学视角。 传统上认为四福音书的作者分别是: 马太:耶稣的十二门徒之一,原为税吏 马可:彼得的同工和翻译 路加:保罗的同伴,医生和历史学家 约翰:耶稣所爱的门徒,也是《启示录》和约翰书信的作者 然而现代学术研究认为,四福音书可能是早期基督徒社群集体创作的成果,而非个人作品。无论如何,它们代表了早期基督教对耶稣事迹和教导的不同理解和强调。 每部福音书都有其独特视角和神学强调: 《马太福音》: 主要受众为犹太基督徒 核心主题:耶稣是应验旧约预言的弥赛亚(基督) 频繁引用旧约预言;强调耶稣是新摩西;颁布新律法(如山上宝训);包含五大讲论 独特材料:东方博士来朝;逃往埃及;八福;比喻中的绵羊和山羊 耶稣的形象为大卫的子孙、合法的犹太王 《马可福音》: 主要受众为罗马基督徒 核心主题:耶稣是受苦的仆人、带来上帝的国度 是最短且最早的福音书;强调十字架和受苦 独特材料:较少独特材料;强调「弥赛亚的秘密」 耶稣的形象为受苦的人子、战胜邪灵的驱魔者 《路加福音》: 主要受众为希腊人和外邦人 核心主题:耶稣带来普世救恩,特别关注弱势群体 历史细节丰富;与《使徒行传》成对;强调祷告和圣灵 独特材料:天使报喜;牧羊人来访;浪子回头;好撒玛利亚人 耶稣的形象为完美人性的救主、关爱穷人和被社会边缘化者 《约翰福音》: 主要受众为广泛的希腊、罗马世界 核心主题:耶稣是成肉身的道(logos),是上帝的儿子 高度象征性和神学性;使用「我是」宣言;强调耶稣的神性 独特材料:迦拿婚宴;尼哥底母夜访;与撒玛利亚妇人对话;拉撒路复活 耶稣形象:永恒的道成肉身,与父合一的神圣启示者 四部福音书虽各有侧重,但共享基础叙事框架: 救赎序幕:施洗约翰的预备(玛 3:1/可 1:2/路 3:4/约 1:23) 加利利事工:呼召门徒、治病驱魔、登山宝训等(太 4-18/可 1-9/路 4-9/约 1-6) 耶路撒冷之旅:面向十字架的转折(太 19-20/可 10/路 9:51-19:27/约 7-12) 受难周:荣入圣殿、最后晚餐、被捕受审(太 21-27/可 11-15/路 19:28-23:56/约 12-19) 复活显现:空坟墓与复活见证(太 28/可 16/路 24/约 20-21) 这种三层递进结构(启示→冲突→救赎)塑造了基督教的核心救赎叙事。四重见证既形成立体视角 —— 马太的律法成全、马可的戏剧张力、路加的人文关怀、约翰的永恒维度 —— 又通过差异化的细节选择(如只有约翰记载「复活拉撒路」)形成神学互补。 尽管视角各异,四福音书共同见证了耶稣的生命、教导、死亡和复活,以及这些事件对人类历史的深远意义。 5.1.5. 历史上的基督 根据学术研究,历史上的耶稣(Jesus of Nazareth)生活在约公元前 4 年至公元 30 到 33 年,出生于犹太加利利地区的拿撒勒,在罗马帝国统治下的巴勒斯坦地区传道。他是犹太人,受过施洗约翰的洗礼,聚集了一群门徒,巡回教导和治病。他的教导挑战了当时的宗教权威,特别是在耶路撒冷圣殿的做法。根据福音书记载,他最终在逾越节期间被罗马总督本丢・彼拉多判处钉十字架刑。 耶稣的教导核心包括上帝的国(Kingdom of God)、爱的律法、末世预言和个人与上帝的直接关系。他经常通过比喻和隐喻传递信息,其教导既扎根于犹太传统,又具革新性。 关于耶稣复活的记载是基督教信仰的基础,但这超出了历史研究的范畴,进入信仰领域。无论如何,耶稣及其追随者创立的运动最终发展成为世界性宗教,深刻塑造了人类历史。 5.2. 《古兰经》 《古兰经》(Al-Qur'an,意为「诵读」)是伊斯兰教的核心经典,穆斯林相信它是真主(Allah)通过天使吉卜利勒(Gabriel)向先知穆罕默德(Muhammad,约 570 到 632 年)的启示,在公元 610 到 632 年间陆续降示。 5.2.1. 结构与内容 《古兰经》由 114 章(苏拉,Surah)组成,除第一章外,大致按长度递减排列。全书约 77,000 词,长度与《新约》相近。它以阿拉伯语写成,穆斯林认为其阿拉伯文本具有神圣性,翻译只能视为解释,而非《古兰经》本身。 《古兰经》内容包括: 一神论(tawhid)、末日审判、众先知传承 亚当、挪亚、亚伯拉罕、摩西和耶稣等先知的故事 伦理道德,如正义、诚实、谦卑、怜悯、慷慨 法律规定,如礼拜、施舍、婚姻、继承、商业交易 天地万物的起源与目的 《古兰经》通过神圣启示的透镜,展现从创世到末日的宏大救赎史诗: 创世之谜(30:20-25) 真主从水上创造生命(21:30),赋予人类代治大地的使命。阿丹(亚当)被造为「真主在大地上的代治者」(2:30),但因易卜劣厮(撒旦)的诱惑堕落,开启人性考验的序幕。 先知长链(40:78) 从努哈(诺亚)的方舟、易卜拉欣(亚伯拉罕)捣毁偶像,到穆萨(摩西)分开红海,26 位先知的故事构成神圣启示的连续剧。尤素福(约瑟)被兄长出卖终成埃及宰相的故事(12 章),成为人性善恶博弈的缩影。 终极启示(33:40) 穆罕默德在希拉山洞接受首次启示:「你当奉造物主的名义诵读!」(96:1)。历经 23 年启示,对抗麦加多神崇拜,建立麦地那穆斯林共同体,最终在辞朝演说中宣告:「今天,我已为你们成全你们的宗教」(5:3)。 审判之日(82:1-5) 当苍穹破裂、群星飘坠时(82:1-2),每个人将看到自己行为的账簿:「行一个小蚂蚁重的善事者,将见其善报;作一个小蚂蚁重的恶事者,将见其恶报」(99:7-8)。 这些叙事如镶嵌画般散落各章,通过「认主独一」的金线交织成整体。正如夜行登霄(17:1)的隐喻 —— 先知从麦加禁寺升至七重天,最终回归人间 —— 象征人类从物质世界向神圣本源的溯游与复归。 《古兰经》的语言具有独特的韵律美和修辞力量,被视为阿拉伯文学的巅峰,对阿拉伯语的标准化和发展产生了决定性影响。 5.2.2. 与其他一神教经典的关系 《古兰经》承认并尊重犹太教和基督教的先知与经典。它提到《讨拉特》(Torah,摩西五经)、《宰逋尔》(Zabur,诗篇)和《引支勒》(Injil,福音)等早期启示。穆斯林相信《古兰经》是最后也是最完美的启示,纠正了之前经典中被人篡改的部分。 《古兰经》继承并重新诠释了许多圣经故事。例如,亚伯拉罕(Ibrahim)被描绘为最早的穆斯林(顺从者);约瑟夫(Yusuf)的故事被完整讲述在第 12 章;耶稣(Isa)被尊为重要先知,但《古兰经》否认他的神性和被钉十字架的说法。 5.2.3. 盟约概念的延续与发展 「约」的概念从希伯来圣经延续到《新约》,再到《古兰经》,体现了一神教传统中上帝与人类关系的发展。在《古兰经》中,真主与人类的原初契约(Primordial Covenant)发生在创造之初,所有人类灵魂被问及:「难道我不是你们的主吗?」他们回答:「是的,我们作证。」(7:172) 这一概念与犹太教和基督教的盟约神学有相似之处,但更强调每个人与真主的直接关系,而非通过特定民族或中介。伊斯兰教认为,每个人生来就带有对真主的自然认知(fitrah),伊斯兰的使命是唤醒这种本性,而非引入全新的概念。 《古兰经》对盟约的理解展现了一神教思想的演进:从上帝与特定民族(以色列)的契约,到基督教强调通过耶稣建立的新约,再到伊斯兰教强调的普遍原初契约。这一演进反映了宗教概念的普世化趋势。 5.3. 印度教经典 印度教与西方一神教传统显著不同,它没有单一创始人、统一教义或中央权威,而是由多种哲学思想、崇拜方式和文化习俗组成的复杂系统。其核心经典也非单一文本,而是随时间演变的丰富文献传统。 5.3.1. 《吠陀》 《吠陀》(Vedas,意为「知识」)是印度最古老的宗教文献,约成形于公元前 1500-500 年,最初通过口头传诵,后来才记录成文。吠陀文献分为四部: 《梨俱吠陀》(Rigveda):最古老部分,包含 1,028 首赞美诗,主要献给阿格尼(火神)、因陀罗(雷神)、索玛(月神)等自然神祇。 《夜柔吠陀》(Yajurveda):祭祀仪式的诵词和指导,包括黑夜柔和白夜柔两个传本。 《娑摩吠陀》(Samaveda):祭祀中的颂歌,多数取自《梨俱吠陀》但配以旋律。 《阿闼婆吠陀》(Atharvaveda):包含咒语、医药知识和哲学思考,反映了普通民众的宗教实践。 每部吠陀又分为四个部分: 本集(Samhitas):原始颂歌和祭祀诵词 梵书(Brahmanas):祭祀仪式的解释和指导 森林书(Aranyakas):适合林居者的冥想实践 奥义书(Upanishads):哲学探讨和精神智慧 吠陀文献通过诗性想象构建了动态的宇宙图景,其核心叙事可概括为三幕神圣戏剧: 原人之祭 《梨俱吠陀》第 10 卷第 90 首《原人歌》描绘惊心动魄的创世场景: 「原人有千头千眼千足,覆盖全地…… 诸神将原人作为祭品,春天为酥油,夏天为燃料,秋天为祭品…… 由其身体分化世界:头为天,足为地,呼吸生风,脐部生空界」(10.90.1-14)。这血腥而神圣的献祭确立了「祭祀即宇宙法则」(yajña)的核心理念。 因陀罗屠龙 雷神因陀罗与蛇形魔神弗栗多(Vritra)的对抗是《梨俱吠陀》最壮丽的史诗: 「他(因陀罗)举起金刚杵,击杀盘踞山中的巨龙,释放被囚禁的江河…… 七座城堡在他怒吼中崩塌」(1.32.1-5)。这场战役象征自然力(雨水 / 干旱)的斗争,更隐喻秩序(ṛta)对混沌的胜利。 苏摩之谜 诸神饮用的永生甘露苏摩(Soma)在祭祀中具象化为仪式饮品,《梨俱吠陀》第 9 卷专述其奥秘: 「我们饮下苏摩,获得不朽…… 它在石间榨取,如野牛咆哮,赋予诗人神力」(9.4.1-3)。这种致幻植物酿制的圣酒,成为连接人神的中介,激发吠陀先知(ṛṣi)的灵视。 吠陀叙事最终落地为现实的火祭(agnihotra)仪式: 黎明与黄昏,祭司将酥油倒入圣火,重演原初祭祀 吟诵《梨俱》诗节召唤诸神,如「阿耆尼啊,你是家族祭司,真理的见证者」(1.1.1) 通过《夜柔》指导的精准仪式动作,维系宇宙呼吸的节奏 这种「神话 - 仪式」的闭环结构,使吠陀文明成为活态的神圣剧场。 《吠陀》反映了早期印度 - 雅利安人的世界观,展示了从自然崇拜向抽象哲学思考的演变。它们构成了印度所有哲学和宗教传统的基础,即使是批判吠陀权威的佛教和耆那教,也与吠陀传统有深刻联系。 《吠陀》的宇宙观围绕着「天道」(rita)概念,认为宇宙受到一种固有秩序的支配,通过适当的祭祀活动,人类可以维护这一秩序。《梨俱吠陀》中的《原人赞》(Purusha Sukta)描述世界起源于原初神人的祭祀,社会等级制度也源于此。这种宇宙 - 社会对应关系影响了印度文化数千年。 5.3.2. 《奥义书》 《奥义书》(Upanishads,意为「坐于附近」,暗示师徒间的秘密教导)是吠陀传统的哲学高峰,约成形于公元前 800-200 年。传统认为有 108 部奥义书,但最重要的是早期的主要奥义书,如《伊舍》《迦他》《文荼迦》《曼荼迦》等。 《迦陀奥义书》记载了少年纳奇柯达斯与死神阎摩的传奇对话: 少年自愿走向冥界之门,用生命换取终极智慧。死神三次试图用诱惑劝退他: 第一次考验:「回去吧!我将赐你百象千金的财富」 少年答:「财富如朝露,请赐我超越生死的智慧」(1.1.24-25) 第二次考验:阎摩展示天界乐园 —— 黄金宫殿与不朽青春。 少年问:「当肉体消亡时,这些享乐将归于何处?」(1.2.10) 第三次考验:死神化作美艳女神,许诺永恒欢愉。 少年说:「感官如野马,唯有驭手(智慧)能达彼岸」(1.3.3-4) 最终阎摩揭示最高真理: 「知梵者超越生死,如同水溶于水,火归于火。自我(Atman)是战车主人,身体是战车,智慧是御者……」(1.3.9-14) 少年领悟「汝即彼」(Tat Tvam Asi)的真谛,肉身重返人间时,死亡枷锁已化为晨露。 《大林间奥义书》的创世寓言: 原初的自我(Ātman)在混沌中自语:「我是谁?」 他分裂为男女二形,交合诞生万物。但雌性化为母牛逃避,雄性化为公牛追逐;雌性变母马,雄性变公马…… 如此衍生众生(1.4.3-6)。 当他意识到「万物即自我」时,发出震撼三界的宣言: 「我是创造!我是毁灭!我是呼吸中的呼吸!光中的光!」(5.15.1) 这个自我探索的寓言,成为吠檀多哲学「梵我同一」的终极隐喻。 《奥义书》标志着印度思想从祭祀仪式向内在探索的转向。它们的核心思想包括: 梵我同一:个体灵魂(Atman,我)与宇宙本体(Brahman,梵)本质上是同一的。《文荼迦奥义书》宣称:「认识梵者,即成为梵。」这一洞见成为印度哲学的基石。 轮回与解脱:灵魂因无明(avidya)而困于生死轮回(samsara);通过正确认识自我本性,可以获得解脱(moksha)。 业力法则:行为(karma)产生后果,决定未来生命形式和经历。 知行并重:既重视知识(jnana),也重视行动(karma)和虔诚(bhakti)。 《奥义书》对印度哲学和宗教产生了决定性影响,所有后来的印度哲学流派,如吠檀多(Vedanta)、数论(Samkhya)、瑜伽(Yoga)等,都可视为对《奥义书》思想的不同诠释。即使佛教的「无我」(anatta)理论,也可理解为对奥义书「我」(atman)概念的批判性发展。 《奥义书》也影响了西方思想。19 世纪,叔本华接触到《奥义书》拉丁文译本后宣称:「它们是人类智慧的产物」;爱默生、梭罗等超验主义者从中汲取灵感;科学家如特斯拉、爱因斯坦和薛定谔也表达过对奥义书思想的赞赏。 5.3.3. 达摩 / 法概念 达摩(Dharma,巴利语:Dhamma)是印度宗教哲学中的核心概念,其含义随语境而变化,难以用单一西方术语准确翻译。 在最广泛意义上,达摩指维持宇宙和社会秩序的根本法则。更具体地说,它可以指: 宇宙法则:支撑世界运行的自然规律 社会职责:基于种姓(varna)和人生阶段(ashrama)的社会责任 种姓体系源于《梨俱吠陀》的《原人歌》(10.90.12),将社会分为四个理想类型: 婆罗门(Brāhmaṇa):祭司与学者,职责为研习吠陀、主持祭祀 刹帝利(Kṣatriya):武士与统治者,负责保护众生、执行正义 吠舍(Vaiśya):农民与商人,从事农业、畜牧与贸易 首陀罗(Śūdra):劳动者,服务前三个种姓 这种「原人身体分化说」(婆罗门生于口,刹帝利生于臂,吠舍生于腿,首陀罗生于足)在《摩奴法典》(约公元前 2 世纪)中被系统化为社会规范。但现实中还存在数千个亚种姓(jati),基于职业、地域形成复杂网络。 每个种姓有其专属的「种姓法」(svadharma): 婆罗门需终身学习,刹帝利需精于武艺,吠舍应诚实经商,首陀罗要勤于服务 违反种姓职责被视为「违逆达摩」,如《薄伽梵歌》警告:「履行他人职责是危险的」(3.35) 但《摩诃婆罗多》也通过妓女苏达玛的故事质疑僵化制度:她因诚实比许多婆罗门更接近真理 值得注意的是,佛教和耆那教在继承达摩概念时,都批判种姓制度。佛陀宣称:「河水入海皆失旧名,四姓入我法中亦复如是」(《小部・经集》2.7)。 道德行为:正义、诚实、非暴力等普遍美德 宗教教义:特定宗教传统的核心教导(如佛法) 在《摩诃婆罗多》中,达摩概念被深入探讨,特别是在面临道德困境时。例如,阿周那在库卢之战前犹豫不决,因为无论他选择战斗还是退出,都会违背某种达摩(战士职责与家族责任的冲突)。黑天的教导(《薄伽梵歌》)解决了这一困境,强调行动本身不如行动时的心态重要,提倡「无执着的行动」(nishkama karma)。 与希腊悲剧类似,印度史诗中的达摩困境展示了道德选择的复杂性和悲剧性。然而,不同于希腊宿命观,印度思想强调通过正确理解和超越二元对立,可以解决这些冲突。《薄伽梵歌》教导阿周那战斗不是为了个人利益,而是为了维护宇宙秩序,这种理解将个人行动与宇宙规律统一起来。 达摩概念在印度各宗教传统中有不同解释。印度教强调种姓职责;佛教将达摩理解为宇宙真理和佛陀教导;耆那教极度强调非暴力(ahimsa)作为达摩的核心。尽管解释各异,达摩作为指导个人行为、社会关系和宇宙秩序的核心原则,贯穿了印度思想的各个流派。 6. 中国古代思想经典 6.1. 中国思想传统概述 在探讨具体经典前,先了解中国古代思想的整体背景十分必要。中国哲学与西方和印度传统有着根本性差异,它较少关注形而上学或宗教启示,而更专注于社会伦理、政治秩序和个人修养的实用智慧。 中国古代思想形成于春秋战国时期(公元前 770-221 年),这一被称为「百家争鸣」的时代,各种思想流派相互竞争,为解决社会动荡和道德危机提供方案。主要流派包括儒家、道家、墨家、法家、名家等。其中最有持久影响的是儒家和道家,它们代表了中国思想的两大主要取向。 中国古典哲学的特点包括: 重实践轻理论:关注现实生活而非抽象概念 天人合一:强调人与自然和谐共处 整体性思维:倾向于综合而非分析 重德行轻信仰:以伦理修养而非宗教信条为核心 注重中庸之道:追求平衡与和谐,避免极端 这些特点使中国哲学既独特又互补于西方和印度传统,为人类文明提供了另一种智慧路径。 6.2. 《论语》 6.2.1. 成书背景与历史地位 《论语》(Analects)是记录孔子(公元前 551-479 年)及其弟子言行的语录集,由孔子门人及再传弟子编撰,约成书于公元前 5-4 世纪。它包含 20 篇,共 492 章,主要采用对话形式,记录孔子与弟子、诸侯及他人的交谈。 《论语》的名称意为「讨论的言辞」,反映了它并非系统哲学著作,而是思想碎片的汇集。它通过简短对话和言论,展现孔子思想的多个方面,需要读者自行连接和解读。 作为儒家经典「四书」之首(其他三部为《大学》《中庸》《孟子》),《论语》在中国文化中的地位无可比拟。它不仅是中国古代教育的核心教材,也深刻影响了东亚文化圈(包括日本、朝鲜、越南)的思想传统。自汉代起,《论语》成为科举考试的必考内容,塑造了中国知识分子的思维方式和价值观。 6.2.2. 孔子及其时代 孔子生活在社会动荡的春秋末期,周王室衰微,诸侯争霸,礼崩乐坏。他生于鲁国(今山东曲阜)一个没落贵族家庭,年轻时曾做过小吏,后来创办私学,收徒讲学。他周游列国十四年,希望说服统治者采纳他的政治理念,但未能如愿。晚年返回鲁国,致力于整理古代文献和教育事业。 孔子自称「述而不作」,表明他不是创新者,而是古代文化的传承者和诠释者。然而,他对传统的重新解释实际上具有深刻的创新性,将古代贵族礼仪转化为普遍适用的伦理原则,使道德从血缘和地位的限制中解放出来。 他的生平和教学方法在《论语》中有生动描述:「子曰:『默而识之,学而不厌,诲人不倦,何有于我哉?』」(《述而》)这种谦虚和教学热忱塑造了他作为中国历史上最伟大教育家的形象。 这句自述集中体现孔子的教育哲学: 默而识(zhì)之:把所见所闻默默记在心里 识:不是死记硬背,而是理解内化 学而不厌:努力学习从不满足 「厌」通「餍」,意为满足 诲人不倦:教导别人从不厌倦 何有于我哉 表面自谦:「这些我做到了哪些呢?」 实则暗含期许:「这些不正是教育者的本分吗?」 6.2.3. 核心思想 《论语》虽然不是系统哲学著作,但包含几个相互关联的核心概念: 仁 仁是孔子思想的核心,《论语》中提到 109 次。它最初指亲属间的亲爱之情,孔子将其扩展为普遍的人道精神和道德完善的理想。 「仁者爱人」(《颜渊》)简洁地表达了仁的本质。仁者是充满慈爱之心、满怀爱意的人。 仁的实践包括自律、尊重他人和推己及人:「己所不欲,勿施于人」(《颜渊》)—— 自己不愿意的,不要施加给别人。 仁不是抽象理念,而是具体行为中体现的品质:「仁远乎哉?我欲仁,斯仁至矣」(《述而》;译文:仁难道离我们很远吗?我想达到仁,仁就到了),强调道德实践的即时性和可得性,以及人进行道德修养的主观能动性。仁是人天生的本性,因此为仁就全靠自身的努力,不能依靠外界的力量。 礼 礼指规范社会行为的仪式和习俗。孔子强调礼不仅是外在形式,更是内在道德的表达:「人而不仁,如礼何?人而不仁,如乐何?」(《八佾》;译文:人的内心不追求仁,表面上遵守礼又有什么用呢?人的内心不追求仁,表面上演奏乐又有什么用呢?)儒家推崇礼乐,礼和乐是形式,其本质是为了提升人性,使上位者能爱民如子,百姓能知耻且格(指人有知耻之心,则能自我检点而归于正道)。 礼在孔子思想中有双重功能:维护社会秩序和培养个人品格。适当的礼仪表达了对他人的尊重,也帮助个人培养内在美德。礼的核心是「敬」,即真诚尊重之心。 义 义是道德正当性的标准,指行为符合道德原则而非追求私利。「君子喻于义,小人喻于利」(《里仁》)表明义与利的对立。然而,孔子并非完全否定利益追求,而是强调义应指导利:「富与贵,是人之所欲也;不以其道得之,不处也」(《里仁》;译文:财富与地位,是每个人都渴望的,但如果不用正当方法获得,君子绝不接受)。 智 智指道德智慧和判断力,而非纯粹的知识积累。孔子重视学习,但认为学习目的是道德实践:「学而不思则罔,思而不学则殆」(《为政》;译文:只学习不思考会陷入迷茫,只空想不学习会走向危险)。 真正的智慧包括认识自己的限度:「知之为知之,不知为不知,是知也」(《为政》;译文:知道就说知道,不知道就承认不知道,这才是真智慧)。 信 信是诚实和信用,社会和谐的基础:「人而无信,不知其可也」(《为政》;译文:如果一个人没有信用,真不知道他如何立身处世)。信任是社会关系和政治统治的基础:「民无信不立」(《颜渊》;译文:失去民众的信任,国家就无法存续)。 君子理想 君子是孔子提出的理想人格,原指贵族身份,被重新定义为道德卓越者。君子德行包括: 自省:「君子求诸己,小人求诸人」(《卫灵公》;译文:君子遇事反思自己,小人遇事指责他人) 中庸:「君子和而不同,小人同而不和」(《子路》;译文:君子在差异中寻求和谐,小人在表面统一中制造矛盾) 执着于道德:「君子喻于义,小人喻于利」(《里仁》;译文:君子的思维围绕道义展开,小人的思维局限在利益算计) 勇于改错:「过而不改,是谓过矣」(《卫灵公》;译文:有了过错却不改正,这才是真正的错误) 君子概念是孔子思想中重要的创新,将社会地位与道德成就分离,为中国建立了基于德行而非血统的精英理想。 6.2.4. 政治与社会思想 孔子的政治思想基于「为政以德」原则:「为政以德,譬如北辰,居其所而众星共之」(《为政》;译文:用德行治理国家,就像北极星稳居中心,群星自然环绕)。他强调统治者的道德榜样作用超过法律强制:「道之以政,齐之以刑,民免而无耻;道之以德,齐之以礼,有耻且格」(《为政》;译文:用政令引导、刑罚约束,百姓只会逃避惩罚而无廉耻;用德行感化、礼教规范,百姓会有羞耻心且自觉守规)。 孔子提倡「正名」,即社会角色与实际行为相符:「君君,臣臣,父父,子子」(《颜渊》;译文:君王守君道,臣子尽臣责,父亲担父职,儿子行子孝)。这反映了他对社会秩序的关注和对个人责任的强调。 他的理想政治是大同社会:「大道之行也,天下为公」(《礼记・礼运》,虽非出自《论语》,但能够反映出孔子思想;译文:理想社会运行时,天下资源属于全体民众)。然而,他认识到这需要循序渐进的变革,而非革命,重视传统作为社会稳定的基础:「温故而知新,可以为师矣」(《为政》;译文:在传统中开创新知,方能成为时代的引领者)。 6.2.5. 教育思想 作为中国第一位专业教育家,孔子的教育观对中国产生深远影响: 因材施教:「有教无类」,不分贵贱皆可教育 启发式教学:「不愤不启,不悱不发」(《述而》;译文:不到学生冥思苦想仍不通时,不去点拨;不到他想说却说不清时,不去引导) 理论与实践结合:「学而时习之,不亦说乎」(《学而》;译文:学习知识并能适时实践,岂不令人愉悦) 终身学习:「吾十有五而志于学,三十而立,四十而不惑,五十而知天命,六十而耳顺,七十而从心所欲,不逾矩」(《为政》) 《论语》记录孔子教学方法灵活多样,针对不同学生提出不同要求。他强调学习古代经典,同时也重视实践和反思:「学而不思则罔,思而不学则殆」(《为政》)。 6.2.6. 历史影响与现代意义 《论语》对中国和东亚文化的影响难以估量。它塑造了中国知识分子的价值观,影响了政治制度、社会规范和家庭关系。儒家思想通过科举制度成为官方意识形态,成为统治阶级的合法性基础。 《论语》也影响了西方。17 世纪传入欧洲后,伏尔泰、康德等启蒙思想家对孔子的道德哲学表示赞赏。莱布尼茨认为中国文明可以补充欧洲文化。 20 世纪,《论语》经历了从被激烈批判到重新评价的过程。当代学者指出儒家思想中的普世价值,如仁爱、和谐、责任,对现代社会仍有启示。儒家强调和谐与平衡的思想,对处理全球化带来的文化冲突提供了另一种视角。 现代解读强调《论语》中的人文主义精神和批判精神,而非教条。儒家思想的现代转化可见于新加坡、韩国和日本的发展模式,展示了传统价值如何与现代化协调发展。 6.3. 《道德经》 6.3.1. 作者与成书 《道德经》,又称《老子》,传统上认为是春秋末期的老子(李耳,约公元前 571-471 年)所著。全书约 5000 字,分为道经(前 37 章)和德经(后 44 章)两部分。「道德」在这里不是现代伦理学意义,而是指宇宙本体(道)及其在人世的体现(德)。 老子的历史真实性一直存在争议。据《史记》记载,他是周朝守藏室的官员,与孔子同时代或稍早,晚年西行,应函谷关令尹喜之请写下此书。但现代学者对此存疑,有人认为《道德经》可能是战国时期(公元前 475-221 年)道家思想的集体结晶,而非单一作者所作。 最早的《道德经》文本来自 1973 年长沙马王堆汉墓出土的帛书,约公元前 200 年。1993 年,荆门郭店楚墓出土的竹简进一步推前了文本历史,约公元前 300 年。这些考古发现证实《道德经》至少在公元前 3 世纪末已成形。 6.3.2. 核心思想 道 道是《道德经》最核心的概念,有多层含义: 宇宙本体:「有物混成,先天地生…… 可以为天下母」(第 25 章) 自然规律:「道法自然」(第 25 章) 无为原则:「道常无为而无不为」(第 37 章) 道具有矛盾特性:「道可道,非常道;名可名,非常名」(第 1 章),它超越语言和概念,只能通过悖论暗示:「玄之又玄,众妙之门」(第 1 章)。 道的本质是「无」和「空」:「天下万物生于有,有生于无」(第 40 章)。这种「无」不是虚无,而是充满潜在性的空白,如:「埏埴以为器,当其无,有器之用」(第 11 章)。 德 德是道在个体中的体现和实践。它不是外加的道德规范,而是顺应道的自然品质:「上德不德,是以有德;下德不失德,是以无德」(第 38 章)。真正的德是不刻意追求美德的结果,而是自然流露。 无为 无为是道家核心行动原则,不是绝对不行动,而是不做违背自然的强制行为:「为无为,则无不治」(第 3 章)。 无为的统治是顺应事物自身规律:「太上,下知有之;其次,亲而誉之…… 功成事遂,百姓皆谓:『我自然』」(第 17 章)。理想统治者工作不被察觉,让民众感到一切自然发生。 返朴归真 《道德经》批评文明的复杂性和人为规范:「绝圣弃智,民利百倍;绝仁弃义,民复孝慈;绝巧弃利,盗贼无有」(第 19 章)。它主张返回简朴自然状态:「见素抱朴,少私寡欲」(第 19 章)。 理想社会是「小国寡民」(第 80 章),人们安于简单生活,不追求复杂知识和远程交往:「甘其食,美其服,安其居,乐其俗」(第 80 章)。 辩证思维 《道德经》展现了独特的辩证思维,强调对立面相互依存:「有无相生,难易相成,长短相形,高下相倾」(第 2 章)。它认为事物会向反面转化:「祸兮福之所倚,福兮祸之所伏」(第 58 章)。 这种思维导致「反者道之动」原则,即事物发展趋向其反面:「弱者道之用」(第 40 章),「曲则全,枉则直,洼则盈,敝则新,少则得,多则惑」(第 22 章)。 6.3.3. 政治思想 《道德经》的政治思想可概括为「无为而治」,认为最好的统治是最少的干预:「故圣人云:我无为,而民自化;我好静,而民自正;我无事,而民自富;我无欲,而民自朴」(第 57 章)。 它批评儒家的积极政治理念,认为道德教化和礼制只会使社会更加混乱:「天下多忌讳,而民弥贫;民多利器,国家滋昏;人多伎巧,奇物滋起;法令滋彰,盗贼多有」(第 57 章)。 《道德经》推崇柔弱胜刚强的治国策略:「天下莫柔弱于水,而攻坚强者莫之能胜」(第 78 章)。理想统治者应保持低调:「圣人不积,既以为人,己愈有;既以与人,己愈多」(第 81 章)。 6.3.4. 与儒家的关系 道家与儒家代表了中国思想的两大主要取向,它们的关系既对立又互补: 对立方面: 儒家强调社会参与,道家主张超脱尘世 儒家重视礼仪规范,道家推崇自然本性 儒家相信教育改良,道家怀疑知识价值 儒家推崇道德完善,道家追求自然和谐 互补方面: 都关注社会和谐,只是方法不同 都强调自我修养,儒家外向,道家内向 都批评暴力与战争 在中国历史上常互相融合,儒家入世,道家出世 中国文化传统常被概括为「外儒内道」,表明这两种思想在实践中的互补性。文人常在政治生涯(儒)与山水隐居(道)间转换,体现中国知识分子的双重理想。 6.3.5. 道家哲学的演变 《道德经》奠定了道家哲学基础,其思想在后世有多种发展方向: 庄子(约公元前 369-286 年)将老子的自然主义发展为一种精神自由哲学,强调超越社会规范,《庄子》的寓言如《逍遥游》展现了道家精神自由的理想。 黄老道家(汉初)将道家思想与法家统治术结合,成为汉初的统治思想,强调顺应自然规律的统治艺术。 宗教道教(东汉末年)将道家哲学转化为追求长生不老的宗教体系,《太平经》和《周易参同契》等著作结合了道家思想与方士传统。 魏晋玄学(3-6 世纪)融合道家、儒家和佛教,发展出「贵无」和「崇有」两派,代表人物如王弼、郭象等,对老庄进行本体论诠释。 新道家(宋明理学)将道家思想融入儒家框架,如周敦颐《太极图说》结合了儒道宇宙观。 这种多元发展表明《道德经》具有丰富的解释空间和深刻的启发性,使其成为中国思想史上最有生命力的经典之一。 6.3.6. 现代意义与全球影响 《道德经》是被翻译最广的中国古典文献,在西方引起广泛关注,影响了从物理学到生态环保的多个领域: 现代物理学:量子物理学家如卡普拉(Fritjof Capra)在《道的物理学》中指出道家宇宙观与量子理论的相似性,如互补原理和整体性。 环境生态:道家的自然哲学启发了深层生态学,强调人与自然和谐共处,而非控制征服。 领导理论:无为而治的理念影响了现代组织管理,强调授权和创造性空间。 心理治疗:道家的接受与顺应思想影响了西方心理治疗,特别是接受与承诺疗法。 流行文化:从《星球大战》中的「原力」到各种冥想实践,道家思想渗透到西方流行文化的方方面面。 《道德经》对现代社会的主要启示包括:批判消费主义和物质主义,强调简朴生活;提供替代性发展模式,不以单纯速度和规模为目标;在技术至上时代重新思考人与自然的关系;在充满对立的世界中,提供一种超越二元思维的智慧。 7. 早期基督教思想 7.1. 圣奥古斯丁与《忏悔录》 7.1.1. 作者生平与历史背景 圣奥古斯丁(Augustine of Hippo,354-430 年)是早期基督教最重要的神学家和哲学家之一,被称为拉丁教父中的巨擘。他出生于北非塔加斯特(今阿尔及利亚),父亲是异教徒,母亲莫尼卡则是虔诚的基督徒。 奥古斯丁的生涯正值罗马帝国转型的关键时期。君士坦丁大帝于 313 年颁布《米兰敕令》,使基督教从被迫害宗教转变为合法信仰,到奥古斯丁时代,基督教已成为帝国主导宗教。然而,帝国政治正日渐衰落,他去世前不久(410 年),罗马城被西哥特人洗劫,标志着西罗马帝国开始崩溃。 年轻时的奥古斯丁并非基督徒,而是沉迷于世俗享乐。他接受良好教育,成为修辞学教师,先后在迦太基、罗马和米兰任教。在米兰,他结识了安波罗修主教,并受到新柏拉图主义影响。经历长期精神挣扎后,在 387 年复活节前夕受洗,正式皈依基督教。 皈依后,他返回北非,创建修道院,过着禁欲生活。391 年,他在希波被祝圣为神父,396 年成为希波主教,直至去世。主教期间,他与多纳徒派、伯拉纠派等异端进行激烈辩论,著书立说,奠定了中世纪基督教神学的基础。 7.1.2. 《忏悔录》的写作与结构 《忏悔录》(Confessiones)写于 397-400 年间,是西方文学中第一部真正意义上的自传,以祈祷形式向上帝倾诉。然而,它远非简单的生平叙述,而是深刻的神学反思和哲学探索。全书共 13 卷: 卷一至九:自传部分,记述奥古斯丁从童年到皈依的人生历程,特别强调他的精神探索与挣扎。 卷十:对当下自我的反思,探讨记忆本质。 卷十一至十三:对《创世记》的冥想与诠释,探讨时间、创造和三位一体等神学主题。 《忏悔录》的标题「confessiones」在拉丁文中有双重含义:既是「忏悔」(认罪),也是「赞美」(认信上帝的伟大)。这双重意义贯穿全书,使之既是对个人罪的坦承,也是对神恩典的颂扬。 7.1.3. 自传叙事:从迷失到皈依 《忏悔录》开篇即点明全书主题:「我们的心若不安息在你怀中,便不得安宁。」这揭示了奥古斯丁对灵魂不安与对神渴望的核心体验。 奥古斯丁坦率描述自己的早年生活,包括犯下的各种罪行。他讲述自己如何偷窃梨子,不是因为饥饿,而是「为恶而恶」;他承认自己沉迷情欲,曾祈求「主啊,请赐我贞洁和节制,但不要太快」;他回忆自己追求名利与虚荣,以及对戏剧和竞技场娱乐的迷恋。 他的精神探索经历了多个阶段: 青年时期接触西塞罗的《荷尔顿修斯》,激发对智慧的热爱 加入摩尼教九年,被其二元论所吸引 深入研究占星术,后在学术和逻辑上发现其谬误 接触学院派怀疑主义,对一切确定性产生怀疑 在米兰被新柏拉图主义吸引,特别是普罗提诺的著作 关键转折发生在米兰花园。在痛苦挣扎中,奥古斯丁听到一个声音说「拿起来读」,随手翻开保罗的《罗马书》,读到「不可荒宴醉酒,不可好色邪荡,不可争竞嫉妒;总要披戴主耶稣基督,不要为肉体安排,去放纵私欲」(罗马书 13:13-14)。这段经文使他心中茅塞顿开,决定皈依基督教。 皈依后不久,他的母亲莫尼卡去世。书中记录了他与母亲在奥斯提亚的一次神秘体验,两人在谈话中共同上升,短暂感受到永恒的临在,这是书中最动人的段落之一。 7.1.4. 哲学与神学思想 《忏悔录》不仅是自传,也是深刻的哲学和神学著作,探讨了多个重要主题: 罪恶与意志 奥古斯丁对罪恶本质的分析极为深刻。他认为罪不是实体,而是对善的缺失或扭曲:「我寻找恶是什么,发现它不是实体,而是意志从最高实体转向低级事物的扭曲。」 他提出「分裂的意志」概念,描述内心挣扎:「意志命令自己,而自己却不服从。」这种自我分裂反映了原罪对人类自由的损害。在奥古斯丁看来,真正的自由不是为所欲为,而是摆脱罪的束缚,做出正确选择的能力。 记忆与时间 《忏悔录》第十卷对记忆本质进行了开创性探讨。奥古斯丁将记忆描述为「广阔的宫殿」,包含过去经验、学习知识,甚至某种对神的内在认识。他提出记忆不仅储存感官经验,还包含非感官性概念,如数学真理。 第十一卷著名的时间分析至今仍有哲学价值。他质问:「什么是时间?如果没人问我,我知道;如果要我解释,我不知道。」他分析过去、现在和未来,认为只有「现在」真实存在,而过去是「不再」的现在,未来是「尚未」的现在。现在本身也是瞬息即逝的。这种分析导向对永恒的思考,永恒不是无尽时间,而是时间的彻底超越,在永恒中,一切都是「同时」的现在。 上帝与创造 奥古斯丁反对摩尼教的二元论,强调上帝创造的一切本质上都是善的:「你看你所创造的一切,都甚美好。」邪恶不是独立实体,而是善的缺失或扭曲。 在诠释《创世记》时,他探讨上帝如何「从无中创造」,以及时间与创造的关系。他认为上帝创造了时间本身,因此在创造之前谈论「时间」是没有意义的。这些思考展现了奥古斯丁将希腊哲学概念与基督教教义融合的才能。 恩典与预定论 《忏悔录》强调人类救赎完全依靠上帝的恩典。奥古斯丁认为,由于原罪,人类无法凭自己的力量选择善。即使信仰本身也是上帝的恩赐:「信仰不是出于我们自己,乃是上帝所赐的礼物。」 这导致他后来发展出预定论学说,认为上帝预先选择了得救者,这些思想对改教家马丁・路德和约翰・加尔文产生深远影响。 7.1.5. 文学特色与修辞艺术 《忏悔录》不仅是哲学和神学著作,也是杰出的文学作品,展现了奥古斯丁作为修辞学教师的才华: 祈祷式叙述 全书直接向上帝倾诉,创造了亲密对话的效果:「你创造了我们归向你,我们的心若不安息在你怀中,便不得安宁。」这种祈祷体强调了上帝作为隐形听众的临在,使读者成为这场灵魂对话的旁观者。 心理分析的深度 奥古斯丁对自己内心状态的分析惊人地现代。他描述记忆、情感、欲望和意志的复杂互动,展现了深刻的自我意识:「我已成为自己的内心荒地。」他对青少年心理的描述,如偷梨事件中对群体压力的分析,显示了对人性的敏锐洞察。 生动的意象与比喻 书中充满鲜活意象,如将上帝描述为「我灵魂的甘露」,将自己比作「浪子远离你的面容」。特别是对内心挣扎的描述常用生动比喻:「我旧日罪恶的习惯依然握住我,低声说:『你要抛弃我们吗?』」 语言的音乐性 拉丁文原文具有强烈的节奏感和韵律美,即使在翻译中也能感受到语言的起伏与张力。奥古斯丁善用排比、对照、顶真等修辞手法,创造出既激情又精确的语言风格。 7.1.6. 历史影响与现代意义 《忏悔录》对西方文学、哲学和神学产生了深远影响: 自传文学的开创 作为西方第一部成熟自传,《忏悔录》确立了内省自传的传统,影响了从卢梭的《忏悔录》到托尔斯泰的《忏悔》等后世经典。它开创了将个人经历与思想探索结合的文体。 神学影响 奥古斯丁的神学思想塑造了整个中世纪基督教。他关于原罪、神恩、预定论的学说成为天主教正统教义,也深刻影响了新教神学。《忏悔录》对信仰经验的描述为后世神秘主义传统提供了模板。 哲学贡献 他对时间、记忆、意识等主题的探讨影响了胡塞尔、海德格尔等现象学家。他的内省方法被视为现代意识哲学的先驱。维特根斯坦曾说:「如果把哲学比作城市,奥古斯丁是先民们留下的古城,而我们则生活在郊外的新区。」 心理学启示 弗洛伊德承认奥古斯丁对潜意识的洞察先于精神分析学。荣格则从奥古斯丁的自我探索中汲取灵感,发展个体化理论。现代心理治疗中的自传式叙事也可追溯到《忏悔录》的传统。 现代意义 在当代多元文化语境下,《忏悔录》仍具有重要价值。它对内心挣扎的真实描述,对生命意义的深刻探索,对善恶问题的不懈思考,超越了特定宗教框架,成为人类共同的精神遗产。 在个人身份日益碎片化的现代社会,《忏悔录》提供了一种整合自我的模式,表明个人叙事如何能赋予生命意义和连贯性。它证明,严肃的自我反思不仅具有心理价值,也有深刻的精神和哲学意义。 与现代许多自传不同,《忏悔录》不是自我肯定或自我辩护,而是谦卑的自我审视,承认人类的限度和对超越力量的需要。这种视角在今天的自我中心文化中尤为珍贵,提醒我们自我实现的真正意义可能恰恰在于超越自我。

2025/4/7
articleCard.readMore

Markdown Rendering in React

One of our project requirements was to support Markdown rendering, aiming to replicate effects similar to ChatGPT and Claude. This article aims to document the problems I encountered and their solutions while implementing this feature. This is an English translation of an article originally published in Chinese on February 20, 2024. The content of the original article may be outdated or deprecated. Please verify the current status of any tools, libraries, or methods mentioned before implementing them in your projects. react-markdown First, install react-markdown. npm install react-markdown Let's test using the official example text from the react-markdown library: # A demo of `react-markdown``react-markdown` is a markdown component for React.👉 Changes are re-rendered as you type.👈 Try writing some markdown on the left.## Overview* Follows [CommonMark](https://commonmark.org)* Optionally follows [GitHub Flavored Markdown](https://github.github.com/gfm/)* Renders actual React elements instead of using `dangerouslySetInnerHTML`* Lets you define your own components (to render `MyHeading` instead of `'h1'`)* Has a lot of plugins## ContentsHere is an example of a plugin in action([`remark-toc`](https://github.com/remarkjs/remark-toc)).**This section is replaced by an actual table of contents**.## Syntax highlightingHere is an example of a plugin to highlight code:[`rehype-highlight`](https://github.com/rehypejs/rehype-highlight).```jsimport React from 'react'import ReactDOM from 'react-dom'import Markdown from 'react-markdown'import rehypeHighlight from 'rehype-highlight'const markdown = `# Your markdown here`ReactDOM.render( <Markdown rehypePlugins={[rehypeHighlight]}>{markdown}</Markdown>, document.querySelector('#content'))\```> Pretty neat, eh?## GitHub flavored markdown (GFM)For GFM, you can *also* use a plugin:[`remark-gfm`](https://github.com/remarkjs/react-markdown#use).It adds support for GitHub-specific extensions to the language:tables, strikethrough, tasklists, and literal URLs.These features **do not work by default**.👆 Use the toggle above to add the plugin.| Feature | Support || ---------: | :------------------- || CommonMark | 100% || GFM | 100% w/ `remark-gfm` |~~strikethrough~~* [ ] task list* [x] checked itemhttps://example.com## HTML in markdown⚠️ HTML in markdown is quite unsafe, but if you want to support it, you canuse [`rehype-raw`](https://github.com/rehypejs/rehype-raw).You should probably combine it with[`rehype-sanitize`](https://github.com/rehypejs/rehype-sanitize).<blockquote> 👆 Use the toggle above to add the plugin.</blockquote>## ComponentsYou can pass components to change things:```markdownimport React from 'react'import ReactDOM from 'react-dom'import Markdown from 'react-markdown'import MyFancyRule from './components/my-fancy-rule.js'const markdown = `# Your markdown here`ReactDOM.render( <Markdown components={{ // Use h2s instead of h1s h1: 'h2', // Use a component instead of hrs hr(props) { const {node, ...rest} = props return <MyFancyRule {...rest} /> } }} > {markdown} </Markdown>, document.querySelector('#content'))/``` In the test text, there are also code blocks, but since I'm using Hexo for my blog, these texts that should be in code blocks (as I set) were all rendered as code blocks. Therefore, I added a backslash after each code block to prevent them from being rendered as code blocks. You can remove these backslashes when testing. import ReactMarkdown from "react-markdown";function App() { const markdownContent = `{The Markdown test text mentioned earlier}`; return ( <> <ReactMarkdown>{markdownContent}</ReactMarkdown> </> );} The rendered effect (remember to replace the value of markdownContent): As you can see, there are some differences compared to Typora or GitHub's rendering effects. For example, the code blocks and regular text styles are too similar, and we'd prefer code blocks with distinct background colors and syntax highlighting. Additionally, special styles like tables, task lists, and strikethrough weren't rendered. remark-gfm Actually, the test text already told us why: these are GFM (GitHub Flavored Markdown) features, and react-markdown doesn't support GFM by default. So we need to install the remark-gfm plugin to support GFM: npm install remark-gfm Using plugins with react-markdown is quite simple: import remarkGfm from "remark-gfm";// ...<ReactMarkdown remarkPlugins={[remarkGfm]}>{markdownContent}</ReactMarkdown> The rendered effect: At this point, you're probably wondering: where are the table borders?? If we inspect the table's styles using developer tools, we'll find that the table is indeed rendered as a table tag, but the User Agent Stylesheet has applied some processing to the table tag. So we need to write some styles to override these default styles: table { border-spacing: 0 !important; border-collapse: collapse !important; border-color: inherit !important; display: block !important; width: max-content !important; max-width: 100% !important; overflow: auto !important;}tbody, td, tfoot, th, thead, tr { border-color: inherit !important; border-style: solid !important; border-width: 2px !important;} Add !important to all styles to override the User Agent Stylesheet styles. The rendered effect now: Due to security considerations, I decided not to use HTML support, so I won't demonstrate it here. Now we've completed a simple Markdown rendering feature. However, there's one crucial feature in this requirement: code block highlighting. Code Block Highlighting Due to project requirements, code blocks must be prominent and have syntax highlighting, which applies to inline code blocks as well. Here we can use react-syntax-highlighter. npm install react-syntax-highlighter react-syntax-highlighter has two engines: prism and highlight.js. You can search online for their detailed differences. Here we'll use the prism engine with the oneDark theme: import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; In the component prop of ReactMarkdown, we can customize how code blocks are rendered. If you've seen other tutorials online, they almost all write it like this: <ReactMarkdown remarkPlugins={[remarkGfm]} components={{ code({ node, inline, className, children, ...props }) { const match = /language-(\w+)/.exec(className || '') return !inline && match ? ( <SyntaxHighlighter style={nightOwl} language={match[1]} PreTag="div" children={String(children).replace(/\n$/, '')} {...props} /> ) : ( <code className={className} {...props} children={children} /> ) } }}> {markdownContent}</ReactMarkdown> Here we customized the rendering behavior of the code component. The parameters in code({ node, inline, className, children, ...props }) {} represent: node: current node inline: whether it's an inline code block className: class name children: child nodes (content in code block) ...props: other properties Then we use regex to match language-xxx in className, and if there's a match and it's not an inline code block, we use SyntaxHighlighter to render the code block; otherwise, we use the default code tag. But!!! The inline property has been deprecated and won't be passed as a parameter in the new version of react-markdown!!! This really gave me a headache at the time, and I couldn't find a solution after searching online. I decided to temporarily solve another problem: rendering code blocks without defined languages. After rendering, the biggest difference between code blocks and inline code blocks is that code blocks have a pre tag wrapper. Since the code tag as a child tag can't get the parent tag's styles from props, but thinking reversely, the pre tag can get the code tag's styles! So I wrote this wild solution: <Markdown remarkPlugins={[remarkGfm]} components={{ pre({node, className, children, ...props}) { if (children["type"] === "code") { try { const match = children["props"]["className"].match(/language-(\w+)/) return ( <pre> <SyntaxHighlighter style={oneDark} language={match[1]} PreTag="div" showLineNumbers wrapLongLines children={String(children["props"]["children"]).replace(/\n$/, '')} /> </pre> ) } catch (e) { return ( <pre> <SyntaxHighlighter style={oneDark} language="python" PreTag="div" showLineNumbers wrapLongLines children={String(children["props"]["children"]).replace(/\n$/, '')} /> </pre> ) } } } }}> {markdownContent}</Markdown> Directly check the children's type in the pre tag - if it's code, then it must be a code block, and then match the language type in className. If there's no match, it will cause an error, so I temporarily added a try...catch statement - if there's no match, just default to rendering it as a Python language code block. The effect is not bad: Afterword There are still some issues with this implementation, such as unresolved inline code block rendering, the try...catch statement not being a good solution, and the technical debt of defaulting to Python language for undefined language code blocks is quite messy! However, due to project time constraints, I didn't delve deeper into these issues. Perhaps when I have more time in the future, I'll revisit and solve these problems. That's for another time.

2025/2/21
articleCard.readMore

Batch Merging TS Files Using ffmpy3

How to use Python's ffmpy3 package to batch merge TS files into a single MP4 file. Before reading, you need to know: ffmpy3 is a Python wrapper for FFmpeg ffmpy3 complies FFmpeg command line based on provided parameters and options Using ffmpy3 Installing ffmpy3 Package Install using pip: pip install ffmpy3 Simple ffmpy3 Example import ffmpy3ff = ffmpy3.FFmpeg( inputs={'input_file': 'parameter1'}, outputs={'output_file': 'parameter2'}) The final result is equivalent to entering in terminal: FFmpeg parameter1 -i input_file parameter2 output_file Batch Merging TS Files Directory Structure ├───folder│ python_file.py│ file.txt│ fileA.ts│ fileB.ts│ fileC.ts│ fileD.ts... file.txt Write the TS filenames in file.txt: file 'fileA.ts'file 'fileB.ts'file 'fileC.ts'file 'fileD.ts' Note: Use single quotes, not double quotes - the latter will cause errors! Use relative paths within the quotes Python File Use this code to batch merge TS files: ff = ffmpy3.FFmpeg( inputs={f'file.txt': '-f concat'}, outputs={f'filename.mp4': '-c copy'})ff.run() The above code is equivalent to entering in terminal: FFmpeg -f concat -i file.txt -c copy filename.mp4

2025/2/21
articleCard.readMore

React + NestJS 购物平台练习【5】用户登录功能

在构建电商平台的过程中,用户登录是一个不可或缺的核心功能。 本文将详细介绍如何在 React 前端实现登录表单组件,并结合 NestJS 后端完成完整的用户认证流程,包括 JWT 认证、记住我功能以及登录状态持久化等关键特性。 1. 小改动 1.1. 改变量 / 字段名字 将 React 项目的 src/stores/user/types.ts 中的 LoginCredentials 的 email 修改为 username: export interface LoginCredentials { username: string; password: string; } 1.2. 修改依赖数组 在 src/pages/VerifyEmail.tsx 中的 useEffect 的依赖数组内加一个 location.search: useEffect(() => { const searchParams = new URLSearchParams(location.search); const token = searchParams.get('token'); const currentToken = useUserStore.getState().verificationToken; if (token && !verificationInProgress && !emailVerified && token !== currentToken) { handleVerification(token).then(r => { if (r.success && 'message' in r) console.log('邮箱验证成功:', r.message); }); }}, [handleVerification, verificationInProgress, emailVerified, location.search]); 1.3. 新建 email-verification.interface.ts 将原本放在 users.service.ts 内的这段内容单独放在一个文件里: export enum EmailVerificationError { TOKEN_INVALID = 'TOKEN_INVALID', TOKEN_EXPIRED = 'TOKEN_EXPIRED', ALREADY_VERIFIED = 'ALREADY_VERIFIED'}export interface VerificationResponse { success: boolean; message: string; error?: EmailVerificationError; userId?: string;} 1.4. 统一查找用户的方法 原先我们只写了靠 ID 来查找用户的方法,如果要进行扩展的话,一个方法一个方法写太麻烦了,干脆写一个更灵活的查找方法、支持通过任何唯一字段查找用户: async findUser(criteria: UserSearchCriteria) { this.logger.debug(`开始查找用户,条件:${JSON.stringify(criteria)}`); const whereClause = Object.entries(criteria) .filter(([, value]) => value !== undefined) .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); if (Object.keys(whereClause).length === 0) { this.logger.warn('查询条件为空'); throw new BadRequestException('至少需要一个查询条件'); } const user = await this.usersRepository.findOne({ where: whereClause }); if (!user) { const [[key, value]] = Object.entries(whereClause); const fieldMap = { id: 'ID', username: '用户名', email: '邮箱', verificationToken: '验证令牌' }; const errorMessage = `未找到${fieldMap[key]}为 ${value} 的用户`; this.logger.warn(`查找用户失败:${errorMessage}`); throw new NotFoundException(errorMessage); } this.logger.debug(`用户查找成功:${user.id},用户详细信息:${JSON.stringify(user)}`); return user;} export type UserSearchCriteria = Partial<{ id: string; username: string; email: string; verificationToken: string;}>; UsersController 的查找用户 API 也可以修改成类似的样子: @ApiOperation({ summary: '根据不同字段查找用户' })@ApiParam({ name: 'field', enum: ['id', 'username', 'email'], description: '查找字段' })@ApiParam({ name: 'value', description: '查找值' })@ApiResponse({ status: HttpStatus.OK, description: '获取用户信息成功', type: Users })@HttpCode(HttpStatus.OK)@Get(':field/:value')async findByField(@Param('field') field: 'id' | 'username' | 'email', @Param('value') value: string): Promise<Users> { const validFields = ['id', 'username', 'email']; if (!validFields.includes(field)) { throw new BadRequestException(`不支持通过 ${field} 字段查找用户`); } return this.usersService.findUser({ [field]: value });} 1.5. JWT Payload 修复 // 过去的接口export interface JWTPayload { sub: number; // 错误的类型 username: string; email: string; role: string; iat?: number; exp?: number; aud?: string; iss?: string;}// 现在的接口export interface JWTPayload { sub: string; // 修正为 string 类型,因为使用 UUID username: string; email: string; role: string; verified: boolean; // 新增字段,用于邮箱验证状态 iat?: number; exp?: number; aud?: string; iss?: string;} 1.6. 布局组件优化 为了更好地适应不同的页面布局需求,我们需要增加主布局组件的灵活性。原有的 MainLayout 组件只支持路由渲染: const MainLayout = () => { return ( <div> <header className="bg-primary shadow"> <nav className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> {/* TODO: 导航内容 */} </nav> </header> <main> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <Outlet /> </div> </main> </div> );}; 我们对其进行了改造,使其同时支持直接的子组件渲染: import React from 'react';import { Outlet } from 'react-router-dom';interface MainLayoutProps { children?: React.ReactNode;}const MainLayout = ({ children }: MainLayoutProps) => { return ( <div> <header className="bg-primary shadow"> <nav className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> {/* TODO: 导航内容 */} </nav> </header> <main> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> { children ? children : <Outlet /> } </div> </main> </div> );};export default MainLayout; 2. 实现登录表单组件 在电商平台中,我们需要一个用户友好的登录界面,让用户能够: 输入用户名和密码进行登录 在输入过程中获得适当的表单验证反馈 看到登录状态的加载提示 收到登陆成功或者失败的明确提示 让我们先在 src/pages 目录下创建 Login.tsx: import React from 'react'; import { useFormik } from 'formik'; import * as Yup from 'yup'; import { useNavigate } from 'react-router-dom'; import { useUserStore } from '../stores'; import AuthLayout from '../layouts/AuthLayout'; import logo from '../assets/ShoppingNest.png'; const Login = () => { const navigate = useNavigate(); const login = useUserStore(state => state.login); const isLoading = useUserStore(state => state.isLoading); const error = useUserStore(state => state.error); return ( // ... )}export default Login; 使用 Formik 管理表单状态和处理提交: const formik = useFormik({ initialValues: { username: '', password: '' }, validationSchema, onSubmit: async (values) => { try { const { username, password } = values; await login({ username, password }); navigate('/', { state: { message: '登陆成功!' } }) } catch (error) { console.error('登录失败:', error); } } }); 使用 Yup 进行表单验证,确保用户名和密码不为空: const validationSchema = Yup.object().shape({ username: Yup.string().required('请输入用户名!'), password: Yup.string().required('请输入密码!')}); 以下是页面的基本结构: return ( <AuthLayout> <img src={logo} className="w-1/4 mx-auto" alt="Shopping Nest的Logo" /> <h2 className="text-2xl font-bold text-center m-6 text-neutral-content"> 登录用户 </h2> {error && ( <div className="mb-4 p-3 text-sm text-error-content bg-error rounded-lg"> {error} </div> )} <form onSubmit={formik.handleSubmit}> <div className="mb-4"> <label className="block text-sm font-semibold text-neutral-content" htmlFor="username"> 用户名 </label> <input type="text" id="username" name="username" className={`w-full mt-2 p-2 border rounded-lg bg-base-300 ${ formik.touched.username && formik.errors.username ? "border-error" : "border-neutral-600" } text-base-content`} value={formik.values.username} onChange={formik.handleChange} onBlur={formik.handleBlur} disabled={isLoading} /> {formik.touched.username && formik.errors.username && ( <p className="text-sm text-error mt-1">{formik.errors.username}</p> )} </div> <div className="mb-4"> <label className="block text-sm font-semibold text-neutral-content" htmlFor="password"> 密码 </label> <input type="password" id="password" name="password" className={`w-full mt-2 p-2 border rounded-lg bg-base-300 ${ formik.touched.password && formik.errors.password ? "border-error" : "border-neutral-600" } text-base-content`} value={formik.values.password} onChange={formik.handleChange} onBlur={formik.handleBlur} disabled={isLoading} /> {formik.touched.password && formik.errors.password && ( <p className="text-sm text-error mt-1">{formik.errors.password}</p> )} </div> <button type="submit" className="w-full bg-primary text-primary-content py-2 rounded-lg mt-4 hover:brightness-90 disabled:opacity-50" disabled={isLoading} > {isLoading ? "登录中…" : "登录"} </button> </form> </AuthLayout> ) 在 src/router.tsx 中添加 /login 路由: import Login from './pages/Login';const router = createBrowserRouter([ { path: '/login', element: <Login /> }]); 3. 配置 JWT 认证 在实现用户登录功能时,我们需要一个安全可靠的身份验证机制,以确保用户身份的真实性并保护系统的安全。 JWT(JSON Web Token)是一个开放标准,它提供了一种紧凑且自包含的方式,在各方之间安全地传输信息。 通过 JWT,我们可以生成安全的访问令牌、验证用户的身份和权限、保护需要认证的 API 端点,并在服务端对令牌的有效性进行校验。 要实现 JWT 认证机制,我们需要从多个方面入手: 配置认证模块、JWT 模块的签名密钥以及相关选项 集成用户模块,以便进行用户身份验证 实现认证服务,该服务需要负责验证用户凭据、生成 JWT 令牌、处理令牌刷新以及检查令牌的有效性,确保认证流程的完整性和安全性 创建一个认证控制器,专门处理用户的登录请求,并返回认证结果和 JWT 令牌,确保前端能够正确接收和使用身份验证信息 实现 JWT 策略和守卫:从请求中提取 JWT 令牌,并验证其有效性,以此来保护系统中需要认证的路由,确保只有经过身份经验的用户才能访问受保护的资源 3.1. 安装依赖 yarn add @nestjs/jwt @nestjs/passport passport passport-jwtyarn add -D @types/passport-jwt 3.2. 认证模块 在 src 目录下创建 auth 目录,并创建 auth.module.ts: import { Module } from '@nestjs/common';import { PassportModule } from '@nestjs/passport';import { JwtModule } from '@nestjs/jwt';import { ConfigService } from '@nestjs/config';import { AuthController } from './auth.controller';import { AuthService } from './auth.service';import { JwtStrategy } from './jwt.strategy';import { UsersModule } from '../users/users.module';@Module({ imports: [ PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.registerAsync({ inject: [ConfigService], useFactory: async (configService: ConfigService) => ({ secret: configService.get<string>('JWT_SECRET'), signOptions: { audience: configService.get<string>('JWT_TOKEN_AUDIENCE'), issuer: configService.get<string>('JWT_TOKEN_ISSUER'), expiresIn: configService.get<number>('JWT_ACCESS_TOKEN_TTL') } }) }), UsersModule ], controllers: [AuthController], providers: [AuthService, JwtStrategy], exports: [AuthService]})export class AuthModule {} 3.3. 用户验证和令牌生成 创建 auth.service.ts: import { Injectable, UnauthorizedException } from '@nestjs/common';import { JwtService } from '@nestjs/jwt';import { ConfigService } from '@nestjs/config';import * as bcrypt from 'bcrypt';import { UsersService } from '../users/users.service';import { JWTPayload } from './interfaces/jwt-payload.interface';@Injectable()export class AuthService { constructor( private jwtService: JwtService, private usersService: UsersService, private configService: ConfigService ) {} // ...} 验证用户: async validateUser(username: string, password: string) { const user = await this.usersService.findUser({ username: username }); if (!user) { throw new UnauthorizedException('用户名或密码错误'); } if (!user.verified) { throw new UnauthorizedException('请先验证邮箱后再登录'); } const isPasswordValid = await bcrypt.compare(password, user.password); if (!isPasswordValid) { throw new UnauthorizedException('用户名或密码错误'); } return user;} 用户登录: async login(user: any) { const payload: JWTPayload = { sub: user.sub, username: user.username, email: user.email, role: user.role }; 首先构建 JWT 载荷。 JWT 基本上由三个部分组成,使用 . 分割: 头.载荷.签名 头包含了令牌类型和使用的签名算法: { "alg": "HS256", // 使用的算法 "typ": "JWT"} 载荷包含了我们存储的实际数据: { "sub": "1234567890", // 用户 ID "username": "john_doe", // 用户名 "email": "john@doe.com", // 邮箱 "role": "user", // 角色 "iat": 1516239022, // 签发时间 "exp": 1516242622 // 过期时间} 签名则使用密钥、对头和载荷进行签名: HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret ) const accessToken = this.jwtService.sign(payload, { // 令牌接收方 audience: this.configService.get<string>('JWT_TOKEN_AUDIENCE'), // 令牌发行方 issuer: this.configService.get<string>('JWT_TOKEN_ISSUER'), // 令牌有效期 expiresIn: this.configService.get<number>('JWT_ACCESS_TOKEN_TTL'),}); 使用 JwtService 生成签名令牌。 return { access_token: accessToken, token_type: 'Bearer', expires_in: this.configService.get<number>('JWT_ACCESS_TOKEN_TTL'), user: { id: user.id, username: user.username, email: user.email, role: user.role, verified: user.verified } };} 这里说一下令牌类型 Bearer,它是 OAuth 2.0 的标准组成部分。 在令牌过期前更新令牌: async refreshToken(user: any) { const payload: JWTPayload = { sub: user.id, username: user.username, email: user.email, role: user.role }; return { access_token: this.jwtService.sign(payload) };} 当现有访问令牌即将过期时,服务器会生成一个新的访问令牌。 使用与原始登录相同的用户信息构建新的载荷,这样用户就不需要重新登陆,可以无缝继续使用系统。 验证令牌: verifyToken(token: string) { try { return this.jwtService.verify(token); } catch (error) { throw new UnauthorizedException('无效的令牌,错误:', error); }} 这个方法负责验证传入的 JWT 令牌的有效性。 JwtService.verify() 方法会检查令牌是否: 签名有效(未被篡改) 未过期 是由我们的系统签发的 在 auth 目录下创建 interfaces/jwt-payload.interface.ts 文件,定义 JWT 载荷的 TypeScript 接口: export interface JWTPayload { sub: number; username: string; email: string; role: string; iat?: number; exp?: number; aud?: string; iss?: string;} 3.4. 认证控制器 我们先来写一下登录所需要的 DTO。 定义登录请求的数据结构: import { ApiProperty } from '@nestjs/swagger';import { IsNotEmpty, IsString, MinLength } from 'class-validator';export class LoginDto { @ApiProperty({ example: 'johndoe', description: '用户名' }) @IsString() @IsNotEmpty({ message: '用户名不能为空' }) username: string; @ApiProperty({ example: 'password123', description: '密码' }) @IsString() @IsNotEmpty({ message: '密码不能为空' }) @MinLength(6, { message: '密码长度不能小于6位' }) password: string;} 登录响应 DTO: import { ApiProperty } from '@nestjs/swagger';export class LoginResponseDto { @ApiProperty({ description: '访问令牌' }) access_token: string; @ApiProperty({ description: '令牌类型', example: 'Bearer' }) token_type: string; @ApiProperty({ description: '过期时间(秒)', example: 3600 }) expires_in: number; @ApiProperty({ description: '用户信息', example: { id: '1', username: 'johndoe', email: 'john@example.com', role: 'user', verified: true } }) user: { id: string; username: string; email: string; role: string; verified: boolean; };} 创建 auth.controller.ts: import { Controller, Post, Body, HttpCode, HttpStatus, UnauthorizedException } from '@nestjs/common';import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';import { AuthService } from './auth.service';import { LoginDto } from './dto/login.dto';import { LoginResponseDto } from './dto/login-response.dto';import winstonLogger from '../loggers/winston.logger';@ApiTags('认证')@Controller('auth')export class AuthController { private readonly logger = winstonLogger; constructor(private readonly authService: AuthService) {} @ApiOperation({ summary: '用户登录' }) @ApiResponse({ status: HttpStatus.OK, description: '登录成功', type: LoginResponseDto }) @ApiResponse({ status: HttpStatus.UNAUTHORIZED, description: '登录失败' }) @HttpCode(HttpStatus.OK) @Post('login') async login(@Body() loginDto: LoginDto): Promise<LoginResponseDto> { try { const user = await this.authService.validateUser(loginDto.username, loginDto.password); this.logger.debug(`用户 ${loginDto.username} 验证通过,正在生成令牌`); const loginResult = await this.authService.login(user); this.logger.info(`用户 ${loginDto.username} 登录成功`); return loginResult; } catch (error) { this.logger.error(`用户 ${loginDto.username} 登录失败: ${error.message}`); throw error; } }} 3.5. 策略实现 AuthService.validateUser() 方法仅是用于登录时的用户验证,以及生成初始的 JWT 令牌。 那后续请求该由谁来验证呢? 可以想象成有这么一个商场。 你在前台登记(登录),工作人员会验证你的身份(AuthService.validateUser()),验证成功后给了你一个特殊的通行证(JWT 令牌)。 你在商场里遛弯,发现有一个 VIP 区域(访问受保护的 API),门口站一保安。你说你想进去,保安说别急先看看你的通行证(JWT 令牌)。 保安需要看到的是: 这个通行证是不是商场发的(JWT 签名验证) 通行证有没有过期(JWT 过期检查) 你的会员资格是否还有效(接下来要写的方法) JWT 策略实现就是这个保安。 创建 jwt.strategy.ts: import { Injectable, UnauthorizedException } from '@nestjs/common';import { PassportStrategy } from '@nestjs/passport';import { ConfigService } from '@nestjs/config';import { Strategy, ExtractJwt } from 'passport-jwt';import { UsersService } from '../users/users.service';@Injectable()export class JwtStrategy extends PassportStrategy(Strategy) { constructor( private configService: ConfigService, private usersService: UsersService ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, secretOrKey: configService.get<string>('JWT_SECRET'), audience: configService.get<string>('JWT_TOKEN_AUDIENCE'), issuer: configService.get<string>('JWT_TOKEN_ISSUER') }); } 检查用户是否还是「有效会员」: async validate(payload: any) { const user = await this.usersService.findUser({ id: payload.sub }); if (!user) { throw new UnauthorizedException('用户不存在'); } if (!user.verified) { throw new UnauthorizedException('用户未验证'); } return { id: user.id, username: user.username, email: user.email, role: user.role }; }} 3.6. JWT 守卫 先前的商场例子里,提到了「受保护的 API」。 那么要如何创建这么个「VIP 区域」呢?我们就需要用到 JWT 守卫。 创建 jwt-auth.guard.ts: @Injectable()export class JwtAuthGuard extends AuthGuard('jwt') { handleRequest(error: any, user: any, info: any) { if (error || !user) { throw new UnauthorizedException('验证失败'); } return user; }} 守卫的工作流程: 收到请求时,检查 Authorization 头部是否包含 JWT 令牌 如果没有令牌或令牌无效,直接拦截请求并返回 401 错误 如果令牌有效,让请求通过并继续处理 使用方式也很简单: @Controller('orders')export class OrdersController { constructor(private ordersService: OrdersService) {} // 无需登录即可访问的公开 API // 例如获取热门商品 @Get('popular') getPopularOrders() { return this.ordersService.getPopularOrders(); } // 需要登录才能访问的 API // 例如用户的订单 @UseGuards(JwtAuthGuard) @Get('my-orders') getMyOrders(@CurrentUser() user: any) { return this.ordersService.getOrdersByUserId(user.id); } // 用户下单 @UseGuards(JwtAuthGuard) @Post('create') createOrder(@CurrentUser() user: any, @Body() orderData: any) { return this.ordersService.createOrder(user.id, orderData); }} 也可以直接保护控制器的所有路由: @UseGuards(JwtAuthGuard) // 保护个人信息路由@Controller('profile')export class ProfileController { @Get() getProfile(@CurrentUser() user: any) { return user; } @Put() updateProfile(@CurrentUser() user: any, @Body() updateData: any) { return this.profileService.update(user.id, updateData); }} 3.7. 用户装饰器 假设我们有一个购物车的 API,需要获取当前用户的购物车信息: @Controller('cart')export class CartController { constructor(private cartService: CartService) {} @UseGuards(JwtAuthGuard) @Get('my-cart') async getMyCart(@CurrentUser() user: any) { // user 对象包含了 JWT 令牌中的用户信息 return this.cartService.getCartByUserId(user.id); }} 如果没有 CurrentUser 装饰器,我们需要: @Get('my-cart')async getMyCart(@Request() req: any) { const user = req.user; // 从 request 对象中手动获取用户信息 return this.cartService.getCartByUserId(user.id);} CurrentUser 装饰器的实现: import { createParamDecorator, ExecutionContext } from '@nestjs/common';export const CurrentUser = createParamDecorator((data: unknown, ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest(); return request.user;}); 它的好处在于: 简化代码:不需要手动从 request 中提取用户信息 类型安全:可以明确知道返回的是用户对象 可重用性:在任何需要当前用户信息的地方都可以使用 关注点分离:控制器方法只需要关心用户信息,不需要关心它是如何获取的 像是还可以有这样的使用场景: @Controller('users')export class UsersController { @UseGuards(JwtAuthGuard) @Get('profile') getProfile(@CurrentUser() user: any) { return user; // 直接返回用户信息 }} 4. 添加记住我功能 在现代电商平台中,用户体验是至关重要的。用户希望能够快速、便捷地访问他们的账户,而不需要每次都重新输入用户名和密码。这就是「记住我」功能的用武之地。 通过「记住我」功能,用户可以选择在登录时保持一段时间的登录状态,即使关闭浏览器或重新打开应用,用户仍然可以自动登录,无需再次输入凭证。 4.1. 技术方案设计 我们采用 RefreshToken + AccessToken 的双令牌认证方案: AccessToken:短期令牌,用于访问 API RefreshToken:长期令牌,用于刷新 AccessToken 这种方案的优势在于: 安全性高:AccessToken 短期有效,即使泄露风险也较小 用户体验好:RefreshToken 可以静默刷新 AccessToken,用户无感知 可控性强:可以随时注销 RefreshToken,确保账户安全 #mermaid-1760500296119{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-1760500296119 .error-icon{fill:#552222;}#mermaid-1760500296119 .error-text{fill:#552222;stroke:#552222;}#mermaid-1760500296119 .edge-thickness-normal{stroke-width:2px;}#mermaid-1760500296119 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-1760500296119 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-1760500296119 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-1760500296119 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-1760500296119 .marker{fill:#333333;stroke:#333333;}#mermaid-1760500296119 .marker.cross{stroke:#333333;}#mermaid-1760500296119 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-1760500296119 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-1760500296119 .cluster-label text{fill:#333;}#mermaid-1760500296119 .cluster-label span,#mermaid-1760500296119 p{color:#333;}#mermaid-1760500296119 .label text,#mermaid-1760500296119 span,#mermaid-1760500296119 p{fill:#333;color:#333;}#mermaid-1760500296119 .node rect,#mermaid-1760500296119 .node circle,#mermaid-1760500296119 .node ellipse,#mermaid-1760500296119 .node polygon,#mermaid-1760500296119 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-1760500296119 .flowchart-label text{text-anchor:middle;}#mermaid-1760500296119 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-1760500296119 .node .label{text-align:center;}#mermaid-1760500296119 .node.clickable{cursor:pointer;}#mermaid-1760500296119 .arrowheadPath{fill:#333333;}#mermaid-1760500296119 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-1760500296119 .flowchart-link{stroke:#333333;fill:none;}#mermaid-1760500296119 .edgeLabel{background-color:#e8e8e8;text-align:center;}#mermaid-1760500296119 .edgeLabel rect{opacity:0.5;background-color:#e8e8e8;fill:#e8e8e8;}#mermaid-1760500296119 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-1760500296119 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-1760500296119 .cluster text{fill:#333;}#mermaid-1760500296119 .cluster span,#mermaid-1760500296119 p{color:#333;}#mermaid-1760500296119 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-1760500296119 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-1760500296119 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是 否 有效 无效 用户登录 记住我? 生成 7 天 RefreshToken 生成会话级 RefreshToken 返回 AccessToken + RefreshToken 访问 API 携带 AccessToken Token 有效? 正常访问 使用 RefreshToken 获取新 AccessToken 重新尝试 API 请求 4.2. 环境配置 4.2.1. 修改环境变量 首先需要在 .env 文件中添加新的配置项: # JWT JWT_ACCESS_SECRET=access_secret JWT_REFRESH_SECRET=refresh_secret JWT_TOKEN_AUDIENCE=localhost:4000 JWT_TOKEN_ISSUER=localhost:4000 JWT_ACCESS_TOKEN_TTL=3600 JWT_REFRESH_TOKEN_TTL=604800 配置说明: JWT_ACCESS_SECRET:访问令牌密钥 JWT_REFRESH_SECRET:刷新令牌密钥 JWT_ACCESS_TOKEN_TTL:访问令牌有效期(1 小时) JWT_REFRESH_TOKEN_TTL:刷新令牌有效期(7 天) 4.2.2. 更新配置验证 在 AppModule 中更新 ConfigModule 的配置验证: @Module({ imports: [ ConfigModule.forRoot({ validationSchema: Joi.object({ // ... 其他配置项 ... // JWT 配置 JWT_ACCESS_SECRET: Joi.string().required(), JWT_REFRESH_SECRET: Joi.string().required(), JWT_TOKEN_AUDIENCE: Joi.string().required(), JWT_TOKEN_ISSUER: Joi.string().required(), JWT_ACCESS_TOKEN_TTL: Joi.number().default(3600), JWT_REFRESH_TOKEN_TTL: Joi.number().default(604800) }) }) ]}) 4.2.3. 更新认证模块 在 AuthModule 中更新 JwtModule 的配置: @Module({ imports: [ JwtModule.registerAsync({ inject: [ConfigService], useFactory: async (configService: ConfigService) => ({ secret: configService.get<string>('JWT_ACCESS_SECRET'), signOptions: { audience: configService.get<string>('JWT_TOKEN_AUDIENCE'), issuer: configService.get<string>('JWT_TOKEN_ISSUER'), expiresIn: configService.get<number>('JWT_ACCESS_TOKEN_TTL') } }) }) ]}) 4.2.4. 更新 JWT 策略 修改 JwtStrategy 的配置: @Injectable()export class JwtStrategy extends PassportStrategy(Strategy) { constructor(private configService: ConfigService) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, secretOrKey: configService.get<string>('JWT_ACCESS_SECRET'), audience: configService.get<string>('JWT_TOKEN_AUDIENCE'), issuer: configService.get<string>('JWT_TOKEN_ISSUER') }); }} 4.3. 令牌生成与签名实现 4.3.1. JWT 配置抽离 为了更好地管理 JWT 相关的配置,我们首先创建了一个专门的配置文件 jwt.config.ts: import { ConfigService } from '@nestjs/config';export const getJWTVerifyOptions = (configService: ConfigService) => ({ accessToken: { secret: configService.get('JWT_ACCESS_SECRET'), audience: configService.get('JWT_TOKEN_AUDIENCE'), issuer: configService.get('JWT_TOKEN_ISSUER'), expiresIn: configService.get('JWT_ACCESS_TOKEN_TTL') }, refreshToken: { secret: configService.get('JWT_REFRESH_SECRET'), audience: configService.get('JWT_TOKEN_AUDIENCE'), issuer: configService.get('JWT_TOKEN_ISSUER'), expiresIn: configService.get('JWT_REFRESH_TOKEN_TTL') }}); 这样做的好处是: 集中管理 JWT 配置 方便在不同地方复用配置 确保配置的一致性 4.3.2. 数据结构调整 在 Users 实体中添加 refreshToken 字段,用于存储刷新令牌: @Column({ nullable: true })refreshToken?: string; 在 LoginDto 中添加「记住我」选项: @ApiProperty({ example: false, required: false, description: '是否记住我' })@IsBoolean()@IsOptional()rememberMe?: boolean; 在 LoginResponseDto 中新增刷新令牌字段: @ApiProperty({ description: '刷新令牌' })refresh_token: string; 4.3.3. 认证服务实现 在 AuthService 中,我们需要进行以下主要改动: 为了支持「记住我」功能,需要在用户登录时生成两种令牌:访问令牌(accessToken)和刷新令牌(refreshToken)。 将原有的令牌生成逻辑修改为: // 之前的代码const accessToken = this.jwtService.sign(payload, { audience: this.configService.get<string>('JWT_TOKEN_AUDIENCE'), issuer: this.configService.get<string>('JWT_TOKEN_ISSUER'), expiresIn: this.configService.get<number>('JWT_ACCESS_TOKEN_TTL')});// 修改后的代码const accessToken = this.jwtService.sign(payload);const refreshToken = this.jwtService.sign( payload, getJWTVerifyOptions(this.configService).refreshToken); 在登录响应中添加刷新令牌: // 之前的返回结果return { access_token: accessToken, token_type: 'Bearer', expires_in: this.configService.get<number>('JWT_ACCESS_TOKEN_TTL'), user: { ... }};// 修改后的返回结果return { access_token: accessToken, refresh_token: refreshToken, // 新增 token_type: 'Bearer', expires_in: this.configService.get<number>('JWT_ACCESS_TOKEN_TTL'), user: { ... }}; 新增令牌刷新功能,用于在访问令牌过期后获取新的访问令牌: async refresh(refreshToken: string) { this.logger.debug(`刷新访问令牌`); // 验证刷新令牌 const refreshPayload = this.jwtService.verify( refreshToken, getJWTVerifyOptions(this.configService).refreshToken ); // 查找用户 const user = await this.usersService.findUser({ id: refreshPayload.sub }); if (!user) { this.logger.warn(`刷新失败,未找到用户:${user.username}`); throw new UnauthorizedException('用户不存在'); } // 生成新的访问令牌 const newAccessToken = this.jwtService.sign( { sub: user.id, username: user.username, email: user.email, role: user.role }, getJWTVerifyOptions(this.configService).accessToken ); this.logger.info(`已为用户刷新访问令牌:${user.username}`); return { access_token: newAccessToken, token_type: 'Bearer', expires_in: this.configService.get<number>('JWT_REFRESH_TOKEN_TTL') };} 添加登出功能,用于清除用户的刷新令牌: async logout(userId: string): Promise<void> { await this.usersService.update(userId, { refreshToken: null });} 4.3.4. 控制器层改造 在 AuthController 中,我们需要添加新的接口来支持「记住我」功能: @ApiOperation({ summary: '刷新访问令牌' })@UseGuards(JwtRefreshGuard)@HttpCode(HttpStatus.OK)@Post('refresh')async refresh(@Body() body: { refreshToken: string }) { try { return await this.authService.refresh(body.refreshToken); } catch (error) { this.logger.error(`刷新 Token 失败:${error.message}`); throw new UnauthorizedException('刷新 Token 失败'); }} 同时添加获取当前用户信息和登出的接口: @ApiOperation({ summary: '获取当前用户信息' })@HttpCode(HttpStatus.OK)@Get('me')async getCurrentUser(@CurrentUser() user: JWTPayload) { return this.authService.getCurrentUser(user.sub);}@ApiOperation({ summary: '登出用户' })@HttpCode(HttpStatus.OK)@UseGuards(JwtAuthGuard)@Post('logout')async logout(@Req() req: Request) { const user = req.user as JWTPayload; await this.authService.logout(user.sub); return { message: '已登出' };} 4.3.5. 守卫机制完善 访问令牌守卫 原来的 JwtAuthGuard 实现存在几个问题: 没有直接验证 token 的有效性 没有正确处理 audience 和 issuer 的验证 看一眼原本的代码: @Injectable()export class JwtAuthGuard extends AuthGuard('jwt') { handleRequest(error: any, user: any, info: any) { if (error || !user) { throw new UnauthorizedException('验证失败'); } return user; }} 现在,我们重新实现了这个守卫,提供了更严格的验证和更好的错误处理: import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';import { JwtService } from '@nestjs/jwt';import { ConfigService } from '@nestjs/config';import { TokenUtils } from '../utils/token.utils';import winstonLogger from '../../loggers/winston.logger';@Injectable()export class JwtAuthGuard implements CanActivate { private logger = winstonLogger; constructor( private jwtService: JwtService, private configService: ConfigService ) {} async canActivate(context: ExecutionContext): Promise<boolean> { const token = TokenUtils.extractTokenFromContext(context); if (!token) { this.logger.warn(`未提供访问令牌:${context}`); throw new UnauthorizedException('未提供访问令牌'); } try { const request = context.switchToHttp().getRequest(); request.user = await this.jwtService.verifyAsync(token, { audience: this.configService.get<string>('JWT_TOKEN_AUDIENCE'), issuer: this.configService.get<string>('JWT_TOKEN_ISSUER') }); return true; } catch (error) { this.logger.error(`验证访问令牌失败:${error.message}`, error.stack); throw new UnauthorizedException('无效的访问令牌'); } }} 刷新令牌守卫 新增 JwtRefreshGuard 专门用于处理刷新令牌的验证: import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';import { ConfigService } from '@nestjs/config';import { JwtService } from '@nestjs/jwt';import { TokenUtils } from '../utils/token.utils';import { getJWTVerifyOptions } from '../../config/jwt.config';import winstonLogger from '../../loggers/winston.logger';@Injectable()export class JwtRefreshGuard { private logger = winstonLogger; constructor( private jwtService: JwtService, private configService: ConfigService ) {} async canActivate(context: ExecutionContext) { const token = TokenUtils.extractTokenFromContext(context); const options = getJWTVerifyOptions(this.configService).refreshToken; try { this.jwtService.verify(token, options); return true; } catch (error) { this.logger.error(`验证刷新令牌失败:${error.message}`, error.stack); throw new UnauthorizedException('无效的刷新令牌'); } }} 4.3.6. 工具类支持 在之前的实现中,令牌提取的逻辑分散在各处,容易导致处理不一致。为了统一处理令牌的提取逻辑,新增 TokenUtils 工具类: import { ExecutionContext } from '@nestjs/common';import { Request } from 'express';export class TokenUtils { static extractToken(request: Request): string | undefined { const [type, token] = request.headers.authorization?.split(' ') ?? []; return type === 'Bearer' ? token : undefined; } static extractTokenFromContext(context: ExecutionContext): string | undefined { const request = context.switchToHttp().getRequest<Request>(); return this.extractToken(request); }} 4.4. 客户端功能实现 4.4.1. API 请求封装 在用户登录后,我们需要确保所有 API 请求都能正确携带身份验证信息。然而,accessToken 可能会过期,如果没有自动刷新机制,用户将频繁被登出,影响体验。我们希望实现一个自动处理令牌的方案,使得: 每个请求都自动携带 accessToken 进行身份验证。 当 accessToken 过期时,自动使用 refreshToken 获取新的 accessToken,并重试原请求。 如果 refreshToken 也失效,则清除令牌并让用户重新登录。 我们使用 Axios 拦截器 来实现这一机制,主要分为两部分: 请求拦截器: 在每个请求发送前,自动读取 accessToken,并将其添加到请求头。 如果 accessToken 不存在,则直接发送请求。 响应拦截器: 监听 API 响应,如果返回 401 Unauthorized,说明 accessToken 可能已过期。 如果 refreshToken 仍然有效,则使用它请求新的 accessToken,然后重试原始请求。 如果 refreshToken 失效,则清除所有令牌,并要求用户重新登录。 这是客户端请求与响应拦截逻辑的流程: #mermaid-1760500299402{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-1760500299402 .error-icon{fill:#552222;}#mermaid-1760500299402 .error-text{fill:#552222;stroke:#552222;}#mermaid-1760500299402 .edge-thickness-normal{stroke-width:2px;}#mermaid-1760500299402 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-1760500299402 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-1760500299402 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-1760500299402 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-1760500299402 .marker{fill:#333333;stroke:#333333;}#mermaid-1760500299402 .marker.cross{stroke:#333333;}#mermaid-1760500299402 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-1760500299402 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-1760500299402 .cluster-label text{fill:#333;}#mermaid-1760500299402 .cluster-label span,#mermaid-1760500299402 p{color:#333;}#mermaid-1760500299402 .label text,#mermaid-1760500299402 span,#mermaid-1760500299402 p{fill:#333;color:#333;}#mermaid-1760500299402 .node rect,#mermaid-1760500299402 .node circle,#mermaid-1760500299402 .node ellipse,#mermaid-1760500299402 .node polygon,#mermaid-1760500299402 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-1760500299402 .flowchart-label text{text-anchor:middle;}#mermaid-1760500299402 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-1760500299402 .node .label{text-align:center;}#mermaid-1760500299402 .node.clickable{cursor:pointer;}#mermaid-1760500299402 .arrowheadPath{fill:#333333;}#mermaid-1760500299402 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-1760500299402 .flowchart-link{stroke:#333333;fill:none;}#mermaid-1760500299402 .edgeLabel{background-color:#e8e8e8;text-align:center;}#mermaid-1760500299402 .edgeLabel rect{opacity:0.5;background-color:#e8e8e8;fill:#e8e8e8;}#mermaid-1760500299402 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-1760500299402 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-1760500299402 .cluster text{fill:#333;}#mermaid-1760500299402 .cluster span,#mermaid-1760500299402 p{color:#333;}#mermaid-1760500299402 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-1760500299402 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-1760500299402 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 请求拦截 是 否 存在 accessToken? 发起请求 添加 Authorization 头 直接发送 #mermaid-1760500301385{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-1760500301385 .error-icon{fill:#552222;}#mermaid-1760500301385 .error-text{fill:#552222;stroke:#552222;}#mermaid-1760500301385 .edge-thickness-normal{stroke-width:2px;}#mermaid-1760500301385 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-1760500301385 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-1760500301385 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-1760500301385 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-1760500301385 .marker{fill:#333333;stroke:#333333;}#mermaid-1760500301385 .marker.cross{stroke:#333333;}#mermaid-1760500301385 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-1760500301385 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-1760500301385 .cluster-label text{fill:#333;}#mermaid-1760500301385 .cluster-label span,#mermaid-1760500301385 p{color:#333;}#mermaid-1760500301385 .label text,#mermaid-1760500301385 span,#mermaid-1760500301385 p{fill:#333;color:#333;}#mermaid-1760500301385 .node rect,#mermaid-1760500301385 .node circle,#mermaid-1760500301385 .node ellipse,#mermaid-1760500301385 .node polygon,#mermaid-1760500301385 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-1760500301385 .flowchart-label text{text-anchor:middle;}#mermaid-1760500301385 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-1760500301385 .node .label{text-align:center;}#mermaid-1760500301385 .node.clickable{cursor:pointer;}#mermaid-1760500301385 .arrowheadPath{fill:#333333;}#mermaid-1760500301385 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-1760500301385 .flowchart-link{stroke:#333333;fill:none;}#mermaid-1760500301385 .edgeLabel{background-color:#e8e8e8;text-align:center;}#mermaid-1760500301385 .edgeLabel rect{opacity:0.5;background-color:#e8e8e8;fill:#e8e8e8;}#mermaid-1760500301385 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-1760500301385 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-1760500301385 .cluster text{fill:#333;}#mermaid-1760500301385 .cluster span,#mermaid-1760500301385 p{color:#333;}#mermaid-1760500301385 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-1760500301385 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-1760500301385 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 响应拦截 是 是 是 否 否 否 状态码 = 401? 接收响应 有 refreshToken? 发起刷新请求 刷新成功? 更新 accessToken 并重试 跳转登录页 正常处理 const api = axios.create({ baseURL: process.env.REACT_APP_API_URL, withCredentials: true, timeout: 10000});// 请求拦截器:添加令牌api.interceptors.request.use( (config) => { const token = localStorage.getItem('accessToken'); if (token) { config.headers = AxiosHeaders.from(config.headers); config.headers.set('Authorization', `Bearer ${token}`); } return config; }, (error) => Promise.reject(error));// 响应拦截器:处理令牌刷新api.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config; // 处理 401 错误和令牌刷新 if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true; const refreshToken = localStorage.getItem('refreshToken'); if (refreshToken) { try { const { data } = await axios.post( `${process.env.REACT_APP_API_URL}/auth/refresh`, { refreshToken } ); const newAccessToken = data.access_token; localStorage.setItem('accessToken', newAccessToken); originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; return api(originalRequest); } catch (error) { // 刷新失败时清除所有令牌 localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); return Promise.reject(error); } } } return Promise.reject(error); }); 这个改造实现了对身份验证流程的优化和自动化: 能够自动为请求添加访问令牌,确保每次请求都具备正确的身份验证信息。 能够检测令牌是否过期,并在必要时进行处理,防止因过期导致请求失败。 支持自动刷新令牌,并在刷新成功后重新尝试原始请求,从而提高用户体验,减少因身份验证失效带来的干扰。 4.4.2. 状态管理优化 首先先介绍一下身份验证流程中整个认证状态的流转: #mermaid-1760500303249{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-1760500303249 .error-icon{fill:#552222;}#mermaid-1760500303249 .error-text{fill:#552222;stroke:#552222;}#mermaid-1760500303249 .edge-thickness-normal{stroke-width:2px;}#mermaid-1760500303249 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-1760500303249 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-1760500303249 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-1760500303249 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-1760500303249 .marker{fill:#333333;stroke:#333333;}#mermaid-1760500303249 .marker.cross{stroke:#333333;}#mermaid-1760500303249 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-1760500303249 defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-1760500303249 g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-1760500303249 g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-1760500303249 g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-1760500303249 g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-1760500303249 g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-1760500303249 .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-1760500303249 .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-1760500303249 .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-1760500303249 .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-1760500303249 .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-1760500303249 .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-1760500303249 .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-1760500303249 .edgeLabel .label text{fill:#333;}#mermaid-1760500303249 .label div .edgeLabel{color:#333;}#mermaid-1760500303249 .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-1760500303249 .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-1760500303249 .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-1760500303249 .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-1760500303249 .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-1760500303249 .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-1760500303249 .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-1760500303249 #statediagram-barbEnd{fill:#333333;}#mermaid-1760500303249 .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-1760500303249 .cluster-label,#mermaid-1760500303249 .nodeLabel{color:#131300;}#mermaid-1760500303249 .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-1760500303249 .statediagram-state .divider{stroke:#9370DB;}#mermaid-1760500303249 .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-1760500303249 .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-1760500303249 .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-1760500303249 .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-1760500303249 .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-1760500303249 .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-1760500303249 .note-edge{stroke-dasharray:5;}#mermaid-1760500303249 .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-1760500303249 .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-1760500303249 .statediagram-note text{fill:black;}#mermaid-1760500303249 .statediagram-note .nodeLabel{color:black;}#mermaid-1760500303249 .statediagram .edgeLabel{color:red;}#mermaid-1760500303249 #dependencyStart,#mermaid-1760500303249 #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-1760500303249 .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-1760500303249 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 自动登录检查 自动登录成功 自动登录失败 用户主动登出 AccessToken 过期 刷新成功 刷新失败 清除本地存储 未认证 认证中 已认证 已注销 令牌刷新中 用户在首次访问时可能处于 未认证 状态,系统会自动检查用户的登录状态。 认证过程中可能会发生令牌过期的情况,需要进行 令牌刷新。 而在用户注销时,系统会清除本地存储数据。 为了有效管理用户的认证状态和流程,我们引入了一个状态机,覆盖以下几种主要状态和转变: 未认证 初始状态,表示用户尚未通过认证。系统会自动检查是否有有效的认证信息(如 accessToken 或 refreshToken)。如果没有,用户将无法访问受保护的资源。 认证中 系统正在验证用户的认证信息,可能是通过自动登录检查(例如检查本地存储的 accessToken 或 refreshToken)来恢复用户的会话。若检查成功,用户进入 已认证 状态;若失败,进入 未认证 状态。 已认证 认证成功后,用户的身份验证信息有效,允许访问受保护的资源。如果在该状态下,用户的 accessToken 过期,系统会自动尝试通过刷新令牌(refreshToken)来更新 accessToken,进入 令牌刷新中 状态。 令牌刷新中 当 accessToken 过期且用户依然处于已认证状态时,系统会通过 refreshToken 尝试获取新的 accessToken。如果刷新成功,系统返回 已认证 状态;如果刷新失败,用户会被迫重新认证(进入 未认证 状态)。 已注销 用户主动退出时,系统会清除本地存储的认证信息,返回到 未认证 状态,确保用户的会话被完全销毁。 状态转换 通过自动登录检查、令牌刷新等机制,我们确保用户认证状态的持续性和稳定性。系统根据认证状态的变化自动调整访问权限和用户会话管理。 在用户状态管理中,我们先添加了自动登录相关的功能: const createUserSlice: StateCreator<UserState> = (set, get) => ({ // ... 其他状态 isAutoLoading: false, // 新增自动登录状态 // 清除用户信息 clearUser: () => { localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); set({ user: null, isAutoLoading: false }); }, // 自动登录功能 autoLogin: async () => { const state = get(); if (state.isAutoLoading || state.user) return; const accessToken = localStorage.getItem('accessToken'); const refreshToken = localStorage.getItem('refreshToken'); if (!accessToken && !refreshToken) return; set({ isAutoLoading: true }); try { let token = accessToken; // 如果没有访问令牌但有刷新令牌,尝试刷新 if (!token && refreshToken) { const response = await api.post('/auth/refresh', { refreshToken }); token = response.data.access_token; if (token) { localStorage.setItem('accessToken', token); } } // 获取用户信息 const response = await api.get('/auth/me', { headers: { Authorization: `Bearer ${token}` } }); set({ user: response.data, isAutoLoading: false }); } catch (error) { localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); set({ user: null, isAutoLoading: false }); throw error; } }, // 登出功能增强 logout: async () => { try { await api.post('/auth/logout'); } finally { localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); set({ user: null }); } }}); 4.4.3. 状态管理范式转变 在登录功能中,我们需要处理用户的身份验证,同时提供「记住我」功能。然而,使用传统的状态管理(也就是我们的 Zustand)来存储 error 和 isLoading 状态可能会带来一些问题: 全局状态污染:error 和 isLoading 这些状态只是登录相关,不需要在整个应用范围内存储。 手动管理状态:需要在 try/catch 中手动更新 isLoading,并在发生错误时手动存储 error,代码较为冗长。 状态同步问题:如果多个组件依赖相同的登录逻辑,手动管理状态可能会导致数据不同步。 为了解决这些问题,我们决定使用 React Query 提供的 useMutation,将登录请求的状态管理完全交由 React Query 处理。 安装 React Query: yarn add @tanstack/react-query 让我们以注册组件为例,来看看这次改造。 在之前的实现中,我们使用全局状态来管理加载状态和错误信息: const Register = () => { const register = useUserStore(state => state.register); const isLoading = useUserStore(state => state.isLoading); const error = useUserStore(state => state.error); const formik = useFormik({ // ... onSubmit: async (values) => { try { const { username, email, password } = values; await register({ username, email, password }); navigate('/login', { state: { message: '注册成功!请登录您的账号。', email: values.email } }); } catch (error) { console.error('注册失败:', error); } } });}; 这种方式存在以下问题: 状态管理过于集中,不同组件的加载状态和错误状态混杂在一起 需要手动管理状态的清理和重置 错误处理不够优雅 缺乏对请求生命周期的完整控制 在开始实现具体功能之前,我们需要先配置 React Query。首先修改应用的入口文件 index.tsx: import { QueryClient, QueryClientProvider } from '@tanstack/react-query';const queryClient = new QueryClient();const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);root.render( <React.StrictMode> <QueryClientProvider client={queryClient}> <App /> </QueryClientProvider> </React.StrictMode>); 使用 React Query 的 useMutation 后,代码变得更加清晰和强大: const Register = () => { const setUser = useUserStore(state => state.setUser); const { mutate: register, isPending, error } = useMutation({ mutationFn: async (values: { username: string; email: string; password: string; }) => { const response = await api.post('/users/create', values); return response.data; }, onSuccess: (data) => { setUser(data.user); navigate('/login', { state: { message: '注册成功!请检查邮箱完成验证', email: data.user.email }, replace: true }); } }); const formik = useFormik({ // ... onSubmit: async (values) => { register({ username: values.username, email: values.email, password: values.password }); } });}; 这种改进带来了多个显著的好处,使得代码更加清晰、组件更加独立,且状态管理更加高效: 实现了状态隔离。每个组件都有自己独立的加载和错误状态,不同的请求不会相互影响,从而避免了状态混乱的问题。同时,当组件卸载时,状态会自动清理,确保资源的合理释放。 得益于生命周期钩子的引入,异步操作的管理更加灵活。mutationFn 负责定义实际的异步操作,onSuccess 处理成功场景,而 onError 则用于错误处理。这些钩子让我们能够更精准地控制请求的执行过程。 在错误处理方面,这种改进也带来了增强。错误信息会被自动捕获并保存,同时错误状态与组件绑定,使得错误管理更加直观。此外,还能够提供更详细的错误类型信息,方便开发者调试和优化。 大幅简化了使用方式。开发者不需要手动处理 try/catch 逻辑,也不需要手动管理 loading 状态,只需直接调用 mutate 函数,即可完成异步操作,提高了开发效率和代码可读性。 使用 useMutation 的基本步骤: 定义 mutation 函数: mutationFn: async (values) => { const response = await api.post('/users/create', values); return response.data;} 配置生命周期钩子: onSuccess: (data) => { // 处理成功场景},onError: (error) => { // 处理错误场景} 在组件中使用: const { mutate, isPending, error } = useMutation({ ... });// 触发 mutationmutate(values);// 使用状态{isPending && <LoadingSpinner />}{error && <ErrorMessage error={error} />} 同样的,修改 VerifyEmail 组件: import React, {useEffect} from 'react'; import {useLocation, useNavigate} from 'react-router-dom'; import {useMutation} from '@tanstack/react-query'; import {useUserStore} from '../stores'; import api from '../stores/common/api'; const VerifyEmail = () => { const location = useLocation(); const navigate = useNavigate(); const token = new URLSearchParams(location.search).get('token'); const { emailVerified, verificationError, verificationToken, verificationUserId, clearVerificationState } = useUserStore(state => ({ emailVerified: state.emailVerified, verificationError: state.verificationError, verificationToken: state.verificationToken, verificationUserId: state.verificationUserId, clearVerificationState: state.clearVerificationState })); const { mutate: verifyEmail, isPending } = useMutation({ mutationFn: async (token: string) => { const response = await api.get(`/users/verify-email?token=${token}`); return response.data; }, onSuccess: (data) => { if (data.success) { useUserStore.setState({ emailVerified: true, verificationUserId: data.userId }); navigate('/login', { replace: true }) } else { useUserStore.setState({ verificationError: data.error, verificationUserId: data.userId }); } }, onError: (error) => { useUserStore.setState({ verificationError: error.message || '验证过程中发生未知错误' }); } }); useEffect(() => { if (token) verifyEmail(token); }, [token]); useEffect(() => { return () => { clearVerificationState(); window.history.replaceState({}, '', window.location.pathname); } }, []); const handleResend = async () => { if (verificationUserId) { try { await api.post(`/users/resend-verification/${verificationUserId}`); navigate('/login', { state: { message: '新的验证邮件已发送,请查收邮箱' } }); } catch (error) { useUserStore.setState({ verificationError: '重新发送验证邮件失败' }); throw error instanceof Error ? error.message : '重新发送验证邮件失败'; } } } return ( <div className="min-h-screen flex items-center justify-center bg-base-200"> <div className="card w-96 bg-base-100 shadow-xl"> <div className="card-body items-center text-center"> <h2 className="card-title mb-4"> 验证电子邮件 </h2> {isPending && ( <div className="flex flex-col items-center gap-4"> <span className="loading loading-spinner loading-lg" /> <p>正在验证您的邮箱...</p> </div> )} {emailVerified && ( <div className="alert alert-success"> <span>邮箱验证成功!正在跳转至登录页面……</span> </div> )} {verificationError && ( <div className="alert alert-error flex flex-col gap-3"> <span>{verificationError}</span> {verificationUserId && ( <button className="btn btn-sm btn-outline" onClick={handleResend} > 重新发送验证邮件 </button> )} </div> )} {!isPending && !emailVerified && !verificationError && verificationToken && ( <div className="flex flex-col gap-3"> <p>验证链接已失效</p> <button className="btn btn-outline" onClick={() => verifyEmail(verificationToken)} > 重新尝试验证 </button> </div> )} </div> </div> </div> ); }; export default VerifyEmail; 4.4.4. 登录组件改造 我们需要对登录组件(Login.tsx)进行改造,以支持「记住我」功能。 在表单验证模式中增加「记住我」选项: const validationSchema = Yup.object().shape({ username: Yup.string().required('请输入用户名!'), password: Yup.string().required('请输入密码!'), rememberMe: Yup.boolean() // 新增}); const formik = useFormik({ initialValues: { username: '', password: '', rememberMe: false // 新增 }, validationSchema, onSubmit: async (values) => { login(values); }}); 使用 useMutation 重构登录逻辑: const { mutate: login, isPending, error } = useMutation({ mutationFn: async (credentials: { username: string, password: string, rememberMe: boolean, }) => { const response = await api.post('/auth/login', credentials); return response.data; }, onSuccess: async (data) => { localStorage.setItem('accessToken', data.access_token); // 只有在用户选择“记住我”时才保存刷新令牌 if (formik.values.rememberMe) { localStorage.setItem('refreshToken', data.refresh_token); } setUser(data.user); navigate('/', { state: { message: '登陆成功!' }, replace: true }) }, onError: (error) => { // 登录失败时清除所有令牌 localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); clearUser(); console.log('登录失败:', error); } }); 添加自动登录检查逻辑: useEffect(() => { const checkAutoLogin = async () => { const accessToken = localStorage.getItem('accessToken'); const refreshToken = localStorage.getItem('refreshToken'); if (accessToken || refreshToken) { try { await autoLogin(); } catch (error) { clearUser(); localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); console.log('检查自动登录失败:', error); } } } checkAutoLogin().then(r => console.log('检查自动登录成功:', r)); }, []); 登录后自动跳转: useEffect(() => { if (user && !isAutoLoading) { navigate('/', { replace: true }); } }, [user, isAutoLoading, navigate]); 添加「记住我」选项的界面元素: <div className="mb-4 flex items-center"> <input type="checkbox" id="rememberMe" name="rememberMe" className="w-4 h-4 text-primary bg-base-300 border-neutral-600 rounded" checked={formik.values.rememberMe} onChange={formik.handleChange} disabled={isPending} /> <label htmlFor="rememberMe" className="ml-2 text-sm text-neutral-content cursor-pointer" > 记住我 </label> </div> 4.5. 邮箱验证提示 为了提升用户体验,我们需要在用户未验证邮箱时给出明显的提示。这个功能包含两个部分:提示组件和布局改造。 创建 UnverifiedBanner 组件来显示验证提示: import React from 'react';import { useUserStore } from '../stores';const UnverifiedBanner = () => { const user = useUserStore(state => state.user); if (user && !user.verified) { return ( <div className="p-4 mb-4 text-sm text-warning-content bg-warning rounded"> 您的邮箱尚未验证,请尽快验证。您可以修改邮箱地址或<a href="/resend-verification" className="underline ml-1">重新发送验证邮件</a>。 </div> ); } return null;};export default UnverifiedBanner; 这个组件具有几个重要特点。它通过全局状态获取用户信息,确保始终使用最新的用户数据。同时,它只会在用户尚未完成验证时显示,避免对已验证用户造成干扰。此外,组件还提供了快捷的邮箱验证操作,使用户能够方便地完成身份确认,提高使用体验。 将验证提示集成到 AuthLayout 中: import React from 'react'; import UnverifiedBanner from '../components/UnverifiedBanner'; type AuthLayoutProps = { children: React.ReactNode; }; const AuthLayout = ({ children }: AuthLayoutProps) => { return ( <div className="flex flex-col min-h-screen items-center justify-center bg-base-200"> <UnverifiedBanner /> <div className="w-full max-w-md bg-base-100 p-8 rounded-lg shadow-xl"> {children} </div> </div> ); }; export default AuthLayout; 这种改进带来了多方面的好处。用户可以及时了解自己的邮箱验证状态,避免因未验证而影响正常使用。同时,组件提供了直接的验证操作入口,使用户能够快速完成身份确认。此外,它保持了统一的视觉风格,与整体界面设计相协调,并且不会影响原有的布局结构,确保页面的整洁与一致性。 为了解决用户可能未收到验证邮件或验证邮件过期的问题,我们需要实现验证邮件重发功能: import React, {useCallback, useEffect} from 'react';import { useNavigate } from 'react-router-dom';import {useMutation} from '@tanstack/react-query';import { useUserStore } from '../stores';import api from '../stores/common/api';const ResendVerification = () => { const navigate = useNavigate(); const { user, verificationUserId, clearVerificationState } = useUserStore(state => ({ user: state.user, verificationUserId: state.verificationUserId, clearVerificationState: state.clearVerificationState })); const { mutate: resendEmail, isPending, error } = useMutation({ mutationFn: async (userId: string) => { const response = await api.post(`/users/resend-verification/${userId}`); return response.data; }, onSuccess: () => { navigate('/login', { state: { message: '新的验证邮件已发送至你的注册邮箱' }, replace: true }); } }) const handleResend = useCallback(() => { if (!verificationUserId) { console.error('缺少用户 ID'); return; } resendEmail(verificationUserId); }, [verificationUserId, resendEmail]); useEffect(() => { return () => { clearVerificationState(); }; }, [clearVerificationState]); return ( <div className="min-h-screen flex items-center justify-center bg-base-200"> <div className="card w-96 bg-base-100 shadow-xl"> <div className="card-body items-center text-center"> <h2 className="card-title mb-4"> 邮箱验证 </h2> <div className="flex flex-col items-center gap-4"> <p className="text-sm"> 你的邮箱 <strong className="text-primary">{user?.email}</strong> 还未验证 </p> {error && ( <div className="alert alert-error shadow-lg"> <div> <svg xmlns="http://www.w3.org/2000/svg" className="stroke-current flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /> </svg> <span>{error.message}</span> </div> </div> )} <button onClick={handleResend} disabled={isPending} className={`btn btn-primary w-full ${isPending ? 'loading' : ''}`} > {isPending ? '发送中...' : '重新发送验证邮件'} </button> <div className="text-sm mt-4"> <p className="text-gray-500"> 没有收到邮件?请检查垃圾邮件文件夹 </p> <p className="text-gray-500 mt-2"> 需要修改邮箱?前往{' '} <a href="/settings" className="link link-primary" onClick={(e) => { e.preventDefault(); navigate('/settings'); }} > 账户设置 </a> </p> </div> </div> </div> </div> </div> );}export default ResendVerification; 通过这个功能,我们为用户提供了一个完整的邮箱验证补救方案,帮助他们顺利完成账号验证过程。 4.6. 错误边界处理 在复杂的单页应用中,错误处理是一个非常重要的环节。为了防止应用因为某个组件的错误而完全崩溃,我们引入了错误边界(Error Boundary)机制。 安装 react-error-boundary: yarn add react-error-boundary 4.6.1. 应用入口改造 首先,我们在应用的最顶层添加错误边界保护。修改 App.tsx: import React from 'react';import { RouterProvider } from 'react-router-dom';import { ErrorBoundary } from 'react-error-boundary';import router from './router';import ErrorFallback from './components/ErrorFallback';const App = () => { return ( <ErrorBoundary FallbackComponent={ErrorFallback}> <RouterProvider router={ router } /> </ErrorBoundary> );}export default App; 这样做可以捕获整个应用中的 React 组件错误,防止应用崩溃。 4.6.2. 错误回退组件 我们创建了一个专门的错误回退组件 ErrorFallback.tsx,用于显示错误信息并提供重试功能: import React from 'react';import { FallbackProps } from 'react-error-boundary';const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => { return ( <div className="alert alert-error shadow-lg"> <div> <svg xmlns="http://www.w3.org/2000/svg" className="stroke-current flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /> </svg> <div> <h3 className="font-bold">发生错误!</h3> <pre className="whitespace-pre-wrap">{error.message}</pre> </div> </div> <button className="btn btn-sm btn-primary" onClick={resetErrorBoundary} > 重试 </button> </div> );}export default ErrorFallback; 错误回退组件具备以下核心功能,旨在提升错误处理的可读性和用户体验: 提供清晰的错误信息展示。组件能够显示友好的错误提示,同时呈现具体的错误详情,并保持错误信息的格式化显示,以便用户理解问题所在。 支持错误恢复功能。用户可以通过重试按钮尝试重新执行操作,同时,resetErrorBoundary 方法允许重置错误状态,使应用能够正常运行,让用户得以从错误中恢复。 在用户体验方面,组件采用统一的错误提示样式,确保与应用的整体设计风格保持一致。同时,它提供清晰的操作指引,帮助用户快速做出合适的应对操作,从而减少因错误导致的使用困扰。 4.7. 路由访问控制 为了确保用户只能访问其权限内的页面,我们需要实现路由保护机制。这包括对私有路由的保护和对公共路由的控制。 4.7.1. 受保护路由组件 创建 ProtectedRoute 组件用于保护需要登录才能访问的页面: import React from 'react';import {useLocation, Navigate} from 'react-router-dom';import {useUserStore} from '../stores';const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { const user = useUserStore(state => state.user); const isAutoLoading = useUserStore(state => state.isAutoLoading); const location = useLocation(); // TODO: 返回一个加载动画 if (isAutoLoading) return <div>加载中……</div>; if (!user) return <Navigate to="/login" state={{from: location}} replace />; return <>{children}</>;}export default ProtectedRoute; 4.7.2. 公共路由组件 创建 PublicRoute 组件用于处理登录、注册等公共页面: import React from 'react';import {useUserStore} from '../stores';import {Navigate, useLocation} from 'react-router-dom';const PublicRoute = ({children} : {children: React.ReactNode}) => { const user = useUserStore(state => state.user); const isAutoLoading = useUserStore(state => state.isAutoLoading); const location = useLocation(); if (isAutoLoading) return <div>加载中……</div>; if (user) { const from = location.state?.from?.pathname || '/'; return <Navigate to={from} replace />; } return <>{children}</>;}export default PublicRoute; 4.7.3. 路由配置优化 使用这些保护组件来包装路由: const router = createBrowserRouter([ { path: '/', element: <ProtectedRoute><MainPage /></ProtectedRoute> }, { path: '/register', element: <PublicRoute><Register /></PublicRoute> }, { path: '/login', element: <PublicRoute><Login /></PublicRoute> }, { path: '/verify-email', element: <VerifyEmail /> }, { path: '/resend-verification', element: <ResendVerification /> } ]); 可以写一个简单的 MainPage 组件来测试登出功能: import React from 'react';import { useUserStore } from '../stores';import MainLayout from '../layouts/MainLayout';const MainPage = () => { const logout = useUserStore(state => state.logout); return ( <MainLayout> <button onClick={logout}>登出</button> </MainLayout> )};export default MainPage;

2025/2/12
articleCard.readMore

Hexo i18n Configuration

When running a personal blog, you might encounter this requirement: wanting your website to support multiple languages so that readers from different regions can easily read your content. This is where website internationalization (also known as i18n) comes into play. For blogs built with Hexo, implementing internationalization requires consideration not only of content translation but also template rendering and other issues. This article primarily uses the hexo-generator-plus plugin. Before starting the configuration, please ensure you have uninstalled the following plugins to avoid conflicts: hexo-generator-archive hexo-generator-category hexo-generator-index hexo-generator-tag This article uses the Pug templating language. For the language switching solution in the navigation bar, I have only implemented bilingual logic. Basic Configuration To avoid confusion: The _config.yml in the Hexo root directory will be referred to as Hexo Configuration themes/**/_config.yml will be referred to as Theme Configuration First, we need to make some basic settings in Hexo's configuration file. These settings will determine the website's language options and URL structure. language: [zh, en] # Supported language list, first one is defaultnew_post_name: :title.md # New article naming convention# If you have hexo-abbrlink installedpermalink: posts/:abbrlink.html # Article permalink formatabbrlink: rep: hex # Use hexadecimal as unique identifier# hexo-generator-plus configurationgenerator_plus: language: [zh, en] # Generator supported languages pagination_dir: 'page' # Pagination directory generator: ["index", "archive", "category", "tag"] # Pages to generate # Index generator configuration index_generator: per_page: 10 # These numbers and order can be customized order_by: -date # Archive page configuration archive_generator: per_page: 25 order_by: -date # Category page configuration category_generator: per_page: 25 order_by: -date enable_index_page: true # If you want a category index page # Tag page configuration tag_generator: per_page: 25 order_by: name enable_index_page: true # If you want a tag index page Then configure the desired menu links in the theme configuration: menu: home: / archive: /archives categories: /categories tags: /tags about: /about GitHub: https://github.com/cytrogen RSS: /atom.xml Directory Structure Here is the required directory structure: source/├── _posts/ # Default language blog posts│ └── *.md # No subdirectories allowed├── en/ # English-specific content│ └── Same structure as source directory├── archives/ # Archive page│ └── index.md # layout: archive├── categories/ # Category page│ └── index.md # layout: category-index└── tags/ # Tag page └── index.md # layout: tag-index Of course, you can choose other languages, but other language directories need to match the names in themes/**/languages/*.yml. Please ensure each *.md file has lang: ** in its Front-Matter. Language File Configuration Fixed website text (such as navigation menus, button text, etc.) needs to be internationalized through language files. These files should be placed in the themes/**/languages/ directory. Here's my examples: zh.yml: menu:home: 首页archive: 归档tags: 标签categories: 分类about: 关于friendlinks: 友情链接archive_title: 归档tags_title: 标签categories_title: 分类prev: 上一页next: 下一页prev_post: 上一篇next_post: 下一篇more: ...阅读全文translated: 翻译 · 原文地址 en.yml: menu:home: HOMEarchive: ARCtags: TAGScategories: CATEabout: ABOUTfriendlinks: Friend Linksarchive_title: Archivetags_title: Tagscategories_title: Categoriesprev: PREVnext: NEXTprev_post: PREV POSTnext_post: NEXT POSTmore: ...MOREtranslated: Translate · Original Link Template File Implementation From here on, I'll only write about the solution used for my blog website. Please modify according to your own theme. Category Page Templates Category pages need two templates: category index page and specific category page. Category list page (category-index.pug): extends partial/layoutblock container .archive // Title content prioritizes page.title // If it doesn't exist, use the i18n function __() to get categories_title translation h2.archive-title= page.title || __('categories_title') .category-list // Get all category data each category in get_categories().data // Calculate number of posts matching current language for each category - var postCount = category.posts.data ? category.posts.data.filter(post => post.lang === page.lang).length : 0 if postCount > 0 .category-item // Each category shows as a link, including category name and post count // url_for_lang() generates multilingual-supported URL - var categoryPath = category.slug || category.name a.post-title-link(href=url_for_lang(page.lang, 'categories/' + categoryPath)) = category.name span.category-count= ` (${postCount})` Specific category page (category.pug): extends partial/layoutblock container include mixins/post .archive h2.archive-title= page.category +postList() Here, post-related functionality is encapsulated in a series of mixins for reuse across different pages (mixins/post.pug): mixin postInfo(item) .post-info != full_date(item.date, 'l') // If post has from property and current page is home or post page if item.from && (is_home() || is_post()) // Show a link indicating post translation source a.post-from(href=item.from target="_blank" title=item.from)!= __('translated')mixin posts() ul.home.post-list // Iterate through all posts - for (var post of page.posts.data || []) // Only show posts matching current page language - if (post.lang == page.lang) li.post-list-item article.post-block h2.post-title a.post-title-link(href= url_for(post.path)) != post.title +postInfo(post) // If there's an excerpt, show it with "read more" link if post.excerpt .post-content != post.excerpt a.read-more(href= url_for(post.path))!= __('more') else .post-content != post.content Tag Page Templates Almost identical to category page templates: tag-index.pug: extends partial/layoutblock container include mixins/post .archive h2.archive-title= page.title || __('tags_title') .tag-list each tag in get_tags().data - var postCount = tag.posts.data ? tag.posts.data.filter(post => post.lang === page.lang).length : 0 if postCount > 0 .tag-item - var tagPath = tag.slug || tag.name a.post-title-link(href=url_for_lang(page.lang, 'tags/' + tagPath)) = tag.name span.tag-count= ` (${postCount})`block pagination include mixins/paginator +home()block copyright include partial/copyright tag.pug: extends partial/layoutblock container include mixins/post .archive h2.archive-title= page.tag +postList()block pagination include mixins/paginator +home()block copyright include partial/copyright Archive Page Template Archive page only needs one archive.pug: extends partial/layoutblock container include mixins/post +postList()block pagination include mixins/paginator +home()block copyright include partial/copyright Its mixin: mixin postList() .archive // Check if there are posts if page.posts // Ensure post list exists and is not empty - var posts = page.posts.data || page.posts if posts && posts.length // Create a years object for grouping // Only process posts matching current page language // Get year from post date // Add posts to corresponding year array - var years = {} - for (var post of posts) - if (post.lang == page.lang) - var year = new Date(post.date).getFullYear() - if (!years[year]) years[year] = [] - years[year].push(post) // Sort years in descending order (show largest year first) - Object.keys(years).sort((a, b) => b - a).forEach(function(year) { h2.archive-year!= year - years[year].forEach(function(post) { .post-item +postInfo(post) a.post-title-link(href= url_for(post.path)) != post.title - }) - }) Navigation Bar Implementation The navigation bar is the key interface for language switching (nav.pug). Since my blog theme's navigation bar isn't wide, I wrote top and bottom sections to separate some links: ul.nav.nav-list // Top section div.nav-list-top // Iterate through all keys and values in theme menu config each path, key in theme.menu // Exclude links to be placed at bottom, in my case GitHub and RSS if key !== 'GitHub' && key !== 'RSS' li.nav-list-item // Check if external link // If yes, open in new tab // Add /en prefix for English pages // Check if current page is active (for highlighting) - var re = /^(http|https):\/\/*/gi; - var tar = re.test(path) ? "_blank" : "_self" - var fullPath = page.lang === 'en' ? '/en' + path : path - var act = !re.test(path) && "/" + page.current_url === fullPath a.nav-list-link(class={active: act} href=url_for(fullPath) target=tar) != __(('menu.' + key)) // Bottom section div.nav-list-bottom // Language switch button li.nav-list-item.lang-switch if page.lang == 'en' a.nav-list-link(href=url_for('/')) 中文 else a.nav-list-link(href=url_for('/en')) ENGLISH Usage After completing the above configuration, specify the language in the Front-Matter when creating new articles: ---title: Article Titledate: 2024-01-01lang: en--- If you want to create versions of the same article in other languages, just create a new Markdown file, specify the appropriate lang, and link to the original article using the from field in the Front-Matter: ---title: 文章标题date: 2024-01-01lang: zhfrom: /posts/original-post.html---

2024/12/3
articleCard.readMore

Hexo 配置 i18n

运营个人博客时,可能会遇到这样的需求:希望网站能够支持多语言,让来自不同地区的读者都能便捷地阅读我们的内容。 这就需要用到网站的国际化(也就是 i18n)功能。 对于使用 Hexo 搭建的博客而言,实现国际化不仅需要考虑内容的翻译,还要处理模板渲染等问题。 本文使用的主要插件是 hexo-generator-plus。 在开始配置之前,请确保已经卸载以下插件以避免冲突: hexo-generator-archive hexo-generator-category hexo-generator-index hexo-generator-tag 本文使用的模板语言是 Pug 语言。 对于导航栏中切换语言的方案,我只实现了双语言的逻辑。 基础配置 为了避免困扰: Hexo 根目录下的 _config.yml 将被称呼为 Hexo 配置项 themes/**/_config.yml 将被称呼为 主题配置项 首先,我们需要在 Hexo 的配置文件中进行一些基本设置。这些设置将决定网站的语言选项和 URL 结构。 language: [zh, en] # 支持的语言列表,第一个为默认语言new_post_name: :title.md # 新文章的命名方式# 如果你安装了 hexo-abbrlinkpermalink: posts/:abbrlink.html # 文章的永久链接格式abbrlink: rep: hex # 使用十六进制作为文章的唯一标识# hexo-generator-plus 的配置项generator_plus: language: [zh, en] # 生成器支持的语言列表 pagination_dir: 'page' # 分页目录 generator: ["index", "archive", "category", "tag"] # 需要生成的页面类型 # 首页生成器配置 index_generator: per_page: 10 # 这些数字和顺序可以自定义 order_by: -date # 归档页面配置 archive_generator: per_page: 25 order_by: -date # 分类页面配置 category_generator: per_page: 25 order_by: -date enable_index_page: true # 如果你想要分类列表页的话 # 标签页面配置 tag_generator: per_page: 25 order_by: name enable_index_page: true # 如果你想要标签列表页的话 接着在主题配置项里配置想要的菜单链接: menu: home: / archive: /archives categories: /categories tags: /tags about: /about GitHub: https://github.com/cytrogen RSS: /atom.xml 目录结构 以下是必需的目录结构: source/├── _posts/ # 默认语言的所有博文│ └── *.md # 不能有子目录├── en/ # 英文版特定内容│ └── 与 source 目录结构相同├── archives/ # 归档页面│ └── index.md # layout: archive├── categories/ # 分类页面│ └── index.md # layout: category-index└── tags/ # 标签页面 └── index.md # layout: tag-index 当然,你也可以选择其他语言,但其他语言的目录需要和对应的 themes/**/languages/*.yml 的名称相同。 请确保每个 *.md 的 Front-Matter 中都有 lang: **。 语言文件配置 网站的固定文本(如导航菜单、按钮文字等)需要通过语言文件来实现国际化。这些文件需要放在 themes/**/languages/ 目录下。 这是我的例子: zh.yml: menu:home: 首页archive: 归档tags: 标签categories: 分类about: 关于friendlinks: 友情链接archive_title: 归档tags_title: 标签categories_title: 分类prev: 上一页next: 下一页prev_post: 上一篇next_post: 下一篇more: ...阅读全文translated: 翻译 · 原文地址 en.yml: menu:home: HOMEarchive: ARCtags: TAGScategories: CATEabout: ABOUTfriendlinks: Friend Linksarchive_title: Archivetags_title: Tagscategories_title: Categoriesprev: PREVnext: NEXTprev_post: PREV POSTnext_post: NEXT POSTmore: ...MOREtranslated: Translate · Original Link 模板文件实现 从这里开始,只写关于我的博客网站所用的方案。 请按照自己的主题,自行进行修改。 分类页面模板 分类页面需要两个模板:分类列表页面和具体分类页面。 分类列表页面(category-index.pug): extends partial/layoutblock container .archive // 标题内容优先使用 page.title // 如果不存在则使用国际化函数 __() 获取 categories_title 的翻译 h2.archive-title= page.title || __('categories_title') .category-list // 获取所有分类数据 each category in get_categories().data // 对每个分类计算符合当前语言的文章数量 - var postCount = category.posts.data ? category.posts.data.filter(post => post.lang === page.lang).length : 0 if postCount > 0 .category-item // 每个分类显示为一个链接,包含分类名称、文章数量计数 // url_for_lang() 生成多语言支持的 URL - var categoryPath = category.slug || category.name a.post-title-link(href=url_for_lang(page.lang, 'categories/' + categoryPath)) = category.name span.category-count= ` (${postCount})` 具体分类页面(category.pug): extends partial/layoutblock container include mixins/post .archive h2.archive-title= page.category +postList() 这里文章显示相关的功能被封装成了一系列混入(mixins),方便在不同页面重用(mixins/post.pug): mixin postInfo(item) .post-info != full_date(item.date, 'l') // 如果文章有 from 属性且当前页面是首页或者文章页 if item.from && (is_home() || is_post()) // 显示一个表示文章翻译来源的链接 a.post-from(href=item.from target="_blank" title=item.from)!= __('translated')mixin posts() ul.home.post-list // 遍历所有的文章 - for (var post of page.posts.data || []) // 只显示与当前页面语言匹配的文章 - if (post.lang == page.lang) li.post-list-item article.post-block h2.post-title a.post-title-link(href= url_for(post.path)) != post.title +postInfo(post) // 如果有摘要,就显示摘要和“阅读全文”链接 if post.excerpt .post-content != post.excerpt a.read-more(href= url_for(post.path))!= __('more') else .post-content != post.content 标签页面模板 和分类页面模板近乎一致: tag-index.pug: extends partial/layoutblock container include mixins/post .archive h2.archive-title= page.title || __('tags_title') .tag-list each tag in get_tags().data - var postCount = tag.posts.data ? tag.posts.data.filter(post => post.lang === page.lang).length : 0 if postCount > 0 .tag-item - var tagPath = tag.slug || tag.name a.post-title-link(href=url_for_lang(page.lang, 'tags/' + tagPath)) = tag.name span.tag-count= ` (${postCount})`block pagination include mixins/paginator +home()block copyright include partial/copyright tag.pug: extends partial/layoutblock container include mixins/post .archive h2.archive-title= page.tag +postList()block pagination include mixins/paginator +home()block copyright include partial/copyright 归档页面模板 归档页面只需要一个 archive.pug: extends partial/layoutblock container include mixins/post +postList()block pagination include mixins/paginator +home()block copyright include partial/copyright 它调用的混入: mixin postList() .archive // 是否有文章数据 if page.posts // 确保文章列表存在且不为空 - var posts = page.posts.data || page.posts if posts && posts.length // 创建一个年份对象用于存储分组 // 只处理与当前页面语言相匹配的文章 // 使用文章日期获取年份 // 将文章添加到对应年份的数组中 - var years = {} - for (var post of posts) - if (post.lang == page.lang) - var year = new Date(post.date).getFullYear() - if (!years[year]) years[year] = [] - years[year].push(post) // 将年份按降序排列(先显示年份最大的) - Object.keys(years).sort((a, b) => b - a).forEach(function(year) { h2.archive-year!= year - years[year].forEach(function(post) { .post-item +postInfo(post) a.post-title-link(href= url_for(post.path)) != post.title - }) - }) 导航栏实现 导航栏是实现语言切换的关键接口(nav.pug)。 由于我的博客主题中,导航栏并不宽,因此我写了顶底部来区分开部分链接: ul.nav.nav-list // 顶层 div.nav-list-top // 遍历主题配置项中 menu 的所有键值 each path, key in theme.menu // 排除掉想要放在底部的链接,我这里写的 GitHub 和 RSS if key !== 'GitHub' && key !== 'RSS' li.nav-list-item // 检测是否是外部链接 // 如果是,就在新标签页打开 // 英文页面的路径前添加 /en 前缀 // 判断当前页面是否激活(用于高亮显示) - var re = /^(http|https):\/\/*/gi; - var tar = re.test(path) ? "_blank" : "_self" - var fullPath = page.lang === 'en' ? '/en' + path : path - var act = !re.test(path) && "/" + page.current_url === fullPath a.nav-list-link(class={active: act} href=url_for(fullPath) target=tar) != __(('menu.' + key)) // 底部 div.nav-list-bottom // 语言切换按钮 li.nav-list-item.lang-switch if page.lang == 'en' a.nav-list-link(href=url_for('/')) 中文 else a.nav-list-link(href=url_for('/en')) ENGLISH 使用方法 完成上述配置后,创建新文章时需要在 Front-Matter 中指定语言: ---title: 文章标题date: 2024-01-01lang: zh--- 如果想创建同一篇文章的其他语言版本,只需要创建一个新的 Markdown 文件,指定相应的 lang,并在 Front-Matter 中通过 from 字段关联原文: ---title: Article Titledate: 2024-01-01lang: enfrom: /posts/original-post.html---

2024/12/2
articleCard.readMore

React + NestJS 购物平台练习【4】用户注册功能

全栈实践又到了我们喜闻乐见的用户注册功能…… 1. 修 BUG 我们先修一下先前写的 BUG…… 1.1. 导出 BUG 在我们先前创建的 src/stores/index.ts 中,我们写的是: import useUserStore from './user'; 这样写的话,实际运行项目后会报错。 正确的写法是: export { useUserStore } from './user'; index.ts 是用于导出所有的 Store,因此是 export 而不是 import 要理解为什么必须使用 { useUserStore } 而不是 useUserStore,我们需要了解 JavaScript 和 TypeScript 中的默认导出(default export)和具名导出(named export)之间的区别 1.1.1. 具名导出 在 stores/user/index.ts 中,useUserStore 是通过具名导出方式来导出的: export const useUserStore = createSelectors(useUserStoreBase); 这表示我们将 useUserStore 作为一个具名导出,并且它可以通过具名导出来引用。具名导出的语法正是: export { useUserStore } from './user'; 1.1.2. 默认导出 如果我们使用的是默认导出,那么导出时应该写成: export default useUserStore; 然后我们才可以在其他文件使用默认导出语法: export useUserStore from './user'; 1.2. 实体关系 BUG 后续在后端开发完注册 API、运行应用时,会遇到实体关系配置错误: TypeORMError: Entity metadata for Users#orders was not found. Check if you specified a correct entity object and if it's connected in the connection options. 这是因为 NestJS 使用 TypeORM 时需要通过 TypeOrmModule 注册所有的实体。如果一个实体(例如 Users)中引用了其他实体(例如 Orders),TypeORM 会尝试加载并解析这些关系。如果 Orders 没有在 TypeOrmModule 配置中注册,TypeORM 将会因为找不到该实体的元数据而抛出错误。 唉,这时候有人该问了,第二篇 里不是已经写了个 database.module.ts 来统一配置吗?里面还配置了个 autoLoadEntities 呢。 在数据库模块的配置中,我们确实加上了 autoLoadEntities: true,希望能自动加载所有实体,避免每次手动注册实体。然而,autoLoadEntities 的工作机制并非全局加载所有实体,它仅自动加载通过 TypeOrmModule.forFeature() 注册的实体。因此,如果某个实体未被 forFeature 导入到任何模块中,TypeORM 无法找到它。 在 TypeOrmModule 中,有三种方式来配置实体加载,每种方式有不同的适用场景: 单独定义: TypeOrmModule.forRoot({ //... entities: [ Users, Products, Payments, Orders, OrderItems, InventoryLogs, Categories, Carts, CartItems, Addresses, ],}), 这种方式适合开发阶段,明确知道所有实体的数量和位置时,每当创建新实体时手动添加即可。但在项目复杂、实体较多或依赖多模块的情况下,逐一引入会显得繁琐,容易出错,且不便于代码维护。 自动加载: TypeOrmModule.forRoot({ //... autoLoadEntities: true,}), autoLoadEntities 会自动加载通过 TypeOrmModule.forFeature() 引入的实体。这意味着在模块中显式使用 TypeOrmModule.forFeature([Entity]) 的实体将被自动添加到连接配置的 entities 数组中。 注意: 这要求每个模块中要包含实体的 forFeature 配置。否则未注册的实体将无法自动加载,容易引发实体关系配置错误。 在 Users 实体中,Orders 实体被作为关联实体引用(@OneToMany(() => Orders, order => order.user)),因此 TypeORM 会尝试在 entities 配置数组中找到并加载 Orders 的元数据。由于 Orders 没有在任何模块的 forFeature 中注册,TypeORM 会在解析 Users 实体的关系时找不到 Orders,导致报错。 自定义引入路径: TypeOrmModule.forRoot({ //... entities: ['dist/**/*.entity{.ts,.js}'],}), 这是官方推荐的方式,使用通配符路径直接加载所有编译后的实体文件(如 dist/entities/*.entity.js),避免了逐一手动添加的麻烦,并保证所有实体自动注册,减少遗漏问题。 在我们的 database.module.ts 中添加第三种方式的配置,即可解决报错。 2. 实现注册表单组件 2.1. 基础结构 我们的注册页面由两个主要部分组成: Register 组件:处理表单逻辑、验证以及提交请求 AuthLayout 组件:负责提供页面的布局和样式 Register 组件嵌套在 AuthLayout 中,这样可以确保页面结构保持一致。 首先,我们来看看 AuthLayout.tsx 组件的代码,它定义了一个简单的容器,将任何传递给它的内容居中显示,并设置一些基本的样式: import React from 'react';type AuthLayoutProps = { children: React.ReactNode;};const AuthLayout = ({ children }: AuthLayoutProps) => { return ( <div className="flex min-h-screen items-center justify-center bg-base-200"> <div className="w-full max-w-md bg-base-100 p-8 rounded-lg shadow-xl"> {children} </div> </div> );};export default AuthLayout; 这里使用了 Tailwind CSS 进行样式布局,AuthLayout 用于包装页面的子元素,确保其在屏幕上居中显示,并有一定的阴影和内边距。 接着在 src/pages 目录下创建 Register.tsx。 2.2. 表单开发 在我们的 Register 组件中,我们将使用 Formik 来处理表单的状态管理和提交,并使用 Yup 来进行表单验证。Formik 和 Yup 的结合提供了简洁且强大的表单验证和管理能力。 用以下命令安装 Formik 和 Yup: yarn add formik yup 2.2.1. Formik 用法 Formik 通过 useFormik Hook 管理表单的状态和行为。在 Register 页面中,我们初始化了一个表单,提供了初始值和 onSubmit 处理函数。 import { useFormik } from 'formik';const Register = () => { const formik = useFormik({ initialValues: { username: "", email: "", password: "", confirmPassword: "" }, validationSchema, onSubmit: (values) => { setIsSubmitting(true); setTimeout(() => { console.log(values); // 模拟提交 setIsSubmitting(false); }, 2000); } }); // ...} initialValues:初始化表单的默认值 validationSchema:使用 Yup 创建的验证规则(稍后会详细介绍) onSubmit:处理表单提交的函数 setIsSubmitting(true):在表单提交时,我们将 isSubmitting 设置为 true,这会触发按钮禁用以及按钮文本更新为「注册中……」 setTimeout:为了模拟实际的提交过程,我们使用 setTimeout 延迟了 2 秒钟。实际应用中这里应该替换为 API 请求,不过我们的 API 还没完成呢 setIsSubmitting(false):当提交操作完成时,我们将 isSubmitting 设置为 false,恢复按钮的正常状态 2.2.2. Yup 验证 Yup 是一个 JavaScript 的对象模式验证库,我们通过它来定义表单的验证规则。下面是每个字段的验证规则: import * as Yup from 'yup';const Register = () => { const validationSchema = Yup.object({ username: Yup.string().required("请输入用户名!"), email: Yup.string().email("请输入有效的邮箱地址!").required("请输入邮箱地址!"), password: Yup.string().min(6, "密码必须至少包含6个字符!").required("请输入密码!"), confirmPassword: Yup.string() .oneOf([Yup.ref("password")], "密码不匹配!") .required("请输入确认密码!"), }); // ...} username:必须填写,并且是字符串 email:必须添加,并且符合邮箱格式 password:必须至少有 6 个字符 confirmPassword:必须与密码匹配 Yup 和 Formik 的结合提供了简洁的验证机制,自动管理每个字段的错误信息,并在表单提交时触发验证。 2.2.3. 表单渲染 表单输入框的渲染非常直观。我们通过 formik.handleChange 来处理用户输入,并通过 formik.errors 和 formik.touched 来显示错误信息。 const Register = () => { // ... return ( <AuthLayout> <h2 className="text-2xl font-bold text-center mb-6 text-neutral-content"> 注册账户 </h2> <form onSubmit={formik.handleSubmit}> <div className="mb-4"> <label className="block text-sm font-semibold text-neutral-content" htmlFor="username"> 用户名 </label> <input type="text" id="username" name="username" className={`w-full mt-2 p-2 border rounded-lg bg-base-300 ${formik.touched.username && formik.errors.username ? "border-error" : "border-neutral-600"} text-base-content`} value={formik.values.username} onChange={formik.handleChange} onBlur={formik.handleBlur} /> {formik.touched.username && formik.errors.username && ( <p className="text-sm text-error mt-1">{formik.errors.username}</p> )} </div> {/* ... */} </form> </AuthLayout> );}; value:绑定表单值 onChange 和 onBlur:处理表单输入和失去焦点事件 formik.errors:在字段发生错误时显示错误信息 用同样的写法,写完「邮箱地址」、「密码」和「确认密码」输入框: const Register = () => { // ... return ( <AuthLayout> <h2 className="text-2xl font-bold text-center mb-6 text-neutral-content"> 注册账户 </h2> <form onSubmit={formik.handleSubmit}> {/* ... */} <div className="mb-4"> <label className="block text-sm font-semibold text-neutral-content" htmlFor="email"> 邮箱地址 </label> <input type="email" id="email" name="email" className={`w-full mt-2 p-2 border rounded-lg bg-base-300 ${formik.touched.email && formik.errors.email ? "border-error" : "border-neutral-600"} text-base-content`} value={formik.values.email} onChange={formik.handleChange} onBlur={formik.handleBlur} /> {formik.touched.email && formik.errors.email && ( <p className="text-sm text-error mt-1">{formik.errors.email}</p> )} </div> <div className="mb-4"> <label className="block text-sm font-semibold text-neutral-content" htmlFor="password"> 密码 </label> <input type="password" id="password" name="password" className={`w-full mt-2 p-2 border rounded-lg bg-base-300 ${formik.touched.password && formik.errors.password ? "border-error" : "border-neutral-600"} text-base-content`} value={formik.values.password} onChange={formik.handleChange} onBlur={formik.handleBlur} /> {formik.touched.password && formik.errors.password && ( <p className="text-sm text-error mt-1">{formik.errors.password}</p> )} </div> <div className="mb-4"> <label className="block text-sm font-semibold text-neutral-content" htmlFor="confirmPassword"> 确认密码 </label> <input type="password" id="confirmPassword" name="confirmPassword" className={`w-full mt-2 p-2 border rounded-lg bg-base-300 ${formik.touched.confirmPassword && formik.errors.confirmPassword ? "border-error" : "border-neutral-600"} text-base-content`} value={formik.values.confirmPassword} onChange={formik.handleChange} onBlur={formik.handleBlur} /> {formik.touched.confirmPassword && formik.errors.confirmPassword && ( <p className="text-sm text-error mt-1">{formik.errors.confirmPassword}</p> )} </div> </form> </AuthLayout> );}; 2.2.4. 按钮的状态管理 按钮的状态管理对于处理表单提交时的交互反馈是非常重要的。 useState 是 React 中用于管理组件状态的 Hook。它允许我们在函数组件内部创建一个可变的状态,并返回一个更新该状态的函数。在注册页面中,我们使用 useState 来管理表单是否正在提交。 import React, { useState } from 'react';const Register = () => { const [isSubmitting, setIsSubmitting] = useState<boolean>(false); // ...}; isSubmitting:表示表单是否正在提交,初始值是 false,即默认情况下表单没有提交 setIsSubmitting:用于更新 isSubmitting 状态的函数 每当表单提交时,我们会将 isSubmitting 设置为 true,表示正在进行提交操作。当提交完成后,再将其设置回 false。 2.2.5. 按钮的状态变化 在注册页面中,表单的提交按钮(<button>)会根据 isSubmitting 的状态进行显示不同的文本内容: const Register = () => { // ... return ( <AuthLayout> <h2 className="text-2xl font-bold text-center mb-6 text-neutral-content"> 注册账户 </h2> <form onSubmit={formik.handleSubmit}> {/* ... */} <button type="submit" className="w-full bg-primary text-primary-content py-2 rounded-lg mt-4 hover:brightness-90" disabled={isSubmitting}> {isSubmitting ? "注册中……" : "注册"} </button> </form> );};</button> disabled={isSubmitting}:按钮在提交时被禁用,防止用户多次点击。每次表单提交时,isSubmitting 会被设为 true,这将使按钮处于禁用状态,直到提交结束 按钮文本切换:根据 isSubmitting 的值,按钮的文本会动态改变 如果表单正在提交(isSubmitting === true),按钮文本会显示为「注册中……」 如果表单没有提交(isSubmitting === false),按钮显示为「注册」 2.3. 路由配置 为了使我们的页面能够被访问和管理,在我们的 router.tsx 中添加路由 /register: import Register from './pages/Register';const router = createBrowserRouter([ // ... { path: '/register', element: <Register /> }]); 运行 React 项目,访问 localhost:3000/register: 图片是另外加的。 3. 实现后端注册 API 在现代的 Web 应用开发中,用户注册功能是几乎每个系统都需要的基础部分。用户注册不仅需要保存用户的基本信息,还要确保密码等敏感数据的安全性。 设想这样一个场景:我们正在开发一个用户系统,要求用户可以通过提供必要的个人信息进行注册,并创建一个账号。由于用户密码是非常敏感的信息,我们必须在保存密码之前进行加密,以确保其安全性。此外,我们还需要在需要时提供其他用户管理的接口,如更新、删除等操作。 3.1. 创建用户模块 用户模块负责管理用户相关的逻辑,创建 users.module.ts 文件: import { Module } from '@nestjs/common';import { TypeOrmModule } from '@nestjs/typeorm';import { UsersController } from './users.controller';import { UsersService } from './users.service';import { Users } from '../entities/users.entity';@Module({ imports: [TypeOrmModule.forFeature([Users])], controllers: [UsersController], providers: [UsersService]})export class UsersModule {} 在此模块中,我们使用了 TypeOrmModule.forFeature([Users]) 来将用户实体与 TypeORM 绑定,以便在 UsersService 中使用数据库的增删查改功能。 3.2. 创建用户控制器 控制器负责定义 API 路由和对应的处理方法,创建 users.controller.ts 文件: import { Controller, Post, Get, Body, Param } from '@nestjs/common';import { UsersService } from './users.service';import { Users } from '../entities/users.entity';@Controller('users')export class UsersController { constructor(private readonly usersService: UsersService) {} @Post('create') create(@Body() user: Partial<Users>): Promise<Users> { return this.usersService.create(user); } @Get(':id') findById(@Param('id') id: string): Promise<Users> { return this.usersService.findById(id); } @Post('update/:id') update(@Param('id') id: string, @Body() updateUser: Partial<Users>): Promise<Users> { return this.usersService.update(id, updateUser); } @Post('delete/:id') remove(@Param('id') id: string): Promise<void> { return this.usersService.remove(id); }} 我们定义了以下几个方法: create:用于处理用户注册的 POST 请求,调用 UsersService.create 方法以保存用户信息 findById、update 和 remove 方法:分别用于获取、更新和删除用户信息 3.3. 编写用户服务 UsersService 负责处理具体的业务逻辑,包括数据的加密和与数据库的交互。在实现注册功能时,我们需要对用户密码进行加密,并将加密后的密码与其他信息一起保存到数据库。 创建 users.service.ts: import { Injectable, NotFoundException } from '@nestjs/common';import { InjectRepository } from '@nestjs/typeorm';import { Repository } from 'typeorm';import * as bcrypt from 'bcrypt';import { Users } from '../entities/users.entity';@Injectable()export class UsersService { constructor(@InjectRepository(Users) private usersRepository: Repository<Users>) {} async create(user: Partial<Users>): Promise<Users> { const hashedPassword = await bcrypt.hash(user.password, 10); const newUser = this.usersRepository.create({ ...user, password: hashedPassword }); return this.usersRepository.save(newUser); } async update(id: string, updateUser: Partial<Users>): Promise<Users> { const user = await this.findById(id); if (updateUser.password) { updateUser.password = await bcrypt.hash(updateUser.password, 10); } Object.assign(user, updateUser); return this.usersRepository.save(user); } async remove(id: string): Promise<void> { const result = await this.usersRepository.delete(id); if (result.affected === 0) { throw new NotFoundException(`User with ID ${id} is not found`); } } async findById(id: string): Promise<Users> { const user = await this.usersRepository.findOneBy({ id }); if (!user) { throw new NotFoundException(`User with ID ${id} is not found`); } return user; }} UsersService 实现了以下方法: create:用于创建用户。在保存用户数据前,通过 bcrypt.hash 方法对密码进行加密,然后将加密后的用户数据保存到数据库 update:用于更新用户信息。如果更新的数据中包含 password,则需要再次加密后再保存 remove:用于删除用户信息。如果删除操作没有影响任何行,则会抛出 NotFoundException 错误 findById:用于根据用户 ID 查询用户信息。找不到用户时同样会抛出 NotFoundException 3.4. 通过 Swagger 生成文档标签 我们将为每个控制器方法添加 Swagger 标签: // ...import { ApiTags, ApiOperation, ApiBody, ApiResponse, ApiParam } from '@nestjs/swagger';@ApiTags('Users')@Controller('users')export class UsersController { // ... @ApiOperation({ summary: '创建新用户' }) @ApiBody({ description: '用户信息', type: Users }) @ApiResponse({ status: 201, description: '用户成功创建', type: Users }) @Post('create') // ... @ApiOperation({ summary: '根据ID获取用户信息' }) @ApiParam({ name: 'id', description: '用户ID' }) @ApiResponse({ status: 200, description: '获取用户信息成功', type: Users }) @Get(':id') // ... @ApiOperation({ summary: '更新用户信息' }) @ApiParam({ name: 'id', description: '用户ID' }) @ApiBody({ description: '更新的用户信息', type: Users }) @ApiResponse({ status: 200, description: '用户更新成功', type: Users }) @Post('update/:id') // ... @ApiOperation({ summary: '删除用户' }) @ApiParam({ name: 'id', description: '用户ID' }) @ApiResponse({ status: 200, description: '用户删除成功' }) @Post('delete/:id') // ...} @ApiTags('Users'):将此标签添加到控制器顶部,使得所有方法都归入 Users 类别,便于在 Swagger 文档中管理 @ApiOperation:为每个方法添加操作说明,使其清晰描述了 API 的功能 @ApiBody:为 POST 请求体参数添加描述,指定了参数内容的描述和数据类型 @ApiParam:为路径参数添加说明,方便开发者了解需要传入的参数 @ApiResponse:指定响应的状态码和描述信息,以及返回的数据类型,便于用户了解响应格式 访问 localhost:APP_PORT/api-docs 来查看 Swagger 文档。 3.5. 使用 DTO 来进行优化 在现代 Web 应用中,数据传输对象(DTO)是与外部通信的标准化方式之一。在 NestJS 项目中,DTO(Data Transfer Object)不仅帮助你确保传递的数据格式一致,还提供了结构化和验证机制,使得 API 接口更加清晰、安全和可维护。 DTO 通常用于: 定义 API 接口所需的请求体和响应体的结构 对数据进行验证和转换 确保前后端在数据传输时遵循相同的约定 在我们已经编写了注册用户的逻辑后,为什么还需要创建 DTO 并用它来顶替原本的写法? 答案是:增强数据验证和规范化输入输出。 增强验证机制:DTO 通过类验证器(如 class-validator)来确保传入的数据符合预期。例如,我们可以设置 MinLength 来确保密码长度不小于 6 个字符、使用 IsEmail 来验证邮箱格式。这不仅使代码更加规范,而且还大大提高了 API 的可靠性和安全性 清晰的 API 结构:DTO 使 API 请求和响应体更加清晰。通过 DTO,前后端可以明确约定需要的数据字段和格式,这样可以减少由于数据格式不匹配导致的错误 易于扩展和维护:DTO 提供了一个灵活的扩展点。如果未来业务需求发生变化,我们只需修改 DTO,而不必修改整个业务逻辑。这样,系统的可维护性和扩展性更强 我们已经有了一个用于用户注册的业务逻辑,现在我们要将 DTO 集成到 UsersController 和 UsersService 中。 3.5.1. 创建 DTO 在 src/users 目录下创建 dto 目录,并在其中再创建一个 create-user.dto.ts 文件: import { IsString, IsEmail, MinLength } from 'class-validator';export class CreateUserDto { @IsString() username: string; @IsEmail() email: string; @MinLength(6, { message: '密码长度必须不小于6个字符' }) password: string;} CreateUserDto 定义了用户注册时需要传入的字段,并且为这些字段添加了验证规则: username 字段要求是字符串类型 email 字段要求是有效的邮箱格式 password 字段要求密码长度至少为 6 个字符 3.5.2. 在控制器中使用 DTO 接下来,在 UsersController 中,我们将 CreateUserDto 引入,并将其用于 create 方法的请求体验证。我们需要使用 @Body() 装饰器将请求体映射到 DTO 类。 // ...import { CreateUserDto } from './dto/create-user.dto';@ApiTags('Users')@Controller('users')export class UsersController { // ... @ApiOperation({ summary: '创建新用户' }) @ApiBody({ description: '用户信息', type: CreateUserDto }) // 使用DTO类型 @ApiResponse({ status: 201, description: '用户成功创建', type: Users }) @Post('create') create(@Body() user: CreateUserDto): Promise<Users> { // 接收CreateUserDto类型 return this.usersService.create(user); } // ...} 在上述代码中: 使用 @ApiBody 注解指定了请求体的描述,type 字段使用 CreateUserDto @Body() 装饰器会将请求体映射到 CreateUserDto 类型,从而进行数据验证 3.5.3. 在服务中使用 DTO 在 UsersService 中,create 方法接收了 CreateUserDto 类型的参数,并在保存到数据库之前进行密码的哈希处理: // ...import { CreateUserDto } from './dto/create-user.dto';@Injectable()export class UsersService { constructor(@InjectRepository(Users) private usersRepository: Repository<Users>) {} async create(user: CreateUserDto): Promise<Users> { const hashedPassword = await bcrypt.hash(user.password, 10); // 使用DTO中的password const newUser = this.usersRepository.create({ ...user, password: hashedPassword }); return this.usersRepository.save(newUser); }} 在这里,我们在服务层接收的参数是 CreateUserDto 类型,它包含了所有必要的字段和验证规则。通过这种方式,我们避免了在控制器中进行复杂的验证操作,将其交给 DTO 来处理,使得业务逻辑更加简洁和清晰。 3.5.4. 定义 Swagger Schema 在 NestJS 中,你可以通过在 DTO 中使用 Swagger 的注解来定义和生成 API 的 Schema。 import { IsString, IsEmail, MinLength } from 'class-validator';import { ApiProperty } from '@nestjs/swagger';export class CreateUserDto { @ApiProperty({ description: '用户名,用户的唯一标识', example: 'john_doe' }) @IsString() username: string; @ApiProperty({ description: '用户的邮箱地址,必须为有效的邮箱格式', example: 'johndoe@example.com' }) @IsEmail() email: string; @ApiProperty({ description: '密码,必须至少包含6个字符', example: 'password123456', minLength: 6 }) @MinLength(6, { message: '密码长度必须不小于6个字符' }) password: string;} 3.6. 与前端的集成 先前写前端代码的时候,我们的 Register.tsx 并没有连接到后端 API,而是使用 setTimeout 模拟了一下提交。 因此我们要对现有的前端代码进行修改和优化。 3.6.1. 添加状态管理逻辑 在 Register.tsx 中,我们使用了本地状态管理 isSubmitting。 const [isSubmitting, setIsSubmitting] = useState<boolean>(false); 为了让状态的管理更统一,我们需要将其改为使用全局状态管理,也为之后添加更多功能奠定了基础。 首先是修改 stores/index.ts: export { useUserStore } from './user'; 接着在 Register.tsx 中使用 useUserStore: import { useUserStore } from '../stores';// ...const register = useUserStore(state => state.register);const isLoading = useUserStore(state => state.isLoading);const error = useUserStore(state => state.error); register:一个用于注册用户的全局方法 isLoading:表单提交的状态 error:记录注册过程中发生的错误信息 3.6.2. 新增注册方法 在修改 Register.tsx 时,我们同样需要在 stores 目录下的状态管理逻辑中进行调整,以支持注册功能的完整实现。 首先在 stores/user/types.ts 中新增注册凭据类型: export interface RegisterCredentials { username: string; email: string; password: string; } 并更新用户状态类型: export interface UserState { // ... register: (credentials: RegisterCredentials) => Promise<User>;} 接着在 stores/user/actions.ts 中新添注册功能: register: async (credentials: RegisterCredentials) => { set({ isLoading: true, error: null }); try { const response: AxiosResponse<User> = await api.post<User>('/users/create', credentials); const user = response.data; set({ isLoading: false, error: null, lastUpdated: Date.now() }); return user; } catch (error) { const errorMessage = error instanceof Error ? error.message : '注册过程中发生意外错误'; set({ isLoading: false, error: errorMessage }); throw error; } }, 主要逻辑为: 初始化状态:设置 isLoading 为 true,清空可能的旧错误 调用注册 API:发送用户的注册信息到 /users/create,并解析后端返回的用户数据 成功处理:更新状态,但不设置 user 值,因为注册完成后还需登录 错误处理:捕获错误并设置错误信息 状态恢复:无论成功或失败,都将 isLoading 恢复为 false 3.6.3. 实现与后端的连接 先前我们用了 setTimeout 来模拟注册: setTimeout(() => { console.log(values); setIsSubmitting(false);}, 2000); 现在我们应当与实际的后端进行交互,发送注册请求: onSubmit: async (values) => { try { const { username, email, password } = values; await register({ username, email, password }); navigate('/login', { state: { message: '注册成功!请登录您的账号。', email: values.email } }); } catch (error) { console.error('注册失败:', error); }} 成功后,跳转到 /login 页面,并通过 state 传递一条注册成功的信息。就算失败了,也会捕获错误信息,便于显示给用户。 由于我们在 store 中已经处理了注册错误,这里就不需要额外处理,直接 console.error 即可。 navigate 来自于 react-router-dom 库: import { useNavigate } from 'react-router-dom';// ...const navigate = useNavigate(); 3.6.4. 用户错误信息提示 在 Register.tsx 中添加以下内容: return ( <AuthLayout> <img src={logo} className="w-1/4 mx-auto" alt="Shopping Nest的Logo" /> <h2 className="text-2xl font-bold text-center m-6 text-neutral-content"> 注册账户 </h2> {error && ( <div className="mb-4 p-3 text-sm text-error-content bg-error rounded-lg"> {error} </div> )} {/* ... */} </AuthLayout>); 用户提交表单时,如果后端返回错误信息,将会在表单顶部显示一条清晰的错误提示。这提升了用户体验,让用户了解失败的原因并尝试修正。 3.6.5. 禁用表单控件 将 Register.tsx 中所有的 <input> 标签都添加上这个属性: disabled={isLoading} 这代表着提交过程中表单控件会被禁用,避免用户重复提交。 isLoading 由状态管理工具提供,确保整个应用对状态变化的感知一致。 同样的,对 <button> 也进行修改: <button type="submit" className="w-full bg-primary text-primary-content py-2 rounded-lg mt-4 hover:brightness-90 disabled:opacity-50" disabled={isLoading}> {isLoading ? "注册中…" : "注册"}</button> 运行前端和后端,进行注册测试。 别忘了在前端目录下创建 .env: REACT_APP_API_URL=http://localhost:后端的端口 注册成功后你应该会被导向 /login 页面,上面会弹出: Unexpected Application Error!404 Not Found💿 Hey developer 👋You can provide a way better UX than this when your app throws errors by providing your own ErrorBoundary or errorElement prop on your route. 4. 实现邮箱验证 邮箱验证是用户注册流程中的重要安全环节,旨在确认用户提供的邮箱地址是真实且可用、防止机器人和垃圾注册、提高账号安全性,同时为后续通信建立可靠的联系渠道。 典型的邮箱验证流程包括: 用户注册提供邮箱 系统生成唯一验证令牌 发送包含验证链接的邮件 用户点击链接完成验证 系统校验令牌的有效性 4.1. 前端实现 我们首先需要在路由中添加邮箱验证页面的路由。在 router.tsx 中进行修改: import VerifyEmail from './pages/VerifyEmail';const router = createBrowserRouter([ // ... { path: '/verify-email', element: <VerifyEmail /> }]); 4.1.1. Zustand 状态管理更新 接下来,我们需要更新 Zustand 的用户状态管理,增加邮箱验证相关的状态的方法。 更新 types.ts,添加邮箱验证相关的枚举和接口: export enum EmailVerificationError { TOKEN_INVALID = 'TOKEN_INVALID', TOKEN_EXPIRED = 'TOKEN_EXPIRED', ALREADY_VERIFIED = 'ALREADY_VERIFIED' } 在实现邮箱验证功能时,我定义了三种可能的验证错误状态: TOKEN_INVALID(无效令牌) 当用户提供的验证链接被篡改、不完整或不存在于系统 可能是用户误点击了错误的链接 可能是验证链接已被恶意修改 系统将拒绝这类验证请求,并显示错误提示 TOKEN_EXPIRED(令牌过期) 验证链接已超过有效期限(24 小时) 防止长期未使用的过期链接被重复使用 用户需要重新请求发送验证邮件 提示用户链接已过期,需要重新获取 ALREADY_VERIFIED(已验证) 用户尝试使用已经验证过的邮箱链接再次验证 可能是用户重复点击验证链接 系统将提示用户邮箱已成功验证 通常会直接引导用户登录 export interface VerificationResponse { success: boolean; message: string; error?: EmailVerificationError; userId?: string; } 验证响应接口则设计了一个标准的响应接口: 验证是否成功的标志 返回消息 可选的错误类型 可选的用户 ID export interface UserState { // ... emailVerified: boolean; verificationError?: EmailVerificationError; verificationUserId?: string; verificationInProgress: boolean; verificationToken?: string; verifyEmail: (token: string) => Promise<VerificationResponse>; resendVerificationEmail: (userId: string) => Promise<VerificationResponse>;} 4.1.2. 邮箱验证组件基础结构 在 pages 目录下创建 VerifyEmail.tsx。首先我们来构建组件的基本结构和状态管理: import React, { useCallback, useEffect } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { useUserStore } from '../stores'; import { EmailVerificationError } from '../stores/user/types';const VerifyEmail = () => { // 获取路由和导航相关钩子 const location = useLocation(); const navigate = useNavigate(); // 从 Zustand store 中获取状态和方法 const verifyEmail = useUserStore(state => state.verifyEmail); const resendVerificationEmail = useUserStore(state => state.resendVerificationEmail); const isLoading = useUserStore(state => state.isLoading); const error = useUserStore(state => state.error); const emailVerified = useUserStore(state => state.emailVerified); const verificationError = useUserStore(state => state.verificationError); const verificationUserId = useUserStore(state => state.verificationUserId); const verificationInProgress = useUserStore(state => state.verificationInProgress); // 渲染逻辑将在这里实现 return ( <div className="min-h-screen flex items-center justify-center bg-base-200"> <div className="card w-96 bg-base-100 shadow-xl"> <div className="card-body items-center text-center"> <h2 className="card-title mb-4">验证电子邮件</h2> {/* 不同状态的渲染将在这里实现 */} </div> </div> </div> ); }; export default VerifyEmail; 状态管理部分详解: 从 Zustand store 中提取多个状态和方法 verifyEmail:邮箱验证方法 resendVerificationEmail:重新发送验证邮件方法 isLoading:加载状态 error:错误信息 emailVerified:邮箱是否已验证 verificationError:验证错误类型 verificationUserId:用于重发验证邮件的用户 ID verificationInProgress:验证是否正在进行中 接下来,我们实现邮箱验证的核心处理逻辑: const handleVerification = useCallback(async (token: string) => { try { const result = await verifyEmail(token); if (result.success) window.history.replaceState({}, '', window.location.pathname); return result; } catch (error) { return { success: false, error: error instanceof Error ? error.message : '验证失败' }; } }, [verifyEmail]); 该方法的主要目的是处理邮箱验证逻辑。通过 token 调用 verifyEmail 函数,判断验证是否成功,并在页面历史状态中作出相应的更新。 这里使用了 useCallback 进行性能优化,确保在依赖项未变化时,返回的函数引用不会发生变化。 window.history.replaceState 方法则是替换了当前历史记录的状态。当用户被验证成功后,token 会被移除,也避免了用户刷新页面时重复验证。 const handleResendVerification = async () => { if (verificationUserId) { try { const response = await resendVerificationEmail(verificationUserId); if (response.success) { navigate('/login', { state: { message: '新的验证邮件已发送,请查收邮箱' } }); } } catch (error) { console.error('重新发送验证邮件失败:', error); } } } 这个函数的主要功能是为特定用户重新发送验证邮件。调用异步函数 resendVerificationEmail 成功后就会自动导航到登录页面。 添加两个 useEffect 钩子来管理验证流程: // 处理邮箱验证useEffect(() => { const searchParams = new URLSearchParams(location.search); const token = searchParams.get('token'); const currentToken = useUserStore.getState().verificationToken; if (token && !verificationInProgress && !emailVerified && token !== currentToken) { handleVerification(token).then(r => { if (r.success && 'message' in r) console.log('邮箱验证成功:', r.message); }); } }, [handleVerification, verificationInProgress, emailVerified]); 使用 URLSearchParams 解析 location.search,获取查询参数中的 token 在满足以下条件时调用 handleVerification: URL 中存在 token 当前未在进行验证 邮箱尚未验证成功 传入的 token 不等于当前用户的 verificationToken 调用 handleVerification 处理邮箱验证 // 处理验证成功后的跳转useEffect(() => { if (emailVerified && !isLoading) { const redirectTimer = setTimeout(() => { navigate('/login'); }, 1500); return () => clearTimeout(redirectTimer); } }, [emailVerified, isLoading, navigate]); 当 emailVerified 为 true 且 isLoading 为 false 时,触发跳转逻辑 使用 setTimeout 在 1.5 秒后执行 navigate('/login'),给用户留出视觉反馈时间 在组件卸载或依赖更新时,通过 clearTimeout 清除定时器,避免潜在内存泄漏或多余跳转 最后,我们根据不同的验证状态渲染相应的界面: return ( <div className="min-h-screen flex items-center justify-center bg-base-200"> <div className="card w-96 bg-base-100 shadow-xl"> <div className="card-body items-center text-center"> <h2 className="card-title mb-4">验证电子邮件</h2> {/* 加载中状态 */} { isLoading && ( <div className="flex flex-col items-center gap-4"> <span className="loading loading-spinner loading-lg" /> <p>正在验证您的电子邮件...</p> </div> )} {/* 令牌过期状态 */} { !isLoading && verificationError === EmailVerificationError.TOKEN_EXPIRED && ( <div className="flex flex-col gap-4"> <div className="alert alert-warning"> <svg>...</svg> <span>{error}</span> </div> <button className="btn btn-primary" onClick={handleResendVerification} disabled={isLoading} > 重新发送验证邮件 </button> </div> )} {/* 已验证状态 */} {!isLoading && emailVerified && ( <div className="alert alert-success"> <svg>...</svg> <span>邮箱验证成功!即将跳转到登录页面...</span> </div> )} </div> </div> </div>); 4.1.3. 邮件验证逻辑 上面说到了几个我们并没有写的方法:verifyEmail 和 resendVerificationEmail。 现在我们要在 actions.ts 中完善这两个方法。 先在文件开头位置导入 types.ts 中新增的内容: import { User, UserState, LoginCredentials, RegisterCredentials, VerificationResponse } from './types'; const createUserSlice: StateCreator<UserState> = (set) => ({ // ... emailVerified: false, verificationError: undefined, verificationUserId: undefined, verificationInProgress: false, verificationToken: undefined, // ...}); verifyEmail 方法: verifyEmail: async (token: string): Promise<VerificationResponse> => { // 下面继续...}, verifyEmail 方法的目的在于确保邮箱有效且记录验证状态。 首先为了避免用户重复提交相同的 token、导致不必要的 API 调用,我们需要检查当前的状态: 若 verificationInProgress 为 true,那么就提示用户「验证正在进行中」 若 emailVerified 为 true 且 verificationToken 匹配,那么直接返回成功信息,避免重复请求 const state = useUserStore.getState(); if (state.verificationInProgress || (state.verificationToken === token && state.emailVerified)) { return { success: state.emailVerified, message: state.emailVerified ? '邮箱已验证' : '验证正在进行中' }; } 进入验证流程前,我们需要确保应用的状态是明确的,并为用户显示验证的进度: set({ isLoading: true, error: null, verificationInProgress: true, verificationToken: token }); 验证邮箱地址需要服务端支持,因此发送带 token 的 API 请求进行校验(我这里设计的服务端 API 是要 GET 的): try { const response = await api.get(`/users/verify-email?token=${token}`); const data = response.data; // 下面继续...} 验证成功后,更新状态以记录邮箱已验证,并清理其他临时状态: try { // ... if (data.success) { set({ isLoading: false, error: null, emailVerified: true, verificationError: undefined, verificationUserId: undefined, verificationInProgress: false }); }} 如果验证失败,那就保存失败信息、供用户查看,并允许用户再次尝试: try { // ... else { set({ isLoading: false, error: data.message, emailVerified: false, verificationError: data.error, verificationUserId: data.userId, verificationInProgress: false }); }} 当然,API 请求也有可能失败,我们需要显示错误提示、避免影响用户体验: catch (error) { const errorMessage = error instanceof Error ? error.message : '验证邮箱地址的过程中发生意外错误'; set({ isLoading: false, error: errorMessage, emailVerified: false, verificationInProgress: false }); return { success: false, message: errorMessage }; } resendVerificationEmail 方法 和 verifyEmail 基本上差不多: resendVerificationEmail: async (userId: string) => { set({ isLoading: true, error: null }); try { const response = await api.post<VerificationResponse>(`/users/resend-verification/${userId}`); const data = response.data; set({ isLoading: false, error: data.success ? null : data.message }); return data; } catch (error) { const errorMessage = error instanceof Error ? error.message : '重新发送验证邮件失败'; set({ isLoading: false, error: errorMessage }); throw error; } }, 4.2. 后端实现 在实现用户注册和邮箱验证功能时,我们通常会面临以下几个关键问题: 如何确保用户提供的邮箱是真实有效的? 如何防止垃圾注册和恶意用户? 如何安全地管理用户的验证状态? 4.2.1. 用户实体扩展 为了支持邮箱验证功能,我们要在 Users 实体中添加以下字段: @Column({ default: false })verified: boolean;@Column({ nullable: true })verificationToken: string;@Column({ nullable: true })verificationTokenExpires: Date; 这三个新增字段解决了邮箱验证的核心需求: verified:标记用户是否已验证邮箱 verificationToken:存储唯一的验证令牌 verificationTokenExpires:设置令牌的过期时间 4.2.2. 用户服务中的邮箱验证逻辑 先在 users.service.ts 的上方引入我们需要的内容: import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';import { v4 as uuidv4 } from 'uuid';import { EmailService } from '../email/email.service';import winstonLogger from '../loggers/winston.logger';export enum EmailVerificationError { TOKEN_INVALID = 'TOKEN_INVALID', TOKEN_EXPIRED = 'TOKEN_EXPIRED', ALREADY_VERIFIED = 'ALREADY_VERIFIED'}interface VerificationResponse { success: boolean; message: string; error?: EmailVerificationError; userId?: string;} @Injectable()export class UsersService { private readonly logger = winstonLogger; constructor( @InjectRepository(Users) private usersRepository: Repository<Users>, private emailService: EmailService ) {} // ...} create 方法用于创建一个用户。当一个用户尝试注册自己时,系统应当通过以下步骤确保注册过程的安全性和可靠性: 检查用户是否已存在,防止重复注册带来的冲突和逻辑错误: async create(user: CreateUserDto): Promise<{ id: string; email: string }> { const existingUser = await this.usersRepository.findOne({ where: [{ email: user.email }, { username: user.username }] }); if (existingUser) { this.logger.warn(`用户名为 ${user.username} 或者邮箱地址为 ${user.email} 的用户已存在`); throw new ConflictException('用户名或邮箱已存在'); } // 下面继续...} 对用户密码进行加密后,生成验证令牌: const hashedPassword = await bcrypt.hash(user.password, 10);const verificationToken = uuidv4();this.logger.debug('验证码已生成:' + verificationToken); 结合用户信息和生成的安全数据,创建一个完整的用户对象: const newUser = this.usersRepository.create({ ...user, password: hashedPassword, verificationToken, verificationTokenExpires: new Date(Date.now() + 24 * 60 * 60 * 1000), verified: false});this.logger.info('用户已被保存:' + JSON.stringify(newUser)); 将新用户记录写入数据库,完成用户创建: const savedUser = await this.usersRepository.save(newUser);this.logger.debug('保存的用户:' + JSON.stringify(savedUser)); 发送验证邮件: await this.emailService.sendVerificationEmail(savedUser); 最终返回精简信息,避免返回敏感数据: return { id: savedUser.id, email: savedUser.email}; verifyEmail 方法用于验证用户邮箱。当用户点击验证链接时,系统通过该方法完成验证流程。此方法通过检查令牌的有效性和状态,确保邮箱验证的安全性和可靠性: 根据提供的 token 查找用户,验证请求是否有效: async verifyEmail(token: string): Promise<VerificationResponse> { this.logger.debug(`接收到的 token:${token}`); const user = await this.usersRepository.findOne({ where: { verificationToken: token } }); if (!user) { this.logger.debug(`验证失败:未找到对应 token 的用户,token:${token}`); return { success: false, message: '无效的验证链接', error: EmailVerificationError.TOKEN_INVALID }; } this.logger.debug( `找到用户:${user.email}, 验证状态:${user.verified}, token 过期时间:${user.verificationTokenExpires}` ); // 下面继续...} 检查用户的验证状态。这里会有多个条件: 用户是否已验证: if (user.verified) { this.logger.debug(`验证失败:用户 ${user.email} 已经验证过了`); return { success: false, message: '邮箱已经验证过了', error: EmailVerificationError.ALREADY_VERIFIED };} 验证令牌是否过期: if (user.verificationTokenExpires < new Date()) { this.logger.debug( `验证失败:token 已过期, 用户: ${user.email}, ` + `过期时间:${user.verificationTokenExpires}, 当前时间:${new Date()}` ); return { success: false, message: '验证链接已过期,请重新发送验证邮件', error: EmailVerificationError.TOKEN_EXPIRED, userId: user.id };} 更新用户验证状态,将 verified 属性设置为 true 并保存到数据库: user.verified = true;try { await this.usersRepository.save(user); this.logger.debug(`用户 ${user.email} 验证成功,已更新验证状态`);} catch (error) { this.logger.error(`更新用户验证状态失败:${error.message}`, error.stack); throw error;} 返回成功信息,告诉调用方邮箱验证完成: this.logger.debug(`验证流程完成,用户 ${user.email} 验证成功`);return { success: true, message: '邮箱验证成功'}; resendVerificationEmail 方法用于重新发送验证邮件。当用户请求重发验证邮件时,此方法处理生成新的验证令牌并发送邮件的整个流程,确保未验证用户能够完成邮箱验证: 通过 userId 查询目标用户,确保用户存在: async resendVerificationEmail(userId: string): Promise<VerificationResponse> { try { const user = await this.findById(userId); this.logger.debug(`找到用户:${user.email}, 当前验证状态:${user.verified}`); // 下面继续... }} 检查用户的验证状态;用户是否已验证: if (user.verified) { this.logger.debug(`重发失败:用户 ${user.email} 已经验证过了`); return { success: false, message: '邮箱已经验证过了', error: EmailVerificationError.ALREADY_VERIFIED };} 生成新的验证令牌,确保安全性,并更新过期时间: const oldToken = user.verificationToken;user.verificationToken = uuidv4();user.verificationTokenExpires = new Date(Date.now() + 24 * 60 * 60 * 1000);this.logger.debug( `更新验证信息:用户:${user.email}, ` + `旧 token:${oldToken}, ` + `新 token:${user.verificationToken}, ` + `过期时间:${user.verificationTokenExpires}`); 保存用户数据: await this.usersRepository.save(user);this.logger.debug(`用户验证信息已更新:${user.email}`); 发送验证邮件: await this.emailService.sendVerificationEmail(user);this.logger.debug(`验证邮件已重发至:${user.email}`); 返回成功信息: return { success: true, message: '验证邮件已重新发送'}; 如果出现异常,就得记录错误日志、返回失败消息: catch (error) { this.logger.error(`重发验证邮件失败:userId=${userId}, error=${error.message}`, error.stack); return { success: false, message: '重新发送验证邮件失败', error: EmailVerificationError.TOKEN_INVALID };} 4.2.3. 用户控制器新增接口 我们需要以下两个接口: GET /verify-email:通过查询参数接收验证 token,完成用户邮箱验证 POST /resend-verification/:id:接收用户 id,重新发送验证邮件 @ApiOperation({ summary: '验证用户邮箱' })@ApiQuery({ name: 'token', description: '验证token' })@ApiResponse({ status: HttpStatus.OK, description: '邮箱验证成功' })@HttpCode(HttpStatus.OK)@Get('verify-email')async verifyEmail(@Query('token') token: string): Promise<{ message: string }> { return this.usersService.verifyEmail(token);}@ApiOperation({ summary: '重新发送验证邮件' })@ApiParam({ name: 'id', description: '用户ID' })@ApiResponse({ status: HttpStatus.OK, description: '验证邮件已重新发送' })@HttpCode(HttpStatus.OK)@Post('resend-verification/:id')async resendVerification(@Param('id') id: string): Promise<{ message: string }> { return this.usersService.resendVerificationEmail(id);} 关于 HttpStatus 和 @HttpCode,请见 这里。 4.2.4. 开发邮件模块 安装邮件模块 @nestjs-modules/mailer: yarn add @nestjs-modules/mailer 在 src 目录下创建 email 目录,并在内创建 email.module.ts: import { Module } from '@nestjs/common';import { MailerModule } from '@nestjs-modules/mailer';import { ConfigService } from '@nestjs/config';import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';import { join } from 'path';import { EmailService } from './email.service';@Module({ imports: [ MailerModule.forRootAsync({ useFactory: async (configService: ConfigService) => { const transport = { host: configService.getOrThrow<string>('SMTP_HOST'), port: configService.getOrThrow<number>('SMTP_PORT'), secure: false, auth: { user: configService.getOrThrow<string>('SMTP_USER'), pass: configService.getOrThrow<string>('SMTP_PASS') } }; return { transport, defaults: { from: `"Shopping Nest" <${configService.getOrThrow<string>('SMTP_FROM_ADDRESS')}>` }, template: { dir: join(__dirname, 'templates'), adapter: new HandlebarsAdapter(), options: { strict: true } } }; }, inject: [ConfigService] }) ], providers: [EmailService], exports: [EmailService]})export class EmailModule {} 我们需要在 .env 里新增几个值: SMTP_HOST=SMTP_PORT=SMTP_USER=SMTP_PASS=SMTP_FROM_ADDRESS= 这些是常见的 SMTP(Simple Mail Transfer Protocol)配置参数,用于设置邮件服务器的基本信息以发送电子邮件。 SMTP_HOST:邮件服务器的主机地址,通常是邮件服务提供商的域名或 IP 地址(例如用 Gmail 的话就是写 smtp.gmail.com) SMTP_PORT:邮件服务器使用的端口号,用于建立与邮件服务器的连接 常用端口: 587:用于明文连接后升级为加密连接(STARTTLS) 465:用于加密连接(SSL / TLS) 25:传统的 SMTP 端口,可能被 ISP 限制 587 或 465 是现代邮件服务中最常见的选择 SMTP_USER:用于身份验证的用户名,通常是发送邮件的邮箱地址 SMTP_PASS:与 SMTP_USER 对应的密码,用于 SMTP 身份验证(一些服务 —— 如 Gmail—— 可能要求使用应用专用密码而不是账户密码) SMTP_FROM_ADDRESS:邮件发送的来源地址,显示为邮件的 From 字段,通常是一个经过认证的邮箱地址 no-reply@yourdomain.com(用于自动邮件) support@yourdomain.com(用于支持邮件) 有些服务可能要求 SMTP_FROM_ADDRESS 必须与 SMTP_USER 保持一致(我使用的 MailGun 就是如此) Handlebars 是一个简单但强大的模板语言,允许我们通过 {{变量}} 语法轻松插入动态内容。它支持部分模板(Partials),让我们可以模块化地构建电子邮件模板。 部分模版示例: <header class="email-header"> <div class="logo"> <img src="{{ base64Image }}" alt="Logo" style="max-height: 50px;"> </div></header> 我们希望邮件的内容是动态生成的,并且邮件的布局或样式能够灵活调整,那么就需要结合模板引擎来渲染邮件内容。 接着创建 email.service.ts,它会封装邮件发送的所有复杂逻辑: 导入模块并初始化: import { Injectable } from '@nestjs/common';import { ConfigService } from '@nestjs/config';import { MailerService } from '@nestjs-modules/mailer';import { readFileSync, existsSync } from 'fs';import { join, resolve } from 'path';import { execSync } from 'child_process';import * as HandleBars from 'handlebars';import { Users } from '../entities/users.entity';import winstonLogger from '../loggers/winston.logger';@Injectable()export class EmailService { private readonly logger = winstonLogger; private readonly templatesDir: string; private readonly partialsDir: string; // 下面继续...} 在构造函数中注入 ConfigService 和 MailerService。同时设置模板目录和部分模板目录的路径: constructor( private configService: ConfigService, private mailerService: MailerService ) { this.templatesDir = resolve(__dirname, 'templates'); this.partialsDir = resolve(this.templatesDir, 'partials'); } 初始化模块: async onModuleInit() { this.ensureTemplatesExist(); await this.registerPartials();} onModuleInit 方法会在模块初始化时被调用 ensureTemplatesExist 方法应当是检查模板是否存在,如果不存在则通过执行命令 `yarn copy-templates 来复制模板文件 registerPartials 方法则用于注册模板中的部分文件,确保这些部分模板可以在主模板中引用 确保模板文件存在: private ensureTemplatesExist() { if (!existsSync(this.templatesDir)) { this.logger.warn('dist 中无法找到 templates,复制中...'); execSync('yarn copy-templates'); }} 该方法检查模板文件夹是否存在。如果文件夹不存在,则执行命令将模板文件复制过来。 这确保了在构建后的 dist 文件夹中也能找到模板文件。 这需要在 package.json 里定义一条命令: "scripts": { // ... "copy-templates": "copyfiles -u 3 src/email/templates/**/* dist/email/templates"} copyfiles 用法: 安装 copyfiles 为开发依赖(或者直接全局安装): yarn add -D copyfiles 如果是复制 src 下的所有 .js 文件到 dist,且保留目录结构: yarn copyfiles 'src/**/*.js' dist/ 不保留目录结构: yarn copyfiles -f 'src/**/*.js' dist/ copyfiles -u 3 src/email/templates/**/* dist/email/templates 的含义是将 src/email/templates/ 文件夹下的所有文件和子文件夹复制到 dist/email/templates/,并通过 -u 3 参数调整复制时的目标路径结构。 其中 -u 3 表示忽略源路径中从末尾数起的 3 个目录层级,也就是不包含这些层级到目标路径中。 ensureTemplatesExist 方法解决了 NestJS 项目中、构建过程不会自动复制静态资源文件的问题。 当执行 yarn build 时,TypeScript 编译器只处理 .ts 文件。静态模板和资源不会自动复制到 dist 目录。 注册部分模板: private async registerPartials() { const partials = [ { name: 'header', file: 'header.hbs' }, { name: 'footer', file: 'footer.hbs' }, { name: 'styles', file: 'styles.hbs' } ]; for (const partial of partials) { const partialPath = join(this.partialsDir, partial.file); if (!existsSync(partialPath)) { this.logger.error(`部分模板文件不存在:${partialPath}`); throw new Error(`找不到部分模板文件:${partial.file}`); } try { const template = readFileSync(partialPath, 'utf8'); HandleBars.registerPartial(partial.name, template); this.logger.debug(`成功注册部分模板:${partial.name}`); } catch (error) { this.logger.error(`注册部分模板失败:${partial.name}`, { error: error.message, stack: error.stack, path: partialPath }); throw error; } }} 在模板中,通常会有一些公共部分(如页头、页脚、样式等),这些部分可以通过 Handlebars 的 registerPartial 方法来注册,之后可以在主模板中调用。 这里的三个 .hbs 文件会在后面完善。 编译模板: private async compileTemplate(templateName: string, context: any): Promise<string> { const templatePath = join(this.templatesDir, `${templateName}.hbs`); if (!existsSync(templatePath)) { this.logger.error(`模板文件不存在:${templatePath}`); throw new Error(`找不到模板文件:${templateName}.hbs`); } try { const templateContent = readFileSync(templatePath, 'utf8'); const template = HandleBars.compile(templateContent); return template(context); } catch (error) { this.logger.error('编译模板失败', { error: error.message, stack: error.stack, template: templateName, path: templatePath }); throw error; }} compileTemplate 方法用于读取模板文件并将其编译为 HTML 内容。它通过 Handlebars.compile 将模板文件内容编译为渲染函数,然后使用传入的 context 对象(包含动态内容)来渲染模板。 发送验证邮件: async sendVerificationEmail(user: Users) { const logoPath = join(__dirname, '../assets/ShoppingNest.png'); this.logger.debug('Logo Path:' + logoPath); const logoBase64 = readFileSync(logoPath).toString('base64'); const logoMimeType = 'image/png'; const base64Image = `data:${logoMimeType};base64,${logoBase64}`; const frontendUrl = this.configService.get<string>('CORS_ORIGIN'); const verificationUrl = `${frontendUrl}/verify-email?token=${user.verificationToken}`; try { const html = await this.compileTemplate('verification', { username: user.username, verificationUrl, expiresIn: '24小时', year: new Date().getFullYear(), base64Image }); await this.mailerService.sendMail({ to: user.email, subject: '验证你的邮箱地址', html }); this.logger.info(`验证邮件已成功发送至 ${user.email}`); } catch (error) { this.logger.error('发送验证邮件失败', { error: error.message, stack: error.stack, user: user.email }); throw new Error(`发送验证邮件失败:${error.message}`); }} 首先,读取图片文件并将其转换为 base64 格式,之后拼接验证链接,并使用 Handlebars 模板渲染邮件内容。 最后调用 MailerService.sendMail 发送邮件。 图片位置被我写在了 src/assets 目录中。但是它和 src/email/templates 目录一样,有着不会被自动复制到 dist 目录的问题。 解决方法也很简单,同样在 package.json 中定义一个命令: "scripts": { // ... "copy-assets": "copyfiles -u 2 src/assets/**/* dist/assets"} 然后在 app.service.ts 被初始化时调用(也可以在 email.service.ts 中调用,只是我认为 src/assets 内的静态文件并不是仅被邮件模块使用): async onModuleInit() { this.ensureAssetsExist();}private ensureAssetsExist() { const assetsPath = resolve(__dirname, 'assets'); if (!existsSync(assetsPath)) { this.logger.warn('dist 中无法找到 assets,复制中...'); execSync('yarn copy-assets'); }} 现在我们可以开始写验证邮件的实际内容了: 在 src/email 目录下创建 templates 目录,并创建 verification.hbs,作为验证邮件的主体模板: <!DOCTYPE html><html lang="zh"><head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>验证你的邮箱地址</title> <style> {{> styles}} </style></head><body>{{> header}}<div class="email-content"> <h2>您好,{{username}}!</h2> <p>感谢您注册我们的服务。请点击下面的按钮验证您的邮箱地址:</p> <div class="button-container"> <a href="{{verificationUrl}}" class="verify-button">验证邮箱</a> </div> <p>如果上面的按钮无法点击,请复制以下链接到浏览器地址栏:</p> <p class="verification-url">{{verificationUrl}}</p> <p>此验证链接将在{{expiresIn}}后过期。</p> <p>如果您没有注册我们的服务,请忽略此邮件。</p></div>{{> footer}}</body></html> 这里使用了三个动态内容插值: {{username}}:动态填充用户名称 {{verification}}:用户唯一的验证链接 {{expiresIn}}:链接有效期提示 在 src/email/templates 目录下创建 partials 目录,并在内创建 header.hbs: <header class="email-header"><div class="logo"> <img src="{{ base64Image }}" alt="Logo" style="max-height: 50px;"></div></header> 这里的 style 可以根据自己的 Logo 图片进行调整。 其实还有一种方法,是在前端项目中的 public 目录里放 Logo 图片,然后这里直接调用 前端链接/图片文件名称.图片文件类型。 但是这意味着如果前端项目是在本地环境运行的,图片链接便无效。 在 src/email/templates/partials 目录下创建 footer.hbs: <footer class="email-footer"> <p>© {{year}} 你的公司名称. 保留所有权利。</p> <p>此邮件由系统自动发送,请勿回复。</p></footer> 这里可以自行修改。 在 src/email/templates/partials 目录下创建 styles.hbs: body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; background-color: #f9f9f9;}.email-header { text-align: center; padding: 20px; background-color: #ffffff; border-bottom: 1px solid #eee;}.email-content { max-width: 600px; margin: 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);}.button-container { text-align: center; margin: 30px 0;}.verify-button { display: inline-block; padding: 12px 24px; background-color: #4CAF50; color: #ffffff; text-decoration: none; border-radius: 5px; font-weight: bold; transition: background-color 0.3s;}.verify-button:hover { background-color: #45a049;}.verification-url { word-break: break-all; padding: 10px; background-color: #f5f5f5; border-radius: 4px; font-family: monospace;}.email-footer { text-align: center; padding: 20px; color: #666; font-size: 0.9em;} 同样可以自行修改。 最后便是在各个模块中进行引用: users.module.ts: import { Module } from '@nestjs/common';import { TypeOrmModule } from '@nestjs/typeorm';import { UsersController } from './users.controller';import { UsersService } from './users.service';import { Users } from '../entities/users.entity';import { EmailModule } from '../email/email.module';@Module({ imports: [TypeOrmModule.forFeature([Users]), EmailModule], controllers: [UsersController], providers: [UsersService], exports: [UsersService]})export class UsersModule {} app.module.ts: // ...import { EmailModule } from './email/email.module';@Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, validationSchema: Joi.object({ // ... CORS_ORIGIN: Joi.string().required(), // SMTP 配置 SMTP_HOST: Joi.string().required(), SMTP_PORT: Joi.number().required(), SMTP_USER: Joi.string().required(), SMTP_PASS: Joi.string().required(), SMTP_FROM_ADDRESS: Joi.string().required() }) }), // ... EmailModule ]}) 5. 其他 5.1. 修改 http-exception.filter.ts import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';import winstonLogger from '../loggers/winston.logger';@Catch()export class HttpExceptionFilter implements ExceptionFilter { private readonly logger = winstonLogger; catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const request = ctx.getRequest(); const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; const errorResponse = { statusCode: status, timestamp: new Date().toISOString(), path: request.url, message: exception instanceof HttpException ? exception.getResponse() : 'Internal server error' }; // 用 winstonLogger 输出异常 if (exception instanceof HttpException) { this.logger.warn(`HTTP 异常:${JSON.stringify(errorResponse)}`); } else { this.logger.error(`未处理异常:${exception}`); } response.status(status).json(errorResponse); }} 5.2. 修改 Swagger 标签 从 @ApiResponse({ status: 200, description: 'xxx' }) 统统修改为 @ApiResponse({ status: HttpStatus.OK, description: 'xxx' })@HttpCode(HttpStatus.OK) 状态码本身是数字,对于大多数人而言并不直观、需要对其有较深的理解。若需要统一调整状态码(如规范化状态码的使用),代码中可能需要逐一查找和替换。更何况状态码本身也更容易被误写为其他数字,难以自动检测错误。 而枚举值提供了清晰的语义,直接描述了状态码的含义,因此更容易理解。当需要统一调整时,可以直接通过 IDE 的自动补全功能快速选择合适的状态码。 以下为对应: HTTP 状态码HttpStatus 枚举值 200OK 201CREATED 204NO_CONTENT 400BAD_REQUEST 401UNAUTHORIZED 404NOT_FOUND 409CONFLICT 500INTERNAL_SERVER_ERROR 5.3. 清空数据库中所有表的内容 在开发和测试中,尤其是运行集成测试或重置开发环境时,可能需要快速清空数据库中的所有表数据。 我根据 MySQL 写了一个清空所有表数据的脚本,确保测试数据或旧数据被完全移除,便于后续的干净测试或初始化操作。 import dataSource from '../config/data-source';async function deleteAllData() { try { await dataSource.initialize(); console.log('已连接到数据库'); const entityMetadatas = dataSource.entityMetadatas; const totalTables = entityMetadatas.length; await dataSource.query('BEGIN'); await dataSource.query('SET FOREIGN_KEY_CHECKS = 0'); for (let i = 0; i < totalTables; i++) { const entityMetadata = entityMetadatas[i]; const tableName = entityMetadata.tableName; const startTime = Date.now(); try { console.log(`清空表:${tableName}`); await dataSource.query(`TRUNCATE TABLE \`${tableName}\``); const elapsedTime = (Date.now() - startTime) / 1000; console.log(`清空表 (${i + 1}/${totalTables}): ${tableName} 完成,耗时: ${elapsedTime.toFixed(2)} 秒`); } catch (error) { console.error(`清空表 ${tableName} 时报错:`, error); } } await dataSource.query('COMMIT'); console.log('所有表都被清空'); } catch (error) { console.error('删除数据期间报错:', error); await dataSource.query('ROLLBACK'); } finally { await dataSource.query('SET FOREIGN_KEY_CHECKS = 1'); await dataSource.destroy(); }}deleteAllData().catch(error => { console.error('删除数据期间报错:', error);}); 可以在 package.json 中添加以下命令来使用: "scripts": { // ... "delete-all-data": "ts-node src/database/delete-all-data.ts"}

2024/11/28
articleCard.readMore

React + NestJS 购物平台练习【3】数据设计与实现

搭建完前端与后端的基础结构后,我们就可以开始着手于数据库设计了。 1. 设计基础实体 在设计我们的购物平台的过程中,实体及其关系是核心的数据模型。我们从业务逻辑的角度来看,每个实体的意义和作用,这样可以更好地理解其在系统中的角色。 这里,我们以「用户」、「商品」、「订单」等为主线,讲解如何建立这些实体以及它们之间的关系,并举例说明其在业务场景中的应用。 1.1. 用户 用户实体是系统的核心,因为大部分操作都需要绑定到用户。每个用户都有其唯一的 ID、用户名、邮箱和密码,这些信息用于身份验证和授权。 示例,一个用户 John Doe 创建了账号,并通过邮箱 johndoe@example.com 登录系统。数据库会在 Users 表中新增一条记录,保存 John 的邮箱、加密密码和创建时间。 1.1.1. 代码实现分析 让我们深入分析 Users 实体的代码实现: import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany, OneToOne} from 'typeorm';@Entity()export class Users { @PrimaryGeneratedColumn('uuid') id: string; @Column({ unique: true }) username: string; @Column({ unique: true }) email: string; @Column() password: string; @CreateDateColumn() created_at: Date; @UpdateDateColumn() updated_at: Date; // ...} 这部分使用了 TypeORM 的装饰器来定义表结构: @Entity() 装饰器将这个类标记为一个数据库实体 @PrimaryGeneratedColumn('uuid') 表示自动生成 UUID 作为主键 @Column({ unique: true }) 为用户名和邮箱添加了唯一性约束,防止重复注册 @CreateDateColumn() 会自动记录实体的创建时间 @UpdateDateColumn() 会自动记录实体的更新时间 用户表的字段及其业务含义如下: id:主键,用于唯一标识每个用户,类型为 UUID,确保安全性和唯一性 username:用户名,系统中用于显示的名称,通常在用户之间是唯一的 email:用户的邮箱,通常作为主要联系手段,同时也是登录的标识之一 password:用户密码,以加密方式存储,用于用户验证 created_at 和 updated_at:创建时间和更新时间,记录用户注册和资料更新的时间 1.1.2. 关联关系分析 Users 实体与其他实体之间建立了多个关联关系: import { Orders } from './orders.entity';import { Carts } from './carts.entity';import { Addresses } from './addresses.entity';import { Payments } from './payments.entity';@Entity()export class Users { // ... @OneToMany(() => Orders, order => order.user) orders: Orders[]; @OneToOne(() => Carts, cart => cart.user) cart: Carts; @OneToMany(() => Addresses, address => address.user) addresses: Addresses[]; @OneToMany(() => Payments, payment => payment.user) payments: Payments[];} 这些关联展示了用户实体在系统中的核心地位: 用户 - 订单关系(@OneToMany) 一个用户可以有多个订单 这种一对多的关系允许系统追踪用户的所有购买历史 用户 - 购物车关系(@OneToOne) 每个用户只能有一个购物车 一对一的关系确保购物车数据的独立性和安全性 用户 - 地址关系(@OneToMany) 用户可以保存多个收货地址 方便用户在下单时快速选择收货地址 用户 - 支付关系(@OneToMany) 记录用户的所有支付记录 用于追踪交易历史和财务统计 1.1.3. 用户角色 在开发应用程序时,管理用户的权限和访问控制是至关重要的。这是因为不同类型的用户可能需要访问系统的不同功能。 例如,一个购物平台可能有以下几种角色: 管理员:可以管理用户、查看所有订单、编辑商品等 普通用户:只能查看商品、下订单、查看个人资料等 游客:仅限浏览,不进行任何交互 在这种场景下,我们需要为用户管理系统添加一个角色字段,以便区分不同的用户类型。 角色字段通常有助于完成以下任务: 不同权限管理:每种角色都有不同的权限。管理员可能有权访问系统的所有数据,而普通用户只能查看他们自己的数据。通过角色字段,我们可以在数据库中存储每个用户的角色信息 角色控制的用户界面:角色字段可以帮助系统为不同角色的用户显示不同的页面或功能。例如,管理员可以访问管理控制台,而普通用户只能看到他们的订单历史 安全性增强:通过角色字段,系统可以在后端进行权限验证,确保不同角色的用户只能执行允许他们执行的操作,避免了恶意操作或权限滥用 为了明确区分不同角色的值,通常我们会使用枚举(enum)来为角色提供固定的值。 enum 是一种特殊的类,用来表示一组固定的常量。 通过将 role 字段设置为枚举类型,我们可以确保角色值仅限于我们定义的固定值,这些值会自动被 TypeORM 映射到数据库字段中。 // 添加角色的枚举类型export enum UserRole { ADMIN = 'admin', USER = 'user', GUEST = 'guest',}@Entity()export class Users { // ... // 在 Users 实体中添加 role 字段 @Column({ type: 'enum', enum: UserRole, default: UserRole.USER, // 默认值为普通用户 }) role: UserRole;} 1.2. 商品 商品实体是平台中与交易直接相关的核心实体,它承载了商品的基本信息、库存管理、定价等重要功能。一个设计良好的商品实体不仅要满足基本的展示需求,还要支持库存管理、订单处理等复杂业务场景。 商品表的作用是提供系统中可供购买的商品清单,记录商品价格和库存。在库存管理方面,当库存减少到零时,商品需要标记为「缺货」或「下架」。 示例,商家上架了一款新商品「智能手表」,库存数量为 100,售价为 200 元,商品类别为「电子产品」。这款商品的信息会存储在 Products 表中,供用户选择和购买。 1.2.1. 代码实现分析 让我们详细分析 Products 实体的代码实现: import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, OneToMany} from 'typeorm';@Entity()export class Products { @PrimaryGeneratedColumn('uuid') id: string; @Column() name: string; @Column('text') description: string; @Column('decimal') price: number; @Column() stock: number; @CreateDateColumn() created_at: Date; @UpdateDateColumn() updated_at: Date; // ...} 代码实现的特点: 使用 text 类型存储描述,支持长文本内容 使用 decimal 类型存储价格,避免浮点数计算误差 stock 字段直接使用普通的数值类型,便于进行库存相关的计算 商品表的字段及其业务含义如下: id:主键,用于唯一标识每个商品,类型为 UUID name:商品名称,帮助用户识别商品 description:商品描述,用于展示商品的详细信息 price:商品价格,定义用户在购买该商品时的成本 stock:商品库存数量,代表当前商品的剩余数量 created_at 和 updated_at:创建时间和更新时间,记录商品上架和更新的时间 1.2.2. 关联关系分析 Products 实体与其他实体建立了多个关联关系: import { Categories } from './categories.entity';import { OrderItems } from './order-items.entity';import { CartItems } from './cart-items.entity';import { InventoryLogs } from './inventory-logs.entity';@Entity()export class Products { // ... @ManyToOne(() => Categories, category => category.products) category: Categories; @OneToMany(() => OrderItems, orderItem => orderItem.product) orderItems: OrderItems[]; @OneToMany(() => CartItems, cartItem => cartItem.product) cartItems: CartItems[]; @OneToMany(() => InventoryLogs, inventoryLog => inventoryLog.product) inventoryLogs: InventoryLogs[];} 商品 - 类别关系(@ManyToOne) 一个商品属于一个类别 支持商品分类管理和分类检索 多对一的关系使得类别可以包含多个商品 商品 - 订单项关系(@OneToMany) 一个商品可以出现在多个订单中 通过 OrderItems 中间表存储具体的购买数量和价格 支持订单历史查询和销售统计 商品 - 购物车项关系(@OneToMany) 一个商品可以被加入多个用户的购物车 通过 CartItems 中间表记录购物车中的商品数量 商品 - 库存日志关系(@OneToMany) 记录商品库存变动历史 支持库存追踪和审计 1.3. 类别 类别实体是商品分类的基础,它帮助我们组织和管理商品,提供更好的浏览和检索体验。一个良好的类别系统能够帮助用户更快地找到所需商品,同时也便于商家进行商品管理。 当平台增加新类别「智能家居」时,它会被添加到 Categories 表中。用户在浏览智能家居产品时,只需点击此类别,即可看到所有相关商品。 1.3.1. 代码实现分析 import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';@Entity()export class Categories { @PrimaryGeneratedColumn('uuid') id: string; @Column() name: string; @Column('text') description: string; @CreateDateColumn() created_at: Date; @UpdateDateColumn() updated_at: Date; // ...} 类别表的字段及其业务含义如下: id:主键,唯一标识类别,类型为 UUID name:类别名称,例如「电子产品」或「家具」 description:类别描述,进一步解释类别内容 created_at 和 updated_at:创建和更新时间,记录类别的管理信息 1.3.2. 关联关系分析 Categories 实体主要与 Products 实体建立了一对多的关联关系: import { Products } from './products.entity';@Entity()export class Categories { // ... @OneToMany(() => Products, product => product.category) products: Products[];} 类别 - 商品关系(@OneToMany) 一个类别可以包含多个商品 体现了分类组织的层次结构 支持按类别查询和统计商品 1.4. 订单 订单是电商系统中最核心的业务实体之一,它记录了交易的全过程,连接了用户、商品、支付等多个业务环节。一个完善的订单系统需要处理订单状态流转、支付流程、商品库存等复杂的业务场景。 当用户下单购买一部智能手表,总价为 200 元,订单状态为「待支付」。在用户支付成功后,订单状态更新为「已支付」。 1.4.1. 代码实现分析 import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, OneToMany} from 'typeorm';@Entity()export class Orders { @PrimaryGeneratedColumn('uuid') id: string; @Column('decimal') total_price: number; @Column() status: string; @CreateDateColumn() created_at: Date; @UpdateDateColumn() updated_at: Date; // ...} id:订单的唯一标识符,使用 UUID 类型 total_price:订单总价,使用 decimal 类型确保精确计算 status:订单状态,用于追踪订单的处理流程 created_at 和 updated_at:记录订单的创建和更新时间 1.4.2. 关联关系分析 Orders 实体与其他实体建立了多个关联关系: import { Users } from './users.entity';import { OrderItems } from './order-items.entity';@Entity()export class Orders { // ... @ManyToOne(() => Users, user => user.orders) user: Users; @OneToMany(() => OrderItems, orderItem => orderItem.order) orderItems: OrderItems[];} 这些关联关系支持了完整的订单业务流程: 订单 - 用户关系(@ManyToOne) 每个订单必须属于一个用户 支持用户订单历史查询 便于进行用户消费分析 订单 - 订单项关系(@OneToMany) 一个订单可以包含多个商品 通过 OrderItems 存储具体的商品信息和数量 支持订单明细查询和统计 1.5. 订单项 订单项实体记录了订单中每个商品的具体购买信息,包括数量、单价和总价等。它不仅连接了订单和商品,还保存了购买时的价格快照,这对于订单历史记录和财务核算都很重要。 1.5.1. 代码实现分析 import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm';@Entity()export class OrderItems { @PrimaryGeneratedColumn('uuid') id: string; @Column() quantity: number; @Column('decimal') price: number; @Column('decimal') total_price: number; // ...} id:订单项的唯一标识符,使用 UUID 类型 quantity:购买数量 price:商品单价的快照,使用 decimal 类型 total_price:该项商品的总价,使用 decimal 类型 1.5.2. 关联关系分析 OrderItems 实体与其他实体建立了多个多对一的关联关系: 订单项 - 订单关系(@ManyToOne) @ManyToOne(() => Orders, order => order.orderItems)order: Orders; 多个订单项属于同一个订单 通过这个关系可以获取订单的完整商品清单 支持订单总价计算和商品统计 订单项 - 商品关系(@ManyToOne) @ManyToOne(() => Products, product => product.orderItems)product: Products; 记录该订单项对应的具体商品 支持商品销售统计和分析 可以追踪商品的购买历史 1.6. 购物车 每个用户都有一个购物车,用于暂存待购买的商品信息。 购物车实体记录了用户在线上选择和加入的商品,是下单前的临时存储。它与订单实体有着类似的结构,但服务于不同的业务场景。 1.6.1. 代码实现分析 import { Entity, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToOne, OneToMany } from 'typeorm';import { Users } from './users.entity';import { CartItems } from './cart-items.entity';@Entity()export class Carts { @PrimaryGeneratedColumn('uuid') id: string; @CreateDateColumn() created_at: Date; @UpdateDateColumn() updated_at: Date; // ...} 代码实现的特点: 使用一对一关系与用户实体关联,确保每个用户只有一个购物车 包含基本的时间戳字段,跟踪购物车的创建和更新 购物车表的字段及其业务含义如下: id:购物车的唯一标识符,使用 UUID 类型 created_at 和 updated_at:记录购物车的创建和更新时间 1.6.2. 关联关系分析 Carts 实体与其他实体建立了以下关联关系: 购物车 - 用户关系(@OneToOne) @OneToOne(() => Users, user => user.cart)user: Users; 购物车 - 购物车项关系(@OneToMany) @OneToMany(() => CartItems, cartItem => cartItem.cart)cartItems: CartItems[]; 一个购物车可以包含多个购物车项 通过这个关系可以获取购物车中的商品列表 1.7. 购物车项 购物车项实体记录了用户在购物车中选择的商品及其数量,它与订单项实体有着类似的结构。 用户在购物车中添加了两部智能手表,购物车项记录该商品 ID、数量为 2,以及关联的购物车 ID。 1.7.1. 代码实现分析 import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm';@Entity()export class CartItems { @PrimaryGeneratedColumn('uuid') id: string; @Column() quantity: number; // ...} CartItems 实体的特点: 使用多对一关系连接购物车和商品实体 记录了商品的购买数量,用于计算购物车总价 1.7.2. 关联关系分析 购物车项 - 购物车关系(@ManyToOne) @ManyToOne(() => Carts, cart => cart.cartItems)cart: Carts; 购物车项 - 商品关系(@ManyToOne) @ManyToOne(() => Products, product => product.cartItems)product: Products; 1.8. 支付 支付实体记录了用户在完成下单后的支付信息,包括支付金额、支付状态等。它与订单实体有着直接的关联关系。 1.8.1. 代码实现分析 import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne } from 'typeorm';@Entity()export class Payments { @PrimaryGeneratedColumn('uuid') id: string; @Column('decimal') amount: number; @Column() status: string; @CreateDateColumn() created_at: Date; // ...} Payments 实体的特点: 使用多对一关系连接订单和用户实体 记录了支付的金额和状态 包含支付的创建时间戳 1.8.2. 关联关系分析 支付 - 订单关系(@ManyToOne) @ManyToOne(() => Orders, order => order.id)order: Orders; 一笔订单可以有多个支付记录 通过这个关系可以获取支付所属的订单信息 支付 - 用户关系(@ManyToOne) @ManyToOne(() => Users, user => user.payments)user: Users; 一个用户可以有多笔支付记录 通过这个关系可以获取支付所属的用户信息 1.9. 地址 地址实体记录了用户的收货地址信息,包括街道、城市、州 / 省、邮编和国家等详细信息。这些信息在用户下单时需要用到,也可以用于生成发货标签等。 1.9.1. 代码实现分析 import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne } from 'typeorm';@Entity()export class Addresses { @PrimaryGeneratedColumn('uuid') id: string; @Column() street: string; @Column() city: string; @Column() state: string; @Column() postal_code: string; @Column() country: string; @CreateDateColumn() created_at: Date; @UpdateDateColumn() updated_at: Date; // ...} 1.9.2. 关联关系分析 地址 - 用户关系(@ManyToOne) @ManyToOne(() => Users, user => user.addresses)user: Users; 一个用户可以有多个地址信息 通过这个关系可以获取地址所属的用户信息 1.10. 库存日志 库存日志实体记录了商品库存的变动历史,包括每次库存增减的数量和时间。这些记录对于库存管理、库存盘点和异常排查都非常重要。 1.10.1. 代码实现分析 import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne } from 'typeorm';@Entity()export class InventoryLogs { @PrimaryGeneratedColumn('uuid') id: string; @Column() change: number; @CreateDateColumn() created_at: Date; // ...} InventoryLogs 实体的特点: 使用多对一关系与商品实体关联 记录了库存变动数量(change),正数表示入库,负数表示出库 包含时间戳,记录每次库存变动的具体时间点 1.10.2. 关联关系分析 库存日志 - 商品关系(@ManyToOne) @ManyToOne(() => Products, product => product.inventoryLogs)product: Products; 一个商品可以有多条库存变动记录 通过这个关系可以追踪特定商品的库存变动历史 2. 创建数据库迁移文件 在开发应用程序时,数据库模式的更新是一项常见而重要的工作,尤其是在应用不断演进的过程中。 假设我们在开发一个应用,并需要更新数据库的表结构,比如添加新字段、修改字段类型等。如何在不丢失现有数据的情况下进行这些修改呢? TypeORM 可以帮助我们轻松处理数据库操作。它内置了强大的迁移功能,使我们能够定义数据库结构变更并自动应用到数据库中。 2.1. 添加自定义命令 首先,我们在 package.json 中添加一些脚本来管理 TypeORM 的迁移操作。这些脚本将自动构建项目并运行相应的迁移命令,方便我们快速执行迁移操作: { "scripts": { // ... "typeorm": "typeorm-ts-node-commonjs -d src/config/data-source.ts", "migration:initialize": "yarn build && yarn typeorm migration:generate src/migrations/InitialMigration", "migration:generate": "yarn build && yarn typeorm migration:generate", "migration:run": "yarn build && yarn typeorm migration:run", "migration:revert": "yarn build && yarn typeorm migration:revert" }} 让我们一行一行地分析这些脚本的作用: typeorm:指定 TypeORM 的配置文件路径(src/config/data-source.ts),它是所有 TypeORM 操作的基础 migration:initialize:生成 InitialMigration 迁移文件 migration:generate:生成新的迁移文件 用法:yarn migration:generate src/migrations/xxx migration:run:执行所有还未运行的迁移,应用到数据库中 migration:revert:撤销上一次迁移,通常用于回滚数据库结构到上一个状态 2.2. 配置 TypeORM 数据源 接下来,我们需要设置 TypeORM 的数据库配置。 在项目的 src/config/data-source.ts 文件中,我们使用 DataSourceOptions 来定义数据库连接的详细信息。 这里我们还使用了 @nestjs/config 模块来动态读取环境变量,这样在不同环境中可以使用不同的数据库配置。 import { ConfigService } from '@nestjs/config';import { DataSource, DataSourceOptions } from 'typeorm';import { config } from 'dotenv';config();const configService = new ConfigService();const dataSourceOptions: DataSourceOptions = { type: configService.get<any>('DB_TYPE', 'mysql'), host: configService.get<string>('DB_HOST'), port: configService.get<number>('DB_PORT'), username: configService.get<string>('DB_USER'), password: configService.get<string>('DB_PASSWORD'), database: configService.get<string>('DB_NAME'), synchronize: configService.get<string>('APP_ENV') === 'development', entities: ['dist/entities/*.entity{.ts,.js}'], migrations: ['dist/migrations/*{.ts,.js}'], subscribers: []};export default new DataSource(dataSourceOptions); type:指定数据库类型,这里我们通过环境变量 DB_TYPE 来设置,默认为 MySQL host、port、username、password、database:这些参数是数据库连接的必要信息,都可以通过环境变量配置 synchronize:如果 APP_ENV 为 development,则会启用同步模式,让 TypeORM 自动更新数据库表结构 注意:生产环境下建议禁用此项 entities 和 migrations:指定实体和迁移文件的路径。TypeORM 会使用这些路径找到相关文件 2.3. 生成数据库迁移文件 当配置完成后,我们可以通过以下命令来生成用于初始化数据库的迁移文件: yarn migration:initialize 这条命令会自动构建项目,并使用 TypeORM 生成一个迁移文件(位于 src/migrations,且默认命名为 数字数字数字-InitialMigration.ts)。 这个文件会包含创建、修改或删除表结构的 SQL 语句。例如,如果你添加了一个新的字段,生成的迁移文件就会包含一个 ALTER TABLE 语句。 2.4. 执行迁移 生成迁移文件后,使用以下命令将其应用到数据库: yarn migration:run 此命令会检查并执行所有尚未在数据库中应用的迁移。这样可以确保你的数据库结构与最新的迁移文件一致。 2.5. 回滚迁移 在开发过程中,偶尔可能需要回滚上次迁移,例如在测试或调试时。使用以下命令可以撤销上一次迁移: yarn migration:revert TypeORM 会自动识别上次执行的迁移,并撤销相应的更改,恢复到之前的数据库结构。 3. 设置数据库索引 在开发应用程序时,尤其是当数据库中的数据量越来越大时,查询性能变得至关重要。 数据库索引是一种数据结构,它帮助数据库管理系统(DBMS)高效地检索数据。索引就像书籍中的目录,它可以快速定位到某个数据的位置,而不是扫描整个表。当我们对数据库表进行查询时,索引可以显著提高查询性能,尤其是在处理大量数据时。 在 MySQL 中,索引通常应用于那些需要频繁查询的字段。常见的索引类型有: 主键索引:自动为表的主键字段创建 唯一索引:确保每个值唯一 普通索引:加速数据检索,但不强制唯一性 全文索引:用于加速文本内容的检索 没有索引的情况下,数据库在执行查询时必须扫描整个表,逐行比较数据。这种方式在小型表中可能没什么问题,但在数据量较大时,会导致性能急剧下降。 假设我们的购物平台,数据库中存储了数百万条订单记录。当用户搜索特定订单时,如果没有适当的索引,系统可能会扫描整个订单表来找到匹配的记录。随着数据量增加,查询速度变得非常慢,甚至可能导致系统响应延迟。 使用索引后,数据库不再需要扫描整个表,而是通过索引快速定位到目标数据,从而显著提升查询速度。 在 NestJS 中,我们可以通过 TypeORM 为实体字段添加索引。TypeORM 提供了 @Index() 装饰器,允许我们在数据库表的字段上添加索引。 3.1. 用户 @Index()@CreateDateColumn()created_at: Date; 在 Users 实体中,我们使用了 @Index() 装饰器来为 created_at 字段添加索引。 在大多数应用程序中,created_at 字段通常用于按照时间排序或进行时间范围查询。 例如,用户可能需要查看某一时间段内的所有注册用户,或者获取最近创建的用户列表。 假设在应用程序中,用户经常执行以下查询: 查询某个时间段内注册的用户。 按照注册时间排序显示用户列表。 如果没有为 created_at 字段添加索引,数据库会需要扫描整个用户表来执行这些查询,这样会导致性能问题。添加索引后,数据库可以通过索引快速查找和排序数据,显著提高查询速度,尤其是在数据量较大的情况下。 3.1.1. 关系字段为何不需要索引? 这时候就有人问了:「难道像 orders、cart 这些字段,用户就不会查询了吗?如果经常用到,为什么不为它们加个索引呢?」答案是:确实不需要。 在 TypeORM 中,关系字段(比如 orders 和 cart)通常是外键字段,数据库会自动为这些字段创建索引。例如,在 Users 实体中,orders 字段是通过 @OneToMany 与 Orders 实体关联的。虽然我们没有显式地为 orders 字段添加索引,但在 Orders 表中,user 字段作为外键会自动被索引。 这意味着,当我们查询某个用户的所有订单时,数据库会直接利用 Orders 表中自动创建的 user 索引,来加速查询。而我们不需要额外为 orders 字段添加索引,TypeORM 和数据库已经为我们处理好了这部分的优化。 3.2. 商品 @Index()@Column('decimal')price: number; price 字段通常会用于以下几种查询: 按照价格范围查询产品,例如查找价格低于某个值的所有产品 按照价格排序,例如将产品列表按价格从低到高或从高到低排序 @Index()@Column()stock: number; stock 字段通常用于以下查询场景: 查找库存数量为零或低于某个值的产品(例如「缺货」产品或「库存预警」产品) 按照库存数量排序,帮助商家快速查看库存最少的产品 @Index()@CreateDateColumn()created_at: Date; 所有的 created_at 字段都需要添加索引。 3.3. 支付 @Index()@CreateDateColumn()created_at: Date; 3.4. 订单 @Index()@Column()status: string; status 字段通常用于查询订单的状态,例如: 查询某一状态的订单(如「待处理」、「已发货」、「已完成」等) 按照订单状态统计订单数量 @Index()@CreateDateColumn()created_at: Date; 3.5. 订单项 @Index()@Column()quantity: number; quantity 字段通常在以下情况中用于查询: 查询具有特定数量的订单项(例如查询某种商品的批量购买情况) 按照数量对订单项进行筛选或排序 3.6. 类别 @Index()@Column()name: string; name 字段是分类的唯一标识,用于以下情况: 按类别名称搜索产品类别(例如,用户希望快速找到特定名称的类别) 在名称字段上进行排序或进行自动完成的匹配查询 @Index()@CreateDateColumn()created_at: Date; 3.7. 购物车 @Index()@CreateDateColumn()created_at: Date; 3.8. 地址 @Index()@Column()postal_code: string; 在地址系统中,postal_code(邮政编码)字段可能用于以下场景: 按邮政编码筛选地址。例如,在电商系统中,按邮政编码筛选用户地址,以确定是否支持配送 邮政编码的批量统计或区域分析,例如确定特定邮政编码区域的用户密度 @Index()@CreateDateColumn()created_at: Date; 3.9. 库存日志 @Index()@CreateDateColumn()created_at: Date; 接下来,我们可以使用以下命令生成数据库迁移文件: yarn migration:generate src/migrations/CreateIndex 然后使用以下命令应用迁移并更新数据库: yarn migration:run

2024/11/7
articleCard.readMore

React + NestJS 购物平台练习【2】后端项目框架搭建

搭建基础的 NestJS 项目框架,包括以下内容: 初始化 NestJS 项目 配置环境变量 配置数据库连接 配置 Swagger 文档 设置基础中间件 配置日志系统 1. 初始化 NestJS 项目 1.1. 安装 NestJS CLI 工具 全局安装 NestJS 的 CLI 工具: yarn global add @nestjs/cli 确保全局安装的包路径已被添加到环境变量 PATH 中,否则无法在终端中使用 nest 命令。 可以使用以下命令查看 Yarn 的全局包路径: yarn global bin 将输出的路径添加到 PATH 中。 1.2. 创建新的 NestJS 项目 执行以下命令,创建一个名为 shopping-nest-server 的新项目: nest new shopping-nest-server 运行后,NestJS CLI 会提示选择包管理工具,选择 Yarn,等待其自动安装所需的依赖。 项目生成后,NestJS CLI 默认会在根目录下生成一个 test 目录和一些单元测试文件(.spec.ts)。我暂时不需要测试,所以删去了这些内容。 NestJS CLI 创建的 NestJS 项目会自带 ESLint 和 Prettier,我们只需要将 上一章 配置好的 .prettierrc 复制过来、确保前端后端都遵循相同的代码规范即可: { "printWidth": 120, "tabWidth": 2, "useTabs": false, "semi": true, "singleQuote": true, "trailingComma": "none", "bracketSpacing":true, "bracketSameLine": true, "arrowParens": "avoid"} 2. 配置环境变量和配置文件 在实际配置前,我们先来考虑一下,什么是需要配置的: 应用基本配置 APP_PORT:定义应用监听的端口号 APP_ENV:表示当前的应用环境。应用将根据环境做出一些环境特定的设置,比方说日志的详细级别 核心数据库配置 数据库是应用的核心部分,用于存储应用的持久化数据。 DB_TYPE:数据库的类型。我选择使用 mysql 或者 postgres DB_HOST、DB_PORT、DB_USER、DB_PASSWORD、DB_NAME:这些参数确保应用能连接到正确的数据库实例。不同环境中的数据库配置往往不尽相同,开发环境中可能连接到本地数据库、生产环境中可能连接到远程数据库 缓存配置 缓存系统是提升应用性能的重要手段。 Redis 是一个高效的内存缓存数据库,常用于缓存频繁访问的数据,从而减轻数据库负载、提升响应速度。 REDIS_HOST、REDIS_PORT:让应用访问正确的 Redis 实例 REDIS_PASSWORD 部分 Redis 服务需要密码验证,通过密码可以保障缓存数据的安全 REDIS_DB:指定 Redis 数据库编号,有助于在同一 Redis 实例中隔离不同用途的数据 Elasticsearch 配置 Elasticsearch 是一个分布式搜索和分析引擎,适用于海量数据的全文检索和分析。 ELASTICSEARCH_HOST、ELASTICSEARCH_PORT:定义了 Elasticsearch 服务的位置和端口 ELASTICSEARCH_USERNAME、ELASTICSEARCH_PASSWORD:通过用户名和密码的方式实现对 Elasticsearch 集群的访问控制 ELASTICSEARCH_INDEX:定义应用使用的索引。索引类似于数据库中的表,是 Elasticsearch 存储和查询数据的基本单位 文件存储配置 对于需要上传文件(如用户头像、商品图片等)的应用来说,文件存储服务是不可或缺的。 很多应用会选择云存储服务(如 Amazon S3、Aliyun OSS)或者本地可用的 MinIO 来满足存储需求。 STORAGE_ENDPOINT:指定文件存储服务的 API 端点,便于与远程存储服务连接 STORAGE_ACCESS_KEY 和 STORAGE_SECRET_KEY:用于认证的密钥对,保障文件存储服务的安全访问 STORAGE_BUCKET、STORAGE_REGION:定义存储的目标存储桶和区域位置,以便更合理地管理文件资源,减少延迟 STORAGE_USE_SSL:配置是否启用 HTTPS,以增强数据传输的安全性 JWT 配置 JWT 用于实现用户身份验证和会话管理,是一种轻量的认证方式。 在过去的 React + NestJS + SocketIO 教程文章 中,我们已经讲过了 JWT,感兴趣的可以看看。 JWT_SECRET:用于签发和验证 JWT 的密钥,确保身份认证的安全性。设计一个足够强度的密钥并保持其私密性至关重要 JWT_TOKEN_AUDIENCE:指定 JWT 的受众,即令牌面向的服务或应用。设置受众可以帮助确保令牌仅被指定应用使用,提高认证的安全性 JWT_TOKEN_ISSUER:用于声明 JWT 的发布者,一般设置为认证服务器的标识,确保 JWT 的来源是可信的 JWT_ACCESS_TOKEN_TTL:JWT 访问令牌的有效时间,单位为秒。合理设置过期时间既能提升安全性(防止会话过长导致会话劫持风险),又可避免用户频繁重新登录带来的不便 2.1. 安装必要依赖 首先安装 @nestjs/config、dotenv 以及数据库驱动和 TypeORM,以支持加载环境变量、进行数据库连接和配置。 yarn add @nestjs/configyarn add dotenv -Dyarn add @nestjs/typeorm typeorm mysql2 # 替换 mysql2 为 pg 以使用 PostgreSQL @nestjs/config 是 NestJS 提供的官方配置模块,专为加载、管理和验证环境变量而设计 dotenv 是配置模块的底层依赖,通过 .env 文件加载环境变量 TypeORM 是一个 TypeScript 支持的 ORM(对象关系映射),能够与关系型数据库集成 2.2. 创建环境变量文件 在项目根目录下创建 .env 文件: # 应用APP_PORT=4000APP_ENV=development# 核心数据库DB_TYPE=mysqlDB_HOST=localhostDB_PORT=3306DB_USER=用户名DB_PASSWORD=密码DB_NAME=数据库名称# Redis 缓存REDIS_HOST=localhostREDIS_PORT=6379REDIS_PASSWORD=密码REDIS_DB=0# ElasticsearchELASTICSEARCH_HOST=localhostELASTICSEARCH_PORT=9200ELASTICSEARCH_USERNAME=用户名ELASTICSEARCH_PASSWORD=密码ELASTICSEARCH_INDEX=products# 文件存储STORAGE_ENDPOINT=STORAGE_ACCESS_KEY=access_key_idSTORAGE_SECRET_KEY=secret_access_keySTORAGE_BUCKET=桶名STORAGE_REGION=STORAGE_USE_SSL=true# JWTJWT_SECRET=secretJWT_TOKEN_AUDIENCE=localhost:4000JWT_TOKEN_ISSUER=localhost:4000JWT_ACCESS_TOKEN_TTL=3600 这里的环境变量覆盖了不同模块所需的配置项,确保各模块配置的灵活性。 2.3. 引入配置模块和验证 在 AppModule 中配置 @nestjs/config,使用 Joi 对环境变量进行验证,确保每个变量都满足格式要求: import { Module } from '@nestjs/common';import { ConfigModule } from '@nestjs/config';import { AppController } from './app.controller';import { AppService } from './app.service';import { DatabaseModule } from './database/database.module';import * as Joi from 'joi';@Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, // 让 ConfigModule 全局可用 validationSchema: Joi.object({ APP_PORT: Joi.number().default(4000), APP_ENV: Joi.string().valid('development', 'production', 'test').default('development'), // 核心数据库 DB_TYPE: Joi.string().valid('mysql', 'postgres').default('mysql'), DB_HOST: Joi.string().required(), DB_PORT: Joi.number().default(3306), DB_USER: Joi.string().required(), DB_PASSWORD: Joi.string().required(), DB_NAME: Joi.string().required(), // Redis 缓存 REDIS_HOST: Joi.string().required(), REDIS_PORT: Joi.number().default(6379), REDIS_PASSWORD: Joi.string().allow(''), REDIS_DB: Joi.number().default(0), // Elasticsearch ELASTICSEARCH_HOST: Joi.string().required(), ELASTICSEARCH_PORT: Joi.number().default(9200), ELASTICSEARCH_USER: Joi.string().required(), ELASTICSEARCH_PASSWORD: Joi.string().required(), ELASTICSEARCH_INDEX: Joi.string().required(), // 文件存储 STORAGE_ENDPOINT: Joi.string().allow(''), STORAGE_ACCESS_KEY: Joi.string().required(), STORAGE_SECRET_KEY: Joi.string().required(), STORAGE_BUCKET: Joi.string().required(), STORAGE_REGION: Joi.string().allow(''), STORAGE_USE_SSL: Joi.boolean().default(true), // JWT JWT_SECRET: Joi.string().required(), JWT_TOKEN_AUDIENCE: Joi.string().required(), JWT_TOKEN_ISSUER: Joi.string().required(), JWT_ACCESS_TOKEN_TTL: Joi.number().default(3600) }) }), DatabaseModule ], controllers: [AppController], providers: [AppService]})export class AppModule {} isGlobal:将配置模块设为全局模块,避免在其他模块中重复引入 validationSchema:通过 Joi 验证环境变量的值,确保值类型与业务需求匹配;例如 DB_HOST 需要是字符串,APP_PORT 应为数值,且数据库和 JWT 密钥都必须存在 Joi 是一个 JavaScript 数据验证库,通常用来确保应用中的数据符合特定的规则或格式。 2.3.1. 基本用法 Joi 提供了一个简单的链式 API 来定义验证规则。验证的流程一般是:定义 schema(验证规则) -> 验证数据 -> 获取结果或错误。 const Joi = require('joi');// 定义 schemaconst schema = Joi.object({ name: Joi.string().min(3).max(30).required(), age: Joi.number().integer().min(0).max(120), email: Joi.string().email()});// 验证数据const result = schema.validate({ name: 'Alice', age: 25, email: 'alice@example.com' });// 检查结果if (result.error) { console.log(result.error.details);} else { console.log(result.value);} 2.3.2. 基本类型验证 Joi 支持的基本类型包括:string、number、boolean、array、object 等。每种类型可以组合其他规则,如最小/最大值、必填/选填、格式限制等。 字符串验证 Joi.string() // 定义为字符串类型Joi.string().min(3) // 最小长度Joi.string().max(30) // 最大长度Joi.string().email() // 必须是电子邮箱格式Joi.string().regex(/^[a-zA-Z0-9]{3,30}$/) // 使用正则表达式验证格式 数字验证 Joi.number() // 定义为数字类型Joi.number().integer() // 必须是整数Joi.number().min(0) // 最小值Joi.number().max(100) // 最大值Joi.number().positive() // 必须是正数Joi.number().negative() // 必须是负数 布尔值验证 Joi.boolean() // 定义为布尔类型 数组验证 Joi.array() // 定义为数组类型Joi.array().items(Joi.number()) // 数组中每项都必须是数字Joi.array().min(1).max(5) // 数组长度限制Joi.array().unique() // 数组中的每个元素必须唯一 对象验证 Joi.object({ username: Joi.string().required(), password: Joi.string().min(8).required(),}) 2.3.3. 条件验证 条件验证允许定义复杂的规则,如基于字段值的条件或逻辑分支。 when 条件验证: const schema = Joi.object({ password: Joi.string().min(8).required(), confirmPassword: Joi.string().valid(Joi.ref('password')).when('password', { is: Joi.exist(), // 如果 password 存在…… then: Joi.required(), // ……confirmPassword 也是必填 otherwise: Joi.forbidden() // 否则不允许出现 confirmPassword })}); 2.3.4. 嵌套对象和数组验证 Joi 允许定义嵌套结构,如对象嵌套和数组嵌套。 嵌套对象 const schema = Joi.object({ user: Joi.object({ name: Joi.string().required(), age: Joi.number().min(0) }).required()}); 嵌套数组 const schema = Joi.array().items( Joi.object({ id: Joi.number().required(), name: Joi.string().required() })); 2.3.5. 自定义验证器 Joi 支持自定义验证函数,用于更复杂的场景。 const schema = Joi.string().custom((value, helpers) => { if (!/^[a-zA-Z]+$/.test(value)) { return helpers.error('any.invalid'); // 返回一个自定义错误 } return value; // 验证通过}, 'Custom alphabet validation'); 2.3.6. 配合 NestJS 使用 在 NestJS 中,可以结合 @nestjs/config 模块来使用 Joi 验证配置文件。 import { Module } from '@nestjs/common';import { ConfigModule } from '@nestjs/config';import * as Joi from 'joi';@Module({ imports: [ ConfigModule.forRoot({ validationSchema: Joi.object({ // 假设有核心数据库的配置 DB_HOST: Joi.string().required(), DB_PORT: Joi.number().default(5432), }) }) ],})export class AppModule {} 2.3.7. 错误处理与自定义错误消息 Joi 会在验证失败时返回详细的错误信息,可以自定义错误消息。 const schema = Joi.object({ name: Joi.string().min(3).required().messages({ 'string.base': `"name" should be a type of 'text'`, 'string.empty': `"name" cannot be an empty field`, 'string.min': `"name" should have a minimum length of {#limit}`, 'any.required': `"name" is a required field` })}); 2.4. 使用配置 作为一个使用 ConfigService 的例子,我们将从 .env 中读取 APP_PORT 的值,并将其作为应用的启动端口。 import { NestFactory } from '@nestjs/core';import { ConfigService } from '@nestjs/config';import { AppModule } from './app.module';async function bootstrap() { const app = await NestFactory.create(AppModule); const configService = app.get(ConfigService); const port = configService.get<number>('APP_PORT') || 4000; await app.listen(port);}bootstrap(); 运行 nest start 来查看是否有任何问题。 3. 设置数据库连接 首先,确保自己的本机环境中有安装 MySQL 或者 PostgreSQL。 安装教程请自行在网上搜索,本篇文档将只会使用 8.0.40 MySQL Community。 挖坑:未来可能会添加支持其他数据库的功能。 3.1. 配置 MySQL 打开 MySQL CLI(或者使用 mysql -u root -p 来进行连接) 创建数据库: CREATE DATABASE 数据库名称; 数据库名称 要匹配 .env 中的: DB_NAME=数据库名称 创建用户: CREATE USER '用户名'@'localhost' IDENTIFIED BY '密码'; 这里的内容要匹配 .env 中的这一段内容: DB_USER=用户名DB_PASSWORD=密码 设置权限: GRANT ALL PRIVILEGES ON 数据库名称.* TO '用户名'@'localhost';FLUSH PRIVILEGES; 设置完后可以测试一下: mysql -u 用户名 -p -h localhost -P 3306 数据库名称 3.2. 创建 DatabaseModule 在 NestJS 项目中,集中管理数据库连接的配置非常重要,尤其是在需要支持多种环境(如开发、测试、生产)时。 创建 DatabaseModule 能让我们将数据库的配置代码分离出来,以便在不同的环境中灵活调整配置,比如使用 ConfigService 来获取环境变量。 通过 TypeOrmModule.forRootAsync 方法,我们可以使用异步的方式配置 TypeORM。这样可以确保数据库配置在应用初始化时依赖于环境变量,如 DB_HOST、DB_USER、DB_PASSWORD 等,从而增强配置的灵活性和安全性。 import { Module } from '@nestjs/common';import { TypeOrmModule } from '@nestjs/typeorm';import { ConfigModule, ConfigService } from '@nestjs/config';@Module({ imports: [ ConfigModule, TypeOrmModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: (configService: ConfigService) => ({ type: configService.get<'mysql' | 'postgres'>('DB_TYPE'), host: configService.get<string>('DB_HOST'), port: configService.get<number>('DB_PORT'), username: configService.get<string>('DB_USER'), password: configService.get<string>('DB_PASSWORD'), database: configService.get<string>('DB_NAME'), autoLoadEntities: true, synchronize: configService.get<string>('APP_ENV') === 'development' }) }) ]})export class DatabaseModule {} ConfigService:用于从环境变量获取配置,确保 DB_TYPE 等参数的灵活性 forRootAsync:动态配置 TypeOrmModule,适用于需要依赖环境变量初始化的模块 autoLoadEntities: true:TypeORM 会自动加载应用中定义的所有实体。这让我们可以在项目中自由地添加新的实体,而不需要每次手动导入 synchronize:将其设置为 true 会在开发环境中自动同步数据库表结构,以便在本地开发时快速响应数据结构的修改。但在生产环境中,建议关闭 synchronize,以防止意外数据丢失或表结构破坏 4. 配置 Swagger 文档 在现代 Web 开发中,API 文档对于开发人员和用户来说都是至关重要的,特别是在团队协作中,清晰的 API 文档可以大大提高开发效率。 NestJS 提供了内置的 Swagger 支持,允许我们快速生成符合 OpenAPI 标准的文档,为用户提供更好的接口可视化。 OpenAPI 是一种用于描述 RESTful API 的规范,它提供了一种标准化的格式,用于定义 API 的端点、请求、响应、认证等内容。 它的前身是 Swagger 规范,因此你可能听过 Swagger 和 OpenAPI 这两个词被混用。 OpenAPI 的主要目标是使 API 设计、文档、测试和集成过程更为高效和一致。 4.1. 安装依赖 首先,我们需要安装 @nestjs/swagger 和 swagger-ui-express 两个模块。 @nestjs/swagger 提供了 NestJS 对 Swagger 的支持 swagger-ui-express 是 Swagger UI 的依赖包,用于在浏览器中显示 API 文档 在项目根目录下运行以下命令来安装它们: yarn add @nestjs/swagger swagger-ui-express 4.2. 配置 Swagger 打开 main.ts 并添加以下代码: // ...import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';async function bootstrap() { // ... const swaggerConfig = new DocumentBuilder() .setTitle('API 文档') .setDescription('Shopping-Nest 的 API 文档') .setVersion('1.0') .addBearerAuth() .build(); const document = SwaggerModule.createDocument(app, swaggerConfig); SwaggerModule.setup('api-docs', app, document); // ...} 在这里,我们使用 DocumentBuilder 来创建 Swagger 文档的基本信息。常见的配置项有: .setTitle():设置 API 文档的标题 .setDescription():提供 API 的描述信息 .setVersion():指定 API 的版本号 .addBearerAuth():如果 API 需要 JWT 认证(通常用于保护 API),可以添加 Bearer 认证支持 SwaggerModule.setup() 方法将 Swagger UI 绑定到指定的路由路径(这里是 /api-docs),之后,我们可以通过访问 http://localhost:APP_PORT/api-docs 来查看生成的文档。 4.3. 如何使用 Swagger 在我们完成 Swagger 的基础配置后,接下来的步骤将详细介绍如何利用 Swagger 注释来生成清晰的 API 文档。这一部分将涵盖如何为控制器、DTO(数据传输对象)和请求参数添加 Swagger 装饰器,以便 Swagger 能够生成准确且全面的 API 文档。 4.3.1. 为控制器添加注释 在 NestJS 中,控制器负责处理客户端请求并返回响应。我们可以使用 Swagger 提供的装饰器为控制器中的每个方法添加注释,以描述其功能、请求参数和返回结果。 import { Controller, Get, Post, Body } from '@nestjs/common';import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';import { CreateUserDto } from './dto/create-user.dto';@ApiTags('Users') // 给控制器添加标签@Controller('users')export class UserController { @Post() @ApiOperation({ summary: 'Create a new user' }) // 描述此 API 的作用 @ApiResponse({ status: 201, description: 'The user has been successfully created.' }) // 201 状态响应 @ApiResponse({ status: 400, description: 'Invalid input data.' }) // 400 状态响应 create(@Body() createUserDto: CreateUserDto) { return 'This action adds a new user'; } @Get() @ApiOperation({ summary: 'Retrieve a list of users' }) // 描述此 API 的作用 @ApiResponse({ status: 200, description: 'A list of users.' }) // 200 状态响应 getAllUsers() { return [{ id: 1, name: 'John Doe' }]; }} 4.3.2. 为 DTO 添加注释 DTO(数据传输对象)用于定义请求和响应的结构。使用 Swagger 的 @ApiProperty 装饰器,可以清晰地说明每个字段的含义和要求。 import { ApiProperty } from '@nestjs/swagger';export class CreateUserDto { @ApiProperty({ description: 'The name of the user' }) // 描述 name 字段 name: string; @ApiProperty({ description: 'The age of the user', minimum: 1 }) // 描述 age 字段并设置最小值 age: number; @ApiProperty({ description: 'The email of the user', required: true }) // 描述 email 字段 email: string;} 在上面的例子中,CreateUserDto 包含了三个属性:name、age 和 email。每个属性都使用了 @ApiProperty 装饰器来提供详细描述,并且可以设置字段的其他约束(如是否必填、类型等)。 4.3.3. 为请求参数添加注释 如果你的 API 需要接受路径参数、查询参数或请求体中的数据,Swagger 也提供了相关的装饰器来帮助描述这些参数。 import { Controller, Get, Param } from '@nestjs/common';import { ApiParam } from '@nestjs/swagger';@Controller('users')export class UserController { @Get(':id') @ApiOperation({ summary: 'Retrieve a user by ID' }) // 描述此 API 的作用 @ApiParam({ name: 'id', required: true, description: 'The ID of the user to retrieve', type: Number }) // 描述路径参数 getUserById(@Param('id') id: number) { return { id, name: 'John Doe' }; // 示例返回 }} 在这个示例中,@ApiParam 用于描述路径参数 id。它帮助用户理解这个参数是必须的,且应该是一个数字。 5. 设置基础中间件 在现代 Web 开发中,处理安全性、请求速率限制、响应压缩以及自定义日志记录是打造可靠、高效应用的基础。 NestJS 提供了简单灵活的中间件配置支持,通过整合 helmet、@nestjs/throttler、compression 等库,开发者可以轻松地实现这些功能。 5.1. 添加 CORS 支持 首先,我们需要确保应用支持跨域请求(CORS),特别是在前后端分离的情况下。以下是启用和配置 CORS 的方法: async function bootstrap() { // ... const corsOrigin = configService.get<string>('CORS_ORIGIN'); app.enableCors({ origin: corsOrigin, methods: 'GET,POST', credentials: true, }); // ...} 在 .env 中添加: CORS_ORIGIN=http://localhost:3000 使用 http://localhost:3000 是因为 React 本地环境默认运行在 localhost:3000。 5.2. 增强安全性 helmet 是一组帮助设置安全 HTTP 头的中间件,能够防范常见的 Web 攻击(例如,XSS 攻击和点击劫持)。 安装 helmet 库: yarn add helmet 开启 helmet 保护: import helmet from 'helmet';async function bootstrap() { // ... app.use(helmet()); // ...} 通过这段简单的代码,helmet 会自动添加一组常用的安全头(以下信息来自于 npm): Content-Security-Policy:一个强大的允许清单,控制页面上可以发生的操作,有助于缓解多种攻击 Cross-Origin-Opener-Policy:帮助页面实现进程隔离 Cross-Origin-Resource-Policy:阻止其他网站跨域加载您的资源 Origin-Agent-Cluster:将进程隔离改为基于源的方式 Referrer-Policy:控制 Referer 请求头 Strict-Transport-Security:告知浏览器优先使用 HTTPS X-Content-Type-Options:避免 MIME 类型嗅探 X-DNS-Prefetch-Control:控制 DNS 预取 X-Download-Options:强制将下载的文件保存到本地(仅适用于 Internet Explorer) X-Frame-Options:传统的标头,用于防范点击劫持攻击 X-Permitted-Cross-Domain-Policies:控制 Adobe 产品(如 Acrobat)的跨域行为 X-Powered-By:关于 Web 服务器的信息,已移除,以防止简单攻击利用该信息 X-XSS-Protection:传统的标头,旨在防止 XSS 攻击,但通常效果不佳,因此 Helmet 将其禁用 5.3. 压缩响应 压缩响应能够有效减少传输的数据量,提升页面加载速度。 安装 compression 库: yarn add compression 配置压缩的级别和触发条件: import * as compression from 'compression';async function bootstrap() { // ... const compressionLevel = configService.get<number>('COMPRESSION_LEVEL') || 6; const compressionThreshold = configService.get<number>('COMPRESSION_THRESHOLD') || 1024; app.use( compression({ level: compressionLevel, threshold: compressionThreshold, }) ); // ...} 在 .env 中添加: # 响应压缩COMPRESSION_LEVEL=6COMPRESSION_THRESHOLD=1024 在上面的代码中,level 设置了压缩级别(范围从 0-9,数字越大压缩越强,但 CPU 负荷越高),而 threshold 设置了触发压缩的响应体积阈值(单位为字节)。 5.4. 限制请求速率 防止滥用 API 资源是每个 Web 应用的核心需求之一。我们可以使用 @nestjs/throttler 模块对请求速率进行限制,确保服务不会被大量请求淹没。 yarn add @nestjs/throttler 我们在 AppModule 中通过 ThrottlerModule.forRootAsync 配置速率限制。利用 ConfigService 从 .env 文件中获取 ttl(时间窗口)和 limit(最大请求数)参数: import { ThrottlerModule, ThrottlerModuleOptions } from '@nestjs/throttler';@Module({ imports: [ // ... ThrottlerModule.forRootAsync({ inject: [ConfigService], useFactory: (configService: ConfigService): ThrottlerModuleOptions => [ { ttl: configService.get<number>('THROTTLE_TTL') || 60, limit: configService.get<number>('THROTTLE_LIMIT') || 10 } ] }), ],}) 在 .env 中添加: # 速率限制THROTTLE_TTL=60THROTTLE_LIMIT=10 5.4.1. 使用例子 配置后,系统会自动为所有 API 路由设置速率限制。也可以在特定控制器或路由中通过 @Throttle 装饰器进行覆盖: import { Controller, Get } from '@nestjs/common';import { Throttle } from '@nestjs/throttler';@Controller('test')export class TestController { @Throttle(5, 10) // 每 10 秒最多 5 个请求 @Get() testRoute() { return "Testing rate limiting"; }} 5.5. 自定义日志记录 为了记录请求信息,我们可以实现一个简单的 LoggerMiddleware,并在 AppModule 中配置它: import { Injectable, NestMiddleware } from '@nestjs/common';import { Request, Response, NextFunction } from 'express';@Injectable()export class LoggerMiddleware implements NestMiddleware { use(req: Request, res: Response, next: NextFunction) { console.log(`Request... ${req.method} ${req.url}`); next(); }} 在 AppModule 中使用 configure 方法应用此中间件: import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';import { LoggerMiddleware } from './middlewares/logger.middleware';@Module({ imports: [/* 其他模块 */],})export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { consumer.apply(LoggerMiddleware).forRoutes('*'); }} 这样,每次请求都会在控制台输出请求方法和 URL 路径,帮助我们跟踪请求流向和响应情况。 启动 NestJS 项目,在浏览器中访问 localhost:APP_PORT(或者默认的 localhost:4000),就能在终端中看到 Request... GET / 的字眼。 5.6. 设置全局错误处理 为了统一错误响应格式,可以创建自定义异常过滤器来捕获异常,并返回标准化的错误信息。 我们在项目中定义 HttpExceptionFilter 类,并将其注册为全局过滤器: import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';@Catch()export class HttpExceptionFilter implements ExceptionFilter { catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const request = ctx.getRequest(); const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; const errorResponse = { statusCode: status, timestamp: new Date().toISOString(), path: request.url, message: exception instanceof HttpException ? exception.getResponse() : 'Internal server error' }; response.status(status).json(errorResponse); }} 接着在 main.ts 中注册过滤器: import { HttpExceptionFilter } from './filters/http-exception.filter';async function bootstrap() { // ... app.useGlobalFilters(new HttpExceptionFilter()); // ...} 通过此过滤器,所有未处理的异常都会以标准格式返回。 6. 配置日志系统 在开发和运维中,日志记录是至关重要的一部分。通过详细的日志记录,我们可以更好地了解应用的运行状态、排查错误,甚至帮助团队进行性能优化。 在开发或生产环境中,可能会遇到以下问题: 控制台日志输出过于混乱:控制台日志输出没有明显的视觉区分,开发者难以快速找到关键信息 文件日志管理不当:日志文件没有分目录管理,日志存储时间不固定,且文件体积容易过大 日志轮转:没有对日志文件进行按日期轮换,容易导致单个日志文件过大,不利于维护 6.1. 安装依赖 我们首先需要安装 chalk、nest-winston、winston 和 winston-daily-rotate-file 这些依赖。 yarn add chalk@^4 nest-winston winston winston-daily-rotate-file 注意:由于我的 NestJS 项目使用的是 CommonJS 模块系统,和使用 ESM 的 chalk@5 不兼容,所以我采用的最简单直接的方法就是降级到 chalk@4。 6.2. 配置 winston 日志文件 在 NestJS 项目中创建一个 winston.logger.ts 文件,用于配置 winston 的日志记录选项,包括日志等级、日志格式、文件轮转等。 6.2.1. 配置日志目录 我们将设置不同的日志目录来分别存储错误日志、警告日志和常规应用日志。 使用 fs 来检查目录是否存在,不存在时自动创建: import * as fs from 'fs';import * as path from 'path';const logDirectories = ['logs/errors', 'logs/warnings', 'logs/app'];logDirectories.forEach(dir => { const fullPath = path.join(__dirname, '..', '..', dir); try { if (!fs.existsSync(fullPath)) { console.log(`Creating directory at: ${fullPath}`); fs.mkdirSync(fullPath, { recursive: true }); } } catch (error) { console.error(`Error creating directory ${fullPath}:`, error); }}); 这段代码创建了 logs/errors、logs/warnings 和 logs/app 三个目录,用于分别保存错误、警告和常规日志。 6.2.2. 定义日志颜色 接下来,通过 chalk 为不同的日志级别定义颜色。这样在控制台输出时,不同的日志级别会有明显的颜色区分: import * as chalk from 'chalk';const getChalkColor = (level: string): chalk.Chalk => { switch (level) { case 'error': return chalk.red; case 'warn': return chalk.yellow; case 'info': return chalk.green; case 'debug': return chalk.blue; case 'verbose': return chalk.cyan; default: return chalk.white; }}; 这样做的好处是,可以根据日志等级设置不同颜色,从而在控制台中快速识别重要的日志信息。 6.2.3. 配置 winston 日志选项 接下来,我们配置 winston 的核心功能,包括日志格式、日志文件轮转和控制台输出: import { createLogger, format, transports } from 'winston';import * as DailyRotateFile from 'winston-daily-rotate-file';const winstonLogger = createLogger({ format: format.combine(format.timestamp(), format.errors({ stack: true }), format.splat(), format.json()), defaultMeta: { service: 'log-service' }, transports: [ new DailyRotateFile({ filename: path.join(__dirname, '..', '..', 'logs/errors/error-%DATE%.log'), datePattern: 'YYYY-MM-DD-HH-mm-ss', zippedArchive: true, maxSize: '20m', maxFiles: '14d', level: 'error' }), new DailyRotateFile({ filename: path.join(__dirname, '..', '..', 'logs/warnings/warning-%DATE%.log'), datePattern: 'YYYY-MM-DD-HH-mm-ss', zippedArchive: true, maxSize: '20m', maxFiles: '14d', level: 'warn' }), new DailyRotateFile({ filename: path.join(__dirname, '..', '..', 'logs/app/app-%DATE%.log'), datePattern: 'YYYY-MM-DD-HH-mm-ss', zippedArchive: true, maxSize: '20m', maxFiles: '14d' }), new transports.Console({ format: format.combine( format.colorize(), format.simple(), format.printf(info => { const level = info.level.toLowerCase(); const chalkColor = getChalkColor(level); return `${chalkColor(`${info.timestamp} - ${info.level}:`)} ${info.message}`; }) ), level: 'debug' }) ]});export default winstonLogger; 这段配置实现了以下几个功能: DailyRotateFile:设置了日志文件的轮转,每个日志类型(错误、警告、应用)都将按日期命名并存储 控制台输出:控制台输出带有颜色区分,并包含时间戳和日志级别,便于快速读取 日志格式:定义了 json 格式日志输出,并包含 timestamp 和 stack 信息 6.3. 引入日志配置 在 AppModule 中通过 WinstonModule 引入 winstonLogger,使日志系统能够全局生效: import { WinstonModule } from 'nest-winston';import winstonLogger from './loggers/winston.logger';@Module({ imports: [ // ... WinstonModule.forRoot({ transports: winstonLogger.transports, format: winstonLogger.format, defaultMeta: winstonLogger.defaultMeta, exitOnError: false }) ]}); 通过 WinstonModule.forRoot() 配置,我们将之前定义的 winstonLogger 作为全局日志管理器,使 NestJS 自动将应用日志转发到 winston。 6.4. 应用日志系统 为了启用配置的日志系统,我们需要在 main.ts 中将其应用到应用程序: import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';async function bootstrap() { // ... app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER)); // ...} 这样就将 winston 配置的日志记录器注入到应用中,使日志管理器可以通过 NestJS 的日志 API 来记录日志。 6.5. 修改 LoggerMiddleware 最后,我们来修改一下 LoggerMiddleware,将其记录下每个请求的详细信息,包括: 请求方法 URL IP HTTP 版本 状态码 响应时间等 这在调试和性能分析时尤为有用。 import { Injectable, Logger, NestMiddleware } from '@nestjs/common';import { Request, Response, NextFunction } from 'express';import * as dayjs from 'dayjs';@Injectable()export class LoggerMiddleware implements NestMiddleware { private logger = new Logger(); use(req: Request, res: Response, next: NextFunction) { const start = Date.now(); const { method, originalUrl, ip, httpVersion, headers } = req; const { statusCode } = res; res.on('finish', () => { const end = Date.now(); const duration = end - start; const logFormat = `${dayjs().valueOf()} ${method} ${originalUrl} HTTP/${httpVersion} ${ip} ${statusCode} ${duration}ms ${headers['user-agent']}`; if (statusCode >= 500) { this.logger.error(logFormat, originalUrl); } else if (statusCode >= 400) { this.logger.warn(logFormat, originalUrl); } else { this.logger.log(logFormat, originalUrl); } }); next(); }} 事件监听:使用 finish 事件来确保所有响应数据都已发送 日志格式:每个请求记录的格式包括请求时间、请求方法、URL、IP、状态码、响应耗时等信息 自动区分日志级别:根据响应的状态码自动设置日志等级 错误状态码(500+)记录为 error 客户端错误(400+)记录为 warn 其他成功请求则都记录为 log

2024/11/4
articleCard.readMore

React + NestJS 购物平台练习【1】前端项目框架搭建

在现代电子商务发展迅速的今天,构建一个高效、易用的购物平台是开发者的一项关键技能。 该系列是全栈实践新坑,使用 React 和 NestJS 的技术栈、从零开始开发一个完整的购物平台(其实是先前开的几个全栈实践坑都让我意识到自己基础实力不足)。 1. 初始化 React + TypeScript 项目 使用以下命令创建 React 项目: yarn create react-app shopping-nest --template typescript 导航至 shopping-nest 目录: cd shopping-nest 使用 Yarn 安装依赖: // 安装 ESLint 和 Prettier 相关依赖yarn add @typescript-eslint/eslint-plugin @typescript-eslint/parseryarn add -D eslint prettier eslint-config-prettier eslint-plugin-prettieryarn add -D eslint-config-react-app// 安装 react-router-domyarn add react-router-dom @types/react-router-dom// 安装 axiosyarn add axios// 安装 TailwindCSS 相关依赖yarn add tailwindcss postcss autoprefixer// 安装 UI 组件和图标库yarn add @headlessui/reactyarn add lucide-react 运行 yarn run start 检查一下是否会出问题。 2. 配置 ESLint 和 Prettier 我使用的是 Jetbrains WebStorm,记得要更新到 2024 的版本喔。 ESLint 的版本为 9.13.0。 Prettier 的版本为 3.3.3。 2.1. 配置 ESLint 运行以下命令: npx eslint --init 根据自己的习惯选择。 生成的 mjs 配置文件差不多如下,我自己修改了 files 值为 src 目录下的文件。 import globals from "globals";import pluginJs from "@eslint/js";import tseslint from "typescript-eslint";import pluginReact from "eslint-plugin-react";export default [ {files: ["src/**/*.{js,mjs,cjs,ts,jsx,tsx}"]}, {languageOptions: { globals: globals.browser }}, pluginJs.configs.recommended, ...tseslint.configs.recommended, pluginReact.configs.flat.recommended,]; 可以查看 ESLint 官方文档 或者 TypeScript-ESLint 文档 自行修改。我自己就保留默认的了。 2.2. 配置 Prettier 在项目目录处创建 .prettierrc 文件一个(项目目录这里默认为 package.json 所在的目录)。 除了查看 Prettier 官方文档 自己填写外,还可以使用一些工具生成 Prettier 配置内容。 我这里用了 Prettier Config Generator 生成: { "printWidth": 120, "tabWidth": 2, "useTabs": false, "semi": true, "singleQuote": true, "trailingComma": "none", "bracketSpacing":true, "bracketSameLine": true, "arrowParens": "avoid"} 3. 配置 Tailwind CSS 3.1. 安装并初始化 TailWind CSS 配置 在项目目录下使用以下命令来生成 tailwind.config.js 和 postcss.config.js: npx tailwindcss init -p 然后修改 tailwind.config.js 的内容,将 content 配置为监控 src 文件夹下的所有文件,以便在这些文件中应用 TailWind 的样式: /** @type {import('tailwindcss').Config} */export default { content: [], theme: { extend: {}, }, plugins: []}; 在 src/index.css 文件的顶部添加以下内容,导入 TailWind 的核心样式、组件和工具: @tailwind base;@tailwind components;@tailwind utilities; 3.2. 安装并配置 daisyUI 组件库 为了方便开发,安装 daisyUI 组件库,它提供了丰富的组件和自定义主题功能: yarn add daisyui 然后在 tailwind.config.js 文件中引入 daisyUI 插件: import daisyui from "daisyui";// ...export default { // ... plugins: [ daisyui, ], daisyui: {}} 对于 daisyUI 的配置,可以根据其 文档 进行修改。 我安装 daisyUI 还有一个目的,那就是其自定义主题的功能。 在 daisyUI 的 主题生成器 里,你可以选择自己设计一套颜色方案,或者说随机出一套颜色方案。该页面中还有预览页面可供参考。 我对颜色不敏感,设计能力也很遭殃。这是我随机出的: export default { // ... daisyui: { themes: [ { mytheme: { "primary": "#60a5fa", "primary-content": "#030a15", "secondary": "#00b7ac", "secondary-content": "#000c0b", "accent": "#d68900", "accent-content": "#100700", "neutral": "#182f19", "neutral-content": "#ccd1cc", "base-100": "#32253a", "base-200": "#2a1f31", "base-300": "#221928", "base-content": "#d2cfd4", "info": "#00a7c9", "info-content": "#000a0f", "success": "#67c400", "success-content": "#040e00", "warning": "#f97316", "warning-content": "#150500", "error": "#dc2626", "error-content": "#ffd9d4", } } ] }} 3.3. 配置 PostCSS 根据 PostCSS 官方 说的: PostCSS 是一种利用 JS 插件转换样式的工具。 这些插件可以检查 CSS、支持变量和混合体、转译未来的 CSS 语法、内联图片等。 postcss.config.js 的初始配置如下: export default { plugins: { tailwindcss: {}, autoprefixer: {}, },}; 目前这是基本的配置。如果需要更多 PostCSS 功能,可以根据需求进一步配置。 4. 设置路由基础结构 路由系统是管理不同 URL 对应显示不同页面内容的机制。 4.1. 创建统一布局 作为开发者,在构建 Web 应用时,创建一个统一的布局非常重要。因为它能够为用户提供一致的界面的导航体验。 在大多数应用中,我们会有一些固定的部分,比方说导航栏、页脚,以及一个用于动态展示内容的区域。 import React from 'react';import { Outlet } from 'react-router-dom'; 首先引入 Outlet,它是 react-router-dom 提供的一个工具,允许在布局中插入不同的内容。 通过 Outlet,我们可以渲染由路由定义的组件,也就是首页啦、关于页这些,也不需要每次都重写导航和布局。 const MainLayout = () => { return ( <div> <header className="bg-primary shadow"> <nav className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> {/* TODO: 导航内容 */} </nav> </header> 用 <header> 标签来定义页面的头部,这里我之后会引入导航栏组件,先放个 TODO 马克一下。 <main> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <Outlet /> </div> </main> </div> );}; 接下来是 <main> 标签,它是页面的核心内容区域。 <Outlet /> 会根据当前路由,动态渲染不同的组件。在开发中,这个设计的好处是我们可以轻松地切换页面,并保持一致的布局框架。 4.2. 路由配置 在单页面应用,也就是 SPA 中,路由是关键。它决定了用户访问某个路径时应该显示哪个组件。 我们现在需要一个机制,让用户能够在不同页面之间切换,比如从首页切换到用户账户信息页。路由能够帮助我们将 URL 和组件相互关联,确保用户在访问特定路径时,看到对应的页面。 import { createBrowserRouter } from 'react-router-dom';import MainLayout from './layouts/MainLayout'; 首先导入 createBrowserRouter,用它可以创建一个支持浏览器历史记录的路由系统。接着我们将先前定义好的 MainLayout 引入作为根布局。 const router = createBrowserRouter([ { path: '/', element: <MainLayout /> }]); 这意味着,无论用户访问的子页面是什么,MainLayout 的结构都会保持一致,而页面主体部分会根据路由变化而动态加载。 4.3. 设置应用入口 App 组件是整个应用的入口。它负责将路由系统注入到 React 的组件树中,这样其他组件才能知道根据不同的路径应该显示什么内容。 为了加载整个路由配置,我们需要一个统一的入口,因此需要用到 RouterProvider。它将之前配置的路由传递给应用,让各个子组件能够根据 URL 做出相应的渲染。 import { RouterProvider } from 'react-router-dom';import router from './router'; 引入 RouterProvider 和先前定义好的 router 配置。 const App = () => { return <RouterProvider router={router} />;}; 用 RouterProvider 包裹住应用的根组件,并把 router 传递给它。通过这种方式,整个应用的路由系统就生效了。 虽然话是这么说,但因为内部什么组件都没写好,运行时还是什么都看不到的…… 5. 配置状态管理工具 在开发中,状态管理是前端应用的核心部分之一,尤其是在涉及到用户登录、登出、数据持久化等功能时。 Zustand 是一个轻量级的状态管理库,它相比于 Redux 等传统工具更加简洁易用。因为是个练习项目,我便选择了这个更小巧的状态管理库。 5.1. 简单的例子 5.1.1. 初始化 Zustand 状态管理器 在 src 目录下创建一个 stores 目录,用于存放状态管理相关的文件。 在本例子中,代码结构分为三部分:状态定义和处理(UserState、actions.ts),Zustand 状态创建和持久化(index.ts),以及一些辅助函数(api.ts、log.ts 和 selector.ts)。 使用以下命令安装 Zustand: yarn add zustand stores 目录下创建 index.ts,用作状态管理的入口,这样在应用的其他部分就可以方便地引入状态管理逻辑。 import useUserStore from './user'; 作为一个大致的参考,我选择去写一个用户状态。这里先引入一下这个还未开始写的自定义钩子,理想情况下,它应当允许我们访问和操作与用户相关的状态。 在整个应用中,我们将通过这个钩子获取当前用户信息或调用登录操作。 5.1.2. 定义用户状态类型和接口 stores 目录下创建 user 目录,接着又在 user 目录下创建 types.ts。 export interface User { id: string; name: string; email: string;}export interface UserState { user: User | null; isLoading: boolean; error: string | null;} TypeScript 中的 interface 用于定义用户状态的结构。使用 interface 能更直观地展示用户状态中的各项属性,同时在项目扩展时易于维护 这里定义了 User 和 UserState,这两个接口分别描述了用户对象的结构和与用户相关的状态。 当然,作为一个参考,这些值后续一定会进行修改或者扩展。 User 接口不用多说。UserState 接口包括了: user:当前登录的用户信息;没有用户登录的话就是 null isLoading:是否正在进行异步操作,比如登录请求 error:当然是错误信息啦 setUser 是一个函数属性,接收一个 User 或者 null 类型的参数 login 函数属性接收 LoginCredentials 参数,并返回一个 Promise<User>(使用 TypeScript 的时候这样写有助于检查函数的参数和返回值类型,减少类型错误) 5.1.3. 创建 Zustand 状态管理器 user 目录下创建 index.ts。 我们通过 Zustand 来创建一个用户状态管理器。 import { create } from 'zustand';import { devtools, persist } from 'zustand/middleware';import { UserState } from './types';import createUserSlice from './actions';const useUserStore = create<UserState>()( devtools( persist( createUserSlice, { name: 'user-storage' } ) ));export default useUserStore; 这里我们引入了 Zustand 的两个中间件:devtools 和 persist。 devtools 允许我们在开发时使用 Redux DevTools 进行状态调试,方便查看状态的变化 persist 实现状态的持久化,将用户状态保存在 localStorage 中(先前使用 Redux 的时候,都是要手动使用 localStorage 进行持久性保存。Zustand 则可以直接使用 persist 中间件实现状态的持久化)。这样即使用户刷新页面,用户信息依然保留 name: 'user-storage' 指定了持久化状态的存储键名 5.1.4. 定义用户状态的操作与异步行为 user 目录下创建 actions.ts,定义状态和异步操作。 import { StateCreator } from 'zustand';import { User, UserState } from './types';const createUserSlice: StateCreator<UserState> = (set) => ({ user: null, isLoading: false, error: null, setUser: (user: User | null) => set({ user }), login: async (credentials: { email: string; password: string }) => { set({ isLoading: true, error: null }); try { // TODO: 调用API set({ isLoading: false, user: response.data }); } catch (error) { set({ isLoading: false, error: error.message }); } }});export default createUserSlice; 在这个文件中,我们定义了用户状态的操作逻辑和异步操作。 对于大多数应用来说,登录是一个异步过程,我们需要在发起请求时更新 isLoading 状态,同时在请求失败时记录错误信息。 初始状态,也就是用户未登录时,user 设为 null、isLoading 为 false,error 也为空。 setUser 是一个简单的同步方法,用于手动设置用户信息。 login 是一个异步函数,用于处理登录逻辑。 开发中,典型的流程是: 设置 isLoading 为 true,以便显示加载状态 发起登录请求(因为还没写,就用 TODO 标记了。注意哈,现在这个时候跑指定报错) 请求成功后,将返回的用户信息存储到状态中,并重置 isLoading 为 false 如果请求失败,捕获错误,并更新 error 状态,用户可看到错误提示(现在当然不行) 5.2. 进阶配置 我们已经配置了 Zustand 的基本用户状态管理。接下来,我们将借助 TypeScript,进一步优化和扩展状态管理的功能,包括状态持久化、自定义中间件和选择器等。 5.2.1. 状态持久化与部分存储 在生产环境中,为了提升用户体验,状态持久化是一个常见需求。Zustand 提供了 persist 中间件,帮助我们将部分状态保存在 localStorage 或其他存储中,以确保页面刷新后状态不会丢失。 在 stores/user/index.ts 中,我们定义了 persistOptions,并在其中使用了 partialize 功能,将状态中关键的部分(如用户信息和更新时间)持久化: import { create } from 'zustand';import { devtools, persist, subscribeWithSelector } from 'zustand/middleware';import type { PersistOptions } from 'zustand/middleware';import { UserState } from './types';import createUserSlice from './actions';import { log } from '../common/log';import { createSelectors } from '../common/selector';type UserPersist = Pick<UserState, 'user' | 'lastUpdated'>;const persistOptions: PersistOptions<UserState, UserPersist> = { name: 'user-storage', partialize: (state) => ({ user: state.user, lastUpdated: state.lastUpdated, }),}; Pick<UserState, 'user' | 'lastUpdated'>:使用 Pick 类型将 UserState 中的 user 和 lastUpdated 属性挑选出来,简化了持久化的内容 PersistOptions 类型:类型声明让我们清楚地知道哪些状态会被持久化,避免错误持久化不必要的数据 partialize 是一个用于选择性地存储状态对象中部分属性的函数。在我们的 persistOptions 里,它的作用是从 UserState 状态中挑出 user 和 lastUpdated 这两个属性,并将其存储到持久化的存储中 5.2.2. 订阅特定的状态 subscribeWithSelector 允许我们订阅特定的状态属性变化。与直接订阅整个状态的变化不同,它可以细化到仅在某些具体属性更新时触发回调,从而减少不必要的订阅响应。 继续写 stores/user/index.ts: const useUserStoreBase = create<UserState>()( devtools( persist( subscribeWithSelector( log(createUserSlice), ), persistOptions, ) )); 通过组合其他的 Zustand 插件,我们创建了一个订阅机制。这样做的好处是提高性能、避免不必要的渲染。 接下来这段代码订阅了 useUserStoreBase 中的 user 属性: useUserStoreBase.subscribe( (state) => state.user, (user) => { if (user) { console.log('User logged in: ', user.name); } else { console.log('User logged out'); } })export const useUserStore = createSelectors(useUserStoreBase); (state) => state.user 是一个选择器函数,只返回 state 中的 user 属性,从而使订阅仅响应 user 的变化 当 user 属性变化时,回调触发。回调会根据 user 是否存在(如 user 为 null,或者用户登陆了新的信息)来输出不同的登录状态信息 5.2.3. 自定义日志中间件 为了方便调试,我们可以创建一个日志中间件。这个中间件会在每次状态更新时,记录状态变化信息。 在 stores/common/log.ts 中定义 log 函数,扩展 Zustand 的 set 方法,使其在应用状态变化时输出变更详情。 先写一个泛型类型,用于定义 set 函数所接收的各种更新方式: import { StateCreator } from 'zustand';type SetStateAction<T> = T | Partial<T> | ((state: T) => T | Partial<T>); SetStateAction<T> 的作用是确保状态的更新类型符合期望,允许直接提供新的状态值、部分更新或基于当前状态的更新函数 T:泛型参数,表示整个状态对象的类型,例如 UserState 类型定义: T:可以直接传入整个状态对象,用于完全替换现有状态 Partial<T>:可以传入部分状态对象,即只更新部分属性。Partial<T> 将状态对象的所有属性变为可选 (state: T) => T | Partial<T>:可以传入一个函数,这个函数接收当前状态作为参数,并返回新的状态或部分状态。这种方式允许在回调中基于现有状态动态生成更新值 这样写的目的是为了更灵活的状态更新方式,不仅可以直接替换状态,也可以部分更新或在回调函数中动态更新。 export const log = <T extends object>( config: StateCreator<T, [], [], T>): StateCreator<T, [], [], T> => (set, get, api) => config( (partial: SetStateAction<T>, replace?: boolean) => { console.log('Applying', { partial, replace }); if (replace) { set(partial as T | ((state: T) => T), true); } else { set(partial as SetStateAction<T>); } console.log('New state: ', get()); }, get, api ); log 是一个高阶函数(也就是 HOC),接受一个 Zustand 的 StateCreator 配置函数,并返回一个经过增强的 StateCreator,用于记录状态的变化。 log 的作用是对传入的 config 配置函数进行包装,以便在状态更新时打印更新的内容和更新后的状态,用于调试。 log 的内部逻辑: 参数: config:一个 Zustand 的 StateCreator 函数,负责创建状态。此函数会调用 set 函数来更新状态 返回值:一个增强的 StateCreator 函数,用于替代原始 config 函数 内部逻辑: 包装 set 函数:调用 config 时,将自定义的 set 函数传入 自定义的 set 函数接收 partial 和 replace 两个参数: partial:可以是新的状态值、部分状态值,也可以是一个返回状态的函数 replace:布尔值,表示是否完全替换现有状态 日志输出: console.log('Applying', { partial, replace }) 在更新前输出即将应用的部分状态或新状态 console.log('New state: ', get()) 在更新后输出新的 更新逻辑: 若 replace 为 true,则完全替换当前状态;否则只应用部分更新 调用 get 获取新的状态并打印日志 5.2.4. 状态选择器 在状态管理中,我们通常需要对状态进行选择,以便在不同组件中访问特定的状态字段。 createSelectors 帮助我们自动生成访问器,减少在不同组件中冗余的状态逻辑。 stores/common/selector.ts 中定义 createSelectors,它会为状态中的每个字段创建一个 getter 函数,便于状态的解耦: import { StoreApi, UseBoundStore } from 'zustand';type WithSelectors<S> = S extends { getState: () => infer T } ? S & { use: { [K in keyof T]: () => T[K] } } : never; WithSelectors<S> 定义了一个条件类型,用于增强传入的 store 类型 S S extends { getState: () => infer T } 检查 S 是否包含 getState 方法,并从中推断出 T 类型(状态对象的类型) 返回: 若 S 满足条件,则返回 S 并附加一个 use 属性 use 是一个对象,包含状态对象中每个键对应的 getter 方法,这些方法返回 T[K],即每个状态属性的值 若 S 不满足条件,则返回 never export const createSelectors = < S extends UseBoundStore<StoreApi<T>>, T extends object>(_store: S) => { const store = _store as WithSelectors<S>; store.use = {} as { [K in keyof T]: () => T[K] }; const state = store.getState(); for (const k of Object.keys(state) as Array<keyof T>) { store.use[k] = () => state[k]; } return store;} createSelectors 函数: 参数:接收一个 Zustand store 实例 _store 类型约束: S extends UseBoundStore<StoreApi<T>>:约束 S 必须是一个 UseBoundStore 类型的 store T extends object:状态对象 T 必须是一个对象 逻辑: 将传入的 store _store 进行类型转换,以便使用 WithSelectors 增强后的类型 为 store 增加 use 属性(一个空对象),作为存放每个状态属性 getter 方法的容器 获取当前 store 的 state 对象 for 循环遍历 state 对象的键(即状态对象的属性) 对每个键 k,在 store.use 中创建一个对应的 getter 方法 store.use[k],返回 state[k] 的值 返回增强后的 store 实例 store,其中包含 use 对象和对应的 getter 方法 假设 store 的状态对象如下: const useStore = createSelectors( create((set) => ({ user: { name: "Alice", age: 30 }, loggedIn: true }))); 那么调用 useStore.use.user() 就会返回: { name: "Alice", age: 30 } 5.2.5. API 请求的配置和错误处理 在前端状态管理中,一般会包含 API 请求的逻辑。 我们在 stores/user/actions 中,定义一个 createUserSlice 函数,它是 Zustand 中 UserState 的部分实现,用于管理用户相关的状态和操作。 首先导入依赖: import { StateCreator } from 'zustand';import { AxiosResponse } from 'axios';import { User, UserState, LoginCredentials } from './types';import api from '../common/api'; 定义状态和操作: const createUserSlice: StateCreator<UserState> = (set) => ({ user: null, isLoading: false, error: null, lastUpdated: null, // ... user:存储当前用户信息 isLoading:指示登录操作是否正在进行中 error:保存登录过程中发生的错误信息 lastUpdated:记录上次用户数据更新的时间戳 // ...setUser: (user: User | null) => set({ user }),// ... setUser:一个同步方法,用于直接设置 user 状态。接收一个 User 对象或者 null,并调用 set 更新状态 // ...login: async (credentials: LoginCredentials) => { set({ isLoading: true, error: null }); try { const response: AxiosResponse<User> = await api.post<User>('/auth/login', credentials); const user = response.data; set({ user, isLoading: false, lastUpdated: Date.now(), error: null }); return user; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'An unexpected error occurred during login'; set({ isLoading: false, error: errorMessage, user: null }); throw error; }},// ... login 方法: 启动加载状态:调用 set({ isLoading: true, error: null }) 将 isLoading 设置为 true,并清除之前的错误 API 请求:await api.post<User>('/auth/login', credentials) 向服务器发送登录请求。返回的 response.data 包含了用户信息 成功处理: 若请求成功,set 更新状态,存储用户数据、停止加载、设置 lastUpdated 时间戳,并清除错误 返回 user,便于在调用 login 的地方使用 错误处理: 如果请求失败,捕获 error 并生成错误消息 更新 set 将 isLoading 设置为 false,保存 error 信息,并将 user 设置为 null 抛出错误,以便调用 login 的组件也能捕获并处理该错误 // ... logout: () => { set({ user: null, error: null, lastUpdated: null }); }});export default createUserSlice; logout 方法是登出功能,说白了就是将所有的状态设置为 null,从而达到清除当前用户信息和错误的效果。 6. 设置 API 请求封装 至于 API 嘛,写在了 stores/common/api.ts 中: import axios from 'axios';const api = axios.create({ baseURL: process.env.REACT_APP_API_URL, timeout: 10000}); axios 配置了一个 API 实例 api,设置了基本的请求和响应拦截器。 baseURL 为环境变量 REACT_APP_API_URL,还设置了 10 秒的超时时间 api.interceptors.request.use( (config) => { // TODO: 添加认证信息 return config; }, (error) => Promise.reject(error)); 请求拦截器 api.interceptors.request.use 提供了请求发送前的自定义逻辑处理。可以在 config 中添加认证信息(也就是老生常谈的 JWT Authorization 头)。 如果请求在发送前就失败了,那么拦截器将直接拒绝该错误 api.interceptors.response.use( (response) => response, (error) => { // TODO: 统一错误信息 return Promise.reject(error); });export default api; 响应拦截器 api.interceptors.response.use 允许在接收到响应时进行自定义处理。 响应成功会直接返回数据 请求出错,error 就会被统一处理,然后传递给调用方处理 有很多功能先放 TODO 了,能差不多 GET 到意思就好。

2024/10/29
articleCard.readMore

MUS 1003 笔记

乐理笔记,仅作为个人学习记录,不保证正确性。 采用的教科书是国外的,里面写的所有有关「我们」的字眼并非代表我本人! 1. 音乐基础 1.1. 声音、音高、动态和音色 声音(Sound)始于物体的振动,例如被敲击的桌子或者被拨动的琴弦。振动通过一种介质(通常是空气)传递到我们的耳朵。由于振动,我们的耳膜也开始振动,并将脉冲或信号传递到大脑。在那里,这些脉冲被选择、组织和解释。 我们可以通过识别音乐声音的四个主要属性来区分音乐和其他声音: 音高(Pitch) 动态(响度或柔和度)(Dynamics) 音色(Tone color) 时长(Duration) 1.1.1. 音高:声音的高低 音高(Pitch)是我们在声音中听到的相对高低。 打个比方,我们听一下经典的生日歌,能发现最高的音调出现在第三个【生日】上。 声音的音高由其振动频率决定。振动越快,音高越高;振动越慢,音高越低。振动频率以每秒周期数来衡量。在钢琴上,最高频率的音调是每秒 4186 个周期,最低的是每秒约 27 个周期。 一般来说,振动物体越小,振动越快,音高越高。在其他条件相同的情况下,拨动短弦会比拨动长弦产生出更高的音高。比方说小提琴,它的弦对比低音贝斯的弦更短、产生的音高自然也更高。 在音乐中,具有明确音高的声音被称为音调(Tone)。它具有特定的频率,且振动是规则的、以相等的时间间隔到达耳朵。另一方面,噪音类的声音(比如说刹车声、碰撞的钹声)由于由不规则的振动产生,因此具有不确定的音高。 当两个音调具有不同的音高时,它们会听起来不同。两个音调之间的「距离」被称为音程(Interval)。当音调被称为八度(Octave)的音程分隔时,它们听起来非常相似。 八度指的是两个音之间的间隔、其中高音的频率是低音的两倍。 唱或听《Over the Rainbow》这首歌的开头:「Somewhere over the rainbow」。 注意,-where 上的音调听起来像 Some- 上的音调,尽管它更高。它们之间有一个八度。Some- 上的音调的振动频率正好是 where 上的音调的一半。如果 Some- 音调是每秒 440 个周期,那么 where 音调 —— 也就是高一个八度 —— 将是每秒 880 个周期。比 Some- 低一个八度的音调将是 440 的一半,即每秒 220 个周期。当同时发出时,相隔一个八度的两个音调融合得如此之好,以至于它们几乎似乎合并成一个音调。 八度音程在音乐中很重要。它是熟悉的音阶中第一个音和最后一个音之间的音程。可以试试唱一下这个音阶: 注意,在到达高音 do 之前,八度音程由七个不同的音高填充,高音 do「复制」了低音 do。如果你从高音 do 开始并继续向上演奏音阶,你的每一个原始七个音调都会在高一个八度的地方被「复制」。几个世纪以来,这组七个音调一直是西方文明音乐的基础。这七个音调由钢琴键盘的白键产生: 随着时间的推移,原来的七个音高又增加了五个音高。这五个音高由键盘的黑键产生。所有十二个音调,像原来的七个音调一样,在高八度和低八度中被「复制」。每个音调都有「近亲」,相隔 1、2、3 或更多个八度。(在非西方音乐中,八度可能被分为不同数量的音调 —— 比如说,十七或二十二个。) 一个声音或乐器可以产生的最低音和最高音之间的距离称为其音高范围(Pitch range),或简称为范围(Range)。普通未经训练的声音范围在 1 到 2 个八度之间;钢琴的范围超过 7 个八度。当男人和女人唱同一首旋律时,他们通常会相隔一个八度来唱。 尽管我们所知道的大多数音乐都是基于明确的音高,但不确定音高(如低音鼓或钹发出的声音)也同样重要。一些打击乐器,如锣、牛铃和木块,有不同的尺寸,因此会产生更高或更低的不确定音高。高低不确定音高之间的对比在当代西方音乐和世界各地的音乐文化中起着至关重要的作用。 1.1.2. 动态 音乐中的响度或柔和度称为动态(Dynamics)—— 这是声音的第二个属性。响度与产生声音的振动幅度有关。吉他弦被拨动得越用力(离指板越远),声音就越响。当乐器演奏得更响或更柔和,或者听到的乐器数量发生变化时,就会产生动态变化;这种变化可以是突然的,也可以是逐渐的。响度的逐渐增加通常会产生兴奋感,特别是当音高也上升时。另一方面,响度的逐渐减小可以传达一种平静的感觉。 演奏者可以通过比周围音调更响亮地演奏某个音调来强调它。这种强调被称为重音(Accent)。巧妙、微妙的动态变化为表演增添了活力和情感。有时这些变化会写在乐谱中;但通常,它们并没有写出来,而是由演奏者对音乐的感受所激发的。 在记录音乐时,作曲家传统上使用意大利词语及其缩写来表示动态。最常见的术语是: 对于极端的柔和和响亮,作曲家使用 ppp 或 pppp 和 fff 或 ffff。以下符号表示动态的逐渐变化: 像音乐的许多元素一样,动态指示并不是绝对精确的。一个音调的动态水平 —— 是柔和还是响亮 —— 是相对于周围的其他音调而言的。单把小提琴的最响亮声音与整个管弦乐队的最响亮声音相比是微不足道的,与扩音摇滚乐队相比则更微不足道。但在其自身的上下文中,它可以被认为是极强音(非常响亮)。 1.1.3. 音色 即使小号和长笛在相同的动态水平上演奏相同的音调,我们也能区分它们。区分它们的特质 —— 我们所说的音乐声音的第三个属性 —— 称为音色(也被称为 timbre,发音为 tam′-ber)。音色可以用明亮、暗淡、辉煌、柔和和丰富等词来描述。 与动态变化一样,音色变化也创造了多样性和对比。当同一旋律由一种乐器演奏,然后由另一种乐器演奏时,由于每种乐器的音色不同,它会呈现出不同的表现效果。另一方面,音色的对比可以用来突出新的旋律:在小提琴演奏旋律之后,双簧管可能会呈现出对比鲜明的旋律。 音色也建立了一种连续感;当每次都是相同的乐器演奏时,更容易识别旋律的回归。特定的乐器可以增强旋律的情感影响:小号的辉煌声音适合英雄或军队的曲调;长笛的舒缓音色适合平静的旋律。事实上,作曲家经常在创作旋律时考虑特定乐器的音色。 作曲家可以使用几乎无限多样的音色。结合不同的乐器 —— 例如小提琴、单簧管和长号 —— 会产生新的音色,这是单个乐器无法独自产生的。而且,通过改变演奏旋律的乐器或声音的数量,可以改变音色。最后,近年来发展起来的电子技术使作曲家能够创造出完全不同于传统乐器的音色。 1.2. 演奏媒介:声音和乐器 1.2.1. 声音 纵观历史,歌唱一直是最广泛和最熟悉的音乐形式。古希腊戏剧包括吟唱的合唱团,《圣经》记载摩西、米利暗和以色列人唱歌赞美主。歌手似乎总是具有磁性的吸引力,即使在今天,崇拜的观众也会模仿他们最喜欢的歌手的外貌和生活方式。 歌手和观众之间的交流包含了一点魔力,一种直接而迷人的东西。可能是因为歌手成为了一种乐器,我们特别能与他或她产生共鸣 —— 一个像我们自己一样的人体通过声音和词语表达情感。 声音具有将词语与音乐音调融合的独特能力,因此在许多文化中,诗歌和歌唱是不可分割的。歌唱可以使词语更容易记住,并能增强其情感效果。 由于许多原因,唱好歌是困难的。在歌唱中,我们使用比说话更广泛的音高和音量范围,并且我们更长时间地保持元音。歌唱需要更大的呼吸供应和控制。来自肺部的空气由下腹肌和横膈膜控制。空气使声带振动,歌手的肺、喉咙、嘴巴和鼻子共同作用以产生所需的声音。音调的高低随着声带的紧张度而变化;它们越紧,音调越高。 歌手的音域取决于训练和身体构造。专业歌手可以掌握 2 个八度甚至更多,而未经训练的声音通常限制在大约 1½ 个八度。男性的声带比女性的更长更厚,这种差异产生了较低的音高范围。以下是女性和男性的音域分类,从最高到最低排列(四个基本音域是女高音、女低音、男高音和男低音): 女性男性 女高音(soprano)男高音(tenor) 次女高音(mezzo-soprano)男中音(baritone) 女低音(alto)或女中音(contralto)男低音(bass) 由于品味的不同,歌唱方法在不同文化中差异很大。例如,亚洲的歌唱比西方的更鼻音化。我们的文化中的古典歌手站立时姿势笔直,而西非的歌手则向前弯腰;印度的歌手则坐在地上。事实上,即使在西方,表演风格也有所不同:古典、流行、爵士、民间和摇滚音乐的演唱方式各不相同。例如,古典歌手通常不依赖麦克风,但摇滚音乐部分依靠扩音来传达其特点。 直到 17 世纪末,大多数西方文化的音乐都是声乐。但到 17 世纪末,器乐音乐的重要性与声乐音乐不相上下。从那时起,作曲家们继续创作独唱和合唱声乐作品,有的带有器乐伴奏,有的没有。作品有为男声合唱团创作的;为女声合唱团创作的;以及为混声合唱团创作的,通常结合女高音、女低音、男高音和男低音。声乐作品的伴奏范围从单一乐器(如吉他或钢琴)到整个管弦乐队。 1.2.2. 乐器 世界各地的人们使用的乐器在结构和音色上有很大的不同。乐器可以定义为除人声以外的任何产生音乐声音的机制。西方音乐家通常将乐器分为六大类:弦乐器(如吉他和小提琴);木管乐器(长笛、单簧管);铜管乐器(小号、长号);打击乐器(低音鼓、钹);键盘乐器(风琴、钢琴);和电子乐器(合成器)。 一种乐器通常有不同的尺寸,产生不同的音域。例如,萨克斯管家族包括高音萨克斯管、中音萨克斯管、次中音萨克斯管、上低音萨克斯管和低音萨克斯管。乐器的音色可能会随着演奏的音区(总音域的一部分)而变化。单簧管在低音区听起来深沉而丰富,但在高音区则明亮而尖锐。 器乐演奏者试图模仿歌手声音的美丽和灵活的音色。然而,大多数乐器的音域比人声更广。先前说过,受过训练的歌手的音域大约是 2 个八度,但许多乐器可以达到 3 或 4 个八度,有些甚至可以达到 6 或 7 个八度。此外,乐器通常比人声更快地产生音调。在为特定乐器写作音乐时,作曲家必须考虑其音域和动态以及它能多快地产生音调。 1.2.3. 弦乐器 小提琴(Violin)、中提琴(Viola)、大提琴(Cello,有时也被称为 Violoncello)和低音贝斯(Double bass,有时也被简称为 Bass)构成了交响乐团的弦乐(String)部分。它们在音色、大小和音域上各不相同:小提琴是最小的,音域最高;低音贝斯是最大的,音域最低。在交响乐中,弦乐器通常用弓(Bow)演奏,弓是一根稍微弯曲的棍子,上面紧紧地系着马鬃(见下图)。交响乐的弦乐器也可以用手指拨奏。 在所有的乐器组中,弦乐器具有最大的多功能性和表现力。它们能产生许多音色,并且具有广泛的音高和动态范围。弦乐演奏者可以产生明亮而快速的音调,也可以产生缓慢而颤动的音调;他们可以像歌手一样微妙地控制音色。交响乐作品往往更多地依赖弦乐器,而不是其他乐器组。即使音色不同,这四种弦乐器也能完美地融合在一起。在这里,考虑弦乐器的结构和音色产生方式是有帮助的;小提琴可以代表整个家族。 小提琴的中空木质琴身支撑着四根通常由金属或合成材料制成的琴弦。(即使在今天,一些演奏者仍然偏爱由紧密缠绕的肠线制成的传统琴弦。)琴弦在张力下从一端的尾部穿过木桥延伸到另一端,在那里它们被固定在木钉上。琴桥将琴弦与指板分开,使其能够自由振动;琴桥还将琴弦的振动传递到琴身,从而放大和改变音色。每根琴弦通过拧紧或松开琴钉来调节不同的音高。(张力越大,音高越高。) 演奏者用右手拉动弓使琴弦振动。弓的速度和压力控制着产生的声音的动态和音色。音高由演奏者的左手控制。通过将琴弦按在指板上,演奏者改变了其振动部分的长度,从而改变了音高。这被称为按弦(Stopping)(因为振动在琴弦长度的某个点被停止)。因此,每根琴弦都可以产生一系列的音高。 基本上,中提琴、大提琴和低音贝斯的制作方式相同,发声方式也相似。弦乐器的演奏方式 —— 使用的弦乐演奏技巧 —— 决定了它们将产生的许多音乐效果。这里列出了最常用的技巧: 拨弦(Pizzicato):演奏者用右手的手指拨动琴弦。在爵士乐中,低音贝斯主要作为拨弦乐器演奏,而不是用弓拉奏。 双音(Double stop,两个音同时发声):通过将弓拉过两根琴弦,弦乐演奏者可以同时发出两个音。通过快速旋转弓跨过三根琴弦(三音,Triple stop)或四根琴弦(四音,Quadruple stop),可以几乎同时发出三或四个音。 颤音(Vibrato):弦乐演奏者可以通过在按下琴弦时摇动左手来产生颤动的、富有表现力的音调。这会引起小的音高波动,使音调更加温暖。 弱音器(Mute):演奏者可以通过在琴桥上安装一个夹子(弱音器)来遮蔽或减弱音调。 颤音(Tremolo):演奏者通过快速上下拉动弓来快速重复音调。当音量大时,这可以创造一种紧张感;当音量小时,可以产生一种闪烁的声音。 泛音(Harmonics):当演奏者轻轻触摸琴弦的某些点时,会产生非常高的音调,类似于哨声。 尽管小提琴、中提琴、大提琴和低音提琴相似,但它们就像任何家庭的成员一样,各有不同。 有些弦乐器不是用弓演奏的,而是用手指或拨片(Plectrum)拨奏的。其中最重要的是竖琴(Harp)和吉他(Guitar)。竖琴是唯一在交响乐团中获得广泛接受的拨弦乐器。 1.2.4. 木管乐器 木管乐器(Woodwind instruments)之所以得名,是因为它们在传统上是通过木制管内的空气振动来发声的。然而,在二十世纪期间,短笛(Piccolos)和长笛(Flutes)开始用金属制成。所有木管乐器沿其长度都有小孔,这些小孔可以用手指或由按键机制控制的垫片来开闭。通过开闭这些孔,木管乐器演奏者改变了振动空气柱的长度,从而改变音高。 交响乐团的主要木管乐器如下,按音域从最高(短笛)到最低(低音大管)排列。(每个家族中仅列出最常用的两种乐器。) 长笛家族单簧管家族双簧管家族巴松管家族 短笛(Piccolo) 长笛(Flute)单簧管(Clarinet) 双簧管(Oboe) 中音双簧管 / 英国管(English horn) 低音单簧管(Bass clarinet)巴松管(Bassoon) 倍低音管(Contrabassoon) 木管乐器是伟大的个体主义者,其音色与各种弦乐器相比差异更大。长笛的银色音调与鼻音的双簧管相比,比小提琴与中提琴的差异更大。木管乐器独特的音色主要来自不同的振动方式。长笛和短笛演奏者通过吹过吹口边缘发声,就像吹空瓶口发声一样。(竖笛 Recorder 演奏者通过哨子吹口发声,竖笛是长笛的亲属。)其余的木管乐器依靠振动的簧片发声。簧片(Reed)是一块非常薄的芦苇片,约 2½ 英寸长,通过气流振动。 单簧木管乐器(Single-reed woodwinds)的簧片固定在吹口的孔上,当演奏者吹入乐器时,簧片振动。单簧管和低音单簧管是单簧木管乐器。萨克斯管(Saxophone)也是一种主要用于乐队的单簧木管乐器。在双簧木管乐器(Double-reed woodwinds)中,两片窄窄的芦苇片夹在演奏者的嘴唇之间。双簧管、英国管、巴松管和低音巴松管是双簧木管乐器。 单独听起来,簧片只会发出一种刺耳的声音。虽然簧片木管乐器的音色主要由乐器管的孔径决定,但簧片确实会对音色产生一定影响。专业的木管乐器演奏者花费大量时间浸泡和塑造他们的簧片,以确保获得最佳音色。 每种木管乐器的不同音区的音色也有很大差异。一般来说,低音区往往气息浓重,而高音区则更为尖锐。与可以双音演奏的弦乐器不同,木管乐器一次只能发出一个音。在交响乐中,它们经常被赋予旋律独奏。木管乐器非常适合户外演奏(牧羊人几千年来一直在演奏简单的木管乐器)。因此,它们经常出现在唤起乡村氛围的音乐中。 1.2.5. 铜管乐器 从高音区到低音区,交响乐团铜管乐器(Brass instruments)的主要乐器有小号(Trumpet)、法国号(French horn,有时简称为号 Horn)、长号(Trombone)和大号(Tuba)。小号和长号经常用于爵士乐和摇滚乐队。 铜管乐器的振动来自演奏者的嘴唇,当他或她吹入杯状或漏斗状的吹口(Mouthpiece)时产生振动。这些振动在一个盘绕的管子中被放大和改变音色(以便于携带和演奏)。管子的末端张开形成一个喇叭口(Bell)。现代铜管乐器实际上是由黄铜制成的,但它们的早期版本是由空心动物角、象牙、木头甚至玻璃制成的。 一些铜管乐器,如短号、上低音号和次中音号,主要用于音乐会和行进乐队。短号的形状类似于小号,但音色更柔和。上低音号看起来像大号,音域与长号相同。次中音号是大号家族中的男高音乐器。

2024/9/29
articleCard.readMore

IBM 全栈开发【13・下】:全栈应用程序开发者毕业设计

近期在学习 IBM 全栈应用开发微学士课程,故此记录学习笔记。 该文章不会继续更新! 1. 复习和考试准备 1.1. 云计算概述 1.1.1. 云计算简介 云计算是指通过互联网按需提供计算资源,采取即用即付的方式。 资源在多个用户之间动态分配和重新分配,并根据用户的需求扩大或缩小规模。 云计算的起源可以追溯到 20 世纪 50 年代的大型机,虚拟化技术和管理程序是现代云计算出现的催化剂。 企业必须考虑自身的业务需求、投资可行性和风险承受能力,以制定云计算应用战略,在不造成业务中断或安全、合规性或性能问题的情况下实现预期效益。 云技术应用的增长速度超过预期。推动这一技术浪潮的是提供从基础设施到平台和软件服务等一系列服务的云服务提供商。我们这个时代的一些主要云提供商包括 AWS、阿里巴巴云、谷歌、IBM 和 Microsoft Azure。 1.1.2. 云计算的商业案例 无论企业规模大小,采用云技术都能使企业变得灵活、具有创新性和竞争力,并创造与众不同的客户体验。企业提出的问题不是他们是否应该迁移到云技术,而是他们应该采取什么战略迁移到云技术。 一些案例研究展示了企业通过采用云技术所产生的影响: 美国航空公司采用云技术,在整个企业内迅速为客户创造价值。 UBank 利用云平台服务为开发人员提供更多控制权,从而消除创新障碍。 Bitly 利用云基础设施提供的可扩展性,为其地理位置分散的企业客户提供低延迟交付。 ActivTrades 利用云计算提供的基础设施、存储、网络和安全服务,加快在线交易系统的执行,并向客户交付新功能。 1.1.3. 云技术加速了新兴技术的发展 以云计算为动力的新兴技术正在颠覆现有的商业模式,为企业的发展、创新和为客户创造价值创造前所未有的机遇。 一些案例研究展示了云计算新兴技术的使用如何为全球数百万人创造价值: 利用云上的物联网打击南非偷猎濒危犀牛的行为。 美国网球协会利用云上人工智能为全球数百万球迷提供独特的数字体验。 云端区块链通过建立食品供应链的可追溯性和透明度,帮助农民减少浪费。 KONE 公司利用数据分析为城市基础设施提供预测性维护解决方案。 1.2. 云计算模式 1.2.1. 服务模式 云计算使我们能够以服务的形式利用技术,以按需付费的模式利用远程资源。云计算主要有三种服务模式:基础设施即服务(LaaS)、平台即服务(PaaS)和软件即服务(SaaS)。 对于 IaaS,云提供商管理物理资源。 对于 PaaS,提供商管理平台基础设施。 在 SaaS 模式中,提供商托管并管理应用程序和数据。 基础设施即服务是云计算的一种形式,通过网络以按需付费的方式向消费者提供基本的计算机、网络和存储资源。 云基础设施的主要组成部分包括: 物理数据中心 计算 网络 存储 平台即服务是一种为客户提供完整平台的云计算模式: 硬件、软件和基础设施。 高度抽象、支持服务、运行时环境、快速部署机制和中间件功能是 PaaS 云的显著特征。 PaaS 的优势在于: 可扩展性 产品和服务更快上市 更大的灵活性和创新性

2024/7/8
articleCard.readMore

IBM 全栈开发【13・上】:全栈应用程序开发者毕业设计

近期在学习 IBM 全栈应用开发微学士课程,故此记录学习笔记。 恭喜您成功完成 IBM 全栈软件开发人员专业证书的所有前面课程!现在是通过完成期末考试来检验您的新技能的时候了。 您将通过考试,了解自己在 PC 各门课程中学到的知识。现在,您应该已经熟练掌握了以下主题: 云计算的核心概念 网页开发语言,包括 HTML、CSS 和 JavaScript Git 和 GitHub Node.JS、Express 和 React 容器 Python 数据库概念和相关技术,如 SQL 和 Django 微服务和无服务器计算 这是 IBM 全栈软件开发人员专业证书的最后一门课程。它将测试您迄今为止所掌握的知识和技能。本课程包含分级期末考试,涵盖 PC 中各种课程的内容。 您将就以下主题接受评估:核心云计算概念;HTML、CSS、JavaScript 和 Python 等语言;Node.JS、Express 和 React 等框架;以及 Docker、Kubernetes、OpenShift、SQL、Django、微服务和 Serverless 等后端技术。 在学习本课程之前,请确保您已完成 IBM 全栈开发人员专业证书中的所有先前课程。 通过附加功能丰富汽车经销商门户网站 该课程从模块开始:通过三个实验丰富汽车经销商门户网站的附加功能。 在「全栈应用程序开发项目」(可见 文章)中,您创建了一个汽车经销商应用程序,需要开发前端页面、用户管理、构建数据库操作动作、创建后端服务以及配置 CI / CD 管道。前端使用的技术包括 HTML、CSS、JavaScript 和 React,后端使用的技术包括 Django、Node.JS、NoSQL(Mongo)、容器化、IBM 代码引擎、Python 和 Kubernetes。 通过汽车经销商应用程序,我们可以根据经销商的 ID 查看所有经销商的详细信息和评论,还可以注册、登录为用户,并在注册或登录后为特定经销商添加新的评论,方法是将 About 和 Contact Us 等静态页面整合到一起。 用户注册和登录功能也是在为端点和 Django 视图配置了 Express-Mongo 后端微服务后实现的。还集成了一个情感分析微服务来分析评论。也实现了各种功能,如显示经销商列表、详细信息和评论,在 Django 应用程序中添加新的经销商评论。最后,将 CI / CD Linting 服务集成到应用程序中,并部署到 Kubernetes 上。 在本课程中,您将从增强同一个汽车经销商应用程序开始。改进的重点是前端方面,然后通过添加新的微服务转移到后端,最后在后续实验中将其与前端集成。 强烈建议先完成全栈应用程序开发项目课程,然后再学习本课程,因为本模块的实验是增强已建应用程序的一部分。虽然这些内容不属于评分标准的一部分,但它们对于增强你的理解和技能仍然很有价值。 概述 作为毕业设计项目的一部分,您已经成功创建并测试了 Car Dealerships website,确保其符合预期功能。这包括允许用户查看所有经销商的详细信息、查看对特定经销商的现有评论,以及在作为注册用户登录后发布对特定经销商的新评论。 完成上述工作后,下一步就是增强应用程序的功能。这涉及到应用程序的前端和后端方面。如下所述,您将分三部分实施这些增强功能: 前端改进 将 Dealerships 页面上的 States 下拉菜单转换为可搜索文本框,使用户能够通过输入搜索字符串来筛选经销商。 改进应用程序主页上导航栏和经销商按钮的配色方案,以及 Dealerships Review 页面上审查面板和审查图标的颜色。 微调经销商审查面板的视觉元素,对字体大小和字体对齐方式等方面进行调整。 汽车库存后端服务 使用 MongoDB 和 Node.JS 服务器建立一个新的后端微型服务,专门用于获取与汽车库存相关的各种详细信息。 将新创建的微服务与 Django 应用程序的后端集成,并验证后端服务器的成功启动。 汽车库存服务的前端开发 开发前端组件,并将其与 第 2 部分:汽车库存后端服务 中开发的后端汽车库存微服务集成。 创建一个按 品牌、型号、年份、里程 和 价格 选择汽车的选项。 对 Django 应用程序与集成的汽车库存服务生成的输出进行全面测试。 前端改进 将 Dealerships 页面上的 States 下拉菜单转换为可搜索文本框 访问 frontend/src/components/Dealers/Dealers.jsx 文件。 您会注意到,经销商下拉菜单的代码是在 <select> 下拉菜单元素中显示的: <select name="state" id="state" onChange={(e) => filterDealers(e.target.value)}></select> 用包含以下属性的 <input> 字段取代现有的 <select> 元素: 用户可在文本框中输入搜索州。 根据输入的搜索查询过滤显示的经销商,并与州匹配。 当输入框失去焦点时,将显示的经销商重置为原始列表。 <input type="text" placeholder="Search states..." onChange={handleInputChange} onBlur={handleLostFocus} value={searchQuery} /> 观察输入元素中的函数。现在,让我们创建它们。 创建一个名为 handleInputChange 的新函数,用于管理输入更改,并根据输入的状态查询过滤经销商。 const handleInputChange = (event) => { const query = event.target.value; setSearchQuery(query); const filtered = originalDealers.filter(dealer => dealer.state.toLowerCase().includes(query.toLowerCase()) ); setDealersList(filtered);}; 每次输入框内的值发生变化时,都会触发该函数。 它会检索用户在输入框中输入的当前值,并将其作为查询存储在 setSearchQuery 变量中,用于筛选经销商。 系统会生成一个新数组,其中只包含状态与输入的查询相匹配的经销商。 通过将查询和经销商状态转换为小写,使其大小写不敏感,从而确保用户获得更友好的搜索体验。 然后,该函数将显示与输入的状态查询相匹配的经销商。 总之,handleInputChange 函数可根据用户在搜索栏中的输入动态过滤经销商列表。它实时更新显示的经销商列表,为用户提供响应式搜索功能。 创建 handleLostFocus 函数,以确保当用户将搜索输入留空并点击或滑动标签离开时,经销商列表会重置为原始列表。 const handleLostFocus = () => { if (!searchQuery) { setDealersList(originalDealers); }} 该函数在执行时验证 searchQuery 状态是否为空。 如果 searchQuery 确实为空,它就会将经销商列表恢复到开始搜索前的原始状态。 handleLostFocus 函数在用户点击输入框外或标签页离开输入框时调用,由 onBlur 事件触发。 将此代码与之前定义的其他状态变量一起添加: const [searchQuery, setSearchQuery] = useState(''); 这将利用 useState 钩子创建一个名为 searchQuery 的状态变量和一个相应的函数 setSearchQuery 来更新其值。 searchQuery 状态变量实时保存用户在搜索输入框中输入的值。当用户在搜索栏中输入时,该状态变量会通过调用 setSearchQuery 进行更新,并触发组件的重新渲染,以反映对搜索查询所做的任何更改。 初始化和设置状态变量,用于管理原始经销商列表。 添加以下代码,初始化 dealersList、searchQuery 和 states 变量及其设置函数: const [originalDealers, setOriginalDealers] = useState([]); 在这段代码中,状态变量 originalDealers 及其设置函数 setOriginalDealers 用于跟踪和更新原始经销商列表。 将下面的代码放在 getDealers 函数中更新其他状态(setStates 和 setDealersList)的地方。 setOriginalDealers(all_dealers); 这一行用从应用程序接口获取的所有经销商数组设置状态变量 originalDealers,用于存储过滤前的原始经销商列表。 确保保存所有更改。 运行以下命令来构建应用程序的客户端: cd /home/project/xrwvm-fullstack_developer_capstone/server/frontendnpm run build 访问经销商详细信息页面,测试应用程序的输出。 请观察用于经销商搜索的搜索框的外观,它取代了之前的下拉框。 输入搜索查询,例如 Texas,搜索该州来进行测试。 更改应用程序的配色方案 在本节中,您将了解在以下区域更改应用程序配色方案的步骤: 与应用程序主页有关的方面,包括: 更改导航栏的背景颜色。 修改 View Dealerships 按钮的背景颜色。 与 Dealership Review 页面有关的方面包括: 调整与 Review 面板相关的背景颜色。 自定义与 Review 图标相关的悬浮边框。 更改导航栏的背景颜色 打开文件 frontend/static/Home.html。 您将看到以下代码片段,它将当前的深绿色背景添加到应用程序中的导航栏: <nav class="navbar navbar-expand-lg navbar-light" style={{backgroundColor:"darkturquoise",height:"1in"}}> 用您喜欢的颜色(如 mediumspringgreen)代替它,以修改页眉的背景颜色。 <nav class="navbar navbar-expand-lg navbar-light" style="background-color:mediumspringgreen; height: 1in;"> 保存更改。 运行提供的命令构建客户端并显示上述更改: npm run build 刷新应用页面。 观察导航栏背景的变化。 修改 View Dealerships 按钮的背景颜色 以下代码表示应用程序主页上 View Dealerships 按钮的背景颜色: <a href="/dealers" class="btn" style="background-color: aqua;margin:10px">View Dealerships</a> 将其调整为您喜欢的颜色(例如,plum,一种紫色)。 <a href="/dealers" class="btn" style="background-color: plum; margin:10px">View Dealerships</a> 请观察 View Dealerships 按钮的最新配色方案。 调整与 Review 面板相关的背景颜色 Dealers.css 文件中的样式是为应用程序中的 Dealerships 和 Reviews 面板定制的。review_panel 类包含用于设计经销商审查面板样式的代码。 请注意,它的边框是纯灰色的。 border: solid grey; 调整代码,使其具有纯紫色边框: border: solid purple; 你会发现 Review 面板的边框变成了纯紫色,外观也发生了变化。 自定义与 Review 图标相连的悬浮边框 当用户将鼠标悬停在 Review 图标上时,.review_icon:hover 类将为其应用样式。 请注意,悬停时它的背景是纯浅灰色。 border: solid lightgray; 将颜色调整为黑色色调,边框变细(如 2 像素),以便在悬停时呈现出鲜明而纤细的背景外观。 border: 2px solid #080808; 观察修改后的图标,它带有细细的黑色边框,悬停时会显示出明显的存在感。 更改应用程序的外观和感觉 在本节中,您将学习如何改进应用程序中 Dealer Review 面板的视觉效果。 其中包括: 调整字体大小 确保字体对齐正确,以改善整体外观 调整字体大小 .reviewer 类定义了用户评论的样式。 当前字体大小设置为小。 将字体大小调整到特定值(例如 18 像素),以放大 Review 文本,提高可读性。 font-size: 18px; 确保字体对齐正确,改善整体外观 文本对齐属性并未被指定。 因此,浏览器的默认文本对齐方式(通常为左对齐)会被应用,从而产生左对齐的 Review 文本。 将文本居中,以确保 User reviews 显示在 Review 窗格的中心。 text-align: center; 汽车库存后端服务 使用 MongoDB 和 Node.JS 开发新的后端汽车库存微服务 打开一个新的终端窗口,导航到 xrwvm-fullstack_developer_capstone/server 目录。 生成一个名为 carsInventory 的新目录。 mkdir carsInventory 执行以下命令,初始化一个新的 Node.JS 项目,并在 carsInvent 目录下创建 package.json 文件: npm init 将以下应用程序依赖项添加到 package.json 中: "cors": "^2.8.5", "express": "^4.18.2","mongodb": "^6.3.0","mongoose": "^8.0.1" 这些依赖项对于启用 CORS(跨源资源共享)、处理网络应用程序路由和中间件(Express)、与 MongoDB 交互(MongoDB 驱动程序)以及在使用 MongoDB 的 Node.JS 应用程序中提供便捷的数据建模方式(Mongoose)至关重要。 每个依赖关系中版本号前的 ^ 符号允许在运行 npm install 命令时安装兼容的未来更新。 将 name 设置为 carsInventory,将 main 的值设置为 app.js。这样,package.json 文件看起来应该与下面相似: { "name": "carsInventory", "version": "1.0.0", "description": "", "main": "app.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "author": "", "license": "ISC", "dependencies": { "cors": "^2.8.5", "express": "^4.18.2", "mongodb": "^6.3.0", "mongoose": "^8.0.1" }} 执行命令安装这些依赖项: npm install 在名为 inventory.js 的文件中设置 MongoDB schema。现在,您将使用 mongoose 库为名为 cars 的集合建立 MongoDB schema。 这将用于创建一个与 MongoDB 数据库交互的 mongoose 模型,使应用程序能够以更有条理、更有组织的方式对汽车文档执行 CRUD 操作。 schema 应定义汽车文件的结构,其中应包括以下字段及其数据类型: dealer_id:Number make:String model:String bodyType:String year:Number mileage:Number price:Number 模型将被命名为 cars,与所连接的 MongoDB 数据库中的 cars 集合相对应。MongoDB 模型将以此名称导出。 const { Int32 } = require('mongodb');const mongoose = require('mongoose');const Schema = mongoose.Schema;const cars = new Schema({ dealer_id: { type: Number, required: true }, make: { type: String, required: true }, model: { type: String, required: true }, bodyType: { type: String, required: true }, year: { type: Number, required: true }, mileage: { type: Number, required: true }, price: { type: Number, required: true }});module.exports = mongoose.model('cars', cars); 获取包含汽车库存和相关详细信息的 JSON 数据集。 首先创建一个名为 data 的文件夹并导航进入: mkdir datacd data 下载汽车库存数据集: wget https://cf-courses-data.s3.us.cloud-object-storage.appdomain.cloud/IBM-CD0321EN-SkillsNetwork/labs/v2/m6/car_records.json 检查文件,发现每个汽车对象都有以下字段: make, model, bodyType, year, dealer_id, mileage, price 返回 carsInvent 目录,创建更多文件。 建立具有后端端点功能的 Node.JS 服务器。首先创建一个名为 app.js 的文件: touch app.js 它应具备以下功能: 设置与 MongoDB 集成的 Express 服务器 从文件 car_records.json 中读取数据 建立与 MongoDB 的连接 定义根 API 端点 /,当访问根 API 时,它会响应一条欢迎访问 Mongoose API 消息 定义以下六个端点,用于根据各种条件查询汽车: cars/:id 端点,可根据指定的经销商 ID 从 MongoDB 集合中检索并返回汽车文档 /carsbymake/:id/:make 端点,可根据经销商 ID 和汽车品牌检索并返回汽车文档 /carsbymake/:id/:model 端点,可根据经销商 ID 和车型检索并返回汽车文档 /carsbymaxmileage/:id/:mileage 端点,可根据经销商 ID 和里程限制检索并返回汽车文件,如下所示: 里程数: 小于或等于 50000 50000 至 100000 100000 至 150000 150000 至 200000 大于 200000 /carsbyprice/:id/:price 端点,可根据经销商 ID 和价格约束检索并返回汽车文件,如下所示: 价格: 小于或等于 20000 20000 至 40000 40000 至 60000 60000 至 80000 大于 80000 /carsbymake/:id/:year 端点,可根据经销商 ID 和最低年份约束检索并返回汽车文件 在 3050 端口启动服务器。 /*jshint esversion: 8 */const express = require('express');const mongoose = require('mongoose');const fs = require('fs');const cors = require('cors');const app = express();const port = 3050;app.use(cors());app.use(express.urlencoded({ extended: false }));const carsData = JSON.parse(fs.readFileSync('car_records.json', 'utf8'));mongoose.connect('mongodb://mongo_db:27017/', { dbName: 'dealershipsDB' });const Cars = require('./inventory');try { Cars.deleteMany({}).then(() => { Cars.insertMany(carsData.cars); });} catch (error) { console.error(error); // Handle errors properly here}app.get('/', async (req, res) => { res.send('Welcome to the Mongoose API');});app.get('/cars/:id', async (req, res) => { try { const documents = await Cars.find({dealer_id: req.params.id}); res.json(documents); } catch (error) { res.status(500).json({ error: 'Error fetching reviews' }); }});app.get('/carsbymake/:id/:make', async (req, res) => { try { const documents = await Cars.find({dealer_id: req.params.id, make: req.params.make}); res.json(documents); } catch (error) { res.status(500).json({ error: 'Error fetching reviews by car make and model' }); }});app.get('/carsbymodel/:id/:model', async (req, res) => { try { const documents = await Cars.find({ dealer_id: req.params.id, model: req.params.model }); res.json(documents); } catch (error) { res.status(500).json({ error: 'Error fetching dealers by ID' }); }});app.get('/carsbymaxmileage/:id/:mileage', async (req, res) => { try { let mileage = parseInt(req.params.mileage) let condition = {} if(mileage === 50000) { condition = { $lte : mileage} } else if (mileage === 100000){ condition = { $lte : mileage, $gt : 50000} } else if (mileage === 150000){ condition = { $lte : mileage, $gt : 100000} } else if (mileage === 200000){ condition = { $lte : mileage, $gt : 150000} } else { condition = { $gt : 200000} } const documents = await Cars.find({ dealer_id: req.params.id, mileage : condition }); res.json(documents); } catch (error) { res.status(500).json({ error: 'Error fetching dealers by ID' }); }});app.get('/carsbyprice/:id/:price', async (req, res) => { try { let price = parseInt(req.params.price) let condition = {} if(price === 20000) { condition = { $lte : price} } else if (price=== 40000){ console.log("\n \n \n "+ price) condition = { $lte : price, $gt : 20000} } else if (price === 60000){ condition = { $lte : price, $gt : 40000} } else if (price === 80000){ condition = { $lte : price, $gt : 60000} } else { condition = { $gt : 80000} } const documents = await Cars.find({ dealer_id: req.params.id, price : condition }); res.json(documents); } catch (error) { res.status(500).json({ error: 'Error fetching dealers by ID' }); }});app.get('/carsbyyear/:id/:year', async (req, res) => { try { const documents = await Cars.find({ dealer_id: req.params.id, year : { $gte :req.params.year }}); res.json(documents); } catch (error) { res.status(500).json({ error: 'Error fetching dealers by ID' }); }});app.listen(port, () => { console.log(`Server is running on http://localhost:${port}`);}); 为 Node.JS 应用程序创建 Docker 镜像。首先创建 Dockerfile: touch Dockerfile 添加以下内容: 基础 Docker 镜像:node:18.12.1-bullseye-slim 安装 9.1.3 版本的 npm 从当前目录内添加 package.json、app.js、car_records.json 到 Docker 镜像的根目录 复制当前目录的所有文件到 Docker 镜像 让容器监听 3050 端口 将容器启动时要运行的默认命令指定为 node app.js FROM node:18.12.1-bullseye-slimRUN npm install -g npm@9.1.3ADD package.json .ADD app.js .ADD data/car_records.json .COPY . .RUN npm installEXPOSE 3050CMD [ "node", "app.js" ] 为运行两个服务(MongoDB 容器和 Node.JS 应用程序)设置 Docker Compose 配置文件。 创建 Docker Compose 配置 YAML 文件(docker-compose.yml): touch docker-compose.yml 添加内容: version: '3.9'services: # Mongodb service mongo_db: container_name: carsInventory_container image: mongo:latest ports: - 27018:27017 restart: always volumes: - mongo_data:/data/db # Node api service api: image: nodeapp ports: - 3050:3050 depends_on: - mongo_dbvolumes: mongo_data: {} 构建 Docker 应用: docker build . -t nodeapp 执行以下命令启动服务器: docker-compose up 启动服务器并验证汽车库存服务的根端点是否显示了 Welcome to the Mongoose API 的消息。 将微服务与 Django 应用程序后端集成,并成功启动后端服务器 导航至 xrwvm-fullstack_developer_capstone/server 目录。如果 Django 服务器已在运行,则停止该服务器。 在 .env 文件中插入汽车库存服务端点的 URL(上一节已复制)。 searchcars_url='your end' 不要添加尾端的 \。 在 restapis.py 中加入执行以下功能的代码: 从 .env 文件中获取 URL。 searchcars_url = os.getenv( 'searchcars_url', default="http://localhost:3050/") 执行一个名为 searchcars_request 的方法,该方法具有以下功能: 接受端点和变量关键字参数 利用提供的端点、查询参数和基本 URL 构建一个完整的请求 URL 执行 GET 请求并返回响应的 JSON 内容 处理错误(包括网络异常)并提供成功完成消息 代码结构将与您之前在 Capstone 主项目中创建的 get_request 方法非常相似: def searchcars_request(endpoint, **kwargs): params = "" if (kwargs): for key, value in kwargs.items(): params = params+key + "=" + value + "&" request_url = searchcars_url+endpoint+"?"+params print("GET from {} ".format(request_url)) try: # Call get method of requests library with URL and parameters response = requests.get(request_url) return response.json() except: # If any error occurs print("Network exception occurred") finally: print("GET request call complete!") 在 djangoapp/views.py 文件中添加名为 get_inventory 的视图,以获取汽车库存。 该视图应处理 HTTP 请求,提取查询参数和经销商 ID。 它应使用提供的参数构建一个 API 端点,并调用 restapis.py 中定义的 searchcars_request 函数来检索汽车数据。确保为 searchcars_request 函数加入模块导入。 它应返回状态为 200 的 JSON 响应,如果提供了经销商 ID,则返回获取的汽车。 如果没有经销商 ID,则应返回状态为 400 的 JSON 响应和 Bad Request 消息。 # Module importfrom .restapis import get_request, analyze_review_sentiments, post_review, searchcars_request# Code for the viewdef get_inventory(request, dealer_id): data = request.GET if (dealer_id): if 'year' in data: endpoint = "/carsbyyear/"+str(dealer_id)+"/"+data['year'] elif 'make' in data: endpoint = "/carsbymake/"+str(dealer_id)+"/"+data['make'] elif 'model' in data: endpoint = "/carsbymodel/"+str(dealer_id)+"/"+data['model'] elif 'mileage' in data: endpoint = "/carsbymaxmileage/"+str(dealer_id)+"/"+data['mileage'] elif 'price' in data: endpoint = "/carsbyprice/"+str(dealer_id)+"/"+data['price'] else: endpoint = "/cars/"+str(dealer_id) cars = searchcars_request(endpoint) return JsonResponse({"status": 200, "cars": cars}) else: return JsonResponse({"status": 400, "message": "Bad Request"}) return JsonResponse({"status": 400, "message": "Bad Request"}) 在 djangoapp/urls.py 文件中包含该视图的路由。 path(route='get_inventory/<int:dealer_id>', view=views.get_inventory, name='get_inventory'), 导航到 xrwvm-fullstack_developer_capstone/server 目录。 确保 Docker Compose 服务器正在运行。如果没有,请使用 docker-compose up 命令启动它。 执行模型迁移并启动服务器。 python3 manage.py makemigrationspython3 manage.py migratepython3 manage.py runserver 确保服务器启动成功且无错误。如果出现任何错误日志,请检查并根据需要处理您的代码。 汽车库存服务的前端开发 开发并集成与后端汽车库存微服务相对应的前端服务 这需要创建一个 React 组件,专门用于搜索和显示汽车信息,并为该组件整合相应的路线。 创建一个用于搜索和显示汽车信息的 React 组件。 导航至 xrwvm-fullstack_developer_capstone/server/frontend/src/components/Dealers 目录,并添加名为 SearchCars.jsx 的新文件。 在该文件中加入以下内容: import React, { useState, useEffect } from 'react';import { useParams } from 'react-router-dom';import Header from '../Header/Header';const SearchCars = () => { const [cars, setCars] = useState([]); const [makes, setMakes] = useState([]); const [models, setModels] = useState([]); const [dealer, setDealer] = useState({"full_name":""}); const [message, setMessage] = useState("Loading Cars...."); const { id } = useParams(); let dealer_url = `/djangoapp/get_inventory/${id}`; let fetch_url = `/djangoapp/dealer/${id}`; const fetchDealer = async ()=>{ const res = await fetch(fetch_url, { method: "GET" }); const retobj = await res.json(); if(retobj.status === 200) { let dealer = retobj.dealer; setDealer({"full_name":dealer[0].full_name}) } } const populateMakesAndModels = (cars)=>{ let tmpmakes = [] let tmpmodels = [] cars.forEach((car)=>{ tmpmakes.push(car.make) tmpmodels.push(car.model) }) setMakes(Array.from(new Set(tmpmakes))); setModels(Array.from(new Set(tmpmodels))); } const fetchCars = async ()=>{ const res = await fetch(dealer_url, { method: "GET" }); const retobj = await res.json(); if(retobj.status === 200) { let cars = Array.from(retobj.cars) setCars(cars); populateMakesAndModels(cars); } } const setCarsmatchingCriteria = async(matching_cars)=>{ let cars = Array.from(matching_cars) console.log("Number of matching cars "+cars.length); let makeIdx = document.getElementById('make').selectedIndex; let modelIdx = document.getElementById('model').selectedIndex; let yearIdx = document.getElementById('year').selectedIndex; let mileageIdx = document.getElementById('mileage').selectedIndex; let priceIdx = document.getElementById('price').selectedIndex; if(makeIdx !== 0) { let currmake = document.getElementById('make').value; cars = cars.filter(car => car.make === currmake); } if(modelIdx !== 0) { let currmodel = document.getElementById('model').value; cars = cars.filter(car => car.model === currmodel); if(cars.length !== 0) { document.getElementById('make').value = cars[0].make; } } if(yearIdx !== 0) { let curryear = document.getElementById('year').value; cars = cars.filter(car => car.year >= curryear); if(cars.length !== 0) { document.getElementById('make').value = cars[0].make; } } if(mileageIdx !== 0) { let currmileage = parseInt(document.getElementById('mileage').value); if(currmileage === 50000) { cars = cars.filter(car => car.mileage <= currmileage); } else if (currmileage === 100000){ cars = cars.filter(car => car.mileage <= currmileage && car.mileage > 50000); } else if (currmileage === 150000){ cars = cars.filter(car => car.mileage <= currmileage && car.mileage > 100000); } else if (currmileage === 200000){ cars = cars.filter(car => car.mileage <= currmileage && car.mileage > 150000); } else { cars = cars.filter(car => car.mileage > 200000); } } if(priceIdx !== 0) { let currprice = parseInt(document.getElementById('price').value); if(currprice === 20000) { cars = cars.filter(car => car.price <= currprice); } else if (currprice === 40000){ cars = cars.filter(car => car.price <= currprice && car.price > 20000); } else if (currprice === 60000){ cars = cars.filter(car => car.price <= currprice && car.price > 40000); } else if (currprice === 80000){ cars = cars.filter(car => car.price <= currprice && car.price > 60000); } else { cars = cars.filter(car => car.price > 80000); } } if(cars.length === 0) { setMessage("No cars found matching criteria"); } setCars(cars); } let SearchCarsByMake = async ()=> { let make = document.getElementById("make").value; dealer_url = dealer_url + "?make="+make; const res = await fetch(dealer_url, { method: 'GET', headers: { 'Content-Type': 'application/json', }}) const retobj = await res.json(); if(retobj.status === 200) { setCarsmatchingCriteria(retobj.cars); } } let SearchCarsByModel = async ()=> { let model = document.getElementById("model").value; dealer_url = dealer_url + "?model="+model; const res = await fetch(dealer_url, { method: 'GET', headers: { 'Content-Type': 'application/json', }}) const retobj = await res.json(); if(retobj.status === 200) { setCarsmatchingCriteria(retobj.cars); } } let SearchCarsByYear = async ()=> { let year = document.getElementById("year").value; if (year !== "all") { dealer_url = dealer_url + "?year="+year; } const res = await fetch(dealer_url, { method: 'GET', headers: { 'Content-Type': 'application/json', }}) const retobj = await res.json(); if(retobj.status === 200) { setCarsmatchingCriteria(retobj.cars); } } let SearchCarsByMileage = async ()=> { let mileage = document.getElementById("mileage").value; if (mileage !== "all") { dealer_url = dealer_url + "?mileage="+mileage; } const res = await fetch(dealer_url, { method: 'GET', headers: { 'Content-Type': 'application/json', }}) const retobj = await res.json(); if(retobj.status === 200) { setCarsmatchingCriteria(retobj.cars); } } let SearchCarsByPrice = async ()=> { let price = document.getElementById("price").value; if(price !== "all") { dealer_url = dealer_url + "?price="+price; } const res = await fetch(dealer_url, { method: 'GET', headers: { 'Content-Type': 'application/json', }}) const retobj = await res.json(); if(retobj.status === 200) { setCarsmatchingCriteria(retobj.cars); } } const reset = ()=>{ const selectElements = document.querySelectorAll('select'); selectElements.forEach((select) => { select.selectedIndex = 0; }); fetchCars(); } useEffect(() => { fetchCars(); fetchDealer(); },[]); return ( <div> <Header /> <h1 style={{ marginBottom: '20px'}}>Cars at {dealer.full_name}</h1> <div> <span style={{ marginLeft: '10px', paddingLeft: '10px'}}>Make</span> <select style={{ marginLeft: '10px', marginRight: '10px' ,paddingLeft: '10px', borderRadius :'10px'}} name="make" id="make" onChange={SearchCarsByMake}> {makes.length === 0 ? ( <option value=''>No data found</option> ):( <> <option disabled defaultValue> -- All -- </option> {makes.map((make, index) => ( <option key={index} value={make}> {make} </option> ))} </> ) } </select> <span style={{ marginLeft: '10px', paddingLeft: '10px'}}>Model</span> <select style={{ marginLeft: '10px', marginRight: '10px' ,paddingLeft: '10px', borderRadius :'10px'}} name="model" id="model" onChange={SearchCarsByModel}> {models.length === 0 ? ( <option value=''>No data found</option> ) : ( <> <option disabled defaultValue> -- All -- </option> {models.map((model, index) => ( <option key={index} value={model}> {model} </option> ))} </> )} </select> <span style={{ marginLeft: '10px', paddingLeft: '10px'}}>Year</span> <select style={{ marginLeft: '10px', marginRight: '10px' ,paddingLeft: '10px', borderRadius :'10px'}} name="year" id="year" onChange={SearchCarsByYear}> <option selected value='all'> -- All -- </option> <option value='2024'>2024 or newer</option> <option value='2023'>2023 or newer</option> <option value='2022'>2022 or newer</option> <option value='2021'>2021 or newer</option> <option value='2020'>2020 or newer</option> </select> <span style={{ marginLeft: '10px', paddingLeft: '10px'}}>Mileage</span> <select style={{ marginLeft: '10px', marginRight: '10px' ,paddingLeft: '10px', borderRadius :'10px'}} name="mileage" id="mileage" onChange={SearchCarsByMileage}> <option selected value='all'> -- All -- </option> <option value='50000'>Under 50000</option> <option value='100000'>50000 - 100000</option> <option value='150000'>100000 - 150000</option> <option value='200000'>150000 - 200000</option> <option value='200001'>Over 200000</option> </select> <span style={{ marginLeft: '10px', paddingLeft: '10px'}}>Price</span> <select style={{ marginLeft: '10px', marginRight: '10px' ,paddingLeft: '10px', borderRadius :'10px'}} name="price" id="price" onChange={SearchCarsByPrice}> <option selected value='all'> -- All -- </option> <option value='20000'>Under 20000</option> <option value='40000'>20000 - 40000</option> <option value='60000'>40000 - 60000</option> <option value='80000'>60000 - 80000</option> <option value='80001'>Over 80000</option> </select> <button style={{marginLeft: '10px', paddingLeft: '10px'}} onClick={reset}>Reset</button> </div> <div style={{ marginLeft: '10px', marginRight: '10px' , marginTop: '20px'}} > {cars.length === 0 ? ( <p style={{ marginLeft: '10px', marginRight: '10px', marginTop: '20px' }}>{message}</p> ) : ( <div> <hr/> {cars.map((car) => ( <div> <div key={car._id}> <h3>{car.make} {car.model}</h3> <p>Year: {car.year}</p> <p>Mileage: {car.mileage}</p> <p>Price: {car.price}</p> </div> <hr/> </div> ))} </div> )} </div> </div> );};export default SearchCars; 为该组件添加路由并构建前端。 在 frontend/src/App.js 中为该组件添加导入语句和路由,将搜索路径设为 /searchcars/:id。 import SearchCars from "./components/Dealers/SearchCars"; <Route path="/searchcars/:id" element={<SearchCars />} /> 整合锚链接,将用户从特定经销商的评论页面重定向到 Search Cars 页面。 转到 frontend/src/components/Dealers/Dealer.jsx,插入一个锚元素。 将其标记为 Search Cars,并设置为点击后导航至 Search Cars 页面。 <a href={`/searchcars/${id}`}>SearchCars</a> 构建应用程序的前端,以便部署。 npm run build 在 djangoproj/urls.py 中添加路径为 searchcars/<int:dealer_id> 的动态 URL 模式。 path('searchcars/<int:dealer_id>',TemplateView.as_view(template_name="index.html")),

2024/7/7
articleCard.readMore

Hexo 本地搜索插件教程

随着文章数量的增加,我们的博客网站自然是不能只有归档或者标签页的,更别提有时候我们不记得标题、只记得文章内一个简短的词汇。 一个简单的本地搜索栏可以帮到我们。 本文章使用到了 Hexo-Generator-Search 库。 本文章使用的是 Pug 模板语言。 安装依赖 npm install --save hexo-generator-search Hexo-Generator-Search 会生成搜索索引文件,其中包含文章的所有必要数据,可用于为博客编写本地搜索引擎。它支持 XML 和 JSON 格式输出,我们这里会使用 XML。 两者的输出区别可见官方仓库的示例。 在博客根目录中的 _config.yml 文件内添加以下配置项: search: path: search.xml field: post content: true template: ./search.xml path:文件路径。默认为 search.xml。如果文件扩展名为 .json,则输出格式为 JSON。否则将输出 XML 格式文件。 值得注意的是,这里的路径指的是 public 路径。 field:您要搜索的搜索范围,您可以选择: post(默认):仅涵盖博客的所有文章。 page:只涵盖博客的所有页面。 all:将涵盖博客的所有文章和页面。 页面 指的是 Hexo 中 archive、tags 等页面。 content:是否包含每篇文章的全部内容。如果为 false,则生成的结果只包括标题和其他元信息,而不包括正文。默认为 true。 template(可选):自定义 XML 模板的路径。 在博客根目录中添加 search.xml,用作生成搜索索引的模板: <?xml version="1.0" encoding="utf-8"?><search> {% if posts %} {% for post in posts.toArray() %} {% if post.indexing == undefined or post.indexing %} <entry> <title>{{ post.title }}</title> <link href="{{ (url + post.path) | uriencode }}"/> <url>{{ (url + post.path) | uriencode }}</url> {% if content %} <content type="html"><![CDATA[{{ post.content | noControlChars | safe }}]]></content> {% endif %} {% if post.categories and post.categories.length>0 %} <categories> {% for cate in post.categories.toArray() %} <category> {{ cate.name }} </category> {% endfor %} </categories> {% endif %} {% if post.tags and post.tags.length>0 %} <tags> {% for tag in post.tags.toArray() %} <tag> {{ tag.name }} </tag> {% endfor %} </tags> {% endif %} </entry> {% endif %} {% endfor %} {% endif %} {% if pages %} {% for page in pages.toArray() %} {% if post.indexing == undefined or post.indexing %} <entry> <title>{{ page.title }}</title> <link href="{{ (url + page.path) | uriencode }}"/> <url>{{ (url + page.path) | uriencode }}</url> {% if content %} <content type="html"><![CDATA[{{ page.content | noControlChars | safe }}]]></content> {% endif %} </entry> {% endif %} {% endfor %} {% endif %}</search> 官方仓库中提到,当运行以下命令后就可以在 public 路径中看到生成的结果: hexo g 显示结果 首先需要清楚一件事:Hexo-Generator-Search 仅 生成搜索索引文件!你要如何使用这个文件就是你自己的事情,包括写不写搜索栏、怎么写搜索结果等。此处仅讲解我自己的方案,极大参考了 Hexo-Theme-Freemind 的写法。 部分主题自带了本地搜索功能,建议先看一下你使用的主题是否有内置。 Hexo-Theme-Freemind 使用了 AJAX 和 jQuery。从网上找到最新的 jQuery CDN(要 minified 的),并将 script 标签写在 head.pug 中: script(src='https://code.jquery.com/jquery-3.7.1.min.js', integrity='sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=', crossorigin='anonymous') 这样,我们就可以使用 jQuery 了。 jQuery 是一个轻量级的 JavaScript 库,使得开发者在网站上使用 JavaScript 更容易更方便。 例如,我们想要在页面加载时添加一个动画效果,就可以写: script. $(document).ready(function() { $("body").fadeIn(2000); }); $ 符号在 jQuery 中代表一个函数,也是 jQuery 对象的别名,可以靠它来选择和操作 HTML 元素。 AJAX 是一种在客户端创建异步 Web 应用程序的 Web 开发技术。它允许 Web 应用程序在不干扰现有页面显示和行为的情况下,异步地从服务器发送和检索数据。这意味着可以更新网页的部分内容,而无需重新加载整个页面。 编写搜索视图 搜索视图是用于显示搜索表单和搜索结果的地方。最直观的说法就是用户看到的搜索栏。 Hexo-Theme-Freemind 使用的是 EJS 模板语言,感兴趣的可以 看一眼。 由于我使用的是单列主题 Hexo-Theme-Hermes,整个页面就没有什么侧边栏等地方存放搜索栏。 而多列主题(例如 Hexo-Theme-Freemind)就可以在侧边栏内直接塞入搜索栏。 为了不破坏单列主题的核心概念,我决定在导航栏内添加一个搜索图标。当用户点击图标后,一个搜索窗口会弹出来,内含搜索栏以及显示搜索结果的子容器。用户再次点击搜索图标或者点击搜索窗口外的区域都会导致搜索窗口消失。 搜索图标我使用的是 FontAwesome 的 fas fa-search。从 FontAwesome 那边注册、拿到属于自己的 kit 后,在 head.pug 中添加: script(src="https://kit.fontawesome.com/########.js", crossorigin="anonymous") Hexo-Theme-Hermes 的导航栏写法如下: ul.nav.nav-list each value, key in theme.menu li.nav-list-item - var re = /^(http|https):\/\/*/gi; - var tar = re.test(value) ? "_blank" : "_self" - var act = !re.test(value) && "/"+page.current_url === value a.nav-list-link(class={active: act} href=url_for(value) target=tar) != key.toUpperCase() 它是根据 theme 路径的配置项依次添加 li 标签,我们的搜索图标可以直接添加到 ul 标签的内部(和 each value, key in theme.menu 同级): if config.search li.nav-list-item#search-icon i.fas.fa-search CSS 样式: #search-icon { cursor: pointer;} cursor 属性设置鼠标指针在元素上方时显示的光标类型。pointer 表示光标将显示为一个指向链接的手指图标,通常在链接或可点击元素上使用,以向用户表明该元素可以被点击。这有助于提高用户界面的可用性。 接着我们来快速写一个显示搜索栏和搜索结果的容器。我要求不多,就是一个方方正正的小窗口在页面正中间。 div#search-popup.hidden div#search-panel input(type="text", id="local-search-input", name="q", results="0", placeholder=__('搜索...')) div#local-search-results CSS 样式: .hidden { display: none;}#search-popup { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 75%; height: 50%; padding: 20px; border: 3px solid #ccc; background-color: #fff; text-align: center; z-index: 10;}#search-popup input[type="text"] { display: block;} 该窗口必须初始状态为隐藏,显现后则是一个固定在屏幕中心的弹出窗口,文本内容居中对齐。如果需要输入文本,输入框就会块级显示。 因为我希望该弹窗在 Z 轴上的位置优先于其他,我就写了个 z-index: 10(实际测试时发现 position: absolute 的类会优先于 #search-popup,才决定加上的)。 #search-panel { display: flex; flex-direction: column; padding: 0; margin: 0; height: 100%; width: 100%;} #search-panel 的主要目的是将搜索栏和搜索结果以更简单的方式分开。Flexbox 布局好用、爱用、不用不会写。 #local-search-input { width: 100%; margin-bottom: 1rem;}#local-search-results { overflow-y: auto; flex-grow: 1;} 一些之后会用到的 CSS 样式: em.search-keyword { border-bottom: 1px dashed #4088b8; font-weight: bold;}ul.search-result-list { padding-left: 10px;}a.search-result-title { font-weight: bold;}p.search-result { color: #555;} 最终结果: 写的很随便也不好看,未来再美化吧。能用就行。 添加弹出窗口的显示逻辑 只是单单写一个图标和窗口还不够,我们还没有添加窗口的显示逻辑。先前说了,我希望的是点击图标之后,窗口会显示;再次点击图标,窗口会隐藏。 在 Pug 文件中添加 script 标签: script. document.getElementById('search-icon').addEventListener('click', function(event) { const popup = document.getElementById('search-popup'); if (popup.classList.contains('hidden')) { popup.classList.remove('hidden'); } else { popup.classList.add('hidden'); } event.stopPropagation(); }); document.getElementById('search-popup').addEventListener('click', function(event) { event.stopPropagation(); }); document.addEventListener('click', function() { const popup = document.getElementById('search-popup'); if (!popup.classList.contains('hidden')) { popup.classList.add('hidden'); } }); 当用户点击 ID 为 search-icon 的元素时,会触发事件监听器。监听器会首先获取 ID 为 search-popup 的元素,然后检查该元素是否包含 hidden 类。如果是,就移除这个类;反之添加这个类。最后调用 event.stopPropagation() 来阻止事件冒泡、传播到父元素。 当用户点击 ID 为 search-popup 的元素时,也会触发一个事件监听器。这个监听器只会做一件事,那就是调用 event.stopPropagation() 来阻止事件冒泡。这样做的目的是防止当用户在弹出窗口上点击时,触发下面的文档点击事件监听器。 当用户点击文档的任何地方时,会触发一个事件监听器。它首先会获取 ID 为 search-popup 的元素,然后检查是否包含 hidden 类。如果不是,就添加这个类。这样的话,每当用户点击弹出窗口以外的任何地方时,弹出窗口就会隐藏。 编写搜索脚本 搜索脚本会告诉浏览器如何抓取搜索数据,并过滤出我们要搜索的内容。 这里我近乎是照搬了 Hexo-Theme-Freemind 的 写法,只修改了一丢丢细节。 未来可能会尝试改进这段代码。先挖坑了。 const searchFunc = function (path, search_id, content_id) { 'use strict'; $.ajax({ url: path, dataType: "xml", success: function (xmlResponse) { const datas = $("entry", xmlResponse).map(function () { return { title: $("title", this).text(), content: $("content", this).text(), url: $("url", this).text() }; }).get(); const $input = document.getElementById(search_id); if (!$input) return; const $resultContent = document.getElementById(content_id); if ($("#local-search-input").length > 0) { $input.addEventListener('input', function () { let str = '<ul class=\"search-result-list\">'; const keywords = this.value.trim().toLowerCase().split(/[\s\-]+/); $resultContent.innerHTML = ""; if (this.value.trim().length <= 0) { return; } datas.forEach(function (data) { let isMatch = true; const content_index = []; if (!data.title || data.title.trim() === '') { data.title = "Untitled"; } const data_title = data.title.trim(); const data_content = data.content.trim().replace(/<[^>]+>/g, ""); const data_url = data.url; let index_title = -1; let index_content = -1; let first_occur = -1; if (data_content !== '') { keywords.forEach(function (keyword, i) { index_title = data_title.toLowerCase().indexOf(keyword.toLowerCase()); index_content = data_content.toLowerCase().indexOf(keyword.toLowerCase()); if (index_title < 0 && index_content < 0) { isMatch = false; } else { if (index_content < 0) { index_content = 0; } if (i === 0) { first_occur = index_content; } } }); } else { isMatch = false; } if (isMatch) { str += "<li><a href='" + data_url + "' class='search-result-title'>" + data_title + "</a>"; const content = data.content.trim().replace(/<[^>]+>/g, ""); if (first_occur >= 0) { let start = first_occur - 20; let end = first_occur + 80; if (start < 0) { start = 0; } if (start === 0) { end = 100; } if (end > content.length) { end = content.length; } let match_content = content.substring(start, end); keywords.forEach(function (keyword) { const regS = new RegExp(keyword, "gi"); match_content = match_content.replace(regS, function(match) { return "<em class=\"search-keyword\">" + match + "</em>"; }); }); str += "<p class=\"search-result\">" + match_content + "...</p>" } str += "</li>"; } }); str += "</ul>"; $resultContent.innerHTML = str; }); } } });}; 函数定义和 AJAX 请求: const searchFunc = function (path, search_id, content_id) { 'use strict'; $.ajax({ url: path, dataType: "xml", success: function (xmlResponse) { // ... 后续代码 } });}; 这段代码定义了一个名为 searchFunc 的函数,它接受三个参数: path:XML 文件的路径 search_id:搜索栏的 ID content_id:搜索结果显示区域的 ID 函数使用 jQuery 的 AJAX 方法从指定的 path 获取 XML 数据。 数据处理: const datas = $("entry", xmlResponse).map(function () { return { title: $("title", this).text(), content: $("content", this).text(), url: $("url", this).text() };}).get(); 处理从 XML 响应中获取的数据,将每个 entry 元素转换为包含 title、content 和 url 的对象数组。 这里使用了 jQuery 的 map 方法来遍历 XML 元素,并使用 get() 来将结果转换为普通数组。 搜索输入监听: const $input = document.getElementById(search_id);if (!$input) return;const $resultContent = document.getElementById(content_id);if ($("#local-search-input").length > 0) { $input.addEventListener('input', function () { // ... 搜索逻辑 });} 设置对搜索输入框的监听。当用户在搜索栏中输入内容时就会触发搜索。 这段代码混合使用了原生 JavaScript(document.getElementById 和 addEventListener)和 jQuery($("local-search-input").length)。 搜索逻辑: let str = '<ul class=\"search-result-list\">';const keywords = this.value.trim().toLowerCase().split(/[\s\-]+/);$resultContent.innerHTML = "";if (this.value.trim().length <= 0) { return;}datas.forEach(function (data) { // ... 匹配逻辑});str += "</ul>";$resultContent.innerHTML = str; 这是搜索功能的核心逻辑。将输入的搜索词与数据进行匹配,并构建搜索结果 HTML。 匹配和高亮显示: if (isMatch) { str += "<li><a href='" + data_url + "' class='search-result-title'>" + data_title + "</a>"; const content = data.content.trim().replace(/<[^>]+>/g, ""); if (first_occur >= 0) { // ... 截取匹配内容 keywords.forEach(function (keyword) { const regS = new RegExp(keyword, "gi"); match_content = match_content.replace(regS, function(match) { return "<em class=\"search-keyword\">" + match + "</em>"; }); }); str += "<p class=\"search-result\">" + match_content + "...</p>" } str += "</li>";} 处理匹配结果的显示和关键词高亮。此处使用了正则表达式去除 HTML 标签,使用 substring 截取匹配上下文,然后使用 replace 和正则表达式实现关键词高亮。 将 search.js 放在 theme/hermes/source/js 路径下,运行 hexo g 之后便会出现在 public/js 路径下。 连接视图和脚本 有了搜索视图和搜索脚本后,我们就可以把两者连接在一起。 在 Pug 文件中添加 script 标签: if config.search script(src="/js/search.js") script. let search_path = "#{config.search.path}"; if (search_path.length === 0) { search_path = "search.xml"; } const path = "#{config.root}" + search_path; searchFunc(path, 'local-search-input', 'local-search-results'); 首先是引入搜索脚本 search.js。前面说过,该脚本在 public 路径下时会是在 js 路径下。接着调用 search.js 中我们定义好的 searchFunc 函数。 最终效果:

2024/7/3
articleCard.readMore

ByteByteGo 简报翻译【1】:Netflix 如何管理 2.38 亿会员?

开了个新坑,近期从 Instagram 的广告上得知了 ByteByteGo,并且订阅了他们的简报(Newsletter)服务,基本上隔个两三天就会收到他们的简报邮件。其中有些简报让我非常感兴趣,故分享。 2024 年 06 月 25 日:Netflix 如何管理 2.38 亿会员?(How Netflix Manages 238 Million Memberships?) 如果侵权,请联系我删除! 简报内容 免责声明:本文章中的细节来自 Netflix 工程团队的文章 / 演讲。所有架构细节均归功于 Netflix 工程团队。原文链接见文末参考文献部分。我们尝试分析了这些细节,并提供了我们的意见。如果您发现任何不准确或遗漏之处,请留下评论,我们将尽力修正。 作为一家基于订阅的流媒体服务公司,Netflix 的主要收入来源是会员业务。目前,Netflix 在全球拥有 2.38 亿名会员,有效管理会员对公司的成功和持续增长至关重要。 Netflix 的会员平台在处理用户订阅的整个生命周期中发挥着至关重要的作用。 会员生命周期由不同阶段和场景组成: 注册: 用户可直接或通过 T-Mobile 等合作伙伴渠道注册 Netflix 服务,开始 Netflix 之旅。 计划变更: 现有会员可根据自己的喜好和需求修改订阅计划。 续订: 作为一项订阅服务,Netflix 会自动尝试使用与用户账户相关联的付款方式为用户续订计划。 付款问题: 如果支付网关出现问题或资金不足,用户的账户可能会被暂停或给予宽限期来解决问题。 会员资格暂停或取消: 用户可以选择暂时中止会员资格或永久取消订阅。 在下面的章节中,我们将探讨 Netflix 工程团队为支持其会员平台的各种功能和可扩展性而做出的架构决策。 Netflix 会员平台的高级架构 在深入了解 Netflix 会员制平台的细节之前,让我们先回顾一下该公司最初的定价架构是如何设计的。 早期,Netflix 的定价模型相对简单,只需管理少量计划和支持基本功能。 为了满足这些最初的要求,Netflix 采用了轻量级的内存库。事实证明,这种方法相当高效,因为定价系统的范围有限,因此设计简单、精简。 下图展示了这一基本架构: #mermaid-1760500296165{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-1760500296165 .error-icon{fill:#552222;}#mermaid-1760500296165 .error-text{fill:#552222;stroke:#552222;}#mermaid-1760500296165 .edge-thickness-normal{stroke-width:2px;}#mermaid-1760500296165 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-1760500296165 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-1760500296165 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-1760500296165 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-1760500296165 .marker{fill:#333333;stroke:#333333;}#mermaid-1760500296165 .marker.cross{stroke:#333333;}#mermaid-1760500296165 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-1760500296165 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-1760500296165 .cluster-label text{fill:#333;}#mermaid-1760500296165 .cluster-label span,#mermaid-1760500296165 p{color:#333;}#mermaid-1760500296165 .label text,#mermaid-1760500296165 span,#mermaid-1760500296165 p{fill:#333;color:#333;}#mermaid-1760500296165 .node rect,#mermaid-1760500296165 .node circle,#mermaid-1760500296165 .node ellipse,#mermaid-1760500296165 .node polygon,#mermaid-1760500296165 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-1760500296165 .flowchart-label text{text-anchor:middle;}#mermaid-1760500296165 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-1760500296165 .node .label{text-align:center;}#mermaid-1760500296165 .node.clickable{cursor:pointer;}#mermaid-1760500296165 .arrowheadPath{fill:#333333;}#mermaid-1760500296165 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-1760500296165 .flowchart-link{stroke:#333333;fill:none;}#mermaid-1760500296165 .edgeLabel{background-color:#e8e8e8;text-align:center;}#mermaid-1760500296165 .edgeLabel rect{opacity:0.5;background-color:#e8e8e8;fill:#e8e8e8;}#mermaid-1760500296165 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-1760500296165 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-1760500296165 .cluster text{fill:#333;}#mermaid-1760500296165 .cluster span,#mermaid-1760500296165 p{color:#333;}#mermaid-1760500296165 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-1760500296165 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-1760500296165 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Netflix 服务 会员服务 Netflix 计划和定价库 Netflix 定价服务初步设计 随着 Netflix 全球业务的扩展和产品的多样化,最初为定价架构提供服务的轻量级内存库变得不敷使用。 定价目录的复杂性和范围不断扩大,其在多个应用程序中的重要性也与日俱增,这给运营带来了挑战。该库的规模和依赖性使其难以维护和扩展,因此有必要过渡到更强大和可扩展的架构。 下图显示了 Netflix 现代会员制平台的高层架构: #mermaid-1760500299403{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-1760500299403 .error-icon{fill:#552222;}#mermaid-1760500299403 .error-text{fill:#552222;stroke:#552222;}#mermaid-1760500299403 .edge-thickness-normal{stroke-width:2px;}#mermaid-1760500299403 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-1760500299403 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-1760500299403 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-1760500299403 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-1760500299403 .marker{fill:#333333;stroke:#333333;}#mermaid-1760500299403 .marker.cross{stroke:#333333;}#mermaid-1760500299403 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-1760500299403 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-1760500299403 .cluster-label text{fill:#333;}#mermaid-1760500299403 .cluster-label span,#mermaid-1760500299403 p{color:#333;}#mermaid-1760500299403 .label text,#mermaid-1760500299403 span,#mermaid-1760500299403 p{fill:#333;color:#333;}#mermaid-1760500299403 .node rect,#mermaid-1760500299403 .node circle,#mermaid-1760500299403 .node ellipse,#mermaid-1760500299403 .node polygon,#mermaid-1760500299403 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-1760500299403 .flowchart-label text{text-anchor:middle;}#mermaid-1760500299403 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-1760500299403 .node .label{text-align:center;}#mermaid-1760500299403 .node.clickable{cursor:pointer;}#mermaid-1760500299403 .arrowheadPath{fill:#333333;}#mermaid-1760500299403 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-1760500299403 .flowchart-link{stroke:#333333;fill:none;}#mermaid-1760500299403 .edgeLabel{background-color:#e8e8e8;text-align:center;}#mermaid-1760500299403 .edgeLabel rect{opacity:0.5;background-color:#e8e8e8;fill:#e8e8e8;}#mermaid-1760500299403 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-1760500299403 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-1760500299403 .cluster text{fill:#333;}#mermaid-1760500299403 .cluster span,#mermaid-1760500299403 p{color:#333;}#mermaid-1760500299403 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-1760500299403 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-1760500299403 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 会员宽限期服务 会员状态管理服务 订阅服务 Cassandra 订阅数据库 Cassandra 订阅历史数据库 Apache Spark 对账工作 订阅历史服务 kafka 会员捆绑包服务 会员应用商店服务 会员定价服务 计划定价目录服务 CockroachDB 计划和定价表 代码兑换服务 CockroachDB 代码兑换表 Netflix 会员制服务的架构 会员平台由十几个微服务组成,旨在支持四个九(99.99%)的可用性。 这种高可用性要求源于该平台在各种面向用户的流程中发挥的关键作用。如果任何服务出现宕机,都会直接影响用户体验。 该平台支持多个关键功能: 当用户点击 Netflix 上的播放按钮时,会直接呼叫会员系统,以确定与其计划相关的服务质量。用户允许的并发流和支持的设备等因素都在考虑之列。由于 Netflix 每天都要处理数十亿个流媒体请求,因此该流程处理的流量最大。 当用户访问其账户页面时会触发会员流。更改计划、管理额外会员和取消会员资格等操作会直接与会员服务交互。 该平台是任何特定时间点会员总数的权威来源。它发出事件并写入持久存储,Netflix 内部和外部的下游分析系统都会使用该存储。 架构图的要点如下: 会员平台在全球范围内管理会员计划和定价目录,不同地区的计划和定价目录各不相同。计划定价目录服务处理基于特定地点产品的规则管理。 两个 CockroachDB 数据库用于存储计划定价和代码兑换信息。会员定价服务支持会员操作,如更改计划或添加额外会员。 专门的微服务负责处理与合作伙伴的互动,包括捆绑激活、注册以及与苹果应用商店等平台的集成。 会员数据存储在 Cassandra 数据库中,该数据库支持订阅服务和历史跟踪服务。 该平台不仅满足 2.38 亿活跃会员的需求,还关注前会员和重新加入会员的体验。 该平台生成的数据将提供给下游消费者,以便他们了解注册情况和收入预测。 Netflix 选择使用 CockroachDB 和 Cassandra 很有意思。 CockroachDB 具有很强的一致性,适合处理计划定价信息等关键数据,而 Cassandra 则是一种高度可扩展的 NoSQL 数据库,适合处理大量会员数据。 此外,99.99% 的可用性也表明 Cassandra 非常注重弹性和容错性。无论如何,Netflix 以其全面的混乱工程实践而闻名,它可以主动测试系统的弹性。 注册流程 一旦用户开始 Netflix 之旅,他们就会遇到选择计划的选项。 由于货币、定价和可用计划存在地域差异,因此准确呈现计划选择页面至关重要。Netflix 的会员制平台可确保根据用户的位置和设备类型向其展示适当的选项。 下图显示了 Netflix 注册流程的详细步骤以及在流程中触发的服务: #mermaid-1760500301612{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-1760500301612 .error-icon{fill:#552222;}#mermaid-1760500301612 .error-text{fill:#552222;stroke:#552222;}#mermaid-1760500301612 .edge-thickness-normal{stroke-width:2px;}#mermaid-1760500301612 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-1760500301612 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-1760500301612 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-1760500301612 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-1760500301612 .marker{fill:#333333;stroke:#333333;}#mermaid-1760500301612 .marker.cross{stroke:#333333;}#mermaid-1760500301612 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-1760500301612 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-1760500301612 .cluster-label text{fill:#333;}#mermaid-1760500301612 .cluster-label span,#mermaid-1760500301612 p{color:#333;}#mermaid-1760500301612 .label text,#mermaid-1760500301612 span,#mermaid-1760500301612 p{fill:#333;color:#333;}#mermaid-1760500301612 .node rect,#mermaid-1760500301612 .node circle,#mermaid-1760500301612 .node ellipse,#mermaid-1760500301612 .node polygon,#mermaid-1760500301612 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-1760500301612 .flowchart-label text{text-anchor:middle;}#mermaid-1760500301612 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-1760500301612 .node .label{text-align:center;}#mermaid-1760500301612 .node.clickable{cursor:pointer;}#mermaid-1760500301612 .arrowheadPath{fill:#333333;}#mermaid-1760500301612 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-1760500301612 .flowchart-link{stroke:#333333;fill:none;}#mermaid-1760500301612 .edgeLabel{background-color:#e8e8e8;text-align:center;}#mermaid-1760500301612 .edgeLabel rect{opacity:0.5;background-color:#e8e8e8;fill:#e8e8e8;}#mermaid-1760500301612 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-1760500301612 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-1760500301612 .cluster text{fill:#333;}#mermaid-1760500301612 .cluster span,#mermaid-1760500301612 p{color:#333;}#mermaid-1760500301612 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-1760500301612 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-1760500301612 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 1. 检索计划详情 2. 基于区域、设备类型的加载和读取计划 3. 展示选项 4. 会员选择了一个计划 5. 会员服务启动 5. 持续订阅信息 6. 写入历史服务 7. 通知计费 8. 生成发票 9. 发布事件 增长工程应用程序 会员计划目录 规则 SKUDB 选择计划页面 付款页面 会员状态服务 会员订阅服务 会员历史服务 计费工程应用程序 会员定价服务 事件 数据科学管道 消息管道 下面将详细介绍每个步骤: 首先,用户通过 Netflix 的增长工程应用程序选择计划。计划详情从会员计划目录服务中获取,该服务由 CockroachDB 支持。 会员计划目录服务根据预定义的地区和设备类型规则加载和读取计划。 然后将检索到的计划展示给用户,让他们根据自己的偏好和预算做出明智的决定。 用户选择计划后,流程会进入付款确认屏幕。在这里,用户提供付款详情并确认订阅。 确认后,用户点击 Start Membership 按钮,触发会员状态服务。该服务会将相关信息(如所选计划、价格层级和国家)保存到 Cassandra 数据库中。 会员状态服务还会将付款情况通知计费工程应用程序。 计费工程应用程序根据从会员定价服务获取的注册数据生成发票。 会员数据会同时写入会员历史服务,以确保全面记录用户的订阅历史。 发布事件以提示激活会员资格。这些事件会触发信息管道,负责向用户发送欢迎邮件,并通知下游系统以进行分析。 如何跟踪会员历史? 在 Netflix 会员平台的早期阶段,会员历史和数据是通过应用程序级事件来跟踪的。 虽然这种方法在初期已经足够,但随着 Netflix 的扩张和会员数据复杂性的增加,显然需要一种更精细、更持久的数据跟踪解决方案。 为了满足这一需求,Netflix 开发了基于变更数据捕获(CDC)模式的强大解决方案。 作为参考,CDC 是一种设计模式,可直接捕获对数据库所做的更改,并将这些更改传播到下游系统进行进一步处理或分析。 下图显示了 CDC 流程的工作原理: #mermaid-1760500304245{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-1760500304245 .error-icon{fill:#552222;}#mermaid-1760500304245 .error-text{fill:#552222;stroke:#552222;}#mermaid-1760500304245 .edge-thickness-normal{stroke-width:2px;}#mermaid-1760500304245 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-1760500304245 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-1760500304245 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-1760500304245 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-1760500304245 .marker{fill:#333333;stroke:#333333;}#mermaid-1760500304245 .marker.cross{stroke:#333333;}#mermaid-1760500304245 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-1760500304245 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-1760500304245 .cluster-label text{fill:#333;}#mermaid-1760500304245 .cluster-label span,#mermaid-1760500304245 p{color:#333;}#mermaid-1760500304245 .label text,#mermaid-1760500304245 span,#mermaid-1760500304245 p{fill:#333;color:#333;}#mermaid-1760500304245 .node rect,#mermaid-1760500304245 .node circle,#mermaid-1760500304245 .node ellipse,#mermaid-1760500304245 .node polygon,#mermaid-1760500304245 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-1760500304245 .flowchart-label text{text-anchor:middle;}#mermaid-1760500304245 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-1760500304245 .node .label{text-align:center;}#mermaid-1760500304245 .node.clickable{cursor:pointer;}#mermaid-1760500304245 .arrowheadPath{fill:#333333;}#mermaid-1760500304245 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-1760500304245 .flowchart-link{stroke:#333333;fill:none;}#mermaid-1760500304245 .edgeLabel{background-color:#e8e8e8;text-align:center;}#mermaid-1760500304245 .edgeLabel rect{opacity:0.5;background-color:#e8e8e8;fill:#e8e8e8;}#mermaid-1760500304245 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-1760500304245 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-1760500304245 .cluster text{fill:#333;}#mermaid-1760500304245 .cluster span,#mermaid-1760500304245 p{color:#333;}#mermaid-1760500304245 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-1760500304245 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-1760500304245 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 目标系统 交易 数据库 CDC 进程 缓存 数据仓 交易日志 交易表 数据库 CDC 如何工作? 采用类似 CDC 的方法可确保对会员数据源所做的所有 delta 更改都记录在一个仅有附件的日志系统中,该日志系统由 Cassandra 数据库提供支持。 下图显示了 Netflix 会员平台的历史数据流: #mermaid-1760500305455{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-1760500305455 .error-icon{fill:#552222;}#mermaid-1760500305455 .error-text{fill:#552222;stroke:#552222;}#mermaid-1760500305455 .edge-thickness-normal{stroke-width:2px;}#mermaid-1760500305455 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-1760500305455 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-1760500305455 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-1760500305455 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-1760500305455 .marker{fill:#333333;stroke:#333333;}#mermaid-1760500305455 .marker.cross{stroke:#333333;}#mermaid-1760500305455 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-1760500305455 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-1760500305455 .cluster-label text{fill:#333;}#mermaid-1760500305455 .cluster-label span,#mermaid-1760500305455 p{color:#333;}#mermaid-1760500305455 .label text,#mermaid-1760500305455 span,#mermaid-1760500305455 p{fill:#333;color:#333;}#mermaid-1760500305455 .node rect,#mermaid-1760500305455 .node circle,#mermaid-1760500305455 .node ellipse,#mermaid-1760500305455 .node polygon,#mermaid-1760500305455 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-1760500305455 .flowchart-label text{text-anchor:middle;}#mermaid-1760500305455 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-1760500305455 .node .label{text-align:center;}#mermaid-1760500305455 .node.clickable{cursor:pointer;}#mermaid-1760500305455 .arrowheadPath{fill:#333333;}#mermaid-1760500305455 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-1760500305455 .flowchart-link{stroke:#333333;fill:none;}#mermaid-1760500305455 .edgeLabel{background-color:#e8e8e8;text-align:center;}#mermaid-1760500305455 .edgeLabel rect{opacity:0.5;background-color:#e8e8e8;fill:#e8e8e8;}#mermaid-1760500305455 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-1760500305455 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-1760500305455 .cluster text{fill:#333;}#mermaid-1760500305455 .cluster span,#mermaid-1760500305455 p{color:#333;}#mermaid-1760500305455 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-1760500305455 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-1760500305455 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 1. 计费合作伙伴更新请求 2. 更新客户的账单合作伙伴 3. 为客户添加更新数据 4. 将数据插入会员历史表 5. 发出更新会员资格的事件 会员服务 会员订阅服务 Cassandra 会员历史服务 Cassandra 事件 新会员历史记录流程 让我们来看看这个过程的步骤: 假设有一个修改会员计费伙伴的更新请求。会员订阅服务接收到该请求。 会员订阅服务会处理该请求并更新 Cassandra 数据库中的相关信息。 除了更新主数据库外,用户的更新数据还会附加到会员历史服务中。该服务负责维护会员数据所有更改的历史记录。 会员历史服务将附加数据插入会员历史表。该表是历史数据的持久存储。 最后会发出一个事件,通知下游系统有关成员资格更新的信息。这样,其他服务和流程就能对变化做出反应,并执行任何必要的操作。 这种设计有多种好处: 详细调试:通过维护会员数据更改的全面历史记录,系统可以进行详细的调试和故障排除。开发人员可以追溯事件发生的顺序,了解数据是如何演变的。 事件回放和对账:日志系统的只附加特性允许重放事件。在数据损坏或不一致的情况下,系统可以通过从已知的良好状态重放事件来调节数据。 客户服务分析:会员历史记录服务捕获的历史数据使客户服务分析变得简单。 Netflix 会员平台的技术足迹 Netflix 会员制平台的技术架构可大致分为两个主要领域:开发和运营 / 监控。 让我们来详细了解每个领域。 开发堆栈 Netflix 会员制平台的开发堆栈可以用以下要点来描述: Netflix 的架构经过优化,可处理每秒高读取请求(RPS),以支持其庞大的用户群。 会员平台由超过 12 个微服务组成,这些微服务在 HTTP 层使用 gRPC 进行通信。通常情况下,该平台每秒可处理 300-400 万个请求。为了支持如此大的处理量,Netflix 采用了 gRPC 层的客户端缓存和整个记录的内存缓存等技术,以防止 CockroachDB 成为单点故障。 会员平台使用的主要编程语言是带有 Spring Boot 的 Java。不过,在某些重写场景中,Netflix 正在逐步过渡到 Kotlin。 Kafka 在消息传递和与其他团队(如消息传递和下游分析)的接口方面发挥着关键作用。这确保了不同系统间的顺畅通信和数据流。 Netflix 利用 Spark 和 Flink 对其大数据执行离线对账任务。这些对账工作对于保持会员平台内各种记录系统(如订阅和会员历史数据库)之间的数据一致性和一致性至关重要。数据的准确性还延伸到外部系统,确保整个生态系统的状态一致。 为确保在线系统的数据一致性,Netflix 采用了轻量级事务并使用 Cassandra 等数据库。这种方法保证了不同服务间数据的完整性和可靠性。 运行和监测 Netflix 非常重视可观察性和监控,以确保其会员平台的顺利运行: 广泛的日志记录、仪表板和分布式跟踪机制可实现快速的错误检测和解决。在 Netflix 复杂的微服务环境中,这些工具对于发现和排除故障至关重要。 设置了生产警报,以跟踪运营指标并保证最佳服务水平。 利用运营数据来推动机器学习模型,从而增强异常检测并启用自动问题解决流程。所有这些都是为了努力为用户提供不间断的流媒体体验。 Netflix 利用 Kibana 和 Elasticsearch 等工具创建仪表板并分析日志数据。在错误率激增的情况下,这些仪表盘可让团队快速识别导致问题的特定端点,并采取纠正措施。 结论 总之,Netflix 的会员平台是公司成功的关键组成部分,使其能够管理用户订阅的整个生命周期。该平台已从一个简单、轻量级的库发展成为一个强大、可扩展的架构,每秒可处理数百万个请求。 需要记住的一些关键要点如下: 会员平台负责管理用户注册、计划变更、续订和取消。 它采用微服务架构,使用 CockroachDB 和 Cassandra 等数据库存储会员数据。 该平台使用 CDC 捕获并存储会员数据的历史变化,用于调试、事件重放和分析。 参考文献 Managing 238M memberships at Netflix Netflix』s Membership Platform Presentation on Managing 238M memberships at Netflix

2024/6/29
articleCard.readMore

IBM 全栈开发【12】:Git 和 GitHub 入门

近期在学习 IBM 全栈应用开发微学士课程,故此记录学习笔记。 唉,本来还剩一个证就考完了,结果原先十二个证的微学士学位路线被标过期,最新的路线新添了这个 Git 和 GitHub 入门的证,100 美元喜减一。 1. Git 和 GitHub 基础知识 应用程序开发人员很少单独工作。大型网络/云/移动开发和数据科学项目将包括许多人 —— 前端开发员、后端开发员、数据库管理员、存储库管理员等。必须跟踪和控制每个贡献者的每次更改,以实现协作、问责制和版本管理。这种分布式版本控制在管理小型和大型软件项目时都极为重要。 在本模块中,你将了解一些流行的版本控制工具,创建一个 GitHub 账户,并使用 GitHub 网页界面创建一个仓库,向其中添加文件并提交更改。在 GitHub 等基于 Git 的版本控制系统中,分支是工作流程的核心。您还将熟悉创建和使用分支,并将更改合并到主分支。 学习目标: 定义分布式版本控制。 列出用于版本控制的几种工具,包括流行的 Git 平台。 描述 Git 的基本概念和仓库的目的。 在你的仓库中添加 / 编辑文件,并使用 GitHub 网页界面提交修改。 创建 GitHub 账户和仓库。 解释如何使用分支并描述拉取请求。 使用 GitHub Web 界面创建 GitHub 分支并执行合并操作。 1.1. Git 和 GitHub 概述 Git 和 GitHub 是深受开发人员和数据科学家欢迎的环境,但如果不了解版本控制的基本知识,就无法讨论它们。 版本控制系统可让您跟踪对源代码的更改。这样,如果您犯了错误,就可以很容易地恢复旧版本的文档。此外,它还能让与其他团队成员的协作变得更加容易。 用一个例子来说明版本控制是如何工作的:假设你有一份购物清单,希望室友们确认你需要的东西并添加其他物品。如果没有版本控制,在去购物之前就会有一大堆乱七八糟的东西要收拾,而有了版本控制,在每个人都献计献策之后,你就能清楚地知道自己需要什么。 Git 是根据通用公共许可证或 GNU 发布的免费开源软件。它是一种分布式版本控制系统(或称 DVCS),这意味着世界上任何地方的用户都可以在自己的电脑上拥有项目的副本。当他们进行修改时,他们可以将自己的版本同步到远程服务器,与你共享。 Git 并不是唯一的版本控制系统,但它的分布式特性是其成为最流行版本控制系统的主要原因之一。版本控制系统广泛应用于涉及代码的事务,但你也可以对图像、文档和任何文件类型进行版本控制。请注意,Git 还支持分支策略,如功能分支,以组织和管理开发工作。 GitHub 是最受欢迎的 Git 仓库网络托管服务之一。其他服务包括 Git Lab、Bitbucket 和 Beanstock。 在开始之前,你需要了解一些基本术语: Secure Shell 或简称 SSH 协议是一种从一台计算机安全远程登录到另一台计算机的方法。 Fork 是一个仓库的副本。 拉取请求(Pull Request 或简称 PR)是指你请求他人在你的修改成为最终版本之前对其进行审核和批准。 工作目录(Working Directory)包含计算机上与 Git 仓库相关联的文件和子目录。 提交(Commit)是项目当前状态在某个特定时间点的快照,并附有所做改动的说明。 分支(Branch)是一条独立的开发线,可以让你独立完成功能或修正。 合并(Merge)是将一个分支中的改动合并到另一个分支中,通常是将特性分支合并到主分支中。 克隆(Clone)会在你的电脑上创建一个远程 Git 仓库的本地副本。 1.1.1. GitHub 入门 2000 年代初,Linux 的开发由一个名为 BitKeeper 的免费系统管理。2005 年,BitKeeper 转为收费系统,出于多种原因,这给 Linux 开发人员带来了麻烦。Linus Torvalds 带领团队开发了一个替代的源代码版本控制系统。该项目在很短的时间内完成,主要特点由一个小组确定。其中包括: 对非线性开发的强大支持。(当时,Linux 补丁以每秒 6.7 个的速度到达) 分布式开发。每个开发人员都可以拥有一份完整开发历史的本地副本。 与现有系统和协议兼容。这是承认 Linux 社区多样性的必要条件。 高效处理大型项目。 历史加密验证。这确保分布式系统的代码更新完全一致。 可插拔的合并策略。许多开发途径都可能导致复杂的整合决策,可能需要明确的整合战略。 Git 仓库模型有什么特别之处?Git 是一个分布式版本控制系统。主要用于在开发过程中跟踪源代码。它包含在程序员之间进行协调、跟踪更改和支持非线性工作流程的元素。由 Linus Torvalds 于 2005 年创建,用于发布 Linux 内核。 Git 是一个分布式版本控制系统,用于跟踪内容变更。它是协作的中心点,尤其侧重于敏捷开发方法。在中央版本控制系统中,每个开发人员都需要从中央系统中签出代码并提交到中央系统中。由于 Git 是一种分布式版本控制系统,每个开发人员都有一份完整开发历史的本地副本,而且变更会从一个仓库复制到另一个仓库。每个开发人员都可以充当中枢。如果正确使用 Git,就会有一个与可部署代码相对应的主分支。团队可以持续整合已准备好发布的变更,并在两次发布之间同时在不同的分支上工作。 Git 还允许对任务进行集中管理,并对每个团队进行访问级别控制。Git 可通过 GitHub 桌面客户端等方式在本地使用,也可直接通过连接到 GitHub 网页界面的浏览器使用。IBM 云基于完善和成熟的开源工具,包括 Git 仓库(通常称为 repos)。 GitHub 是 Git 仓库的在线托管服务。GitHub 由微软的一家子公司托管。GitHub 提供免费、专业和企业账户。截至 2019 年 8 月,GitHub 拥有超过 1 亿个仓库。 仓库是用于存储文件(包括应用程序源代码)的数据结构。仓库可以跟踪和维护版本控制。 GitLab 是一个完整的 DevOps 平台,以单个应用程序的形式交付。GitLab 可访问由源代码管理控制的 Git 代码库。通过 GitLab,开发人员可以协作、审查代码、发表评论并帮助改进彼此的代码。使用自己的本地代码副本工作。在需要时对代码进行分支和合并。利用内置的持续集成(Cl)和持续交付(CD)简化测试和交付。 1.1.2. GitHub 分支 本课内有关注册 GitHub 账户和创建 GitHub 仓库的内容被我跳过了,建议去网上搜索相关的教程。 GitHub 中的所有文件都存储在一个分支中。主分支是确定的,它存储的是代码的可部署版本。主分支是默认创建的,但你也可以使用任何分支作为代码的主要、完成和可部署版本。当你计划更改内容时,可以创建一个新的分支,并给它起一个描述性的名字。新分支一开始就是原始分支的完全副本。当你进行更改时,你创建的分支会保留更改后的代码。 要创建新分支,请单击下拉菜单 Main,在新分支文本中添加新分支名称,然后选择 Create Branch。 对于大型软件项目来说,GitHub 分支可能非常复杂。对于我们正在探索的这种简单项目,可以考虑以下方法:从一个共同的基础开始,也就是这个项目的初始源。在开发新功能时,代码会被分支。 举个例子,两个分支都在进行修改。当两个工作流准备好合并时,每个分支的代码都会被识别为一个提示(tip),然后将两个提示合并为第三个合并分支。 #mermaid-1760500296129{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-1760500296129 .error-icon{fill:#552222;}#mermaid-1760500296129 .error-text{fill:#552222;stroke:#552222;}#mermaid-1760500296129 .edge-thickness-normal{stroke-width:2px;}#mermaid-1760500296129 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-1760500296129 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-1760500296129 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-1760500296129 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-1760500296129 .marker{fill:#333333;stroke:#333333;}#mermaid-1760500296129 .marker.cross{stroke:#333333;}#mermaid-1760500296129 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-1760500296129 .commit-id,#mermaid-1760500296129 .commit-msg,#mermaid-1760500296129 .branch-label{fill:lightgrey;color:lightgrey;font-family:'trebuchet ms',verdana,arial,sans-serif;font-family:var(--mermaid-font-family);}#mermaid-1760500296129 .branch-label0{fill:#ffffff;}#mermaid-1760500296129 .commit0{stroke:hsl(240, 100%, 46.2745098039%);fill:hsl(240, 100%, 46.2745098039%);}#mermaid-1760500296129 .commit-highlight0{stroke:hsl(60, 100%, 3.7254901961%);fill:hsl(60, 100%, 3.7254901961%);}#mermaid-1760500296129 .label0{fill:hsl(240, 100%, 46.2745098039%);}#mermaid-1760500296129 .arrow0{stroke:hsl(240, 100%, 46.2745098039%);}#mermaid-1760500296129 .branch-label1{fill:black;}#mermaid-1760500296129 .commit1{stroke:hsl(60, 100%, 43.5294117647%);fill:hsl(60, 100%, 43.5294117647%);}#mermaid-1760500296129 .commit-highlight1{stroke:rgb(0, 0, 160.5);fill:rgb(0, 0, 160.5);}#mermaid-1760500296129 .label1{fill:hsl(60, 100%, 43.5294117647%);}#mermaid-1760500296129 .arrow1{stroke:hsl(60, 100%, 43.5294117647%);}#mermaid-1760500296129 .branch-label2{fill:black;}#mermaid-1760500296129 .commit2{stroke:hsl(80, 100%, 46.2745098039%);fill:hsl(80, 100%, 46.2745098039%);}#mermaid-1760500296129 .commit-highlight2{stroke:rgb(48.8333333334, 0, 146.5000000001);fill:rgb(48.8333333334, 0, 146.5000000001);}#mermaid-1760500296129 .label2{fill:hsl(80, 100%, 46.2745098039%);}#mermaid-1760500296129 .arrow2{stroke:hsl(80, 100%, 46.2745098039%);}#mermaid-1760500296129 .branch-label3{fill:#ffffff;}#mermaid-1760500296129 .commit3{stroke:hsl(210, 100%, 46.2745098039%);fill:hsl(210, 100%, 46.2745098039%);}#mermaid-1760500296129 .commit-highlight3{stroke:rgb(146.5000000001, 73.2500000001, 0);fill:rgb(146.5000000001, 73.2500000001, 0);}#mermaid-1760500296129 .label3{fill:hsl(210, 100%, 46.2745098039%);}#mermaid-1760500296129 .arrow3{stroke:hsl(210, 100%, 46.2745098039%);}#mermaid-1760500296129 .branch-label4{fill:black;}#mermaid-1760500296129 .commit4{stroke:hsl(180, 100%, 46.2745098039%);fill:hsl(180, 100%, 46.2745098039%);}#mermaid-1760500296129 .commit-highlight4{stroke:rgb(146.5000000001, 0, 0);fill:rgb(146.5000000001, 0, 0);}#mermaid-1760500296129 .label4{fill:hsl(180, 100%, 46.2745098039%);}#mermaid-1760500296129 .arrow4{stroke:hsl(180, 100%, 46.2745098039%);}#mermaid-1760500296129 .branch-label5{fill:black;}#mermaid-1760500296129 .commit5{stroke:hsl(150, 100%, 46.2745098039%);fill:hsl(150, 100%, 46.2745098039%);}#mermaid-1760500296129 .commit-highlight5{stroke:rgb(146.5000000001, 0, 73.2500000001);fill:rgb(146.5000000001, 0, 73.2500000001);}#mermaid-1760500296129 .label5{fill:hsl(150, 100%, 46.2745098039%);}#mermaid-1760500296129 .arrow5{stroke:hsl(150, 100%, 46.2745098039%);}#mermaid-1760500296129 .branch-label6{fill:black;}#mermaid-1760500296129 .commit6{stroke:hsl(300, 100%, 46.2745098039%);fill:hsl(300, 100%, 46.2745098039%);}#mermaid-1760500296129 .commit-highlight6{stroke:rgb(0, 146.5000000001, 0);fill:rgb(0, 146.5000000001, 0);}#mermaid-1760500296129 .label6{fill:hsl(300, 100%, 46.2745098039%);}#mermaid-1760500296129 .arrow6{stroke:hsl(300, 100%, 46.2745098039%);}#mermaid-1760500296129 .branch-label7{fill:black;}#mermaid-1760500296129 .commit7{stroke:hsl(0, 100%, 46.2745098039%);fill:hsl(0, 100%, 46.2745098039%);}#mermaid-1760500296129 .commit-highlight7{stroke:rgb(0, 146.5000000001, 146.5000000001);fill:rgb(0, 146.5000000001, 146.5000000001);}#mermaid-1760500296129 .label7{fill:hsl(0, 100%, 46.2745098039%);}#mermaid-1760500296129 .arrow7{stroke:hsl(0, 100%, 46.2745098039%);}#mermaid-1760500296129 .branch{stroke-width:1;stroke:#333333;stroke-dasharray:2;}#mermaid-1760500296129 .commit-label{font-size:10px;fill:#000021;}#mermaid-1760500296129 .commit-label-bkg{font-size:10px;fill:#ffffde;opacity:0.5;}#mermaid-1760500296129 .tag-label{font-size:10px;fill:#131300;}#mermaid-1760500296129 .tag-label-bkg{fill:#ECECFF;stroke:hsl(240, 60%, 86.2745098039%);}#mermaid-1760500296129 .tag-hole{fill:#333;}#mermaid-1760500296129 .commit-merge{stroke:#ECECFF;fill:#ECECFF;}#mermaid-1760500296129 .commit-reverse{stroke:#ECECFF;fill:#ECECFF;stroke-width:3;}#mermaid-1760500296129 .commit-highlight-inner{stroke:#ECECFF;fill:#ECECFF;}#mermaid-1760500296129 .arrow{stroke-width:8;stroke-linecap:round;fill:none;}#mermaid-1760500296129 .gitTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-1760500296129 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} main develop Common base1-067d1c72-8ce25303-05b36a4Feature tip5-4b99b9d6-56f6f15New merge commitExample Git diagram 开发人员在分支中处理源文件。由于有些项目需要一段时间,源文件并不能立即发挥作用。要更改文件内容,需要选择文件,点击铅笔图标,进行更改,然后提交更改。当开发人员完成指定的工作后,为了保存他们的修改,他们会提交代码。提交表示开发人员确信代码代表了正在开发的功能或功能集的稳定平台。 当开发人员将变更源提交到自己的路径时,他们需要写一个注释来描述变更。注释应具有意义和描述性。开发人员可以选择提交到当前分支或创建一个新分支。最后,点击 Commit Changes。 写注释时的一些最佳做法:不要用句号结束评论信息;提交信息的字符数不要超过 50 个;使用扩展窗口了解详情;始终使用主动语态。 拉取用于启动分支合并,以捕捉变更。拉取请求会将提交的修改建议提供给其他人审查和使用。即使代码尚未完成,也可以在任何提交后进行拉取。拉取需要用户批准更改。该用户可以是变更的作者,也可以是团队中的指定人员。 请注意,如果你在不属于你的分支上做了改动,GitHub 会自动以你的名义提出拉取请求。由于日志文件不可更改,因此总能找到批准合并变更的人。 要打开拉取请求,请单击 Pull Requests 并选择 New Pull Request: 从比较框中选择新分支。 向下滚动查看变更。 确认这些更改就是你要评估的内容。 为请求添加标题和描述。 点击 Create Pull Request。 Git 仓库的目的是让主分支成为唯一部署的代码。开发人员可以修改分支中的源文件,但只有提交后才会发布。拉取命令发出后,代码会被审核和批准。审核通过的代码会被合并回主代码。 要将已提交的代码变更合并到主代码中,请单击 Merge Pull Request,然后单击 Confirm Merge。当某个分支的所有变更完成后,该分支将被视为过时,应予以删除。 2. 分支、Git 命令和管理 GitHub 项目 当你开始使用 GitHub 仓库并自动化工作流程时,使用网页界面可能会受到限制,而且更耗时。这就是 Git 命令的用武之地。你可以在自己的桌面或云 IDE 提供的虚拟桌面上使用它,无论你在哪里开发代码。 在本模块中,你将熟悉并使用各种 Git 命令来克隆和分叉仓库,以及使用命令行提交、推送和拉取更改。 2.1. Git 工作流概览 想象一下,你加入了一个为电子商务平台开发功能的团队。你的任务是添加产品推荐功能。团队使用 Git 和 GitHub 进行版本控制和协作。您将如何与使用 Git 和 GitHub 的团队有效协作?清楚地了解 Git 工作流程能确保您有效地使用 Git 进行版本控制和协作。这些知识将帮助你避免代码冲突和无意中覆盖团队成员工作等问题。 第一步是克隆团队在 GitHub 上托管的仓库。克隆会在本地计算机上创建一个项目代码副本,以及远程仓库的完整版本历史。通过克隆过程中建立的连接,你可以向远程仓库推送代码变更。你也可以将远程仓库中的任何更改提取到本地仓库中并进行更新。 作为开发人员,您可能会尝试多种方法来编写该功能的代码。在代码准备就绪之前,您可能不想让更改影响主分支。好的做法是从主分支创建一个分支,然后在新创建的分支上工作。在 Git 中,分支是一个基于主分支的独立开发空间,您可以在其中进行修改。在这种情况下,添加功能时不能干扰主代码库。 开发完功能后,下一步就是选择修改过的文件并将它们移到暂存区(Staging area)。暂存区是一个临时存储空间,在要求 Git 将所选文件保存到本地仓库之前,您可以在这里收集这些文件。现在您可以将文件提交到新创建的分支。提交文件可以记录更改,确保新功能成为分支的一部分。当你提交文件时,包含提交信息可为你和团队成员提供上下文,以便他们理解变更。例如,在提交信息中加入「已实现产品推荐功能」。 下一步是将分支中的变更推送到远程仓库。通常情况下,代码在合并到远程仓库的主分支之前,会由负责管理仓库的维护者进行审核。要合并分支,需要创建拉取请求。这是向维护者提出的请求,请其审查并批准分支中的更改。维护者合并拉取请求后,分支中的更改就会反映在主分支中。同样,如果你有维护者权限,就会收到团队成员的拉取请求,你将审查并批准他们的请求。 您已经了解了软件开发项目的典型工作流程。在某些情况下,你可能会从头开始一个项目,并打算与他人合作。你可以将本地项目目录初始化为 Git 仓库,这样 Git 就会开始跟踪项目目录中的改动。下一步是选择所有需要 Git 跟踪的项目文件,将它们移到暂存区,然后进行初始提交。接着创建一个空白仓库,并将本地 Git 仓库链接到远程仓库。再之后,你会把本地仓库中的所有项目文件推送到远程仓库。其他开发人员可以克隆远程仓库,并按照常规工作流程更新项目文件。 让我们通过一个用例来更好地理解工作流程。某公司为一个新的网络应用程序开发项目指派了一个由开发人员、测试人员和产品经理组成的团队。首席开发员 Anne 在自己的工作目录中初始化了一个 Git 仓库。她创建项目文件,将其移动到暂存区域,然后进行初始提交。然后,她将提交推送到自己创建的空白远程仓库。所有开发人员都克隆了远程仓库,并通过创建分支开始工作。 其中一名开发人员 John 负责实现用户身份验证功能。他从主分支中创建了一个名为 User Auth 的新分支。经过全面测试后,他将分支推送到远程仓库,并创建了一个拉取请求。Anne 审核并批准了拉取请求,将修改合并到主分支中,这样其他开发人员就可以访问 John 所做的修改。同样,其他开发人员也会向远程仓库推送他们所做功能的提交。至此,功能开发完成,项目准备发布。 Anne 从远程仓库中的主分支创建了一个名为 Release 1.0 的发布分支。团队从远程仓库中提取变更,以便更新本地仓库。他们在 Release 1.0 分支中执行最终测试、错误修复和文档更新。测试完成后,他们提交更改,将提交推送到远程仓库,并创建拉取请求。Anne 审核并将更改合并到主分支中,然后将发布标记为 Release 1.0。 2.2. Git 命令概述 Git 命令有多种用途,例如跟踪和保存更改,以及与他人共享更改。通常情况下,许多此类活动都是通过网页界面执行的。命令行界面(或 CLI)是执行这些活动的另一种选择。 让我们来学习一些基本命令,如: git init git add git status git commit git reset git log git branch git checkout git merge git init 命令会在一个目录中初始化一个新的 Git 仓库,这是为项目设置版本控制的第一步。要创建一个 Git 仓库,可以在命令提示符下导航到所需的目录。然后运行 git init 命令。该命令将创建一个名为 .git 的隐藏子目录。 第二个命令是 git status 命令。通过 git status,您可以查看工作目录的状态以及您所做修改的暂存快照。它提供的信息包括哪些文件已被修改,以及这些文件是否为下一次提交而暂存。为此,您可以导航到 git 仓库所在的目录,然后在命令提示符下运行 git status 命令。这将显示有关当前仓库状态的信息。 第三种是 git add . 命令,它可以将工作目录中的改动移动到暂存区域。例如,您的 Git 仓库中有一个名为 index.html 的文件,您在其中做了一些改动。这些改动将包含在下一次提交中。为此,你可以打开命令提示符并导航到有 index-html 文件的 Git 仓库。运行 git add . 命令。这样,当前目录中的所有改动都会在下一次提交时加入。 第四个是 git commit 命令。git commit 会将您所做修改的阶段快照保存到项目中,并附带说明性信息。这条信息解释了提交的目的。假设您想在仓库中创建一个新的 HTML 文件。您可以使用 git commit -m "commit message" 命令来提供描述性信息,比如 git commit -m "created a new HTML file"。 至此,你已经了解了 git init、git add、git status 和 git commit 命令。 现在,第五个命令是 git log 命令,它能让你浏览项目以前的改动。如果想查看提交历史,可以定位到 Git 仓库所在的目录,然后运行 git log 命令。这将提供每次提交的相关信息。例如,创建一个新的 HTML 文件就会反映在提交历史中。 下一个命令是 git reset。git reset 命令会撤销您对工作目录中文件所做的更改。因此,如果代码出错,可以运行 git reset 命令。通过使用 git reset soft 命令,您将重新添加已暂存的更改,并准备好再次提交。 第七个命令是 git branch 命令。它可以列出、创建、重命名甚至删除 Git 仓库中的分支。例如,如果想在项目中创建一个名为 code list 的新分支,可以使用 git branch 命令,后面跟上新分支的名称,在本例中就是 code_list。 第八个命令是 git checkout 命令。git checkout 命令可以让你在现有分支之间切换。因此,如果你想切换到 Git 仓库中名为 code_list 的分支,可以运行 git checkout 命令,后面跟上该分支的名称,即 code_list。这条命令会从仓库中签出一个分支到工作目录中。 第九条也是最后一条命令是 git merge。git merge 可以让你把所有东西重新组合在一起,并将特性分支与主分支合并。因此,如果您在代码列表分支上实现了一个新功能,您就会想把它合并回主分支。您可以使用 git checkout 命令进入目标分支,然后使用 git merge 命令,后面跟上分支名称,在本例中就是 code_list。该命令允许 Git 将特性分支的所有变更集应用到主分支的顶端。 2.2.1. 更多的命令 git pull:它能从远程仓库获取变更,并将其合并到本地分支。首先,运行 git checkout 命令切换到要合并的分支。然后,运行 git pull 命令,它会从远程仓库的主分支获取改动,并将其合并到你的当前分支。 git push:它能将本地仓库内容上传到远程仓库。首先运行 git checkout 命令,确保您所在的是要推送的分支,然后再将该分支推送到远程仓库。 git version:它显示系统上安装的当前 Git 版本。 git diff:它能显示提交之间的变化、提交树和工作树等。它还能比较分支。 git revert:它通过应用新提交来恢复提交。这会创建一个新的提交,撤销上次提交所做的更改。 git config -global user.email <Your GitHub Email>:它为 Git 设置全局电子邮件配置。需要在提交前执行,以验证用户的电子邮件 ID。 git config –global user.name <Your GitHub Username>:它为 Git 设置全局用户名配置。需要在提交前执行,以验证用户的用户名。 git remote:它列出了与本地仓库相关联的所有远程仓库的名称。 git remote -v:它列出了本地 Git 仓库所连接的所有远程仓库,以及与这些远程仓库相关的 URL。 git remote add origin <URL>:它使用指定的 URL 添加一个名为 origin 的远程仓库。 git remote rename:在 git remote rename 命令后面跟上要重命名的远程仓库名称(在这个例子中,origin)和新名称(例如:upstream)。这将把 origin 远程仓库重命名为 upstream。 git remote rm <name>:它会删除指定名称的远程仓库。 git format-patch:它可生成用于通过电子邮件提交的补丁。这些补丁可用于通过电子邮件提交更改或与他人共享。 git request-pull:它能为电子邮件请求生成待处理更改摘要。它有助于将分支或分叉中的更改传达给上游仓库维护者。 git send-email:它能以电子邮件的形式发送补丁集合。它允许你通过电子邮件向收件人发送多个补丁文件。请确保使用 git config 命令设置电子邮件地址和名称,以便电子邮件客户端在发送邮件时知道发件人的信息。 git am:它将补丁应用到仓库。它将补丁文件作为输入,并将补丁文件中指定的更改应用到仓库。 git daemon:它通过 Git:// 协议公开软件仓库。Git 协议是一种轻量级协议,旨在实现 Git 客户端和服务器之间的高效通信。 git instaweb:它能立即启动网络服务器来浏览资源库。它提供了一种简化的方式,无需配置完整的网络服务器,即可通过网络界面查看资源库内容。 git rerere:它会重复使用之前已解决的合并冲突的解决记录。请注意,需要将 rerere.enabled 配置选项设为 true(git config -global rerere.enabled true),git rerere 才能工作。此外,请注意 git rerere 只适用于使用同一分支和提交解决的冲突。 3. 最终项目 现在,你已经熟悉了 GitHub 仓库、分支和 Git 命令,在本模块中,你将运用所学知识和技能创建一个 GitHub 项目,添加一些文件(如开源许可证),并将其公开共享。 3.1. 概述和评分标准 现在您已经掌握了使用 GitHub UI 和 Git CLI 的知识和技能,您将利用这些技能在 Git 上创建一个开源项目,对该项目进行修改并将其提供给社区。您将分叉一个 GitHub 仓库,克隆该仓库到本地系统,更改本地仓库,在本地提交更改,推送回 GitHub 分叉仓库,并创建一个拉取请求,将您的更新添加到原始仓库。这个期末作业项目分为两个部分。 GitHub UI 你最近受聘成为一家小额信贷初创公司的开发人员,该公司的使命是为低收入人群赋权并提供机会。核心团队目前使用 Subversion(SVN)管理代码。他们希望慢慢将代码转移到 Git。作为第一步,他们要求您在 GitHub 上的一个新仓库中托管计算单利的示例代码。您不仅要托管脚本,还要遵循本课程介绍的最佳实践,为开源项目创建支持文档,包括行为准则和贡献指南。此外,该资源库应根据 Apache License 2.0 向社区开放。 Git CLI 恭喜您与贵公司一起在 GitHub 上创建了一个开源的简单利息计算器 bash 脚本。您的更改已被接受和合并,公司已创建了一个新的全球资源库,供团队协作使用。随着时间的推移,其他开发人员也为该仓库做出了贡献。您的团队在其中一个标记文件中发现了一个错误。我们要求您 fork 这个仓库,在提供的实验室环境中使用 Git CLI 修正错误,并打开一个拉取请求。 3.2. 第 1 部分 - GitHub UI 完成本实验后,你将证明自己可以: 在 GitHub 账户中创建一个新仓库。 为项目选择合适的许可证。 创建一个 README.md 文件,解释项目的目的。 创建 Code of Conduct Markdown 文件,说明你希望社区成员如何行为和互动。 创建 Contribution Guidelines Markdown 文件,告诉社区如何贡献。 将新文件提交到仓库。 注意:在整个实验过程中,系统会提示你将 URL 复制并粘贴到编辑器中,然后保存到自己的设备上。这些 URL 将在课程的 Final Submission 部分上传供同行评审。您可以使用任何编辑器应用程序来记录您的 URL。 3.2.1. 创建 GitHub 仓库 创建一个名为 github-final-project 的新 GitHub 仓库,并确保它是公开的。 选择 Add a README file 和 Choose a license 复选框。从下拉菜单中选择 Apache 2.0 License。 点击 Create Repository。您的仓库就创建完成了,其中包括 README 和 LICENSE 文件。现在,您可以更新仓库文件,为社区提供有用的信息。 将仓库的 URL 保存在记事本中,稍后提交给同行评审。 3.2.2. 更新 README 文件 在文件中添加以下信息: A calculator that calculates simple interest given principal, annual rate of interest and time period in years.Input: p, principal amount t, time period in years r, annual rate of interestOutput simple interest = p*t*r 可选:您可以在开发项目的过程中继续更新 README 文件。您可以从以下资源中找到一些有用的 README 内容: GitHub README Make a README Awesome README 3.2.3. 添加行为准则 行为准则有助于为项目参与者的行为制定基本规则。它定义了如何参与社区活动的标准。 GitHub 提供了常用的行为准则模板,可帮助您快速为项目添加行为准则。要在项目中添加行为准则,请完成以下步骤: 在仓库根目录下添加一个名为 CODE_OF_CONDUCT.md、模板为 Contributor Covenant 的新文件。 访问仓库主页,检查文件是否已创建。 输入 CODE_OF_CONDUCT 后就会自动出现选择模板的选择。 3.2.4. 增加贡献指南 贡献指南告诉项目参与者如何为项目做出贡献。要添加贡献指南,请完成以下步骤: 在仓库根目录下创建一个名为 CONTRIBUTING.md 的新文件,其中包含以下信息: All contributions, bug reports, bug fixes, documentation improvements, enhancements, and ideas are welcome. 将文件提交到主分支,并检查它是否列在仓库的主页上。 您也可以选择查看以下指南,了解贡献指南的示例并更新此文件: Contributing to Legit Info, a Call for Code for Racial Justice Project Contributing to OpenEEW Contributing to Atom How to contribute to Ruby on Rails 3.2.5. 托管脚本文件 在仓库根目录下新建一个名为 simple-interest.sh 的文件。 在新文件中添加以下代码: #!/bin/bash# This script calculates simple interest given principal,# annual rate of interest and time period in years.# Do not use this in production. Sample purpose only.# Author: Upkar Lidder (IBM)# Additional Authors:# <your GitHub username># Input:# p, principal amount# t, time period in years# r, annual rate of interest# Output:# simple interest = p*t*recho "Enter the principal:"read pecho "Enter rate of interest per year:"read recho "Enter time period in years:"read ts=`expr $p \* $t \* $r / 100`echo "The simple interest is: "echo $s 将文件提交到主分支。 3.3. 第 2 部分 - Git CLI 完成本实验后,您将能够证明自己可以: 将上游仓库 fork 到自己的账户。 本地克隆代码。 在仓库中创建一个分支。 在分支中进行修改并提交。 将分支合并回主分支。 从分叉仓库向上游仓库创建拉取请求。 还原之前所做的更改。 3.3.1. Fork 仓库 Fork 项目的 源代码库。 3.3.2. 修正错字并与 master 合并 我自己在本地环境使用 Git Bash,分支并非教程中写的 main 而是 master,因此下面的内容都会以 master 分支为主。 克隆自己 Fork 的仓库。 git clone https://github.com/<Your name>/jbbmo-Introduction-to-Git-and-GitHub.git 创建一个名为 bug-fix-typo 的分支。 git checkout -b bug-fix-typo 将 README.md 中的页脚从 2022 XYZ, Inc. 改为 2023 XYZ, Inc. 添加修正后的文件并提交,同时写上有意义的信息。 git add README.mdgit commit -m "Updated the footer year in README.md" 将修复推送到 bug-fix-typo 分支。 git push origin bug-fix-typo 切换到 master 分支。将 bug-fix-typo 分支合并回 master 分支。 git checkout mastergit merge bug-fix-typo 3.3.3. 还原错字并提交拉取请求 检查 master 分支中 README.md 的内容。该文件现在应为: 2023 XYZ, Inc. 可以在 Git Bash 中使用 cat <filename> 命令来直接查看。 创建一个名为 bug-fix-revert 的新分支。 使用 git revert 命令还原上一个任务中的更改。 git loggit revert <commit-hash> 首先,您需要找到要恢复的变更的提交哈希值。这可以通过 git log 查看提交历史来实现。有了提交哈希值,就可以用 git revert <commit-hash> 来还原了。记得将 <commit-hash> 替换为实际的提交哈希值。 现在文件应为: 2022 XYZ, Inc. 将还原推送到你的仓库中的 bug-fix-revert 分支。 转到 GitHub UI。从你的仓库的 bug-fix-revert 分支向原始仓库的主分支创建一个新的拉取请求。此 PR 将自动关闭。 3.3.4. 检查分支状态 导航至页面上 GitHub UI 中的 Branches 部分。它的格式如下: https://github.com/<Your Github username>/jbbmo-Introduction-to-Git-and-GitHub/branches 在这一部分中,您可以找到分支名称及其当前状态。

2024/6/28
articleCard.readMore

IBM 全栈开发【11】:全栈应用程序开发项目

近期在学习 IBM 全栈应用开发微学士课程,故此记录学习笔记。 1. 应用程序:静态页面 恭喜您成为「Best Cars」汽车经销商的首席云应用程序开发员。作为热身任务,您需要运行并测试其主要 Django 应用程序。 Django 应用程序将主要用于用户管理和身份验证、管理汽车型号和品牌,以及路由其他 MongoDB 服务以获取经销商和客户评论。您将在毕业设计课程中分阶段构建此 Django 应用程序和相关服务。 将更改推送到 Git 仓库的步骤: git config --global user.email "yourgithub@email.com"git config --global user.name "name"git add .git commit -m"Adding temporary changes to Github"git push 导航至此仓库,并创建一个包含本项目所需的基本启动代码的版本库分叉(fork)。 1.1. 在开发服务器上运行 Django 应用程序 观察 Django 应用程序骨架结构的文件夹结构。您会看到服务器文件夹下有三个子文件夹: djangoapp:包含 Django 应用程序。 djangoproj:包含项目配置。 frontend:HTML 和 CSS 以及 React 前端。 接下来,让我们设置 Python 运行时并测试应用程序。 cd xrwvm-fullstack_developer_capstone/serverpip install virtualenvvirtualenv djangoenvsource djangoenv/bin/activate 在虚拟环境中安装必要的 Python 软件包。软件包名称已在 requirements.txt 中提供。 python3 -m pip install -U -r requirements.txt 在 server/djangoproj/settings.py 的 TEMPLATES 下,您会发现 DIRS 是一个空列表。在列表中添加 os.path.join(BASE_DIR,'frontend/static'),以便 Django 应用程序识别前端静态文件。设置如下: 'DIRS': [ os.path.join(BASE_DIR,'frontend/static')], 在同一文件 server/djangoproj/settings.py 中,在文件底部添加 Django 应用程序查找静态文件的目录。 STATICFILES_DIRS = [ os.path.join(BASE_DIR,'frontend/static')] 执行迁移,创建必要的表格。 python3 manage.py makemigrations 运行迁移以激活应用程序的模型。 python3 manage.py migrate 启动本地开发服务器。 python3 manage.py runserver 在文件编辑器中打开 server/djangoproj/settings.py,设置 ALLOWED_HOSTS 和 CSRF_TRUSTED_ORIGINS 以反映 Django 应用程序的根 URL。 请不要在末尾添加 /。 ALLOWED_HOSTS=['localhost','<your application URL here>']CSRF_TRUSTED_ORIGINS=['<your application URL here>'] 1.2. 添加「About Us」页面 在编辑器中打开 server/frontend/static/About.html。同一文件夹中还有样式表 style.css。在 About.html 中的 <head> 标签中链接样式表,以便在 HTML 文件中使用。 <link rel="stylesheet" href="/static/style.css"><link rel="stylesheet" href="/static/bootstrap.min.css"> 在名为 about-header 的 <div> 标记中粘贴以下内容。 <h1>About Us</h1>Welcome to Best Cars dealership, home to the best cars in North America. We deal in sale of domestic and imported cars at reasonable prices. 将图片 person.png 改为真人图片,并修改 About.html 中的所有文字,使其看起来更真实。根据您的喜好更改样式。 转到 djangoproj/urls.py,在 urlpatterns 中添加以下内容: path('about/', TemplateView.as_view(template_name="About.html")), 1.3. 添加「Contact Us」页面 在 server/frontend/static 文件夹下添加一个名为 Contact.html 的新文件。 添加样式表链接。 添加标题导航栏,将 Contact Us 作为活动链接。 在文件中写入联系信息内容。您可以发挥创意,编写自己的信息。使用 CSS 创建样式。 将 contact 路径添加到 djangproj/urls.py: path('contact/', TemplateView.as_view(template_name="Contact.html")), Django 服务器会自动重启。 2. 应用程序:用户管理 现在,您已经构建并部署了最初的 Django 应用程序。下一步,经销商的管理员将查看应用程序,以识别用户并根据角色(如匿名用户或注册用户)管理其访问权限。为此,您需要在应用程序中添加身份验证和授权,即用户管理。在本课中,您需要执行以下任务来添加用户管理功能: 为应用程序创建超级用户。 构建客户端并进行配置。 检查客户端配置。 添加登录视图以处理登录请求。 添加注销视图以处理注销请求。 添加注册视图来处理注册请求。 2.1. 为您的应用创建超级用户 运行以下命令创建超级用户: python manage.py createsuperuser 输入用户名、电子邮件和密码后,您应该会看到 Superuser created successfully 的信息,表明超级用户已创建。执行以下命令运行服务器: python manage.py runserver 请在端口 8000 上启动应用程序,并在 URL 末尾添加 /admin,以进入 Django 管理用户界面。使用刚刚为超级用户创建的凭据登录管理员站点。单击 AUTHENTICATION AND AUTHORIZATION 部分下的 Users。您应该可以查看刚刚创建的超级用户。 2.2. 构建客户端并进行配置 打开新终端,切换到客户端目录: cd /server/frontend 安装所有必需的软件包: npm install 运行以下命令构建客户端: npm run build 在编辑器中打开 server/djangoproj/settings.py。在 TEMPLATES 下可以找到 DIRS。在列表中添加 os.path.join(BASE_DIR,'frontend/build'),以便 Django 应用程序识别前端。设置如下: 'DIRS': [ os.path.join(BASE_DIR, 'frontend/static'), os.path.join(BASE_DIR, 'frontend/build'), os.path.join(BASE_DIR, 'frontend/build/static'),], 在 server/djangoproj/settings.py 中,将 Django 应用程序查找静态文件的目录添加到名为 STATICFILES_DIRS 的列表中。设置如下: STATICFILES_DIRS = [ os.path.join(BASE_DIR, 'frontend/static'), os.path.join(BASE_DIR, 'frontend/build'), os.path.join(BASE_DIR, 'frontend/build/static'),] 2.3. 添加新的登录视图 接下来,您需要创建一个新的登录 Django 视图来处理登录请求。打开 djangoapp/views.py,取消顶部的导入语句。观察登录视图以验证用户身份。用户登录后,视图会返回一个包含用户名和状态的 JSON 对象。 打开 server/djangoapp/urls.py,取消注释顶部的导入语句: from django.urls import pathfrom django.conf.urls.static import staticfrom django.conf import settingsfrom . import views 通过取消对 server/djangoapp/urls.py 中路径条目的注释,为登录视图配置路由,如下所示: path(route='login', view=views.login_user, name='login'), 如下所示,在 server/djangoproj/urls.py 中添加路径条目,为登录页面配置路由。登录页面是通过 /server/frontend/src/App.js 中配置的路由呈现的 React 页面。 path('login/', TemplateView.as_view(template_name="index.html")), 2.4. 添加注销功能 您需要创建一个新的注销 Django 视图来处理注销请求。 打开 djangoapp/views.py,添加一个新的注销视图来处理注销请求。用户注销后,视图应返回一个包含用户名的 JSON 对象。 def logout_request(request): logout(request) data = {"userName": ""} return JsonResponse(data) 在 djangoapp/urls.py 中添加路径条目,为注销视图配置路由: path(route='logout', view=views.logout_request, name='logout'), 在 server/frontend/static/Home.html 中的空白处加入以下代码,以处理用户退出登录: const logout = async (e) => { let logout_url = window.location.origin+"/djangoapp/logout"; const res = await fetch(logout_url, { method: "GET", }); const json = await res.json(); if (json) { let username = sessionStorage.getItem('username'); sessionStorage.removeItem('username'); window.location.href = window.location.origin; window.location.reload(); alert("Logging out "+username+"...") } else { alert("The user could not be logged out.") }}; 打开一个新的终端,运行以下命令: cd /server/frontendnpm run build 如果会话没有过期,您将从上一步登录。如果没有,请登录,然后单击 Logout。 2.5. 添加注册功能 您需要创建一个新的注册 Django 视图来处理注册请求。 打开 djangoapp/views.py,添加一个新的注册视图来处理注册请求。用户注册时,应创建一个用户对象,并登录该用户。它应返回一个包含用户名的 JSON 对象。 @csrf_exemptdef registration(request): context = {} data = json.loads(request.body) username = data['userName'] password = data['password'] first_name = data['firstName'] last_name = data['lastName'] email = data['email'] username_exist = False email_exist = False try: # Check if user already exists User.objects.get(username=username) username_exist = True except: # If not, simply log this is a new user logger.debug("{} is new user".format(username)) # If it is a new user if not username_exist: # Create user in auth_user table user = User.objects.create_user(username=username, first_name=first_name, last_name=last_name,password=password, email=email) # Login the user and redirect to list page login(request, user) data = {"userName":username,"status":"Authenticated"} return JsonResponse(data) else : data = {"userName":username,"error":"Already Registered"} return JsonResponse(data) 在 djangoapp/urls.py 中添加路径条目,为注册视图配置路由: path(route='register', view=views.registration, name='register'), 在 frontend/src/components/Register 中创建一个名为 Register.jsx 的文件。我们已经提供了该页面要使用的 CSS。在 Register.jsx 中添加以下代码: import React, { useState } from "react";import "./Register.css";import user_icon from "../assets/person.png"import email_icon from "../assets/email.png"import password_icon from "../assets/password.png"import close_icon from "../assets/close.png"const Register = () => { const [userName, setUserName] = useState(""); const [password, setPassword] = useState(""); const [email, setEmail] = useState(""); const [firstName, setFirstName] = useState(""); const [lastName, setlastName] = useState(""); const gohome = ()=> { window.location.href = window.location.origin; } const register = async (e) => { e.preventDefault(); let register_url = window.location.origin+"/djangoapp/register"; const res = await fetch(register_url, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ "userName": userName, "password": password, "firstName":firstName, "lastName":lastName, "email":email }), }); const json = await res.json(); if (json.status) { sessionStorage.setItem('username', json.userName); window.location.href = window.location.origin; } else if (json.error === "Already Registered") { alert("The user with same username is already registered"); window.location.href = window.location.origin; }}; return( <div className="register_container" style={{width: "50%"}}> <div className="header" style={{display: "flex",flexDirection: "row", justifyContent: "space-between"}}> <span className="text" style={{flexGrow:"1"}}>SignUp</span> <div style={{display: "flex",flexDirection: "row", justifySelf: "end", alignSelf: "start" }}> <a href="/" onClick={()=>{gohome()}} style={{justifyContent: "space-between", alignItems:"flex-end"}}> <img style={{width:"1cm"}} src={close_icon} alt="X"/> </a> </div> <hr/> </div> <form onSubmit={register}> <div className="inputs"> <div className="input"> <img src={user_icon} className="img_icon" alt='Username'/> <input type="text" name="username" placeholder="Username" className="input_field" onChange={(e) => setUserName(e.target.value)}/> </div> <div> <img src={user_icon} className="img_icon" alt='First Name'/> <input type="text" name="first_name" placeholder="First Name" className="input_field" onChange={(e) => setFirstName(e.target.value)}/> </div> <div> <img src={user_icon} className="img_icon" alt='Last Name'/> <input type="text" name="last_name" placeholder="Last Name" className="input_field" onChange={(e) => setlastName(e.target.value)}/> </div> <div> <img src={email_icon} className="img_icon" alt='Email'/> <input type="email" name="email" placeholder="email" className="input_field" onChange={(e) => setEmail(e.target.value)}/> </div> <div className="input"> <img src={password_icon} className="img_icon" alt='password'/> <input name="psw" type="password" placeholder="Password" className="input_field" onChange={(e) => setPassword(e.target.value)}/> </div> </div> <div className="submit_panel"> <input className="submit" type="submit" value="Register"/> </div> </form> </div> )}export default Register; 在 frontend/src/App.js 中配置路由: <Route path="/register" element={<Register />} /> 在之前用于运行 npm run build 的终端中,再次运行以反映最新更改。 在编辑器中打开 server/djangoproj/urls.py,在 urlpatterns 中添加以下路径。路由已在 App.js 中配置: path('register/', TemplateView.as_view(template_name="index.html")), 如果已登录,请注销,然后点击 Register 链接。您将看到如下所示的注册页面。 3. 后端服务 在本模块中,您将在 Express 应用程序中实现一些与 MongoDB 进行交易的端点。然后使用 Docker 将 Mongo 和 Express 服务器容器化并运行。此外,您还将使用 Django 模型设置 CarMake 和 CarModel,并填充数据库。然后,将情感分析器部署到 IBM 代码引擎。最后,您将创建代理服务来访问这些外部服务。 您在上一个模块中创建的 Django 应用程序需要与数据库通信。在本模块中,您将创建一个容器化的 Node.JS 应用程序,使用 MongoDB 作为后端为 API 端点提供服务。 您将在 Express 应用程序中编写这些后端服务,并使用 Docker 将其容器化。 您将查看和测试以下端点: /fetchReviews/dealer/29 /fetchDealers /fetchDealer/3 /fetchDealers/Kansas 3.1. 使用 Express-Mongo 实现应用程序接口端点 切换到包含数据文件的目录: cd /server/database 在在线课程实验项目中,我们为您提供了两个用于 Reviews 和 Dealerships 实体的模式文件,以及包含要加载到 MongoDB 并通过端点提供的经销商和评论数据的 JSON 文件。 server/database/data/dealerships.json server/database/data/reviews.json Node 应用程序将使用 mongoose 与 MongoDB 交互。评论和经销商的模式分别在 review.js 和 dealership.js 中定义。 查看 server/database/app.js 中的内容。它将提供以下端点: fetchReviews(用于获取所有评论) fetchReviews/dealer/:id(用于获取特定经销商的评论) fetchDealers(用于获取所有经销商的评论) fetchDealers/:state(用于获取某一州的所有经销商信息) fetchDealer/:id(按 ID 查找经销商) insert_review(用于插入评论) 有些端点已经为您实施。请利用这些想法和先前的学习成果,实施尚未实施的端点。 3.1.1. 与 Mongoose 合作提供 API 端点 运行以下命令来构建 Docker 应用程序。记住,每次更改 app.js 时都要这样做。 请确保你的计算机里有安装 Docker。 运行 Docker 还需要开启 Hyper-V,可以上网找教程。 docker build . -t nodeapp 我们创建了用于运行两个容器的 docker-compose.yml,一个用于 Mongo,另一个用于 Node 应用程序。运行以下命令来运行服务器: docker-compose up 3.1.2. 创建应用程序接口端点 URL 实现 server/database/app.js 中尚未实施的以下端点(可以直接参考已经被实现的端点写法): fetchDealers: app.get('/fetchDealers', async (req, res) => { try { const documents = await Dealerships.find(); res.json(documents); } catch (error) { res.status(500).json({ error: 'Error fetching documents' }); }}); fetchDealers/:state: app.get('/fetchDealers/:state', async (req, res) => { try { const documents = await Dealerships.find({state: req.params.state}); res.json(documents); } catch (error) { res.status(500).json({ error: 'Error fetching documents' }); }}); fetchDealer/:id: app.get('/fetchDealer/:id', async (req, res) => { try { const documents = await Dealerships.find({id: req.params.id}); res.json(documents); } catch (error) { res.status(500).json({ error: 'Error fetching documents' }); }}); 写完后,停止上一个任务中启动的 Docker 应用程序,接着再次执行 docker build 和 docker-compose 命令。 在 3030 端口中测试以下端点: /fetchReviews/dealer/29 /fetchDealers /fetchDealer/3 /fetchDealers/Kansas 3.2. 构建 CarModel 和 CarMake Django 模型 您已经创建了一个经销商和与 CRUD API 相关的评论。接下来,您需要为经销商的库存创建数据模型和服务。 每个经销商都管理着不同车型和品牌的汽车库存,这些都是相对静态的数据,因此适合本地存储在 Django 中。要集成外部经销商和审查数据,您需要从 Django 应用程序调用 API,并在 Django 视图中处理 API 结果,然后通过 React 页面呈现。此类 Django 视图使用代理服务根据用户请求从外部资源获取数据,并使用 React 组件进行渲染。 在本课中,您需要执行以下任务来添加汽车模型、与汽车相关的模型和视图以及代理服务: 创建 CarModel 和 CarMake Django 模型 在管理网站注册 CarModel 和 CarMake 模型 创建带有相关汽车品牌和经销商的新汽车模型对象 3.2.1. 建立 CarModel 和 CarMake 模型的步骤 您需要在 server/djangoapp/models.py 中创建两个新模型: 一个 CarMake 模型,用于保存汽车品牌的一些数据。 一个 CarModel 模型,用于保存汽车模型的一些数据。 创建汽车制造商 Django 模型 class CarMake(models.Model): 名称 描述 你想包含在汽车品牌中的任何其他字段 打印汽车品牌对象的 __str__ 方法 class CarMake(models.Model): name = models.CharField(max_length=100) description = models.TextField() def __str__(self): return self.name 创建汽车模型 Django 模型 class CarModel(models.Model): 与 CarMake 模型的多对一关系(使用外键字段,一个汽车品牌可以有多个汽车型号) 经销商 ID(整数字段)指 Cloudant 数据库中创建的经销商 名称 类型(带选择参数的 CharField,用于提供有限的选择,如轿车、SUV 和旅行车) 年份(日期字段) 您希望包含在汽车模型中的任何其他字段 打印汽车品牌和车型对象的 __str__ 方法 class CarModel(models.Model): car_make = models.ForeignKey(CarMake, on_delete=models.CASCADE) # Many-to-One relationship name = models.CharField(max_length=100) CAR_TYPES = [ ('SEDAN', 'Sedan'), ('SUV', 'SUV'), ('WAGON', 'Wagon'), # Add more choices as required ] type = models.CharField(max_length=10, choices=CAR_TYPES, default='SUV') year = models.IntegerField(default=2023, validators=[ MaxValueValidator(2023), MinValueValidator(2015) ]) def __str__(self): return self.name 您需要在管理网站上注册 CarMake 和 CarModel,这样就可以方便地管理它们的内容(即执行 CRUD 操作): # djangoapp/admin.pyfrom django.contrib import adminfrom .models import CarMake, CarModeladmin.site.register(CarMake)admin.site.register(CarModel) 为模型运行迁移: python manage.py makemigrationspython manage.py migrate --run-syncdb --run-syncdb 允许在不迁移的情况下为应用程序创建表格。 3.2.2. 在管理网站注册 CarMake 和 CarModel 模型的步骤 打开 djangoapp/views.py,在文件开头的其他导入语句之后导入 CarMake 和 CarModel,然后添加一个方法来获取汽车列表,方法中包含以下代码: from .models import CarMake, CarModel# ...def get_cars(request): count = CarMake.objects.filter().count() print(count) if(count == 0): initiate() car_models = CarModel.objects.select_related('car_make') cars = [] for car_model in car_models: cars.append({"CarModel": car_model.name, "CarMake": car_model.car_make.name}) return JsonResponse({"CarModels":cars}) 打开 server/djangoapp/urls.py,在其中添加 get_cars 的路径: path(route='get_cars', view=views.get_cars, name ='getcars'), 打开 server/djangoapp/populate.py,然后粘贴以下代码,以便在数据库中填充数据。如果 CarModel 为空,则在第一次调用 get_cars 时填充数据。如果您想手动添加数据,请跳过此步骤。 from .models import CarMake, CarModeldef initiate(): car_make_data = [ {"name":"NISSAN", "description":"Great cars. Japanese technology"}, {"name":"Mercedes", "description":"Great cars. German technology"}, {"name":"Audi", "description":"Great cars. German technology"}, {"name":"Kia", "description":"Great cars. Korean technology"}, {"name":"Toyota", "description":"Great cars. Japanese technology"}, ] car_make_instances = [] for data in car_make_data: car_make_instances.append(CarMake.objects.create(name=data['name'], description=data['description'])) # Create CarModel instances with the corresponding CarMake instances car_model_data = [ {"name":"Pathfinder", "type":"SUV", "year": 2023, "car_make":car_make_instances[0]}, {"name":"Qashqai", "type":"SUV", "year": 2023, "car_make":car_make_instances[0]}, {"name":"XTRAIL", "type":"SUV", "year": 2023, "car_make":car_make_instances[0]}, {"name":"A-Class", "type":"SUV", "year": 2023, "car_make":car_make_instances[1]}, {"name":"C-Class", "type":"SUV", "year": 2023, "car_make":car_make_instances[1]}, {"name":"E-Class", "type":"SUV", "year": 2023, "car_make":car_make_instances[1]}, {"name":"A4", "type":"SUV", "year": 2023, "car_make":car_make_instances[2]}, {"name":"A5", "type":"SUV", "year": 2023, "car_make":car_make_instances[2]}, {"name":"A6", "type":"SUV", "year": 2023, "car_make":car_make_instances[2]}, {"name":"Sorrento", "type":"SUV", "year": 2023, "car_make":car_make_instances[3]}, {"name":"Carnival", "type":"SUV", "year": 2023, "car_make":car_make_instances[3]}, {"name":"Cerato", "type":"Sedan", "year": 2023, "car_make":car_make_instances[3]}, {"name":"Corolla", "type":"Sedan", "year": 2023, "car_make":car_make_instances[4]}, {"name":"Camry", "type":"Sedan", "year": 2023, "car_make":car_make_instances[4]}, {"name":"Kluger", "type":"SUV", "year": 2023, "car_make":car_make_instances[4]}, ] for data in car_model_data: CarModel.objects.create(name=data['name'], car_make=data['car_make'], type=data['type'], year=data['year']) 如果您想手动添加汽车品牌和型号,也可以进入管理页面,根据自己的意愿添加。请注意,在项目后期,您只能从这些表格中选择一个品牌和车型发表评论。 进入 127.0.0.1:8000/djangoapp/get_cars 来查看已添加的汽车列表。 3.3. 创建后台 API 的 Django 代理服务 在之前的实验中,您创建了 CarModel 和 CarMake 的 Django 模型,这些模型驻留在本地 SQLite 存储库中。您还获得了由 Express API 端点提供服务的经销商和评论模型 Mongo DB。 现在,您需要集成这些模型和服务,以管理经销商和评论等所有实体。 要集成外部经销商和评论数据,您需要从 Django 应用程序调用 API 端点,并在 Django 视图中处理 API 结果。这些 Django 视图可以看作是终端用户的代理服务,因为它们会根据用户的请求从外部资源获取数据。 在本实验中,您将创建此类 Django 视图作为代理服务。 3.3.1. 运行 Mongo 服务器 后端 Mongo Express 服务器需要在实验室环境中的一个终端启动并运行。在这一阶段,服务器代码已经实现了所有的终端点。运行 docker-compose up 来启动服务器(端口 3030)。 打开 djangoapp/.env,将您的后台网址替换为上一步在记事本中复制的后台网址: 确保末尾的 / 没有被复制。 backend_url=your backend url 3.3.2. 创建与后端交互的函数 在之前的实验中,您已经创建了用于 fetchReviews 和 fetchDealers 的 API 端点。现在实现一个方法来从 Django 应用程序访问这些端点。 在 Django 中,有许多方法可以进行 HTTP 请求。这里我们使用一个非常流行且易于使用的 Python 库,名为 requests。 打开 djangoapp/restapis.py,添加 get_request 方法,如下所示: def get_request(endpoint, **kwargs): params = "" if(kwargs): for key,value in kwargs.items(): params=params+key+"="+value+"&" request_url = backend_url+endpoint+"?"+params print("GET from {} ".format(request_url)) try: # Call get method of requests library with URL and parameters response = requests.get(request_url) return response.json() except: # If any error occurs print("Network exception occurred") 3.3.3. 启动 Code Engine 以下为 Code Engine 相关。该课程采用的是 IBM 的 Skills Network 实验环境,Code Engine 被直接嵌入在这个实验环境中。 创建一个项目,启动 Code Engine。Code Engine 环境需要一段时间来准备。您将在设置面板中看到进度状态。 Code Engine 设置完成后,您可以看到它已激活。单击 Code Engine CLI,在下面的终端中开始预配置的 CLI。 3.3.4. 将情感分析作为微服务部署在 Code Engine 上 在 Code Engine CLI 中,更改为 server/djangoapp/microservices 目录: cd xrwvm-fullstack_developer_capstone/server/djangoapp/microservices 我们为您提供了使用 NLTK 进行情感分析的 sentiment_analyzer.py。我们还为您提供了一个 Dockerfile,您将使用它在代码引擎中部署此服务,并将其作为微服务使用。 运行以下命令,用 Docker 构建情感分析应用程序(请注意,代码引擎实例是瞬时的,并附在你的实验室空间用户名上): docker build . -t us.icr.io/${SN_ICR_NAMESPACE}/senti_analyzer 运行以下命令推送 Docker 镜像: docker push us.icr.io/${SN_ICR_NAMESPACE}/senti_analyzer 在 Code Engine 上部署 senti_analyzer 应用程序: ibmcloud ce application create --name sentianalyzer --image us.icr.io/${SN_ICR_NAMESPACE}/senti_analyzer --registry-secret icr-secret --port 5000 连接到生成的 URL 以访问微服务,并检查部署是否成功。如果应用程序部署验证成功,请在浏览器中将 /analyze/Fantastic 服务附加到 URL,查看是否返回正值。 打开 djangoapp/.env,将 Code Engine 部署 URL 替换为上文获得的部署 URL: sentiment_analyzer_url=your code engine deployment url URL 最后的 / 要去掉。 如果想要在本地环境中部署该微服务,可以进入 /server/djangoapp/microservices 目录,启动 Docker: docker build -t sentiment_app .docker run -p 5050:5050 my_microservice 然后要在 .env 文件中注释掉 sentiment_analyzer_url 一行。 更新 djangoapp/restapis.py,并在其中添加以下函数,以使用微服务分析情感: def analyze_review_sentiments(text): request_url = sentiment_analyzer_url+"/analyze/"+text try: # Call get method of requests library with URL and parameters response = requests.get(request_url) return response.json() except Exception as err: print(f"Unexpected {err=}, {type(err)=}") print("Network exception occurred") 3.3.5. 创建 Django 视图以获取经销商 使用以下代码更新 djangoapp/views.py 中的 get_dealerships 视图方法。它将使用您在 restapis.py 中通过 /fetchDealers 端点实现的 get_request。 def get_dealerships(request, state="All"): if(state == "All"): endpoint = "/fetchDealers" else: endpoint = "/fetchDealers/"+state dealerships = get_request(endpoint) return JsonResponse({"status":200,"dealers":dealerships}) 为 url.py 中的 get_dealerships 视图方法配置路由: path(route='get_dealers', view=views.get_dealerships, name='get_dealers'),path(route='get_dealers/<str:state>', view=views.get_dealerships, name='get_dealers_by_state'), 在 views.py 中创建一个以 dealer_id 为参数的 get_dealer_details 方法,并添加一个映射 urls.py。它将使用你在 restapis.py 中实现的 get_request,并传递 /fetchDealer/<dealer id> 端点。 def get_dealer_details(request, dealer_id): if(dealer_id): endpoint = "/fetchDealer/"+str(dealer_id) dealership = get_request(endpoint) return JsonResponse({"status":200,"dealer":dealership}) else: return JsonResponse({"status":400,"message":"Bad Request"}) 同样配置路由: path(route='dealer/<int:dealer_id>', view=views.get_dealer_details, name='dealer_details'), 在 views.py 中创建以 dealer_id 为参数的 get_dealer_reviews 方法,并添加 urls.py 映射。它将使用在 restapis.py 中实现的 get_request,并传递 /fetchReviews/dealer/<dealer id> 端点。它还将调用 restapis.py 中的 analyze_review_sentiments,以消费微服务并确定每条评论的情感,然后在 review_detail 字典中设置值,并作为 JsonResponse 返回。 情感属性的值将由情感分析微服务决定。它可以是正面的、中性的或负面的。 def get_dealer_reviews(request, dealer_id): # if dealer id has been provided if(dealer_id): endpoint = "/fetchReviews/dealer/"+str(dealer_id) reviews = get_request(endpoint) for review_detail in reviews: response = analyze_review_sentiments(review_detail['review']) print(response) review_detail['sentiment'] = response['sentiment'] return JsonResponse({"status":200,"reviews":reviews}) else: return JsonResponse({"status":400,"message":"Bad Request"}) path(route='reviews/dealer/<int:dealer_id>', view=views.get_dealer_reviews, name='dealer_details'), 3.3.6. 创建 Django 视图以发布经销商评论 你已经学会了如何进行各种 GET 调用,现在来创建 Django 视图以发布经销商评论:打开 restapis.py,添加一个 post_review 方法,该方法将接收一个数据字典,并在后台调用 add_review。字典将以键值对的形式接收经销商评论所需的所有值。 def post_review(data_dict): request_url = backend_url+"/insert_review" try: response = requests.post(request_url,json=data_dict) print(response.json()) return response.json() except: print("Network exception occurred") 打开 views.py,创建一个新的 def add_review(request): 方法来处理评论文章请求。在 add_review 视图方法中: 首先检查用户是否通过身份验证,因为只有通过身份验证的用户才能为经销商发布评论。 使用字典调用 post_request 方法。 将 post_request 的结果返回 add_review 视图方法。您可以打印帖子响应。 以 JSON 格式返回成功状态和信息。 在 url.py 中为 add_review 视图配置路由。 def add_review(request): if(request.user.is_anonymous == False): data = json.loads(request.body) try: response = post_review(data) return JsonResponse({"status":200}) except: return JsonResponse({"status":401,"message":"Error in posting review"}) else: return JsonResponse({"status":403,"message":"Unauthorized"}) path(route='add_review', view=views.add_review, name='add_review'), 从 restapis.py 导入方法,以便在 views.py 中使用: from .restapis import get_request, analyze_review_sentiments, post_review 4. 应用程序:动态页面 在本模块中,您将使用 React 组件添加动态页面,以列出经销商、按州过滤经销商、查看经销商详细信息并添加经销商评论。 创建前台页面,向终端用户展示后台服务。 创建一个组件来列出经销商。 开发一个经销商详情和评论组件。 创建评论提交页面。 您在上一个模块中创建了所有必要的后台服务(Django 视图和 API 端点),用于管理经销商、评论和汽车。接下来,是时候创建一些风格化的前端 React 页面来向终端用户展示这些服务结果了。在本学习模块中,您需要执行以下任务将前端添加到应用程序中: 创建一个经销商组件,列出所有经销商 创建一个经销商详细信息组件,显示特定经销商的评论 创建评论提交页面 4.1. 为经销商页面添加和设置 React 组件 打开 frontend/src/App.js,导入 Dealer 组件并将其与其他组件一起添加到顶部。该组件已为您创建,并使用表格元素列出经销商。您可以随意更改外观和感觉。 import Dealers from './components/Dealers/Dealers'; 为 /dealers 添加路由,以呈现 Dealers 组件: <Route path="/dealers" element={<Dealers/>} /> 打开 server/djangoproj/urls.py,在其中添加 Dealers 和 Dealer 的路由: path('dealers/', TemplateView.as_view(template_name="index.html")), 打开一个新终端,像以前一样创建前端。进入 127.0.0.1:8000/dealers 测试 get_dealers 视图。 4.2. 添加 React 组件 Dealer 显示评论 打开 frontend/src/App.js,导入 Dealer React 组件的路由并将其添加到其他路由中。这样,当您点击经销商表上的链接时,就会呈现一个经销商特定的 React 页面以及评论。 该页面还有一个链接,可以让已登录的用户发表评论。您将在下一个任务中添加发布评论页面。 import Dealer from "./components/Dealers/Dealer" <Route path="/dealer/:id" element={<Dealer/>} /> 在之前创建前端的同一终端再次创建前端。 打开 server/djangoproj/urls.py,加入以下代码,添加显示经销商页面的路径: path('dealer/<int:dealer_id>',TemplateView.as_view(template_name="index.html")), 刷新应用程序,进入 View Reviews 页面,点击任何经销商的名称链接,查看其评论。 4.3. 创建经销商详情或评论页面 通过身份验证的用户应能点击该链接并为经销商添加评论。我们将添加一个评论提交页面。 打开并查看 frontend/src/components/Dealers/PostReview.jsx。根据需要对外观和感觉进行修改。 导入 PostReview 组件,并在 frontend/src/App.js 中添加 postreview/<dealer id> 路由。 import PostReview from "./components/Dealers/PostReview" <Route path="/postreview/:id" element={<PostReview/>} /> 转到构建前端的终端,再次运行构建。 打开 server/djangoprojs/urls.py,在其中加入以下代码,以添加审阅后页面的路径: path('postreview/<int:dealer_id>',TemplateView.as_view(template_name="index.html")), 使用已注册的用户名和密码登录。您将看到 Post Review 链接。向经销商添加评论,测试该链接。 5. CI / CD、容器化和部署到 Kubernetes 在本模块中,您将为您创建的所有 JavaScript 和 Python 文件设置一个 CI / CD 操作流程。然后,您将运行所有服务器端组件,包括 Docker 容器中的 Express-Mongo 服务器和代码引擎上的情感分析器无服务器部署。最后,您将构建前端 React 应用程序,并在 Kubernetes 上部署 Django 应用程序。 配置 Django 应用程序并将其部署到 Kubernetes。 恭喜您在添加静态页面和用户管理后测试了应用程序。下一步是为源代码设置持续集成和持续交付(CI / CD)。如果有多人参与项目,这一点尤为重要。持续集成(CI)为开发人员提供了一种协作方式,而持续交付(CD)则提供了一种不间断地向客户交付变更的方式。在本模块中,您将: 了解所提供的 GitHub 工作流程(workflow)模板中的工作流程 了解所提供的 GitHub 工作流程模板中的 Linting 作业 启用 GitHub Actions 并运行 Linting 工作流 Linting 是一种自动检查源代码中是否存在编程和样式错误的方法,通常通过使用 Lint 工具(也称为 Linter)来实现。Lint 工具是一种基础的静态代码分析器。 5.1. 添加持续集成和持续部署功能 您的团队正在壮大!管理层决定招聘前端和后端工程师,以确保路线图上的功能能在未来版本中及时开发出来。然而,这意味着多名工程师需要在版本库上并行工作。您的任务是确保推送到主分支的代码符合团队的编码风格,并且没有语法错误。 在本实验中,你将为版本库添加语法检查功能,在开发人员创建拉取请求或将分支合并到默认主分支时自动检查语法错误。在开始实验之前,我们先来了解一下 GitHub Actions。 5.1.1. GitHub Actions GitHub Actions 提供了一种事件驱动的方式来自动执行项目中的任务。您可以监听多种事件。下面是几个例子: push:当有人向版本库分支(branch)推送时运行任务。 pull_request:当有人创建拉取请求(PR)时运行任务。您还可以在某些活动发生时启动任务,例如: PR 已打开 PR 已关闭 PR 被重新打开 create:当有人创建分支或标记时运行任务。 delete:当有人删除分支或标记时运行任务。 manually:手动启动任务。 在本实验中,您将使用以下一个或多个组件: Workflows(工作流):您可以添加到存储库的作业集合。 Events:启动工作流的活动。 Jobs(作业):一个或多个步骤的序列。作业默认并行运行。 Steps:可在作业中运行的单个任务。一个步骤可以是一个操作或命令。 Actions:工作流的最小模块。 下面为您提供了一个工作流程模板。让我们来研究一下: name: 'Lint Code'on: push: branches: [master, main] pull_request: branches: [master, main]jobs: lint_python: name: Lint Python Files runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: 3.12 - name: Install dependencies run: | python -m pip install --upgrade pip pip install flake8 - name: Print working directory run: pwd - name: Run Linter run: | pwd # This command finds all Python files recursively and runs flake8 on them find . -name "*.py" -exec flake8 {} + echo "Linted all the python files successfully" lint_js: name: Lint JavaScript Files runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@v3 - name: Install Node.js uses: actions/setup-node@v3 with: node-version: 14 - name: Install JSHint run: npm install jshint --global - name: Run Linter run: | # This command finds all JavaScript files recursively and runs JSHint on them find ./server/database -name "*.js" -exec jshint {} + echo "Linted all the js files successfully" 第一行命名工作流程。 下一行定义了工作流的运行时间。工作流应在开发人员向主分支推送变更或创建 PR 时运行。这两种方式的捕获方式如下: 推送到主分支(主分支或主分支)时运行: push: branches: [master, main] 在主分支(主分支或主分支)上创建 PR 时运行: pull_request: branches: [master, main] 然后,您将定义所有工作。本工作流程中有两个任务: lint_python: 检查 Python 函数 lint_js: 检查 JavaScript 函数 5.1.2. GitHub Jobs 让我们逐一看看这些作业: lint_python: 使用 actions/setup-python@v4 操作设置该操作的 Python 运行时。 使用 pip install 安装所有依赖项。 在服务器目录下的所有文件中递归运行 Linting 命令 flake8 *.py。 打印一条提示信息,说明 Linting 已成功完成。 lint_function_js: 使用 actions/setup-node@v3 操作设置要运行的 Node.JS 运行时。 安装所有 JSHint 内核 npm install jshint。 在数据库目录中的所有 .js 文件上递归运行 Linting 命令。 打印一条提示信息,说明 Linting 已成功完成。 5.1.3. 启动 GitHub Actions 要启用 GitHub Actions,请登录 GitHub 并打开已 Fork 的仓库。然后,转到 Actions 选项卡,点击 Set up a workflow yourself。将上述 Lint 代码粘贴到 main.yml 中并提交。再次打开 Actions 选项卡,就会看到提交已自动启动了检查工作流程。 您可以单击工作流程运行,查看单个作业和每个作业的日志。工作流程成功完成后,你会看到绿色的 √,表示工作顺利。红叉表示在检查代码时发现了错误。 查看这些提示,解决你可能遇到的常见 Linting 错误。 Flake-8 Lint(Python)Linting 错误: 如果收到下列一个或多个错误: E117 over-indentedE128 continuation line under-indented for visual indent 解决方法: 确认所有代码都保持适当的缩进 —— 既不会缩进过小,也不会缩进过大。 注意:使用文本编辑器确保准确执行。 E501 line too long (xxx > 79 characters) 解决办法: 将代码分成多行,确保每行最多不超过 79 个字符。 F401 'xxx' imported but unused 解决方法: 核实后续代码段中是否使用了提及的实体或变量(xxx)。如果未使用实体或变量,则删除包含该实体或变量的行。 这里有个小坑,djangoapp/views.py 文件中每个函数中的 request 参数绝对不能删除,建议在当前函数内 print 一下 request 参数来回避该错误。部分不能被删除但同时也没被用到的变量同理。 W292 no newline at end of file 解决方法: 在文件的最终代码后插入新行,并将光标置于垂直窗格的最左侧(不向右缩进)。 E302 expected 2 blank lines, found 1 解决方案: 确保每对相邻函数之间正好有两行空行(不能多也不能少)。 E231 missing whitespace after ':' 解决方法: 确保在所有字典键值对中的分号后留一个空格。 例如,如果现有代码为 "a":"b",请将其更改为 "a": "b"。 E275 missing whitespace after keyword 解决办法: 确保每个关键字后都有一个空格。 例如,如果您的现有代码是 if("condition"):,请将其改为 if ("condition"):。 E722 do not use bare 'except' 解决方法: 使用 except Exception: 而不是 except 作为捕获异常和全面处理异常的最佳实践。 例如,如果现有代码是: except: print("Error") 您可以将其改为: except Exception as e: print(f"Error: {e}") JS Hint(JavaScript)Linting 错误: 如果收到下列一个或多个错误: 'const' is available in ES6 (use 'esversion: 6') or Mozilla JS extensions (use moz).'arrow function syntax (=>)' is only available in ES6 (use 'esversion: 6').'async functions' is only available in ES8 (use 'esversion: 8').'let' is available in ES6 (use 'esversion: 6') or Mozilla JS extensions (use moz).'template literal syntax' is only available in ES6 (use 'esversion: 6'). 解决方法: 在报告此错误的文件开头添加以下一行: /*jshint esversion: 8 */ ['xxxxxx'] is better written in dot notation. 解决方法: 当字典或 JSON 键值对的格式为 key[value] 时,就会出现这个问题。要解决这个问题,请将格式切换为 key.value。 5.2. 容器化并部署到 Kubernetes 根据最新的技术发展趋势,并为了避免供应商锁定,贵公司的管理团队希望将经销商应用程序部署到多个云中。该应用程序目前在 Code Engine 上运行,但您被告知并非所有云提供商都提供托管 Code Engine 服务。所有大型云提供商都有托管和管理容器的方法,因此您被要求将容器作为缓解这一问题的可能方法。在对应用程序进行容器化时,过程包括将应用程序与其相关的环境变量、配置文件、库和软件依赖关系打包。其结果是一个容器映像,可以在容器平台上运行。您还需要使用 Kubernetes 来管理容器化部署。 Kubernetes 是一个开源容器编排平台,可以自动部署、管理和扩展应用程序。 在本模块中,您将: 为应用程序添加在容器中运行的功能 为应用程序添加部署工件,以便 Kubernetes 对其进行管理 注意:在开始实验之前,请按照步骤检查和删除以前持续存在的会话,以避免在运行实验时出现任何问题。 请运行以下命令: kubectl get deployments 如果您发现经部署 dealership 已经存在,请删除它: kubectl delete deployment dealership 请运行以下命令: ibmcloud cr images 如果有任何 dealership 镜像,请使用删除它: ibmcloud cr image-rm us.icr.io/<your sn labs namespace>/dealership:latest && docker rmi us.icr.io/<your sn labs namespace>/dealership:latest 请输入您的 SN Labs 命名空间,以代替 <your sn labs namespace>。(SN Labs 命名空间是 IBM Skills Network 内置的) 如果您不记得自己的命名空间,可以使用以下任一命令获取: oc 项目 ibmcloud cr 命名空间(请使用格式为 sn-labs-$USERNAME 的命名空间) 请退出 SN Labs 并清除浏览器缓存和 cookie。 请重新启动实验室并按以下步骤操作。 5.2.1. 设置环境 如果实验室环境已重置,请克隆您的 Git 仓库。 打开一个新终端,切换到 server/database 目录,然后按照之前的操作运行 Mongo Express 服务器。 如果部署在 Code Engine 上的情感分析器微服务不可用,请重新部署并更新所需的 URL。 打开另一个新终端。切换到 server/frontend 目录。运行以下命令创建前端: npm installnpm run build 5.2.2. 添加 Dockerfile 在 server 目录下创建一个 Dockerfile。文件中应列出以下步骤: 添加基础镜像。 添加 requirements.txt 文件。 安装并更新 Python。 更改工作目录。 公开端口。 运行命令启动应用程序。 下面是一个示例文件,供您开始使用: FROM python:3.12.0-slim-bookwormENV PYTHONBUFFERED 1ENV PYTHONWRITEBYTECODE 1ENV APP=/app# Change the workdir.WORKDIR $APP# Install the requirementsCOPY requirements.txt $APPRUN pip3 install -r requirements.txt# Copy the rest of the filesCOPY . $APPEXPOSE 8000RUN chmod +x /app/entrypoint.shENTRYPOINT ["/bin/bash","/app/entrypoint.sh"]CMD ["gunicorn", "--bind", ":8000", "--workers", "3", "djangoproj.wsgi"] 请注意,Dockerfile 中倒数第二个命令指的是 entrypoint.sh。在 server 目录中创建该文件。该文件应包含以下内容: #!/bin/sh# Make migrations and migrate the database.echo "Making migrations and migrating the database. "python manage.py makemigrations --noinputpython manage.py migrate --run-syncdb --noinputpython manage.py collectstatic --noinputexec "$@" 5.2.3. 构建镜像并将其推送到容器注册表 您必须记住如何构建镜像并将其推送到 IBM Cloud Image Registry(ICR)。您需要在这里执行相同的操作,然后在 Kubernetes 部署文件中引用该镜像。 请导出 SN Labs 命名空间,并在控制台中打印出来,如下所示: MY_NAMESPACE=$(ibmcloud cr namespaces | grep sn-labs-)echo $MY_NAMESPACE 使用当前目录下的 Dockerfile 执行 docker 构建: docker build -t us.icr.io/$MY_NAMESPACE/dealership . 接下来,将镜像推送到容器注册表: docker push us.icr.io/$MY_NAMESPACE/dealership 5.2.4. 添加部署工件 在 server 目录下创建 deployment.yaml 文件,以创建部署和服务。文件应如下所示: apiVersion: apps/v1kind: Deploymentmetadata: labels: run: dealership name: dealershipspec: replicas: 1 selector: matchLabels: run: dealership strategy: rollingUpdate: maxSurge: 25% maxUnavailable: 25% type: RollingUpdate template: metadata: labels: run: dealership spec: containers: - image: us.icr.io/your-name-space/dealership:latest imagePullPolicy: Always name: dealership ports: - containerPort: 8000 protocol: TCP restartPolicy: Always 请在上述文件中输入您的 SN Labs 命名空间,以代替 your-name-space。 5.2.5. 部署应用程序 使用以下命令和部署文件创建部署: kubectl apply -f deployment.yaml 通常,我们会在部署中添加一个服务;不过,我们将在此环境中使用端口转发功能来查看正在运行的应用程序: kubectl port-forward deployment.apps/dealership 8000:8000 注意:如果出现任何错误,请稍等片刻,然后重新运行该命令。

2024/6/28
articleCard.readMore

IBM 全栈开发【10】:微服务和无服务器

近期在学习 IBM 全栈应用开发微学士课程,故此记录学习笔记。 1. 微服务入门 1.1. 十二要素应用程序方法论 在现代软件开发中,软件通常以服务的形式交付。软件通过互联网集中托管和访问。这种软件通常被称为网络应用程序或软件即服务(简称 SaaS)。十二要素应用程序方法论适合这类应用程序。微服务不是十二要素应用程序的必要条件。不过,微服务通常与十二要素应用程序方法论结合使用。 十二要素可分为软件交付生命周期中的代码、部署和运维三个阶段。 1.1.1. 代码阶段 首先是 要素一:代码库。您应始终在版本控制系统(如 Git)中跟踪应用程序的代码库。代码库和应用程序之间是一一对应的关系。一个应用程序应包含在一个单一的代码库中。但是,应用程序会有多个部署或实例。虽然这些部署中的代码库是相同的,但每个部署中都可能存在不同的应用程序版本。例如,开发或测试环境中可能会有尚未到达生产环境的变更。 接下来是 要素五:构建、发布和运行。这一阶段展示了代码库如何成为生产部署。构建阶段编译代码、收集依赖关系,然后将代码库转化为称为构建的可执行单元。发布阶段将构建与部署的当前配置相结合,这样代码就可以运行了。然后,在运行阶段实现应用程序。这三个阶段应严格分开。例如,代码不应在运行时进行更改,因为这样会导致这些更改无法纳入构建阶段。 要素十 是 开发与开发的一致性。这一要素最大限度地减少了开发环境和生产环境之间的差异,这对于持续交付以快速将变更推广到生产环境是必不可少的。此举可减少代码在一个环境中运行正常、而在另一个环境中运行不正常的情况。奇偶校验对于后端服务尤为重要:如果在生产中使用 MySQL 数据库,则应在开发环境中使用相同版本的 MySQL 数据库。奇偶校验有助于在开发过程中尽早发现故障。 我们要讨论的最后一个代码要素是 要素二:依赖。应用程序的可靠性取决于其最不可靠的依赖关系。因此,十二要素应用程序不依赖于任何软件包、依赖项或工具的隐式存在。您必须明确声明所有依赖关系。这样,当新开发人员抓取代码库时,就不会假设他们的机器上已经存在任何依赖关系。 1.1.2. 部署阶段 第一个部署要素是 要素三,即 配置。配置是指不同部署之间可能存在差异的一切。暂存和生产环境可能使用不同的数据库,因此开发人员应根据部署情况配置数据库的凭据和位置。应避免将配置存储为常量,因为不同环境下的配置可能不同。将配置存储在环境变量中,这样就可以在部署过程中轻松更改,而无需更改代码。 暂存(也称为预生产,Staging)复制了一个完整的生产环境,以便在生产变更之前对其进行测试。自动化和公共云平台可按需提供暂存环境,以便对生产变更进行最终测试。 要素四 是 后台服务。十二要素应用程序不应区分本地服务和第三方服务。两者都应可通过 URL 或其他定位器信息以及任何凭据进行访问,这样开发人员就可以在不更改代码的情况下轻松更换后端服务。例如,如果数据库出现问题,就可以启动一个新的数据库,并在无需更改代码的情况下将其替换进来。 要素六 是 进程。应用程序在环境中以一个或多个进程的形式执行。进程应该是无状态的,不共享任何东西。您应将持久数据存储在数据库等后端服务中,因为内存和文件系统不会在进程间共享。如果另一个进程处理后续事务,后续事务将无法访问前一个进程中的数据。因此,数据需要集中存储。 如果进程是无状态的,不共享任何东西,那么应用程序就可以启动额外的进程进行横向扩展,处理额外的工作量,而不会在进程间产生相互依赖关系。 端口绑定 是 要素七。创建面向网络的服务时,不应在运行时将网络服务器注入应用程序。相反,网络应用程序应通过绑定端口并监听该端口上的传入请求来导出 HTTP。您可以将端口绑定用于 HTTP 和其他服务。绑定端口通常是在代码中通过声明网络服务器库为依赖关系来实现的。随后,由于这些应用程序可以通过 URL 访问,因此它们可以成为其他应用程序的后端服务。 1.1.3. 运维阶段 让我们从 要素八「并发性」开始。应用程序运行并发进程来处理不断增加的负载。由于进程是无状态的,不共享任何内容,因此应用程序可以启动更多进程进行横向扩展,处理更多传入请求,而不会在进程间产生相互依赖关系。 要素九 是 可处置性,它规定应用程序进程需要最短的启动时间,并应在终止时优雅地结束。快速启动可让您快速部署代码或配置变更。您还可以轻松扩展应用程序,因为新的部署可以快速启动。 要素十一 决定了 如何处理日志。日志可提供应用程序性能的可见性,因此应用程序不应关注日志的存储。相反,应用环境应将日志作为写入标准输出的事件流来处理。执行环境可以捕获所有运行应用程序的日志流,将其汇总并路由至目的地。当目的地是日志分析工具时,这一操作尤其有用。 最后一个要素是 要素十二:管理流程。管理流程是管理应用程序的一次性流程,例如数据库迁移。管理流程使用相同的代码库和配置针对发布版本运行。此外,应用程序代码应包含管理流程,使其与应用程序保持同步。 1.2. 微服务 微服务架构是一种方法,在这种方法中,单个应用程序由许多松散耦合且可独立部署的小型服务组成。这些服务通常有自己的技术栈,包括数据库和数据管理模式。团队甚至可以为不同的组件选择不同的编程语言,因为它们通过 API 端点相互依赖。 微服务组件通过 REST API、事件流和消息代理的组合相互通信,并根据业务功能进行隔离和组织,服务之间的分隔线称为有界上下文。 由于服务之间不存在相互依赖关系,因此您可以更轻松地更新代码,添加新特性或功能,而无需触及整个应用程序。每个团队都可以选择适合自身需求和专长的技术栈,构建团队负责的组件。这些组件可以相互独立地扩展,从而减少了因单一功能可能面临过多负载而不得不扩展整个应用程序所带来的浪费和成本。 无论你在哪里看到微服务的扩展,通常都涉及水平扩展。水平扩展是指通过添加更多的资源实例来进行扩展,也被称为「向外扩展」。有了微服务,单个服务可以单独部署和扩展。如果操作得当,微服务所需的基础架构会更少,因为它们可以只对需要的组件进行精确扩展,而不是像单体应用程序那样对整个应用程序进行扩展。 调用应用程序接口通常是建立特定服务状态的有效方法。然而,这并不是一种特别有效的了解最新进展的方式。此时,事件流可以帮助广播状态变化,从而通过引入消息代理帮助扩展微服务。 1.2.1. 单体应用程序 VS SOA VS 微服务 单体应用程序的所有或大部分功能都在一个进程中完成。应用程序由内部层或库来管理。这些层可用于安全、报告、分析、用户界面或数据访问。各层之间紧密相连,相互依赖。这正是单体设计的优势所在;因为一切都在同一个代码库中,所以简单、功能和特性之间的交叉较少。 然而,随着时间的推移,大多数产品都在不断发展,范围也在不断扩大,设计变得越来越复杂,修改起来也越来越困难。这也成为适应新技术的障碍,因为这意味着要重新编写整个应用程序。 Windows 窗体应用程序是单体设计的一个典型例子。在这里,我们将用户界面、业务逻辑和数据访问全部嵌入到一个代码库中,从而构成了整个应用程序。只有数据库不在其中。 面向服务架构(SOA)是以服务提供者和消费者的方式设计和构建的。所提供的服务作为一个离散的功能单元,无缝集成,易于重复使用。每个 SOA 服务都由三个部分组成。 接口(interface)定义了服务提供商如何执行服务消费者的请求。 合约(contract)定义了服务提供商和服务消费者应如何交互。 实现(implementation)则是服务代码。 拥有这三个独立组件的好处是,它们有助于提高可靠性,并由于服务之间的固定合约而支持并行开发。 然而,由于这些期望和要求,SOA 设计可能会变得复杂,阻碍快速应用开发。而且,SOA 设计是一项昂贵的投资,因此通常只适合那些能够投入所需资源和专业知识的企业团队。SOA 是一个企业范围的概念。银行系统就是一个例子。它使现有的应用程序能够通过松散耦合的接口进行公开,每个接口都与业务功能相对应,从而使扩展企业一部分的应用程序能够在其他应用程序中重复使用功能。银行的每个接口都提供服务,以完成其职责。 与 SOA 一样,微服务架构由松散耦合、可重用和专用的组件组成,这些组件通常彼此独立工作。即使是微服务中的数据也不会与其他服务共享,这有助于您独立扩展单个微服务,而独立也意味着您可以自由选择底层技术。这种架构的好处是易于开发,可以将单个工作单元定义为一项服务。由于每个服务都有指定的职责,因此可以灵活地添加新技术。 不过,虽然使用微服务有其令人信服的理由,但也存在一些相关的问题。首先是安全问题。现在有这么多不同的服务独立托管,每个服务都需要有自己的安全模式。例如,一个简单的要求,如传输层安全(或 TLS),以确保网络通信的安全。其次,调试和隔离问题也会变得更加困难,因为每个服务都是独立运行的,这意味着你可能会发现找到根本原因很有难度。 微服务架构是一个应用范围概念。例如,电子商务应用程序可以有独立的微服务来处理订单、安全和分析。微服务设计可将单个应用程序的内部结构分割成小块,这些小块可独立更改、扩展和管理。但是,微服务并没有定义应用程序之间的对话方式。 1.2.2. 微服务模式 微服务有许多可用的模式,可以应对一些更常见的问题和机遇。例如,单页面应用程序(或 SPA)模式、后端换前端(或 BFF)模式、Strangler 模式和服务发现模式。 SPA 模式: 随着功能更强大的浏览器、速度更快的网络和客户端语言的融合,许多网络界面开始将所有功能整合到单页应用程序中。用户通过一个界面进入,不会重新加载登陆页面,也不会脱离最初的体验。 这些应用程序结合使用 HTML、CSS 和 JavaScript,通过动态服务调用后端基于 REST 的服务来响应用户输入,更新屏幕的部分内容,而不是重定向到一个全新的页面。 这种应用架构通常简化了前端体验,但后端服务需要承担更多责任。 BFF 模式: 虽然单页面应用程序能很好地满足单渠道用户体验的需求,但它在不同渠道(如移动和网络)的用户体验方面效果不佳。BFF 模式在用户体验和体验所调用的资源之间插入了一个层。这种设计可以在不同渠道之间实现定制的用户体验。 例如,在台式机上使用的应用程序与在移动设备上使用的应用程序在屏幕尺寸、显示效果和性能限制方面都有所不同。BFF 模式允许开发人员为每个用户界面创建和支持一种后端类型,并使用该界面的最佳选项,而不是试图支持一种通用后端,这种后端适用于任何界面,但可能会对前端性能产生负面影响。 假设用户可以通过移动应用程序或桌面上的网络应用程序访问应用程序。在应用 BFF 模式时,您需要开发一个专门用于移动体验的后端和另一个用于网络体验的后端。每个后端都知道如何调用正确的服务和协调代码,以优化所请求的渠道体验。 移动应用程序可能会显示更有限的数据子集,屏幕尺寸也会与网络体验不同。每个后端都是一个微服务。您可以应用微服务架构,将单体后端分离成不同的服务,执行各自特定的必要任务,而不是让单体应用程序先检查需要哪个通道,然后再包含所有逻辑,为该通道准备用户体验。 Strangler 模式: 有助于分阶段管理单体应用程序的重构。该模式的隐喻名称来源于花园中藤蔓缠绕树木的现象。试想一下,一个网络应用程序是通过单个 URL 构建的,这些 URL 的功能映射到业务域的不同方面。 利用 Strangler 模式,您可以使用网络应用程序的结构,将应用程序拆分成多个功能域,并一次针对一个域使用基于微服务的新实现来取代这些域。这两个方面形成了独立的应用程序,它们并排存在于同一个 URL 空间中。随着时间的推移,新重构的应用会取代原来的应用,直到最后可以关闭单体应用。 Strangler 模式包括以下步骤: 转换。这将在云平台或现有环境中创建一个并行的新网站。 共存。这将使现有网站在指定时间内正常运行。它会逐步从当前位置重定向到新网站,以获得新实施的功能。 消除。这将删除现有网站上过时的功能,或在从原网站重定向流量时停止维护该功能。 服务发现模式: 可帮助应用程序和服务相互发现。之所以需要这种模式,是因为在微服务架构中,服务实例会因扩展、升级、服务故障甚至服务终止而发生动态变化。负载平衡器也可以使用这种模式进行健康检查,并在服务失败时重新平衡流量。其他模式包括实体和聚合模式,这种模式可用于电子商务网站,在该网站上,订单将是由买方分组的产品的聚合。 适配器模式: 就像你去另一个国家旅行时想到插头适配器一样。适配器模式的目的是帮助转换原本不兼容的对象之间的关系。例如,如果您集成了第三方应用程序接口。 1.2.3. 微服务反模式 虽然有很多模式可以很好地开发微服务,但同样也有很多模式会让开发团队很快陷入困境。以下是开发微服务时的一些禁忌: 不要构建微服务。 微服务的第一条规则是不要从微服务开始。当您确定单体应用程序的复杂性会对应用程序的开发和维护产生负面影响时,请考虑将该应用程序重构为更小的服务。 当应用程序变得过于庞大,无法轻松更新和维护时,这些微服务将成为分解复杂性的理想选择,使应用程序更易于管理。 然而,在你感受到这种痛苦之前,你甚至还没有一个需要重构的单体。 不重视自动化。 如果您有一个单体应用程序,您只需要部署一个软件。一旦转向微服务架构,您将拥有不止一个应用程序,每个应用程序都有不同的代码、测试和部署周期。 试图构建微服务时,必须同时具备以下两个条件: 适当的自动化部署和监控,或…… 管理云服务,以支持您现在庞大的异构基础设施。 会带来很多不必要的麻烦。 因此,在构建微服务时,一定要使用 DevOps 或云服务。 不要构建纳米服务。 如果过分追求微服务中的 微,就很容易发现自己正在构建纳米服务!其复杂性将超过微服务架构的总体收益。 倾向于创建大型服务,并在适当的时候创建小型服务: 部署更改变得困难。 通用数据模型变得过于复杂。 加载和扩展要求不再同步并影响应用程序性能。 不要变成 SOA。 微服务和面向服务架构(SOA)这两个概念经常被混为一谈,因为在最基本的层面上,两者都是构建可被其他应用程序使用的可重用的单个组件。 然而,微服务是细粒度的,每个微服务都有独立的数据存储,也就是有边界的上下文。 微服务项目如果演变成 SOA 项目,很可能会不堪重负。 不要为每种服务建立网关。 您应该使用 API 网关,而不是在每个服务中实施终端用户身份验证、节流、协调、转换、路由和分析。 API 网关是一种 API 管理工具,位于客户端和后端服务集合之间。 这将成为上述非功能性问题的核心,并避免在每个服务中重新设计这些问题。 微服务的目的是解决三个最常见的挑战,即通过提供细粒度服务的业务功能来提升客户体验、灵活应对新需求和降低成本。 但是,在这样做的同时,你应该避免上述反模式的陷阱,以免微服务对你的开发、交付和管理要求造成困扰。 2. Web API 要素:REST API 和 GraphQL 2.1. REST REST 是「表征状态传输」(Representational State Transfer)的缩写。REST API 为集成应用程序提供了一种灵活、轻量级的方式,并已成为微服务架构中连接组件的最常用方法。它是一种定义应用程序如何在网络中相互通信的架构风格。 应用程序接口(API)需具有三个特征才可将其归类为 RESTful(REST 式的): 它通过 HTTP 管理所有请求。 它提供无状态的客户端 - 服务器通信。 它由组件之间的统一接口组成。 REST API 通过 HTTP 请求进行通信,以执行标准功能,如在资源中创建、读取、更新和删除记录(也称为 CRUD)。例如,REST API 使用 POST 请求创建记录,使用 GET 请求检索记录,使用 PUT 请求更新记录,使用 DELETE 请求删除记录。 REST 应用程序接口是无状态的,这意味着每个请求都包含处理该请求所需的全部信息。REST 的创始人罗伊・菲尔丁(Roy Fielding)在他的论文中说道:「从客户端到服务器的每个请求都必须包含理解请求所需的全部信息,而不能利用服务器上的任何存储上下文。因此,会话状态完全保存在客户端。」REST API 的这种无状态特性也使其具有可扩展性。 RESTful API 的主要优点是接口统一,无论请求来自何处。REST API 应确保同一块数据(如产品 ID)只属于一个统一资源标识符(或 URI)。资源应包含客户可能需要的所有信息。例如,客户可能需要产品的名称和价格。 让我们来看一个 REST 的例子:CEX.IO 是一个加密货币交易所。它通过 REST API 为开发者提供比特币、其他加密货币的价格和市场数据。CEX 在其开发人员部分记录了所有 API 调用,包括请求和响应参数的详细信息,全部采用简单的 JSON 格式,以及 JavaScript、Python、C# 和 Java 示例请求和代码片段。例如,您可以通过其公共 API 请求货币对的最后价格。 2.1.1. 创建 REST API Python 是一种编程语言,能让您更快速地工作,更有效地集成系统。Python 可用于许多应用领域。它借助 Flask 等框架支持网络开发。借助数学、科学和工程领域的 SciPy 等库,Python 被广泛应用于科学和数值计算领域。Python 在人工智能和机器学习领域也很受欢迎。Flask 被归类为微型网络框架,因为它不需要特定的工具或库。它的构建考虑到了可扩展性和简单性。与其他框架相比,Flask 应用程序以轻量级著称。由于 Flask 对使用什么数据库或模板引擎没有意见,因此它是 RESTful API 的不错选择。 # hello.pyfrom flask import Flaskapp = Flask(__name__)@app.route('/')def hello_world(): return 'Hello World!' 使用以下命令: flask --app hello run 从浏览器中打开 127.0.0.1:5000 便能看到响应。 # products.pyfrom flask import Flask, jsonify, requestimport jsonapp = Flask(__name__)products = [ {'id': 143, 'name': 'Notebook', 'price': 5.49}, {'id': 144, 'name': 'Black Marker', 'price': 1.99}] 现在,您将创建一个名为 products.py 的新 Python 文件,该文件将为产品微服务提供所有端点。首先定义导入并创建默认产品列表。我们不会使用任何数据库来持久化这些产品,因此每次重启 API 时,产品列表都是一样的。接下来,定义 GET 方法来检索所有产品。该方法隐式返回 200,在 HTTP 中表示 0K。 2.1.2. 使用 cURL 和 Postman 发送应用程序接口请求 与服务器之间的数据传输需要支持必要网络协议的工具。Linux 上有多种用于此目的的工具,其中最流行的是 curl。curl 是 Client URL 的缩写,是一种命令行工具,可以通过各种网络协议传输数据。它开发于 1998 年,通过指定相关的 URL 和需要发送或接收的数据,可用于与网络或应用程序服务器进行通信。你可以运行一个简单的 curl 命令(如本例所示),然后查看输出结果: curl https://www.example.com/ curl 最常见的用例包括从互联网下载文件、端点测试、调试和错误记录。curl 支持的一些常见协议包括 HTTP、HTTPS、FTP 和 IMAP。 如果运行这个示例的 curl 命令: curl -X 'GET' 'http://127.0.0.1:5000/products' -H 'accept: application/json' 你将得到如图所示的输出结果: [ {"id":143,"name":"Notebook","price":5.49}, {"id":144,"name":"Black Marker","price":1.99}] curl 接受多种选项,这使它成为一个用途极为广泛的命令。选项以一个或两个破折号开头,如果不需要附加值,单破折号选项可以写在一起。那么,让我们来分解一下命令和输出:连字符 X 表示明确指定 HTTP 函数,在本例中就是 GET。然后,我们指定要评估的 URL。连字符 H 允许您定义标题,在本例中就是告诉网络服务器我们要使用 JSON。我们的输出是以 JSON 表示的产品微服务返回的产品列表。 Postman 是一个用于构建和使用 API 的 API 平台,它基于一系列用户友好的强大工具,使开发人员能够轻松地创建、测试、共享和记录 API。Postman 简化了 API 生命周期的每个步骤,允许协调多个请求,这些请求可以在多次重复或迭代中执行,并帮助您简化协作,从而更快地创建更好的 API。由于其简单易用,它是最流行、最方便的工具之一,可用于测试支持多种协议的各种 API(例如 GET、POST、PUT 和 PATCH 等 HTTP 请求),然后将 API 转换为 JavaScript 和 Python 等语言的代码。 WhatsApp 业务平台云 API 就是一个广受欢迎的例子,它利用 Postman 创建了一种简化的、开发人员友好的体验。有了 Postman,使用自动化和预先填入的数据,入职时间缩短到几分钟,而其他需要手动设置的方法则无法做到这一点。从注册 WhatsApp 商务平台到测试电话号码和建立信息通话,开发人员只需几分钟就能完成。 您可以将 Postman 下载到电脑,也可以使用在线版本。 2.1.3. 记录和测试 REST API Swagger API 指定了一个接口,并连接不同的系统,为它们提供一致的通信。API 文档就像一本参考手册,包含有效使用和集成系统的说明。Swagger 通过运行 OpenAPI 规范来确保您符合指导原则,从而节省了编写 API 文档的时间。 Swagger 允许您描述 API 的结构,以便机器可以读取它们。有了 API 的结构,Swagger 就能自动构建引人入胜的交互式 API 文档。该结构定义在符合 OpenAPl 规范的 JSON 或 YAML 文件中。 最初,API 并不是为了自我服务消费。它们由数据驱动,解决了连接和通信方面的一些特殊用例。而 OpenAPl 规范为 RESTful APIs 定义了一个标准的、与语言无关的接口 -- 该规范与语言无关,可由人类和机器读取。此外,它还允许人们和计算机发现并了解服务的功能,而无需访问源代码、额外的文档或检查网络流量。它定义了 API 支持的所有操作、所需参数和预期返回值,以及所需的 API 身份验证。它甚至还定义了服务条款、联系信息和暴露的 API 的许可证信息等内容。 Flask 支持将 Python 函数作为 API 暴露。Flask Swagger UI 允许您描述和可视化 REST API,从而改进了 Flask 的功能。要在应用程序中引入 Swagger UI,您需要使用 flask 蓝图 flask-swagger-ui: pip install flask-swagger-ui from flask import Flask, jsonify, requestimport jsonfrom flask_swagger_ui import get_swaggerui_blueprintswaggerui_blueprint = get_swaggerui_blueprint( '/products/docs', 'https://{HOSTNAME}/swagger.json', config={'app_name': "Products microservice"})app.register_blueprint(swaggerui_blueprint) Swagger.json 以 JSON 文件的形式保存了 API 的定义和特征。您需要将此文件与您的 API 一起公开。因此,您需要一个路由来提供静态 Swagger.json 文件。 @app.route('/swagger.json')def static_file(): return app.send_static_file('swagger.json') 您现在有了 API 定义和 Swagger。您可以使用 UI 测试您的 API,这是使用 Swagger 的主要好处。此外,它还能让消费者获得有关您的 API、支持的功能、请求和响应的更多详细信息。它还显示产品信息部分的内容。它显示了唯一可用的方法,即检索所有产品的 GET 方法。然后,您可以执行该端点并查看结果,在本例中,结果就是产品列表。 2.2. API 网关 API 网关是一种 API 管理工具,位于客户端和后台服务集合之间。它汇集了满足客户需求所需的各种服务,并返回相应的结果。为什么要使用 API 网关呢?因为 API 网关可以帮助您保护 API 免受恶意使用或过度使用。因此,您可以使用具有速率限制的验证服务。使用分析和监控服务还有助于了解 API 的使用情况。此外,您还可以使用计费系统将 API 货币化。网关还能为您的各种微服务提供单一接触点,并对请求作出单一响应。最后,您可以无缝添加或移除 API,而客户对后端运行的服务一无所知。 设您的网店采用的是微服务架构,因此其中的一些服务将包括:产品信息服务,用于共享产品的基本信息(如白痴名称和价格);库存服务,用于显示现有库存;订单服务,用于让客户下订单购买产品;以及验证服务,用于验证平台上的用户。那么,客户端如何访问微服务呢?当您需要与多个应用程序接口交互时,这是一个问题。API 网关可以消除这种复杂性,并允许您:更改主机及其位置、增加或减少服务实例的数量,以及用新服务替换现有服务(例如订购服务)。客户对服务的访问不受影响。 使用 API 网关的好处如下: 它能使客户端免受应用程序如何分割成微服务的影响。换句话说,它将调用多个服务的逻辑从客户端转移到网关,从而简化了客户端。 它还能为每个客户端提供最佳的应用程序接口,无论客户端是谁。 它减少了请求或往返的次数。 例如,API Gateway 可以让客户只需往返一次,就能从多个服务中检索数据。无论您的微服务如何进行内部通信,API 网关都将提供与外界通信的标准协议。虽然 API 网关有很多好处,但它也有一些缺点: 它是另一个需要开发和维护的组件。 如果设计不慎,它还可能成为应用程序的单点故障。 由于在执行应用程序时增加了网络步骤,网关会增加响应时间。 市场上有很多 API 网关产品。您可以选择托管或开源方案。来自 IBM 的业界领先的高安全性应用网关 IBM DataPower Gateway 可根据您的需求提供两种产品:Apigee 或 Cloud Endpoints。 微软 Azure 和亚马逊 AWS 也在其平台上提供网关。虽然这些都是托管产品,但在开放源代码领域,一些著名的产品有: 最受欢迎的 Kong Apache APISIX Tyk(也有托管版本) Gloo(也有企业版本) 3. 无服务器概述 3.1. 无服务计算 云原生计算基金会(或 CNCF)将无服务器定义为「构建和运行无需服务器管理的应用程序的概念」。它描述了一种更细粒度的部署模式,在这种模式下,捆绑为一个或多个功能的应用程序被上传到一个平台,然后根据当下所需的确切需求来执行、扩展和计费。换句话说,无服务器计算将基础设施管理的责任卸载给了云提供商,使开发人员能够专注于应用程序的业务逻辑。 将无服务器计算视为功能即服务(或 FaaS)平台和后台即服务(或 BaaS)服务的组合。FaaS 平台用于运行功能。BaaS 代表后端云服务,如云数据库、对象存储服务和消息队列。现在,IT 计算的历史显示了从传统计算到无服务器计算的渐进趋势,从而加快了部署速度、缩短了生命周期并提高了生产率。 这一演变趋势有几个里程碑: 传统计算使用物理机,但受制于前期投资、容量规划等因素。物理机的部署需要数周或数月的时间,使用寿命长达数年。 在云计算中采用虚拟化技术,可实现更快的配置、高可扩展性和可用性。虚拟化有助于创建多个虚拟机(VM)或容器。虚拟机管理系统的部署只需几分钟,寿命为数天或数周。 基于操作系统虚拟化的容器只需几秒钟即可部署,并可存活几分钟或几小时。 而无服务器应用程序只需要无服务器架构的核心代码。无服务器功能的部署只需几毫秒,生命周期仅几秒钟。 无服务器计算是标准云计算的进步,它抽象了基础设施和软件环境。它是一种架构风格,代码在云平台上运行,由云提供商管理硬件和软件设置、安全性、可扩展性等。客户只需为使用付费,而无需为 CPU 空闲时间付费。而开发人员只需关注函数形式的应用代码。 无服务器计算具有以下特点: 它是无主机的,这意味着开发人员无需采购、管理和维护服务器。 它是弹性的,因为自动伸缩是无服务器的即时和固有特性。 它提供自动负载均衡,可在多个后端系统之间分配输入流量。 它是无状态的,因此性能更快,可扩展性更高。 它是事件驱动的,这意味着只有当事件发生时才会触发功能。 它提供高可用性,无需额外的工作或成本。 而且,它是基于使用量的细粒度计费。 那么,无服务器世界中的功能是如何工作的呢?以创建 Docker 或 Kubernetes 容器所需的步骤为例。开发人员使用云提供商支持的语言(如 Python、Java、Node.JS 或 Go)编写代码,创建一个函数。然后,开发人员将函数上传到云。然后,定义触发函数的事件,如用户点击。一旦事件发生,触发器就会被调用,云提供商就会运行函数,从而生成容器对象。 让开发人员花更多时间创建高质量和优化的应用程序对企业大有裨益。开发人员可以使用任何流行的编程语言构建功能,通过添加其他功能扩展应用程序的功能,执行更好的测试(因为功能一次只执行一项任务),优化应用程序和功能,并改善用户体验。 为了实现零运行成本的目标,云提供商负责日常基础设施的管理和维护工作,如最大限度地提高计算机内存和网络利用率,同时最大限度地降低成本,提供服务器管理(包括操作系统更新和安全补丁),启用自动扩展,维护高可用性,实施安全,配置高性能(或低延迟),以及设置监控和日志。 3.1.1. 无服务器责任模型 在比较图中所示的不同云服务模式时,让我们来看看您与云提供商的责任,尤其是无服务器服务模式。我们从最左侧的堆栈(传统堆栈)开始,完全由您来管理。在 IaaS 模式中,您负责管理从操作系统到顶层的各层,而云提供商负责管理较低的四层。在 PaaS 模式中,您管理前两层,即应用层和数据层,而云提供商管理其余各层。在无服务器模式中,用户只管理应用程序层,而云提供商管理其余各层。而在 SaaS 模式中,云提供商负责管理整个堆栈。 3.1.2. 无服务的利与弊 让我们来了解一下传统计算所面临的挑战,以及无服务器计算的出现是如何克服这些挑战的。 在传统计算中,开发和运营团队需要建立和维护自己的基础设施。这些过程耗费大量时间,非常复杂,而且需要资金投入。然而,随着云、容器和无服务器计算的出现,开发团队可以专注于编写高质量代码,并在毫秒级的时间内构建和运行应用程序,而无需担心基础设施、可扩展性和容错问题。无服务器计算也面临着一些挑战,包括供应商锁定、第三方依赖性和网络。 也让我们来看看无服务器计算的优势和限制,然后将其与容器和传统计算进行比较。 在无服务器计算中,云提供商承担了大部分工作,这带来了许多好处。由于云提供商负责管理基础设施,因此无需进行基础设施设置和维护,从而降低了成本。云提供商可确保可靠性,从而实现高可用性和容错性。开发人员受益匪浅,因为他们可以专注于应用程序,做自己最喜欢的事情。如果没有无服务器,应用程序的所有部分都会持续运行,从而造成资源浪费。 无服务器功能允许您对应用程序进行配置,使其仅在需要时运行某些部分。例如,应用程序的前端必须持续运行,以保持用户登录。而身份验证服务只需偶尔调用,因此可以节省资源和成本。函数的运行时间以毫秒为单位,而容器或虚拟机(VM)的运行时间则分别以秒和分钟为单位。 许多云提供商都内置了代码编辑器,称为集成开发环境(IDE),可加快开发、部署和更新。云服务提供商按请求付费,不收取任何闲置资源的费用。您可以使用任何流行的编程语言进行开发。在身份验证、数据库和其他后端服务方面有大量第三方支持。由于开发人员只专注于开发,因此企业可以专注于业务,以比竞争对手更快的速度发布产品。 无服务器环境允许更多的创新和实验,即使会有失败。由于没有基础设施需要管理,绿色计算也成为一种必然的可能。 然而,无服务器计算并非最适合每种情况,因为它确实存在一些限制: 许多企业在处理突发性工作负载时都能节省大量成本,但对于以长期运行流程为特征的工作负载,按使用付费模式带来的好处就会减少。对于这类应用,传统环境可能更具成本效益。 对云提供商技术或环境的依赖会导致供应商锁定风险。 如果隔了很长时间才收到请求,应用程序通常必须重新启动所有进程,即所谓的冷启动。这会增加功能运行时间。对于银行、医疗保健或边缘相关应用等时间关键型应用而言,无服务器延迟是不可接受的。 由于从端点到源代码的攻击面发生了变化,加上提供商的安全实施存在局限性,安全问题日益突出。 在任何分布式系统中,监控和调试都很复杂。 由于无法在本地系统中模仿后端即服务(或 BaaS)环境,因此测试完整功能和调试应用程序问题具有挑战性。 语言支持取决于云提供商。并非所有云提供商都支持所有编程语言,因此您只能使用云提供商支持的语言。 没有服务器可优化利用率或性能,因为您无法控制服务器。 没有状态持久性。例如,在下一次调用同一函数时,将无法使用之前的运行状态。由于本地缓存只能持续几个小时,因此最好使用低延迟的外部缓存,如 Redis 或 Memcached。 3.1.3. 无服务器 VS 容器 无服务器和容器能一起工作吗?当然可以!无服务器和容器并不相互排斥。它们在混合解决方案中配合得最好。如果你在无服务器或容器之间难以取舍,请遵循标准的行业建议:「首先构建无服务器。如果需要,再转向容器」。 让我们比较一下无服务器计算和容器化,从无服务器的优点和容器的缺点开始: 无服务器计算更具成本效益,因为您只需为使用的内容付费。 对于无服务器计算,可扩展性完全由云提供商负责。 云提供商管理所有基础设施。部署时间只需几毫秒,而不是几秒钟。 就上市速度而言,由于开发速度快,企业可以专注于核心业务,而不必担心基础设施。 现在,让我们来看看容器的优点和无服务器的缺点: 有了容器,在本地环境或云中进行测试就更容易了。 移植容器更容易,因为它们不受操作系统、语言和提供商的影响。 容器的延迟非常低,因此即使是时间紧迫的工作负载也适用。 容器也非常适合长期运行的应用程序,因为完成批处理作业没有时间限制。 使用容器,你可以同时配置应用程序和资源。 在语言支持方面,容器化支持任何语言。 让我们比较一下无服务器计算和传统计算,从无服务器计算的优点和传统计算的缺点开始: 无服务器架构是一种云计算模式,开发人员专注于编写高质量代码。 无服务器计算更具成本效益,因为你只需为使用的内容付费。 可扩展性完全由云提供商负责。 库和集成可在应用程序中使用。 最后,让我们来看看传统计算的优点和无服务器计算的缺点: 在传统计算中,数据由您控制。 在网络方面,您可以通过常规的互联网协议(或 IP)地址访问代码,而无需设置专用的应用程序编程接口(或 API)。 只需在组织的网络边界内实施安全防护。 由于整个设置都由您来管理,因此很少出现供应商锁定的情况。 3.1.4. 无服务器框架 无服务器框架(Serverless Framework)是一个使用 Node.JS 编写的免费开源 Web 框架。它最初设计用于安全、快速地配置亚马逊网络服务或 AWS Lambda 函数、事件和基础设施资源。但它并不局限于 AWS。其他支持的提供商包括微软 Azure、谷歌云平台和 Apache OpenWhisk。 无服务器框架是一个命令行界面或 CLI,提供开箱即用的结构、自动化和最佳实践,让您可以专注于构建复杂的事件驱动型无服务器架构,包括函数、事件、资源和服务。 功能只是部署在云中的代码,通常是为执行单个任务而编写的。每个功能都是一个独立的执行和部署单元,就像一个微服务。 任务可以是将用户保存到数据库,也可以是在指定时间执行任务。 功能由事件触发,而事件则来自其他资源,例如:API 网关、URL 上的 HTTP 请求或 S3 存储桶中上传的新文件。 资源是功能使用的基础设施组件,例如云提供商作为服务提供给您的数据库,或用于存储文件的 S3 存储桶。 服务是框架的组织单位。您可以将其视为一个项目文件,尽管您可以为一个应用程序提供多个服务。服务通过 serverless.yml 文件进行配置,您可以在其中定义要部署的功能、事件和资源。 使用框架 CLL 部署时,配置文件中的所有内容都会一次性部署。serverless.yml 文件控制着服务中的所有内容。该文件有专门的章节用于指定函数、事件和资源。 service: productsfunctions: productsCreate: events: - httpApi: 'POST /products/create' productsDelete: events: - httpApi: 'DELETE /products/delete'resources: 让我们在 AWS 上使用无服务器框架构建并部署一个 Hello World 应用程序。 首先,您需要 serverless CLI,可以使用 npm 全局安装: npm install -g serverless 运行 serverless 命令: serverless 使用 Python 创建第一个 AWS HTTP API。该命令将带您完成一个向导,部署完成后,将为您提供一个 URL。如果在浏览器中打开,就会看到如图所示的结果。 接着更改函数代码,以在请求时返回 Hello World: def hello(event, context): body = 'Hello World!' response = {'statusCode': 200, 'body': body} return response 然后就只需重新部署并再次测试即可。 3.1.5. 无服务器参考架构和用例 让我们来看一个实现简单 TODO 应用程序的 Web 应用程序,其中注册用户可以创建、读取、更新和删除项目。 Web 应用程序参考架构是一个通用的、事件驱动的后端,使用 AWS Lambda 和 Amazon API Gateway 实现其业务逻辑。它还使用 Amazon DynamoDB 作为数据库,并使用 Amazon Cognito 进行用户管理。应用程序中的所有静态内容都使用 AWS Amplify Console 托管。 基本上,Web 应用程序包括 3 个不同的组件。前端应用程序包含使用创建 React 应用程序生成的所有静态内容,而创建 React 应用程序则借助 HTML 文件、用于样式设计的 CSS、在客户端运行的 JavaScript 以及图像来工作。所有这些对象都托管在 AWS Amplify Console 上。当用户连接到网站时,所需的资源就会下载到他们的浏览器并开始运行。当应用程序需要与后端通信时,就会向后端发出 REST API 调用。 后端应用程序是实现实际业务逻辑的地方。逻辑托管在 Lambda 函数中,前端通过 API Gateway 使用 REST API 进行访问。数据随后存储在 DynamoDB 中。TODO 应用程序将用户限制在自己的待办事项中。因此,用户必须经过注册和身份验证才能访问自己的待办事项。为此,您可以使用 Cognito 用户池,让用户注册应用程序并进行身份验证。 无服务器应用程序的一个常见用例是事件流。这些应用无需设置前期基础设施即可编写和部署。它们可以从发布者、订阅者主题或事件日志中触发,为您提供弹性、可扩展的事件管道,而无需维护复杂的集群。这些事件流管道可为分析系统提供动力,更新二级数据存储和缓存,或为监控系统提供信息。后处理的例子包括图像和视频处理,您可以针对不同的目标设备动态调整图像大小或更改视频转码。后期处理还可用于图像识别或人工智能目的,以检测护照照片上是否有阴影。 在构建应用程序时,您需要决定在应用程序中使用哪种语言。无服务器应用程序可以使用多种语言,从而防止团队被语言锁定,即不得不无限期地使用与传统软件相同的语言。通常,选择哪种语言来构建应用程序并不是最适合项目的,而是取决于可用资源。 3.1.6. 流行的无服务器平台 亚马逊网络服务(AWS)Lambda 是亚马逊提供的一种无服务器、事件驱动的计算服务。该服务可让您为任何应用程序或后端服务运行代码,而无需配置或管理服务器。它可以响应任何规模的执行请求,从每天十几个事件到每秒数十万个自动执行请求。 AWS 平台提供两百多种服务。您只需支付运行功能的费用以及在 Lambda 和其他 AWS 服务之间传输数据的费用。此外,它还可用于各种用例,例如文件处理、网络应用、物联网(IoT)和移动后端。其次,谷歌云功能具有简单直观的开发人员体验。只需编写代码,然后让谷歌云处理运行基础架构。它可以根据流量自动将所有基础架构管理抽象为零,并即时进行扩展和缩减。数据更新时,Google Firebase 会立即提醒开发人员。轻量级提取、转换和加载(ETL)功能等异步工作负载不需要单独的服务器。您可以直接部署与所选事件相关联的函数,然后执行该函数。 Azure 是一种无服务器解决方案,可让您编写更少的代码、维护更少的基础设施并节省成本。使用 C#、Java、JavaScript、PowerShell 或 Python 编写函数,或使用自定义处理程序来使用几乎任何其他语言。Microsoft Azure 具有出色的开发人员操作或 DevOps 功能,可为持续集成和交付提供简便的设置。这一功能也适用于 Azure 函数。 使用 Consumption 计划,您只需在函数运行时支付费用,而高级计划和应用程序服务计划则提供满足特殊需求的功能。例如,Azure Functions 有许多不同的用例:当文件上传或 blob 存储发生变化时运行代码,收集和处理来自物联网设备的数据,或在预先定义的定时间隔执行代码。 IBM 云函数允许您从事件或直接通过 REST API 触发操作,从而促进与其他服务的轻松集成。使用 IBM Cloud Functions,您只需为您使用的时间支付费用,甚至低至十分之一秒。 IBM 云功能的一部分是 IBM Watson 强大的认知服务。例如,检测图像或视频中出现的物体或人物。云功能可在多个 IBM 云区域内使用。它支持高可用性,以避免单点故障。客户数据(如操作代码)会在不同区域自动同步。在生产中调试无服务器应用程序可能很困难,因为无法访问运行代码的运行时环境和基础架构。为了访问环境,IBM 提供了 LogDNA 的日志分析和 IBM Cloud Monitoring 指标。 大多数平台都有特定的供应商。您还可以选择开源开发。Knative 就是这样一个选择。Knative 基于通过 Kubernetes 运行的容器,Kubernetes 是容器的协调框架。与其他无服务器平台相比,Knative 的优势在于它提供了一个基于 Kubernetes 开发的平台,从而避免了供应商锁定。您可以在任何支持 Kubernetes 的云中部署该平台,而无需做任何更改。这也使得它与平台无关。此外,它还允许您根据自己的需求制定推广策略,通过将流量逐步转移到无服务器组件的最新版本来实现。 3.2. FaaS 功能即服务或 FaaS 是一种云计算服务,可让您根据事件执行代码,而无需通常与构建和启动微服务应用程序相关的复杂基础设施。FaaS 具有以下特点: 它是无服务器计算的一个子集。 它以多个函数的形式创建应用程序,其中一个函数是用任何编程语言编写的软件。 FaaS 既可以部署在混合云上,也可以部署在企业内部环境中。 它是无状态的,但可以使用外部缓存来维护状态。 函数以毫秒为单位执行,并行处理单个请求,因此具有瞬时可扩展性。 FaaS 按运行功能所需的时间计费,而不是按服务器实例大小计费。 使用 FaaS,您可以将服务器划分为可自动独立扩展的功能,这样您就不必管理基础设施,也就可以去专注于应用程序代码,从而缩短产品上市时间。 FaaS 模式的最大优势之一是成本。您只需在发生操作时支付费用。当操作完成时,一切都会停止 -- 没有代码运行,服务器不会闲置,也不会产生任何费用。 由于函数是无状态的、独立的小代码块,因此可以根据需要自动、独立、即时地扩展。如果需求下降,它们会自动缩减。 FaaS 本身具有高可用性,因为它分布在不同地区和可用区,部署时无需增加成本。 无服务器堆栈由三个主要部分组成,即功能即服务(FaaS)、后台即服务(BaaS)和 API 网关。让我们来看看无服务器堆栈中的组件是如何工作的: 事件请求来自不同的渠道,如 HTTP 请求、来自 GitHub 和 Docker Hub 等资源库的网络钩子以及计划作业。 这些请求会经过 API 网关,由该网关识别并转发给相应的功能。 然后,这些功能会处理这些请求,并进一步(如有必要)将它们导向后端服务(如文件和对象存储、块存储、通知服务等),以便进一步处理和 / 或存储。 然后通过 FaaS 组件和 API 网关将输出或响应发送回客户端。 举个现实世界中的例子,您需要将个人资料图片上传到网站。网站可能还需要该图片的缩略图,以便在某些网页上显示。这是使用功能即服务的常见情况。用户选择一张个人照片。图片被上传到对象存储桶。该事件触发一个 IBM 云函数,该函数处理个人资料照片并创建缩略图。然后,该函数将缩略图存储在对象存储中,以便网站在需要时可以访问缩略图。 FaaS 函数应设计为响应事件时执行单个工作。因此,要限制代码范围、提高效率并减轻重量,以便函数能快速加载和执行。 FaaS 的价值在于功能的隔离。过多的函数会增加成本,并降低函数隔离的价值。使用过多的第三方库会减慢函数的初始化速度,使其难以扩展。 一些常见的托管 FaaS 提供商包括: 亚马逊的 AWS Lambda 谷歌云功能 微软的 Azure 功能 IBM 的云功能 Red Hat 的 OpenShift 云功能 Netlify、Oracle 和 Twilio 等其他一些提供商 如果不想依赖第三方管理平台,也有许多自我管理的 FaaS 可供选择。其中包括: Fission 是 Kubernetes 上的无服务器功能框架 Fn Project 是一个容器原生无服务器平台 Knative 是一个基于 Kubernetes 的平台,用于构建、部署和管理无服务器工作负载 OpenFaaS 允许您将任何 Linux 或 Windows 进程转化为函数 4. 创建和部署微服务 在本地成功构建和测试微服务后,您需要部署微服务。如果选择自托管微服务,则需要进行详细调整,并认真应对许多非同小可的挑战。 首先,你需要特意配置和构建你的微服务,使它们为生产做好准备,包括必要的资产,如库依赖关系、资源、凭据等。然后将它们编译并构建成一个可执行的二进制文件,以便在托管环境中运行。接下来,您需要仔细选择运行微服务的基础设施,如网络服务器、操作系统、网络、数据库等等。您的团队需要从众多选项中谨慎选择。由于微服务的流量会发生波动,因此需要动态地扩大或缩小规模。例如,电子商务网站在节假日总是会出现流量高峰,而几天或一周后流量就会大幅下降。在大多数情况下,您需要部署多个相关的微服务来工作和通信。微服务之间的通信需要可靠和安全。最后,还需要进行日志记录、监控和仪表板工作等活动,以确保所有微服务的稳定性,并可识别甚至预见任何生产问题。自助托管还可能面临其他挑战,这些挑战取决于微服务的具体实施和构建。 接下来,我们将举例说明如何部署基于 Python 的微服务。假设您构建了一个基于 Python 的微服务,它可能是一个 Flask、Django 或任何其他 Python 网络应用程序。微服务无法直接开始提供所需的服务。它需要一个网络服务器接口或入口点来调用你的微服务。对于基于 Python 的微服务,主要有两种可用的接口:网络服务器网关接口(或 WSGI)是网络服务器与网络应用程序或微服务之间通信的主要 Python 标准。顾名思义,它只支持同步服务调用。 目前有许多流行的 WSGI 网络服务器,如 Gunicorn 和 uWSGI。你需要决定哪一种最适合你的需求。 异步服务器网关接口(ASGI)是另一种网络服务器接口。它与 WSGI 的主要区别在于它支持异步代码,因此你的微服务可以被异步调用。一些流行的 ASGI 网络服务器包括 Daphne 和 Hypercorn。 无论是 WSGI 还是 ASGI 网络服务器,都需要在特定类型的基础设施上运行。根据您的服务要求或协议,基础设施可以是一台笔记本电脑、一台专用工作站,也可以是一个拥有数百个计算和数据节点的复杂集群。 4.1. IBM 云代码引擎 可以看出,要在生产中部署微服务,您需要做出许多权衡和努力。值得庆幸的是,您现在可以在云平台上部署微服务,只需付出极少的努力。现在,让我们来介绍一下 IBM 云代码引擎(IBM Cloud Code Engine),这是一个可轻松部署微服务的全面而强大的平台。IBM 云代码引擎,简称「代码引擎」,抽象了构建、部署和管理工作负载的操作负担,使开发人员可以专注于代码开发。IBM Cloud Code Engine 的主要目标是减轻开发人员的部署负担。在它的帮助下,开发人员可以将代码推送到云平台,而无需考虑基础架构。 IBM 云代码引擎可以看作是一个完全托管的无服务器平台。它结合了平台即服务(PaaS)、容器即服务(CaaS)和无服务器部署模型所需的所有功能。IBM 云代码引擎可运行您的工作负载,包括微服务、Web 应用程序、事件驱动函数或批处理作业。如果您已构建了微服务,您可以将其作为 IBM Cloud Code Engine 应用程序运行,以直观的用户体验为传入的 HTTPS 请求提供服务,而无需轻松管理您的基础架构。 IBM 云代码引擎有三种主要用例或部署模式:第一种用例涉及将构建的应用程序部署到代码引擎。这里的应用程序可以是微服务、Web 应用程序或控制台应用程序。第二种用例是直接推送源代码。代码引擎可以从 GitHub repo 等远程仓库或本地工作区的源代码构建应用程序。构建好的应用程序可以自动部署,无需担心构建过程,既方便又省时。第三种用例是创建和运行批处理作业,如数据处理或分析任务。例如,如果您的一个微服务需要分析结果,您可以部署一个批处理作业,在同一平台上执行分析任务。因此,您部署的所有微服务和作业都可以无缝地协同工作,因为它们都托管在同一个基础架构中。 4.1.1. 术语 代码引擎中的「项目」一词代表一个包含并管理其资源和实体的组。代码引擎中的分组包含的实体有构建、应用程序、作业、传输层服务(或 TLS)HTTP 连接证书等。 项目的一个重要功能是为其实体提供命名空间。命名空间提供了一种在单个组内隔离实体组和资源的机制。在命名空间内,实体名称必须是唯一的,但在不同命名空间之间则不需要。另一个重要功能是管理资源和提供访问控制。 在代码引擎中,您的代码将运行应用程序。与常规部署的网络应用程序一样,运行中的应用程序可以提供 HTTP 请求或 REST API。除了传统的 HTTP 请求,代码引擎还支持使用 WebSockets 的应用程序。WebSocket 是一种基于传输控制协议(或 TCP)的通信协议。它主要用于客户端和服务器之间的长期运行和基于会话的通信,如聊天应用程序。代码引擎可通过创建或销毁应用程序运行实例来扩展应用程序。应用程序运行实例的数量会根据接收到的工作负载和您的配置设置自动增加或减少(至零)。 在代码引擎上下文中,构建或镜像构建(image build)是一种可用于从源代码创建容器镜像的机制。容器镜像包括容器运行所需的所有资产,如可执行源代码、依赖项、资源、容器引擎、系统库、配置设置等。 代码引擎支持从 Dockerfile 构建,Dockerfile 是一个文本文件,其中包含构建 Docker 容器镜像的所有命令。此外,它还可以使用云原生构建包(Cloud Native Buildpack)。构建包是另一种常用的容器镜像构建方式。它包含用于执行检查源代码、创建构建计划或执行构建计划以生成镜像等任务的可执行文件。从源代码构建容器镜像后,您可以将构建的容器镜像部署到代码引擎,并创建相应的应用程序。 作业是代码的一次性执行。与应用程序一样,作业也会运行可执行代码。根据工作量的不同,代码引擎会创建一个或多个作业实例。与主要处理 HTTP 请求或 WebSocket 会话的应用程序不同,作业旨在一次性运行并退出。在代码引擎中运行作业前,您可以指定作业每次运行时使用的工作负载配置。一些典型的作业包括批量查询和转换数据的数据处理作业、机器学习模型训练作业、根据预设计划生成报告的报告作业,以及创建和发送账单的计费作业。 4.1.2. 为微服务构建容器镜像 容器是一个独立的、包含所有内容的可执行软件单元。应用程序的源代码与其库、依赖项和运行时一起打包在容器中。容器构建完成后,可在任何设备上运行,如笔记本电脑、台式电脑或内部服务器。它还可以在云中运行。容器体积小、速度快、便于携带,因为与虚拟机不同,容器的每个实例都不需要客户操作系统,而是可以利用主机操作系统的功能和资源。微服务的关键要素之一是,就每个运行实例的计算资源而言,它通常规模较小且相互隔离。这使得容器与微服务架构中的这种小型轻量级服务完美匹配。 Docker 是一个软件平台,用于构建和运行作为容器的应用程序。此外,大量的互补工具、技术和开发方法仍在不断增长,并形成了庞大的 Docker 和容器化经济。Docker 提供了一种通过开源平台构建和运行容器的简单方法。Docker 是 IBM 云引擎中使用的主要容器技术。容器镜像是云引擎应用程序的基础。 现在让我们讨论使用 Docker 文件从源代码构建容器的典型方法。假设您已经完成了应用程序开发,并在集成开发环境中使用源代码、依赖项和库对其进行了本地测试。现在,你想构建一个 docker 容器。第一步就是创建一个 Dockerfile。Dockerfile 就像一个蓝图,通过它可以构建一个镜像。它概述了构建所需容器的所有说明。容器镜像是在创建 Dockerfile 之后构建的。 请注意,容器镜像和容器是两种不同的东西。镜像是一个不可更改的文件,其中包含应用程序运行所需的所有应用程序资产,如源代码、库和依赖项。镜像是只读的;如果更改镜像,就需要创建一个新的镜像。与面向对象编程一样,容器镜像就像一个类,它就像一个正在运行的容器的模板。因此,如果我们实例化一个容器镜像,就会得到一个名为容器的运行镜像。 因此,基本上你只需编写一个合适的 Dockerfile,剩下的就交给 Docker 来处理吧。 接下来,让我们看看构建基于 Flask 的微服务的 Dockerfile 示例。 FROM python:3.8 Dockerfile 的第一行以 FROM 指令开始,用于指定后续指令将构建的基础镜像。该基础镜像通常来自公共资源库,比如操作系统,或者来自特定语言的基础镜像,比如 Python。你也可以选择添加一个更具体、更高级的基础镜像,以更好地服务于你的微服务,而不是一般的 Python docker 镜像。 # Or import a 3rd party image such as tiangolo/uwsgi-nginx-flask:python3.8 你可以添加更具体、更先进的基础镜像,以便更好地为你的微服务服务。例如,uwsgi-nginx-flask 可让你创建 Python 语言的 Flask 微服务,并在单个容器中与 uWSGl 和 Nginx 一起运行。 COPY . /app 下一条 COPY 指令会将所有文件复制到镜像中的 /app 文件夹。 ENV LISTEN_PORT 8080 你可以使用 ENV 指令设置环境变量。在这里,你将 LISTEN PORT 设置为 8080,这意味着用该镜像制作的容器将监听 8080 端口。 EXPOSE 8080 Dockerfile 中的 EXPOSE 关键字向 Docker 表明,容器将监听 8080 端口的流量。 RUN pip install -r requirements.txt RUN 指令执行命令。 CMD ["python", "-m", "flask", "run"] 最后一个是 CMD。该指令的主要目的是提供执行容器的默认值。该指令通常定义了应在容器中运行的可执行文件。在示例中,你运行的是 flask 微服务。但请注意,一个 Dockerfile 中只能有一条 CMD 指令。 容器镜像构建完成后,可以将其推送到容器存储库,以便更好地管理。这个容器存储库称为容器注册中心。常见的注册表有 Docker Hub 和 IBM Cloud Container Registry。注册表中的容器镜像可以通过唯一的镜像名称轻松提取和使用。 hostname:repository:tage.g., icr.io/codeengine/hello 镜像名称由主机名、存储库和标记组成。主机名标识了该镜像应推送到的注册中心。例如,icr.io 表示 IBM 容器注册中心。存储库是一组相关的容器镜像。通常,这些镜像是同一应用程序或服务的不同版本,因此应用程序或服务的名称就是一个好的资源库名称。最后,标签提供了有关镜像的特定版本或变体的信息。 4.1.3. 部署和运行应用程序 您可以使用两种模式之一将基于容器映像的应用程序部署到 IBM Cloud Code: 首先,您可以构建容器映像并将其推送到私有或公共容器注册中心。云代码引擎可以使用唯一的映像名称提取映像,然后通过授予注册表访问权限自动进行部署。 或者,如果您不想手动构建映像,您可以指定一个 dockerfile 或一个 buildpack 以及您的代码,然后向云代码引擎发出指令,让它从源代码中构建您的应用程序,然后进行部署。因此,这两种模式本质上是一样的。 唯一的区别是谁负责构建和推送容器映像。IBM 云控制台是一个精心设计的网络门户,供最终用户方便地管理他们的 IBM 云服务,包括代码引擎。在 IBM 云控制台中,只需点击几下,您就可以轻松创建和部署应用程序。如果您熟悉命令行界面(CLI),并希望执行更精确的部署,可以选择 IBM Cloud CLI 来创建和部署应用程序。 您的应用程序部署到代码引擎后,引擎将提供应用程序的端点 URL,该 URL 可指向应用程序的主页面或微服务的入口端点。如上所述,IBM 云控制台提供了一个精心设计的用户界面,可帮助您轻松创建和部署应用程序。 现在让我们看看创建应用程序的主要步骤:首先,您需要指定应用程序的名称。然后,您可以选择从容器映像或源代码进行部署。这里您可以选择默认的容器映像选项。接下来,您需要提供图像的引用,还可选择提供注册表访问权限,以便代码引擎从注册表中提取图像。但请注意,这些只是主要步骤。您还可以根据应用程序的需要配置其他高级设置。创建应用后,云引擎将自动部署应用 如果应用运行无误,应在几分钟内准备就绪。应用程序就绪后,您可以单击「Test application」,使用 URL 进行测试。 如果您喜欢命令行界面,也可以使用 IBM Cloud CLI 部署应用程序。创建和部署应用程序的主要命令是 ibmcloud ce app create:它有三个主要参数:应用程序名称 --name、容器注册表中的映像引用 --image 以及注册表访问(如果容器注册表不是公开的)--registry-secret。 我们来看一个示例: ibmcloud ce app create --name helloworldapp --image us.icr.io/mynamespace/hello_repo --registry-secret myregistry 在这里,您想通过 us.icr.io 注册服务器中的映像创建一个名为 helloworldapp 的应用程序。为了让代码引擎提取映像,您创建并提供了名为 myregistry 的注册表访问权限。 创建并部署应用程序后,可以使用 ibmcloud ce app get 命令运行并测试应用程序,该命令有两个主要参数:应用程序名称 --name 和应用程序的输出格式(如 URL)--output。下面,我们以获取先前部署的应用程序 helloworldapp 的输出为例进行说明。 ibmcloud ce app get --name helloworldapp --output url 使用应用程序名称调用 ibmcloud ce app get,并指定输出格式为 URL。然后,您就可以从命令输出中看到应用程序的公开访问 URL。 4.1.4. 更新已部署的应用程序 假设您为宠物业务开发了基于微服务的 PetShop 网络应用程序,并将每个微服务作为应用程序部署在代码引擎项目中。您的 PetShop 应用程序的网络流量很大,因此您决定将部分数据从 SQL DB 迁移到非 SQL DB。因此,您需要为新数据库服务开发和构建另一个容器映像,并根据新映像更新您的宠物数据库服务。此外,您还需要为应用程序添加一些新的环境变量,并需要更多计算资源来处理对 NoSQL DB 的请求。 代码引擎会管理已部署应用程序的每个修订版,因此您无需删除应用程序并部署新的应用程序。您只需更新现有应用程序,代码引擎就会为您创建和管理新版本。更新代码引擎应用程序有以下四种常见情况: 更新环境变量,如数据库位置或秘钥 更新应用程序可见性,如将应用程序的 URL 从公开更改为私有或项目专用 更新应用程序的图片参考或 GitHub repo 更新应用程序的运行时资源 与应用程序部署一样,您也可以使用 IBM 云代码引擎控制台或 CLI 更新应用程序。如果是添加环境变量之类的简单更新,使用精心设计的控制台 UI 会更方便快捷。对于更复杂、更精确的应用程序更新,代码引擎 CLI 可能更合适。执行所有应用程序更新相关操作的主要命令是 ibmcloud ce application update。 在应用程序控制台页面,点击环境变量表,可以找到所有环境变量。要添加或更新环境变量,您可以单击「Add environment variable(添加环境变量)」按钮。另外,如果您喜欢使用命令行界面(IBM Cloud CLI),则添加或更新环境变量的主要命令是 ibmcloud ce app update,其中包含两个主要参数:应用程序名称 --name 以及环境变量的名称和值 --env。 让我们来看一个快速示例: ibmcloud ce app update --name pet_db_service --env DB_HOST=localhost 首先,在应用程序中添加一个名为 DB_HOST 的环境变量,其值为 localhost。update 命令会返回已更新到最新版本的信息。 ibmcloud ce app get --name pet_db_service 要仔细检查环境变量是否添加成功,可以使用 app get 命令显示 pet_db_service 应用程序的详细信息。现在可以看到环境变量 DB_HOST 已按预期添加。 部署应用程序时,会分配两种类型的 URL:内部 URL 和外部 URL。内部 URL 用于与应用程序内的其他应用程序通信。外部 URL 可以是公共 URL、外部 URL 或仅限 IBM 专用网络的 URL。选择 URL 类型可定义应用程序的可见性。 在应用程序控制台页面,单击系统域映射选项卡更新应用程序的可见性。选择「No external system domain mapping(无外部系统域映射)」时,此应用程序无法从公共互联网访问,只能从本项目内的组件(群集 - 本地)进行网络访问。选择此选项时,将显示群集本地 URL。选择私有时,可通过 IBM 云虚拟私有端点访问此应用程序。选择公开时,您可以查看应用程序的公开和群集本地 URL。 同样,您也可以通过 CLI 更新应用程序的可见性:主要命令仍然是 ibmcloud ce app update,有两个主要参数:应用程序名称 --name 和应用程序的可见性 --visibility。让我们来看一个快速示例: ibmcloud ce app update --name pet_db_service --visibility private 首先将 pet_db_service 应用程序的可见性更新为 private。然后,如果您获取应用程序的详细信息,就会看到两个 URL。外部 URL 现在包含一个私有子域,这意味着现在只能通过 IBM 虚拟专用网络访问 pet_db_service。 现在,您可以通过控制台用户界面更新图像引用,方法如下: 在应用程序控制台页面上,单击「Code」选项卡。 然后为应用程序指定新的映像引用。 在 CLI 上,ibmcloud ce app update 命令有三个参数用于更新映像引用:应用程序名称 --name、映像引用的名称和值 --image,以及用于访问非公开容器注册表的注册表秘密 --secret。 例子: ibmcloud ce app update --name pet_db_service --image us.icr.io/petshop/no_sql_pet_db_service --registry-secret myregistry 如果应用程序实例的响应时间过长或 CPU 和内存使用率过高,可以增加应用程序的运行时资源。为此,请进入应用程序页面并单击运行时选项卡。然后就可以根据需要更新实例的 CPU、内存和短暂存储。如果您想让应用程序扩大或缩小规模,也可以在同一用户界面上更新扩展和并发设置。 要通过 CLI 更新 CPU 或 GPU,需要使用相同的 ibmcloud ce app update 命令,其中包含三个主要参数:应用程序名称 --name、为实例设置的 CPU 数量 --cpu 和为实例设置的内存数量 --memory。 让我们看看示例: ibmcloud ce app update --name pet_db_service --cpu 2 --memory 16GB 您将 pet_db_service 运行时资源的每个实例增加到 2 个 CPU 和 16GB 内存。

2024/6/11
articleCard.readMore

IBM 全栈开发【9】:使用 SQL 和数据库开发 Django 应用程序

近期在学习 IBM 全栈应用开发微学士课程,故此记录学习笔记。 1. 数据库入门 1.1. SQL SQL 全称是 Structured Query Language,是为管理关系数据库中的数据而设计的,对于处理结构化数据非常有用。 数据是由文字、数字和图片组成的事实集合。而数据库是一个数据存储库,提供添加、修改和查询数据的功能。 关系数据库将表格数据存储为相关项目的集合,列中包含项目属性。非关系型数据库提供了一种灵活、可扩展的数据存储和检索方法。 关系数据库是优化存储、检索和处理大量数据的理想选择。 实体 - 关系模型(Entity-Relationship model)是设计关系数据库的工具。实体变为表、属性变为列。 基本 SQL 语句包括 CREATE TABLE、INSERT、SELECT、UPDATE 和 DELETE。 IBM DB2、SQL Server、MySQL、Oracle Database 和 PostgreSQL 都是有名的关系数据库。 部分有名的云关系数据库包括 RDS、Google Cloud SQL、IBM DB 2 on Cloud、Oracle Cloud 和 SQL Azure。 1.1.1. 用例 OLTP(Online Transaction Processing,在线事务处理)应用程序侧重于高速运行的事务型任务。关系数据库非常适合 OLTP 应用程序,因为它们可以容纳大量用户、支持插入更新或删除少量数据,还支持频繁查询和更新以及快速响应时间。 在数据仓库环境中,关系数据库可针对在线分析处理(或 OLAP)进行优化。在 OLAP 中,历史数据可用于商业智能分析。 物联网解决方案要求速度以及从边缘设备收集和处理数据的能力,这就需要一个轻量级的数据库解决方案。 1.2. RDBMS DBRM 全称为 Database Management System,也就是数据库管理系统,是一组创建和维护数据库的程序。它允许存储、修改和查询数据库中的信息。 RDBMS 全称是 Rational Database Management System,也就是关系数据库管理系统。这是一种成熟的、文档齐全的技术,具有灵活性、减少冗余、易于备份和灾难恢复,并符合 ACID 标准。 ACID 标准是指数据库管理系统在写入或更新资料的过程中,为保证交易是正确可靠的,所必须具备的四个特性: 原子性,或称不可分割性(Atomicity) 一致性(Consistency) 隔离性(Isolation) 持久性(Durability) 1.2.1. 限制 RDBMS 不能很好地处理半结构化或非结构化数据,因此不适合对此类数据进行广泛分析。 要在两个 RDBMS 之间进行迁移,源表和目标表之间的模式和数据类型必须相同。 关系数据库对数据字段的长度有限制,这意味着如果尝试输入的信息超过字段所能容纳的长度,信息将不会被存储。 尽管数据有其局限性和演变性,但在大数据云计算、物联网设备和社交媒体时代,RDBMS 仍然是处理结构化数据的主要技术。 1.3. NoSQL NoSQL 在过去的文章中有提到过,全称为 Not Only SQL(不只是 SQL),有时也指非 SQL,是一种非关系型数据库设计,为数据的存储和检索提供灵活的模式。 NoSQL 数据库有四种常见类型: 键值存储 基于文档 基于列 基于图形 1.3.1. 键值存储 键值数据库中的数据以键值对集合的形式存储。键代表数据的一个属性,是唯一的标识符。键和值可以是简单的整数或字符串,也可以是复杂的 JSON 文档。 键值存储非常适用于存储用户会话数据和用户偏好、实时推荐和定向广告以及内存数据缓存。 但是,如果想查询特定数据值的数据、需要数据值之间的关系,或者需要多个唯一键,键值存储可能不是最合适的选择。 Redis、Memcached 和 DynamoDB 就是这类数据库中的一些著名例子。 1.3.2. 基于文档 基于文档的文档数据库将每条记录及其相关数据存储在单个文档中。它们可以对文档集合进行灵活的索引、强大的临时查询和分析。文档数据库适用于电子商务平台、医疗记录、存储、客户关系管理平台和分析平台。 但是如果希望运行复杂的搜索查询和多操作事务,那么基于文档的数据库可能就不是最佳选择。 MongoDB、DocumentDB、CouchDB 和 Cloudant 是一些流行的基于文档的数据库。 1.3.3. 基于列 基于列的模型将数据存储在以数据列而不是行分组的单元格中。 列的逻辑分组,即通常一起访问的列,称为列族。例如,客户的姓名和个人资料信息很可能会一起被访问,但他们的购买历史记录却不会一起被访问,因此客户姓名和个人资料信息数据可以归入一个列族。 由于列数据库将与列相对应的所有单元格存储为连续的磁盘条目,因此访问和搜索数据的速度非常快。对于需要大量写入请求、存储时间序列数据、天气数据和物联网数据的系统来说,列式数据库是个不错的选择。 但是,如果您需要使用复杂的查询或经常改变查询模式,这可能不是最佳选择。 最流行的列数据库是 Cassandra 和 HBase。 1.3.4. 基于图形 基于图形的数据库使用图形模型来表示和存储数据。 它们特别适用于可视化、分析和查找不同数据之间的联系。圆圈是节点,包含数据,箭头代表关系。图形数据库是处理关联数据(即包含大量相互关联关系的数据)的最佳选择。 图形数据库非常适合社交网络、实时产品推荐、网络图、欺诈检测和访问管理。 但如果您想处理大量事务,它可能不是最佳选择,因为图形数据库没有针对大量分析查询进行优化。 Ne04J 和 CosmosDB 是比较流行的图数据库。 1.4. 测验 您正在为一家网上商店建立一个网站。您需要存储产品信息、客户详细信息和订单历史记录。以下哪项最恰当地描述了数据库在这种情况下的作用? 在这种情况下,数据库是相关表格的集合。它提供了一种结构化的方式,将产品信息、客户详情和订单历史记录存储在不同的表格中,从而实现高效管理和数据检索。 以下哪项是 RDBMS 的最大优势? 符合 ACID 标准是 RDBMS 的重要优势之一。ACID 代表原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability),可确保数据事务得到可靠处理。 在图书馆实体 - 关系数据模型中,图书是 _____ 的一个示例,而图书的书名、版本和出版日期则是 _______ 的示例。 在实体 - 关系模型中,实体是一个名词(人、地点或事物)。属性是实体的属性或特征。 关于数据库,以下哪些说法是正确的? 数据库是逻辑上连贯的数据集合,具有一定的内在含义。 关系型数据库的主要优势是什么? 关系模型提供了逻辑和物理数据的独立性,允许在不影响底层数据库结构的情况下访问和修改数据。 2. ORM:弥合现实世界与关系模型之间的差距 2.1. Django Django 是一个可访问的高级开源 Python 网页框架。 Django 采用模型视图控制器 MVC 模式,可帮助开发人员快速高效地构建网络应用程序,从而实现快速开发和代码重用。用户几乎可以用 Django 构建任何网络应用程序,包括内容管理系统(CMS)、社交媒体平台、商业应用程序和新网站。 Django 提供了一系列开箱即用的特性和功能:Django 提供了一个对象关系映射或称 ORM 层,允许使用 Python 类定义数据模型。这使得使用数据库和执行操作变得更加容易。例如查询、插入、更新和删除记录。 Django 有一个内置模板引擎,使开发人员能够将应用程序的业务逻辑与表现逻辑分开。 Django 还提供根据应用程序中的模型自动生成的管理界面。它为管理网站内容提供了友好的用户界面,并可根据具体要求进行定制。Django 提供强大的安全功能,包括防止常见的网络漏洞,如跨站脚本(xss)、跨站请求伪造(CSRF)和 SQL 注入。 它还提供密码散列和用户会话管理机制。Django 内置身份验证和授权机制,允许管理用户账户,包括注册登录和密码管理。 它还提供细粒度的授权控制,以定义用户权限和访问限制。Django 可借助模块(也称为 Django 应用程序或包)进行扩展。这些模块是可重复使用的组件,可以将其集成到 Django 项目中,以添加特定的功能或特性。例如,Django 使用 gettext 模块支持语言本地化,Django 提供第三方软件包,如用于验证码集成的 Django Simple Captcha,它有助于防止表单中的自动僵尸提交,Django 的内置表单模块包括强大的表单。验证逻辑可确保表单数据符合标准。 Django 倡导的架构是每个网络服务器实例独立运行,称为无共享或无状态。该架构中的每个网络服务器实例都能自主处理请求和响应,而无需依赖共享资源或在请求之间维护任何服务器端状态。这使扩展变得容易,无状态使得开发人员可以添加更多应用程序实例,并在不丢失数据的情况下跨模型传输用户体验。 Django 支持各种测试,包括单元测试、集成测试和功能测试。Django 的测试框架包括一个测试运行器,允许开发人员快速运行测试并提供详细的测试报告。它提供各种测试工具、断言和固定装置,以方便编写和执行测试。 由于 Django 是基于 Python 构建的,因此与平台无关,这意味着它可以在许多平台上运行。这种平台独立性得益于 Python 的可移植性和在不同操作系统上的广泛可用性。这使得开发人员可以选择最适合其需求和基础设施的托管平台。Django 应用程序几乎可以在所有云提供商上运行。Django 的平台无关性使得可以根据具体要求和偏好将应用程序部署到各种云平台上。 一些著名的网页应用程序就是使用 Django 构建的。Instagram 是一个分享照片和视频的流行社交媒体平台,最初就是使用 Django 构建的。著名的音乐流媒体平台 Spotify 也使用了 Django 及其基础设施。著名的新闻出版物《华盛顿邮报》也采用了 Django 作为其内容管理系统或 CNS。 1.2. OOAD 面向对象的分析和设计(简称 OOAD)是一种分析和设计软件系统的方法,当系统将使用面向对象的编程语言来开发时,就需要使用这种方法。 在讨论 OOAD 之前,我们先来了解一下 Java 或 C++ 或 Python 等语言中的面向对象编程。OOAD 的核心是对象。对象包含数据,也有规定对象可以采取的行动的行为。 例如,我可以创建一个代表病人的对象。假设病人的名字是 Nia Patel、Nia 需要取消预约。不过,在创建 Nia 之前,我们必须先创建一个病人对象的通用版本。对象的通用版本称为类。接下来,让我们先讨论类,然后再详细讨论 Nia。 类是对象的蓝图或模板,从类中创建特定对象(也称为实例)。 考虑到 Nia 将是病人类的一个实例。类包含对象、通用属性、属性和方法,但只有在创建对象时,也就是代码中所谓的实例化时,这些通用属性才会被设置为特定值。病人类可能有一个名为 LastName 的变量,它是一个属性,但并不指定 LastName 是什么。Lastname 是一个占位符,直到创建了对象并分配了名称。一旦对象被实例化,就可以调用其方法使对象执行某些操作,如预约或取消预约。OOAD 可用于将系统分解为相互交互的对象。这样,多个开发人员就可以同时处理应用程序的不同方面。 1.2.1. ORM 对象关系映射(ORM)工具被广泛应用于现代软件开发中,为面向对象的编程语言和关系数据库之间架起了一座桥梁。ORM 工具提供了一种使用编程语言对象和概念与数据库交互的方便高效的方法,使开发人员无需编写复杂重复的 SQL 查询。 编程语言都有自己的 ORM 工具,可以简化数据库操作和开发流程。 Django 是一个 Python 网络框架,内置 ORM,提供与数据库交互的高级应用编程接口(API)。Django 提供模型定义、查询构建、数据库迁移和自动查询优化等功能。 SQLAlchemy 是一个流行的 Python 综合 ORM 库,提供了一个灵活而富有表现力的 API,用于与数据库交互。SQLAlchemy 提供高级和低级 ORM 方法,允许开发人员选择所需的抽象级别。 web2py 是一个用 Python 编写的开源全栈网络框架,旨在通过提供包括网络服务器、数据库抽象层和基于网络的开发环境在内的一体化解决方案来简化网络应用程序开发。 Hibernate 广泛用于 Java 应用程序,因为它提供了功能强大、特性丰富的 API,可将 Java 对象映射到关系数据库。Hibernate 支持各种数据库系统,并提供懒加载、缓存和事务管理等高级功能。 EclipseLink 是另一种流行的 Java 工具。它是一个开源框架,为 Java 与关系数据库的映射提供广泛支持。EclipseLink 支持 Java Persistence API(JPA)、缓存、高级查询功能以及与各种应用服务器的集成等功能。 Apache OpenJPA 是 JPA 规范的开源实现。它便于将 Java 类映射到关系数据库表,并提供透明的 Java 对象持久性。 Entity Framework 是微软用于。NET 应用程序的 ORM 框架。它提供了一个易于使用的 API,用于将。NET 对象映射到关系数据库。Entity Framework 支持不同的数据库提供商,提供查询功能,并包含代码优先和数据库优先的模型创建方法等功能。 Dapper 是。NET 的微型操作系统,注重性能和简单性。它为查询数据库提供了轻量级、高效的 API。Dapper 适用于需要直接控制 SQL 查询和执行的场景。 NHibernate 是。NET 平台上广泛使用的 ORM 工具。它的灵感来自 Java 的 Hibernate ORM 工具。NHibernate 在。NET 面向对象编程和关系数据库之间架起了一座桥梁,允许开发人员使用。NET 对象与数据库交互。 ActiveRecord 是 Ruby on Rails(一种流行的 Ruby 网络框架)中的默认 ORM。它提供了一种「约定重于配置」的方法,可轻松将 Ruby 对象映射到数据库表。ActiveRecord 提供了模型关联、查询生成和数据库迁移等功能。 Sequel 是一款灵活、功能丰富的 Ruby ORM 工具,它强调简单性和以 SQL 为中心的方法。Sequel 支持各种数据库系统,提供高级查询功能,并提供插件以实现更多功能。 DataMapper 注重简洁性、灵活性和易用性。它旨在为在 Ruby 应用程序中使用数据库提供简洁直观的 API。DataMapper 的功能包括灵活的映射、数据抽象、查询和存储库模式。 Propel 是一款适用于 PHP 的高性能 ORM 工具,专注于代码生成和简化。它采用代码优先的方法,由 PHP 类和对象定义数据库模式。Propel 可生成高效的 SQL 查询,并提供懒加载、急迫加载和缓存等功能。 CakePHP 包含一个内置的 ORM 组件,为数据库工作提供了一个强大而直观的 API,具有数据库抽象、查询构建和关联管理等功能。 Eloquent 是流行的 PHP 框架 Laravel 框架提供的默认 ORM。它为定义数据库模型和关系提供了简单明了、表现力丰富的语法。Eloquent 简化了数据库操作,包括查询、数据检索和数据库迁移,并支持不同的数据库引擎。 软件开发人员通常使用数据库作为应用程序的主要数据存储库,因此需要将 SQL 集成到应用程序代码中。SQL 语句必须在应用程序代码中组装,并使用数据库 API 在数据库系统中执行 —— 检索到的数据库行将作为 cursor(一种用于遍历数据库中行的特殊控制数据结构)返回给应用程序代码。 在 Python 中运行 SQL 的例子: connection = sqlite3.connect('course.db')cursor = connection.cursor()insert_statement = 'INSERT INTO data_learner (first_name, last_name, dob, occupation) VALUES ("John", "Doe", "1962-01-01", "Developer");'cursor.execute(insert_statement)cursor.execute('SELECT * FROM data_learner')learner = cursor.fetchone() 与使用表格、行和列来模拟实体的 SQL 不同,面向对象语言使用类和对象来模拟实体。在 OOP 中,Course 实体将被定义为一个类,该类有两个基本属性:name 和 description;一个引用属性,即 learner 列表。 数据操作方法也需要与类属性一起定义。这里我们定义了一个简单的方法:get Learners。Learner 实体也将被定义为一个具有四个属性的类:名、姓、出生日期和职业。我们还将定义一个简单的打印个人资料方法。 connection = sqlite3.connect('course.db')cursor = connection.cursor()cursor.execute('SELECT * FROM data_learner')learner_row = cursor.fetchone()learner = Learner()learner.first_name = learner_row['first_name']learner.last_name = learner_row['last_name']learner.dob = learner_row['dob']learner.occupation = learner_row['occupation']learner.print_profile() 我们已经看到,OOP 和 SQL 的数据建模方式是不同的。OOP 使用类、对象和属性对实体建模,而 SQL 则使用表、行和列对实体建模。最后,OOP 使用方法对数据进行 CRUD,而 SQL 则使用数据操作语言(如 SQL 语句插入、删除和更新)对数据进行 CRUD。 既然我们通常使用 OOP 构建现代应用程序,那么我们是否也可以使用 OOP 而不是 SQL 访问数据库呢?这样,我们就可以坚持使用一种编程范式进行开发。人们发明对象关系映射的主要原因是为了弥合 OOP 与 SQL 之间的差距,使使用 OOP 语言访问数据库成为可能。ORM 库或工具可以将关系数据库中存储的数据映射和传输为行到对象或对象到行。 假设我们有一个由开发人员使用 OOP 创建的 Learner 对象模型。ORM 可以帮助将 Learner 对象转移到 Learner 表中的 Learner 行中,并将其读回 Learner 对象。这就减少了开发人员的工作量,因为他们只需关注对象操作。下面是 ORM 如何以一行代码的形式传输三个表连接的 SQL 查询。 SQL 代码例子: connection = sqlite3.connect('sample.db')cursor = connection.cursor()select_statement = 'SELECT learner.name FROM learner INNER JOIN enrollment ON (learner_id = enrollment.learner_id) INNER JOIN course ON (course_id = enrollment.course_id) WHERE course.name = "Introduction to Python"'cursor.execute(select_statement)learners = cursor.fetchall() ORM 代码例子: Course.objects.get(name='Introduction to Python').learners.all() 在开发 OOP 应用程序时,只需定义类和创建对象,无需编写 SQL 即可使用数据库。此外还可以使用一个 ORM 接口来管理多个数据库系统,而不必担心 SQL 语法的差异。所有这些优势都将加快应用程序的交付速度。 但是 SQL 和 OOP 仍然是两种不同的语言,具有不同的建模概念,而且 ORM 可能无法将对象映射到数据库表中。此外,由于 ORM 将数据访问逻辑与应用程序代码结合在一起,因此任何数据库变更都需要同时更改应用程序逻辑和数据访问逻辑。由于 ORM 隐藏了实现细节,因此调试可能很困难。ORM 也有可能会降低应用程序的性能:ORM 增加了一个额外的翻译层,但不能保证翻译后的 SQL 语句得到优化。 1.2.2. Django ORM 在 Django ORM 中,每个 Django 模型都映射到一个数据库表。当您创建一个类对象时,它代表一个表行。每个字段代表一个表列。一旦定义了模型类,模式和表格就会自动生成。 class User(models.Model): first_name = models.CharField(max_length=30) 例如,我们可以定义一个 User 类,它是 Django 模型的子类。然后,Django 将通过生成 table create 语句并根据类字段创建列,在数据库中创建相应的 User 表。在我们的例子中,将根据 first_name 字段创建 first_name 列。模型中的每个字段都应定义为一个 Field 类。 上面的 Python 代码等同于: CREATE TABLE data_user( "id" serial NOT NULL PRIMARY KEY, "first_name" varchar(30) NOT NULL) 模型中的每个字段都应定义为 Field 类。Django 会将每个字段映射为列类型。在这里,first_name 被定义为字符字段,将被转换为 varchar,而 dob 是日期字段,将被转换为 date。对于每一列,我们通过在 Django Field 类中指定参数来定义其元数据,如类型和约束条件。例如,对于 first_name 字段,我们使用 max_length 参数指定 varchar 的长度。 class User(models.Model): first_name = models.CharField(max_length=30) last_name = models.CharField(max_length=30) dob = models.DateField() 等同于: CREATE TABLE data_user( "id" serial NOT NULL PRIMARY KEY, "first_name" varchar(30) NOT NULL, "last_name" varchar(30) NOT NULL, "dob" date NOT NULL) 接下来,我们为实体之间的关系建模。Django ORM 支持常见的关系,如一对一、多对一和多对多。 我们从一对一关系开始。假设我们有一个 Instructor 和 User ER 图。User 类拥有一些常用字段,如姓名或出生日期,而 Instructor 类拥有一些特殊字段,如是否全职或讲师拥有的学员总数。一个教员只能在一个 User 类中存储基本信息,一个用户只能有一个角色,如 Instructor 或 Learner。 Class: Instructoris_full_time: booleantotal_learners: int Class: Userfirst_name: stringlast_name: floatdob: date 将以上 ER 图转换为 Django 模型: class Instructor(models.Model): is_full_time = models.BooleanField() total_learners = models.IntegerField() user = models.OneToOneField(User) class User(models.Model): first_name = models.CharField(max_length=30) last_name = models.CharField(max_length=30) dob = models.CharField(max_length=30) 我们将使用 Project 和 Course ER 图来说明「多对一」关系。Project 类表示一个课程项目,包含项目名称和成绩等字段。Course 类代表在线课程实体。它有原子字段,如名称和描述。更重要的是,它有一个名为 projects 的集合字段,代表一组课程项目对象。因此,一门课程会有许多课程项目,而一个课程项目只属于一门课程。这是一种典型的多对一关系。要在 Django 中创建模型,我们需要添加一个额外的 Course 字段作为外键。请注意,这不是一对一字段,因为多个项目可能拥有相同的课程字段。 关于「多对多」关系,让我们来看看 Course 和 Learner ER 图。 Class: Coursename: stringdescription: stringlearners: set Class: Learneroccupation: enumsocial_link: URL 为了模拟这种关系,我们在其中一个模型中添加了一个 ManyToManyField。通常,ManyToManyField 应放在最常编辑的模型中。例如,Course 可能会随着学习者的添加或删除而每小时被编辑一次,但每个学习者可能每周或每月才注册一次新课程。有时,您可能需要有关模型之间关系的额外信息。 例如,您可能需要学习者和课程之间的注册信息,如注册日期。Django 允许您指定用于管理多对多关系的模型。在我们的例子中,中间模型是 Enrollment 表。它使用 through 参数与 ManyToManyField 关联。 class Course(models.Model): name = models.CharField(max_length=30) description = models.CharField(max_length=30) learners = models.ManyToManyField(Instructor, through='Enrollment') class Enrollment(models.Model): course = models.ForeignKey(...) learner = models.ForeignKey(...) date_enrolled = models.DateField() class Learner(models.Model): occupation = models.CharField(max_length=20) social_link = models.URLField() Django 中的模型继承就像 Python 中的继承一样。你需要确定父模型是否只是通过子模型才能看到的通用信息的持有者,或者父模型是否应该有自己的表。 1.2.3. CRUD CRUD 就是喜闻乐见的 增删查改。 Django 模型提供了全面的 CRUD API,无需编写 SQL 查询即可操作对象。让我们回顾一下我们将在示例中使用的在线课程模型。基本 User 模型包含有关 Instructor 和 Learner 模型的通用信息。Instructor 从 User 模型继承而来,拥有 is_full_time 或学习者总数等字段。Learner 也是继承的。它有 occupation 和 social_link 等字段。Course 模型与 Instructor 模型和 Learner 模型都有多对多关系。Course 模型还与 Project 模型有多对一关系。在 Django 模型中,创建一个对象并调用模型的保存方法将其作为记录插入数据库。 course_cloud_app = Course(name='Cloud Application Development with Database', description='Develop and deploy application on cloud')course_cloud_app.save() 如果创建的对象包含对另一个模型的引用,如外键或多对多字段,则使用相关模型引用来创建关系。 project_orm = Project(name='Object-relational mapping project', grade=0.2, course=course_cloud_app)project_orm.save() 我们创建了一个 Project 对象,并将其 Course 外键指向我们刚刚创建的 course_cloud_app。将对象及其关系插入数据库后,我们就可以进行查询了。首先,让我们看看如何读取模型的所有对象。这相当于在 SQL 中使用 SELECT * 从表中获取表的所有行。一般来说,要使用 Django Model API 读取对象,需要在模型类上使用 Manager 构建一个 QuerySet。 courses = Course.objects.all() filter 方法可以有许多查找参数,如大于、小于、包含或为空。这就像 SQL WHERE 子句中的条件用法。查询参数包含字段名称和查询表达式,对于等价检查,查询表达式可以为空,对于其他检查,查询表达式可以用下划线分隔。 part_time_instructors = Instructor.objects.filter(is_full_time=False) exclude 方法会返回一个与给定查找参数不匹配的新 QuerySet。由于 exclude 和 filter 方法都会返回一个 QuerySet,我们可以进一步追加 exclude 和 filter 方法,形成一个过滤链。这就像在 SQL 的 Where 子句中使用 and 添加多个条件一样。我们可以同时使用 exclude 和 filter 方法查找 Instructor 子集。 filtered_instructors = Instructor.objects.exclude(full_time=False).filter(total_learners__gt=30000).filter(first_name__startswith='J') filtered_instructors = Instructor.objects.filter(full_time=True, total_learners__gt=30000, first_name__startswith='J') 如果只有一个对象符合您的查询,您可以使用 Get 方法返回该对象。例如,如果我们知道只有一个教员的名字是 John,我们就可以使用带有 first_name 查找参数的 Get 方法来获取教员。 instructor_john = Instructor.objects.get(first_name='John') 更新对象的原始字段。对于我们的学习者对象,我们可以将 dob 字段更改为 1985 年 3 月 16 日,然后使用 save 方法将更改更新到数据库。Django 模型将创建并执行相应的 SQL 更新语句。我们还可以更新相关字段,如外键字段或多对多字段。 learner_john.dob = date(1985,3,16)learner_john.save() 我们还可以更新相关字段,如外键字段或多对多字段。例如,我们可以更新课程外键字段,使其指向不同的课程。我们还可以 Add 方法将另一位学员添加到课程中。 project_orm.course = course_pythonproject_orm.save() 要删除数据库中的记录,需要在模型对象或 QuerySet 上调用 Delete 方法。Django ORM 支持不同的 on delete 选项。 project_orm.delete()Course.objects.filter(name__contains='Python').delete() 3. 全栈 Django 开发 3.1. MVC MVC 设计模式将应用程序逻辑分为三个部分: 模型访问和操作数据。 视图以各种形式显示数据。 控制器在模型和视图之间进行协调。 Django 的 MVT 模式与 MVC 相似,只是没有控制器,Django 服务器自身会执行控制器的功能。 MVT:Model-View-Template。 3.2. Django 在 Django 中,视图(View)是一个 Python 函数。Django 视图接收网络请求,如 HTTP GET、POST、DELETE 或 UPDATE,并返回网络响应。网络响应可以是字符串、JSON / XML 文件、HTML 页面,也可以是表示客户端或服务器端错误的错误状态。 Django 使用包含静态 HTML 元素和特殊 Python 代码的模板来生成动态网页。可以在 Django 中创建模板来指定数据的展示方式。Django 模板将静态 HTML 元素与描述如何插入动态部分的 Django 模板标记和变量相结合。这些元素共同作用生成一个 HTML 页面,在用户的网络浏览器中呈现。 创建 Django 项目时,Django 会创建一些核心文件: manage.py 是用于与 Django 项目交互的命令行界面。 settings.py 包含 Django 项目的设置和配置。 urls.py 包含 Django 应用程序的 URL 和路由定义。 可以通过创建一个管理员用户开始构建 Django 管理站点。然后,就可以以超级用户身份登录,并将模型注册到管理员站点,以便对其进行管理。 4. 整合和部署 Django 应用程序 在 Django 中,类视图必须与 URL 模式进行映射,几乎就像为基于函数的视图配置 URL 一样。唯一的区别是你需要指定以下哪一项? as_view 函数。这是 Django 类视图的一个重要方法,它负责创建一个适合用作视图的实例,并调用其 dispatch 方法。这是在 URL 配置中使用类视图时需要指定的方法。 在通过登录 HTML 模板从用户处接收用户名和密码时,哪种 HTTP 方法将用户名或密码发送到在线课程登录视图以验证用户? POST 请求。 Django 提供了一个默认的静态文件应用,它将所有静态文件收集到一个目录中。当你将你的应用部署到生产 Web 服务器时,你可以将所有静态文件移动到这个目录。要做到这一点,有几个步骤。在最后一步,你需要调用什么? 运行 collectstatic 命令。这是 Django 的一个管理命令,用于收集所有应用的静态文件到一个指定的位置,以便在生产环境中使用。 ASGI 是 Django 应用支持的 Web 服务器接口。WSGI 和 ASGI 之间的主要区别是什么? ASGI 支持异步代码,而 WSGI 适合同步 Web 应用。这是 WSGI(Web 服务器网关接口)和 ASGI(异步服务器网关接口)之间的主要区别。ASGI 是 WSGI 的扩展,它增加了对异步编程的支持,这使得在处理大量并发连接时,可以更有效地利用系统资源。 为了解决 Django 视图的可扩展性和可重用性问题,创建了哪种类型的视图? 基于类的视图。Django 引入了基于类的视图(Class-Based Views),以提供更好的可重用性和可扩展性。基于类的视图允许你通过继承和混入(mixins)来重用和定制视图的行为。 授权是在认证过程的什么时候发生的? 认证之后。在大多数系统中,用户必须首先通过认证(例如,通过提供有效的用户名和密码),然后系统才会进行授权,以确定用户对资源的访问权限。 如果你想在不手动下载和导入的情况下使用 Bootstrap CSS 样式类,你应该怎么做? 在你的 HTML 模板的 head 元素中添加一个链接到最新的 Bootstrap 版本。这样,你就可以在你的网页中直接使用 Bootstrap 的 CSS 样式类,而无需手动下载和导入 Bootstrap。 Django 应用可以有特定于应用的静态文件和外部静态文件。你在哪里定义外部静态文件的目录,以便你可以找到它们? 在 settings.py 文件中的 STATICFILES_DIRS 列表。这是 Django 设置中的一个变量,用于指定包含你的静态文件的额外目录的列表。Django 将在这些目录中查找静态文件。 WSGI 是 Python 用于在 Web 服务器和应用程序之间通信的主要标准。为了使 Django 应用与 WSGI 一起工作,启动项目命令创建了一个默认声明可调用应用程序的文件。这个文件叫什么名字? wsgi.py。这是 Django 项目中的一个文件,它包含一个可调用的 WSGI 应用。当你使用 Django 的 startproject 命令创建一个新项目时,这个文件会被自动创建。 5. 利用新功能增强在线课程应用程序 5.1. 最终项目概述和方案 您将有机会掌握 Django 技能并将其应用到项目中。您将收到一个在线课程应用程序的模板代码。然后,您将利用在本课程中学到的技能,计划并实施对应用程序的改进;这些改进包括: 创建问题、选择和提交模型 使用管理站点创建带有考试相关模型的新课程对象 更新课程详细信息模板,以显示问题和选项 创建新的考试结果模板,以显示提交结果 创建新的考试结果提交视图 创建一个新视图来显示和评估考试结果 让我们建立一个虚拟环境,其中包含我们需要的所有软件包: pip install --upgrade distro-infopip3 install --upgrade pip==23.2.1pip install virtualenvvirtualenv djangoenvsource djangoenv/bin/activate pip install -U -r requirements.txt 创建初始迁移并生成数据库模式: 迁移是 Django 将对模型所做的更改(添加字段、删除模型等)传播到数据库模式的方式。迁移主要是自动进行的,但你需要了解何时进行迁移、何时运行迁移以及可能遇到的常见问题。你将使用几条命令与迁移和 Django 处理数据库模式进行交互: migrate:负责应用和取消应用迁移。 makemigrations:负责根据对模型所做的更改创建新的迁移。 sqlmigrate:用于显示迁移的 SQL 语句。 showmigrations:用于列出项目的迁移及其状态。 python manage.py makemigrationspython manage.py migrate 这次成功运行服务器: python manage.py runserver 访问 8000/onlinecourse。 5.1.1. 建立新模型 你需要在 onlinecourse/models.py 中创建几个新模型。 Question 模型将保存具有以下特征的考试问题: 用于保存课程试题 与课程有多对一关系 具有问题文本 每道题都有一个分数 class Question(models.Model): course = models.ForeignKey(Course, on_delete=models.CASCADE) content = models.CharField(max_length=200) grade = models.IntegerField(default=50) def __str__(self): return "Question: " + self.content 此外,你还可以在 Question 模型中添加以下函数来计算分数: def is_get_score(self, selected_ids): all_answers = self.choice_set.filter(is_correct=True).count() selected_correct = self.choice_set.filter(is_correct=True, id__in=selected_ids).count() if all_answers == selected_correct: return True return False Choice 模型可保存问题的所有选项: 与 Question 模型的多对一关系 选项文本 表示该选项是否正确 class Choice(models.Model): question = models.ForeignKey(Question, on_delete=models.CASCADE) content = models.CharField(max_length=200) is_correct = models.BooleanField(default=False) 我们为您提供了已注释的 Submission 模型,该模型具有以下特点: 与 Exam Submissions 之间的多对一关系,例如,多个考试提交可能属于一个课程注册。 与选项或问题的多对多关系。为简单起见,您可以将提交与 Choice 模型相关联。 您需要取消对 Submission 模型的注释,并使用它来关联选定的选择。 class Submission(models.Model): enrollment = models.ForeignKey(Enrollment, on_delete=models.CASCADE) choices = models.ManyToManyField(Choice) 迁移: python manage.py makemigrations onlinecoursepython manage.py migrate 5.1.2. 注册模式变更 现在,您将修改 onlinecourse/admin.py,以便使用您创建的新功能。 首先您需要导入 Question、Choice 和 Submission: from .models import Course, Lesson, Instructor, Learner, Question, Choice, Submission 创建 QuestionInline 和 ChoiceInline 类,这样就可以在管理网站的一个页面上一起编辑它们。 class ChoiceInline(admin.StackedInline): model = Choice extra = 2class QuestionInline(admin.StackedInline): model = Question extra = 2 创建 QuestionAdmin 类: class QuestionAdmin(admin.ModelAdmin): inlines = [ChoiceInline] list_display = ['content'] 注册新模型后,您可以使用管理网站创建一个包含课程、问题和问题选项的新课程。 admin.site.register(Question, QuestionAdmin)admin.site.register(Choice)admin.site.register(Submission) 接着让我们创建一个管理员用户,其详细信息如下: 用户名:admin。 邮箱:留空。 密码:可以使用 p@ssword123。 python manage.py createsuperuser 运行 Django 开发服务器,检查是否可以使用管理站点添加 Question 和 Choice 对象。 5.1.3. 更新课程详细信息模板 现在,您将更新课程详情模板,创建一个包含问题和选项列表的考试部分。一次考试包含多个问题,每个问题都应该有一个以上的正确答案(多选)。 更改将在 templates/onlinecourse/course_details_bootstrap.html 中进行。 如果用户已通过身份验证,则显示带有问题和选项列表的课程考试: {% if user.is_authenticated %}</br><!-- Remaining code will go here -->{% endif %} 添加一个开始考试的按钮: <button class="btn btn-primary btn-block" data-toggle="collapse" data-target="#exam">Start Exam</button> 添加一个可折叠 div: <div id="exam" class="collapse"></div> 在表单中添加 Question 逻辑: <div id="exam" class="collapse"> <form id="questionform" action="{% url 'onlinecourse:submit' course.id %}" method="POST"> {% for question in course.question_set.all %} <!-- Question UI components will go here --> {% endfor %} </form></div> 添加 Quesion UI: <div class="card mt-1"> <div class="card-header"><h5>{{ question.content }}</h5></div> {% csrf_token %} <div class="form-group"> <!-- Choices components go here --> </div></div> 添加 Choices 组件: {% for choice in question.choice_set.all %}<div class="form-check"> <label class="form-check-label"> <input type="checkbox" name="choice_{{choice.id}}" class="form-check-input" id="{{choice.id}}" value="{{choice.id}}">{{ choice.content }} </label></div>{% endfor %} 5.1.4. 提交评估 由于您已创建了多个新模型,因此现在需要在 views.py 的顶部导入它们。 from .models import Course, Enrollment, Question, Choice, Submission 现在,您将为表单提交创建一个基于函数的视图。创建提交视图 def submit(request,course_id) 来为课程注册创建考试提交记录。您可以根据以下逻辑来实现它: 获取当前用户和课程对象,然后获取相关的注册对象 参照注册信息创建一个新的提交对象 从 HTTP 请求对象中收集所选选项 将每个选定的选择对象添加到提交对象中 使用提交 ID 重定向到 show_exam_result 视图,以显示考试结果 配置 urls.py 以路由新的提交视图 def submit(request, course_id): course = get_object_or_404(Course, pk=course_id) user = request.user enrollment = Enrollment.objects.get(user=user, course=course) submission = Submission.objects.create(enrollment=enrollment) choices = extract_answers(request) submission.choices.set(choices) submission_id = submission.id return HttpResponseRedirect(reverse(viewname='onlinecourse:exam_result', args=(course_id, submission_id,))) 在 urls.py 中路由提交视图按钮: path('<int:course_id>/submit/', views.submit, name="submit"), 5.1.5. 评估视图 创建考试结果视图 def show_exam_result(request,course_id,submission_id) 来检查学员是否通过考试以及他们的答题结果。您可以根据以下逻辑实现视图: 根据视图参数中的 id 获取课程对象和提交对象 从提交记录中获取所选选项的 id 检查每个所选选项是否为正确答案 将课程中所有问题的分数相加,计算总分 将课程、选项和成绩添加到上下文中,以便渲染 HTML 页面 配置 urls.py 以路由新的 show_exam_result 视图 def show_exam_result(request, course_id, submission_id): context = {} course = get_object_or_404(Course, pk=course_id) submission = Submission.objects.get(id=submission_id) choices = submission.choices.all() total_score = 0 for choice in choices: if choice.is_correct: total_score += choice.question.grade context['course'] = course context['grade'] = total_score context['choices'] = choices return render(request, 'onlinecourse/exam_result_bootstrap.html', context) 5.1.6. 完成考试结果模板以显示考试提交结果 完成用于提交结果的 exam_result_bootstrap.html HTML 模板,该模板应显示学员是否通过考试,并提供总分、每道题的成绩等详细信息。 使用 Bootstrap 对更新后的模板进行样式调整,以符合设计团队的用户界面设计。 通过考试的学员应看到最终分数和祝贺信息: <b>Congratulations, {{ user.first_name }}!</b> You have passed the exam and completed the course with score {{ grade }}/100 对于考试不及格的学员,应向其显示最终分数和错误选项。应允许学员重考并重新提交: <b>Failed</b> Sorry, {{ user.first_name }}! You have failed the exam with score {{ grade }}/100 您还必须显示考试成绩,以便学员了解自己的成绩: {% for question in course.question_set.all %}<div class="card mt-1"> <div class="card-header"><h5>{{ question.content }}</h5></div> <div class="form-group"> {% for choice in question.choice_set.all %} <div class="form-check"> {% if choice.is_correct and choice in choices %} <div class="text-success">Correct answer: {{ choice.content }}</div> {% else %}{% if choice.is_correct and not choice in choices %} <div class="text-warning">Not selected: {{ choice.content }}</div> {% else %}{% if not choice.is_correct and choice in choices %} <div class="text-danger">Wrong answer: {{ choice.content }}</div> {% else %} <div>{{ choice.content }}</div> {% endif %}{% endif %}{% endif %} </div> {% endfor %} </div></div>{% endfor %}

2024/5/29
articleCard.readMore

Hexo 源代码分析【1】:入口文件

使用 Hexo,痛骂 Hexo,理解 Hexo,成为 Hexo。 这篇文章是用来记录我阅读 Hexo 源代码的过程和分析。 版本号 文章写的时候,Hexo 的版本为 7.2.0: { "name": "hexo", "version": "7.2.0", "description": "A fast, simple & powerful blog framework, powered by Node.js.", "main": "dist/hexo", "bin": { "hexo": "./bin/hexo" }, "scripts": { "prepublishOnly": "npm install && npm run clean && npm run build", "build": "tsc -b", "clean": "tsc -b --clean", "eslint": "eslint lib test", "pretest": "npm run clean && npm run build", "test": "mocha test/scripts/**/*.ts --require ts-node/register", "test-cov": "c8 --reporter=lcovonly npm test -- --no-parallel", "prepare": "husky install" }, "files": [ "dist/", "bin/" ], "types": "./dist/hexo/index.d.ts", "repository": "hexojs/hexo", "homepage": "https://hexo.io/", "funding": { "type": "opencollective", "url": "https://opencollective.com/hexo" }, "keywords": [ "website", "blog", "cms", "framework", "hexo" ], "author": "Tommy Chen <tommy351@gmail.com> (https://zespia.tw)", "maintainers": [ "Abner Chou <hi@abnerchou.me> (https://abnerchou.me)" ], "license": "MIT", "dependencies": { "abbrev": "^2.0.0", "archy": "^1.0.0", "bluebird": "^3.7.2", "hexo-cli": "^4.3.2", "hexo-front-matter": "^4.2.1", "hexo-fs": "^4.1.3", "hexo-i18n": "^2.0.0", "hexo-log": "^4.0.1", "hexo-util": "^3.3.0", "js-yaml": "^4.1.0", "js-yaml-js-types": "^1.0.0", "micromatch": "^4.0.4", "moize": "^6.1.6", "moment": "^2.29.1", "moment-timezone": "^0.5.34", "nunjucks": "^3.2.3", "picocolors": "^1.0.0", "pretty-hrtime": "^1.0.3", "resolve": "^1.22.0", "strip-ansi": "^6.0.0", "text-table": "^0.2.0", "tildify": "^2.0.0", "titlecase": "^1.1.3", "warehouse": "^5.0.1" }, "devDependencies": { "0x": "^5.1.2", "@types/abbrev": "^1.1.3", "@types/bluebird": "^3.5.37", "@types/chai": "^4.3.11", "@types/js-yaml": "^4.0.9", "@types/mocha": "^10.0.6", "@types/node": "^18.11.8 <18.19.9", "@types/nunjucks": "^3.2.2", "@types/rewire": "^2.5.30", "@types/sinon": "^17.0.3", "@types/text-table": "^0.2.4", "c8": "^9.0.0", "chai": "^4.3.6", "cheerio": "0.22.0", "decache": "^4.6.1", "eslint": "^8.48.0", "eslint-config-hexo": "^5.0.0", "hexo-renderer-marked": "^6.0.0", "husky": "^8.0.1", "lint-staged": "^15.2.0", "mocha": "^10.0.0", "rewire": "^7.0.0", "sinon": "^17.0.1", "ts-node": "^10.9.1", "typescript": "^5.3.2" }, "engines": { "node": ">=14" }} Hexo 的入口文件是 dist/hexo/index.js,我们来看看这个文件: 部分代码因为太长了,所以先注释掉,后续会拿出来说。 "use strict";var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod };};const bluebird_1 = __importDefault(require("bluebird"));const path_1 = require("path");const tildify_1 = __importDefault(require("tildify"));const warehouse_1 = __importDefault(require("warehouse"));const picocolors_1 = require("picocolors");const events_1 = require("events");const hexo_fs_1 = require("hexo-fs");const module_1 = __importDefault(require("module"));const vm_1 = require("vm");const { version } = require('../../package.json');const hexo_log_1 = __importDefault(require("hexo-log"));const extend_1 = require("../extend");const render_1 = __importDefault(require("./render"));const register_models_1 = __importDefault(require("./register_models"));const post_1 = __importDefault(require("./post"));const scaffold_1 = __importDefault(require("./scaffold"));const source_1 = __importDefault(require("./source"));const router_1 = __importDefault(require("./router"));const theme_1 = __importDefault(require("../theme"));const locals_1 = __importDefault(require("./locals"));const default_config_1 = __importDefault(require("./default_config"));const load_database_1 = __importDefault(require("./load_database"));const multi_config_path_1 = __importDefault(require("./multi_config_path"));const hexo_util_1 = require("hexo-util");let resolveSync; // = require('resolve');const libDir = (0, path_1.dirname)(__dirname);const dbVersion = 1;const stopWatcher = (box) => { if (box.isWatching()) box.unwatch(); };const routeCache = new WeakMap();const castArray = (obj) => { return Array.isArray(obj) ? obj : [obj]; };const mergeCtxThemeConfig = (ctx) => { ... };const createLoadThemeRoute = function (generatorResult, locals, ctx) { ... };function debounce(func, wait) { ... }class Hexo extends events_1.EventEmitter { ... }Hexo.lib_dir = libDir + path_1.sep;Hexo.prototype.lib_dir = Hexo.lib_dir;Hexo.core_dir = (0, path_1.dirname)(libDir) + path_1.sep;Hexo.prototype.core_dir = Hexo.core_dir;Hexo.version = version;Hexo.prototype.version = Hexo.version;module.exports = Hexo;//# sourceMappingURL=index.js.map Hexo 类 重点来看 Hexo 类的定义: class Hexo extends events_1.EventEmitter { constructor(base = process.cwd(), args = {}) { ... } _bindLocals() { ... } init() { ... } call(name, args, callback) { ... } model(name, schema) { ... } resolvePlugin(name, basedir) { ... } loadPlugin(path, callback) { ... } _showDrafts() { ... } load(callback) { ... } watch(callback) { ... } unwatch() { ... } _generateLocals() { ... } _runGenerators() { ... } _routerRefresh(runningGenerators, useCache) { ... } _generate(options = {}) { ... } exit(err) { ... } execFilter(type, data, options) { ... } execFilterSync(type, data, options) { ... }} 构造函数 constructor(base = process.cwd(), args = {}) { super(); this.base_dir = base + path_1.sep; this.public_dir = (0, path_1.join)(base, 'public') + path_1.sep; this.source_dir = (0, path_1.join)(base, 'source') + path_1.sep; this.plugin_dir = (0, path_1.join)(base, 'node_modules') + path_1.sep; this.script_dir = (0, path_1.join)(base, 'scripts') + path_1.sep; this.scaffold_dir = (0, path_1.join)(base, 'scaffolds') + path_1.sep; this.theme_dir = (0, path_1.join)(base, 'themes', default_config_1.default.theme) + path_1.sep; this.theme_script_dir = (0, path_1.join)(this.theme_dir, 'scripts') + path_1.sep; this.env = { args, debug: Boolean(args.debug), safe: Boolean(args.safe), silent: Boolean(args.silent), env: process.env.NODE_ENV || 'development', version, cmd: args._ ? args._[0] : '', init: false }; this.extend = { console: new extend_1.Console(), deployer: new extend_1.Deployer(), filter: new extend_1.Filter(), generator: new extend_1.Generator(), helper: new extend_1.Helper(), highlight: new extend_1.Highlight(), injector: new extend_1.Injector(), migrator: new extend_1.Migrator(), processor: new extend_1.Processor(), renderer: new extend_1.Renderer(), tag: new extend_1.Tag() }; this.config = Object.assign({}, default_config_1.default); this.log = (0, hexo_log_1.default)(this.env); this.render = new render_1.default(this); this.route = new router_1.default(); this.post = new post_1.default(this); this.scaffold = new scaffold_1.default(this); this._dbLoaded = false; this._isGenerating = false; const dbPath = args.output || base; if (/^(init|new|g|publish|s|deploy|render|migrate)/.test(this.env.cmd)) { this.log.d(`Writing database to ${(0, path_1.join)(dbPath, 'db.json')}`); } this.database = new warehouse_1.default({ version: dbVersion, path: (0, path_1.join)(dbPath, 'db.json') }); const mcp = (0, multi_config_path_1.default)(this); this.config_path = args.config ? mcp(base, args.config, args.output) : (0, path_1.join)(base, '_config.yml'); (0, register_models_1.default)(this); this.source = new source_1.default(this); this.theme = new theme_1.default(this); this.locals = new locals_1.default(); this._bindLocals();} 路径 this.base_dir = base + path_1.sep;this.public_dir = (0, path_1.join)(base, 'public') + path_1.sep;this.source_dir = (0, path_1.join)(base, 'source') + path_1.sep;this.plugin_dir = (0, path_1.join)(base, 'node_modules') + path_1.sep;this.script_dir = (0, path_1.join)(base, 'scripts') + path_1.sep;this.scaffold_dir = (0, path_1.join)(base, 'scaffolds') + path_1.sep;this.theme_dir = (0, path_1.join)(base, 'themes', default_config_1.default.theme) + path_1.sep;this.theme_script_dir = (0, path_1.join)(this.theme_dir, 'scripts') + path_1.sep; base_dir:项目的基础目录 public_dir:项目生成的静态文件存放的目录(./public),当运行 hexo generate 时生成的所有静态文件都会被放在这个目录下 source_dir:Markdown 文章(./source) plugin_dir:插件(./node_modules),Hexo 的插件机制基于 Node.JS 的模块系统 script_dir:脚本(./scripts),Hexo 启动时会自动执行这些 JavaScript 文件 scaffold_dir:脚手架(./scaffolds)是一种模板,可以用它快速创建新的文章 theme_dir:主题(./themes) theme_script_dir:类似于脚本(./themes/scripts) 环境信息 this.env = { args, debug: Boolean(args.debug), safe: Boolean(args.safe), silent: Boolean(args.silent), env: process.env.NODE_ENV || 'development', version, cmd: args._ ? args._[0] : '', init: false}; 包含了关于 Hexo 运行环境的信息,如调试模式、安全模式、静默模式、环境变量、版本号、命令、是否初始化等。 扩展 this.extend = { console: new extend_1.Console(), deployer: new extend_1.Deployer(), filter: new extend_1.Filter(), generator: new extend_1.Generator(), helper: new extend_1.Helper(), highlight: new extend_1.Highlight(), injector: new extend_1.Injector(), migrator: new extend_1.Migrator(), processor: new extend_1.Processor(), renderer: new extend_1.Renderer(), tag: new extend_1.Tag()}; 这些是 Hexo 的核心组件,以后会详细介绍。 其实可以先行阅读 Hexo 官方文档,虽然初次看可能很难看懂,但是看了总是会在未来的某个时刻头脑一亮埋下伏笔。 配置 this.config = Object.assign({}, default_config_1.default); 默认配置的内容: "use strict";module.exports = { title: 'Hexo', subtitle: '', description: '', author: 'John Doe', language: 'en', timezone: '', url: 'http://example.com', root: '/', permalink: ':year/:month/:day/:title/', permalink_defaults: {}, pretty_urls: { trailing_index: true, trailing_html: true }, source_dir: 'source', public_dir: 'public', tag_dir: 'tags', archive_dir: 'archives', category_dir: 'categories', code_dir: 'downloads/code', i18n_dir: ':lang', skip_render: [], new_post_name: ':title.md', default_layout: 'post', titlecase: false, external_link: { enable: true, field: 'site', exclude: '' }, filename_case: 0, render_drafts: false, post_asset_folder: false, relative_link: false, future: true, syntax_highlighter: 'highlight.js', highlight: { auto_detect: false, line_number: true, tab_replace: '', wrap: true, exclude_languages: [], language_attr: false, hljs: false, line_threshold: 0, first_line_number: 'always1', strip_indent: true }, prismjs: { preprocess: true, line_number: true, tab_replace: '', exclude_languages: [], strip_indent: true }, default_category: 'uncategorized', category_map: {}, tag_map: {}, date_format: 'YYYY-MM-DD', time_format: 'HH:mm:ss', updated_option: 'mtime', per_page: 10, pagination_dir: 'page', theme: 'landscape', server: { cache: false }, deploy: {}, ignore: [], meta_generator: true};//# sourceMappingURL=default_config.js.map 看着很熟悉吧,这些配置项都是我们初始化 Hexo 项目时,出现在根目录的 _config.yml 内的配置项。 可见 Hexo 官方文档。 日志 this.log = (0, hexo_log_1.default)(this.env); 这里的 hexo_log_1 是: const hexo_log_1 = __importDefault(require("hexo-log")); index.js 中大量的导入变量都是这样命名的。 渲染 this.render = new render_1.default(this); 可见 Hexo 官方文档。 "use strict";var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod };};const path_1 = require("path");const bluebird_1 = __importDefault(require("bluebird"));const hexo_fs_1 = require("hexo-fs");const getExtname = (str) => { if (typeof str !== 'string') return ''; const ext = (0, path_1.extname)(str); return ext.startsWith('.') ? ext.slice(1) : ext;};const toString = (result, options) => { if (!Object.prototype.hasOwnProperty.call(options, 'toString') || typeof result === 'string') return result; if (typeof options.toString === 'function') { return options.toString(result); } else if (typeof result === 'object') { return JSON.stringify(result); } else if (result.toString) { return result.toString(); } return result;};class Render { constructor(ctx) { this.context = ctx; this.renderer = ctx.extend.renderer; } isRenderable(path) { return this.renderer.isRenderable(path); } isRenderableSync(path) { return this.renderer.isRenderableSync(path); } getOutput(path) { return this.renderer.getOutput(path); } getRenderer(ext, sync) { return this.renderer.get(ext, sync); } getRendererSync(ext) { return this.getRenderer(ext, true); } render(data, options, callback) { if (!callback && typeof options === 'function') { callback = options; options = {}; } const ctx = this.context; let ext = ''; let promise; if (!data) return bluebird_1.default.reject(new TypeError('No input file or string!')); if (data.text != null) { promise = bluebird_1.default.resolve(data.text); } else if (!data.path) { return bluebird_1.default.reject(new TypeError('No input file or string!')); } else { promise = (0, hexo_fs_1.readFile)(data.path); } return promise.then(text => { data.text = text; ext = data.engine || getExtname(data.path); if (!ext || !this.isRenderable(ext)) return text; const renderer = this.getRenderer(ext); return Reflect.apply(renderer, ctx, [data, options]); }).then(result => { result = toString(result, data); if (data.onRenderEnd) { return data.onRenderEnd(result); } return result; }).then(result => { const output = this.getOutput(ext) || ext; return ctx.execFilter(`after_render:${output}`, result, { context: ctx, args: [data] }); }).asCallback(callback); } renderSync(data, options = {}) { if (!data) throw new TypeError('No input file or string!'); const ctx = this.context; if (data.text == null) { if (!data.path) throw new TypeError('No input file or string!'); data.text = (0, hexo_fs_1.readFileSync)(data.path); } if (data.text == null) throw new TypeError('No input file or string!'); const ext = data.engine || getExtname(data.path); let result; if (ext && this.isRenderableSync(ext)) { const renderer = this.getRendererSync(ext); result = Reflect.apply(renderer, ctx, [data, options]); } else { result = data.text; } const output = this.getOutput(ext) || ext; result = toString(result, data); if (data.onRenderEnd) { result = data.onRenderEnd(result); } return ctx.execFilterSync(`after_render:${output}`, result, { context: ctx, args: [data] }); }}module.exports = Render;//# sourceMappingURL=render.js.map bluebird 库是一个流行的 JavaScript Promise 库,它提供了一种更加健壮、高效和优雅的方式来处理异步操作。 在传统的基于回调函数的异步编程中,存在着回调地狱、代码耦合等问题。Promise 的出现解决了这些问题,使着异步代码更加易于理解和维护。而 bluebird 在 Promise 的基础上提供了更多的增强功能和优化。 Render 类包含两个主要方法:render 和 renderSync。 render 方法是一个异步方法,首先检查文件是否可根据其扩展名进行渲染。如果可以,它会获取相应的渲染器并将其应用于数据。渲染后的结果将通过一个过滤器,并在回调函数中返回。 renderSync 方法是 render 的同步版本。工作方式类似,但使用同步文件读取,并使用异常代替回调和 Promise 处理错误。 其他部分包括: getExtname 是一个辅助函数,用于从文件路径中提取文件扩展名。 toString 是一个辅助函数,根据提供的选项将结果对象转换为字符串表示。 Render 类的构造函数接收 Hexo 上下文 ctx,并从中初始化 renderer 属性。 isRenderable 和 isRenderableSync 方法检查给定扩展名的文件是否可渲染。 getOutput 方法获取给定输入扩展名的输出扩展名。 getRenderer 和 getRendererSync 方法获取给定扩展名的相应渲染器。 这段代码为 Hexo 提供了一种基于文件扩展名渲染文件的方式,使用每种扩展名对应的渲染器。 路由 this.route = new router_1.default(); 这行代码实例化了 Router 类的对象,用于管理路由。在 Hexo 官方文档 中,路由是 存储了网站中所用到的所有路径。 "use strict";var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod };};const events_1 = require("events");const bluebird_1 = __importDefault(require("bluebird"));const stream_1 = __importDefault(require("stream"));const { Readable } = stream_1.default;class RouteStream extends Readable { constructor(data) { super({ objectMode: true }); this._data = data.data; this._ended = false; this.modified = data.modified; } // Assume we only accept Buffer, plain object, or string _toBuffer(data) { if (data instanceof Buffer) { return data; } if (typeof data === 'object') { data = JSON.stringify(data); } if (typeof data === 'string') { return Buffer.from(data); // Assume string is UTF-8 encoded string } return null; } _read() { const data = this._data; if (typeof data !== 'function') { const bufferData = this._toBuffer(data); if (bufferData) { this.push(bufferData); } this.push(null); return; } // Don't read it twice! if (this._ended) return false; this._ended = true; data().then(data => { if (data instanceof stream_1.default && data.readable) { data.on('data', d => { this.push(d); }); data.on('end', () => { this.push(null); }); data.on('error', err => { this.emit('error', err); }); } else { const bufferData = this._toBuffer(data); if (bufferData) { this.push(bufferData); } this.push(null); } }).catch(err => { this.emit('error', err); this.push(null); }); }}const _format = (path) => { path = path || ''; if (typeof path !== 'string') throw new TypeError('path must be a string!'); path = path .replace(/^\/+/, '') // Remove prefixed slashes .replace(/\\/g, '/') // Replaces all backslashes .replace(/\?.*$/, ''); // Remove query string // Appends `index.html` to the path with trailing slash if (!path || path.endsWith('/')) { path += 'index.html'; } return path;};class Router extends events_1.EventEmitter { constructor() { super(); this.routes = {}; } list() { const { routes } = this; return Object.keys(routes).filter(key => routes[key]); } format(path) { return _format(path); } get(path) { if (typeof path !== 'string') throw new TypeError('path must be a string!'); const data = this.routes[this.format(path)]; if (data == null) return; return new RouteStream(data); } isModified(path) { if (typeof path !== 'string') throw new TypeError('path must be a string!'); const data = this.routes[this.format(path)]; return data ? data.modified : false; } set(path, data) { if (typeof path !== 'string') throw new TypeError('path must be a string!'); if (data == null) throw new TypeError('data is required!'); let obj; if (typeof data === 'object' && data.data != null) { obj = data; } else { obj = { data, modified: true }; } if (typeof obj.data === 'function') { if (obj.data.length) { obj.data = bluebird_1.default.promisify(obj.data); } else { obj.data = bluebird_1.default.method(obj.data); } } path = this.format(path); this.routes[path] = { data: obj.data, modified: obj.modified == null ? true : obj.modified }; this.emit('update', path); return this; } remove(path) { if (typeof path !== 'string') throw new TypeError('path must be a string!'); path = this.format(path); this.routes[path] = null; this.emit('remove', path); return this; }}module.exports = Router;//# sourceMappingURL=router.js.map 这段代码定义了一个名为 Router 的类和一个名为 RouteStream 的类。它们在 Hexo 框架中用于管理路由和从路由读取数据流。 RouteStream 类: 继承自 Node.JS 内置的 stream.Readable 类,因此它是一个可读流。 构造函数接收一个 data 对象,该对象包含 data 属性和 modified 属性。 _read() 方法是可读流的实现。如果 data 是一个函数,它会尝试从该函数中获取数据。否则,它会将 data 推送到流中。 _toBuffer() 是一个内部方法,用于将数据转换为 Buffer。 Router 类: 继承自 EventEmitter,因此可以发射和监听事件。 routes 属性是一个对象,用于存储路径及其对应的数据。 list() 方法返回所有已注册路径的列表。 format(path) 方法用于格式化路径,移除前导斜杠、替换反斜杠,并为以斜杠结尾的路径添加 index.html。 get(path) 方法用于获取给定路径的数据流。如果路径不存在或数据为 null,则返回 undefined。 isModified(path) 方法用于检查给定路径的数据是否已修改。 set(path, data) 方法用于设置给定路径的数据。如果 data 是一个函数,它会使用 Bluebird 库将其转换为 Promise。该方法还会触发 update 事件。 remove(path) 方法用于移除给定路径的数据。它会将路径对应的值设置为 null,并触发 remove 事件。 Node.JS 的 EventEmitter 是一个模块,提供基于事件驱动的编程方式,是 Node.JS 很多核心模块和第三方模块的基础,也是 Node.JS 中非常重要的一个概念和设计模式。 EventEmitter 是一个构造函数,可以创建一个新的事件发射器对象。这个对象可以发射命名事件,并且可以通过添加事件监听器来监听特定事件。当事件被发射时,所有监听该事件的函数回调都会被同步地调用。 EventEmitter 对象有以下常用方法: emitter.on(eventName, listener) 为指定事件注册一个监听器。 emitter.once(eventName, listener) 为指定事件注册一个一次性的监听器,触发后就会被移除。 emitter.off(eventName, listener) 移除指定事件的监听器。 emitter.emit(eventName, [...args]) 发射指定事件,传递参数给监听器回调函数。 Hexo 官方文档 中也提到了 EventEmitter。 博客文章 this.post = new post_1.default(this); 可见 Hexo 官方文档。 post.js 很长,因此将部分代码分开讲解。 "use strict";var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod };};const assert_1 = __importDefault(require("assert"));const moment_1 = __importDefault(require("moment"));const bluebird_1 = __importDefault(require("bluebird"));const path_1 = require("path");const picocolors_1 = require("picocolors");const js_yaml_1 = require("js-yaml");const hexo_util_1 = require("hexo-util");const hexo_fs_1 = require("hexo-fs");const hexo_front_matter_1 = require("hexo-front-matter");const preservedKeys = ['title', 'slug', 'path', 'layout', 'date', 'content'];const rHexoPostRenderEscape = /<hexoPostRenderCodeBlock>([\s\S]+?)<\/hexoPostRenderCodeBlock>/g;const rSwigPlaceHolder = /(?:<|&lt;)!--swig\uFFFC(\d+)--(?:>|&gt;)/g;const rCodeBlockPlaceHolder = /(?:<|&lt;)!--code\uFFFC(\d+)--(?:>|&gt;)/g;const STATE_PLAINTEXT = Symbol('plaintext');const STATE_SWIG_VAR = Symbol('swig_var');const STATE_SWIG_COMMENT = Symbol('swig_comment');const STATE_SWIG_TAG = Symbol('swig_tag');const STATE_SWIG_FULL_TAG = Symbol('swig_full_tag');const isNonWhiteSpaceChar = (char) => char !== '\r' && char !== '\n' && char !== '\t' && char !== '\f' && char !== '\v' && char !== ' '; PostRenderEscape 类用于处理 Swig / Nunjucks 标签和代码块的转义和还原: Swig 和 Nunjucks 是两种流行的模板引擎。 主要语法包括: {% ... %}:用于执行语句,如条件语句和循环语句。 {{ ... }}:用于输出变量值。 在渲染文章内容时,Hexo 需要先对 Swig / Nunjucks 标签和代码块进行转义,防止被误解析,然后再使用相应的渲染器(如 Markdown)进行渲染。渲染完成后,需要将转义的内容还原回来。 class PostRenderEscape { constructor() { this.stored = []; } static escapeContent(cache, flag, str) { return `<!--${flag}\uFFFC${cache.push(str) - 1}-->`; } static restoreContent(cache) { return (_, index) => { (0, assert_1.default)(cache[index]); const value = cache[index]; cache[index] = null; return value; }; } restoreAllSwigTags(str) { const restored = str.replace(rSwigPlaceHolder, PostRenderEscape.restoreContent(this.stored)); return restored; } restoreCodeBlocks(str) { return str.replace(rCodeBlockPlaceHolder, PostRenderEscape.restoreContent(this.stored)); } escapeCodeBlocks(str) { return str.replace(rHexoPostRenderEscape, (_, content) => PostRenderEscape.escapeContent(this.stored, 'code', content)); } /** * @param {string} str * @returns string */ escapeAllSwigTags(str) { if (!/(\{\{.+?\}\})|(\{#.+?#\})|(\{%.+?%\})/s.test(str)) { return str; } let state = STATE_PLAINTEXT; let buffer = ''; let output = ''; let swig_tag_name_begin = false; let swig_tag_name_end = false; let swig_tag_name = ''; let swig_full_tag_start_buffer = ''; const { length } = str; for (let idx = 0; idx < length; idx++) { const char = str[idx]; const next_char = str[idx + 1]; if (state === STATE_PLAINTEXT) { // From plain text to swig if (char === '{') { // check if it is a complete tag {{ }} if (next_char === '{') { state = STATE_SWIG_VAR; idx++; } else if (next_char === '#') { state = STATE_SWIG_COMMENT; idx++; } else if (next_char === '%') { state = STATE_SWIG_TAG; idx++; swig_tag_name = ''; swig_full_tag_start_buffer = ''; swig_tag_name_begin = false; // Mark if it is the first non white space char in the swig tag swig_tag_name_end = false; } else { output += char; } } else { output += char; } } else if (state === STATE_SWIG_TAG) { if (char === '%' && next_char === '}') { // From swig back to plain text idx++; if (swig_tag_name !== '' && str.includes(`end${swig_tag_name}`)) { state = STATE_SWIG_FULL_TAG; } else { swig_tag_name = ''; state = STATE_PLAINTEXT; output += PostRenderEscape.escapeContent(this.stored, 'swig', `{%${buffer}%}`); } buffer = ''; } else { buffer = buffer + char; swig_full_tag_start_buffer = swig_full_tag_start_buffer + char; if (isNonWhiteSpaceChar(char)) { if (!swig_tag_name_begin && !swig_tag_name_end) { swig_tag_name_begin = true; } if (swig_tag_name_begin) { swig_tag_name += char; } } else { if (swig_tag_name_begin === true) { swig_tag_name_begin = false; swig_tag_name_end = true; } } } } else if (state === STATE_SWIG_VAR) { if (char === '}' && next_char === '}') { idx++; state = STATE_PLAINTEXT; output += PostRenderEscape.escapeContent(this.stored, 'swig', `{{${buffer}}}`); buffer = ''; } else { buffer = buffer + char; } } else if (state === STATE_SWIG_COMMENT) { // From swig back to plain text if (char === '#' && next_char === '}') { idx++; state = STATE_PLAINTEXT; buffer = ''; } } else if (state === STATE_SWIG_FULL_TAG) { if (char === '{' && next_char === '%') { let swig_full_tag_end_buffer = ''; let _idx = idx + 2; for (; _idx < length; _idx++) { const _char = str[_idx]; const _next_char = str[_idx + 1]; if (_char === '%' && _next_char === '}') { _idx++; break; } swig_full_tag_end_buffer = swig_full_tag_end_buffer + _char; } if (swig_full_tag_end_buffer.includes(`end${swig_tag_name}`)) { state = STATE_PLAINTEXT; output += PostRenderEscape.escapeContent(this.stored, 'swig', `{%${swig_full_tag_start_buffer}%}${buffer}{%${swig_full_tag_end_buffer}%}`); idx = _idx; swig_full_tag_start_buffer = ''; swig_full_tag_end_buffer = ''; buffer = ''; } else { buffer += char; } } else { buffer += char; } } } return output; }} escapeContent:用于将给定的字符串转义为占位符,并存储在缓存数组中。 转义后的占位符格式为:<!--flag\uFFFC${index}-->。 restoreContent:用于将占位符还原为原始字符串。 restoreAllSwigTags:使用正则表达式替换所有 Swig 标签占位符,调用 restoreContent 方法还原原始内容。 restoreCodeBlocks:使用正则表达式替换所有代码块占位符,调用 restoreContent 方法还原原始内容。 escapeCodeBlocks:使用正则表达式匹配所有 Markdown 代码块,调用 escapeContent 方法将它们转义为占位符。 escapeAllSwigTags:用于转义 Swig 模板标签。首先检查输入字符串是否包含 Swig 标签,然后使用有限状态机的方式遍历每个字符,识别出 Swig 标签的类型和内容,并调用 escapeContent 进行转义。 有限状态机(Finite State Machine;FSM)是一种数学计算模型,用于描述具有有限个状态以及基于事件进行状态转移的系统。 一个有限状态机包含以下几个基本组成部分: 有限状态集合(States)。 输入事件/符号集合(Input Events/Symbols)。 一个初始状态(Initial State)。 状态转移函数(State Transition Function)。 终止状态集合(Final States)。 有限状态机的工作原理: 机器初始处于一个确定的初始状态。 机器接收一个输入事件 / 符号。 根据当前状态和输入事件 / 符号,机器按照状态转移函数进行状态转移。 机器进入新的状态,等待下一个输入事件 / 符号。 重复以上过程,直到进入某个终止状态或发生无法处理的输入事件 / 符号。 escapeAllSwigTags 使用了一个有限状态机来精确识别和处理各种 Swig 标签。它定义了五种状态,通过遍历输入字符串,根据当前状态和字符进行状态转移,从而正确处理嵌套、注释等复杂情况。 const prepareFrontMatter = (data, jsonMode) => { for (const [key, item] of Object.entries(data)) { if (moment_1.default.isMoment(item)) { data[key] = item.utc().format('YYYY-MM-DD HH:mm:ss'); } else if (moment_1.default.isDate(item)) { data[key] = moment_1.default.utc(item).format('YYYY-MM-DD HH:mm:ss'); } else if (typeof item === 'string') { if (jsonMode || item.includes(':') || item.startsWith('#') || item.startsWith('!!') || item.includes('{') || item.includes('}') || item.includes('[') || item.includes(']') || item.includes('\'') || item.includes('"')) data[key] = `"${item.replace(/"/g, '\\"')}"`; } } return data;};const removeExtname = (str) => { return str.substring(0, str.length - (0, path_1.extname)(str).length);};const createAssetFolder = (path, assetFolder) => { if (!assetFolder) return bluebird_1.default.resolve(); const target = removeExtname(path); if ((0, path_1.basename)(target) === 'index') return bluebird_1.default.resolve(); return (0, hexo_fs_1.exists)(target).then(exist => { if (!exist) return (0, hexo_fs_1.mkdirs)(target); });}; prepareFrontMatter 方法用于处理文章的 Front Matter 元数据。 Front Matter 是指在 Markdown 或其他 markup 文件的头部添加的一组元数据。它通常被包裹在两组连续的三短横线之间,例如: ---title: My Blog Postdate: 2023-05-26tags: [blog, coding]---# My Blog PostThis is the content of the blog post... Front Matter 通常采用 YAML 或 JSON 格式来表示键值对的元数据。Front Matter 中的数据可以在渲染 markdown 文件时被解析和使用,例如在博客系统中用于生成文章的元数据、URL 等。不同的静态站点生成器和 markdown 渲染器支持不同的 Front Matter 语法和元数据项。 它遍历 data 对象的每个键值对。如果值是 moment 对象或 Date 对象,则将其转换为 UTC 时区的 YYYY-MM-DD HH:mm:ss 格式的字符串。如果值是字符串,并且符合某些条件(包含特殊字符或需要转义),则将其用双引号包裹起来,并对双引号进行转义。 这个函数主要用于在生成文章时正确处理 Front Matter 中的日期和特殊字符。 removeExtname 方法用于从文件路径中移除扩展名。它使用 path.extname 获取扩展名,然后从原始路径中截取并返回不包含扩展名的部分。 createAssetFolder 方法用于在生成文章时创建与文章相关联的资源文件夹(如果配置启用了该选项)。 如果 assetFolder 为 false,则直接返回一个已解决的 Promise。否则它会调用 removeExtname 来获取不包含扩展名的文件路径。 如果这个路径的基础名称是 index,则直接返回一个已解决的 Promise(因为 index 通常是默认文件名,不需要创建文件夹)。否则,它会检查该路径是否存在,如果不存在,则创建该路径作为文件夹。 class Post { constructor(context) { this.context = context; } create(data, replace, callback) { if (!callback && typeof replace === 'function') { callback = replace; replace = false; } const ctx = this.context; const { config } = ctx; data.slug = (0, hexo_util_1.slugize)((data.slug || data.title).toString(), { transform: config.filename_case }); data.layout = (data.layout || config.default_layout).toLowerCase(); data.date = data.date ? (0, moment_1.default)(data.date) : (0, moment_1.default)(); return bluebird_1.default.all([ // Get the post path ctx.execFilter('new_post_path', data, { args: [replace], context: ctx }), this._renderScaffold(data) ]).spread((path, content) => { const result = { path, content }; return bluebird_1.default.all([ // Write content to file (0, hexo_fs_1.writeFile)(path, content), // Create asset folder createAssetFolder(path, config.post_asset_folder) ]).then(() => { ctx.emit('new', result); return result; }); }).asCallback(callback); } _getScaffold(layout) { const ctx = this.context; return ctx.scaffold.get(layout).then(result => { if (result != null) return result; return ctx.scaffold.get('normal'); }); } _renderScaffold(data) { const { tag } = this.context.extend; let splitted; return this._getScaffold(data.layout).then(scaffold => { splitted = (0, hexo_front_matter_1.split)(scaffold); const jsonMode = splitted.separator.startsWith(';'); const frontMatter = prepareFrontMatter(Object.assign({}, data), jsonMode); return tag.render(splitted.data, frontMatter); }).then(frontMatter => { const { separator } = splitted; const jsonMode = separator.startsWith(';'); // Parse front-matter const obj = jsonMode ? JSON.parse(`{${frontMatter}}`) : (0, js_yaml_1.load)(frontMatter); Object.keys(data) .filter(key => !preservedKeys.includes(key) && obj[key] == null) .forEach(key => { obj[key] = data[key]; }); let content = ''; // Prepend the separator if (splitted.prefixSeparator) content += `${separator}\n`; content += (0, hexo_front_matter_1.stringify)(obj, { mode: jsonMode ? 'json' : '' }); // Concat content content += splitted.content; if (data.content) { content += `\n${data.content}`; } return content; }); } publish(data, replace, callback) { if (!callback && typeof replace === 'function') { callback = replace; replace = false; } if (data.layout === 'draft') data.layout = 'post'; const ctx = this.context; const { config } = ctx; const draftDir = (0, path_1.join)(ctx.source_dir, '_drafts'); const slug = (0, hexo_util_1.slugize)(data.slug.toString(), { transform: config.filename_case }); data.slug = slug; const regex = new RegExp(`^${(0, hexo_util_1.escapeRegExp)(slug)}(?:[^\\/\\\\]+)`); let src = ''; const result = {}; data.layout = (data.layout || config.default_layout).toLowerCase(); // Find the draft return (0, hexo_fs_1.listDir)(draftDir).then(list => { const item = list.find(item => regex.test(item)); if (!item) throw new Error(`Draft "${slug}" does not exist.`); // Read the content src = (0, path_1.join)(draftDir, item); return (0, hexo_fs_1.readFile)(src); }).then(content => { // Create post Object.assign(data, (0, hexo_front_matter_1.parse)(content)); data.content = data._content; data._content = undefined; return this.create(data, replace); }).then(post => { result.path = post.path; result.content = post.content; return (0, hexo_fs_1.unlink)(src); }).then(() => { if (!config.post_asset_folder) return; // Copy assets const assetSrc = removeExtname(src); const assetDest = removeExtname(result.path); return (0, hexo_fs_1.exists)(assetSrc).then(exist => { if (!exist) return; return (0, hexo_fs_1.copyDir)(assetSrc, assetDest).then(() => (0, hexo_fs_1.rmdir)(assetSrc)); }); }).thenReturn(result).asCallback(callback); } render(source, data = {}, callback) { const ctx = this.context; const { config } = ctx; const { tag } = ctx.extend; const ext = data.engine || (source ? (0, path_1.extname)(source) : ''); let promise; if (data.content != null) { promise = bluebird_1.default.resolve(data.content); } else if (source) { // Read content from files promise = (0, hexo_fs_1.readFile)(source); } else { return bluebird_1.default.reject(new Error('No input file or string!')).asCallback(callback); } // Files like js and css are also processed by this function, but they do not require preprocessing like markdown // data.source does not exist when tag plugins call the markdown renderer const isPost = !data.source || ['html', 'htm'].includes(ctx.render.getOutput(data.source)); if (!isPost) { return promise.then(content => { data.content = content; ctx.log.debug('Rendering file: %s', (0, picocolors_1.magenta)(source)); return ctx.render.render({ text: data.content, path: source, engine: data.engine, toString: true }); }).then(content => { data.content = content; return data; }).asCallback(callback); } // disable Nunjucks when the renderer specify that. let disableNunjucks = ext && ctx.render.renderer.get(ext) && !!ctx.render.renderer.get(ext).disableNunjucks; // front-matter overrides renderer's option if (typeof data.disableNunjucks === 'boolean') disableNunjucks = data.disableNunjucks; const cacheObj = new PostRenderEscape(); return promise.then(content => { data.content = content; // Run "before_post_render" filters return ctx.execFilter('before_post_render', data, { context: ctx }); }).then(() => { data.content = cacheObj.escapeCodeBlocks(data.content); // Escape all Nunjucks/Swig tags if (disableNunjucks === false) { data.content = cacheObj.escapeAllSwigTags(data.content); } const options = data.markdown || {}; if (!config.syntax_highlighter) options.highlight = null; ctx.log.debug('Rendering post: %s', (0, picocolors_1.magenta)(source)); // Render with markdown or other renderer return ctx.render.render({ text: data.content, path: source, engine: data.engine, toString: true, onRenderEnd(content) { // Replace cache data with real contents data.content = cacheObj.restoreAllSwigTags(content); // Return content after replace the placeholders if (disableNunjucks) return data.content; // Render with Nunjucks return tag.render(data.content, data); } }, options); }).then(content => { data.content = cacheObj.restoreCodeBlocks(content); // Run "after_post_render" filters return ctx.execFilter('after_post_render', data, { context: ctx }); }).asCallback(callback); }}module.exports = Post;//# sourceMappingURL=post.js.map Post 类包含了在 Hexo 框架中创建、发布和渲染文章的和新方法。 create(data, replace, callback):创建一篇新文章。 首先根据配置和传入的数据准备文章的 slug、布局和日期。 然后通过执行 new_post_path 过滤器获取文章路径,并调用 _renderScaffold 方法渲染文章内容。 最后将渲染后的内容写入文件,并根据配置创建相关的资源文件夹。 _getScaffold(layout) 和 _renderScaffold(data): _getScaffold 方法用于获取指定布局的脚手架。 _renderScaffold 方法用于渲染脚手架,并将传入的数据合并到模板中。 先解析 Front Matter,然后使用标签插件渲染 Front Matter。最后将渲染后的 Front Matter 和原始内容合并,返回最终的文章内容。 publish(data, replace, callback):将草稿文章发布为正式文章。 首先从 _drafts 目录查找对应的草稿文件,读取其内容。 然后将草稿内容与传入的数据合并,调用 create 方法创建正式文章。 最后删除原始的草稿文件,并根据配置复制相关的资源文件夹。 render(source, data, callback):渲染文章内容。 首先读取源文件或使用传入的内容。 然后执行 before_post_render 过滤器。 接着对代码块和 Swig / Nunjucks 标签进行转义,使用 Markdown 或其他渲染器渲染文章内容。 渲染完成后,将转义的内容还原,并执行 after_post_render 过滤器。 如果禁用了 Nunjucks,则返回渲染后的内容;否则使用标签插件渲染一次。 脚手架 脚手架文件是创建新文章或页面时使用的模板文件。 可见 Hexo 官方文档。 this.scaffold = new scaffold_1.default(this); "use strict";const path_1 = require("path");const hexo_fs_1 = require("hexo-fs");class Scaffold { constructor(context) { this.context = context; this.scaffoldDir = context.scaffold_dir; this.defaults = { normal: [ '---', 'layout: {{ layout }}', 'title: {{ title }}', 'date: {{ date }}', 'tags:', '---' ].join('\n') }; } _listDir() { const { scaffoldDir } = this; return (0, hexo_fs_1.exists)(scaffoldDir).then(exist => { if (!exist) return []; return (0, hexo_fs_1.listDir)(scaffoldDir, { ignorePattern: /^_|\/_/ }); }).map(item => ({ name: item.substring(0, item.length - (0, path_1.extname)(item).length), path: (0, path_1.join)(scaffoldDir, item) })); } _getScaffold(name) { return this._listDir().then(list => list.find(item => item.name === name)); } get(name, callback) { return this._getScaffold(name).then(item => { if (item) { return (0, hexo_fs_1.readFile)(item.path); } return this.defaults[name]; }).asCallback(callback); } set(name, content, callback) { const { scaffoldDir } = this; return this._getScaffold(name).then(item => { let path = item ? item.path : (0, path_1.join)(scaffoldDir, name); if (!(0, path_1.extname)(path)) path += '.md'; return (0, hexo_fs_1.writeFile)(path, content); }).asCallback(callback); } remove(name, callback) { return this._getScaffold(name).then(item => { if (!item) return; return (0, hexo_fs_1.unlink)(item.path); }).asCallback(callback); }}module.exports = Scaffold;//# sourceMappingURL=scaffold.js.map Scaffold 类在 Hexo 框架中用于管理 Scaffold 模板文件,也就是脚手架文件。脚手架是用于生成新文章或页面的默认模板。 constructor(context):初始化了 scaffoldDir 属性,指定脚手架文件所在的目录。它还定义了一个 defaults 对象,包含了默认的脚手架内容。 _listDir():用于列出 scaffoldDir 目录下的所有脚手架文件。它会忽略以下划线开头的文件或目录。 _getScaffold(name):根据给定的名称获取对应的脚手架文件信息。 先调用 _listDir() 方法获取所有脚手架文件,然后查找名称匹配的文件。 get(name, callback):用于获取指定名称的脚手架内容。 如果找到对应的文件,它会读取并返回文件内容。如果没有找到,它会返回 defaults 对象中对应的默认脚手架内容。 set(name, content, callback):用于设置或创建一个新的脚手架文件。 首先检查是否已经存在同名的文件,如果存在就使用原有路径,否则就在 scaffoldDir 目录下创建新文件(文件扩展名默认为 .md)。 remove(name, callback):用于删除一个脚手架文件。它会查找对应名称的文件,如果存在则将其删除。 数据库 this._dbLoaded = false;this._isGenerating = false;// If `output` is provided, use that as the// root for saving the db. Otherwise default to `base`.const dbPath = args.output || base;if (/^(init|new|g|publish|s|deploy|render|migrate)/.test(this.env.cmd)) { this.log.d(`Writing database to ${(0, path_1.join)(dbPath, 'db.json')}`);} 初始化两个内部状态标志,用于跟踪数据库是否已经加载(_dbLoaded)和网站是否正在生成(_isGenerating)。 确定数据库文件将要被保存在的路径。如果执行 Hexo 命令时提供了 output 参数,那么数据库就会被保存在该输出路径下;不然就保存在项目根目录下。 接着检查当前执行的 Hexo 命令是否属于 init、new、g、publish、deploy、render 或者 migrate。如果是,就打印说数据库文件被写入到哪里去了。 this.database = new warehouse_1.default({ version: dbVersion, path: (0, path_1.join)(dbPath, 'db.json')}); Hexo 初始化和配置数据库,实例化过程中传入了一个配置对象: version:数据库的版本号。在 index.js 的最上方能找到: const dbVersion = 1; path:数据库文件的保存路径,由先前定义的 dbPath 和文件名 db.json 拼接而成。 此处实例化用到了 warehouse 库。这是一个轻量级的数据存储库,主要用于 Node.JS 应用程序中处理 JSON 数据。它提供了一种简单的方式来定义、存储和检索模型数据,类似于一个迷你数据库或 ORM(对象关系映射)。 多配置文件路径管理 const mcp = (0, multi_config_path_1.default)(this); "use strict";var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod };};const path_1 = require("path");const hexo_fs_1 = require("hexo-fs");const js_yaml_1 = __importDefault(require("js-yaml"));const hexo_util_1 = require("hexo-util");module.exports = (ctx) => function multiConfigPath(base, configPaths, outputDir) { const { log } = ctx; const defaultPath = (0, path_1.join)(base, '_config.yml'); if (!configPaths) { log.w('No config file entered.'); return (0, path_1.join)(base, '_config.yml'); } let paths; // determine if comma or space separated if (configPaths.includes(',')) { paths = configPaths.replace(' ', '').split(','); } else { // only one config let configPath = (0, path_1.isAbsolute)(configPaths) ? configPaths : (0, path_1.resolve)(base, configPaths); if (!(0, hexo_fs_1.existsSync)(configPath)) { log.w(`Config file ${configPaths} not found, using default.`); configPath = defaultPath; } return configPath; } const numPaths = paths.length; // combine files let combinedConfig = {}; let count = 0; for (let i = 0; i < numPaths; i++) { const configPath = (0, path_1.isAbsolute)(paths[i]) ? paths[i] : (0, path_1.join)(base, paths[i]); if (!(0, hexo_fs_1.existsSync)(configPath)) { log.w(`Config file ${paths[i]} not found.`); continue; } // files read synchronously to ensure proper overwrite order const file = (0, hexo_fs_1.readFileSync)(configPath); const ext = (0, path_1.extname)(paths[i]).toLowerCase(); if (ext === '.yml') { combinedConfig = (0, hexo_util_1.deepMerge)(combinedConfig, js_yaml_1.default.load(file)); count++; } else if (ext === '.json') { combinedConfig = (0, hexo_util_1.deepMerge)(combinedConfig, js_yaml_1.default.load(file, { json: true })); count++; } else { log.w(`Config file ${paths[i]} not supported type.`); } } if (count === 0) { log.e('No config files found. Using _config.yml.'); return defaultPath; } log.i('Config based on', count.toString(), 'files'); const multiconfigRoot = outputDir || base; const outputPath = (0, path_1.join)(multiconfigRoot, '_multiconfig.yml'); log.d(`Writing _multiconfig.yml to ${outputPath}`); (0, hexo_fs_1.writeFileSync)(outputPath, js_yaml_1.default.dump(combinedConfig)); // write file and return path return outputPath;};//# sourceMappingURL=multi_config_path.js.map multiConfigPath 函数用于处理 Hexo 配置文件。该函数会根据传入的配置文件路径来合并多个配置文件并生成一个新的配置文件 _multiconfig.yml。 顺带一个读取配置文件路径的参数: this.config_path = args.config ? mcp(base, args.config, args.output) : (0, path_1.join)(base, '_config.yml'); 根据用户的命令行输入和 Hexo 项目的配置,决定要读取的配置文件的路径。 注册模型 (0, register_models_1.default)(this); "use strict";var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc);}) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k];}));var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v });}) : function(o, v) { o["default"] = v;});var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result;};const models = __importStar(require("../models"));module.exports = (ctx) => { const db = ctx.database; const keys = Object.keys(models); for (let i = 0, len = keys.length; i < len; i++) { const key = keys[i]; db.model(key, models[key](ctx)); }};//# sourceMappingURL=register_models.js.map 上三个函数是 TypeScript 编译器在编译时生成的,用于处理 ES 模块的导入和默认导出行为。它们确保在使用 CommonJS 模块时,能正确处理 ES 模块的导入。 接下来导入的 models 模块是 hexo/dist/models 目录,这里使用了 __importStar 函数导入 models 模块中的所有导出,作为 models 对象的属性。 models 模块内容很多,后续会仔细讲解。 register_models 模块导出一个函数,该函数接收一个 ctx 参数,并从 ctx 对象中获取数据库实例 db、获取 models 对象的所有键,遍历这些键,为每个模型在数据库中注册一个模型。 主要功能是导入所有的模型,并将这些模型注册到数据库中。 源文件管理 this.source = new source_1.default(this); "use strict";var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod };};const box_1 = __importDefault(require("../box"));class Source extends box_1.default { constructor(ctx) { super(ctx, ctx.source_dir); this.processors = ctx.extend.processor.list(); }}module.exports = Source;//# sourceMappingURL=source.js.map Source 类继承自 Box 类,所以先看一眼 Box 类: "use strict";var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); });};var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod };};Object.defineProperty(exports, "__esModule", { value: true });const path_1 = require("path");const bluebird_1 = __importDefault(require("bluebird"));const file_1 = __importDefault(require("./file"));const hexo_util_1 = require("hexo-util");const hexo_fs_1 = require("hexo-fs");const picocolors_1 = require("picocolors");const events_1 = require("events");const micromatch_1 = require("micromatch");const defaultPattern = new hexo_util_1.Pattern(() => ({}));class Box extends events_1.EventEmitter { constructor(ctx, base, options) { super(); this.options = Object.assign({ persistent: true, awaitWriteFinish: { stabilityThreshold: 200 } }, options); if (!base.endsWith(path_1.sep)) { base += path_1.sep; } this.context = ctx; this.base = base; this.processors = []; this._processingFiles = {}; this.watcher = null; this.Cache = ctx.model('Cache'); this.File = this._createFileClass(); let targets = this.options.ignored || []; if (ctx.config.ignore && ctx.config.ignore.length) { targets = targets.concat(ctx.config.ignore); } this.ignore = targets; this.options.ignored = targets.map(s => toRegExp(ctx, s)).filter(x => x); } _createFileClass() { const ctx = this.context; class _File extends file_1.default { render(options) { return ctx.render.render({ path: this.source }, options); } renderSync(options) { return ctx.render.renderSync({ path: this.source }, options); } } _File.prototype.box = this; return _File; } addProcessor(pattern, fn) { if (!fn && typeof pattern === 'function') { fn = pattern; pattern = defaultPattern; } if (typeof fn !== 'function') throw new TypeError('fn must be a function'); if (!(pattern instanceof hexo_util_1.Pattern)) pattern = new hexo_util_1.Pattern(pattern); this.processors.push({ pattern, process: fn }); } _readDir(base, prefix = '') { const { context: ctx } = this; const results = []; return readDirWalker(ctx, base, results, this.ignore, prefix) .return(results) .map(path => this._checkFileStatus(path)) .map(file => this._processFile(file.type, file.path).return(file.path)); } _checkFileStatus(path) { const { Cache, context: ctx } = this; const src = (0, path_1.join)(this.base, path); return Cache.compareFile(src.substring(ctx.base_dir.length), () => getHash(src), () => (0, hexo_fs_1.stat)(src)).then(result => ({ type: result.type, path })); } process(callback) { const { base, Cache, context: ctx } = this; return (0, hexo_fs_1.stat)(base).then(stats => { if (!stats.isDirectory()) return; // Check existing files in cache const relativeBase = base.substring(ctx.base_dir.length); const cacheFiles = Cache.filter(item => item._id.startsWith(relativeBase)).map(item => item._id.substring(relativeBase.length)); // Handle deleted files return this._readDir(base) .then((files) => cacheFiles.filter((path) => !files.includes(path))) .map((path) => this._processFile(file_1.default.TYPE_DELETE, path)); }).catch(err => { if (err && err.code !== 'ENOENT') throw err; }).asCallback(callback); } _processFile(type, path) { if (this._processingFiles[path]) { return bluebird_1.default.resolve(); } this._processingFiles[path] = true; const { base, File, context: ctx } = this; this.emit('processBefore', { type, path }); return bluebird_1.default.reduce(this.processors, (count, processor) => { // patten supports *nix style path only, replace backslashes on Windows const params = processor.pattern.match(escapeBackslash(path)); if (!params) return count; const file = new File({ // source is used for file system path, keep backslashes on Windows source: (0, path_1.join)(base, path), // path is used for URL path, replace backslashes on Windows path: escapeBackslash(path), params, type }); return Reflect.apply(bluebird_1.default.method(processor.process), ctx, [file]) .thenReturn(count + 1); }, 0).then(count => { if (count) { ctx.log.debug('Processed: %s', (0, picocolors_1.magenta)(path)); } this.emit('processAfter', { type, path }); }).catch(err => { ctx.log.error({ err }, 'Process failed: %s', (0, picocolors_1.magenta)(path)); }).finally(() => { this._processingFiles[path] = false; }).thenReturn(path); } watch(callback) { if (this.isWatching()) { return bluebird_1.default.reject(new Error('Watcher has already started.')).asCallback(callback); } const { base } = this; function getPath(path) { return path.substring(base.length); } return this.process().then(() => (0, hexo_fs_1.watch)(base, this.options)).then(watcher => { this.watcher = watcher; watcher.on('add', path => { this._processFile(file_1.default.TYPE_CREATE, getPath(path)); }); watcher.on('change', path => { this._processFile(file_1.default.TYPE_UPDATE, getPath(path)); }); watcher.on('unlink', path => { this._processFile(file_1.default.TYPE_DELETE, getPath(path)); }); watcher.on('addDir', path => { let prefix = getPath(path); if (prefix) prefix += path_1.sep; this._readDir(path, prefix); }); }).asCallback(callback); } unwatch() { if (!this.isWatching()) return; this.watcher.close(); this.watcher = null; } isWatching() { return Boolean(this.watcher); }}function escapeBackslash(path) { // Replace backslashes on Windows return path.replace(/\\/g, '/');}function getHash(path) { const src = (0, hexo_fs_1.createReadStream)(path); const hasher = (0, hexo_util_1.createSha1Hash)(); const finishedPromise = new bluebird_1.default((resolve, reject) => { src.once('error', reject); src.once('end', resolve); }); src.on('data', chunk => { hasher.update(chunk); }); return finishedPromise.then(() => hasher.digest('hex'));}function toRegExp(ctx, arg) { if (!arg) return null; if (typeof arg !== 'string') { ctx.log.warn('A value of "ignore:" section in "_config.yml" is not invalid (not a string)'); return null; } const result = (0, micromatch_1.makeRe)(arg); if (!result) { ctx.log.warn('A value of "ignore:" section in "_config.yml" can not be converted to RegExp:' + arg); return null; } return result;}function isIgnoreMatch(path, ignore) { return path && ignore && ignore.length && (0, micromatch_1.isMatch)(path, ignore);}function readDirWalker(ctx, base, results, ignore, prefix) { if (isIgnoreMatch(base, ignore)) return bluebird_1.default.resolve(); return bluebird_1.default.map((0, hexo_fs_1.readdir)(base).catch(err => { ctx.log.error({ err }, 'Failed to read directory: %s', base); if (err && err.code === 'ENOENT') return []; throw err; }), (path) => __awaiter(this, void 0, void 0, function* () { const fullpath = (0, path_1.join)(base, path); const stats = yield (0, hexo_fs_1.stat)(fullpath).catch(err => { ctx.log.error({ err }, 'Failed to stat file: %s', fullpath); if (err && err.code === 'ENOENT') return null; throw err; }); const prefixPath = `${prefix}${path}`; if (stats) { if (stats.isDirectory()) { return readDirWalker(ctx, fullpath, results, ignore, prefixPath + path_1.sep); } if (!isIgnoreMatch(fullpath, ignore)) { results.push(prefixPath); } } }));}exports.default = Box;//# sourceMappingURL=index.js.map 部分辅助函数: __awaiter:用于处理异步函数和生成器函数,使得可以使用 async/await 语法。 __importDefault:用于处理默认导入。 escapeBackslash:将路径中的反斜杠替换为斜杠。 getHash:生成文件的 SHA-1 哈希值。 toRegExp:将字符串转换为正则表达式。 isIgnoreMatch:检查路径是否与忽略模式匹配。 readDirWalker:递归读取目录,并收集所有符合条件的文件路径。 box/index.js 也导入了一个 file.js 文件,该文件定义了一个 File 类,用来表示文件对象,并提供了一些方法来读取和获取文件状态: "use strict";const hexo_fs_1 = require("hexo-fs");class File { constructor({ source, path, params, type }) { this.source = source; this.path = path; this.params = params; this.type = type; } read(options) { return (0, hexo_fs_1.readFile)(this.source, options); } readSync(options) { return (0, hexo_fs_1.readFileSync)(this.source, options); } stat() { return (0, hexo_fs_1.stat)(this.source); } statSync() { return (0, hexo_fs_1.statSync)(this.source); }}File.TYPE_CREATE = 'create';File.TYPE_UPDATE = 'update';File.TYPE_SKIP = 'skip';File.TYPE_DELETE = 'delete';module.exports = File;//# sourceMappingURL=file.js.map read(options):异步读取文件内容,返回一个 Promise。 readSync(options):同步读取文件内容,返回文件内容。 stat():异步获取文件状态,返回一个 Promise。 statSync():同步获取文件状态,返回文件状态。 文件类型常量:明确表示文件的操作类型。 Box 类也是继承自 EventEmitter。 主要的属性和方法: 构造函数:初始化 Box 类的实例,设置: options:默认选项和用户传入的选项合并。 context:上下文对象。 base:基础路径。 processors:处理器列表。 _processingFiles:正在处理的文件。 watcher:文件监视器。 Cache:缓存模型。 File:文件类。 ignore:忽略模式。 _createFileClass:创建一个文件类,用于处理文件的渲染操作。 addProcessor:添加一个处理器,用于处理特定模式的文件。 _readDir:读取目录,并递归处理所有文件。 _checkFileStatus:检查文件状态,比较缓存文件和实际文件。 process:处理目录中的所有文件。 _processFile:处理单个文件,根据文件类型执行处理器。 watch:监视目录中的文件变化,并在文件添加、修改或删除时触发相应的处理。 unwatch:停止监视目录。 isWatching:检查是否正在监视目录。 Box 类的更多详情可见 Hexo 官方文档。 现在再来看 Source 类。它继承自 Box 类,拥有着 Box 类的所有属性和方法。Box 类提供了文件读取、监视、处理等能力。而 Source 类在此基础上,专注于处理特定于源文件的逻辑。 主题 this.theme = new theme_1.default(this); Theme 类被定义在 hexo/dist/theme 中,未来会详细说。目前只讲 index.js 的大致逻辑。 Theme 类是 Hexo 中负责管理主题的核心模块。 可见 Hexo 官方文档。 "use strict";var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod };};const path_1 = require("path");const box_1 = __importDefault(require("../box"));const view_1 = __importDefault(require("./view"));const hexo_i18n_1 = __importDefault(require("hexo-i18n"));const config_1 = require("./processors/config");const i18n_1 = require("./processors/i18n");const source_1 = require("./processors/source");const view_2 = require("./processors/view");class Theme extends box_1.default { constructor(ctx, options) { super(ctx, ctx.theme_dir, options); this.config = {}; this.views = {}; this.processors = [ config_1.config, i18n_1.i18n, source_1.source, view_2.view ]; let languages = ctx.config.language; if (!Array.isArray(languages)) languages = [languages]; languages.push('default'); this.i18n = new hexo_i18n_1.default({ languages: [...new Set(languages.filter(Boolean))] }); class _View extends view_1.default { } this.View = _View; _View.prototype._theme = this; _View.prototype._render = ctx.render; _View.prototype._helper = ctx.extend.helper; } getView(path) { // Replace backslashes on Windows path = path.replace(/\\/g, '/'); const ext = (0, path_1.extname)(path); const name = path.substring(0, path.length - ext.length); const views = this.views[name]; if (!views) return; if (ext) { return views[ext]; } return views[Object.keys(views)[0]]; } setView(path, data) { const ext = (0, path_1.extname)(path); const name = path.substring(0, path.length - ext.length); this.views[name] = this.views[name] || {}; const views = this.views[name]; views[ext] = new this.View(path, data); } removeView(path) { const ext = (0, path_1.extname)(path); const name = path.substring(0, path.length - ext.length); const views = this.views[name]; if (!views) return; views[ext] = undefined; }}module.exports = Theme;//# sourceMappingURL=index.js.map Theme 类同样继承自 Box 类: constructor(ctx, options):构造函数接受上下文对象 ctx 和选项 options,调用父类 box_1.default 的构造函数。 初始化 config 和 views 属性为空对象。 processors:设置处理器数组,包括配置、国际化、源文件和视图处理器。 初始化多语言支持,确保 languages 是数组,并添加 default 语言。使用 hexo-i18n 库创建 i18n 对象。 定义一个新的 _View 类,继承自 view_1.default,并在其原型上添加 _theme、_render 和 _helper 属性。 撇开本地文件导入,这里的 hexo-i18n 是 Hexo 自己的 i18n 模块。 i18n 的全程是 Internationalization(国际化),是指在设计软件、将软件与特定语言及地区脱钩的过程。由于英文单字长度过长,所以常被简称为 i18n(18 意味着在 Internationalization 这个单字中,i 和 n 之间有 18 个字母。 Theme 类也有以下方法: getView(path):根据给定路径获取视图。 setView(path, data) 设置指定路径的 View 内容。 removeView(path) 移除指定路径的 View。 通过实例化 Theme 类,Hexo 可以获得一个用于操作当前主题的对象,从而正确地渲染和生成静态页面。 视图(View)在 Hexo 中是用于渲染页面内容的模板文件。通常使用模板引擎语法编写,如 Swig、Pug 等,允许在模板中嵌入动态数据和逻辑。 视图主要有以下几种类型: 布局(Layout):定义了页面的基本结构,如 HTML 头部、导航栏、页脚等通用部分。所有其他视图都将被渲染到布局视图中的特定位置。 包含(Partial):可重用的模板片段,通常用于渲染页面的某一部分,如文章列表、评论区等。它们可以在其他视图中被引入和渲染。 页面(Page):用于渲染特定的页面内容,如文章、分类、标签等。它们通常会引入布局视图和其他所需的包含视图。 助手(Helper):一些辅助函数,用于在模板中执行特定的逻辑或操作,如格式化日期、生成链接等。 本地数据 this.locals = new locals_1.default(); Locals 类用于管理和缓存局部变量: "use strict";const hexo_util_1 = require("hexo-util");class Locals { constructor() { this.cache = new hexo_util_1.Cache(); this.getters = {}; } get(name) { if (typeof name !== 'string') throw new TypeError('name must be a string!'); return this.cache.apply(name, () => { const getter = this.getters[name]; if (!getter) return; return getter(); }); } set(name, value) { if (typeof name !== 'string') throw new TypeError('name must be a string!'); if (value == null) throw new TypeError('value is required!'); const getter = typeof value === 'function' ? value : () => value; this.getters[name] = getter; this.cache.del(name); return this; } remove(name) { if (typeof name !== 'string') throw new TypeError('name must be a string!'); this.getters[name] = null; this.cache.del(name); return this; } invalidate() { this.cache.flush(); return this; } toObject() { const result = {}; const keys = Object.keys(this.getters); for (let i = 0, len = keys.length; i < len; i++) { const key = keys[i]; const item = this.get(key); if (item != null) result[key] = item; } return result; }}module.exports = Locals;//# sourceMappingURL=locals.js.map Locals 类有两个属性: cache:使用 hexo-util 模块的 Cache 类实例化缓存对象。 getters:初始化一个空对象,用于存储 getter 函数。 Locals 类也有以下方法: get(name) 获取指定名称的本地数据。 set(name, value) 设置指定名称的本地数据。 remove(name) 移除指定名称的本地数据。 invalidate() 清空缓存。 toObject() 将本地数据转换为普通对象。 Locals 类是 Hexo 中用于管理本地数据的模块。本地数据在 Hexo 插件开发中意外重要,后续会详细讲解。 暂且,请看 Hexo 官方文档。 本地数据绑定 构造函数的最后一行是用来绑定本地数据的: this._bindLocals(); _bindLocals() { const db = this.database; const { locals } = this; locals.set('posts', () => { const query = {}; if (!this.config.future) { query.date = { $lte: Date.now() }; } if (!this._showDrafts()) { query.published = true; } return db.model('Post').find(query); }); locals.set('pages', () => { const query = {}; if (!this.config.future) { query.date = { $lte: Date.now() }; } return db.model('Page').find(query); }); locals.set('categories', () => { // Ignore categories with zero posts return db.model('Category').filter(category => category.length); }); locals.set('tags', () => { // Ignore tags with zero posts return db.model('Tag').filter(tag => tag.length); }); locals.set('data', () => { const obj = {}; db.model('Data').forEach(data => { obj[data._id] = data.data; }); return obj; });} 首先获取 Hexo 实例的数据库对象 this.database,接着使用对象解构赋值的语法从 this 对象中提取 locals 属性的值。this 指向的是当前执行上下文中的对象实例,在这里,this 是 Hexo 实例本身。 const { locals } = this; 相当于这行代码: const locals = this.locals; 通过调用 locals.set() 方法,为不同的本地数据设置了获取器函数。当其他地方访问这些本地数据时,相应的获取器函数将被执行,从数据库中查询并返回所需的数据。 具体来说,这个方法设置了以下几个本地数据及其获取器: posts:获取所有已发布且不是未来日期的文章。如果 config.future 设置为 true,则也包括未来日期的文章。如果 _showDrafts() 返回 true,则也包括草稿文章。 pages:获取所有已发布且不是未来日期的页面。如果 config.future 设置为 true,则也包括未来日期的页面。 categories:获取所有包含文章的分类。 tags:获取所有包含文章的标签。 data:获取所有自定义数据。 初始化 init() { this.log.debug('Hexo version: %s', (0, picocolors_1.magenta)(this.version)); this.log.debug('Working directory: %s', (0, picocolors_1.magenta)((0, tildify_1.default)(this.base_dir))); // Load internal plugins require('../plugins/console')(this); require('../plugins/filter')(this); require('../plugins/generator')(this); require('../plugins/helper')(this); require('../plugins/highlight')(this); require('../plugins/injector')(this); require('../plugins/processor')(this); require('../plugins/renderer')(this); require('../plugins/tag').default(this); // Load config return bluebird_1.default.each([ 'update_package', // Update package.json 'load_config', // Load config 'load_theme_config', // Load alternate theme config 'load_plugins' // Load external plugins & scripts ], name => require(`./${name}`)(this)).then(() => this.execFilter('after_init', null, { context: this })).then(() => { // Ready to go! this.emit('ready'); });} init 方法负责进行初始化工作: 打印 Hexo 版本和工作目录信息到控制台,使用 picocolors 库为版本和目录路径上色。 加载内部插件。 执行一系列初始化步骤后,使用了 bluebird.each 方法按顺序执行以下步骤: update_package:更新 package.json 文件。 load_config:加载配置文件。 load_theme_config:加载主题配置文件。 load_plugins:加载外部插件和脚本。 在所有初始化步骤完成后,执行 after_init 过滤器,允许插件和主题在初始化后进行一些额外的操作。 发射 ready 事件,表示 Hexo 已经准备就绪,可以执行其他操作了。 调用控制台命令 call(name, args, callback) { if (!callback && typeof args === 'function') { callback = args; args = {}; } const c = this.extend.console.get(name); if (c) return Reflect.apply(c, this, [args]).asCallback(callback); return bluebird_1.default.reject(new Error(`Console \`${name}\` has not been registered yet!`));} 这段代码定义了 call 方法,用于调用已注册的控制台命令。 先是检查了是否传入回调函数。如果没有,但是第二个参数是一个函数的话,就将第二个参数视为回调函数,同时将 args 设置为一个空对象。这是为了兼容只传入回调函数的情况。 从 this.extend.console 对象中获取名为 name 的控制台命令。如果找到了对应的控制台命令对象 c,便执行以下步骤: 使用 Reflect 对象的 apply 方法调用控制台命令 c。 .asCallback(callback) 将上一步的调用结果转换为一个 Promise 对象,并将该 Promise 与回调函数 callback 关联。这样就可以在 Promise 完成时自动调用回调函数。 如果未能找到对应的控制台命令对象,就返回一个被拒绝的 Promise,其中包含一个表示该命令尚未注册的错误消息。 定义数据库模型 model(name, schema) { return this.database.model(name, schema);} 回到 数据库 的 Database 类,能看出 model 方法是用来创建一个模型的。 解析插件 resolvePlugin(name, basedir) { try { // Try to resolve the plugin with the Node.js's built-in require.resolve. return require.resolve(name, { paths: [basedir] }); } catch (err) { try { // There was an error (likely the node_modules is corrupt or from early version of npm) // Use Hexo prior 6.0.0's behavior (resolve.sync) to resolve the plugin. resolveSync = resolveSync || require('resolve').sync; return resolveSync(name, { basedir }); } catch (err) { // There was an error (likely the plugin wasn't found), so return a possibly // non-existing path that a later part of the resolution process will check. return (0, path_1.join)(basedir, 'node_modules', name); } }} 先是使用 require.resolve 方法用于解析模块。如果解析失败了,就继续尝试使用 resolve.sync 方法。最后如果依然失败,则返回一个可能不存在的路径以供后续处理。 require.resolve 方法是 Node.JS 内置的,用于解析模块的路径。 如果 require.resolve 失败,通常是由于 node_modules 目录损坏或其他原因。 require('resolve').sync 方法是一个更兼容旧版本 npm 的解决方案,类似于早期 Hexo 版本的插件解析方式。resolve.sync 方法如果也失败,很有可能是因为插件未找到,这时候便返回一个大概并不存在的路径。 加载插件 loadPlugin(path, callback) { return (0, hexo_fs_1.readFile)(path).then(script => { // Based on: https://github.com/nodejs/node-v0.x-archive/blob/v0.10.33/src/node.js#L516 const module = new module_1.default(path); module.filename = path; module.paths = module_1.default._nodeModulePaths(path); function req(path) { return module.require(path); } req.resolve = (request) => module_1.default._resolveFilename(request, module); req.main = require.main; req.extensions = module_1.default._extensions; req.cache = module_1.default._cache; script = `(async function(exports, require, module, __filename, __dirname, hexo){${script}\n});`; const fn = (0, vm_1.runInThisContext)(script, path); return fn(module.exports, req, module, path, (0, path_1.dirname)(path), this); }).asCallback(callback);} 使用 hexo-fs 库的 readFile 异步读取插件文件的内容,返回一个 Promise 对象。 hexo-fs 库的源代码未来再说,暂且只需要知道是 Hexo 的文件系统模块。 接着创建模块环境,并设置 filename 和 paths 属性。这里的 module_1 是 module 模块,定义了多种与模块加载、解析和处理相关的类型和接口。filename 属性被赋值插件的路径 path;paths 被赋值模块的搜索路径。 req 方法用于模块加载。这个方法实际上模拟了 require 函数。之后这段代码分别定义了 req.resolve 方法来解析模块的绝对路径,设置 req.main 属性来指向 Node.JS 主模块、req.extensions 属性来处理模块文件的加载、req.cache 属性来缓存加载的模块。 插件的内容被包装在一个异步函数中,并传入了必要的参数、赋值给变量 script。 使用 vm_1.runInThisContext 方法将包装后的插件内容编译为可执行函数。 vm_1 是 vm 模块,提供了一组 API 用于在 JavaScript 中创建虚拟机和运行沙盒化的代码。 执行编译后的函数,传入需要的参数。 最终将 Promise 对象转换为回调函数形式,并进行执行。 显示草稿内容 _showDrafts() { const { args } = this.env; return args.draft || args.drafts || this.config.render_drafts;} 根据用户的输入和 Hexo 的配置,确定是否应该显示草稿内容。 加载 load(callback) { return (0, load_database_1.default)(this).then(() => { this.log.info('Start processing'); return bluebird_1.default.all([ this.source.process(), this.theme.process() ]); }).then(() => { mergeCtxThemeConfig(this); return this._generate({ cache: false }); }).asCallback(callback);} load_database 模块检查数据库文件是否存在,存在则尝试加载数据库。如果加载失败,则删除数据库文件。它使用了 hexo-fs 模块来处理文件系统操作,并使用 bluebird 提供的 Promise 来管理异步操作。通过 ctx 上下文对象来传递数据库和日志对象,以及跟踪数据库加载状态。 "use strict";var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod };};const hexo_fs_1 = require("hexo-fs");const bluebird_1 = __importDefault(require("bluebird"));module.exports = (ctx) => { if (ctx._dbLoaded) return bluebird_1.default.resolve(); const db = ctx.database; const { path } = db.options; const { log } = ctx; return (0, hexo_fs_1.exists)(path).then(exist => { if (!exist) return; log.debug('Loading database.'); return db.load(); }).then(() => { ctx._dbLoaded = true; }).catch(() => { log.error('Database load failed. Deleting database.'); return (0, hexo_fs_1.unlink)(path); });};//# sourceMappingURL=load_database.js.map 在数据库加载完成后,使用 then 方法处理返回的 Promise。在这个处理函数中,首先记录了日志信息 Start processing,然后使用 bluebird_1.default.all 方法并行处理两个任务: this.source.process() 处理源文件。 this.theme.process() 处理主题。 处理完源文件和主题后,再次使用了 then 方法处理返回的 Promise。调用 mergeCtxThemeConfig 函数,将 Hexo 实例的上下文与主题配置合并,然后调用了 this._generate({ cache: false }) 方法,生成静态文件。 mergeCtxThemeConfig 函数在 index.js 文件的很上方: const mergeCtxThemeConfig = (ctx) => { if (ctx.config.theme_config) { ctx.theme.config = (0, hexo_util_1.deepMerge)(ctx.theme.config, ctx.config.theme_config); }}; 该函数用于将 ctx.config.theme_config(主题配置)合并到 ctx.theme.config(博客目录)中。这里还使用了 hexo-util 库的深度合并。hexo-util 库也是未来会讲的。 _generate(options = {}) { if (this._isGenerating) return; const useCache = options.cache; this._isGenerating = true; this.emit('generateBefore'); // Run before_generate filters return this.execFilter('before_generate', null, { context: this }) .then(() => this._routerRefresh(this._runGenerators(), useCache)).then(() => { this.emit('generateAfter'); // Run after_generate filters return this.execFilter('after_generate', null, { context: this }); }).finally(() => { this._isGenerating = false; });} 为了防止重复生成,_generate 方法先是判断了 _isGenerating 变量,再进行接下来的操作。从 options 参数中提取 cache 选项,是为了决定是否在生成过程中使用缓存。 设置 this._isGenerating 为 true 后,便开始生成。generateBefore 事件被触发,接着执行 before_generate 过滤器。 过滤器(Filter)后续会讲解。目前先延申 Hexo 官方文档 中的说明: 过滤器用于修改特定文件,Hexo 将这些文件依序传给过滤器,而过滤器可以针对文件进行修改。 其中的 execFilter 方法在 Hexo 类的最下方被定义: execFilter(type, data, options) { return this.extend.filter.exec(type, data, options);} Hexo 类也有一个 execFilterSync 方法: execFilterSync(type, data, options) { return this.extend.filter.execSync(type, data, options);} before_generate 过滤器执行完毕后,调用 _routerRefresh 方法: _routerRefresh(runningGenerators, useCache) { const { route } = this; const routeList = route.list(); const Locals = this._generateLocals(); Locals.prototype.cache = useCache; return runningGenerators.map((generatorResult) => { if (typeof generatorResult !== 'object' || generatorResult.path == null) return undefined; const path = route.format(generatorResult.path); const { data, layout } = generatorResult; if (!layout) { route.set(path, data); return path; } return this.execFilter('template_locals', new Locals(path, data), { context: this }) .then(locals => { route.set(path, createLoadThemeRoute(generatorResult, locals, this)); }) .thenReturn(path); }).then(newRouteList => { for (let i = 0, len = routeList.length; i < len; i++) { const item = routeList[i]; if (!newRouteList.includes(item)) { route.remove(item); } } });} 初始化和获取路由列表:从 this 中获取 route 对象,并调用其 list 方法获取当前路由列表;生成局部变量 Locals,并将 useCache 选项赋给 Locals.prototype.cache,决定是否使用缓存。 处理生成器结果:遍历 runningGenerators 数组,对于每个生成器结果进行处理。如果 generatorResult 不是对象或 path 为空,则返回 undefined。 格式化路径并处理数据:调用 route.format 方法格式化路径;解构 generatorResult,获取 data 和 layout。如果 layout 不存在,直接将 path 和 data 设置到路由中,并返回 path。 执行 template_locals 过滤器并设置路由:调用 execFilter 方法执行 template_locals 过滤器,传入新的 Locals 实例。在过滤器执行完毕后,将路径和创建的主题路由设置到路由中,并返回 path。 const createLoadThemeRoute = function (generatorResult, locals, ctx) { const { log, theme } = ctx; const { path, cache: useCache } = locals; const layout = [...new Set(castArray(generatorResult.layout))]; const layoutLength = layout.length; locals.cache = true; return () => { if (useCache && routeCache.has(generatorResult)) return routeCache.get(generatorResult); for (let i = 0; i < layoutLength; i++) { const name = layout[i]; const view = theme.getView(name); if (view) { log.debug(`Rendering HTML ${name}: ${(0, picocolors_1.magenta)(path)}`); return view.render(locals) .then(result => ctx.extend.injector.exec(result, locals)) .then(result => ctx.execFilter('_after_html_render', result, { context: ctx, args: [locals] })) .tap(result => { if (useCache) { routeCache.set(generatorResult, result); } }).tapCatch(err => { log.error({ err }, `Render HTML failed: ${(0, picocolors_1.magenta)(path)}`); }); } } log.warn(`No layout: ${(0, picocolors_1.magenta)(path)}`); };}; createLoadThemeRoute 方法创建加载主题路由的闭包。 首先从 ctx 中获取日志记录器 log 和主题对象 theme、从 locals 中获取路由的路径 path 和缓存标志 useCache。然后将 generatorResult.layout 转换为一个唯一的布局数组,记录布局的长度,同时将 locals.cache 设置为 true,以确保视图在渲染期间使用缓存。 返回一个闭包。闭包指的是函数和函数内部引用的外部变量的组合。该闭包在调用时会渲染 HTML 视图。 如果 useCache 为 true,并且缓存中存在 generatorResult,直接返回缓存的结果。否则遍历布局数组,并尝试从主题中获取对应的视图。 如果找到了视图,就使用该视图渲染 locals,并执行注入器和过滤器,最后返回渲染结果。否则记录警告并返回。 注入器(Injector)是 Hexo 扩展之一,在 Hexo 官方文档 中被声明为 用于将静态代码片段注入生成的 HTML 的 <head> 和 / 或 <body> 中,且注入必须在 after_render:html 过滤器之前完成。 移除旧路由:获取新生成的 newRouteList;遍历旧路由列表 routeList,如果某个路由不在 newRouteList 中,则将其移除。 _routerRefresh 方法确保了生成器结果正确应用到路由中,同时清理不再需要的旧路由,保持路由的最新状态。 路由刷新完成后,_generate 方法触发了 generateAfter 事件,并执行 after_generate 过滤器。 最后使用 finally 方法确保无论生成过程成功与否,都会将 this._isGenerating 重置为 false,允许后续生成操作进行。 通过执行前后过滤器和触发事件,_generate 方法确保生成过程的各个阶段都可以被插件或自定义代码扩展和修改。 load 方法在调用 _generate、生成静态文件后,将整个异步操作的结果传递给回调函数 callback。asCallback 方法将 Promise 转换为传统的回调形式,以兼容老式回调风格的代码。 监视文件变化 watch 函数的目的是在监视文件变化时重新生成内容,并在需要时启用缓存。 watch(callback) { let useCache = false; const { cache } = Object.assign({ cache: false }, this.config.server); const { alias } = this.extend.console; if (alias[this.env.cmd] === 'server' && cache) { // enable cache when run hexo server useCache = true; } this._watchBox = debounce(() => this._generate({ cache: useCache }), 100); return (0, load_database_1.default)(this).then(() => { this.log.info('Start processing'); return bluebird_1.default.all([ this.source.watch(), this.theme.watch() ]); }).then(() => { mergeCtxThemeConfig(this); this.source.on('processAfter', this._watchBox); this.theme.on('processAfter', () => { this._watchBox(); mergeCtxThemeConfig(this); }); return this._generate({ cache: useCache }); }).asCallback(callback);} watch 函数会先从 this.config.server 中读取 cache 配置,默认值为 false。如果当前命令为 server,并且配置了 cache,就会启用缓存。 接着使用 debounce 函数防抖 _generate 方法,间隔设置为 100 毫秒: function debounce(func, wait) { let timeout; return function () { clearTimeout(timeout); timeout = setTimeout(() => { func.apply(this); }, wait); };} debounce 函数的目的是限制某个函数在一段时间内的调用次数,当该函数被连续调用时,只有在停止调用后等待指定的时间才会真正执行。这样便可以优化性能。 调用 load_database_1.default 方法加载数据库,打印日志 Start processing,同时监视 source 和 theme 的变化,使用 bluebird_1.default.all 以并行方式执行。 合并主题配置并设置监控回调:这里的 mergeCtxThemeConfig 方法之前已经说过了。当 source 处理完成后,调用 _watchBox;当 theme 处理完成后,调用 _watchBox 并再次合并主题配置。 调用 _generate 方法,初次生成内容,并根据 useCache 决定是否使用缓存。 最后使用 bluebird 库的 asCallback 方法,将结果作为回调传递给 callback。 停止监控 既然有了监视用的方法,那么也有取消监视的方法。 unwatch() { if (this._watchBox != null) { this.source.removeListener('processAfter', this._watchBox); this.theme.removeListener('processAfter', this._watchBox); this._watchBox = null; } stopWatcher(this.source); stopWatcher(this.theme);} unwatch 方法检查并清除了 _watchBox。根据上面 watch 方法的内容,this._watchBox 是一个防抖函数,用来处理文件变动后的生成操作。如果它存在,就需要将其从 source 和 theme 对象的 processAfter 事件监听器中移除。 stopWatcher 函数被定义在 index.js 的最上方: const stopWatcher = (box) => { if (box.isWatching()) box.unwatch(); }; 它用于停止对给定对象的监控。逻辑很简单:该对象是否正在被监控,如果是,就调用 unwatch 方法停止监控。 生成本地环境 _generateLocals() { const { config, env, theme, theme_dir } = this; const ctx = { config: { url: this.config.url } }; const localsObj = this.locals.toObject(); class Locals { constructor(path, locals) { this.page = Object.assign({}, locals); if (this.page.path == null) this.page.path = path; this.path = path; this.url = hexo_util_1.full_url_for.call(ctx, path); this.config = config; this.theme = theme.config; this.layout = 'layout'; this.env = env; this.view_dir = (0, path_1.join)(theme_dir, 'layout') + path_1.sep; this.site = localsObj; } } return Locals;} Locals 类中,许多环境相关的属性,如 url、config 等属性都被设置。该方法生成了本地环境的信息对象,以便在模块渲染过程中使用。 运行生成器 同样,生成器(Generator)后续也会讲解。 在 Hexo 官方文档 中,生成器被解释为 会根据处理后的原始文件建立路由。 _runGenerators() { this.locals.invalidate(); const siteLocals = this.locals.toObject(); const generators = this.extend.generator.list(); const { log } = this; // Run generators return bluebird_1.default.map(Object.keys(generators), key => { const generator = generators[key]; log.debug('Generator: %s', (0, picocolors_1.magenta)(key)); return Reflect.apply(generator, this, [siteLocals]); }).reduce((result, data) => { return data ? result.concat(data) : result; }, []);} invalidate 方法使 Hexo 实例的本地数据无效,以便在生成器运行之前重新加载。 提取当前 Hexo 实例的本地数据对象,将其赋值给 siteLocals 变量。 获取 Hexo 实例中已注册的所有生成器,将它们保存在 generators 变量中。 运行生成器之前,记录了一条调试信息,指示将要运行哪个生成器。 使用 bluebird 库的 map 方法对生成器进行并行处理。每个生成器都被调用 Reflect.apply(generator, this, [siteLocals]),将生成器函数应用到当前的 Hexo 实例上。 使用 bluebird 库的 reduce 方法将生成器返回的数据收集到一个数组中,并返回这个数组。 _runGenerators 方法运行 Hexo 实例中所有注册的生成器,并将它们返回的数据收集到一个数组中。 退出进程 exit 方法用于退出 Hexo 进程。 exit(err) { if (err) { this.log.fatal({ err }, 'Something\'s wrong. Maybe you can find the solution here: %s', (0, picocolors_1.underline)('https://hexo.io/docs/troubleshooting.html')); } return this.execFilter('before_exit', null, { context: this }).then(() => { this.emit('exit', err); });} 这里值得说的是,exit 方法会触发 execFilter 方法、触发 before_exit 过滤器。以及最终它会触发 exit 事件。 定义属性和导出 Hexo.lib_dir = libDir + path_1.sep;Hexo.prototype.lib_dir = Hexo.lib_dir;Hexo.core_dir = (0, path_1.dirname)(libDir) + path_1.sep;Hexo.prototype.core_dir = Hexo.core_dir;Hexo.version = version;Hexo.prototype.version = Hexo.version;module.exports = Hexo; Hexo 类本身终于讲完了,在 Hexo 类的下方,它的部分属性和原型属性被定义,并最终被导出为模块的默认输出。 LibDir:Hexo 库目录的路径。 core_dir:核心目录的路径。 version:版本号。

2024/5/26
articleCard.readMore

IBM 全栈开发【8】:用于人工智能和开发项目的 Python

近期在学习 IBM 全栈应用开发微学士课程,故此记录学习笔记。 1. Python 编码实践和打包概念 1.1. 开发生命周期 应用程序开发生命周期分为七个阶段,包括: 收集需求: 收集应用程序的用户、业务和技术需求。 分析: 分析需求。 设计: 设计完整的解决方案。 编码和测试: 构建和测试应用程序的不同组件。 用户和系统测试: 用户测试应用程序的可用性,进行系统集成测试和性能测试。 生产: 应用程序可供所有最终用户使用。 维护: 升级或修复任何用户或系统问题。 1.2. PEP8 PEP8 关于代码可读性的指导原则包括以下内容: 缩进四个空格。 空行用于分隔函数和类。 操作符周围和逗号后的空格。 PEP8 的编码规范具有一致性和可管理性,其中包括: 在函数内添加较大的代码块。 使用带下划线的小写字母命名函数和文件。 使用驼峰大写为类命名。 用大写字母命名常量,单词之间用下划线分隔。 1.3. 单元测试 单元测试是一种验证代码单元是否按设计运行的方法。在与最终代码库集成之前,必须测试每个单元。 1.3.1. 例子 def add(a,b): return a + bdef substract(a,b): return a - b import unittestfrom math import add, substractclass TestMain(unittest.TestCase): def test_add(self): self.assertEqual(add(6,4),10) def test_substract(self): self.assertEqual(substract(6,4),3) 1.4. 创建包 以软件包名称创建文件夹。 创建一个空的 __init__.py 文件。 创建所需的模块。 在 __init__.py 文件中,添加代码以引用软件包中需要的模块。 可以在 Python shell 中通过 bash 终端验证软件包。 1.5. 其他 关于网络应用程序,以下两种说法是正确的: 所有网络应用程序都是 API。 网络应用支持 CRUD 操作。 在哪个测试阶段验证应用程序在更大框架内的功能?: 集成测试。 PyLint 是一款 Python 静态代码分析工具。 在为一个管理活动的应用程序收集需求时,客户提到项目的目标是提高客户保留率。 业务需求 描述了上述情况。 2. 使用 Flask 部署网络应用程序 2.1. Flask Flask 是一个微型框架,只需最少的依赖即可运行。Flask 具有调试服务器、路由、模板和错误处理等功能,可用于构建网站。Flask 可以作为 python 软件包安装。与 Flask 相比,Django 是一个全栈框架。你可以通过实例化 Flask 类来创建服务器。 Flask 为每次客户端调用提供一个请求和一个响应对象。可以从 Flask 请求中获取更多信息,如标题。您可以解析请求对象,获取查询参数、正文和其他参数。您甚至可以在将响应发送回客户端之前,在响应对象上设置状态。 可以使用动态路由创建 RESTful 端点。 HTTP 状态代码有多种类型,分别显示成功、用户错误或服务器错误。Flask 在响应时会隐式返回成功代码 200。您也可以明确提供状态代码。Flask 还提供应用程序级的错误处理程序。 Flask 支持 CRUD。 可以使用 Flask 渲染静态和动态模板。 2.1.1. 代码案例 实例化 Flask: from flask import Flaskapp = Flask(__name__) @app.route 装饰器: @app.route('/')def hello_world(): return "<b>My first Flask application in action!</b>" 400 错误: @app.route('/')def search_response(): query = request.args.get('q') if not query: return {"error_message": "Input parameter missing"}, 422 resource = fetch_from_database(query) if resource: return {"message": resource} else: return {"error_message": "Resource not found"}, 404 3. 使用 Flask 创建人工智能应用并进行部署 Watson NLP 库被嵌入到实验网页中,外部工具无法访问。 3.1. 项目概述 在这个同行评分的毕业设计中,你将扮演一名软件工程师,需要开发一款基于人工智能的网络应用程序。您将分析情景并执行以下任务: 克隆项目资源库。 使用 Watson NLP 库创建一个情绪检测应用程序。 格式化应用程序的输出。 打包应用程序。 在应用程序上运行单元测试。 使用 Flask 对应用程序进行网络部署。 纳入错误处理。 运行静态代码分析。 在这个项目中,您将开发一个集成了 Embeddable Watson 人工智能库的网络应用程序。然后,您将提交相关截图供同行评审。 3.2. 情景 你被一家电子商务公司聘为软件工程师,负责创建一个基于人工智能的网络应用程序,对其特色产品的客户反馈进行分析。为实现这一要求,您将创建一个情感检测系统,处理客户以文本格式提供的反馈,并解读相关的情感表达。 情感检测扩展了情感分析的概念,从语句中提取更细微的情感,如喜悦、悲伤、愤怒等,而不是情感分析所提供的简单极性。这使得情感检测成为一个非常重要的研究分支,企业将此类系统广泛用于基于人工智能的推荐系统、自动聊天机器人等。 3.2.1. 详情 Watson NLP 库是预装在 Cloud IDE 框架中的可嵌入库。因此,无需将它们导入到你的代码中。你只需向库中的正确函数发送 post 请求,然后接收输出。 这些可嵌入的人工智能库基于流行的人工智能模型,提供了各种基于 NLP 和语音的功能。 使用 Watson NLP 库的 Emotion Predict 方法。访问该功能的 URL、Headers 和 Input json 格式如下。 URL: 'https://sn-watson-emotion.labs.skills.network/v1/watson.runtime.nlp.v1/NlpService/EmotionPredict'Headers: {"grpc-metadata-mm-model-id": "emotion_aggregated-workflow_lang_en_stock"}Input json: { "raw_document": { "text": text_to_analyse } } text_to_analyze 被用作一个变量,用于保存需要分析的实际书面文本。 在项目根目录创建 emotion_detection.py 文件。 创建 emotion_detector 方法用于运行情感检测。 用于分析的文本会被存储在 text_to_analyze 变量中,并会被作为参数传入该方法内。 import requests def emotion_detector(text_to_analyse: str):URL = 'https://sn-watson-emotion.labs.skills.network/v1/watson.runtime.nlp.v1/NlpService/EmotionPredict'Headers = { "grpc-metadata-mm-model-id": "emotion_aggregated-workflow_lang_en_stock" }Input_Json = { "raw_document": { "text": text_to_analyse } }response = requests.post(url=URL, json=Input_Json, headers=Headers)return response.json() 返回的内容: { "emotionPredictions": [ { "emotion": { "anger": 0.0132405795, "disgust": 0.0020517302, "fear": 0.009090992, "joy": 0.9699522, "sadness": 0.054984167 }, "target": "", "emotionMentions": [ { "span": { "begin": 0, "end": 26, "text": "i love this new technology" }, "emotion": { "anger": 0.0132405795, "disgust": 0.0020517302, "fear": 0.009090992, "joy": 0.9699522, "sadness": 0.054984167 } } ] } ], "producerId": { "name": "Ensemble Aggregated Emotion Workflow", "version": "0.0.1" }} 调整输出,使其返回以下格式的内容: { "anger": anger_score, "disgust": disgust_score, "fear": fear_score, "joy": joy_score, "sadness": sadness_score, "dominant_emotion": "<name of dominant emotion>"} def emotion_detector(text_to_analyse: str): URL = 'https://sn-watson-emotion.labs.skills.network/v1/watson.runtime.nlp.v1/NlpService/EmotionPredict' Headers = { "grpc-metadata-mm-model-id": "emotion_aggregated-workflow_lang_en_stock" } Input_Json = { "raw_document": { "text": text_to_analyse } } response = requests.post(url=URL, json=Input_Json, headers=Headers) emotions = response.json()['emotionPredictions'][0]['emotion'] items = emotions.items() max_item = max(items, key=lambda x: x[1]) emotions['dominant_emotion'] = max_item[0] return emotions 创建 EmotionDetection Python 包。 创建文件夹,命名为 EmotionDetection。 创建 __init__.py 文件。 在包的根目录下创建 emotion_detector.py 文件,其中包含我们之前创建的 emotion_detector 函数。 在应用程序上运行单元测试。 创建新文件 test_emotion_detection.py,调用包中的函数,并针对以下语句和主要情绪进行测试: 语句主要情绪 I am glad this happenedjoy I am really mad about thisanger I feel disgusted just hearing about thisdisgust I am so sad about thissadness I am really afraid that this will happenfear import pytestfrom EmotionDetection import emotion_detectordef test_emotion_detector(): test_cases = [ ("I am glad this happened", "joy"), ("I am really mad about this", "anger"), ("I feel disgusted just hearing about this", "disgust"), ("I am so sad about this", "sadness"), ("I am really afraid that this will happen", "fear"), ] for text, expected_emotion in test_cases: result = emotion_detector(text) assert result['dominant_emotion'] == expected_emotion, f"For text: {text}, expected: {expected_emotion}, but got: {result['dominant_emotion']}"if __name__ == "__main__": pytest.main() 检查单元测试输出,验证单元测试是否通过。 python3.11 test_emotion_detection.py 使用 Flask 进行应用程序的网络部署: 注意:模板文件夹中的 index.html 文件和静态文件夹中的 mywebscript.js 文件已作为仓库的一部分提供。本项目无需对这些文件进行改动。 在项目根目录下创建 server.py。确保应用程序调用函数的 Flask 装饰器是 \emotionDetector。客户要求以下面示例中的格式显示输出结果(假设是对 "I love my life" 这句话进行评价): { "anger": 0.006274985, "disgust": 0.0025598293, "fear": 0.009251528, "joy": 0.9680386, "sadness": 0.049744144, "dominant_emotion":"joy"} 响应应当被格式化为: For the given statement, the system response is 'anger': 0.006274985, 'disgust': 0.0025598293, 'fear': 0.009251528, 'joy': 0.9680386 and 'sadness': 0.049744144. The dominant emotion is joy. 应用程序需要部署在 localhost:5000 上。 在函数 emotion_detector 中加入错误处理功能,以管理用户的空白条目,即在没有任何输入的情况下运行应用程序。 访问服务器响应的 status_code 属性,以正确显示空白条目的系统响应。 对于 status_code = 400,函数将返回相同的字典,但所有键的值均为 None。 修改 server.py,在 dominant_emotion 为 None 时加入错误处理。在这种情况下,响应应显示 Invalid text! Please try again!.。 EmotionDetection/__init__.py: import requestsdef emotion_detector(text_to_analyse: str): if not text_to_analyse: return {'anger': None, 'disgust': None, 'fear': None, 'joy': None, 'sadness': None, 'dominant_emotion': None} URL = 'https://sn-watson-emotion.labs.skills.network/v1/watson.runtime.nlp.v1/NlpService/EmotionPredict' Headers = { "grpc-metadata-mm-model-id": "emotion_aggregated-workflow_lang_en_stock" } Input_Json = { "raw_document": { "text": text_to_analyse } } response = requests.post(url=URL, json=Input_Json, headers=Headers) if response.status_code == 400: return {'anger': None, 'disgust': None, 'fear': None, 'joy': None, 'sadness': None, 'dominant_emotion': None} emotions = response.json()['emotionPredictions'][0]['emotion'] items = emotions.items() max_item = max(items, key=lambda x: x[1]) emotions['dominant_emotion'] = max_item[0] return emotions server.py: """This module defines a Flask application that provides an emotion detection API."""from flask import Flask, request, render_templatefrom EmotionDetection import emotion_detectorapp = Flask(__name__)@app.route('/emotionDetector')def handle_emotion_detector(): """ This function handles GET requests to the /emotionDetector route. It expects a textToAnalyze arg that contains the text to analyze. It returns a string with all predicted emotions. """ predicted_emotions = emotion_detector(request.args.get('textToAnalyze')) if predicted_emotions['dominant_emotion'] is None: return 'Invalid text! Please try again!' response_text = ("For the given statement, " + f"the system response is 'anger': {predicted_emotions['anger']}, " f"'disgust': {predicted_emotions['disgust']}, " + f"'fear': {predicted_emotions['fear']}, " f"'joy': {predicted_emotions['joy']} " + f"and 'sadness': {predicted_emotions['sadness']}. " f"The dominant emotion is {predicted_emotions['dominant_emotion']}.") return response_text@app.route("/")def render_index_page(): """ This function renders index.html to the / route. """ return render_template('index.html')if __name__ == '__main__': app.run(host='localhost', port=5000)

2024/5/20
articleCard.readMore

IBM 全栈开发【7】:数据科学的 Python 基础

近期在学习 IBM 全栈应用开发微学士课程,故此记录学习笔记。 1. Python 基础 2. Python 数据结构 3. Python 编程基础 4. 用 Python 处理数据 4.1. Pandas Pandas 是一个功能强大的 Python 库,用于数据处理和分析,提供数据结构和函数来处理结构化数据,如数据帧和序列。 可以使用 import 命令导入文件,然后输入文件名。 使用 as 命令可以为文件提供一个更短的名称。 在 Pandas 中,使用数据帧(df)来指定要读取的文件。 数据帧由行和列组成。 可以使用特定 DataFrame 的一列或多列来创建新的 DataFrame。 我们可以处理 DataFrames 中的数据,并将结果保存为不同的格式。 在 Python 中,可以使用 Unique 方法来确定 DataFrames 列中的唯一元素。 您可以使用不等式运算符和 df,为 DataFrames 中选定的列分配一个布尔值。 将新的 DataFrame 保存为不同的 DataFrame,其中可能包含先前 DataFrame 中的值。 4.2. NumPy NumPy 是一个用于数值和矩阵运算的 Python 库,提供多维数组对象和各种数学函数,可高效处理数据。 NumPy 是 Pandas 的基础。 NumPy 数组或 ND 数组类似于列表,通常具有固定大小和同类元素。 一维 NumPy 数组是具有单轴的元素线性序列,就像传统的列表,但针对数值计算和数组操作进行了优化。 可以使用索引访问 NumPy 数组中的元素。 使用属性 dtype 可以获取数组元素的数据类型。 你可以使用 nsize 和 ndim 分别获取数组的大小和维度。 可以在 NumPy 中使用索引和切片方法。 向量加法是 Python 中广泛使用的操作。 用线段或箭头来表示向量加法是非常有用的。 NumPy 代码的运行速度更快,这对处理大量数据很有帮助。 用负号代替加号,可以执行向量减法。 在 Python 中,数组与标量相乘需要将数组中的每个元素与标量值相乘,从而得到一个新数组,其中的每个元素都按标量缩放。 Hadamard 积是指两个相同形状的数组按元素相乘,得到一个新数组,其中每个元素都是输入数组中相应元素的乘积。 Python 中的点乘是两个数组的元素乘积之和,通常用于向量和矩阵操作,以找到相应元素相乘并求和的标量结果。 在使用 NumPy 时,通常会使用 Matplotlib 等库来从存储在 NumPy 数组中的数值数据创建图表和可视化效果。 二维 NumPy 数组是一种具有行和列的网格状结构,适合以矩阵或表格的形式表示数据,用于数值计算。 在 NumPy 中,shape 指的是数组的维数(行列数),表示数组的大小和结构。 使用属性 size 可以获得数组的大小。 使用矩形属性可以访问数组中的各种元素。 在 NumPy 中使用标量对元素进行乘法运算。 5. API 和数据收集 5.1. API Python 中的简单 API 是应用程序编程接口,它提供了与服务、库或数据交互的简单易用的方法,通常只需最少的配置或复杂度。 API 使两个软件可以相互对话。 在 Python 中使用 API 库需要导入该库,调用其函数或方法来发出 HTTP 请求,并解析响应以访问 API 提供的数据或服务。 Pandas API 通过与其他软件组件通信来处理数据。 当你创建一个字典,然后使用 DataFrames 构造函数创建一个 Pandas 对象时,实例就形成了。 方法 head 将从 DataFrames 的顶部(默认为 5)显示所提及的行数,而方法 means 将计算平均值并返回值。 5.1.1. REST API REST API 允许通过互联网进行通信,利用存储、访问更多数据、人工智能算法等资源。 HTTP 方法通过互联网传输数据。 HTTP 消息通常包含一个 JSON 文件,其中包含操作指令。 包含 JSON 文件的 HTTP 消息会作为网络服务的响应返回给客户端。 处理时间序列数据需要使用 Pandas 时间序列函数。 您可以获取每日蜡烛图数据,并使用 Plotly 绘制蜡烛图。 5.1.2. HTTP HTTP(超文本传输协议)在客户端(网络浏览器)和万维网服务器之间传输数据,包括网页和资源。 HTTP 协议可能包括多种类型的 REST API。 HTTP 响应包括资源类型、资源长度等信息。 统一资源定位符(URL)是在网络上查找资源的最常用方法。 URL 分为三个部分:方案、互联网地址或基本 URL 和路径。 GET 方法是请求信息的常用方法之一。其他一些方法也可能包含正文。 响应方法包含响应的版本和正文。 POST 向服务器提交数据,PUT 更新服务器上已有的数据,DELETE 从服务器删除数据。 5.1.3. Requests Requests 是一个 Python 库,可以轻松发送 HTTP / 1.1 请求。 可以使用 GET 方法修改查询结果。 可以用一个 Query 字符串从一个 URL 获取多个请求,如名称、ID 等。 5.1.4. 爬虫 Python 中的网络抓取包括从网站中提取和解析数据,以便使用 Beautiful Soup 和 requests 等库为各种应用程序收集信息。 HTML 包含由角括号(称为标签)括起来的蓝色文本元素所包围的文本。 Python 中的每个标签名称都是一个类,每个标签都是一个实例。 您可以选择网页上的 HTML 元素来查看网页。 网页可能还包含 CSS 和 JavaScript 以及 HTML 元素。 每个 HTML 文档就像一个 HTML 树,其中可能包含字符串和其他标记。 每个 HTML 表格都有表格标记,并定义了行、页眉、正文等。

2024/5/18
articleCard.readMore

React + NestJS + Socket.io 项目实践【3】:消息发送

近期在期末考,所以更新会比较慢,这篇文章主要讲解如何实现消息发送功能。 客户端的私聊界面 之前的文章中我都没有去讲解客户端的代码。在讲解消息发送之前,我先介绍一下客户端的代码。 我的客户端中有一个 PrivateMessageChatPage 组件,用来显示和某个用户的私聊界面。这是最终的效果: 而 PrivateMessageChatPage 的布局是这样的: 目前只有 PrivateMessageMessagesWrapper 和 PrivateMessageTextBox 组件是有内容的,其他的组件都是空的。不过这不影响我们的消息显示。 首先导入这些组件和一些类型: import React, { useState } from 'react'import PrivateMessageTabBar from './Private_Message_Tab_Bar'import PrivateMessageMessagesWrapper from './Private_Message_Messages_Wrapper'import PrivateMessageProfilePanel from './Private_Message_Profile_Panel'import PrivateMessageTextBox from './Private_Message_Text_Box'import FriendsListSideBar from '../private_message_common/Friends_List_Sidebar'import { Message } from '../../types/interfaces'import { MessageContext } from '../context/Message_Context' Message 类型: export interface Message { id?: string senderId: string receiverId: string text: string timestamp: string} 每条消息都有一个 senderId、receiverId、text 和 timestamp。id 是消息的唯一标识符。 我们之后会根据 senderId 和 receiverId 来从服务端获取消息。 PrivateMessageChatPage 组件: const PrivateMessageChatPage = () => { const [receiverName, setReceiverName] = useState<string>('Dummy') const [newMessage, setNewMessage] = useState<Message | null>(null) const addMessage = (message: Message) => { setNewMessage(message) } return ( <MessageContext.Provider value={{ newMessage, addMessage }}> <FriendsListSideBar /> <div className="d-flex flex-column mx-0 h-100 w-100"> <div className="d-flex flex-row" style={{ height: '48px', padding: '8px', fontSize: '16px', borderBottom: 'solid 3px rgba(45, 47, 52)' }}> <PrivateMessageTabBar receiverUsername={receiverName} setReceiverUsername={setReceiverName} /> </div> <div className="d-flex flex-row flex-fill align-items-stretch p-0"> <div className="d-flex flex-column h-100 position-relative" style={{ minWidth: 0, minHeight: 0, flex: '1 1 auto' }}> <div className="position-relative" style={{ flex: '1 1 auto', minHeight: 0, minWidth: 0, zIndex: 0 }}> <PrivateMessageMessagesWrapper receiverUsername={receiverName} /> </div> <div className="position-sticky bottom-0 w-100" style={{ backgroundColor: 'rgba(49, 51, 56)' }}> <PrivateMessageTextBox receiverUsername={receiverName} addMessage={addMessage} /> </div> </div> <PrivateMessageProfilePanel /> </div> </div> </MessageContext.Provider> )}export default PrivateMessageChatPage MessageContext 是一个 React 上下文,用来传递消息。 React 的 Context API 是一种在组件之间共享数据的方法,而不必通过组件树的逐层传递 props。 通过创建一个 Context 对象,然后使用 <MyContext.Provider> 组件将值传递给后代组件,可以在组件树中传递数据。 import React from 'react'import { Message } from '../../types/interfaces'interface MessageContextType { newMessage: Message | null addMessage: (message: Message) => void}export const MessageContext = React.createContext<MessageContextType | undefined>(undefined) 在这个例子中,MessageContext 被用来在组件树中共享 newMessage 和 addMessage。 newMessage 是最新的消息。 addMessage 是一个函数,用来添加消息。 我们的 PrivateMessageTextBox 组件是用来发送消息的,当用户在输入框中输入新的消息并发送时,组件会调用 addMessage 方法,将新消息添加到 MessageContext 中。而 PrivateMessageMessagesWrapper 组件会监听 newMessage 的改变,一旦 newMessage 出现了变化,新消息就会被添加到该组件的状态中用于显示。 这意味着,用户只要发送了消息,消息就会立即显示在界面上。 PrivateMessageTextBox 组件的 handleSendMessage 方法: const PrivateMessageTextBox: React.FC<PrivateMessageTextBoxProps> = ({ receiverUsername }) => { const [message, setMessage] = useState<string>('') const context = useContext(MessageContext) if (!context) { throw new Error('MessageContext is undefined') } const {addMessage} = context const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => { setMessage(e.target.value) } const handleSendMessage = async (e?: FormEvent) => { e && e.preventDefault() const jwtToken = localStorage.getItem('jwtToken') const senderId = localStorage.getItem('userId') if (!senderId) { throw new Error('User ID not found in local storage') } const response = await UserService.getUserByUsername(jwtToken, receiverUsername) const receiver = response.data const privateMessage = { id: `${socket.id}${Math.random()}`, senderId: senderId, receiverId: receiver._id, text: message, timestamp: new Date().toISOString(), } socket.emit('privateMessageSent', privateMessage) addMessage(privateMessage) setMessage('') }} 这里的 UserService.getUserByUsername 方法进行了更改,需要多传入一个 jwtToken 参数。 export const UserService = { getUserByUsername: (token: string | null, username: string) => { return api.get(`/users/username/${username}`, { headers: { Authorization: `Bearer ${token}` } }) }, getUserByUserId: (token: string | null, userId: string) => { return api.get(`/users/userid/${userId}`, { headers: { Authorization: `Bearer ${token}` } }) },} 这是因为在上个文章中我实现了 @Public 装饰器,用来标记哪些路由是公开的。 根据用户的用户名或者 ID 来获取用户信息,都应当是私有的,所以需要传入 jwtToken。 OAuth 2.0 授权框架规范中定义了 Bearer 令牌类型,它是一种用于 OAuth 2.0 的访问令牌,用于对资源进行身份验证。任何持有 Bearer 令牌的人都可以访问与该令牌相关联的资源。 向服务端发送 privateMessageSent 事件后,立即调用 addMessage 方法,将消息添加到 MessageContext 中。 消息传递 如果只是写了客户端的代码,那么消息只是在客户端显示,而不会真正的发送到服务端。要实现消息发送,我们需要在服务端中接收消息。 在 上个文章 的最后一个分段中,我实现了 Socket 模块。 Socket 用白话来说就是一个通道,客户端和服务端可以通过这个通道进行双向通信。双向通信和传统的 HTTP 请求不同,HTTP 请求是单向的,客户端向服务端发送请求、服务端返回响应。而 Socket 是双向的,客户端和服务端可以随时向对方发送消息。 上个文章中 SocketGateway(网关)订阅了 privateMessageSent 事件,但我只是简单的打印了一下消息,并没有去更进一步的处理。 @SubscribeMessage('privateMessageSent')handlePrivateMessage(@MessageBody() data: any, client: Socket) { console.log('Received private message:', data, 'from', client)} 这次我要详细地讲解如何实现消息传递。首先消息传递在技术上的流程是这样的: 客户端使用 Socket 向服务端发送 privateMessageSent 事件。 服务端在 SocketGateway 中接收到 privateMessageSent 事件后,依靠 MessagesService 将消息存储到数据库中。 为什么要细分出两个模块呢?因为我认为这两个模块的职责不同,Socket 模块负责处理 Socket 相关的逻辑,Messages 模块则负责处理消息的存储与获取。 上个文章中并没有细写 Messages 模块,我现在来写一下: import { Module } from '@nestjs/common'import { MongooseModule } from '@nestjs/mongoose'import { Message, MessageSchema } from './message.schema'import { MessagesService } from './messages.service'import { MessagesController } from './messages.controller'@Module({ imports: [MongooseModule.forFeature([{ name: Message.name, schema: MessageSchema }])], providers: [MessagesService], controllers: [MessagesController], exports: [MessagesService],})export class MessagesModule {} exports 是用来导出 MessagesService 的,这样其他模块就可以使用 MessagesService 了。刚才也说过,SocketGateway 需要使用 MessagesService 来存储消息。 我在这里做了一些修改,将 MessagesSchema 更改为了 MessageSchema。因为这个模型实际上是用来存储单一的消息,所以我认为它的名字应该是单数形式。同时,我将原先的 sender 和 receiver 更改为了 senderId 和 receiverId,因为我想通过用户的 ID 来查找用户,而不是直接使用用户对象。 import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'import { Document } from 'mongoose'@Schema()export class Message extends Document { @Prop({ required: true }) senderId: string @Prop({ required: true }) receiverId: string @Prop({ required: true }) text: string @Prop({ default: Date.now }) timestamp: Date}export const MessageSchema = SchemaFactory.createForClass(Message) 在这里,我添加了一个 timestamp 字段,用来记录每条消息的发送时间。 在客户端里,我将发送给服务端的消息数据结构设计为以下形式: { id: `${socket.id}${Math.random()}`, senderId: senderId, receiverId: receiver._id, text: message, timestamp: new Date().toISOString(),} 这里,我也添加了一个 timestamp 字段。这是因为我希望客户端在发送消息后,能立即在界面上显示这条消息,而不需要等待服务端的响应。 值得注意的是,我选择让客户端直接显示消息,而没有等待服务端存储消息并返回。这样做的结果是,客户端显示的时间戳实际上是客户端发送消息的时间,而不是服务端存储消息的时间,除非用户刷新了页面,让客户端向服务端请求实际的数据。 接着我们要在 MessagesService 中添加一个方法,用来存储消息。 import { Injectable } from '@nestjs/common'import { InjectModel } from '@nestjs/mongoose'import { Model } from 'mongoose'import { Message } from './message.schema'import { CreateMessageDto } from './dto/create-message.dto'@Injectable()export class MessagesService { constructor(@InjectModel(Message.name) private messageModel: Model<Message>) {} async create(createMessageDto: CreateMessageDto): Promise<Message> { const createdMessage = new this.messageModel(createMessageDto) return await createdMessage .save() .then(async (message) => { console.log('Message saved:', message) return message }) .catch((error: any) => { console.log('Error saving message:', error) throw error }) }} CreateMessageDto 是一个数据传输对象,用来传输消息的数据。 export class CreateMessageDto { readonly text: string readonly senderId: string readonly receiverId: string} 在这个方法中,我只需要 text、senderId 和 receiverId 这三个字段。 接着使用 save 方法来保存消息,如果保存成功则返回消息,否则抛出错误。 最后就是在 SocketGateway 中调用 MessagesService 的 create 方法: import { MessagesService } from '../messages/messages.service'// ...export class SocketGateway implements OnGatewayInit { constructor( // ... private messagesService: MessagesService, ) {} @SubscribeMessage('privateMessageSent') async handlePrivateMessage(@MessageBody() data: any): Promise<void> { await this.messagesService.create({ senderId: data.senderId, receiverId: data.receiverId, text: data.text }) }} 别忘了 SocketModule 中要导入 MessagesModule,才能让 SocketGateway 使用 MessagesService。 import { Module } from '@nestjs/common'import { SocketService } from './socket.service'import { SocketGateway } from './socket.gateway'import { MessagesModule } from '../messages/messages.module'@Module({ imports: [MessagesModule], providers: [SocketGateway, SocketService],})export class SocketModule {} 这样服务端就可以接收到客户端发送的消息,并将消息存储到数据库中。 消息显示 用户点击他们和其他用户的私聊界面时,我们需要从服务端获取他们之间的所有消息。 在我的应用中,因为是仿照 Discord 的,所有的私聊路由都是 /channel/@me/:id 这种形式的。id 是接收者的 ID。 也就是说我们可以让服务端有一个 GET 路由,当客户端访问这个路由时,服务端会返回当前用户和 id 用户之间的所有消息。 在 MessagesController 中添加 GET 路由: import { Controller, Get, Param, Request } from '@nestjs/common'import { Request as ExpressRequest } from 'express'import { MessagesService } from './messages.service'@Controller('messages')export class MessagesController { constructor(private readonly messagesService: MessagesService) {} @Get(':senderId/:receiverId') async getMessages( @Request() req: ExpressRequest, @Param('senderId') senderId: string, @Param('receiverId') receiverId: string, ) { return await this.messagesService.getMessages(senderId, receiverId) }} 当客户端访问 /api/messages/:senderId/:receiverId 时,服务端会返回当前用户(senderId)和 receiverId 用户之间的所有消息。 MessagesService 中添加 getMessages 方法: async getMessages(senderId: string, receiverId: string) { return this.messageModel .find({ senderId: senderId, receiverId: receiverId, }) .exec() .then((messages) => { console.log('Messages found:', messages) return messages }) .catch((error: any) => { console.log('Error finding messages:', error) throw error })} 客户端里也添加一个方法,专门访问 /api/messages/:receiverId: export const MessageService = { getMessagesByUserId: (token: string | null, senderId: string | null, receiverId: string) => { return api.get(`/messages/${senderId}/${receiverId}`, { headers: { Authorization: `Bearer ${token}` } }) },} 现在回到客户端的 PrivateMessageMessagesWrapper 组件。我们需要明白这个组件的职责是什么: 当用户点击某个用户的私聊界面时,组件会向服务端请求 senderId 用户和 receiverId 用户之间的所有消息。 当用户发送消息时,组件会将新消息添加到消息列表中来立即显示。 首先以最基础的形式来写这个组件: const PrivateMessageMessagesWrapper: React.FC<PrivateMessageMessagesWrapperProps> = ({ receiverUsername }) => { const [messages, setMessages] = useState<Message[]>([]) const currentUser = useSelector((state: { auth: { user: UserProfile } }) => state.auth.user) return ( <div className="d-flex flex-column position-absolute top-0 bottom-0 overflow-y-scroll overflow-x-hidden" style={{ left: 0, right: 0, overflowAnchor: 'none' }}> <ol className="p-0 m-0" style={{ flex: 1, minHeight: '0', listStyle: 'none' }}> {messages.map((message, index) => ( <li key={message.id || index} className="position-relative mx-2" style={{ outline: 'none' }}> <div className="position-relative" style={{ marginTop: '1.0625rem', minHeight: '2.75rem', paddingTop: '0.125rem', paddingBottom: '0.125rem', paddingLeft: '72px', paddingRight: '48px!important', wordWrap: 'break-word', userSelect: 'text', }}> <div className="position-static ms-0 ps-0" style={{ textIndent: 'none' }}> {/* User's avatar */} <img src={imgURL} className="position-absolute overflow-hidden" style={{ pointerEvents: 'auto', textIndent: '-9999px', left: '16px', marginTop: 'calc(4px-0.125rem)', width: '40px', height: '40px', borderRadius: '50%', cursor: 'pointer', userSelect: 'none', }} alt="" /> {/* Username and message time */} <h3 className="overflow-hidden position-relative p-0 m-0 d-flex flex-row align-items-center" style={{ display: 'block', lineHeight: '1.375rem', minHeight: '1.375rem', whiteSpace: 'break-spaces', }}> <span className="me-1 fs-6 position-relative overflow-hidden text-white" style={{ fontWeight: '500', display: 'inline', verticalAlign: 'baseline', outline: 'none', }}> {message.senderId === currentUser._id ? currentUser.username : receiverUsername} </span> <span className="ms-1" style={{ fontSize: '0.75rem', height: '1.25rem', verticalAlign: 'baseline', display: 'inline-block', cursor: 'default', pointerEvents: 'none', outline: 'none', fontWeight: '500', color: 'rgba(148, 154, 158)', }}> <time dateTime={message.timestamp.toString()}>{formatDate(message.timestamp)}</time> </span> </h3> </div> {/* Message Content */} <div className="overflow-hidden position-relative fs-6 p-0 m-0" style={{ userSelect: 'text', whiteSpace: 'break-spaces', wordWrap: 'break-word', marginLeft: 'calc(-1 * 72px)', paddingLeft: '72px', textIndent: '0', lineHeight: '1.375rem', color: 'rgba(219, 222, 225)', }}> <span>{message.text}</span> </div> </div> </li> ))} </ol> </div> )} messages 是该组件的状态,用来存储所有的需要被显示的消息。消息的数据结构是 Message,上面已经定义过了。 currentUser 是从 Redux 中提取出的当前用户的信息,包括了用户的 ID 和用户名。 这个组件的底层逻辑是这样的: map 遍历 messages 数组,对每一条消息都生成一个 li 元素。 每一条消息都包含了用户的头像(这里写死了)、用户名(如果消息的 senderId 和当前用户的 ID 相同,那么消息的用户名就是当前用户的用户名,否则就是接收者的用户名)、消息发送时间和消息内容。 消息发送时间是通过 formatDate 函数格式化的: export const formatDate = (timestamp: string) => { const date = new Date(timestamp) const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0') const hours = String(date.getHours()).padStart(2, '0') const minutes = String(date.getMinutes()).padStart(2, '0') return `${year}/${month}/${day} ${hours}:${minutes}`} 那么我们该如何获取消息、并将消息添加到 messages 中呢? 当 PrivateMessageMessagesWrapper 组件被挂载时,以及 receiverUsername 发生变化时,我们都需要向服务端请求消息: useEffect(() => { const fetchMessages = async () => { const jwtToken = localStorage.getItem('jwtToken') const senderId = localStorage.getItem('userId') const response = await UserService.getUserByUsername(jwtToken, receiverUsername) const receiver = response.data try { const res = await MessageService.getMessagesByUserId(jwtToken, senderId, receiver._id) setMessages(res.data) return res.data } catch (err) { console.error(err) } } fetchMessages().then((r) => console.log('Messages fetched:', r))}, [receiverUsername]) 这里我使用了 useEffect 钩子,当 receiverUsername 发生变化时,就会调用 fetchMessages 方法。 fetchMessages 方法会向服务端请求 senderId 用户和 receiverId 用户之间的所有消息,并将消息存储到 messages 中。 不只是如此,先前我们在 PrivateMessageTextBox 组件中发送消息时,会将用户自身发送的消息添加到 MessageContext 中。PrivateMessageMessagesWrapper 组件同样也需要去监听用户自身发送的消息,并将这些消息添加到 messages 中: const context = useContext(MessageContext)if (!context) { throw new Error('MessageContext is undefined')}const { newMessage } = contextuseEffect(() => { if (newMessage) { setMessages((prevMessages) => [...prevMessages, newMessage]) }}, [newMessage]) 滚动到底部 当该被显示的消息超过了可视区域时,用户需要手动滚动到底部才能看到最新的消息。这是不友好的,我们应当让用户看到最新的消息。 const endOfMessagesRef = useRef<null | HTMLSpanElement>(null)const prevMessagesLength = useRef<number>(0)useEffect(() => { if (endOfMessagesRef.current && messages.length > prevMessagesLength.current) { endOfMessagesRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' }) } prevMessagesLength.current = messages.length}, [messages])return ( <div> <ol> {messages.map((message, index) => ( // ... ))} <span ref={endOfMessagesRef} /> </ol> </div>) 我使用了 useRef 来创建一个 endOfMessagesRef 引用,用来指向消息列表的最底部。当 messages 数组的长度发生变化时,我就将 endOfMessagesRef 滚动到可视区域。 prevMessagesLength 也是一个 useRef,用来存储上一次 messages 的长度。这个长度在回调函数的最后会被更新为当前的 messages 的长度。 useEffect 的回调函数会先检查 endOfMessagesRef.current 是否存在,以及 messages 的长度是否大于 prevMessagesLength.current。如果两个条件都满足,就将 endOfMessagesRef.current 滚动到可视区域。 scrollIntoView 方法是一个 DOM 方法,用来将元素滚动到可视区域。 behavior 决定了滚动的动画效果,smooth 表示平滑滚动。 block 决定了元素在垂直方向上的对齐方式,nearest 表示将元素对齐到最接近的边缘。 inline 决定了元素在水平方向上的对齐方式,start 表示将元素对齐到起始边缘。 span 标签是一个空元素,用来占位,需要放在 ol 标签的最后一个子元素后面。 浏览器刷新后状态丢失 在用户刷新浏览器后,Redux 的状态会丢失。这是因为 Redux 的状态是存储在内存中的,刷新浏览器后内存被清空,状态也就丢失了。 由于我们已经在 localStorage 中存储了用户的 jwtToken 和 userId,我们可以从 localStorage 中获取这些信息,并重新设置 Redux 的状态。 import { useAppDispatch } from './redux/store'import { setUserDetails } from './redux/actions/authActions'function App() { const dispatch = useAppDispatch() useEffect(() => { const token = localStorage.getItem('jwtToken') const userId = localStorage.getItem('userId') if (token && userId) { dispatch(setUserDetails(userId)) } }, [dispatch])} setUserDetails 是一个 Redux 的 action,用来设置用户的 ID。 export const setUserDetails = (userId: string) => { return async (dispatch: Dispatch) => { try { const token = localStorage.getItem('jwtToken') const response = await UserService.getUserByUserId(token, userId) if (response.status === 200) { dispatch(setCurrentUser(response.data)) } else { console.log(response.data.message) } } catch (error) { console.error(error) } }} 此处使用了 localStorage 中的 userId 来向服务端请求了用户的信息,并将用户信息存储到 Redux 的状态中。 令牌过期 服务端向客户端返回的 jwtToken 是有过期时间的。当 jwtToken 过期后,客户端再向服务端发送请求时,服务端会返回 401 Unauthorized 错误。 在这种情况下,我们应当让用户重新登录。我在 axios 的拦截器中添加了一个拦截器,用来处理 401 错误。 api.interceptors.response.use( (response) => { return response }, (error) => { if (error.response) { if (error.response.status === 401 && error.response.statusText === 'Unauthorized') { localStorage.removeItem('jwtToken') window.location.reload() } } return Promise.reject(error) },) window.location.reload() 会重新加载页面,没有 jwtToken 的情况下,用户会因为路由守卫而被重定向到登录页面。 其他的小改动 User 接口 User 接口的 id 字段改为 _id: export interface User { _id?: string emailAddress?: string username: string password?: string access_token?: string} 这是因为在 MongoDB 中,每个文档都有一个 _id 字段,用来唯一标识文档。为了可以直接将 MongoDB 中的文档映射到 User 接口,我将 id 改为了 _id。 自定义滚动条 我使用的是 Edge 浏览器,它的滚动条不能说难看,只能说和好看不搭边。所以我在 index.css 中自定义了滚动条的样式: ::-webkit-scrollbar { width: 16px; height: 16px;}::-webkit-scrollbar-track { background: hsl( 220 calc( 1 * 6.5%) 18% / 1); margin-bottom: 8px; border: 4px solid transparent; background-clip: padding-box; border-radius: 8px;}::-webkit-scrollbar-thumb { background: hsl( 225 calc( 1 * 7.1%) 11% / 1); background-clip: padding-box; border: 4px solid transparent; border-radius: 8px; min-height: 40px;}::-webkit-scrollbar-corner { background: transparent;} 这些样式是根据 Discord 的滚动条样式来写的。

2024/5/14
articleCard.readMore

React + NestJS + Socket.io 项目实践【2】:JWT 验证

上篇文章我们迁移了整个项目到 NestJS,这篇文章我们将实现 JWT 验证。 一些改动 为了项目的可维护性,我对项目的目录结构进行了一些调整。 首先是把所有注册登录相关的代码都从 users 文件夹放到 auth 文件夹中,只给 users 文件夹留下用户信息的相关代码。 UsersModule: import { Module } from '@nestjs/common'import { MongooseModule } from '@nestjs/mongoose'import { User, UserSchema } from './user.schema'import { UsersController } from './users.controller'import { UsersService } from './users.service'@Module({ imports: [ MongooseModule.forFeature([ { name: User.name, schema: UserSchema, }, ]), ], controllers: [UsersController], providers: [UsersService],})export class UsersModule {} UsersService 添加了更多用来查询用户的方法: import { Injectable } from '@nestjs/common'import { InjectModel } from '@nestjs/mongoose'import { Model } from 'mongoose'import { User } from './user.schema'@Injectable()export class UsersService { constructor( @InjectModel(User.name) private usersModel: Model<User>, ) {} async findByEmail(emailAddress: string): Promise<User | null> { return this.usersModel.findOne({ emailAddress }).exec() } async findByUsername(username: string): Promise<User | null> { return this.usersModel.findOne({ username }).exec() } async findAll(): Promise<User[]> { return this.usersModel.find().exec() }} UsersController 新添了一个 GET 路由,用于根据用户名查询用户信息,后续会用到: import { Controller, Get, Param } from '@nestjs/common'import { UsersService } from './users.service'import { Public } from '../common/decorator/public.decorator'@Controller('users')export class UsersController { constructor(private readonly usersService: UsersService) {} @Public() @Get(':username') async findOne(@Param('username') username: string) { return this.usersService.findByUsername(username) }} 假设要查询的用户的用户名是 dummy,那么请求的 URL 就是 /api/users/dummy。 客户端的文件改动则是将大部分文件从 components 目录中拿了出来,例如 Redux 相关的文件被统一放到了 redux 目录、和 components 同级。 同时我还删除了上一篇文章中写的 Guard 组件,因为在这篇文章中我们会要写 功能更为复杂的升级版路由守卫。 JWT 生成 JWT 是一种用于在网络上传输信息的简洁方法。对比 Session 和 Cookie,JWT 的优势在于不需要在服务端存储用户信息,而是通过加密的方式将用户信息存储在 Token 中,然后在客户端存储这个 Token。 有关 JWT 的更多信息,可以自行查阅资料。 JWT 验证的实现很大程度上参照了稀土掘金上的一篇文章:NestJS 登录功能:基于 JWT 的身份验证。 .env 文件中配置: JWT_SECRET=secretJWT_TOKEN_AUDIENCE=localhost:4000JWT_TOKEN_ISSUER=localhost:4000JWT_ACCESS_TOKEN_TTL=3600 AppModule 中配置 ConfigModule,这里需要用到 Joi 包(用于验证环境变量): import { ConfigModule } from '@nestjs/config'import * as Joi from 'joi'@Module({ imports: [ ConfigModule.forRoot({ validationSchema: Joi.object({ JWT_SECRET: Joi.string().required(), JWT_TOKEN_AUDIENCE: Joi.string().required(), JWT_TOKEN_ISSUER: Joi.string().required(), JWT_ACCESS_TOKEN_TTL: Joi.number().default(3600), }), }), ], // ...}) 使用 Joi 包的目的是为了验证环境变量是否存在,以及是否符合预期的类型。 假设环境变量 JWT_SECRET 不存在,那么 Joi 会抛出一个错误,阻止应用程序启动。 新建一个 jwt.config.ts 文件,用于配置 JwtModule: import { registerAs } from '@nestjs/config'export default registerAs('jwt', () => { return { secret: process.env.JWT_SECRET, audience: process.env.JWT_TOKEN_AUDIENCE, issuer: process.env.JWT_TOKEN_ISSUER, accessTokenTtl: parseInt(process.env.JWT_ACCESS_TOKEN_TTL ?? '3600', 10), }}) 接着在 AuthModule 引入这个配置: import { JwtModule } from '@nestjs/jwt'import jwtConfig from '../common/config/jwt.config'@Module({ imports: [ ConfigModule.forFeature(jwtConfig), JwtModule.registerAsync(jwtConfig.asProvider()), ], // ...}) 回到我们用户登陆的逻辑。当服务端验证用户信息后,我们需要生成一个 Token,然后返回给客户端。 新建一个 active-user-data.interface.ts 文件,用于定义 Token 中的负载: export interface ActiveUserData { sub: number name: string} 在 JWT 中,sub(主题,通常是 ID)和 name 是常见的有效载荷字段,定义这些字段有助于应用进行身份验证和授权、帮助服务器识别发送请求的用户。 AuthService 中添加两个方法,用于生成 Token 和验证 Token: // ...import { JwtService } from '@nestjs/jwt'import { ConfigType } from '@nestjs/config'import { User } from '../users/user.schema'import jwtConfig from '../common/config/jwt.config'import { ActiveUserData } from './interfaces/active-user-data.interface'@Injectable()export class AuthService { constructor( private readonly jwtService: JwtService, @Inject(jwtConfig.KEY) private readonly jwtConfiguration: ConfigType<typeof jwtConfig>, ) {} async generateTokens(user: User) { return await this.signToken<Partial<ActiveUserData>>(user._id, {name: user.username}) } private async signToken<T>(userId: number, payload?: T) { return await this.jwtService.signAsync( { sub: userId, ...payload, }, { secret: this.jwtConfiguration.secret, audience: this.jwtConfiguration.audience, issuer: this.jwtConfiguration.issuer, expiresIn: this.jwtConfiguration.accessTokenTtl, }, ) } // ...} signToken 方法接收一个用户 ID 和一个负载对象,然后使用 JwtService 的 signAsync 方法生成一个 Token。这里使用的 jwtConfig 是我们之前配置的 JWT 配置。 generateTokens 方法接收一个用户对象,然后调用 signToken 方法生成一个 Token。 在 login 方法中调用 generateTokens 方法: async login(loginUserDto: LoginUserDto) { const user = await this.usersModel.findOne({ username: loginUserDto.username, }) if (!user) throw new UnauthorizedException('The username is invalid') const passwordValid = await bcrypt.compare(loginUserDto.password, user.password) if (!passwordValid) throw new UnauthorizedException('The password is invalid') // 返回Token return await this.generateTokens(user)} AuthController 也需要做一些改动: @Post('login')async login(@Body() loginUserDto: LoginUserDto, @Res() res: Response) { try { const resultToken = await this.authService.login(loginUserDto) res.status(HttpStatus.OK).json({ status: '00000', message: 'User logged in successfully', token: resultToken, // <-- 向客户端返回Token }) } catch (error) { console.error(error) if (error instanceof UnauthorizedException) { res.status(HttpStatus.UNAUTHORIZED).json({ status: 'U0202', message: 'The username or password is invalid', }) } else { res.status(HttpStatus.BAD_REQUEST).json({ status: 'U0200', message: 'Failed to log in user due to an unknown error', }) } }} 客户端接收到 Token 后,可以将 Token 存储在 localStorage 中,以便后续请求时使用: const loginUser = (userData: User, navigate: (path: string) => void) => { return async (dispatch: Dispatch) => { try { const response = await UsersService.login(userData); const data = response.data; if (data.status === "00000") { localStorage.setItem("jwtToken", data.token); dispatch(setCurrentUser({ ...userData, access_token: data.token, })); console.log(data); navigate("/"); } else console.log(data.message); } catch (error) { console.error(error); } }} JWT 验证 生成 Token 后,我们需要在每次请求时验证 Token。 服务端新建一个 access-token.guard.ts 文件: import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException } from '@nestjs/common'import { ConfigType } from '@nestjs/config'import { JwtService } from '@nestjs/jwt'import { Reflector } from '@nestjs/core'import { Request } from 'express'import { REQUEST_USER_KEY } from '../../common'import jwtConfig from '../../common/config/jwt.config'import { IS_PUBLIC_KEY } from '../../common/decorator/public.decorator'@Injectable()export class AccessTokenGuard implements CanActivate { constructor( private readonly reflector: Reflector, private readonly jwtService: JwtService, @Inject(jwtConfig.KEY) private readonly jwtConfiguration: ConfigType<typeof jwtConfig>, ) {} async canActivate(context: ExecutionContext): Promise<boolean> { const isPublic = this.reflector.get(IS_PUBLIC_KEY, context.getHandler()) if (isPublic) return true const request = context.switchToHttp().getRequest() const token = this.extractTokenFromHeader(request) if (!token) throw new UnauthorizedException() try { request[REQUEST_USER_KEY] = await this.jwtService.verifyAsync(token, this.jwtConfiguration) } catch (error) { throw new UnauthorizedException() } return true } private extractTokenFromHeader(request: Request): string | undefined { const authorization = request.headers['authorization'] const [, token] = authorization?.split(' ') ?? [] return token }} AccessTokenGuard 类实现了 CanActivate 接口,该接口用于验证请求是否可以通过。 extractTokenFromHeader 方法用于从请求头中提取 Token。 上述代码中导入的两个新文件分别是 common/index.ts 和 common/decorator/public.decorator.ts: export const REQUEST_USER_KEY = 'user' 这个常量在 request 对象中用作键、用于存储已验证的 JWT 的解码信息。具体来说,当一个请求到达并通过 canActivate 方法时,会从请求的 Authorization 头中提取 JWT。如果 JWT 存在且有效(也就是能够被 JwtService 和 verifyAsync 方法验证),那么 JWT 的解码信息就会被存储在 request[REQUEST_USER_KEY] 中。 这样做的目的是为了在后续的请求处理中,可以直接通过 request[REQUEST_USER_KEY] 来获取 JWT 的解码信息,无需再次验证。 import { SetMetadata } from '@nestjs/common'export const IS_PUBLIC_KEY = 'isPublic'export const Public = () => SetMetadata(IS_PUBLIC_KEY, true) 首先我们要清楚一个概念。在我们启用了 JWT 验证的情况下,会导致所有的请求都需要携带 JWT Token。但是有些请求我们并不想要求用户携带 Token,比如说注册和登录请求(因为用户还没有 Token)。这时我们就需要一个装饰器来标记这些请求是公开的。 Public 装饰器标记了一个请求是公开的,这样在 AccessTokenGuard 中就可以根据这个标记来判断是否需要验证 Token。 有了 Public 装饰器后我们就可以在 AuthController 中标记 register 和 login 路由是公开的: import { Public } from '../common/decorator/public.decorator'// ...@Public()@Post('register')@Public()@Post('login') NestJS 的项目每当新建一个东西时,都需要在 app.module.ts 中引入。这里我们导入 AccessTokenGuard: import { APP_GUARD } from '@nestjs/core'import { AccessTokenGuard } from './guards/access-token.guard'@Module({ // ... providers: [ { provide: APP_GUARD, useClass: AccessTokenGuard, }, // ... ],})export class AppModule {} APP_GUARD 用于告诉 NestJS 我们要使用 AccessTokenGuard 这个守卫。 有了这个守卫后,每次请求都会被验证 Token。如果请求中没有 Token 或者 Token 无效,那么请求就会被拒绝。 客户端判断用户是否登录 未登录的用户是不能访问某些页面的,同理,已登录的用户也不能访问登录和注册页面。 我们先自定义一个 Hook 来检查用户是否已经进行了身份验证。新建一个 useAuth 文件: import { useEffect, useState } from 'react'export const useAuth = () => { const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthChecked, setIsAuthChecked] = useState(false); useEffect(() => { const token = localStorage.getItem("jwtToken"); if (token) { setIsAuthenticated(true); } else { setIsAuthenticated(false); } setIsAuthChecked(true); }, []); return { isAuthenticated, isAuthChecked };} 这个 Hook 会检查 localStorage 中是否存在 jwtToken,如果存在则认为用户已经登录、设置 isAuthenticated 为 true,否则设置为 false。 为了不重复进行这个检查,我们还设置了一个 isAuthChecked 状态,用于标记用户是否已经进行了身份验证。 在我们当前的项目中,身份验证无非有两种必需导向用户到不同页面的情况: 用户未登录,但是访问了首页,这时我们需要将用户导向登录页面。 用户已经登录,但是访问了登录或注册页面,这时我们需要将用户导向首页。 我们先解决第一个问题。新建一个 ProtectedRoute 组件(意味着只有登录用户才能访问的路由): export const ProtectedRoute: React.FC<RouteProps> = ({ children }) => { const { isAuthenticated, isAuthChecked } = useAuth() const navigate = useNavigate() useEffect(() => { if (!isAuthenticated && isAuthChecked) { if (window.location.pathname === '/channels/@me') { navigate('/login', { replace: true }) } } }, [isAuthenticated, isAuthChecked, navigate]) if (!isAuthChecked) return null return <>{isAuthenticated ? children : <Navigate to="/login" replace />}</>} 在 App.tsx 中使用这个组件: function App() { return ( <BrowserRouter> <Routes> <Route path="/" element={ <Navigate to="/channels/@me" replace /> } /> <Route path="/channels/@me/*" element={ <ProtectedRoute><Home /></ProtectedRoute> } /> {/* <--- 这里 */} {/* ... */} </Routes> </BrowserRouter> );} 当用户尝试访问 /channels/@me 页面或其子页面时,如果用户未登录,那么就会被导向登录页面。 第二个问题同理,只要写一个逻辑相反的 UnauthenticatedRoute 组件即可: export const UnauthenticatedRoute: React.FC<RouteProps> = ({ children }) => { const { isAuthenticated, isAuthChecked } = useAuth() const navigate = useNavigate() useEffect(() => { if (isAuthenticated && isAuthChecked) { navigate('/channels/@me', { replace: true }) } }, [isAuthenticated, isAuthChecked, navigate]) if (!isAuthChecked) return null return <>{!isAuthenticated ? children : <Navigate to="/channels/@me" replace />}</>} 使用方法也是一样的: function App() { return ( <BrowserRouter> <Routes> // ... <Route path="/login" element={ <UnauthenticatedRoute><Login /></UnauthenticatedRoute> } /> <Route path="/register" element={ <UnauthenticatedRoute><Register /></UnauthenticatedRoute> } /> </Routes> </BrowserRouter> );} ESLint 和 Prettier 配置 我开发这个项目一直用的是 WebStorm。最近换设备后,一进入客户端的目录后都会弹出错误,说是 ESLint 配置冲突,就决定重新为两个目录配置 ESLint 和 Prettier。 服务端因为新建项目时就自带了 .eslintrc.js 和 .prettierrc 文件,所以只需要在客户端新建这两个文件即可。 客户端下安装必要的包: npm install --save-dev eslint-plugin-prettier eslint-config-prettier 新建 .eslintrc.js 文件: module.exports = { parser: '@typescript-eslint/parser', parserOptions: { project: 'tsconfig.json', // <--- 这个文件是WebStorm新建项目时自动生成的 tsconfigRootDir: __dirname, sourceType: 'module', }, plugins: ['@typescript-eslint/eslint-plugin', 'react'], extends: [ 'plugin:@typescript-eslint/recommended', 'plugin:react/recommended', 'plugin:prettier/recommended', ], root: true, env: { browser: true, jest: true, es6: true, }, rules: { '@typescript-eslint/interface-name-prefix': 'off', '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'off', 'react/prop-types': 'off', },}; .prettierrc 文件我直接搬的服务端的配置,如下: { "singleQuote": true, "trailingComma": "all", "endOfLine": "lf", "arrowParens": "always", "bracketSameLine": true, "bracketSpacing": true, "embeddedLanguageFormatting": "auto", "htmlWhitespaceSensitivity": "css", "insertPragma": false, "jsxSingleQuote": false, "printWidth": 120, "proseWrap": "never", "quoteProps": "as-needed", "requirePragma": false, "semi": false, "tabWidth": 2, "useTabs": false, "vueIndentScriptAndStyle": false, "singleAttributePerLine": false} 重启 WebStorm 后,错误提示消失,一切正常。 其他 我长期没有更新的原因非常简单,我于三月底的时候遇到了一个不知道是什么 BUG 的 BUG。无论怎么修改代码,客户端都无法连接到服务端,而服务端也没有报告详细的错误信息。那时候直接摆烂了,也没有再继续开发。 最近重新看了一下代码,发现问题竟然是服务端的 logger.middleware.ts 的一个参数被我手贱抹去! import { Injectable, NestMiddleware } from '@nestjs/common'import { Request, Response, NextFunction } from 'express'@Injectable()export class LoggerMiddleware implements NestMiddleware { use(req: Request, res: Response, next: NextFunction) { // <--- res参数被我删掉了 console.log('Request...', req.method, req.originalUrl) next() }} 那么为什么这个异常没有被服务端捕获呢?因为我的全局异常过滤器也没有写好! 这是原先的全局异常过滤器: import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'@Catch()export class AnyExceptionFilter implements ExceptionFilter { catch(exception: any, host: ArgumentsHost) { const ctx = host.switchToHttp() const response = ctx.getResponse() const request = ctx.getRequest() const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR const message = exception instanceof HttpException ? exception.getResponse() : 'Internal server error' response.status(status).json({ statusCode: status, timestamp: new Date().toISOString(), path: request.url, message: message, }) }} 能发现问题没有?status 和 message 都被我写死了!如果异常不是 HttpException,那么这两个值就都只会是 500 和 Internal server error。 这是我修改后的全局异常过滤器: import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'import { Request, Response } from 'express'@Catch()export class AnyExceptionFilter implements ExceptionFilter { catch(exception: any, host: ArgumentsHost) { const ctx = host.switchToHttp() const response = ctx.getResponse<Response>() const request = ctx.getRequest<Request>() const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR const message = exception instanceof HttpException ? exception.getResponse() : exception console.error(exception) response.status(status).json({ statusCode: status, timestamp: new Date().toISOString(), path: request.url, message: message, }) }} 现在,无论异常是什么,都会被正确地捕获并返回给客户端。 希望大家以我为戒,不要犯我这样的错误。 新添加但没有完成的东西 服务端新增了 Socket 模块,毕竟完成了注册登录、JWT 生成验证等功能后,下一个就是老生常态的消息接收发送。既然我们使用的是 Socket.IO,那就要新建一个 Socket 模块。 SocketModule: import { Module } from '@nestjs/common';import { SocketService } from './socket.service';import { SocketGateway } from './socket.gateway';@Module({ providers: [SocketGateway, SocketService],})export class SocketModule {} SocketService: import { Injectable } from '@nestjs/common'import { Server } from 'socket.io'@Injectable()export class SocketService { private server: Server initialize(server: Server) { this.server = server } sendMessage(event: string, message: any) { this.server.emit(event, message) }} initialize 方法用于初始化 Socket 服务器。 sendMessage 方法用于向所有连接的客户端发送消息。 这里讲一下 Controller 和 Gateway 的区别。 在传统的 HTTP 请求 / 响应模型中,Controller 负责处理来自客户端的请求并返回响应。然而,我们在使用 WebSocket 时,不再有请求和响应的概念,而是有事件和消息的概念。这时,Gateway(网关)就是用来处理这些事件和消息的。 SocketGateway: import { WebSocketGateway, WebSocketServer, SubscribeMessage, MessageBody, OnGatewayInit } from '@nestjs/websockets'import { Server, Socket } from 'socket.io'import { SocketService } from './socket.service'@WebSocketGateway(3001, { allowEIO3: true, cors: { origin: process.env.CLIENT_ORIGIN || 'http://localhost:3000', credentials: true, },})export class SocketGateway implements OnGatewayInit { @WebSocketServer() server: Server constructor(private readonly socketService: SocketService) {} afterInit(server: Server) { this.socketService.initialize(server) } @SubscribeMessage('privateMessageSent') handlePrivateMessage(@MessageBody() data: any, client: Socket) { console.log('Received private message:', data, 'from', client) }} 在 SocketGateway 中,我们使用 this.socketService.initialize(server) 初始化了 Socket 服务器,然后使用 @SubscribeMessage 装饰器来监听客户端发送的消息。 我们可以在客户端发送消息时调用 socket.emit('privateMessageSent', message)。来看一下我的输入框 PrivateMessageTextBox 组件: import React, { useState, KeyboardEvent, ChangeEvent, FormEvent } from "react";import { Icon } from "@iconify/react";import socket from "../../redux/actions/messageActions";import { UsersService } from '../../redux/actions/serverConnection'interface PrivateMessageTextBoxProps { receiverUsername: string;}const PrivateMessageTextBox: React.FC<PrivateMessageTextBoxProps> = ({ receiverUsername }) => { const [message, setMessage] = useState<string>(""); const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSendMessage(); } } const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => { setMessage(e.target.value); } const handleSendMessage = async (e?: FormEvent) => { e && e.preventDefault(); const senderId = localStorage.getItem("userId"); const response = await UsersService.getUserByUsername(receiverUsername); const receiver = response.data; socket.emit("privateMessageSent", { id: `${socket.id}${Math.random()}`, senderId: senderId, receiverId: receiver._id, text: message }); setMessage(""); } return ( <form className="px-2 m-3" onSubmit={ handleSendMessage }> <div className="w-100 p-0 m-0" style={{ marginBottom: '24px', backgroundColor: 'rgba(56, 58, 64)', textIndent: '0', borderRadius: '8px' }} > <div className="overflow-x-hidden overflow-y-scroll" style={{ borderRadius: '8px', backfaceVisibility: 'hidden', scrollbarWidth: 'none' }} > <div className="d-flex position-relative"> <span className="position-sticky" style={{ flex: '0 0 auto', alignSelf: 'stretch' }}> <Icon icon="bi:plus-circle-fill" className="position-sticky w-auto m-0" style={{ height: '44px', padding: '10px 16px', top: '0', marginLeft: '-16px', background: 'transparent', color: 'rgba(181, 186, 193)', border: '0' }} /> </span> <span className="p-0 fs-6 w-100 position-relative" style={{ background: 'transparent', resize: 'none', border: 'none', appearance: 'none', fontWeight: '400', lineHeight: '1.375rem', height: '44px', minHeight: '44px', boxSizing: 'border-box', color: 'rgba(219, 222, 225)', }} > <textarea autoCapitalize="none" autoComplete="off" autoCorrect="off" autoFocus={ true } placeholder="Text @dummy" spellCheck="true" className="position-absolute overflow-hidden" value={ message } onChange={ handleChange } onKeyDown={ handleKeyDown } style={{ border: 'none', outline: 'none', resize: 'none', paddingBottom: '11px', paddingTop: '11px', paddingRight: '10px', left: '0', right: '10px', background: 'transparent', caretColor: 'rgba(219, 222, 225)', color: 'rgba(219, 222, 225)' }} /> </span> </div> </div> </div> </form> )}export default PrivateMessageTextBox; 这个组件是一个输入框,用户输入消息后按下回车键就会发送消息。发送的消息会被 SocketGateway 监听到,然后打印到控制台。 其他的地方可以忽略掉不看,只需要重点看 handleSendMessage 方法。这个方法会向服务器发送一个 privateMessageSent 事件,事件的数据是一个对象,包含了消息的 ID、发送者 ID、接收者 ID 和消息内容: const handleSendMessage = async (e?: FormEvent) => { e && e.preventDefault(); const senderId = localStorage.getItem("userId"); const response = await UsersService.getUserByUsername(receiverUsername); const receiver = response.data; socket.emit("privateMessageSent", { id: `${socket.id}${Math.random()}`, senderId: senderId, receiverId: receiver._id, text: message }); setMessage("");} 这里提一嘴 userId 的来处。 在服务端的 AuthController 的 login 方法中,我们向客户端发送了一个 Token,但我们也可以发送其他东西,比如用户的 ID。 const user = await this.usersService.findByUsername(loginUserDto.username)res.status(HttpStatus.OK).json({ status: '00000', message: 'User logged in successfully', token: resultToken, userId: user._id,}) 在客户端也要写对应的接收逻辑: const loginUser = (userData: User, navigate: (path: string) => void) => { return async (dispatch: Dispatch) => { try { const response = await UsersService.login(userData) const data = response.data if (data.status === '00000') { localStorage.setItem('jwtToken', data.token) localStorage.setItem('userId', data.userId) // <--- 这里 dispatch( setCurrentUser({ ...userData, access_token: data.token, id: data.userId, // <--- 也把ID存储到Redux里,说不定未来会用到呢 }), ) console.log(data) navigate('/') } else console.log(data.message) } catch (error) { console.error(error) } }} 消息从客户端发送到服务端后,服务端会打印出消息的内容。 一个简单的消息发送就这样完成了。 接下来还需要完成消息的接收、存储和展示,这个就留到下一篇文章再说了。

2024/4/28
articleCard.readMore

Pug 学习笔记

我的博客网站基于 Hexo,使用了 Pug 模板引擎。有时候感觉现在正在使用的主题不够满足自己的需求,就得手动修改 Pug 文件。 1. Pug 基础 Pug 过去被称为 Jade,是一种高性能的模板引擎,广泛用于 Node.JS。它使用简洁的缩进语法,使得 HTML 更易于编写和阅读。 1.1. Pug 语法 Pug 使用缩进来表示标签的嵌套关系: doctype htmlhtml head title My Pug Template body h1 Hello, Pug! 这将生成以下 HTML: <!DOCTYPE html><html> <head> <title>My Pug Template</title> </head> <body> <h1>Hello, Pug!</h1> </body></html> 树状结构: ul li Item 1 li Item 2 li Item 3 标签后面使用括号来添加属性,并使用空格来添加内容: a(href="https://www.example.com") Visit Example.com 约等于: <a href="https://www.example.com">Visit Example.com</a> 你可以声明一个标签是自闭合的,加上一个斜杠的话: img/ 纯文本使用管道符 |: p | This is a paragraph of text. | This is another line of text. 标签内文本是最常见的: p This is a paragraph of <strong>text</strong> . Pug 模板中也可以使用 JavaScript 变量: - var name = 'World'h1 Hello, #{name}! <h1>Hello, World!</h1> 以及标准的 JavaScript 控制结构: - var user = { name: 'John', admin: true }if user.admin p Hello, #{user.name}! You are an admin.else p Hello, #{user.name}! <p>Hello, John! You are an admin.</p> 2. Hexo 中的 Pug Hexo 可以使用 Pug 模板引擎,但是需要安装 hexo-renderer-pug 插件: npm install hexo-renderer-pug --save 2.1. 配置项 在 Pug 模板中可以使用 theme 对象来访问主题配置项: year = year == theme.startyear ? year : theme.startyear + ' - ' + year; 而在 _config.yml 中内容如下: startyear: 2022 对于 Hexo 项目根目录下的 _config.yml 文件,可以使用 config 对象来访问配置项: title = config.title 2.2. 页面布局 模板文件模板文件,重点便是「模板」这个词。我们可以将一些重复的内容放在模板文件中,然后在其他页面中引用。 比方说 HTML 的 <head> 标签: // head.pugmeta(charset="utf-8")meta(name="X-UA-Compatible", content="IE=edge")title block site_title = config.titleblock description meta(name="description", content= config.description ? config.description : 'A Blog Powered By Hexo')meta(name="viewport", content="width=device-width, initial-scale=1")link(rel="icon", href=url_for(theme.favicon))link(rel="stylesheet", href=url_for("css/hermes.css"))- var xml = config.url + '/atom.xml' link(rel="search", type="application/opensearchdescription+xml", href=xml, title=config.title) 又或者说导航栏、页脚等等。 layout.pug 文件是一个特殊的模板文件,它是所有页面的父模板。这里是一个简单的例子: doctypehtml(lang=config.language) head include head body header include header.pug main block content footer include footer.pug block 是一个特殊的标签,它允许子模板文件覆盖父模板文件中的内容。比如说,我们可以在 layout.pug 中定义一个 block content,然后在其他页面中覆盖它。 extends layoutblock content h1 Hello, Pug! p This is a paragraph. extends 则是用来引用父模板文件的。

2024/4/3
articleCard.readMore

React + NestJS + Socket.io 项目实践【1】:从 Express 迁移

二月份的时候我写了一篇 React + Express + Socket.io 之间的实时通信【2】:注册登录,那时候我还在用 Express 作为后端框架。 因为中途想到使用 TypeScript,所以我决定迁移到 NestJS。 前端 先说一下我对前端的改动。 因为是想要整个项目都用 TypeScript,所以我把 src 目录下的所有 .js 文件都改成了 .tsx。 很多文件都不需要改动,例如 App.js: import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";import "./App.css";import "bootstrap/dist/css/bootstrap.min.css";import Home from "./components/Home";import Login from "./components/Login";import Register from "./components/Register";import PrivateMessageHomepage from "./components/private_message_homepage/Private_Message_Homepage";import PrivateMessageChatpage from "./components/private_message_chatpage/Private_Message_Chatpage";import socket from "./components/utils/actions/authActions";function App() { return ( <BrowserRouter> <Routes> <Route path="/" element={ <Navigate to="/channels/@me" replace /> } /> <Route path="/channels/@me" element={ <Home /> }> <Route path="" element={ <PrivateMessageHomepage style={{ flex: '1 1 auto' }} /> } /> <Route path="dummy" element={ <PrivateMessageChatpage style={{ flex: '1 1 auto' }} /> }/> </Route> <Route path="/login" element={ <Login socket={ socket } /> } /> <Route path="/register" element={ <Register /> } /> </Routes> </BrowserRouter> );}export default App; App.tsx: import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";import "bootstrap/dist/css/bootstrap.min.css";import "./App.css";import Home from "./components/Home";import Login from "./components/Login";import Register from "./components/Register";import Guard from "./components/utils/guard";// import PrivateMessageHomepage from "./components/private_message_homepage/Private_Message_Homepage";import PrivateMessageChatPage from "./components/private_message_chat_page/Private_Message_Chat_Page";function App() { return ( <BrowserRouter> <Routes> <Route path="/" element={ <Navigate to="/channels/@me" replace /> } /> <Route path="/channels/@me" element={ <Guard /> }> <Route path="" element={ <Home />} /> <Route path="dummy" element={ <PrivateMessageChatPage style={{ flex: '1 1 auto' }} /> }/> </Route> <Route path="/login" element={ <Login /> } /> <Route path="/register" element={ <Register /> } /> </Routes> </BrowserRouter> );}export default App; 对比一下会发现其实没有变化,PrivateMessageHomepage 改成了 Guard 只是因为业务逻辑的改动,跟 TypeScript 无关。 Redux 涉及到 Redux 的文件多多少少都有一些改动。 store.js: import { configureStore } from "@reduxjs/toolkit";import authSlice from "./reducers/authSlice";export default configureStore({ reducer: { auth: authSlice }}); store.ts: import { configureStore } from "@reduxjs/toolkit";import { useDispatch } from "react-redux";import authSlice from "./reducers/authSlice";const store = configureStore({ reducer: { auth: authSlice, }})export type RootState = ReturnType<typeof store.getState>;export type AppDispatch = typeof store.dispatch;export const useAppDispatch = () => useDispatch<AppDispatch>();export default store; 原先的 store.js 是直接导出了 configureStore 的返回值;store.ts 先是导出了 RootState 和 AppDispatch 这两个类型,然后道出了 useAppDispatch 这个自定义 Hook、以替代 useDispatch。 authSlice.js: import { createSlice } from "@reduxjs/toolkit";export const authSlice = createSlice({ name: "auth", initialState: { isAuthenticated: false, user: {}, error: null }, reducers: { setCurrentUser: (state, action) => { state.isAuthenticated = true; state.user = action.payload; }, setError: (state, action) => { state.error = action.payload; } }});export const { setCurrentUser, setError } = authSlice.actions;export default authSlice.reducer; authSlice.ts: import { createSlice, PayloadAction } from '@reduxjs/toolkit';import { User } from '../interfaces';interface AuthState { isAuthenticated: boolean; user: User | null; error: string | null;}const initialState: AuthState = { isAuthenticated: false, user: null, error: null,};export const authSlice = createSlice({ name: 'auth', initialState, reducers: { setCurrentUser: (state, action: PayloadAction<User>) => { state.isAuthenticated = true; state.user = action.payload; }, setError: (state, action: PayloadAction<string>) => { state.error = action.payload; }, },});export const { setCurrentUser, setError } = authSlice.actions;export default authSlice.reducer; authSlice.js 和 authSlice.ts 的区别在于 action 的类型声明。TypeScript 是 JavaScript 的超集,目的是为了更好地进行静态类型检查,以避免各种各样的错误。要知道 JavaScript 是弱类型语言,这意味着你可以在不同的地方使用不同的类型而不报错。 interface 关键字用于定义一个接口,接口是一种抽象的结构、定义了一个对象应该具有的属性和方法。AuthState 接口被定义后,有三个属性:isAuthenticated、user 和 error。然后设置 initialState 为 AuthState 类型。 也就是说 initialState 无论怎么改动,都必须符合 AuthState 的结构。假设我在 initialState 的 isAuthenticated 属性后面加了一个 isRegistered 属性,那么在 reducers 中的 state 就会报错,因为 isRegistered 属性并不在 AuthState 中。 setCurrentUser 和 setError 的 action 参数都是 PayloadAction 类型,PayloadAction 是一个泛型接口,接受一个类型参数,这个类型参数就是 action.payload 的类型。这样一来,action.payload 的类型就被限制了,不会出现不符合预期的情况。 比方说 setError 的 action 参数就被限制为 string 类型。 我还新建了一个 interfaces.ts 文件来存放会被多个文件引用的接口: export interface User { id?: number; emailAddress?: string; username: string; password?: string; access_token?: string;} 属性后面的 ? 表示这个属性是可选的。想象一下,User 接口会被注册、登录以及登出的组件引用: 注册时,用户传来的数据中不会有 id、access_token 这两个属性 登录时,用户传来的数据中不会有 emailAddress 这个属性 登出时,用户传来的数据中不会有 password 这个属性 所以这些属性都是可选的。 Axios 我本来是全程使用 Socket.io 来进行通信,这也包括了登录、注册等操作。但是 Socket.io 并不适合用来做这些操作,所以我还是用了 Axios。 比方说注册用户,使用 Socket.io 的话就是这样: const registerUser = (userData, navigate) => { return dispatch => { socket.emit("register", userData); socket.on("newRegisteredUser", data => { data.status === "00000" ? navigate("/login") : console.log(data.message); }); }}; 先发送 register 事件,然后接收从服务端发来的 newRegisteredUser 事件,根据 data.status 的值来决定跳转到登录页面还是打印错误信息。 换成 Axios 的话,要先创建一个服务: import axios from 'axios';import { User } from "../interfaces";const api = axios.create({ baseURL: 'http://localhost:4000/api',});export const UsersService = { register: (data: User) => { return api.post('/users/register', data); }, login: (data: User) => { return api.post('/users/login', data); },} 使用 baseURL 的好处是如果后端地址改变、只需要改动一次就行了。 UsersService 对象中有两个方法:register 和 login,分别用于注册和登录。当我们需要向后端发送请求时,只需要调用这些方法即可: const registerUser = (userData: User, navigate: (path: string) => void) => { return async (dispatch: Dispatch) => { try { const response = await UsersService.register(userData); const data = response.data; if (data.status === "00000") navigate("/login"); } catch (error) { console.error(error); } }} 我还添加了一个 try...catch 语句,用于捕获请求失败的情况。 登录和注册 在写函数组件时,需要定义函数组件的类型: import React from "react";// ...const Register: React.FC = () => { // ...} 如果该函数组件还有 props,那么就需要定义 props 的类型: import React from 'react';// ...interface PrivateMessageHomepageProps { style: React.CSSProperties;}const PrivateMessageHomepage: React.FC<PrivateMessageHomepageProps> = ({ style }) => { // ...} 例如这里的 PrivateMessageHomepage 组件有一个 style 属性,所以需要定义其类型为 React.CSSProperties,毕竟是一个 CSS 样式对象嘛。 之前讲到共用的 User 接口,但对于注册来说还需要 4 个必需的属性:emailAddress、birthYear、birthMonth 和 birthDay。其中 emailAddress 虽在 User 接口中,但是是可选的,不符合注册的要求。 所以我们可以使用 & 运算符来合并两个接口: import { User } from "./utils/interfaces";// ...type RegisterUser = User & { emailAddress: string; birthYear: string; birthMonth: string; birthDay: string;} RegisterUser 接口继承了 User 接口,并添加了 4 个必需的属性。后加的属性会覆盖前面的属性,所以 User 接口中的 emailAddress 属性被覆盖了。 之后再使用 useState 来定义 userData: const [userData, setUserData] = useState<RegisterUser>({ username: "", emailAddress: "", password: "", birthYear: "", birthMonth: "", birthDay: "" }); 登录时我们不需要 User 接口中其他的属性,只需要 username 和 password 属性。所以我们可以挑选出需要的属性: type LoginUser = Pick<User, "username" | "password">; Pick 的用处是从一个对象中挑选出一些属性,返回一个新的对象。这意味着 LoginUser 接口只包含 User 接口中的 username 和 password 属性。 像是需要展现出用户名的地方我们也可以这样写: type UserProfile = Pick<User, "username">; 页面跳转 最前面提及到的 Guard 组件是用来判断用户是否登录的,如果用户没有登录,那么就跳转到登录页面: import React from "react";import { Navigate, Outlet } from "react-router-dom";const Guard: React.FC = () => { const auth = localStorage.getItem("auth"); if (auth) return <Outlet/>; else return <Navigate to="/login" />;}export default Guard; 如果 localStorage 中有 auth 这个键,那么就渲染 Outlet 组件,否则就跳转到登录页面。 Outlet 组件是用来渲染子路由的。回到 App.tsx,能看到 /channels/@me 路由被 Guard 组件保护,子路由分别是默认的 Home 组件和 dummy 子路由。 也就是说用户在登陆后跳转到 /channels/@me 路由,会被 Guard 组件验证,然后渲染 Home 组件。 /channels/@me/dummy 路由是用来测试私聊的,但是它也在 Guard 组件的保护之下。 其他 我的项目中有一些按钮在被鼠标悬停时会有一些样式变化,原先的逻辑是这样的: const [hoverStates, setHoverStates] = useState({});const updateHoverState = (item, isHovered) => { setHoverStates(prev => ({ ...prev, [item]: isHovered }));} 转换到 TypeScript 后: const [hoverStates, setHoverStates] = useState<Record<string, boolean>>({});const updateHoverState = (item: string, isHovered: boolean) => { setHoverStates({ ...hoverStates, [item]: isHovered }); } Record 的第一个参数定义了键的类型,第二个参数定义了值的类型。hoverStates 被定义为一个字典,键和值再被定义为 string 和 boolean 类型。 输入框组件里分别有着: 判断用户是否按下了回车键的 handleKeyDown 函数 处理用户输入信息的 handleChange 函数 处理用户提交表单的 handleSendMessage 函数 这些函数都需要定义类型: const handleKeyDown = (e: KeyboardEvent) => {}const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {}const handleSendMessage = (e: FormEvent) => {} 后端 NestJS 是一个基于 Node.JS 的后端框架,它使用 TypeScript 编写,提供了一些装饰器来简化开发。 我目前有的依赖: { "@nestjs/common": "^10.3.3", "@nestjs/core": "^10.3.3", "@nestjs/jwt": "^10.2.0", "@nestjs/mapped-types": "*", "@nestjs/mongoose": "^10.0.4", "@nestjs/platform-express": "^10.3.3", "@nestjs/platform-socket.io": "^10.3.3", "@nestjs/typeorm": "^10.0.2", "@nestjs/websockets": "^10.3.3", "bcrypt": "^5.1.1", "class-validator": "^0.14.1", "cookie-parser": "^1.4.6", "dotenv": "^16.4.5", "mongoose": "^8.2.1", "morgan": "^1.10.0", "reflect-metadata": "^0.2.1", "rxjs": "^7.8.1", "typeorm": "^0.3.20"} NestJS 的入口文件是 main.ts: import { NestFactory } from '@nestjs/core'import { AppModule } from './app.module'import * as dotenv from 'dotenv'import { Server } from 'socket.io'dotenv.config()async function bootstrap() { const app = await NestFactory.create(AppModule) app.enableCors({ origin: process.env.CLIENT_ORIGIN || 'http://localhost:3000', credentials: true, }) app.setGlobalPrefix('api') const server = app.getHttpServer() new Server(server) await app.listen(process.env.PORT || 4000)}bootstrap().catch((err) => console.error(err)) dotenv 是用来读取 .env 文件的,.env 文件用来存放环境变量。CLIENT_ORIGIN 是前端的地址,PORT 是后端的端口。 因为前后端分离的项目中,前端和后端是不同的域名,所以会有跨域问题。app.enableCors 方法用来解决跨域问题,origin 参数是前端的地址,credentials 参数是 true 表示允许携带 cookie。 app.setGlobalPrefix 方法用来设置全局前缀,所有的路由都会加上这个前缀。比方说后面设置的 /users/register 路由会变成 /api/users/register。 app.getHttpServer 方法返回一个 http.Server 实例,new Server(server) 用来创建一个 Socket.io 服务器。 最后调用 app.listen 方法来启动服务器。 NestJS 概念 NestJS 目前支持两个 HTTP 平台:Express 和 Fastify。这是因为 NestJS 的开发团队认为 NestJS 立志于成为一个模块化的框架,不单单是一个 HTTP 框架。只要创建了适配器,NestJS 就可以在任何平台上运行。 NestJS 的核心概念有: 控制器(Controller) 服务(Service) 模块(Module) 控制器 控制器是处理传入请求的地方,它们会调用服务来完成请求。控制器的方法可以使用装饰器来定义路由。 import { Controller, Get } from '@nestjs/common'import { AppService } from './app.service'@Controller()export class AppController { constructor(private readonly appService: AppService) {} @Get() getHello(): string { return this.appService.getHello() }} @Controller 装饰器用来定义一个控制器。假设我们的后端地址是 http://localhost:4000,那么 @Controller() 装饰器的参数就是 http://localhost:4000。 @Get() 装饰器用来定义一个 GET 请求,这个请求的路径就是控制器的路径,也就是请求 http://localhost:4000。 假设我想要请求 http://localhost:4000/api/users/register,那么就要写成: @Controller('users')@Get('register') 为什么不写成 @Controller('api/users') 呢?因为全局前缀已经被我们设置为 api 了。 服务 刚才的控制器中有一个 AppService 服务,服务是处理业务逻辑的地方。服务可以被控制器调用,也可以被其他服务调用。 import { Injectable } from '@nestjs/common'@Injectable()export class AppService { getHello(): string { return 'Welcome to HotaruTS!!' }} @Injectable 装饰器用来定义一个服务。服务中的方法可以被其他服务调用,也可以被控制器调用。 刚才调用的是 getHello 方法,返回的是一个字符串。如果使用 Postman 请求 http://localhost:4000,响应的内容就是 Welcome to HotaruTS!!。 模块 模块是一个用来组织应用程序的地方,每个应用程序至少有一个根模块。模块中可以包含控制器、服务、提供器等。 根模块可以看成是 Express 里的 app 对象,它是所有模块的入口。 先来看一下我 Express 项目中的 app.js: const createError = require('http-errors');const express = require('express');const path = require('path');const cookieParser = require('cookie-parser');const logger = require('morgan');const indexRouter = require('./routes/index');const usersRouter = require('./routes/users');const app = express();app.set('views', path.join(__dirname, 'views'));app.set('view engine', 'pug');app.use(logger('dev'));app.use(express.json());app.use(express.urlencoded({ extended: false }));app.use(cookieParser());app.use(express.static(path.join(__dirname, 'public')));app.use('/', indexRouter);app.use('/users.js', usersRouter);app.use(function(req, res, next) { next(createError(404));});app.use(function(err, req, res, next) { res.locals.message = err.message; res.locals.error = req.app.get('env') === 'development' ? err : {}; res.status(err.status || 500); res.render('error');});module.exports = app; 导入依赖 创建路由器 indexRouter 和 usersRouter 创建 app 对象,也就是 Express 的实例 设置视图引擎和视图路径 使用中间件,分别是: logger:记录请求日志,dev 参数表示开发环境 express.json:解析 JSON 格式的请求体 express.urlencoded:解析 URL 编码的请求体,extended 参数表示是否使用 qs 库 cookieParser:解析 cookie express.static:设置静态文件目录,也就是 public 目录 配置路由,让 indexRouter 和 usersRouter 分别处理 / 和 /users 路径 处理 404 错误 处理其他错误 导出 app 对象 得知了这些,我们就可以仿照 Express 的写法来写 NestJS 的模块。 首先导入依赖: import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common'import { APP_FILTER } from '@nestjs/core'import { MongooseModule } from '@nestjs/mongoose'import * as express from 'express'import * as cookieParser from 'cookie-parser'import * as morgan from 'morgan'import { AppController } from './app.controller'import { AppService } from './app.service'import { AnyExceptionFilter } from './any-exception.filter'import { LoggerMiddleware } from './common/middleware/logger.middleware'import { SocketModule } from './socket/socket.module'import { UsersModule } from './users/users.module' @Module 装饰器可以定义一个模块,参数分别是: imports:导入其他模块 controllers:控制器 providers:提供器 因为这个项目的数据库是 MongoDB,所以我导入了 MongooseModule 模块。SocketModule 和 UsersModule 是自定义的模块。 UsersModule 后面会详细讲解,SocketModule 等未来写到消息传递时再讲。 控制器就不用多说了,模块本来就是用来组织控制器的。AppModule 中只有一个控制器 AppController。 提供器是一个用来提供服务的地方,服务可以被控制器调用。AppService 就是一个提供器。除此之外,还有一个全局异常过滤器 AnyExceptionFilter。 @Module({ imports: [ MongooseModule.forRoot(process.env.DATABASE_URL || 'mongodb://localhost:27017/hotaru'), SocketModule, UsersModule, ], controllers: [AppController], providers: [ AppService, { provide: APP_FILTER, useClass: AnyExceptionFilter, }, ],}) 为 AppModule 添加了一个 configure 方法,这个方法是 NestModule 接口的一个方法,作用是添加中间件。 consumer.apply 方法用来添加中间件,参数是一个或多个中间件。这里添加了 morgan、express.json、express.urlencoded、cookieParser、express.static 和 LoggerMiddleware 中间件。 forRoutes('*') 表示所有路由都会使用这些中间件。 最终导出 AppModule: export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { consumer .apply( morgan('dev'), express.json(), express.urlencoded({ extended: false }), cookieParser(), express.static('public'), LoggerMiddleware, ) .forRoutes('*') }} 这样,我们就完成了一个 NestJS 的模块。 日志中间件 LoggerMiddleware 中间件是一个自定义的中间件,用来记录请求日志: import { Injectable, NestMiddleware } from '@nestjs/common';import { Request, Response, NextFunction } from 'express';@Injectable()export class LoggerMiddleware implements NestMiddleware { use(req: Request, res: Response, next: NextFunction) { console.log('Request...', req.method, req.originalUrl); next(); }} 目前这个中间件只是简单地打印请求方法和请求路径。 用户模块 用户模块是一个用来处理用户注册、登录、登出的模块。 在 NestJS 里创建一个模块可以使用 CLI: nest g module users 这个命令会在 src 目录下创建一个 users 目录,里面有一个 users.module.ts 文件。 首先我们得知道原先的 Express 项目里,用户注册的逻辑是怎么写的: socket.on("register", async userData => { const existingUserEmail = await User.findOne({ emailAddress: userData.emailAddress }); const existingUsername = await User.findOne({ username: userData.username }); if (existingUserEmail || existingUsername) { console.log(`[U0102] User already exists: ${userData.username}`); User.find({}).then((docs) => { console.log(docs); }).catch((err) => { console.error(err); }); socket.emit("newRegisteredUser", { status: "U0102", message: "User already exists." }); return; } await User.create({ emailAddress: userData.emailAddress, username: userData.username, password: userData.password, DOBYear: userData.birthYear, DOBMonth: MonthToNumber[userData.birthMonth], DOBDay: userData.birthDay }) console.log(`[00000] User registered: ${userData.username}`); socketIO.emit("newRegisteredUser", { status: "00000", token: generateJWT(userData.username) });}); 因为使用的是 Socket.io,所以要监听 register 事件,然后验证用户填写的信息。如果用户已经存在,就返回错误信息;如果用户不存在,就创建一个新用户。 其中的状态码是自定义的,采用的是类似于阿里巴巴代码规约的状态码。 在 NestJS 中,鉴于客户端已经改为使用 Axios 这样的 HTTP 库,我们就不再使用 Socket.io 了。先创建一个路径为 /users 的控制器: @Controller('users')export class UsersControllers { constructor(private readonly usersService: UsersService) { }} constructor 方法中注入了一个私有且只读的 usersService 服务,这意味着这个服务只能在这个控制器中使用。 @Post('register')async register(@Body() registerUserDto: RegisterUserDto, @Res() res: Response) {} @Post('register') 装饰器用来定义一个 POST 请求,请求路径是 /users/register。 @Body() 装饰器用来获取请求体,registerUserDto 是一个数据传输对象,包含了用户注册时需要的信息,也就是客户端传来的数据: emailAddress username password birthYear birthMonth birthDay @Res() 装饰器用来获取响应对象。 const existingUser = await this.usersService.findByEmail(registerUserDto.emailAddress)if (existingUser) { throw new HttpException('Email address already in use', HttpStatus.BAD_REQUEST)}if (!registerUserDto.emailAddress) { throw new HttpException('Email address is required', HttpStatus.BAD_REQUEST)}try { const user = this.usersService.register(registerUserDto) res.status(HttpStatus.OK).json({ status: '00000', message: 'User registered successfully', user: user, })} catch (error) { res.status(HttpStatus.BAD_REQUEST).json({ status: 'U0100', message: 'Failed to register user', })} 首先调用 this.usersService.findByEmail 方法来查找用户是否已经存在,如果存在就返回错误信息。接着判断用户填写的信息是否完整,如果不完整就返回错误信息。 最后调用 this.usersService.register 方法来注册用户,如果注册成功就返回成功信息,否则返回错误信息。 每次返回响应时都要设置状态码,比方说请求成功时写的 HttpStatus.OK,请求失败时写的 HttpStatus.BAD_REQUEST。尽管已经自定义了一套状态码,但是还是要遵循 HTTP 协议的状态码,谁叫我们是用 HTTP 协议的呢。 那么 UsersService 里到底是什么样的呢? import { Injectable, UnauthorizedException } from '@nestjs/common'import { JwtService } from '@nestjs/jwt'import { InjectModel } from '@nestjs/mongoose'import { v4 as uuidv4 } from 'uuid'import * as bcrypt from 'bcrypt'import { Model } from 'mongoose'import { RegisterUserDto, LoginUserDto } from './dto'import { User, UserDocument } from './user.schema'@Injectable()export class UsersService { constructor( @InjectModel(User.name) private usersModel: Model<UserDocument>, private jwtService: JwtService, ) { } async register(registerUserDto: RegisterUserDto): Promise<void | User> { const id = uuidv4() const hashedPassword = await bcrypt.hash(registerUserDto.password, 10) const newUser = new this.usersModel({ id, ...registerUserDto, password: hashedPassword, }) let savedUser: void | User try { savedUser = await newUser.save().then(() => console.log('User registered successfully')) } catch (err) { console.error(err) } return savedUser } async findByEmail(emailAddress: string): Promise<User | null> { return this.usersModel.findOne({ emailAddress }).exec() }} @InjectModel(User.name) 注入了一个 Mongoose 模型,用来操作数据库。下面的 jwtService 是用来生成 JWT 的服务,以后再聊聊 JWT。 register 方法中我们先用 uuidv4 方法生成一个唯一的 ID,然后用 bcrypt 库对密码进行加密。接着使用 new 关键字创建一个用户实例、传入所有创建用户时需要的信息。 最后调用 save 方法保存用户信息,如果保存成功就返回用户信息,否则返回 void。 findByEmail 方法用来查找用户是否已经存在,如果存在就返回用户信息,否则返回 null。 现在已经看到了很多次 ...Dto,这是什么呢? DTO 的全程为 Data Transfer Object,数据传输对象。它是一个用来传输数据的对象,通常用来传输数据给服务端或者从服务端传输数据给客户端。在 NestJS 中,DTO 是一个用来定义数据结构的类,用来规范数据的传输。 例如 RegisterUserDto 类用来定义用户注册时需要的信息: import { IsEmail, IsNotEmpty, IsString } from 'class-validator'export class RegisterUserDto { @IsEmail() emailAddress: string @IsString() @MinLength(6) username: string @IsString() @MinLength(8) password: string @IsString() birthYear: string @IsString() birthMonth: string @IsString() birthDay: string} 这里使用了多个装饰器来定义每个属性的类型,有助于进行数据验证。 UserSchema 则是用来定义用户模型的,是 MongoDB 要求的数据结构: import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'import { Document } from 'mongoose'export type UserDocument = User & Document@Schema()export class User { @Prop({ required: true, unique: true }) username: string @Prop({ required: false, unique: true }) emailAddress: string @Prop({ required: true }) password: string @Prop({ required: false }) birthYear: string @Prop({ required: false }) birthMonth: string @Prop({ required: false }) birthDay: string}export const UserSchema = SchemaFactory.createForClass(User) @Prop 装饰器用来定义一个属性,@Schema 装饰器用来定义一个模式。UserDocument 是一个用户文档,继承了 User 和 Document。 这里我们定义的属性和 RegisterUserDto 中的属性是一样的,但 UserSchema 注重于定义会被存储在数据库中的数据结构,RegisterUserDto 注重于定义会在客户端和服务端之间传输的数据结构。 最终,我们在 UsersModule 中导入这些模块: import { Module } from '@nestjs/common'import { MongooseModule } from '@nestjs/mongoose'import { JwtService } from '@nestjs/jwt'import { User, UserSchema } from './user.schema'import { UsersControllers } from './users.controllers'import { UsersService } from './users.service'@Module({ imports: [MongooseModule.forFeature([{ name: User.name, schema: UserSchema }])], controllers: [UsersControllers], providers: [UsersService, JwtService],})export class UsersModule {} 不要忘了,新建的模块要在 AppModule 中导入: @Module({ imports: [UsersModule] })

2024/3/30
articleCard.readMore

MTH 2207 笔记

微积分笔记,仅作为个人学习记录,不保证正确性。 1. 渐近线 垂直渐近线(Vertical asymptote)和水平渐近线(Horizontal asymptote)是两种常见的渐近线类型。 1.1 垂直渐近线 垂直渐近线是函数图像在某个特定的 xxx 值附近无限接近但永远不会达到的垂直线,这通常发生在函数的分母为零、分子不为零的情况下。 例如函数 f(x)=1xf(x)= \frac{1}{x}f(x)=x1​ 中,x=0x=0x=0 是一个垂直渐近线,因为当 xxx 无限接近 0 时,f(x)f(x)f(x) 无限接近 ∞\infty∞ (或 −∞-\infty−∞,取决于 xxx 的正负、是从左边还是右边无限接近)。 1.2 水平渐近线 要找到水平渐近线,我们需要考虑函数在 xxx 值无限大或无限小时的行为。对于有理函数,你可以通过比较分子和分母的最高此项的次数来确定是否存在水平渐近线,以及它的位置在哪里: 如果分子的最高次项的次数大于分母的最高次项的次数,那么函数没有水平渐近线;例如 f(x)=x2+1xf(x)= \frac{x^2+1}{x}f(x)=xx2+1​ 如果分子的最高次项的次数等于分母的最高次项的次数,那么函数有一个水平渐近线,它的值等于分子的最高次项的系数除以分母的最高次项的系数;例如 f(x)=x2+1x2f(x)= \frac{x^2+1}{x^2}f(x)=x2x2+1​,水平渐近线的值是 1 如果分子的最高次项的次数小于分母的最高次项的次数,那么函数有一个水平渐近线,它的值等于 0;例如 f(x)=x2+1x3f(x)= \frac{x^2+1}{x^3}f(x)=x3x2+1​ 2. 导数 导数是函数在某一点的瞬时变化率,它的定义是函数在这一点的切线的斜率。导数的计算方法有很多,其中最常见的是使用极限的定义: f′(x)=lim⁡h→0f(x+h)−f(x)hf'(x)= \lim_{h \to 0} \frac{f(x+h)-f(x)}{h}f′(x)=h→0lim​hf(x+h)−f(x)​ 这个定义的意思是,当 hhh 无限接近 0 时,函数在 xxx 点的瞬时变化率等于 f(x+h)f(x+h)f(x+h) 和 f(x)f(x)f(x) 之间的变化量除以 hhh。 2.1 导数的性质 导数(Derivative)有很多有用的性质,其中最重要的是: 导数可以用来判断函数的增减性: 如果 f′(x)>0f'(x)>0f′(x)>0,那么 f(x)f(x)f(x) 在 xxx 点是增加的 如果 f′(x)<0f'(x)<0f′(x)<0,那么 f(x)f(x)f(x) 在 xxx 点是减少的 如果 f′(x)=0f'(x)=0f′(x)=0,那么 f(x)f(x)f(x) 在 xxx 点是不变的 一个圆的半径以每秒 3 英寸的速度增加。当半径为 5 英寸时,它的面积增长速度是多少(单位为平方英寸 / 秒)? 我们知道一个量(圆的半径)的变化速度,想要求另一个量(圆的面积)的变化速度。这是一个典型的导数问题,因为导数描述了一个量的变化速度。我们可以使用导数的定义来解决这个问题: 圆的面积 AAA 和半径 rrr 之间的关系是 A=πr2A = \pi r^2A=πr2。我们知道半径 rrr 随着时间 ttt 的变化速度(即半径的导数)是 333 英寸 / 秒,所以我们可以写出 r(t)=5+3tr(t)= 5 + 3tr(t)=5+3t。我们想要求 AAA 随着时间的变化速度,也就是 dAdt\frac{dA}{dt}dtdA​。我们可以使用链式法则来解决这个问题: dAdt=dAdr⋅drdt\frac{dA}{dt} = \frac{dA}{dr} \cdot \frac{dr}{dt}dtdA​=drdA​⋅dtdr​ A=πr2A = \pi r^2A=πr2,所以 dAdr=2πr\frac{dA}{dr} = 2\pi rdrdA​=2πr。 r=5+3tr = 5 + 3tr=5+3t,所以 drdt=3\frac{dr}{dt} = 3dtdr​=3。 把这两个值代入上面的公式,我们得到: dAdt=2πr⋅3=6πr=6π(5+3t)\frac{dA}{dt} = 2\pi r \cdot 3 = 6\pi r = 6\pi(5 + 3t)dtdA​=2πr⋅3=6πr=6π(5+3t) 当 r=5r = 5r=5 时,t=0t = 0t=0,所以 dAdt=6π⋅5=30π\frac{dA}{dt} = 6\pi \cdot 5 = 30\pidtdA​=6π⋅5=30π 平方英寸 / 秒。 点 P 沿 x 轴以每秒 2 个单位的速度向右移动。当 P 位于(4,0)时,P 和(0,3)之间的距离增加的速度是多少? 点 P 的坐标随时间变化,即 (x(t),0)(x(t), 0)(x(t),0),其中 x(t)x(t)x(t) 是时间 ttt 的函数,并且 dxdt=2\frac{dx}{dt} = 2dtdx​=2 (单位 / 秒)。 我们需要找到点 P 和点(0,3)之间的距离 DDD 随时间 ttt 的变化率,也就是 dDdt\frac{dD}{dt}dtdD​。我们可以使用勾股定理来解决这个问题: D2=(x−0)2+(0−3)2=x2+9D^2 =(x-0)^2 +(0-3)^2 = x^2 + 9D2=(x−0)2+(0−3)2=x2+9 由于求的点 P 在(4,0),所以 x=4x = 4x=4,得出 D=5D = 5D=5。 我们可以对上面的公式求导数,得到: ddtD2=ddt(x2+9)\frac{d}{dt} D^2 = \frac{d}{dt}(x^2 + 9)dtd​D2=dtd​(x2+9) 2D⋅dDdt=2x⋅dxdt2D \cdot \frac{dD}{dt} = 2x \cdot \frac{dx}{dt}2D⋅dtdD​=2x⋅dtdx​ dDdt=2x⋅dxdt2D=xD⋅dxdt\frac{dD}{dt} = \frac{2x \cdot \frac{dx}{dt}}{2D} = \frac{x}{D} \cdot \frac{dx}{dt}dtdD​=2D2x⋅dtdx​​=Dx​⋅dtdx​ 导入已知的值,我们得到: dDdt=45⋅2=85\frac{dD}{dt} = \frac{4}{5} \cdot 2 = \frac{8}{5}dtdD​=54​⋅2=58​ 2.1.1. 区间 一个函数在某个区间是增加的还是减少的,可以查看该区间的导数是正还是负。 如果函数 g(t)g(t)g(t) 的一阶导数在某个区间内大于 0,那么函数在该区间内是增加的;如果导数小于 0,那么函数在该区间内是减少的。 在一个闭区间内,函数的绝对最小值是该函数在该区间上取得的最小的函数值;绝对最大值同理。 2.1.2. 临界点 一个函数的临界点(Critical point)是指函数的导数等于 0 或不存在的点。临界点是函数的极值点的候选者,因为函数在极值点处的导数是 0。 2.1.3. 微分运算符 微分运算符 ddx\frac{d}{dx}dxd​ 是一个用来表示对 xxx 的导数的运算符,它的作用是把一个函数变成它的导数。例如: ddxx2=2x\frac{d}{dx} x^2 = 2xdxd​x2=2x 这意味着 x2x^2x2 的导数是 2x2x2x,或者说,x2x^2x2 在任何点的瞬时变化率都是 2x2x2x。 微分运算符公式: ddxxn=nxn−1\frac{d}{dx} x^n = nx^{n-1}dxd​xn=nxn−1 2.2. 切线 一个函数在某一点的导数描述了该函数在该点处的斜率,也就是说,它描述了函数在该点处的切线(Tangent line)。 具体来说,如果我们有一个函数 f(x)f(x)f(x),并且我们知道它在 x=ax=ax=a 处的导数是 f′(a)f'(a)f′(a),那么我们可以使用点斜式来写出它在 x=ax=ax=a 处的切线方程: y−y1=m(x−x1)y - y_1 = m(x - x_1)y−y1​=m(x−x1​) 其中 mmm 是斜率,也就是导数 f′(a)f'(a)f′(a);x1x_1x1​ 和 y1y_1y1​ 是切线上的一个点,也就是 (a,f(a))(a, f(a))(a,f(a))。 2.3. 导数规则 导数有很多规则,其中最常见的是: 积规则:ddx(uv)=u′v+uv′\frac{d}{dx}(uv)= u'v + uv'dxd​(uv)=u′v+uv′ 商规则:ddxuv=u′v−uv′v2\frac{d}{dx} \frac{u}{v} = \frac{u'v - uv'}{v^2}dxd​vu​=v2u′v−uv′​ 链式法则:ddxf(g(x))=f′(g(x))g′(x)\frac{d}{dx} f(g(x))= f'(g(x))g'(x)dxd​f(g(x))=f′(g(x))g′(x) 也可以是:dydt=dydx⋅dxdt\frac{dy}{dt} = \frac{dy}{dx} \cdot \frac{dx}{dt}dtdy​=dxdy​⋅dtdx​ 2.4. 高阶导数 高阶导数(Higher-order derivative)是指一个函数的导数的导数。例如,如果 f′(x)f'(x)f′(x) 是 f(x)f(x)f(x) 的导数,那么 f′′(x)f''(x)f′′(x) 是 f′(x)f'(x)f′(x) 的导数,也就是 f(x)f(x)f(x) 的二阶导数。 在物理学中,高阶导数通常用来描述加速度、速度和位移之间的关系。例如,如果 f(t)f(t)f(t) 是一个物体的位移函数,那么 f′(t)f'(t)f′(t) 是速度函数,f′′(t)f''(t)f′′(t) 是加速度函数。 2.5. 实践 Suppose f′(2)= 0 and f′′(2)> 0. Which of the following statements is true? a)f(2)must be a relative maximum of f b)f(2)must be a relative minimum of f c)f(2)must be a relative extremum of f, which could be a max or min d)(2, f(2))must be a point of inflection of f e)Not enough information to know whether(2, f(2))is a point of inflection 首先 x=2x = 2x=2 处函数的切线斜率(导数)为 0,这意味着 222 可能是函数的局部极大值或者局部极小值,或者可能是拐点。这是因为导数为零代表着函数在该点的切线是水平的,这通常发生在两种情况下:函数的山顶或谷底,或者函数的形状从上升到下降或者从下降到上升的地方。 再看第二个条件,f′′(2)>0f''(2)> 0f′′(2)>0。基于第一个条件,我们知道 x=2x = 2x=2 处的切线是水平的,所以 f′′(2)f''(2)f′′(2) 的正负号告诉我们 x=2x = 2x=2 处的函数是凹还是凸。因为 f′′(2)>0f''(2)> 0f′′(2)>0,所以 x=2x = 2x=2 处的函数是凹的,这意味着 x=2x = 2x=2 处的函数是一个局部极小值。 On which interval is g(x)=1x+ln⁡xg(x)= \frac{1}{x} + \ln xg(x)=x1​+lnx increasing? 这个问题在询问函数 g(x)g(x)g(x) 在哪个区间是呈现凹增的。函数的二阶导数告诉我们函数的凹凸性,如果函数的二阶导数大于 0,那么函数是凹的;如果函数的二阶导数小于 0,那么函数是凸的。 1x\frac{1}{x}x1​ 的导数是 −1x2-\frac{1}{x^2}−x21​,ln⁡x\ln xlnx 的导数是 1x\frac{1}{x}x1​,所以 g′(x)=−1x2+1xg'(x)= -\frac{1}{x^2} + \frac{1}{x}g′(x)=−x21​+x1​。g′′(x)=2x3−1x2g''(x)= \frac{2}{x^3} - \frac{1}{x^2}g′′(x)=x32​−x21​,所以 g(x)g(x)g(x) 是凹的。 我们还要找出在哪个区间 g′′(x)>0g''(x)> 0g′′(x)>0,将 g′′(x)g''(x)g′′(x) 替换成 000,算出 x=2x=2x=2。 接下来我们要找出区间 (0,2)(0,2)(0,2) 和 (2,+∞)(2,+\infty)(2,+∞) 中具体哪里才是 g′′(x)>0g''(x)> 0g′′(x)>0。 2.6. 应用 生产 xxx 个单位的产品的成本是 C(x)=x2+30x+72xC(x)= \frac{x^2+30x+72}{\sqrt{x}}C(x)=x ​x2+30x+72​,其中 x≥0x \geq 0x≥0。求边际成本为 0 时的生产量。 在经济学中,边际成本(Marginal cost)是生产一个额外单位产品所需的额外成本。在微积分中,边际成本可以通过求生产成本函数的导数来得到。 C′(x)=1.5x−1/2(x−2)(x+12)C'(x)= 1.5x^{-1/2}(x-2)(x+12)C′(x)=1.5x−1/2(x−2)(x+12) 因为 xxx 不能为负数,所以 x=2x=2x=2 是边际成本为 0 时的生产量。

2024/3/14
articleCard.readMore

IBM 全栈开发【6】:容器 & Kubernetes 和 OpenShift 简介

近期在学习 IBM 全栈应用开发微学士课程,故此记录学习笔记。 在现代软件开发中,容器技术已经成为了一种必备的技术。它们通过提供一种轻量级、可移植且独立于平台的方式来封装和运行应用程序,解决了许多传统软件开发和部署过程中的问题。 1. 容器和容器化 尽管容器化(Containerization)并非全新的技术概念,但直到 2013 年 —— 随着 Docker 的出现 —— 它才开始引起人们的广泛关注并迅速成长。如今,容器已经成为现代云原生(Cloud Native)开发的核心技术之一。 容器技术解决了长期困扰软件开发的一个重要问题:软件的可移植性。它使得应用程序能在多种不同的平台上无缝运行。 容器是由容器化引擎支持的标准软件单元,它封装了应用程序代码、运行时环境(Runtime)、系统工具,以及开发者构建、发布和运行应用程序所需的所有设置。由于容器的体积小巧,开发者可以在几乎无需等待的情况下,立即开始容器化他们的应用程序。 在传统的软件开发环境中,开发人员往往难以有效地隔离应用程序,也无法为物理服务器上的应用程序指定或分配特定的存储和内存资源。这导致服务器的资源往往被未充分利用或过度利用,从而降低了效率并减少了投资的回报。 此外,传统的部署方式需要对资源进行全面的配置,并且维护成本高昂。物理服务器的限制可能会在高峰工作负载期间影响应用程序的性能。容器技术通过虚拟化操作系统并管理容器的运行,有效地解决了这些问题。 1.1. 容器引擎 容器引擎提供了运行和管理容器的基础设施。这些引擎使得容器能够独立于平台、操作系统、编程语言和 IDE 运行,降低了部署时间和成本,提高了资源利用率,并实现了跨不同环境的端口。 Docker:最流行的容器引擎,它提供了一种轻量级的、可移植的、自包含的容器化解决方案。Docker 容器可以在任何地方运行,无论是开发人员的笔记本电脑、数据中心的物理服务器,还是云服务提供商的虚拟机 Podman:开源的容器引擎,它提供了一个简单的命令行界面,用于管理容器和镜像。Podman 可以在不需要守护进程的情况下运行容器,这使得它更适合于在生产环境中使用、对比 Docker 更安全 LXC:Linux 容器(Linux Containers,LXC),一个操作系统级的虚拟化技术,它允许多个 Linux 系统共享同一个内核。LXC 适合进行数据密集型应用程序的测试和开发 Vagrant:开源的虚拟化工具,它在正在运行的物理机上提供更高级别的隔离 1.2. Docker Docker 是目前最流行的容器平台: 它是一个开发平台,用于以容器形式开发、发布和运行应用程序 架构简单,具有巨大的可扩展性和可移植性,得到了开发者的欢迎 将应用程序和基础设施隔离,包括硬件、操作系统和容器运行时 使用 Go 语言编写,利用 Linux 内核特性提供其功能,并使用命名空间来提供称为容器的隔离工作区 为每个容器创建一组命名空间 Docker 通过提供一致且隔离的环境实现稳定的应用程序部署,部署快速: 镜像小且可重用 自动化功能有助于消除错误,简化维护周期,并支持敏捷和 CI / CD DevOps 实践 简单版本控制可以加快测试、回滚和重新部署的速度 有助于对应用程序进行分段,以便于刷新、清理和修复 Docker 激发了更多的创新,例如: Docker CLI:Docker 命令行界面 Docker Compose:用于定义和运行多容器 Docker 应用程序的工具 Prometheus:用于监控和警报的开源系统 Docker Swarm 或者 Kubernetes 的编排技术 使用微服务和 Serverless 的开发方式 然而,Docker 并不适合所有的应用场景。例如,对于需要高性能或安全性、基于 Monolith、需要丰富 GUI 功能、或者执行标准桌面或有限功能的应用程序,就可能不适合使用 Docker。 1.2.1. Docker 的工作流程 在 Docker 中,开发者首先创建一个 Dockerfile,然后使用 Dockerfile 创建容器镜像,最后使用容器镜像创建正在运行的容器。 Dockerfile 中的 FROM 命令用于定义基础镜像,CMD 命令用于定义容器启动后需要执行的命令。 FROM nginx:latestCMD ["nginx", "-g", "daemon off;"] 以上 Dockerfile 用于创建一个基于 Nginx 的容器镜像,它使用了 nginx:latest 作为基础镜像,并在容器启动后执行 nginx -g daemon off; 命令、也就是启动 Nginx 服务 Docker 提供了一套命令行工具,用于管理容器和镜像: docker build -t my-app:v1 . 命令用于使用 Dockerfile 中的标签创建容器镜像 -t 参数用于指定镜像的标签 . 参数用于指定 Dockerfile 所在的目录 my-app:v1 用于指定镜像的名称和版本 docker run -p 8080:80 nginx 命令用于从镜像创建并运行容器 -p 参数用于指定容器端口和主机端口的映射 nginx 用于指定容器镜像的名称 8080:80 用于指定主机端口和容器端口的映射 docker push my-app:v1 命令用于将镜像存储在配置的注册表中 docker pull nginx 命令用于从配置的注册表中检索镜像 Docker 主机包含了 Dockerfile、镜像(Image)、容器、网络(Network)、存储卷(Storage)等对象,以及其他的插件和附加组件。其中,网络用于隔离容器通信,存储卷(Volume)和绑定挂载(Bind mount)用于在容器停止运行后保留数据。 Docker 的工作原理如下: 用户通过 Docker 命令行接口(CLI)或 Docker 的 REST API 客户端向 Docker 主机服务器(Host)发送指令 Docker 主机内部运行着一个被称为 dockerd 的守护进程 守护进程负责监听并处理来自 Docker API 的请求或命令 守护进程承担了构建、运行和分发 Docker 容器的重要任务 Docker 将构建好的容器镜像存储在注册表(Registry)中,以便于后续的提取和运行 1.2.2. Docker 的客户端 - 服务器架构 Docker 客户端具有与本地或远程的 Docker 主机进行通信的能力。 在一个系统上,Docker 客户端和 Docker 守护进程可以同时运行,或者 Docker 客户端可以连接到远程的 Docker 守护进程进行操作。同时,Docker 守护进程也能与其他守护进程进行交互,以共同管理 Docker 服务。 Docker 利用注册表来存储和分发镜像。这些注册表可以是公开的,如 Docker Hub,也可以是私有的。注册表的托管位置可以由第三方供应商提供,或者在私有数据中心或云环境中自我托管。 将镜像推送到注册表的过程如下: 开发人员首先构建镜像,然后将其推送到注册表。这个过程可以手动执行,也可以通过自动化构建管道实现。在这个过程中,Docker 将这些镜像存储在注册表中 之后,本地计算机、云系统或其他本地系统就可以从注册表中拉取这些镜像进行使用 2. Kubernetes 基础 每个人的容器之旅都是从一个容器开始的。然而,随着时间的推移,新的应用程序被编写出来,项目被部署到全球范围内以增加可用性。最初的一个容器不可避免地会变成多个容器。此时,我们需要考虑如何将数百或数千个容器连接、管理和扩展为一个大型应用程序,例如数据库或 Web 应用程序。为了创建、扩展和管理大量容器,容器编排(Container orchestration)是必要的。 容器编排是一种自动化容器管理的方法,它可以帮助开发人员和系统管理员在大规模容器环境中管理容器。容器编排系统可以自动化容器的部署、扩展、管理和运行,从而简化了容器的管理和维护。 在大型动态环境中,容器编排是必不可少的: 简化复杂性,自动化复杂的管理、协调任务 无需人工干预的部署和扩展 提高我们的开发速度、敏捷性和效率,无缝地集成到我们的 CI / CD 工作流程和 DevOps 实践中 容器编排使用以 YAML 或 JSON 格式编写的配置文件来定义容器的部署、扩展和管理。这些配置文件可以用于定义容器的资源、网络、存储、服务发现、负载均衡、监控和日志记录等方面。 容器编排工具有: Marathon:Apache Mesos 的一个框架,是一个开源的集群管理器,允许通过自动化管理和监控来扩展容器的基础设施任务 Nomad:HashiCorp 的一个开源的集群管理和调度工具,支持 Docker 和其他容器化工作负载 Docker Swarm:Docker 的一个编排工具,它允许用户在 Docker 主机上运行和管理多个容器;经过专业设计,它可以与 Docker Engine 无缝集成 Kubernetes:Google 开源的容器编排工具,它提供了一种自动化容器部署、扩展和操作的平台,支持多个云环境;因其广泛的社区支持和丰富的功能,Kubernetes 已经成为了最流行的容器编排工具 2.1. Kubernetes Kubernetes 被官方描述为一个开源系统,用于自动化容器化应用程序的部署、扩展和操作。它是由 Google 设计并捐赠给 Cloud Native Computing Foundation(CNCF)的,它促进了声明式管理、自动化部署、自我修复、水平扩展和服务发现等功能。 Kubernetes 的主要概念是: Pod:Kubernetes 中最小的部署单元,它是一个或多个容器的集合,它们共享网络和存储资源 每个 Pod 都有一个唯一的 IP 地址,它们可以通过这个 IP 地址进行通信 服务:一组 Pod 的抽象概念,它们共享一个策略,定义了如何访问这些 Pod 一组具有相同 Service 的 Pod 会有一个共享的 DNS 名称 Persistent Volume(持久卷):Kubernetes 提供的持久存储 资源配置:决定了 Pod 的资源使用情况,包括 CPU、内存、存储等 安全措施:保护云原生工作负载 调度和驱逐:资源紧张的情况下,Kubernetes 的调度器可以决定在哪个 Node 上启动 Pod,或者在需要的时候驱逐(Evict)Pod Kubernetes 还支持抢占(Preemption),即在资源紧张的情况下,可以终止优先级较低的 Pod,以便优先级较高的 Pod 可以运行 集群管理:Kubernetes 提供了一系列的工具和资源,用于管理集群的状态和配置 2.1.1. Kubernetes 生态系统 在容器化应用程序的生态系统中,除了 Kubernetes 这样的容器编排工具外,还需要一些其他的工具来完成各种任务: 构建容器镜像 容器注册表 应用程序日志记录和监控 持续集成/持续部署(CI/CD) 在 Kubernetes 生态系统中,有许多不同类型的提供商都在为这个生态系统做出贡献。这些提供商包括: 公共云提供商如 Prisma、IBM、Google 和 AWS 提供了 Kubernetes 服务,使得用户可以在他们的云平台上轻松部署和管理 Kubernetes 集群。这些公共云提供商通常会提供一些附加的服务,如托管的数据库服务、存储服务和网络服务,以支持 Kubernetes 的运行 开源框架提供商如 Red Hat、VMWare、SUSE、Mesosphere 和 Docker 提供了一些开源的 Kubernetes 发行版,这些发行版包含了一些额外的功能,如更强大的网络插件、存储插件和安全插件,以满足特定的企业需求 管理提供商如 Digital Ocean、Ioodse、SUPERGIANT、CloudSoft、Turbonomic、Techtonic 和 Weaverworks 提供了一些工具和服务,以帮助用户更轻松地管理 Kubernetes 集群。这些工具和服务可以帮助用户自动化一些常见的管理任务,如集群的创建和删除、节点的添加和移除、以及应用的部署和更新 工具提供商如 Jfrog、Univa、Aspen Mesh、Bitnami 和 Cloud 66 提供了一些工具,以帮助用户更轻松地使用 Kubernetes。这些工具包括容器镜像构建工具、CI / CD 工具、网络插件、存储插件等 监控和日志记录提供商如 Sumo Logic、DATADOG、New Relic、Iguazio、Grafana、SignalFX、Sysdig 和 Dynatrace 提供了一些工具和服务,以帮助用户收集和分析 Kubernetes 集群的日志和性能指标 安全提供商如 GUARDCORE、BLACKDUCK、Yubico、Cilium、Aqua、Twistlock 和 Alcide 提供了一些工具和服务,以帮助用户保护 Kubernetes 集群的安全。这些工具和服务可以帮助用户防止未经授权的访问,以及检测和防止安全威胁 负载平衡提供商如 AVI Networks、VMWare 和 NGiNX 提供了一些工具和服务,以帮助用户在 Kubernetes 集群中实现负载均衡。这些工具和服务可以帮助用户在多个 Pod 或 Node 之间分配流量,以实现高可用性和伸缩性 2.1.2. Kubernetes 系统的主要组件 Kubernetes 系统由一系列的组件构成,这些组件共同协作,以管理和运行容器化应用程序。以下是 Kubernetes 系统的主要组件: 集群(Cluster):这是运行容器化应用程序的节点(Node)集群。每个集群都由一个主节点(也称为控制面板)和一个或多个工作节点组成 控制面板(Control Plane):控制面板维护着集群的预期状态。它负责调度和启动新的应用程序,监控应用程序的运行状态,以及在应用程序出现问题时进行自动恢复 节点(Node):节点是运行应用程序的工作机器,它可以是虚拟机或物理机。每个节点都由控制面板管理,并运行着 Kubernetes 的一些组件,如 kubelet 和 kube-proxy API 服务器(API Server):API 服务器公开了 Kubernetes API,它是控制面板的前端,所有的集群通信都通过这个 API 进行 etcd:etcd 是一个高可用的分布式键值存储系统,它存储了集群的所有配置数据,定义了集群的状态 调度器(Scheduler):调度器负责将新创建的 Pod 分配给节点。它会根据各种调度策略,如资源需求、硬件/软件/策略约束、亲和性和反亲和性规格、数据位置等,选择一个最适合的节点 控制器管理器(Controller Manager):控制器管理器运行所有的控制器进程,包括节点控制器、复制控制器、端点控制器、服务账户和令牌控制器等。它持续监控集群的状态,并确保实际状态与预期状态一致 云控制器管理器(Cloud Controller Manager):云控制器管理器运行与底层云服务商交互的控制器,将集群链接到云提供商的 API 中 Pod:Pod 是 Kubernetes 的最小部署单元,每个 Pod 包含一个或多个紧密相关的容器。这些容器共享相同的网络命名空间,可以通过 localhost 互相通信 kubelet:kubelet 是工作节点上最重要的组件,它与 API 服务器通信,接收新的和修改的 Pod 规范,并确保 Pod 及其关联的容器按需运行 容器运行时(Container Runtime):容器运行时是负责运行容器的软件。它提供了容器的可插拔性,也就是说,你可以选择使用 Docker、containerd、CRI - O 或其他兼容 Kubernetes 的容器运行时 kube-proxy:kube-proxy 是一个在集群中每个节点上运行的网络代理,它维护网络规则,使得从网络外部访问 Pod 的请求可以正确路由到目标 Pod,同时也实现了服务的负载均衡 2.1.3. Kubernetes 对象 在 Kubernetes 中,对象是一种持久化的实体,它表示了 Kubernetes 集群中的一种状态。这些对象包括 Pod、Service、Volume、Namespace 等,它们定义了你在集群中运行的应用程序、应用程序可用的资源、应用程序的行为方式(如重启策略)、以及应用程序应该如何处理更新。 Kubernetes 对象由两个主要字段构成:规范(Spec)和状态(Status)。规范描述了对象的期望状态,这是由用户提供的。状态描述了对象的当前状态,这是由 Kubernetes 系统提供的。 要使用 Kubernetes 对象,你可以直接使用 Kubernetes API,或者使用 kubectl 命令行界面,或者两者结合使用。例如,你可以使用 kubectl create 命令来创建一个新的对象,或者使用 kubectl get 命令来查看对象的状态。 标签(Labels)是附加到 Kubernetes 对象上的键值对,用于识别和组织对象。一个对象可以有多个标签,而且多个对象可以有相同的标签。这使得你可以轻松地组织和选择你的对象。标签选择器(Label Selectors)是 Kubernetes 中的一种核心分组机制,它允许你根据标签来选择一组对象。 命名空间(Namespace)在 Kubernetes 中提供了一种资源隔离的机制。一个 Kubernetes 集群可以包含多个命名空间,每个命名空间代表了集群中的一个逻辑分区。你可以在不同的命名空间中运行不同的应用程序,这些应用程序的资源是彼此隔离的。这使得你可以在一个集群中运行多个独立的应用程序,而不需要担心它们会相互干扰。 YAML 文件可以定义要创建的对象。例如我们来定义一个简单的 Pod: apiVersion: v1kind: Podmetadata: name: nginxspec: containers: - name: nginx image: nginx:1.7.9 ports: - containerPort: 80 ReplicaSet 是 Pod 的一组副本,它确保指定数量的 Pod 副本在任何时间都是可用的。例如我们来定义一个简单的 ReplicaSet: apiVersion: apps/v1kind: ReplicaSetmetadata: name: nginx-replicaset labels: app: nginxspec: replicas: 3 selector: matchLabels: app: nginx template: metadata: labels: app: nginx specs: containers: - name: nginx image: nginx:1.7.9 ports: - containerPort: 80 ReplicaSet 其实并不建议直接创建,而是通过 Deployment 来创建。Deployment 是 ReplicaSet 的一种控制器,它提供了一种声明式的方式来管理 Pod 的副本。例如我们来定义一个简单的 Deployment: apiVersion: apps/v1kind: Deploymentmetadata: name: nginx-deployment labels: app: nginxspec: replicas: 3 selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx:1.7.9 ports: - containerPort: 80 对比 ReplicaSet,Deployment 提供了一个关键功能:滚动更新。滚动更新是指在不中断服务的情况下,逐步更新 Pod 的副本。这样可以确保应用程序在更新过程中保持可用性。 Deployment 适合无状态的应用程序,而 StatefulSet 适合有状态的应用程序。StatefulSet 是 Pod 的一组有序的副本,它确保每个 Pod 副本都有唯一的标识符和稳定的网络标识符。 Service 是一个 REST 对象,它定义了一组 Pod 的访问策略。Service 可以通过标签选择器来选择一组 Pod,然后通过 Service 的 IP 地址和端口号来访问这些 Pod。Service 有四种类型:ClusterIP、NodePort、ExternalLoadBalancer 和 ExternalName。 ClusterIP:Service 的默认类型,它将 Service 暴露为集群内部的 IP 地址 NodePort:在静态端口上暴露 Service,这样就可以通过 Node 的 IP 地址和静态端口来访问 Service;不建议在生产环境中使用 NodePort ExternalLoadBalancer(又称 ELB):自动将流量引导到 NodePort ExternalName:将 Service 映射到外部的 DNS 名称 Ingress 是一个 API 对象,它定义了一组 HTTP 和 HTTPS 路由规则,用于将外部流量引导到集群内部的 Service。Ingress 可以提供负载均衡、SSL 终止、主机名基于路由、路径基于路由等功能。 DaemonSet 是一个确保每个 Node 上都运行一个 Pod 的控制器。它通常用于运行一些系统级的服务,如日志收集器、监控代理、网络代理等。 Job 是一个确保 Pod 成功运行一次的控制器。它通常用于运行一些独立的任务,如数据备份、数据迁移、数据处理等。如果 Pod 失败,Job 会自动重试,直到 Pod 成功运行。 aCronJob 是一个确保 Pod 周期性运行的控制器 2.2. kubectl 命令行工具 kubectl 是 Kubernetes 的命令行工具,它允许你与 Kubernetes 集群进行交互。kubectl 可以用于创建、删除、更新、查看和监控 Kubernetes 对象。 kubectl 命令类型有: 命令式命令(Imperative Commands) 命令式对象配置(Imperative Object Configuration) 声明式对象配置(Declarative Object Configuration) 2.2.1. 命令式命令 命令式命令是一种直接操作 Kubernetes 对象的方式。例如,你可以使用 kubectl run 命令来创建一个新的 Pod: kubectl run nginx --image nginx 适合用于开发和测试环境,但不适合用于生产环境 并不灵活,选项相当有限 持久性差,不容易追踪和管理 不易于在团队中共享和协作 2.2.2. 命令式对象配置 命令式对象配置是一种指定必需的操作、参数和选项来创建 Kubernetes 对象的方式。例如,你可以使用 kubectl create 命令来创建一个新的 Pod: kubectl create -f nginx.yaml 多个环境中使用相同的配置文件将产生相同的结果 对比命令式命令结构更清晰,更易于维护 可重用也可共享 状态管理不易,需要手动维护 如果需要管理多个对象,使用命令式对象配置会变得非常复杂 2.2.3. 声明式对象配置 声明式对象配置是一种将配置数据存储在文件中,然后使用 kubectl apply 命令来应用这些配置数据的方式。操作由 kubectl 自动完成,而不是由用户手动完成: kubectl apply -f nginx/ 声明式对象配置关注状态而非操作,用户只需要描述所需的最终状态 可以轻松地在多个环境中共享和重用 学习曲线较陡,需要一定的学习成本 适合用于生产环境 调试和故障排除较为困难 3. 使用 Kubernetes 管理应用 3.1. ReplicaSet ReplicaSet 是 Kubernetes 中的一个控制器,它确保指定数量的 Pod 副本在任何时间都是可用的。ReplicaSet 使用标签选择器来选择一组 Pod,并确保这些 Pod 的副本数量与指定的数量一致。 ReplicaSet 总是尝试将指定数量的 Pod 副本保持在运行状态。如果有 Pod 失败,ReplicaSet 会自动创建新的 Pod 副本来替换它。如果有 Pod 副本数量超过指定数量,ReplicaSet 会自动删除多余的 Pod 副本。 3.2. 自动扩展器 Kubernetes 中的自动扩展器(Autoscaler)是一个控制器,它根据 CPU 利用率或自定义指标自动调整 Pod 的副本数量。 水平自动扩展器(Horizontal Pod Autoscaler,HPA)根据 CPU 利用率自动调整 Pod 的副本数量。 垂直自动扩展器(Vertical Pod Autoscaler,VPA)根据内存利用率自动调整 Pod 的资源请求和限制。 集群自动扩展器(Cluster Autoscaler)根据节点资源利用率自动调整节点的数量。 3.3. 滚动更新 滚动更新是指在不中断服务的情况下,逐步更新 Pod 的副本。滚动更新可以确保应用程序在更新过程中保持可用性。 滚动更新有两种策略:滚动更新和重启更新。滚动更新是逐步替换旧的 Pod 副本,而重启更新是一次性重启所有的 Pod 副本。 3.4. ConfigMaps ConfigMaps 是 Kubernetes 中的一种对象,它用于存储配置数据。ConfigMaps 可以存储任意类型的配置数据,如环境变量、配置文件、命令行参数等。 ConfigMaps 可以通过两种方式使用:直接注入到 Pod 中,或者通过卷挂载到 Pod 中。 这些配置数据可以在 Pod 中使用,以便应用程序可以访问它们。 3.5. Secrets Secrets 相对于 ConfigMaps,它是一种更安全的存储机制,用于存储敏感数据,如密码、密钥、证书等。 4. Kubernetes 生态系统:OpenShift、Istio 等 4.1. Red Hat OpenShift Kubernetes 和 OpenShift 都是容器编排平台,后者是专门为开放式混合云端构建的企业级 Kubernetes 容器平台。 OpenShift 运行在 Kubernetes 集群上,对象数据被存储在 etcd 中,使用 Kubernetes API 进行通信。OpenShift 提供了一些额外的功能,如构建、部署、监控、日志记录、安全性、网络、存储、服务发现、负载均衡等。 OpenShift 也和 Jenkins 集成,具有更多的服务和功能。 4.2. 构建 构建(build)是将输入转换成对象的过程,构建输入包括内联 Dockerfile 定义,以及从图片、Git 仓库、二进制文件等提取出的内容。 ImageStream 是 OpenShift 中引用容器镜像的一个抽象。它可以引用一个或多个镜像,以及一个或多个标签。ImageStream 还可以引用一个或多个构建配置,以及一个或多个构建。 Webhook、图像更改或配置更改都可以触发构建。常见的构建策略包括 Source-to-Image(S2I)构建、Dockerfile 构建、Pipeline 构建等。 构建需要一个构建配置(BuildConfig)来定义构建的策略和参数。构建配置可以定义构建触发器、构建策略、构建输出、构建环境等。 4.3. CRD 自定义资源定义(Custom Resource Definition,CRD)是 Kubernetes 中的一种机制,它允许用户定义自己的资源类型。CRD 可以用于定义新的资源类型,如 Pod、Service、ReplicaSet 等。 CRD 扩展了 Kubernetes API。与自定义控制器配合使用的话可以在 Kubernetes 中创建新的声明式 API。 Operator 使用 CRD 和自定义控制器来自动化集群任务。Operator Framework 涵盖了编码、测试、交付和更新。 Operator Maturity Model(OMM)是一个用于评估 Operator 的成熟度的模型。OMM 包括五个阶段:初始、基本、稳定、成熟和退休。 4.4. Istio Istio 是一个开源的服务网格平台,它提供了一种简单的方式来连接、管理和保护微服务。Istio 可以在 Kubernetes 中运行,也可以在其他环境中运行。 服务网格(Service Mesh)是一种用于管理服务之间通信的基础设施层。服务网格可以提供流量管理来控制服务之间的流量,用于加密服务之间流量的安全性,以及用于排除故障的服务行为的可观察性并优化应用程序。 Istio 支持连接、安全、执行和可观察性这四个概念。常用的示例之一是微服务应用程序。Istio 也为基本服务监控需求提供服务通信指标,包括延迟、流量、错误和饱和度。

2024/3/12
articleCard.readMore

PHY 2002 笔记

物理学笔记,仅作为个人学习记录,不保证正确性。 1. 一维运动 位移(Displacement)是一个物体从起始点到终点的直线距离和方向。 Δx=xf−xi\Delta x = x_f - x_i Δx=xf​−xi​ xfx_fxf​:终点位置 xix_ixi​:起始位置 速度(Velocity)是物体位置随时间的变化率。 vavg=ΔxΔtv_{\text{avg}} = \frac{\Delta x}{\Delta t} vavg​=ΔtΔx​ 瞬时速度(Instantaneous velocity)是物体在某一时刻的速度 加速度(Acceleration)是速度随时间的变化率。 aavg=ΔvΔta_{\text{avg}} = \frac{\Delta v}{\Delta t} aavg​=ΔtΔv​ 瞬时加速度(Instantaneous acceleration)是物体在某一时刻的加速度 恒定加速度的情况下,v=v0+atv=v_0+atv=v0​+at 1.1. 匀速 / 匀加速直线运动 假设速度随着时间呈线性变化、恒定加速度也是如此的情况(见下图)下,起始速度和终点速度的算术平均值可以用来表示任意时间间隔内的平均速度:vˉ=v0+v2\bar{v}=\frac{v_0+v}{2}vˉ=2v0​+v​。 基于这个公式,我们可以推导出物体位移随时间变化的公式: Δx=vˉt=(v0+v2)t\Delta x = \bar{v} t =(\frac{v_0+v}{2})tΔx=vˉt=(2v0​+v​)t Δx=12(v0+v)t\Delta x = \frac{1}{2}(v_0+v)tΔx=21​(v0​+v)t 再把速度公式代入,得到: Δx=12(v0+v0+at)t\Delta x = \frac{1}{2}(v_0 + v_0 + at)tΔx=21​(v0​+v0​+at)t Δx=v0t+12at2\Delta x = v_0 t + \frac{1}{2} a t^2Δx=v0​t+21​at2 如果不想要包含时间的话,可以推出: Δx=12(v+v0)(v−v0a)=v2−v022a\Delta x = \frac{1}{2}(v + v_0)(\frac{v-v_0}{a})= \frac{v^2-v^2_0}{2a}Δx=21​(v+v0​)(av−v0​​)=2av2−v02​​ v2=v02+2aΔxv^2 = v^2_0 + 2a \Delta xv2=v02​+2aΔx 1.1.1. 总结 公式介绍 v=v0+atv=v_0+atv=v0​+at速度随时间的变化 Δx=v0t+12at2\Delta x = v_0 t + \frac{1}{2} a t^2Δx=v0​t+21​at2位移随时间的变化 v2=v02+2aΔxv^2 = v^2_0 + 2a \Delta xv2=v02​+2aΔx速度和位移的关系 1.2. 自由落体 亚里士多德认为物体的速度与质量成正比,也就是说物体越重,下落的速度越快。但据说伽利略通过实验发现,不同质量的物体在没有空气阻力的情况下,下落的速度是相同的。宇航员在月球上的实验也证实了这一点。在空气阻力可以忽略不计的情况下,这种运动称为自由落体。 自由落体的加速度是一个常量,通常用 ggg 表示,称为重力加速度。在地球表面,g=9.8 m/s2g=9.8\ m/s^2g=9.8 m/s2。 重力加速度的方向是向下的。不过在运动方程中重力加速度为正还是负取决于我们选择的坐标系。如果我们选择向上定义为负值,那么重力加速度就是负的 自由落体的速度随时间的变化是线性的: v=v0+gtv = v_0 + gtv=v0​+gt 自由落体的位移随时间的平方增加: h=h0+v0t+12gt2h = h_0 + v_0 t + \frac{1}{2} g t^2h=h0​+v0​t+21​gt2 2. 二维运动 在二维空间中,位移、速度和加速度的概念与一维运动类似。不过在二维运动中,我们需要考虑两个方向上的运动,通常指的是水平方向和竖直方向。 二维位移是一个物体从起始点到终点的直线距离和方向,同时也要考虑两个方向上的变化。 Δr⃗=r⃗f−r⃗i\Delta \vec{r} = \vec{r}_f - \vec{r}_iΔr =r f​−r i​ r⃗\vec{r}r :位置矢量 Δr⃗\Delta \vec{r}Δr 包含了 Δx\Delta xΔx 和 Δy\Delta yΔy 两个分量,代表着物体在 Δt\Delta tΔt 时间内从(xi,yi)(x_i, y_i)(xi​,yi​) 到(xf,yf)(x_f, y_f)(xf​,yf​) 的位移。 平均速度的定义与一维运动类似: v⃗avg=Δr⃗Δt\vec{v}_{\text{avg}} = \frac{\Delta \vec{r}}{\Delta t}v avg​=ΔtΔr ​ 因为位移是一个矢量、时间是一个标量,所以我们可以总结出平均速度也是一个矢量、沿着 Δr⃗\Delta \vec{r}Δr 的方向。 瞬时速度是其平均速度在 Δt\Delta tΔt 为 0 时的极限: v⃗=lim⁡Δt→0Δr⃗Δt\vec{v} = \lim_{\Delta t \to 0} \frac{\Delta \vec{r}}{\Delta t}v =Δt→0lim​ΔtΔr ​ 平均加速度: a⃗avg=Δv⃗Δt\vec{a}_{\text{avg}} = \frac{\Delta \vec{v}}{\Delta t}a avg​=ΔtΔv ​ 瞬时加速度: a⃗=lim⁡Δt→0Δv⃗Δt\vec{a} = \lim_{\Delta t \to 0} \frac{\Delta \vec{v}}{\Delta t}a =Δt→0lim​ΔtΔv ​ 2.1. 二维运动 在二维运动中,物体可以同时在水平方向和竖直方向上运动。这种二维运动的一个重要特例是抛体运动(Projectile motion)。 如图,物体离开地面时的抛物线轨迹速度为 v⃗0\vec{v}_0v 0​,v⃗\vec{v}v 会随着时间增长。但是 vxv_xvx​,也就是水平方向的速度是恒定的,依旧等同于 v0xv_0xv0​x。当物体到达最高点时,vyv_yvy​呈现为 0,但加速度依然等同于自由落体的加速度、方向向下。 假设物体在离开地面的时候 t=0t=0t=0、起始速度为 v⃗0\vec{v}_0v 0​,且速度矢量形成了一个角度 θ\thetaθ,那么我们可以得到: v0x=v0cos⁡θv_{0x} = v_0 \cos \thetav0x​=v0​cosθ v0y=v0sin⁡θv_{0y} = v_0 \sin \thetav0y​=v0​sinθ 如果只考虑水平方向、水平方向的加速度恒定,那么我们可以得到: vx=v0x+axtv_x = v_{0x} + a_x tvx​=v0x​+ax​t Δx=v0xt+12axt2\Delta x = v_{0x} t + \frac{1}{2} a_x t^2Δx=v0x​t+21​ax​t2 vx2=v0x2+2axΔxv_x^2 = v_{0x}^2 + 2 a_x \Delta xvx2​=v0x2​+2ax​Δx 以上的 v0xv_{0x}v0x​等于 v0cos⁡θ0v_0 \cos \theta_0v0​cosθ0​ 反过来只考虑垂直方向和垂直方向的加速度的话,那便是: vy=v0y+aytv_y = v_{0y} + a_y tvy​=v0y​+ay​t Δy=v0yt+12ayt2\Delta y = v_{0y} t + \frac{1}{2} a_y t^2Δy=v0y​t+21​ay​t2 vy2=v0y2+2ayΔyv_y^2 = v_{0y}^2 + 2 a_y \Delta yvy2​=v0y2​+2ay​Δy 同理,v0yv_{0y}v0y​等于 v0sin⁡θ0v_0 \sin \theta_0v0​sinθ0​,且 aya_yay​恒定 这种情况下速度 vvv 可以使用勾股定理表示: v=vx2+vy2v = \sqrt{v_x^2 + v_y^2}v=vx2​+vy2​ ​ 角度 θ\thetaθ 可以使用反正切函数表示: θ=tan⁡−1vyvx\theta = \tan^{-1} \frac{v_y}{v_x}θ=tan−1vx​vy​​ 如果矢量落在第二或第三象限,那么 θ\thetaθ 的值需要加上 180°180°180° 2.2. 相对速度 相对速度将两个不同观测者的测量结果联系起来。物体的测量速度取决于观察者相对于物体的速度。例如高速公路上同方向行驶的汽车,它们相对于地球而言是高速行驶的,但相对于彼此,它们几乎没有移动。 因此,速度的测量取决于观察者的参照系。参照系只是坐标系。大多数时候,我们使用相对于地球的静止参照系,但偶尔我们也会使用与相对于地球以恒定速度运动的公共汽车、汽车或飞机相关的运动参照系。 在二维空间中,相对速度的计算可能令人困惑,因此采用系统的方法非常重要和有用。假设 E 是一个观察者,相对于地球静止不动。将两辆汽车分别标记为 A 和 B,并引入以下符号: rAE⃗\vec{r_{AE}}rAE​ ​:汽车 A 相对于 E 的位移 rBE⃗\vec{r_{BE}}rBE​ ​:汽车 B 相对于 E 的位移 vAB⃗\vec{v_{AB}}vAB​ ​:汽车 A 相对于 B 的速度 3. 牛顿运动定律 牛顿运动定律是描述物体运动的基本规律,由牛顿在 17 世纪提出。这些定律是经典力学的基础,对于理解和预测物体如何在力的作用下移动至关重要。 牛顿第一定律也被称为惯性定律,它表明如果没有外力作用,物体将保持其状态不变。也就是说静止的物体将保持静止,而移动的物体将以恒定速度直线运动。 比方说,当你在车上的时候,车突然启动,你会向后倾斜。这是因为你的身体保持了静止状态,而车突然加速了。当车突然停下来的时候,你会向前倾斜。这是因为你的身体保持了前进的状态,而车突然停下了。 牛顿第二定律是最重要的定律之一,它表明力(Force)等于物体的质量乘以加速度,或者说物体的加速度是力和质量的商。 F⃗=ma⃗\vec{F} = m \vec{a}F =ma 这个定律解释了为什么物体在受到外力作用时会加速。它还解释了为什么物体的质量越大,所需的力越大,加速度越小。 牛顿第三定律也被称为作用与反作用定律,它表明对于每一个作用力,都有一个与之大小相等、方向相反的反作用力。如果物体 A 对物体 B 施加一个力,那么物体 B 对物体 A 也会施加一个大小相等、方向相反的力。 当我们使用锤子敲击钉子时,我们施加了一个向下的力,钉子对锤子也施加了一个向上的力。这个力使得锤子停止下降。当我们在地面上行走时,我们对地面施加了一个向后的力,地面对我们也施加了一个向前的力。这个力使得我们向前移动。 3.1. 力 力是一个矢量,它有大小和方向。 如果拉动弹簧,弹簧就会拉伸;如果用力拉小车,小车就会移动;如果踢足球,足球就会短暂变形然后飞出去。这些都是接触力(Contact force)的例子。 重力(Gravitational force)是地球对物体的吸引力。它的大小等于物体的质量乘以重力加速度(在地球表面约为 9.8 m / s²)。重力总是朝下。 摩擦力(Frictional force)是阻止物体在表面上滑动的力。摩擦力的方向总是与物体相对于表面的运动方向相反。 电磁力(Electromagnetic force)是电荷之间的相互作用力。 3.2. 运动定律 力的国际单位是牛顿(N): 1 N=1 kg⋅m/s21\ N = 1\ kg \cdot m/s^21 N=1 kg⋅m/s2 在美国,磅(lb)是力的单位:1 N=0.225 lb1\ N = 0.225\ lb1 N=0.225 lb 3.3. 动摩擦力 摩擦力是一种接触力,源于物体与其环境之间的微观相互作用。空气摩擦力(Air friction)影响着各种交通工具,流体摩擦力(Fluid friction)影响着船只的运动。摩擦力并不单单是阻碍物体运动的力,它还可以使物体运动。 动摩擦力(Force of kinetic friction)是当两个物体在相对运动时、产生出的抵抗这种运动的力。它的方向总是与物体相对运动的方向相反。 fk=μkNf_{\text{k}} = \mu_{k} Nfk​=μk​N NNN 是作用于物体上的法向力(Normal force),公式为 N=mgN = mgN=mg μk\mu_{k}μk​是物体和表面之间的动摩擦系数(Coefficient of kinetic friction) 动摩擦力的特点是它与物体的速度无关,只取决于接触表面的性质和正压力的大小。这就是为什么在相同表面下,重的物体比轻的物体更难推动。 3.4. 静摩擦力 静摩擦力(Static friction forces)是当两个物体试图滑动但实际上并未滑动时、产生的抵抗滑动的力。 0≤fs≤fs, max≤μsN0 \leq f_{\text{s}} \leq f_{\text{s, max}} \leq \mu_{s} N0≤fs​≤fs, max​≤μs​N NNN 是法向力 μs\mu_{s}μs​是物体与表面之间最大的静摩擦系数 该公式仅用于计算最大静摩擦力 3.5. 张力 将绳索固定在物体上并拉动它,即可产生张力(Tension force)。张力是绳索内部的拉力,它总是朝着绳索的两端方向。 4. 能量 能量(Energy)以各种形式存在于宇宙中,即使是日常物质中的惰性物质也蕴含着大量的能量。虽然能量可以从一种转化为另一种,但迄今为止的所有观测和实验都表明,宇宙中的能量总量从未发生过变化。 对于孤立系统来说也是如此,孤立系统是一组物体的集合,它们可以相互交换能量,但不能与宇宙的其他部分交换能量。如果孤立系统中的一种能量形式减少,那么系统中的另一种能量形式必然增加。 例如,如果系统由连接到电池的电机组成,那么电池将化学能转化为电能,电机将电能转化为机械能。了解能量如何从一种形式转化为另一种形式,对所有科学都至关重要。 4.1. 功 功(Work)用于描述力在物体上所做的影响。在物理学中,功是通过力使物体移动一定距离所做的工作。如果力的方向与物体移动的方向一致,那么功就是力和距离的乘积。 功的直观定义: W=F⋅dW = F \cdot dW=F⋅d FFF 是作用在物体上的力的大小 ddd 是物体位移的大小 这个定义仅给出了当力恒定,并且位移平行时物体所做的功,这代表着在这个定义下位移必须沿着一条直线 功是一个标量,对比矢量更容易处理。它与方向无关,也不明确取决于时间。因为功的单位是力和距离的单位,所以国际单位是牛顿米(N・m),也称为焦耳(J)。 如果力的方向与物体移动的方向不一致,那么我们可以使用以下公式: W=F⋅d⋅cos⁡θW = F \cdot d \cdot \cos \thetaW=F⋅d⋅cosθ θ\thetaθ 是力和位移之间的夹角 4.2. 动能 动能(Kinetic energy)是物体由于运动而具有的能量。 K=12 mv2K = \frac{1}{2}\ m v^2K=21​ mv2 功 - 能定理(Work-energy theorem)表明,当力对物体做功时,物体的动能会增加。这个定理可以用来解释为什么物体在受到外力作用时会加速。 W=ΔKW = \Delta KW=ΔK 该定理是牛顿第二定律的一个推论,为我们提供了一种计算物体速度变化的方法 例如你用力推动一个静止的物体使其开始运动,那么你对物体做的功就等于物体获得的动能。如果你用力使一个正在运动的物体停下来,那么你对物体做的功就等于物体失去的动能 净功(Net work)等同于物体动能的变化: Wnet=KEf−KEi=ΔKEW_{\text{net}} = KE_f - KE_i = \Delta KEWnet​=KEf​−KEi​=ΔKE 净功是所有作用在物体上的力所做的功的总和。如果净功为正,那么物体的动能会增加;如果净功为负,那么物体的动能会减少。 4.3. 保守力和非保守力 力可以被分为两种类型:保守力(Conservative force)和非保守力(Non-conservative force)。 保守力的特点是它做的功仅仅取决于物体的初始位置和最终位置,而不依赖于物体从初始位置到最终位置所走的路径。换句话说,对于保守力而言,从一点到另一点的路径无论如何变化,只要初始和最终位置相同,做的功就是相同的。重力和弹簧力便是两个常见的保守力。 保守力的一个重要特性便是它们可以与势能(Potential energy)联系起来。 非保守力也被称为耗散力(Dissipative force),它们做的功取决于物体所走的路径。摩擦力和空气阻力便是两个常见的非保守力。 非保守力通常会导致物体的机械能(Mechanical energy,是动能和势能的和)减少,因为它们会将物体的机械能转化为其他形式的能量,例如热能。 Wnc+Wc=ΔKEW_{\text{nc}} + W_{\text{c}} = \Delta KEWnc​+Wc​=ΔKE 4.4. 重力势能 具有动能的物体可以对另一个物体做功,例如一个正在移动的锤子可以把钉子钉进墙里。高架上的砖块也能做功:它可以从架子上掉下来、加速向下、正中钉子、将钉子打入地板。这块砖可以具有势能,因为从它的位置来看,它有可能做功。 重力是一种保守力,每一种保守力都可以从中找到一种叫做势能函数的特殊表达式。重力功(Gravitational work)是当物体在重力的作用下位移时、重力对物体做的功。 Wg=Fdcos⁡θ=mg(yi−yf)cos⁡0°=−mg(yf−yi)W_g = Fd \cos \theta = mg(y_i-y_f)\cos 0 \degree = -mg(y_f-y_i)Wg​=Fdcosθ=mg(yi​−yf​)cos0°=−mg(yf​−yi​) 该公式描述了一本质量为 mmm 的书从 yiy_iyi​摔落到 yfy_fyf​(不考虑空气摩擦力) 计算净功:Wnet=Wnc+Wg=ΔKEW_{\text{net}} = W_{nc} + W_g = \Delta KEWnet​=Wnc​+Wg​=ΔKE WncW_{nc}Wnc​意味着由非保守力做的功 重力势能(Gravitational potential energy)是一个物体由于其在地球表面或其他天体表面以上的位置而获得的能量。 PE=mgyPE = mgyPE=mgy yyy 是相对于地球表面、物体所在的高度 重力功和重力势能的关系: Wg=−(PEf−PEi)=−(mgyf−mgyi)W_g = -(PE_f-PE_i)= -(mgy_f - mgy_i)Wg​=−(PEf​−PEi​)=−(mgyf​−mgyi​) 根据这两个公式,功 - 能定理(对于非保守力)的公式可以进行扩展: Wnc=(KEf−KEi)+(PEf−PEi)W_{nc} =(KE_f - KE_i)+(PE_f - PE_i)Wnc​=(KEf​−KEi​)+(PEf​−PEi​) 4.5. 重力与机械能守恒定律 机械能(Mechanical energy)是一个物体的动能和势能之和。假设在一个封闭的系统中,如果只有保守力作用,那么这个物体的机械能就是守恒的。 KEi+PEi=KEf+PEfKE_i + PE_i = KE_f + PE_fKEi​+PEi​=KEf​+PEf​ 如果重力是系统中唯一做功的力,那么机械能守恒原理的形式为: 12 mvi2+mgyi=12 mvf2+mgyf\frac{1}{2}\ mv^2_i + mgy_i = \frac{1}{2}\ mv^2_f + mgy_f21​ mvi2​+mgyi​=21​ mvf2​+mgyf​ 4.6. 弹簧势能 在拉伸或压缩弹簧时,外力所做的功可以通过消除外力来恢复,因此弹簧力和重力一样、也是保守力。 PEs=12 kx2PE_s = \frac{1}{2}\ kx^2PEs​=21​ kx2 kkk 是弹簧的硬度 xxx 是弹簧被压缩或拉伸的长度 加上弹簧势能,功 - 能定理又可以进行一波扩展: Wnc=(KEf−KEi)+(PEgf−PEgi)+(PEsf−PEsi)W_{nc} =(KE_f - KE_i)+(PE_{gf} - PE_{gi})+(PE_{sf} - PE_{si})Wnc​=(KEf​−KEi​)+(PEgf​−PEgi​)+(PEsf​−PEsi​) 4.7. 能量守恒定律 能量守恒定律(Conservation of energy)是物理学中的基本定律之一,它指出,在一个封闭的系统中,能量不能被创建也不能被销毁,只能从一种形式转化为另一种形式。也就是说,系统的总能量在任何时候都保持不变。 4.8. 功率 在物理学中,功率(Power)是做功或转移能量的速率。也就是说,功率描述了单位时间内做多少功或转移多少能量。功率的定义式为: Pˉ=WΔt=FΔxΔt=Fvˉ\bar{P} = \frac{W}{\Delta t} = \frac{F \Delta x}{\Delta t} = F \bar{v}Pˉ=ΔtW​=ΔtFΔx​=Fvˉ WWW 是做的功或者转移的能量(瓦特) Δt\Delta tΔt 是时间周期 以上是速度平均的情况下,如果是瞬时速度: P=FvP = FvP=Fv 功率的国际单位是焦耳,也可以被称为瓦特: 1 W=1 J/s=1 kg⋅m2/s31\ \text{W} = 1\ \text{J/s} = 1\ \text{kg} \cdot \text{m}^2 / \text{s}^31 W=1 J/s=1 kg⋅m2/s3 单位马力(Horsepower)有时也会被使用: 1 hp=550 ft⋅lbs=746 W1\ \text{hp} = 550\ \frac{\text{ft} \cdot \text{lb}}{\text{s}} = 746\ \text{W}1 hp=550 sft⋅lb​=746 W 在发电领域,人们习惯用千瓦时来衡量能量: 1 kWh=(103 W)(3600 s)=(103 J/s)(3600 s)=3.60⋅106J1\ \text{kWh} =(10^3\ \text{W})(3600\ \text{s})=(10^3\ \text{J} / \text{s})(3600\ \text{s})= 3.60 \cdot 10^6 \text{J}1 kWh=(103 W)(3600 s)=(103 J/s)(3600 s)=3.60⋅106J 5. 旋转运动和万有引力 5.1. 角的单位 在物理学中,角度通常用弧度(Radian)来表示。弧度是一个角度单位,它定义为半径等于圆弧长度的角度。弧度的符号是 rad。 但在日常生活中,我们通常使用度(Degree)来表示角度。一个圆有 360 度,一个弧度等于 180/π 度。 rad=degree⋅π180\text{rad} = \text{degree} \cdot \frac{\pi}{180}rad=degree⋅180π​ 转数(Revolutions)是一个物体绕一个轴旋转的次数,通常用符号 rev 表示。一个完整的旋转是 360 度或 2π2\pi2π 弧度。 rev=rad⋅12π\text{rev} = \text{rad} \cdot \frac{1}{2\pi}rev=rad⋅2π1​ rpm(Revolutions per minute)是一个物体每分钟绕轴旋转的次数。它是一个常用的单位,特别是在描述发动机和电机的转速时。 已知 1 转等于 2π2\pi2π 弧度,1 分钟等于 60 秒,我们可以得到: rad/s=rpm⋅2π60\text{rad/s} = \text{rpm} \cdot \frac{2\pi}{60}rad/s=rpm⋅602π​ 5.2. 角速度 / 加速度 角速度(Angular velocity)是一个物体绕轴旋转的速度。它是角度的变化率,通常用符号 ω\omegaω 表示。角速度的国际单位是弧度每秒(rad / s)。 ω=vr\omega = \frac{v}{r}ω=rv​ vvv 是物体的线速度 rrr 是物体绕轴旋转的半径 角加速度(Angular acceleration)是角速度随时间的变化率,通常用符号 α\alphaα 表示。角加速度的国际单位是弧度每秒的平方(rad / s²)。 α=ωt\alpha = \frac{\omega}{t}α=tω​ 以该公式为基底,我们也能推导出: ω=αt\omega = \alpha tω=αt t=ωαt = \frac{\omega}{\alpha}t=αω​ 我们也知道角位移 θ\thetaθ 的公式:θ=12αt2\theta = \frac{1}{2} \alpha t^2θ=21​αt2, 因此,θ=12α(ωα)2=ω22α\theta = \frac{1}{2} \alpha(\frac{\omega}{\alpha})^2 = \frac{\omega^2}{2\alpha}θ=21​α(αω​)2=2αω2​。 线位移 sss 的公式为:s=rθs = r \thetas=rθ,所以 s=rω22αs = r \frac{\omega^2}{2\alpha}s=r2αω2​。 而线性速度和角速度之间的关系是: v=rωv = r \omegav=rω 线性加速度和角加速度之间的关系是: a=rαa = r \alphaa=rα

2024/3/6
articleCard.readMore

Docker 实践:构建 React 和 Express 项目

近期考虑了去学习如何部署自己的网站项目。根据网上的资料,决定先使用 Docker + Nginx 的组合来部署到本地上,之后再考虑部署到云端。 项目结构 我的项目前端是 React,后端是 Express、使用了 Socket.io 来实现实时通信、使用了 MongoDB 来存储数据。 Docker 容器的话需要为每个服务创建一个容器,所以我需要创建三个容器:前端、后端、数据库。同时还要创建一个 Nginx 容器来作为反向代理。 Nginx 是一个高性能的 HTTP 和反向代理服务器,也是一个 IMAP/POP3/SMTP 代理服务器。 那么反向代理是什么意思呢?通常情况下我们如果访问一个网站,浏览器会直接向服务器发送请求,服务器再返回数据给浏览器。而反向代理是指,浏览器发送请求给 Nginx,Nginx 再将请求转发给服务器,服务器返回数据给 Nginx,Nginx 再返回数据给浏览器。 这么做的目的是为了隐藏服务器的真实 IP 地址,提高安全性。因为用户只能向 Nginx 发送请求,而不能直接向服务器发送请求。 Dockerfile Docker 是一个开源的应用容器引擎,让开发者可以打包他们的应用以及依赖包到一个可移植的容器中,然后发布到任何流行的 Linux 机器上,也可以实现虚拟化。 为什么要使用 Docker 呢?因为 Docker 可以让开发者摆脱「在我的机器上可以运行」的问题。 应用能够在任何地方运行,而不用担心环境问题。这样就可以避免因为环境问题导致的 bug,也可以避免因为环境问题导致的部署问题。 Docker 的两个重要概念为镜像和容器。 镜像是一个只读的模板,可以想象为一个菜谱、详细列出了如何制作一道菜的步骤。就像你无法在菜谱上做菜一样,你也无法在镜像上做任何操作。 容器是镜像的一个实例,可以想象为一道菜。Docker(厨师)会根据镜像(菜谱)制作出容器(菜),并且可以对容器进行操作。 Dockerfile 则是编写菜谱的过程。它是一个文本文件,包含了一条条的指令,每一条指令构建一层,从而构建出一个完整的镜像。 每个服务都需要一个 Dockerfile 来构建镜像。我的项目结构中暂且只有前端和后端,所以我需要创建两个 Dockerfile、存放在这两个目录下。 # client/Dockerfile# 使用node:20.11.0-alpine作为基础镜像# alpine代表着这是一个轻量级的镜像、体积更小FROM node:20.11.0-alpine# 设置工作目录# 工作目录是容器中的一个目录,用来存放项目文件WORKDIR /app# 复制package.json到工作目录COPY package.json .# 安装依赖RUN npm install# 复制所有文件到工作目录COPY . .# 启动项目CMD ["npm", "start"] FROM node:20.11.0-alpineWORKDIR /appCOPY package.json .RUN npm installCOPY . .# EXPOSE指令通知了Docker、容器在运行时监听的端口# 这个指令并不会让容器的端口映射到宿主机的端口,如果需要映射,还需要在运行容器时使用-p参数EXPOSE 4000CMD ["node", "./bin/www"] 宿主机是指安装了 Docker 的机器,也就是我们的电脑。 Docker Compose Docker Compose 是一个用来定义和运行多容器 Docker 应用的工具。通过一个单独的 docker-compose.yml 配置文件来配置应用的服务,然后使用 docker-compose up 命令来从配置文件中构建、启动、管理整个应用。 因为我需要创建多个容器,所以我需要一个 docker-compose.yml 文件来更好地管理这些容器。 在整个项目的根目录下创建一个 docker-compose.yml 文件: # docker-compose.ymlversion: '3'# 定义服务services: # 定义nginx服务 nginx: image: nginx:alpine ports: # 将容器的80端口映射到宿主机的80端口 - "80:80" depends_on: # 依赖于client和server服务 - client - server volumes: # 将宿主机的nginx.conf文件映射到容器的/etc/nginx/conf.d/default.conf文件 # 这里的nginx.conf文件之后会提到 - ./nginx.conf:/etc/nginx/conf.d/default.conf networks: # 将nginx服务加入到app-network网络中 - app-network client: build: ./client # 使用client目录下的Dockerfile构建镜像 ports: - "3000:3000" networks: - app-network server: build: ./server ports: - "4000:4000" networks: - app-network # 定义mongodb服务 mongodb: image: mongo ports: # 将容器的27017端口映射到宿主机的28017端口,之后会提到为什么端口号不一样 - "28017:27017" volumes: # 将mongodb_data卷挂载到/data/db目录 - mongodb_data:/data/db networks: - app-networkvolumes: # 定义mongodb_data卷 mongodb_data:networks: app-network: # 定义app-network网络 driver: bridge 卷是一种数据持久化和数据共享的机制。它可以将宿主机的目录挂载到容器中,这样容器中的数据就可以持久化到宿主机上了。 即使容器被删除,宿主机上的数据也不会丢失。 网络定义了容器之间如何相互通信。每个网络都代表了一个独立的虚拟网络,容器可以连接到这个网络上,从而实现容器之间的通信。bridge 类型会给容器分配一个 IP 地址,这样容器之间就可以通过 IP 地址相互通信。不同 bridge 类型的网络是隔离的,即使是同一台宿主机上的容器也不能相互通信。 配置 Nginx Nginx 的配置文件是 nginx.conf,这个文件需要放在 docker-compose.yml 文件所在的目录下。 server { listen 80; # 告诉Nginx监听80端口 location / { # 当访问根路径时 proxy_pass http://client:3000; # 将请求转发到client服务的3000端口 } location /api/ { # 当访问/api路径时 proxy_pass http://server:4000; # 将请求转发到server服务的4000端口 } location /socket.io/ { # 当访问/socket.io路径时 proxy_pass http://server:4000; # 将请求转发到server服务的4000端口 proxy_http_version 1.1; # 使用HTTP/1.1协议 proxy_set_header Upgrade $http_upgrade; # 设置请求头,和WebSocket有关 proxy_set_header Connection 'upgrade'; }} MongoDB Docker 容器中的 MongoDB 服务不同于平常的 MongoDB 服务。通常服务端连接 MongoDB 的地址是 localhost:27017,但是在 Docker 容器中,要使用 mongodb:27017。 由于我的宿主机上已经有一个 MongoDB 服务在运行,所以我将容器的 27017 端口映射到了宿主机的 28017 端口。这样便可以避免端口冲突、使用宿主机上的 MongoDB Compass 直接连接 localhost:28017 来管理容器中的 MongoDB 服务。 构建和运行 首先要构建整个应用: docker-compose build 如果是想要构建单个服务,可以使用 docker-compose build 服务名。 服务名就是 docker-compose.yml 文件中定义的服务名。 然后运行整个应用: docker-compose up 如果想要停止应用,使用 docker-compose down。 和 docker-compose build 一样,如果是想要运行 / 停止单个服务,可以使用 docker-compose up/down 服务名。 运行后就可以在浏览器中访问 localhost 来查看应用了。 但是以上步骤只是在本地运行,并且我也没有使用开发环境。部署到云端、使用生产环境还需要更多的实践,之后再说。

2024/3/1
articleCard.readMore

React 的 Markdown 渲染

项目需求中有一个功能是支持 Markdown 渲染,尽量仿照 ChatGPT、Claude 的效果。 该文章的目的是记录我在实现这个功能时遇到的问题和解决方案。 react-markdown 先安装 react-markdown。 npm install react-markdown 此处运用 react-markdown 库的官方示例中的文本来进行测试: # A demo of `react-markdown``react-markdown` is a markdown component for React.👉 Changes are re-rendered as you type.👈 Try writing some markdown on the left.## Overview* Follows [CommonMark](https://commonmark.org)* Optionally follows [GitHub Flavored Markdown](https://github.github.com/gfm/)* Renders actual React elements instead of using `dangerouslySetInnerHTML`* Lets you define your own components (to render `MyHeading` instead of `'h1'`)* Has a lot of plugins## ContentsHere is an example of a plugin in action([`remark-toc`](https://github.com/remarkjs/remark-toc)).**This section is replaced by an actual table of contents**.## Syntax highlightingHere is an example of a plugin to highlight code:[`rehype-highlight`](https://github.com/rehypejs/rehype-highlight).```jsimport React from 'react'import ReactDOM from 'react-dom'import Markdown from 'react-markdown'import rehypeHighlight from 'rehype-highlight'const markdown = `# Your markdown here`ReactDOM.render( <Markdown rehypePlugins={[rehypeHighlight]}>{markdown}</Markdown>, document.querySelector('#content'))\```> Pretty neat, eh?## GitHub flavored markdown (GFM)For GFM, you can *also* use a plugin:[`remark-gfm`](https://github.com/remarkjs/react-markdown#use).It adds support for GitHub-specific extensions to the language:tables, strikethrough, tasklists, and literal URLs.These features **do not work by default**.👆 Use the toggle above to add the plugin.| Feature | Support || ---------: | :------------------- || CommonMark | 100% || GFM | 100% w/ `remark-gfm` |~~strikethrough~~* [ ] task list* [x] checked itemhttps://example.com## HTML in markdown⚠️ HTML in markdown is quite unsafe, but if you want to support it, you canuse [`rehype-raw`](https://github.com/rehypejs/rehype-raw).You should probably combine it with[`rehype-sanitize`](https://github.com/rehypejs/rehype-sanitize).<blockquote> 👆 Use the toggle above to add the plugin.</blockquote>## ComponentsYou can pass components to change things:```markdownimport React from 'react'import ReactDOM from 'react-dom'import Markdown from 'react-markdown'import MyFancyRule from './components/my-fancy-rule.js'const markdown = `# Your markdown here`ReactDOM.render( <Markdown components={{ // Use h2s instead of h1s h1: 'h2', // Use a component instead of hrs hr(props) { const {node, ...rest} = props return <MyFancyRule {...rest} /> } }} > {markdown} </Markdown>, document.querySelector('#content'))/``` 测试文本中也有代码块,但是因为我用的是 Hexo 写的博客,这些原本应该在(我自己设置的)代码块内的文本都被渲染成了代码块。 因此我在每个代码块的后面都加了个斜杠,以使其不被渲染成代码块。你们在测试时可以 将这些斜杠去掉。 import ReactMarkdown from "react-markdown";function App() { const markdownContent = `{刚才说的Markdown测试文本}`; return ( <> <ReactMarkdown>{markdownContent}</ReactMarkdown> </> );} 显示出来的效果(记得将 markdownContent 的值替换掉): 可以看出和 Typora 或者 GitHub 的渲染效果有一些差异,比方说代码块和普通文字的样式过于贴近,我们还是更适用于有着明显背景色以及代码高亮的代码块。 并且像是表格、任务列表、删除线等特殊样式也没有被渲染出来。 remark-gfm 其实测试文本都告诉了我们为什么:那些是 GFM,也就是 GitHub Flavored Markdown 的特性,而 react-markdown 默认是不支持 GFM 的。 所以我们需要安装 remark-gfm 插件来支持 GFM: npm install remark-gfm react-markdown 引用插件的方式也很简单: import remarkGfm from "remark-gfm";// ...<ReactMarkdown remarkPlugins={[remarkGfm]}>{markdownContent}</ReactMarkdown> 展现出来的效果: 看到这里,你肯定很是疑惑:表格的边框呢?? 如果我们用开发者工具查看这个表格的样式,会发现表格确确实实被渲染成 table 标签,但是 User Agent Stylesheet 中的样式对 table 标签做了一些处理。 所以我们需要自己写一些样式来覆盖这些默认样式: table { border-spacing: 0 !important; border-collapse: collapse !important; border-color: inherit !important; display: block !important; width: max-content !important; max-width: 100% !important; overflow: auto !important;}tbody, td, tfoot, th, thead, tr { border-color: inherit !important; border-style: solid !important; border-width: 2px !important;} 把所有的样式统统加上 !important,这样就可以覆盖掉 User Agent Stylesheet 中的样式了。 再次展现出来的效果: HTML 支持因为安全性考虑,我决定不使用,所以这里就不再展示了。 这样我们就完成了一个简单的 Markdown 渲染功能。不过在这个需求中有一个最为重要的功能:代码块的高亮。 代码块的高亮 因为项目的要求,代码块必须是突显出来、语句高亮的,这对行内代码块也一样。 这里我们可以使用 react-syntax-highlighter。 npm install react-syntax-highlighter react-syntax-highlighter 具有两个引擎:prism 和 highlight.js。两者的区别详细可以直接去网上搜索。 这里我们使用 prism 引擎以及 oneDark 主题: import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; 在 ReactMarkdown 的 components 属性中,我们可以自定义代码块的渲染方式。如果你看过网上的其他教程,近乎清一色的都是这样写的: <ReactMarkdown remarkPlugins={[remarkGfm]} components={{ code({ node, inline, className, children, ...props }) { const match = /language-(\w+)/.exec(className || '') return !inline && match ? ( <SyntaxHighlighter style={nightOwl} language={match[1]} PreTag="div" children={String(children).replace(/\n$/, '')} {...props} /> ) : ( <code className={className} {...props} children={children} /> ) } }}> {markdownContent}</ReactMarkdown> 这里我们自定义了 code 组件的渲染行为。 code({ node, inline, className, children, ...props }) {} 中的参数们分别表示了: node:当前节点 inline:是否是行内代码块 className:类名 children:子节点(代码块中的内容) ...props:其他属性 接着我们使用正则表达式来匹配 className 中的 language-xxx,如果匹配到并且不是行内代码块,就使用 SyntaxHighlighter 来渲染代码块,否则使用默认的 code 标签。 但是!!!! inline 属性在新版本的 react-markdown 中已经被废弃、不会作为参数传入了!!! 这让当时的我非常头疼,网上的资料翻了翻也没有找到解决方案,一时选择去解决另一个问题:没有定义语言的代码块的渲染。 代码块被渲染后,和行内代码块最大的区别是外面贴了一套 pre 标签。由于 code 标签作为子标签无法从 props 中获取到父标签的样式,但是反过来想,pre 标签是可以获取到 code 标签的样式的呀! 于是我嘎嘎乱写: <Markdown remarkPlugins={[remarkGfm]} components={{ pre({node, className, children, ...props}) { if (children["type"] === "code") { try { const match = children["props"]["className"].match(/language-(\w+)/) return ( <pre> <SyntaxHighlighter style={oneDark} language={match[1]} PreTag="div" showLineNumbers wrapLongLines children={String(children["props"]["children"]).replace(/\n$/, '')} /> </pre> ) } catch (e) { return ( <pre> <SyntaxHighlighter style={oneDark} language="python" PreTag="div" showLineNumbers wrapLongLines children={String(children["props"]["children"]).replace(/\n$/, '')} /> </pre> ) } } } }}> {markdownContent}</Markdown> 直接在 pre 标签中判断 children 的类型,如果是 code 那就肯定是代码块了,然后再去匹配 className 中的语言类型。 如果匹配不到就会导致错误,所以我临时加了一个 try...catch 语句,匹配不到的话就默认渲染成 python 语言的代码块吧。 效果还是不错的: 后话 这个需求的实现还是有一些问题的,比方说行内代码块的渲染就没有解决、 try...catch 语句并不是一个好的解决方案,以及没有定义语言就默认渲染成 python 语言的代码块的技术债太鬼畜了! 但是由于项目的时间紧迫,我也没有再去深究这个问题。或许以后时间充裕了我会再去解决这个问题。以后再说吧。

2024/2/21
articleCard.readMore

React 的 Textarea 高度自适应

上次提到过我接触了一个新项目,是校友们策划的一个类 ChatGPT 的项目,我负责前端部分,用的是 React + TailwindCSS 的组合。 我这个刚接触 React 一个月的小白肯定是搓手等着上手、跃跃欲试。 像是 ChatGPT、Claude,甚至是 Discord 这样的聊天室 App,输入框都是能够让用户换行、输入代码块的。我们的项目也不例外。 但是,textarea 组件就算是默认单行,换行时也会向下增加高度,导致脱离原本的父容器,甚至跑到屏幕外面去。 最终结果 环境 先说父容器的样式,一个带了 flex 的 div: <div className="flex flex-col h-full"> <div className="flex-1">{/* 信息内容 */}</div> {/* 主角 */} <Textbox /></div> 而 Textbox 组件的样式差不多是这样的: <div className="flex items-center w-full"> <textarea name="message" className="w-full resize-none" placeholder="Type a message..." rows="1" /> <button>发送</button></div> 正常来说,textarea 的大小是朝下无限增长的,但这不是我们想要的,我们希望它能够朝上增长、挤压信息内容的高度,直到达到一定高度后出现滚动条。 解决方案 为什么要特意说到父容器是一个带了 flex 的 div 呢?因为这是解决问题的关键。 包含了信息内容的兄弟元素会铺满剩余没有被 Textbox 组件占用的空间,而 Textbox 组件的高度是可以动态修改的。 也就是说,我们可以通过监听 textarea 的 scrollHeight 属性,来动态修改 Textbox 整个组件的高度,最终保持让它一直待在父容器的里面。 先创建一个 useRef 来引用 textarea: const textareaRef = useRef(null);// ... <textarea ref={textareaRef} /> 我们还需要创建一个 useState 来保存我们想要的高度: const [height, setHeight] = useState(40); 这里的 40 (像素)是我自己设定的一个最小高度。 创建 useEffect 来监听 textarea 的 scrollHeight 属性: useEffect(() => { const handleResize = () => { const textareaElement = textareaRef.current; textareaElement.style.height = "auto"; textareaElement.style.height = `${textareaElement.scrollHeight}px`; setHeight(Math.max(40, textareaElement.scrollHeight)); } const textareaElement = textareaRef.current; textareaElement.addEventListener("input", handleResize); return () => textareaElement.removeEventListener("input", handleResize);}, []); 在 handleResize 中,我们先将 textarea 的高度设置为 auto,这样就可以让它自己决定高度,然后用 setHeight 来保存 scrollHeight 的值。 Math.max(40, textareaElement.scrollHeight) 是为了保证 textarea 的高度不会小于 40 像素,也就是我刚才说的,我自己设定的一个最小高度。 handleResize 需要在用户输入时触发,所以还要用 addEventListener 来监听 input 事件。 最后别忘了卸载监听器。 那么我们得到的 height 要用在哪里呢?Textbox 组件的最外层 div 上: <div className="flex items-center w-full" style={{ height: `${height}px` }}> <textarea name="message" className="w-full resize-none" placeholder="Type a message..." rows="1" ref={textareaRef} /> <button>发送</button></div> 每次用户输入,height 都会被更新,Textbox 组件的高度也会被更新。拥有着固定高度的 Textbox 组件能够在 Flexbox 布局中自由伸缩,装有信息内容的兄弟元素则会根据 Textbox 组件的高度自动调整。 不过呢还是有需求没能完成:Textbox 组件要在伸展到一定高度时出现滚动条。之后再说吧。 后话 其实之后测试时发现还是有问题的,原因很简单: onKeyDown={e => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); // 我当时忘记加这一句了 handleSendMessage(e.target.value) .then(r => console.log("Message sent")) .catch(e => console.error("Error in sending message:", e)); }}} 因为没有添加 e.preventDefault() 导致每次发送消息时都会多出一行,scrollHeight 也会多出一行,最终导致 Textbox 组件的高度一直在增加、甚至超出父容器。 切记不要忘了啊!血的教训!让我白白修了一天的 bug! handleSize 也可以更新为: const handleResize = e => { e.target.style.height = `inherit`; e.target.style.height = `${e.target.scrollHeight}px`; if (e.target.scrollHeight > 232) { e.target.style.overflowY = "scroll"; e.target.style.height = "232px"; } else { e.target.style.overflowY = "hidden"; e.target.style.height = Math.max(40, e.target.scrollHeight); }} 这样当 scrollHeight 超过 232 时(在我的案例中也就是 9 行),就会停止增加高度、出现滚动条。

2024/2/20
articleCard.readMore

React + Express + Socket.io 之间的实时通信【2】:注册登录

接连着昨日的年轻莽撞,今天 继续研究如何去制作一个类 Slack、Discord 的网页聊天室 App。 其实这篇文在 1 月 23 日开始起草的,然后写代码写着写着就忘了写文。 再加上近期加入了一个新的项目,自己的项目不得不搁置一下。 前端 页面设计的工作我交给了 reactstrap 包,其实用 React-bootstrap 包、或者干脆直接引入 Bootstrap 的 CSS 文件都是可以的。 npm install reactstrap 仿制 Discord 的登录 & 注册页面还是相当容易的: 这里就不说写页面的具体细节,只挑几个我花了时间去搞的地方说。 1. 卡片居中 居中,是前端界最老生常谈的话题之一。浏览器上搜索「居中」一词,会发现十年前大家在聊怎么居中,几年后在聊怎么居中,现在还有 GitHub 网页上出现没有好好居中的标签。 Discord 的登录 / 注册页面的设计方案很简单,正中间一个卡片,上面嘎嘎放表单即可。 我的实现: <Container className='d-flex vh-100'> <Row className='m-auto align-self-center'> ... </Row></Container> 这里推荐一下微软近期出的强力工具:PowerToys,用快捷键 Windows + Shift + C 就可以在屏幕上吸色了,吸的 RGB 值正好用来给我们的标签添加颜色样式。 2. 生日日期选择 这个其实就是: <Container> <Row xs='3'> <Col className='ps-0 pe-1'></Col> <Col className='px-1'></Col> <Col className='ps-1 pe-0'></Col> </Row></Container> 不过重点不在这里,而在 JSX 中用 .map() 方法生成下拉选项。 先看一眼年份的: const currentYear = new Date().getFullYear();const years = [];for (let i = 0; i < 100; i++) { years.push(currentYear - i);}// ...<Input> {years.map( year => ( <option key={year}>{year}</option> ) )}</Input> 其实可以再简化一些,不过能看就行! 月份的逻辑相同: const months = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'November', 'December'];// ...<Input> {months.map( month => ( <option key={month}>{month}</option> ) )}</Input> 日期就更简单了: <Input> {Array.from({ length: 31 }, (_, i) => i + 1).map( day => ( <option key={day} style={{ color: 'rgba(117,122,129)' }}>{day}</option> ) )}</Input> 看着没做每个月内有多少天的逻辑对吧?其实 Discord 也是这样设计的。这种逻辑交给后端就好啦~ 不过我研究了会儿都没实现出来 Discord 的效果:用户还未选择时,年月份三个下拉框都默认显示「年」/「月」/「日」。 3. 数据处理 今日的重头戏。用户在注册时这些数据总要传到服务端去的吧?今天就是来解决这个的。 先装包: npm install react-redux @reduxjs/toolkit Redux 的知识点可以去看我之前的一篇关于 React 的文章。Redux 的概念可以用三个东西来概括:Action、Store 和 Reducer。每当用户与某个组件交互时就会触发 Action(比方说点击按钮),接着 Action 会携带着数据去往 Store 进行存储,途中遇到 Reducer、状态被按照我们要求的进行了更改,最终回到 Store 这个大仓库手里。 为什么我们要使用 Redux 呢?如果我们需要在客户端向服务端发送数据,Redux 可以更好地帮我们管理这些数据,并且 Store 还是全局的,在任一组件中我们都可以访问 Store 中的数据。 我们可以用 @reduxjs / toolkit 包配置 Redux Store。新建一个文件 store.js: import { configureStore } from "@reduxjs/toolkit";const store = configureStore({ reducers: {} });export default store; 不过在完成这段代码之前,我们需要初始化状态。我们的状态需要什么样的数据存储其中?作为一个可以登陆注册的网页 App,我们需要存储用户的登录状态、用户的信息、错误信息等等。这些存储的动作都需要一个 Reducer 来完成。 Redux 官网中在文档里使用了 createSlice 方法来创建 State Slice。 新建一个文件 authSlice.js: import { createSlice } from "@reduxjs/toolkit";export const authSlice = createSlice({ name: "auth", initialState: { isAuthenticated: false, user: {}, error: null }, reducers: { setCurrentUser: (state, action) => { state.isAuthenticated = true; state.user = action.payload; }, setError: (state, action) => { state.error = action.payload; } }});export const { setCurrentUser, setError } = authSlice.actions;export default authSlice.reducer; 这下我们可以完成 store.js 了: import { configureStore } from "@reduxjs/toolkit";import authSlice from "./reducers/authSlice";export default configureStore({ reducer: { auth: authSlice }}); Store 和 Reducer 都有了,自然少不了 Action。 Socket.io 的连接逻辑我会在下一篇文章中讲解,这里我们只需要知道,当用户点击注册按钮时,我们需要将用户的信息发送到服务端。这个过程就是一个 Action。 目前我们只需要验证用户信息是否合法,所以只写了一个 Action:authActions.js。 验证用户信息需要使用到 Socket.io 来和服务端进行通信: import socketIO from "socket.io-client";const socket = socketIO.connect("http://localhost:4000"); 此处假设服务端的端口是 4000。 4. 注册和登录 这两个页面的逻辑是一样的,都是用户输入信息后点击按钮,触发 Action,将用户信息发送到服务端。 在 Socket.io 的连接逻辑中,用户点击按钮后、信息会在客户端中被发送到服务端,服务端会对用户信息进行验证,如果验证通过,服务端会返回一个 Token 给客户端,客户端将 Token 存储到 Store 中;如果验证不通过,服务端则会返回一个错误信息给客户端。 这里只讲一下注册页面的逻辑。 import { useState } from "react";import { useDispatch } from "react-redux";import { useNavigate } from "react-router-dom";import { registerUser } from "./utils/actions/authActions"; useState 是 React 的一个 Hook,用于在函数组件中使用状态。 useNavigate 是 React Router 的一个 Hook,用于在函数组件中进行页面跳转。 registerUser 是等会儿我们会定义的 Action,先不写。 const Register = () => { const dispatch = useDispatch(); const navigate = useNavigate(); const [userData, setUserData] = useState({ username: "", emailAddress: "", password: "", birthYear: "", birthMonth: "", birthDay: "" });} useState 的用法是这样的:接受一个参数作为状态的初始值,比方说我们这里是一个字典,包含着注册页面中所有输入框的值。它会返回一个数组,第一个元素代表着状态的当前值,第二个元素代表着一个函数,用于更新状态。 每当用户输入信息时,我们都应该更新状态。React 提供了一个 onChange 事件供我们使用: const handleChange = e => { setUserData({ ...userData, [e.target.name]: e.target.value });};// ...<Input onChange={ handleChange } /> 每次用户输入信息时,handleChange 函数都会被触发,而 handleChange 函数会调用 setUserData 函数,更新状态。 表单被用户提交后,我们也需要触发一个 Action 来发送用户信息到服务端: const handleSubmit = e => { e.preventDefault(); dispatch(registerUser(userData, navigate));};// ...<Form onSubmit={ handleSubmit } /> useDispatch 是 React Redux 的一个 Hook,用于在函数组件中传递 Action。 那么 registerUser 方法是怎么写的呢? const registerUser = (userData, navigate) => { return dispatch => { socket.emit("register", userData); socket.on("newRegisteredUser", data => { data.status === "00000" ? navigate("/login") : console.log(data.message); }); }}; socket.emit 用于发送数据到服务端;socket.on 用于接收服务端返回的数据。 registerUser 方法会先发送用户填写的信息到服务端,接着监听服务端返回的数据。如果服务端返回的数据中 status 是 00000,则说明注册成功,我们就跳转到登录页面;如果不是,我们就在控制台打印出服务端返回的错误信息。 而在服务端中,我们需要接受名为 register 的事件、验证用户填写的信息,然后发送 newRegisteredUser 事件: socket.on("register", async userData => { const existingUser = await User.findOne({ emailAddress: userData.emailAddress, username: userData.username }); if (existingUser) { console.log(`[U0102] User already exists: ${userData.username}`); socket.emit("newRegisteredUser", { status: "U0102", message: "User already exists." }); return; } await User.create({ emailAddress: userData.emailAddress, username: userData.username, password: userData.password, DOBYear: userData.birthYear, DOBMonth: MonthToNumber[userData.birthMonth], DOBDay: userData.birthDay }) console.log(`[00000] User registered: ${userData.username}`); socketIO.emit("newRegisteredUser", { status: "00000", token: generateJWT(userData.username) });}); User 是一个 Mongoose 模型,用于操作 MongoDB 数据库。 const mongoose = require("mongoose");mongoose.connect("mongodb://localhost:27017/hotaru") .then(() => console.log("Connected to MongoDB")) .catch(err => console.error("Could not connect to MongoDB", err));module.exports = mongoose; const bcrypt = require( "bcrypt");const mongoose = require("./mongodb");const UserSchema = new mongoose.Schema({ emailAddress: { type: String, unique: true }, username: { type: String, unique: true }, password: { type: String, set(val) { return bcrypt.hashSync(val, 10) }, select: false }, DOBYear: { type: Number }, DOBMonth: { type: Number }, DOBDay: { type: Number }, createTime: { type: Date, default: Date.now }})const User = mongoose.model("User", UserSchema);module.exports = { User }; bcrypt 是一个用于加密密码的包,不只是加密密码,我们验证用户登录时也会用到它。 最根本的原因是我们不会在数据库中存储用户的明文密码,要验证用户登陆的话,只能用加密后的用户输入的密码和数据库中的密码进行比对。 我这里也根据网上的文章自己定义了一套错误码,未来可能会展开说说。 这样,我们就完成了注册页面的逻辑。 登录页面的逻辑和注册页面的逻辑是一样的,只是在 registerUser 方法中,我们需要发送 login 事件,而在服务端中,我们需要接受名为 login 的事件: const loginUser = (userData, navigate) => { return dispatch => { socket.emit("login", userData); socket.on("loggedInUser", data => { if (data.status === "00000") { const { token } = data; localStorage.setItem("jwtToken", token); userData["token"] = token; dispatch(setCurrentUser(userData)); navigate("/channels/@me"); } else { console.log(data.message); } }); }}; localStorage 是浏览器提供的一个 API,用于在浏览器中存储数据。这里存储了 JWT Token,以后会提到这是什么。 socket.on("login", async userData => { const existingUser = await User.findOne({ username: userData.username }); if (!existingUser) { console.log(`[U0201] User does not exist: ${userData.username}`); socket.emit("loggedInUser", { status: "U0201", message: "User does not exist." }); return; } bcrypt.compare(userData.password, existingUser.password, (err, confirmPassword) => { if (err) { console.error(err); return; } if (!confirmPassword) { console.log(`[U0202] Password is incorrect: ${userData.password}`); socket.emit("loggedInUser", { status: "U0202", message: "Password is incorrect." }); return; } console.log(`[00000] User logged in: ${userData.username}`); socket.emit("loggedInUser", { status: "00000", token: generateJWT(userData.username) }); });}); 一套组合拳下来,一旦用户的信息被验证成功,就会跳转到频道页面。

2024/2/19
articleCard.readMore

React + Express + Socket.io 之间的实时通信【1】:简单连接

在跟着 IBM 的课程学习全栈开发的时候,期间想到了我于两年前写的一个小项目,当时学习了 Python 的 Flask 框架,就异想天开地编写了一个基于频道的聊天室。当时并不知道实时通信的原理,只是简单地用 Flask 和 SQLite—— 连 Socket.io 都没用上 —— 实现了一个简单的类 Slack 聊天室。 现在见识到了更多的技术,年轻莽撞的我自然是想要重写这个项目。不过具体能不能完成,就是另外一回事了。 阅读本文需知道: 我使用 Jetbrains WebStorm 作为 IDE,新建项目也是使用了 WebStorm 提供的模板 写这个文章时我安装的 Node.JS 版本是 v20.11.0、npm 版本是 10.2.4 项目结构 首先,我们需要新建两个文件夹(废话):一个是用于存放前端代码的 client 文件夹,另一个是用于存放后端代码的 server 文件夹。 在 WebStorm 中,先在 client 文件夹中新建一个 React 项目,然后在 server 文件夹中新建一个 Express 项目。 总体的项目结构差不多是这样的: 总项目(其实不放在一起也OK)/├─ client/│ ├─ node_modules/│ ├─ public/│ ├─ src/│ │ ├─ App.js│ │ ├─ index.js│ ├─ package.json├─ server/│ ├─ bin/│ │ ├─ www│ ├─ node_modules/│ ├─ public/│ ├─ routes/│ ├─ views/│ ├─ app.js│ ├─ package.json 在 client 文件夹中,最重要的便是 src 文件夹,其中的 App.js 和 index.js 是 React 项目的入口文件 在 server 文件夹中,最重要的便是 app.js 文件。不过由于 bin/www 文件是服务器的启动关键,所以需要修改它而不是 app.js 文件 后端 我们率先开始装包: npm install socket.io cors nodemon 不装 Express 包自然是因为 WebStorm 已经帮我们装好了,哈哈。 Socket.io 包是我们这次项目的核心。它能够轻松地实现实时通信,也就是多人聊天的基础。Cors 包用于解决跨域问题,比方说我们的前端项目是运行在 localhost:3000,而后端项目是运行在 localhost:4000,这样就会出现跨域问题。而 Nodemon 包是用于自动重启服务器的,这样我们就不用每次修改代码后都手动重启服务器了,懒人福音。 我们直接来看 bin/www 文件。它的一行代码便告诉了我们它的作用: /** * Create HTTP server. */const server = http.createServer(app); bin/www 创建了一个 HTTP 服务器,而 app 则是我们的 Express 应用。 在导入依赖的地方,我们先导入 Socket.io 和 Cors 包: const cors = require('cors');const { Server } = require("socket.io"); 要想使用 Cors 包,我们就需要激活它: app.use(cors()); 在 HTTP 服务器被创建之后,我们就需要创建 Socket.io 服务器了: const socketIO = new Server(server, { cors: { origin: "http://localhost:3000" }}); 此处的 cors 选项指定了 React 项目的默认端口,为我们之后的前后端通信提供了便利。 接下来,我们需要监听 Socket.io 服务器的连接事件: socketIO.on("connection", (socket) => { console.log(`A user connected: ${socket.id}`); socket.on("disconnect", () => { console.log(`A user disconnected: ${socket.id}`); });}); 这个连接事件会在客户端连接到服务器时触发,打印出客户端的 ID。 最后我们要用上最先装的 Nodemon 包。进入 package.json 文件,找到 scripts 选项,将 start 选项改为: { "start": "nodemon ./bin/www"} 至此,我们的 server 目录就可以先不管了。 前端 和后端一样,先来装包: npm install socket.io-client 为啥不装 React 包呢?额…… 不皮了。 Socket.io-client 包是用于在前端连接到 Socket.io 服务器的。 其实前端的代码很简单,我们只需要在 App.js 中写入以下代码: import socketIO from "socket.io-client";const socket = socketIO.connect("http://localhost:4000"); 好了,大功告成,就这么简单。 后话 最后只需要在两个项目中分别运行 npm start,然后打开浏览器窗口,访问 localhost:3000 就可以看到服务端的终端输出了。 为什么我要水这么一篇文章呢?因为我觉得这个项目的基础部分还是挺有意思的,而且我也想把我学到的东西记录下来,以便日后查阅。 好吧其实根本就是今天早上睡过头、压根没时间花在代码上,所以一整天就干了这么点实质性的东西。 水文章还是挺好玩的,以后有机会再水一篇。

2024/1/22
articleCard.readMore

IBM 全栈开发【5】:Node.JS & Express 创建后端

近期在学习 IBM 全栈应用开发微学士课程,故此记录学习笔记。 1. 服务端 JavaScript 入门 客户端 - 服务端的应用程序(比如基于云的应用程序)通常由「前端」和「后端」组成。前端是指用户在浏览器中看到的应用程序的部分,后端是指在服务器上运行的应用程序的部分。 1.1. 后端 后端开发人员负责开发确保网站正常运行的技术,包括服务器端的应用程序、数据库和服务器。 服务器由硬件和软件组成,它们与客户端进行通信并提供功能。多种类型的服务器可用于不同的目的:Web 服务器用于存储和提供网站的内容,数据库服务器用于存储和提供数据,应用程序服务器用于存储和提供应用程序的功能 数据库是一种用于存储和访问数据的软件。数据库服务器是一种用于存储和访问数据的服务器。数据库服务器可以存储和访问结构化数据(例如,关系数据库)和非结构化数据(例如,文本文件、图像和视频) 网页应用程序接口(API)允许两个软件之间相互通信。网络服务便是网页应用程序接口的一种、使用 HTTP 请求进行通信 编程语言是一种用于编写软件的语言。用于后端开发的编程语言包括 Java、Python、PHP、Ruby、JavaScript 和 C# 框架是一种用于编写软件的工具。框架提供了一组通用的功能,可以帮助开发人员编写软件。用于后端开发的框架包括 Spring、Django、Laravel、Ruby on Rails、Node.JS 和 ASP.NET runtimes 是一种用于运行软件的工具,行为类似于微型操作系统,为应用程序的运行提供必要的资源。Node.JS 就是后端 runtime 环境的一个例子 Node.JS 作为一种后端技术之所以如此流行,原因之一是它运行在谷歌 Chrome 浏览器的开源 V8 引擎上。V8 引擎也是在前端运行浏览器的引擎。大多数现代浏览器都使用 V8 引擎,因此,Node.JS 和浏览器之间的代码兼容性很好。 1.1.1. 可扩展性 可扩展性对企业软件的成功至关重要。它受应用程序负载的影响,是后端的一大责任。而负载指的是并发用户、交易、数据量和其他因素的总和。 可扩展性是指应用程序在不影响性能的情况下动态处理负载增减的能力。 1.2. Node.JS Node.JS 是一个运行在 V8 上的开源语言,它是 JavaScript 的服务器端实现。Node.JS 由事件驱动,使用非阻塞 I / O 模型,这使得它非常轻量级、高效和可扩展。 Node.JS 着重强调使用轻量级语言进行并发编程,它是一种单线程语言,但是,它可以使用事件循环和回调函数来处理并发。 Node.JS 适合希望使用回调函数和 Node.JS runtime 事件循环等功能来构建并发应用程序的开发人员。JavaScript 语言和 Node.JS runtime 的这些功能使得开发人员只需使用一套最少的工具就可以实现快速开发。 通过服务器端 JavaScript,Node.JS 的应用程序可以处理和路由来自客户端的请求: 用户在用 HTML 和 CSS 编写的用户界面中选择一个选项 用户的这一操作会触发在客户端实现业务逻辑的 JavaScript 代码 JavaScript 代码会向服务器发送一个请求(通过 HTTP 调用带有 JSON 数据的 API) 作为在服务器上运行的 Node.JS 应用程序的一部分,REST 网络服务会接收请求并处理它 REST 网络服务处理请求后,通过 HTTP 将结果作为 JSON 数据返回给客户端 1.2.1. 模块 在 Node.JS 中,模块是包含相关的、已封装的 JavaScript 代码的文件,用于实现特定的功能。模块可以是内置的,也可以是外部的;可以是单个文件,也可以是文件夹。 当外部应用程序需要使用模块中包含的代码时,应用程序需要调用该模块。而调用模块就需要使用语句 import() 或者 require()。 模块规范: 一个或多个模块组成一个包,包是一个目录,其中包含一个 package.json 文件,该文件描述了包的内容。包可以发布到 npm(Node.JS 包管理器)上,以便其他开发人员可以使用它们。 常用的模块规范有 CommonJS 和 ES。默认情况下,Node.JS 使用 CommonJS 规范,但是,Node.JS 也支持 ES 规范。库作者仅需要将包文件的扩展名从 .js 更改为 .mjs,就可以使用 ES 模块规范。 CommonJS 规范使用 require(),而 ES 规范使用 import()。 当需要在自身文件之外使用模块时,必须先导出模块。在使用 CommonJS 规范时,可以使用 module.exports 导出模块;在使用 ES 规范时,可以使用 export 导出模块。 import() 和 require() 的区别: import()require() 必须在文件开头调用可以在文件的任何位置调用 不能在条件语句和函数中调用可以在条件语句和函数中调用 静态绑定动态绑定 在编译时解析在运行时解析 异步同步 对比 require(),在涉及到加载数百个模块的应用程序中运行速度更快 require(): // export from a file named message.jsmodule.exports = 'Hello Programmers'; // import from the message.js filelet msg = require('./message.js');console.log(msg); import(): // export from file named module.mjsconst a = 1;export { a as "myvalue" }; // import from module.mjsimport { myvalue } from module.mjs; 1.2.2. 创建简单的网络服务器 Node.JS runtime 打包了许多实用程序模块,你可以使用它们来创建和扩展应用程序。例如,HTTP Node.JS 模块提供了能够监听 HTTP 请求的功能。 let server = http.createServer(function(request, response) { // create an instance of a web server let body = "Hello World!"; response.writeHead(200, { // this callback function handles the incoming request message and provides an appropriate response message "Content-Length": body.length, "Content-Type": "text/plain" }); response.end(body);});server.listen(8080); // set the server to listen to a specific port 1.2.3. Package.json 一个软件包由一个或多个模块组成。每个软件包都有一个 package.json 文件,用于描述 Node.JS 模块的详细信息。 如果模块没有 package.json 文件,Node.JS 就会假定主模块是 index.js 文件。 // Package.json{ "name": "mod_today", "version": "1.0.0", "main": "./lib/today", "license": "Apache-2.0"} 要为模块指定不同的主模块,可以在模块目录中 Node.JS 脚本的相对路径中指定主模块。 1.2.4. 导入 / 导出 Node.JS 模块 你可以使用 require() 函数导入 Node.JS 模块。 let today = require("./today"); require() 语句假定了脚本的文件扩展名为 .js。它会创建一个对象来表示导入的模块,并将其分配给变量 today。 每个 Node.JS 模块都有一个隐式 exports 对象。要向导入模块的 Node.JS 应用程序提供函数或值,就需要在 exports 中添加属性。 let date = new Date();let days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];exports.dayOfWeek = function() { // the dayOfWeek property is added to the exports object return days[date.getDay() - 1];}; 导入 Node.JS 模块时,require() 函数会返回一个 JavaScript 对象,该对象代表着模块的一个实例。 let today = require("./mod_today"); // the today variable is an instance of the today Node.js module that is called "today" 要访问模块的属性,就要从变量中检索属性。 console.log("Happy %s!", today.dayOfWeek()); // today.dayOfWeek() represents the current exported property from the today Node.js module 1.3. Express Express.JS 是一个高度可配置的框架,用于在 Node.JS 上构建应用程序。它通过使用 HTTP 实现程序方法和中间件来抽象出 Node.JS 中的低级 API。 以下功能可让你快速开发应用程序: Express.JS 应用程序 const app = express(); 图像、CSS 和 JavaScript 文件等静态文件 静态路由:定义接收和处理客户端请求的端点 server.js:用于启动应用程序的文件 package.json:用于定义应用程序的依赖项和脚本的文件 1.4. 软件包管理器 软件包管理器是一套用于处理包含依赖关系的模块和软件包的工具。依赖关系是指一个软件包依赖于另一个软件包。 代码库通常包含着许多依赖项,但代码库本身是独立的,不依赖于代码库之外的任何东西。这种独立性使得代码库可以在不同的环境中使用。 软件包管理器能够自动完成查找、安装、更新、配置、维护和删除软件包的工作。它们通常连接并维护一个数据库,其中包含着软件包的依赖关系和版本信息。 1.4.1. npm npm 是 Node.JS 的软件包管理器。它是一个命令行工具,用于安装、更新、配置和删除 Node.JS 软件包。 所有的 npm 软件包都需要一个 package.json 文件,该文件描述了软件包的详细信息。 { "name": "myapp", "version": "1.0.0", "description": "My first Node.js app", "main": "server.js", "scripts": { "start": "node server.js" }, "author": "John Doe", "license": "ISC", "dependencies": { "express": "^4.17.1" }} npm 使用 package.json 文件中的 dependencies 属性来确定软件包的依赖关系。dependencies 属性是一个对象,其中包含着软件包的名称和版本号。 npm 有两种安装软件包的方式:本地安装或者全局安装。如果安装的软件包要在应用程序中使用,就应该使用本地安装。如果安装的软件包要在命令行中使用,就应该使用全局安装。 默认情况下 npm 会采用本地安装。 npm install <package_name> 该命令将在当前工作目录中创建一个 node_modules 文件夹,并将软件包安装到该文件夹中。 全局安装意味着安装软件包的计算机上的所有应用程序都可以使用该代码。全局安装应当谨慎使用,因为它会在计算机上创建一个全局软件包,这可能会导致版本冲突。 要安装 node_modules 文件夹中的所有软件包,要使用以下命令: npm install -g <package_name> 2. 异步 I / O 与回调编程 2.1. 异步 I / O 所有的网络操作都是异步的,因为它们需要等待网络响应。 网络服务调用的响应可能不会立即返回。当应用程序阻塞(或等待)网络操作完成时,就会浪费服务器上的处理时间。 Node.JS 以非阻塞方式进行所有网络操作。每个网络操作都会立即返回。要处理网络调用的结果,就需要编写一个回调函数。 应用程序、Node.JS 框架、调用远程服务器的网络服务和回调函数之间的交互如下: 应用程序会调用 http.request(),该函数会调用远程网络服务器并请求网络服务 在 Node.JS 框架从远程网络服务器接收 HTTP 响应消息之前,它会立即返回 http.request() 函数调用的结果。该结果只表明请求消息已成功发送,并不会说明任何有关响应消息的信息 当 Node.JS 框架从远程服务器接收到 HTTP 响应消息时,它会调用在 http.request() 函数调用过程中定义的回调函数。该函数处理 HTTP 响应消息,并将结果返回给应用程序 let options = { // included the hostname of the remote server, and a URL resource path host: "w1.weather.gov", path: "/xml/current_obs/KSFO.xml"};http.request(options, function(response) { // when the Node.js module calls this anonymous function, events occur while it is receiving parts of the HTTP response object let buffer = ""; let result = ""; response.on("data", function(chunk) { buffer += chunk; }); response.on("end", function() { console.log(buffer); });}).end(); 在实际的应用程序时,你可能需要使用 HTTPS 而不是 HTTP 2.1.1. http.request() 该函数接收一个 URL 和一组选项。如果 URL 和选项都被传入,则将两者合并,选项优先。 http.request(options, [callback function]); 该方法还可以接收一个可选的回调函数,在收到响应后立即调用。 http.request(options, function(response) { ... }); 当 http.request() 调用回调函数时,会在回调函数的第一个参数中传递一个响应对象。该回调函数的第一个参数就是响应对象。 Node.JS 框架会在请求函数运行时发出多个事件。你可以使用 object.on() 方法并将事件名称作为第一个参数传递,从而监听这些事件。如果请求成功,每次数据输入时都会在响应对象上触发一个数据事件,响应结束时触发一个结束事件。 2.1.2. 处理错误 如果请求失败,在 close 事件之后就会出现 error 事件。 let request = http.request(options, function(response) { ... }); request.on("error", function(e) { resultCallback(e.message);});request.end(); 2.2. 回调函数 作为一个异步框架,Node.JS 广泛地使用了回调函数。回调函数是一个函数,它作为参数传递给另一个函数,并在另一个函数完成后调用。 软件开发工具包(SDK)中的 Node.JS 模块会将错误对象作为回调函数的第一个参数。 Call function((error)) 根据这一约定,回调函数会检查第一个参数是否包含了错误对象。 function(error, parameter1, parameter2, ...) { ... } 如果定义了错误对象,回调函数就会处理错误并清理所有打开的网络或数据库连接。 weather.current(location, function(error, temp_f) { if (error) { console.log(error); // if the error is defined, print the error message return; } // otherwise, the weather.current function call completed successfully console.log("The current weather reading is %s degrees.", temp_f);});response.end("... ${temp_f}_") 2.2.1. 传递错误对象 exports.current = function(location, resultCallback) { // ... http.request(options, function(response) { let buffer = ""; let result = ""; response.on("data", function(chunk) { buffer += chunk; }); response.on("end", function() { parseString(buffer, function(error, result) { if (error) { resultCallback(error); return; } resultCallback(null, result.current_observation.temp_f[0]); }); }); });} 2.2.2. 每级一个回调 当 Node.JS 应用程序以非阻塞方式来调用一个模块时,该应用程序会提供一个回调函数来处理结果。 如果主应用程序调用了 http.request(),它就必须提供一个回调处理程序来处理 HTTP 响应消息。 如果主应用程序调用了一个调用了 http.request() 的函数,那就会有两个回调函数: 自定义模块有一个回调函数,用于处理来自 http.request() 的 HTTP 响应消息 主营用程序有一个回调函数,用于处理第一个回调函数捕获的结果 带回调的主应用程序: let weather = require("./weather");let location = "KSFO";weather.current(location, function(temp_f) { console.log(temp_f);}); 主程序调用 weather.current() 时,会传递一个匿名的回调函数来处理调用结果。 exports.current = function(location, resultCallback) { // ... http.request(options, function(response) { // ... response.on("end", function() { resultCallback(...); }); }).end();} 自定义的 Node.JS 模块函数中的 resultCallback() 函数链接着主应用程序中 weather.current() 函数的匿名回调函数 function(temp_f)。 通过回调返回结果: exports.current = function(location, resultCallback) { let option = { host: "w1.weather.gov", path: "/xml/current_obs/" + location + ".xml" }; http.request(options, function(response) { let buffer = ""; response.on("data", function(chunk) { buffer += chunk; }); response.on("end", function() { parseString(buffer, function(error, result) { // ... resultCallback(null, result.current_observation.temp_f[0]); }); }); }).end();} 另一个回调函数的例子: const message = function() { console.log("This message is shown after 3 seconds");}setTimeout(message, 3000); JavaScript 中有一个内置方法叫 setTimeout(),它会在执行操作前等待指定的时间(以毫秒为单位)。在示例中,信息被传入 setTimeout() 函数。因此,在等待 3 秒后,setTimeout() 会将消息写入控制台。 通常这些异步回调(简称 async)都用于访问数据库中的数值、下载图像、读取文件等。 2.2.3. 回调地狱 回调地狱是指在异步编程中,回调函数嵌套过多,导致代码难以阅读和维护。每个回调函数都依赖于前一个回调函数,并等待前一个回调函数完成后才能执行。 firstFunction(args, function() { secondFunction(args, function() { thirdFunction(args, function() { // And so on ... }); });}); 这种结构有时也被称为「The Pyramid of Doom」(末日金字塔)。 回调的另一个问题是 IoC(控制反转)。当控制流(如指令的执行)处于代码的外部时,就会发生控制反转。很多时候,回调会将控制权转交给第三方,但是第三方代码的问题和错误可能很难被发现。这种情况下你不得不去信任第三方代码或者编写额外的代码来确保第三方代码不会在不应该的时候被调用、被调用的次数过多或过少、丢失上下文、传回错误的参数等。 要想缓解回调地狱和 IoC 的问题,你可以: 写注释 使用 Promise 将函数拆分成更小的函数 使用 async/await 2.3. Promise 对于 API 请求、I / O 操作和其他异步操作,Promise 是一种更好的解决方案。 let prompt = require("prompt-sync");let fs = require("fs");const methCall = new Promise((resolve, reject) => { setTimeout(() => { let filename = prompt("What is the name of the file?"); try { const data = fs.readFileSync(filename, { encoding: "UTF-8", flag: "r" }); resolve(data); } catch(err) { reject(err); } }, 3000);}); 2.3.1. Axios 请求 HTTP 请求在同步调用时可能会阻塞。Node.JS 生态系统中有许多包,它们将 Promise 封装在 HTTP 请求中,axios 就是其中之一。 const axios = require("axios").default;const connectToURL = (url) => { const req = axios.get(url); // the status of the promise until it hears back from the URL requested is pending console.log(req); req.then(resp => { console.log("Fulfilling"); console.log(resp.data); }) .catch(err => { console.log("Rejected"); });} 2.4. JSON JSON 是 API 数据交换的标准格式。 要将 JSON 字符串解析为 JavaScript 对象,可以使用方法 JSON.parse()。而要将 JavaScript 对象转换为 JSON 字符串,可以使用方法 JSON.stringify()。 3. Express 网页开发框架 默认的 Node.JS 框架为构建网页应用程序提供了一套有限的功能。 例如,Node.JS 不提供 XML 消息的解析功能。在简单消息中,你可以使用 JavaScript 字符串函数来解析消息,也可以使用 XML 文档对象,但该对象解析 XML 数据流的效率并不高。 开发人员往往依赖第三方软件包来扩展 Node.JS 功能。 你可以将网络服务信息解析为字符串: response.on("data", function(chunk) { buffer += chunk;});response.on("end", function() { let matches = buffer.match(/\<temp_f\>.+\<\/temp_f\>/g); if (null != matches || matches.length > 0) { let result = matches[0].replace(/\<temp_f\>/, "").replace(/\<\temp_f\>/, ""); } resultCallback(null, result);}); 这种手动解析的方法有着许多缺点: 字符串匹配忽略了 XML 数据的结构 信息体可能包含了畸形的 XML 数据 根据 XML 数据的复杂程度,字符串匹配可能要比构建数据的 XML 树更有效率 字符串匹配对 XML 数据结构变化的容忍度很低 如果信息添加或删除了任何 XML 元素,那就必须更改字符串匹配函数的正则表达式 xml2js 是一个流行的 Node.JS 软件包,它可以将 XML 数据解析为 JavaScript 对象。与其他 XML 解析包不同,xml2js 只使用 JavaScript 而不是其他语言。 第三方软件包的软件许可可能与 Node.JS 框架不同。在安装软件包之前,请确认许可条款是否适用于你的公司和应用程序。 npm install xml2js 将软件包导入到 Node.JS 应用程序中: let parseString = require("xml2js").parseString;exports.current = function(location, resultCallback) { // ... let request = https.request(options, function(response) { // ... response.on("end", function() { parseString(buffer, function(error, result) { if (error) { ... } // the result JavaScript variable represents the contents of the XML fragment in buffer resultCallback(null, result.current_observation.temp_f[0]); }); }); });} 3.1. 网页框架 Node.JS 不是网页框架,而是在服务器上执行 JavaScript 的 runtime 环境。网页框架是支持网页应用程序的基本结构,因此要使用 Node.JS,就需要使用与之配合使用的网页框架。 与 Node.JS 协同工作的框架被称为 node 网页框架。它们可采用两种方法构建后端: MVC(模型 - 视图 - 控制器):将应用程序分解为三个部分,每个部分都有自己的职责 REST API 3.1.1. MVC MVC 是一种设计模式,用于将应用程序分解为三个部分: 模型:负责处理数据 视图:负责渲染模型传递的数据 控制器:负责管理数据流、处理用户提供的数据,并将数据发送给模型 MVC 框架一般用于开发需要将数据、数据的展示和操作数据的模块分开的应用程序。 MVC 模式的框架包括 Koa、Django、Express 和 NestJS。 3.1.2. REST API REST API 允许多个网络服务相互通信。但这会受到一些限制:客户端的代码必须完全独立于服务器端的代码;客户端代码的更新不会干扰服务器端代码的运行,反之亦然。 REST API 是无状态的。这代表着客户端不需要知道服务器的状态,服务器也不需要知道客户端的状态。这种无状态的特性使得 REST API 非常适合用于构建分布式应用程序。 REST API 通过对资源的操作进行通信,不依赖于 API 的特定实现。当客户端使用 GET、POST、PUT 和 DELETE 等 HTTP 方法与服务器通信时,服务器便会向客户端响应资源状态。 3.1.3. Express Express.JS 是最流行的 node 网页框架之一。它用于路由和中间件、使用 JavaScript 进行直接编程,意味着学习曲线很低。 Express.JS 提供调试机制,有助于轻松找出应用程序中的错误。它采用异步编程方式,同时处理多个相互独立的操作请求,因此性能很好。 3.1.4. Koa Koa 是一个相对较新的网页框架,由设计 Express 的同一团队设计。它设计得更小巧、更具表现力,并为网页应用程序和 API 提供了更强大的基础。 Koa 使用异步函数,因此不需要回调,这提高了处理错误的能力。该框架适合由经验丰富的大型团队开发高性能、高要求、复杂的应用程序。 3.1.5. Socket.IO Socket.IO 是开发在客户端和服务器之间实时交换双向数据的应用程序的绝佳选择。你可以开发利用 Websocket 而不是 HTTP 协议的应用程序。 它的服务器可以推送数据,而无需客户端调用数据,因此十分适用于聊天室、短信应用、视频会议和多人游戏等应用程序。 3.1.6. Hapi.JS Hapi.JS 是一个可靠的开源节点网页框架,内置了大量安全功能。它的插件系统使得开发人员可以轻松地扩展应用程序的功能。 它最著名的用途是开发代理和 API 服务器、HTTP 代理用户程序、REST API 以及其他桌面和应用程序。 3.1.7. NestJS NestJS 框架适合构建动态、可扩展的企业应用程序,其灵活性得益于大量的库。它采用了 MVC 架构。 NestJS 构建在 Express 的基础之上,因此它们具有相似的功能。 NestJS 与 TypeScript 兼容,还能与前端 Angular 框架结合使用。 TypeScript 是一种 JavaScript 的超集,它添加了类型和其他功能,以帮助开发人员编写更好的代码。 NestJS 结合了面向对象编程和函数式编程的优点,因此它的代码易于阅读和维护。 3.2. Express Express 主要用于两个目的: API 使用服务端渲染(SSR)来设置模板 Express API 设置了一个与应用程序数据层交互的 HTTP 接口。在 API 的情况下,数据会使用响应对象(简称 res)以 JSON 格式返回给客户端。 res.json() 方法用于通知客户端发送数据的内容类型,如图像或文本。它还可用于对数据进行字符串化。 而在 SSR 中,Express 用于设置模板。Express 负责使用客户端通过 HTTP 请求的数据、结合模板动态编写 HTML、CSS 和 / 或 JavaScript。 3.2.1. Node.JS 应用程序框架 Express 实现了一个 app 类,你可以将其映射到网络资源路径。 const express = require("express");const app = express();const port = 3000;// ...let server = app.listen(port, function() { console.log(`Listening on URL http://localhost:${port}`);}); 3.2.2. Express 是如何工作的 在 Node.JS 项目的包文件中,将 Express 作为依赖项添加到 dependencies 属性中 运行 npm 命令来下载缺少的模块 导入 Express 模块并创建一个 Express 应用程序实例 创建一个新的路由处理程序 在指定端口号上启动 HTTP 服务器 // mynodeserver.jsconst express = require("express");const app = express();const port = 3000;app.get('/temperature/:location_code', function(request, response) { const varlocation = request.params.location_code; weather.current(location, function(error, temp_f) { // ... });});let server = app.listen(port, function() { console.log(`Listening on URL http://localhost:${port}`);}); 要处理网页应用程序请求,可将 HTTP 方法和网络资源路径映射到 JavaScript 函数。 3.2.3. 路由 路由是服务器端脚本的一个重要组成部分。对同一服务器的不同路由的请求必须由服务器处理。服务器必须处理对每个路由的请求,否则就会返回相应的错误信息。路由可在应用程序级或路由器级处理。 const express = require("express");const app = new express();app.get("user/about/:id", (req, res) => { res.send("Response about user " + req.params.id);});app.post("user/about/:id", (req, res) => { res.send("Response about user " + req.params.id);});app.get("item/about/:id", (req, res) => { res.send("Response about user " + req.params.id);});app.post("item/about/:id", (req, res) => { res.send("Response about user " + req.params.id);});app.listen(3333, () => { console.log(`Listening at http://localhost:3333`);}); 你需要在应用程序级别使用单独的方法来处理每个路由上的每个方法。当端点或路由较少时,这种方法很简单。但是,当端点或路由数量增加时,这种方法就会变得复杂。我们需要使用路由器来让我们的代码更加简洁、易于阅读和维护。 路由器本身用于处理分支查询,并以不同方法路由每个查询。 const express = require("express");const app = new express();let userRouter = express.Router();let itemRouter = express.Router();app.use("/item", itemRouter);app.use("/user", userRouter);userRouter.get("/about/:id", (req, res) => { res.send("Response about user " + req.params.id);});userRouter.get("/details/:id", (req, res) => { res.send("Details about user " + req.params.id);});itemRouter.get("/about/:id", (req, res) => { res.send("Information about item " + req.params.id);});itemRouter.get("/details/:id", (req, res) => { res.send("Details about item " + req.params.id);});app.listen(3333, () => { console.log(`Listening at http://localhost:3333`);}); 3.2.4. 中间件 中间件包括了可以访问请求和响应对象以及下一个函数的函数。下一个参数决定了函数执行后的操作。 一个 Express 应用程序可以有多个中间件,而且它们之间可以相互连接。 中间件根据目的、用途和功能分为不同的类型: 应用程序级 路由器级 错误处理 内置 第三方 应用程序级中间件可以使用 app.use() 方法绑定到应用程序上: const express = require("express");const app = new express();app.use(function(req, res, next) { if (req.query.password !== "pwd123") { return res.status(402).send("This user cannot login "); } console.log("Time:", Date.now());});app.get("/", (req, res) => { return res.send("Hello World!");});app.listen(3333, () => { console.log(`Listening at http://localhost:3333`);}); 客户端向服务器应用程序发出的所有请求都会通过该中间件进行路由。这种路由对验证和检查会话信息等操作很有用。 路由器级中间件不与应用程序绑定。相反,它与 express.Router() 实例绑定。你可以为特定路由使用特定的中间件,而不是让所有请求都通过同一个中间件: const express = require("express");const app = new express();let userRouter = express.Router();let itemRouter = express.Router();userRouter.use(function(req, res, next) { console.log("User query Time: ", Date()); next();});userRouter.get("/:id", function(req, res, next) { res.send("User " + req.params.id + " last successful login " + Date());});itemRouter.use(function(req, res, next) { console.log("Item query Time: ", Date());next();});itemRouter.get("/:id", function(req, res, next) { res.send("Item " + req.params.id + " last enquiry " + Date());});app.use("/user", userRouter);app.use("/item", itemRouter);app.listen(3333, () => { console.log(`Listening at http://localhost:3333`);}); 响应将根据客户端的请求路径而不同 错误处理中间件既可以绑定到整个应用程序,也可以绑定到特定路由器: const express = require("express");const app = new express();app.use("/user/:id", function(req, res, next) { if (req.params.id == 1) { throw new Error("Trying to access admin login"); } else { next(); }});app.use(function(err, req, res, next) { if (err != null) { res.status(500).send(err.toString()); } else { next(); }});app.get("/user/:id", (req, res) => { return res.send("Hello! User Id ", req.params.id);});app.listen(3333, () => { console.log(`Listening at http://localhost:3333`);}); 错误处理中间件总是需要四个参数:error、request、response 和 next()。不过,你可以省略 next() 参数;即使省略了,也可以在方法中定义。 内置中间件可以绑定到整个应用程序或者特定路由器上。内置中间件对于从服务器渲染 HTML、解析来自前端的 JSON 输入和解析 cookie 等操作很有用。 const express = require("express");const app = new express();// define the static files that can be rendered from the cad220_staticfiles directoryapp.use(express.static("cad220_staticfiles"));app.listen(3333, () => { console.log(`Listening at http://localhost:3333`);}); 你也可以定义自己的中间件或使用第三方中间件,这些中间件可以通过 npm install 命令安装。 创建中间件很简单。你可以定义一个包含三个参数的函数,然后将其与 app.use() 或者 router.use() 绑定。中间件的顺序取决于 .use() 方法用于绑定中间件的顺序。 const express = require("express");const app = new express();function myLogger(req, res, next) { req.timeReceived = Date(); next();}app.use(myLogger);app.get("/", (req, res) => { res.send("Request received at " + req.timeReceived + " is a success!");});app.listen(3333, () => { console.log(`Listening at http://localhost:3333`);}); 3.2.5. 模板渲染 模板渲染是服务器在 HTML 模板中填充动态内容的能力。 const express = require("express");const app = new express();const expressReactViews = require("express-react-views");const jsxEngine = expressReactViews.createEngine();app.set("view engine", 'jsx'); // views are JSX codeapp.set("views", "myviews"); // the views are in a directory named myviewsapp.engine("jsx", jsxEngine);app.get("/:name", (req, res) => { res.render("index", {name: req.params.name});});app.listen(3333, () => { console.log(`Listening at http://localhost:3333`);}); 本代码示例使用了 express-React-views 软件包,它是一个用于渲染 React 视图的 Express 模板引擎。 3.3. 验证 身份验证是通过获取凭证并使用这些凭证验证用户身份的过程。身份验证的目的是识别用户身份,并根据其身份提供访问权限和内容。 身份验证可以通过以下方法实现: 基于会话 基于令牌 无密码 3.3.1. 基于令牌的身份验证 基于令牌的身份验证是在 Node.JS 中实施身份验证的最常见方法。由于令牌只需存储在客户端,所以基于令牌的身份验证更具有可扩展性;服务器只需要验证令牌和用户信息,因此更容易处理多个用户;其灵活性能够在多个服务器上实现身份验证。基于令牌的身份验证中使用的 JWT 可以签名和加密,这意味着它们不会被篡改、没有私人加密密钥便无法读取。 让我们建立一个 Express.JS API 服务器、根据使用权限访问员工信息。应用程序将有两个 API,每个 API 都有自己的端点: 使用 POST API 登录,通过在请求体中发送用户名和密码、返回网页令牌(对该应用程序接口端点的调用应当通过托管应用程序前端的网页服务器来进行) GET API 将获取只有通过身份验证的用户才能访问的员工信息: const express = require("express");const myapp = express();// creates a web server module by calling the express() function and assigning it to the constant myapp// the myapp.get() function creates a GET API endpoint for the Employees API, and any call to this endpoint currently returns an HTTP status code of 401// 401 means Not Authorizedmyapp.get("/employees". (req, res) => { return res.status(401).json({message: "Please login to access this resource"});});myapp.listen(5000, () => { console.log("API Server is localhost:5000");}); 在代码的下一部分,只要用户名和密码正确,我们就允许用户登录,并返回经过验证生成的令牌。一般来说,用户名和密码都存储在数据库中。但是,为了简单起见,我们将在代码中使用「user」和「password」作为用户名和密码。 要生成经过验证的 JWT(JSON Web Token),要使用 jsonwebtoken 包: const express = require("express");const jsonwebtoken = require("jsonwebtoken");const JWT_SECRET = "aVeryVerySecretString"; 通过 myapp.use() 方法,API 方法可以返回 JSON 响应: const myapp = express();myapp.use(express.json());myapp.post("/signin", (req, res) => { const {uname, pwd} = req.body; // however, that the JWT Secret should always be generated using a password generator and stored in the config file as an environment variable and not hard coded in the API, as shown here}); 然后将请求正文中的用户名和密码与从数据库中获取的值进行比较: if (uname === "user" && pwd === "password") { return res.json({ // Once the username and password match, the JWT is generated using the jsonwebstoken.sign() function by including the username and the JWT secret as parameters and is returned as a JSON response from the signin API token: jsonwebtoken.sign({user: "user"}, JWT_SECRET) }); // if the username and password match fails, then a HTTP status code of 401 is returned with the message "Invalid username and/or password" return res.status(401).json({message: "Invalid username and/or password"});}); 接着,我们用「employees」端点定义 GET API 方法: myapp.get("/employees", (req, res) => { let tkn = req.header("Authorization"); if (!tkn) return res.status(401).send("No Token"); if (tkn.startsWith("Bearer ")) { tokenValue = tkn.slice(7, tkn.length).trimLeft(); } // ...}); 从 signin API 调用中获取的令牌会在 Authorization 标头中传递。GET API(也就是「employees」)已更新,可以使用 req.header() 函数从传入的 API 请求中读取 Authorization 标头。值得注意的是,Authorization 标头的值总是以 Bearer 开头,因此这个令牌也被称为 Bearer 令牌。 获取的 JWT 可通过传递获取的令牌和 JWT 密钥、使用函数 jsonwebtoken.verify() 进行验证: myapp.get("/employees", (req, res) => { // ... const verificationStatus = jsonwebtoken.verify(tokenValue, "aVeryVerySecretString"); if (verificationStatus.user === "user") { return res.status(200).json({message: "Access Successful to Employee Endpoint"}); }}); 如果验证失败,则会将 401 状态码返回给客户端。

2024/1/21
articleCard.readMore

IBM 全栈开发【4】:React 创建前端应用

近期在学习 IBM 全栈应用开发微学士课程,故此记录学习笔记。 1. 使用 React 和 ES6 创建前端应用 1.1. 前端框架 前端框架用于创建可连接服务器的动态客户端应用程序。它们通常是开源项目: Angular React Vue 1.1.1. Angular Angular 是一个开源的框架,由谷歌维护。它基于 HTML 和 JavaScript,并且易于实现。 Angular 使用指令来使 HTML 更加动态,所有指令都可用于包含库的 HTML。 <!DOCTYPE html><html> <head> <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.3.16/angular"></script> </head> <body ng-app> Company Name: <input type="text" ng-model="comp_name"> <br> <label ng-bind="comp_name"></label> </body></html> 1.1.2. Vue Vue 是一个开源的前端框架,它使用虚拟 DOM 来实现高性能,HTML 被视为一个完整的对象。Vue 非常轻量级、渲染速度快、易于学习。 <html> <head> <title>VueJS Introduction</title> <script src="https://cdn.jsdelivr.net/.../vue.js"></script> </head> <body> <div id="intro" style="text-align: center;"> <h5> {{ message }} </h5> </div> <script type="text/javascript"> var vue_det = new Vue({ el: "#intro", data: { message: "This is a Vue HTML" } }); </script> </body></html> 1.1.3. React React 是一个用于构建客户端动态网络应用程序的框架,使用动态数据绑定和虚拟 DOM 来扩展 HTML 语法,而不需要编写额外的代码,并保持用户界面元素与应用程序状态的同步。 <html> <body> <h1> Watson Author Finder </h1> <p> Please write your details </p> <form> <input type="text" id="name"> <input type="text" id="age"> </form> </body></html> React 使用 JavaScript XML 这种类似于 HTML 的特殊语言来创建用户界面,其可被 Babel 编译器编译为 JavaScript。 JavaScript XML 要嵌入在特殊的脚本标签中,其中的 type 属性指定了需要 Babel 的内容。 用于构建 React 应用程序的三个重要软件包: React 包:保存组件以及其状态和属性的 React 源代码 ReactDOM 包:React 和 DOM 之间的粘合剂 Babel 编译器:将 JavaScript XML 编译为 JavaScript <html> <!-- Load React API --> <script src="https://unpkg.com/react@16/umd/react.production.min.js"></script> <!-- Load React DOM --> <script src="https://unpkg.com/react-dom@16/umd/react-dom.production.min.js"></script> <!-- Load Babel Compiler --> <script src="https://unpkg.com/babel-standalone@6.15.0/babel.min.js"></script> <body> <div id="comp1"></div> <script type="text/babel"> class Mycomp extends React.Component { // override the render method render() { return <h1>This is my own component named {this.props.name}</h1>; } } ReactDOM.render(<Mycomp name="myBrandNewComp")/>, document.getElementById("comp1")); </script> </body></html> React 组件要在 <script> 标签中定义,type 属性的类型需要设置为 text/babel,以便 Babel 编译器将其编译为 JavaScript 定义的组件为 Mycomp,继承自 React.Component,并重写了 render() 方法 ReactDOM.render() 方法用于渲染组件,并指定组件名称、HTML 标签和要设置的任何属性(该例子中就设置了 name 属性) 组件需要被指定呈现在 HTML 页面的哪个位置(该例子中就是 comp1) Facebook 提供了一个名为「Create React App」的工具,可以简化创建 React 应用程序的过程。 如果已安装 Node.JS,就可以运行以下命令来安装 Create React App: npx create-react-app my-app 当运行完上述命令后,系统会自动创建一个包含所有必要文件的目录结构。该目录结构包含创建和运行 React 应用程序所需的所有文件。 src 目录是我们需要修改的主要目录 App.js 文件是我们要添加到 HTML 页面的 React 根组件 index.js 文件将应用程序添加到 HTML 页面 1.2. ES6 ES6 的全程为 ECMAScript 6,制定了广泛的全球信息和通信技术标准。 JavaScript 遵循 ECMAScript 6 标准(2015 年),其最主要的更改是: let const 箭头函数 Promise 构造函数 类 1.2.1. let 和 const let/const 和 var 不同: var 声明的变量的作用域是全局的。这很有挑战性,尤其是在大型项目中,代表着有许多变量需要维护 let 可以将变量的作用域限制在声明变量的代码块中 function() { let num = 5; num = 6;}console.log(num); // will throw an error const 声明的变量的值不能被修改 const num = 5;console.log(num);num = 6; // will throw an errorconsole.log(num); 1.2.2. 箭头函数 箭头函数允许函数像变量一样声明,这是一种更简洁的函数声明方式。 // how a function was written in the older ES5 JavaScriptfunction sayHello() { console.log("ES5 function - Hello world!");}// arrow function in ES6const sayHello = ()=> console.log("ES6 function - Hello world!"); 箭头函数可以被调用,并可以作为回调的参数传递。 const sayHello = ()=> console.log("Hello world!");setTimeout(sayHello, 1000); 箭头函数也可以像普通函数一样接受参数。 // takes one parameter// brackets are not mandatory// because the code returns a value, it must be in curly bracketsconst oneParamArrowFunc = name => {return "hello " + name};// function brackets must be put around the parameters list// does not need curly brackets because it only has one line of code and returns nothingconst twoParamsArrowFuncWithoutReturn = (first, last) => console.log("hello " + first + " " + last);const twoParamsArrowFuncWithReturn = (first, last) => {return "hello " + first + " " + last};const twoParamsTwoLinesArrowFunc = (first, last) => { const greeting = "hello "; return greeting + " " + first + " " + last;} 1.2.3. Promise Promise 对象表示了一个异步操作的最终完成或失败,以及其返回值。每当你调用异步操作时,Promise 会处于 pending(挂起)状态;当操作成功地执行时,Promise 会处于 fulfilled(履行)状态;当操作失败时,Promise 会处于 rejected(拒绝)状态。 let promiseArgument = (resolve, reject) => { setTimeout(() => { let currTime = new Date().getTime(); if (currTime % 2 === 0) { resolve("Success!"); } else { reject("Failed!"); } }, 2000);};let myPromise = new Promise(promiseArgument); let myPromise = new Promise((resolve, reject) => { setTimeout(() => { let currTime = new Date().getTime(); if (currTime % 2 === 0) { resolve("Success!"); } else { reject("Failed!"); } }, 2000);}); 以上两种写法是等价的。 1.2.4. 类 ES6 中的类使面向对象编程在 JavaScript 中更加容易。类创建了对象的模板,且建立在原型(即 prototype,是所有 JavaScript 对象的属性,包括函数,而函数可用于创建对象实例)的基础上。 function Person(name, age) { this.name = name; this.age = age; return this;}let person1 = Person("John", 20); this 指代的是当前对象 类的概念是在函数原型的前提下建立的,目的是将面向对象编程扩展到 JavaScript 中 构造函数(constructor)是一个特殊的函数,用于创建一个类对象: class Rectangle { constructor(height, width) { this.height = height; this.width = width; }};let myRectangle = new Rectangle(10, 5); 使用 new 关键字就可以创建一个类的实例 在 JavaScript ES6 中,类可以继承自其他类。继承其他类的类被称为子类(subclass),而超类(superclass)是被子类继承的类。子类会继承超类的所有属性和方法。 子类具有特殊权限,能够使用 super() 方法来调用超类的构造函数。 class Square extends Rectangle { constructor(height, width) { if (height === width) { super(height, width); } else { // if the height is not the same as the width specified, // the width will become equal to the height super(height, height); } }}let mySquare = new Square(5, 5); 1.3. JSX JSX 是 JavaScript XML 或 JavaScript Syntax Extension 的缩写,是一种类似于 React 使用的 XML 或 HTML 类语法,用于创建 React 元素。 JSX 允许 XML 或 HTML 类文本与 JavaScript 或 React 代码并存。 JSX 使用预处理器将 JavaScript 文件中的 HTML 类文本转换为标准的 JavaScript 对象,例如转译器或编译器(比方说 Babel)。 const el1 = <h1>This is a sample JSX code snippet</h1> JSX 代码的语法就像是 HTML 使用了类似 JavaScript 的变量 1.3.1. React 代码例子 import React from 'react'function App () { return ( <div> <p>This is a sample list</p> <ul> <li>List item no. 1</li> <li>List item no. 2</li> </ul> </div> );} 而这是普通的 JavaScript 代码: import React from 'react'function App () { return React.createElement( "div", null, React.createElement("p", null, "This is a sample list"), React.createElement("ul", null, React.createElement("li", null, "List item no. 1"), React.createElement("li", null, "List item no. 2")) );} 可以看出来,如果没有 JSX,React 代码将不得不使用大量嵌套来编写,这会导致代码变得难以阅读和维护。 1.3.2. 组件 组件(component)是 React 的核心构件,是一个可重用的代码块,用于创建用户界面。组件可以是函数或类,它们接受输入并返回 React 元素。 组件可以拥有自己的状态,这些状态是描述了组件行为的对象。有状态的组件的类型为类,而无状态的组件的类型为函数。 React 组件通过三个概念实现这些功能: 属性(property):用于从父组件向子组件传递数据 事件(event):使组件能够管理 DOM 事件和用户在系统上交互的动作 状态(state):根据组件的当前状态更新用户界面 React 应用程序是一颗组件树:根组件就像一个容器,它包含了所有其他组件。 所有组件的名称,无论是函数还是类,都必须以大写字母开头。组件可以通过使用 className 属性和 CSS 来进行样式化。 组件类型: 函数式组件通过编写 JavaScript 函数来创建,可以接受也可以不接受数据作为参数,返回 JSX 函数。它们本身没有状态或生命周期方法,因此也被称为无状态组件,但是可以通过实现 React Hooks 来添加这些功能。 React Hook 是 React 的一项新功能,它能让你在不编写类的情况下使用 React 的特性 生命周期方法(lifecycle methods)是 React 内置的方法,可以在 DOM 中的整个持续时间内对组件进行操作 函数式组件用于显示易于阅读、调试和测试的静态数据。 const Democomponent = () => { return <h1>welcome Message!</h1>;} 当组件有属性但生命周期不需要管理时最有用。 函数式组件可以接受用户自定义的属性作为参数: function App(props) { // props passed as a function parameter const compStyle = { color: props.color, fontSize: props.size + 'px' }; return ( <div> <span style={compStyle}>I am a sentence.</span> </div> );}export default App; ReactDOM.render( <React.StrictMode> <App color="blue" size="25"/> <!-- props being sent to the component --> </React.StrictMode>, document.getElementById('root')); 事件处理程序(event handler)可以通过属性来设置,其中 onClick 处理程序在功能组件中使用的最多: import React from 'react';import ReactDOM from 'react-dom';import App from './App';ReactDOM.render( <React.StrictMode> <App color="blue" size="25" clickEvent={ <!-- setting an event handler method as a property --> () => { alert("You clicked me!") } }/> </React.StrictMode>, document.getElementById('root')); function App(props) { return ( <div> <button onClick={props.clickEvent}>Click Me!</button> <!-- setting an event handler from props --> </div> );}export default App; 类组件要比函数式组件更复杂,它们可以将数据传递给其他类组件、可以被 JavaScript ES6 的类创建、可以使用状态、属性和生命周期方法等 React 功能。 class Democomponent extends React.Component { render() { return <h1>Welcome Message!</h1>; }} 由于其多功能性,类组件要比函数式组件更受青睐。由于它们继承了 React.Component,因此必须要覆盖 render() 方法。 // import the React module from the react packageimport React from 'react';// create the App class that extends React.Componentclass App extends React.Component { constructor(props) { super(props) } // override the render method render() { return <button onClick={this.props.clickEvent}>Click Me!</button>; }}export default App; props 在类组件外部设置,而状态要在类组件内部设置: import Reach from 'react';class App extends React.Component { constructor(props) { super(props) } state = {counter: "0"}; // define the state counter of the component App // a function to increment the counter every time a button is clicked incrementCounter = () => { this.setState({counter: parseInt(this.state.counter) + 1}); } // override the render method render() { return <div> <button onClick={this.incrementCounter}>Click Me!</button> <br/> {this.state.counter} </div> }} 纯组件(pure component)优于函数式组件,主要用于提供优化。它们是编写起来最简单最快的组件,不依赖于其作用域之外的任何变量状态,可以用来替代简单的函数式组件。 高阶组件(higher-order component)是 React 中重用组件逻辑的高级技术。API 不提供高阶组件。它们返回组件的函数,用于与其他组件共享逻辑。 // import React and React Native's Text Core Componentimport React from 'react';import { Text } from 'react-native';// define a component as a functionconst Helloworld = () => { return ( <Text>Hello, World!</Text> );}// export your function component// the function can then be imported in any applicationexport default Helloworld; 2. React 组件 2.1. 状态 状态允许你在一个应用程序中修改数据。它被定义为一个对象,使用键值对来存储数据,并帮助你跟踪应用程序中不同类型的数据。 React 组件有一个内置的状态对象,可以在状态对象中存储属于组件的属性值。当状态对象发生变化时,组件会重新渲染。 // componentclass TestComponent extends React.component { constructor() { this.state = { id: 1, name: "John", age: 28 }; } render() { return ( <div> <p>{ this.state.name }</p> <p>{ this.state.age }</p> </div> ) }} 本代码示例展示出了如何创建一个测试组件,该组件包含 id、name 和 age 三个状态属性 组件的 render() 方法返回了状态属性的值 包含属性的状态将根据组件的要求进行更改 React 状态的类型: 共享状态(shared state):由多个组件共享,比较复杂。例如订单应用程序中的所有订单列表 本地状态(local state):存在于单个组件中,不用于其他组件。例如隐藏和显示信息 2.2. 属性 属性用于在 React 组件之间传递数据。工作方式与 HTML 属性类似,它们存储标签的属性值。 React 组件之间的数据流是从父组件到子组件的单向数据流。 属性可以像函数参数一样被传递,但它们是只读的,不能在组件内部更改。属性允许子组件访问父组件中被定义的方法(状态则是由父组件管理,而子组件没有自己的状态),大部分组件将根据接收到的属性来显示信息,并保持无状态。 // componentclass TestComponent extends React.component { render() { return <div>Hi { this.props.name }</div> }}// passing the props as examples to the text component<TestComponent name="John" /><TestComponent name="Jill" /> 该代码示例创建了一个类 TestComponent,该类扩展了 React 组件 2.3. 组件阶段 每个 React 组件在其生命周期中都有三个阶段: 挂载阶段(mounting phase):组件被创建并插入 DOM 中。当组件被创建时,会有四个方法被依次调用: constructor():用于初始化组件的状态和属性 getDerivedStateFromProps():用于更新组件的状态 render():用于渲染组件;必须且只能返回一个 DOM 元素 componentDidMount():用于在组件被插入 DOM 后执行一些操作 import React from 'react';class App extends React.Component { // when the component App is created, the constructor is invoked constructor(props) { super(props) console.log("Inside the constructor") } // the componentDidMount method is invoked componentDidMount = () => { console.log("Inside component did mount") } // the render method is invoked render() { console.log("Inside render method") return ( <div> The component is rendered </div> ); }}export default App; 更新阶段(updating phase):组件的状态或属性发生变化时,会触发更新阶段。当组件更新时,会有五个方法被依次调用: getDerivedStateFromProps():用于更新组件的状态 shouldComponentUpdate():每当状态发生变化时被调用;默认返回 true;应当仅在不想渲染状态的变化时返回 false render():用于渲染组件;必须且只能返回一个 DOM 元素 getSnapshotBeforeUpdate():用于在 DOM 更新前获取 DOM 状态 componentDidUpdate():用于在 DOM 更新后执行一些操作 import React from 'react';class App extends React.Component { state = {counter: "0"}; incrementCounter = () => this.setState({counter: parseInt(this.state.counter) + 1}); // returns true by default // its behavior is rarely changed shouldComponentUpdate() { console.log('Inside shouldComponentUpdate') return true; } getSnapshotBeforeUpdate(prevProps, prevState) { console.log('Inside getSnapshotBeforeUpdate'); console.log('Prev counter is ' + prevState.counter); console.log('New counter is ' + this.state.counter); return prevState; } componentDidUpdate() { console.log('Inside componentDidUpdate') } // logs on to the console and then renders the component render() { console.log('Inside render') return ( <div> <!-- With the onClick of the button, incrementCounter is invoked, increasing the counter state by 1 --> <button onClick={this.incrementCounter}>Click Me!</button> {this.state.counter} </div> ); }}export default App; 卸载阶段(unmounting phase):组件从 DOM 中移除时,会触发卸载阶段。当组件被卸载时,会有一个方法被调用: componentWillUnmount():用于在组件被卸载前执行一些操作 import React from 'react';class AppInner extends React.Component { componentWillUnmount() { console.log('This component will unmount') } render() { return <div>Inner component</div> }}class App extends React.Component { state = {innerComponent:<AppInner/>} componentDidMount() { setTimeout(() => { this.setState({innerComponent: <div>unmounted</div>}) }, 5000) } render() { console.log('Inside render') return ( <div> {this.state.innerComponent} </div> ); }}export default App; 2.4. 组件之间的数据传递 React 组件之间的数据传递可以有: 使用属性的「父到子」数据传递 使用回调函数的「子到父」数据传递 使用 Redux 的「兄弟」数据传递(此处不做讨论) 父到子: class App extends React.Component { state = {childColor: "green", name: "John"} changeColor = () => { const newcolor = document.getElementById('colorbox').value; this.setState({childColor: newcolor}) } changeName = () => { const newname = document.getElementById('namebox').value; this.setState({name: newname}) } render() { console.log('Inside render') return ( <div> Color <input type="Text" onChange={this.changeColor} id="colorbox" /> <br/> Name <input type="Text" onChange={this.changeName} id="namebox" /> <!-- App sets the property color and name for AppInner --> <!-- The data is passed to the child every time a new value is entered in the input boxes in the parent --> <AppInner color={this.state.childColor} name={this.state.name} /> </div> ); }} class AppInner extends React.Component { constructor(props) { super(props) } render() { const txtStyle = {color: this.props.color} return <span style={txtStyle}>{this.props.name}</span> }} 其中,App 组件是 AppInner 组件的父组件 子到父: class App extends React.Component { state = {message: ""} // Func1 is a parent component function which takes a string argument func1 = (childData) => { this.setState({message: childData}) } render() { return ( <div> <!-- Pass the callback func1 as a property to the child --> <AppInner parentCallback = {this.func1} /> <p>{this.state.message}</p> </div> ); }} class AppInner extends React.Component { sendData = () => { setInterval(() => { const currTime = Date(); // the parent class method which sets the state of the parent component this.props.parentCallback(currTime); }, 1000); } // invoke the sendData method componentDidMount() { this.sendData(); } render() { return <div></div> }} 2.5. 组件的生命周期 组件的生命周期代表了组件从创建到销毁的整个过程。React 组件的生命周期包含四个阶段,每个阶段都有不同的方法: 初始化(initialization):组件以给定的属性和默认状态被创建 挂载(mounting):渲染由 render() 方法返回的 JSX 更新(updating):当组件的状态或属性发生变化时,会触发更新阶段 卸载(unmounting):组件从 DOM 中移除 2.5.1. 挂载阶段 挂载阶段中,组件被添加到 DOM,并在组件加载前和加载后调用两个预定义方法: componentWillMount() componentDidMount() 2.5.2. 更新阶段 组件的状态或属性发生变化时,会触发更新阶段。变化可以在组件内发生,也可以通过后台发生,这些变化都会触发 render() 方法的调用。 getDerivedStateFromProps() shouldComponentUpdate() render() getSnapshotBeforeUpdate() componentDidUpdate() 2.5.3. 卸载阶段 组件从 DOM 中移除时,会触发卸载阶段。在卸载阶段,只有一个方法被调用: componentWillUnmount() 2.6. 外部服务 路由器(router)可以连接到外部服务以执行多种操作,例如: GET:从服务器获取数据 class App extends React.Component { state = { user: "None Logged In" } // connect to a server through an axios request componentDidMount() { const req = axios.get("<external server>"); req.then(resp => { // then the promise is fulfilled, you parse the response and extract the data from it to change user to have the same name as its value this.setState({user: resp.data.name}); }) .catch(err => { this.setState({user: "Invalid user"}); }); } render() { return ( <div> Current user - {this.state.user} </div> ); }} POST:将数据发送到服务器 const express = require("express");const app = new express();// this server uses the CORS middleware to allow cross-origin requests to the serverconst cors_app = require("cors");app.use(cors_app());let usercollection = [];app.post("/user", (req, res) => { let newuser = {"name": req.query.name, "gender": req.query.gender} usercollection.push(newuser); return res.send("User successfully added");});app.get("/user", (req, res) => { return res.send(usercollection);})app.listen(3333, () => { console.log("Listening at http://localhost:3333")}) Express 服务器接收端点 /user 的 POST 请求,并将数据存储在 usercollection 数组中 class App extends React.Component { state = {completionstatus: ""} postDataToServer = () => { axios.post("http://localhost:3333/user?name=" + document.getElementById("name").value + "&gender=" + document.getElementById("gender").value ) .then(response => { this.setState({completionstatus: response.data}) }).catch((err) => { this.setState({completionstatus: "Operation failure"}) }) } render() { return ( <div> Enter the name <input type="text" id="name" /> <br /> Enter the gender <input type="text" id="gender" /> <br /> <button onClick={this.postDataToServer}>Post Data</button> <span>{this.state.completionstatus}</span> </div> ); }} UPDATE:修改数据 DELETE:删除数据 大多数对外部服务器的请求都是阻塞性的。要异步调用,可以使用 Promise。 2.7. 测试 测试可以是一套由代码组成的,以验证应用程序的无差错执行。 测试 React 组件有多个好处:验证代码运行无误;通过复制最终用户的行为来测试组件;通过测试组件的不同状态来测试组件;防止先前已修复的错误再次出现。 测试有着两种类型: 在简单的测试环境中渲染组件树并验证其输出 在真实的浏览器环境中运行应用程序,进行端到端的测试 2.7.1. React 组件测试的阶段 安排(arrange):组件需要将其 DOM 渲染到用户界面 操作(act):注册任何可能以编程方法触发的用户行为 断言(assert):验证组件的输出是否与预期的输出相匹配 2.7.2. 测试工具 速度 vs 环境: 有些工具能在做出修改和看到结果之间提供非常快的回馈,但无法精确地模拟浏览器行为 有些工具可能会使用真实的浏览器环境,但会降低迭代速度,在持续集成环境中使用时可能会导致不稳定 测试工具有: Mocha Chai:断言库 Sinon Enzyme:渲染组件 Jest:测试 React 组件,并拥有着 Mocha、Chai、Sinon 以及其他工具的能力 React Testing Library:测试 React 组件 3. React 进阶 3.1. Hooks Hooks 是在用户界面中封装有状态的行为的更简单的方法,它们允许函数式组件访问状态和其他 React 功能。Hooks 是常规的 JavaScript 函数,提供使用上下文或状态等功能的方法,且无需编写类,帮助你使代码更简洁。 类组件有时会带来一些问题,例如封装复杂、组件大小难以管理以及类混淆等。 标准的 Hooks: useState:为函数式组件添加状态 useEffect:管理副作用(side effects) useContext:管理上下文 useReducer:管理 Redux 的状态变化 自定义 Hooks 允许你为应用程序添加特殊功能。它们可以由一个或多个 Hooks 组成、可以被重复使用、分解为更小的 Hooks。自定义 Hooks 需要以 use 开头。 import React, { useState } from "react";function CntApp() { // declare a new state variable "count" // useState is the hook which needs to call inside a function component to add some local state to it const [count, setCount] = useState(0); return ( <div> <p>You clicked {count} many times</p> <button onClick={() => setCount(count + 1)}> Click me </button> </div> );}export default CntApp; 3.2. 表单 大多数 React 表单都是单页面应用程序(SPA)或者加载单个页面的网络应用程序。表单使用组件处理数据、使用事件处理程序控制变量的变化和状态的更新。 表单标签有: <input> <textarea> <select> 在 HTML,状态由表单元素管理;在 React,组件的状态管理着表单元素。 3.2.1. 输入类型 非受控输入受控输入 允许浏览器处理大部分表单元素,并通过 React 的变化事件收集数据使用 React 直接设置和更新输入值,从而完全控制元素 在输入的 DOM 节点中管理自己的状态函数管理数据的传递 元素会在输入值发生变化的时候更新更好地控制表单元素和数据 ref 函数用于从 DOM 中获取表单值属性获取当前值并通知更改 父组件控制更改 表单示例: import React, { Component } from "react";export default function App() { // to track the state of the email address, this example uses a hook const [email, setEmail] = React.useState(""); const [password, setPassword] = React.useState(""); const handleSubmit = (event) => { console.log("Email: ${email}\nPassword: ${password}"); event.preventDefault(); } return ( <form onSubmit={handleSubmit}> <h1>Registration</h1> <label> <!-- to ensure that the email remains updated when the use interacts with form, you must add as input, value, and onChange attributes to the email address --> Email: <input name="email" type="email" value={email} onChange={e => setEmail(e.target.value)} required /> </label> <label> Password: <input name="password" type="password" value={password} onChange={e => setPassword(e.target.value)} required /> </label> <button>Submit</button> </form> );} React Hook Form 是一个创建表单的实用软件包,它可以帮助你创建可重用的表单组件。 3.3. Redux Redux 是一个状态管理库,它遵循一种称为 Flux 架构的模式,通常在组件数量较多的时候实用。 Redux 提供了一个集中的状态管理系统,它将应用程序的所有状态存储在一个单一的对象中,称为存储(store)。存储是一个 JavaScript 对象,它包含了应用程序的所有状态。 Redux 的工作流程:当用户与应用程序的某个组件交互时,Action 会更新整个应用程序的状态,这反过来又会触发组件的重新渲染,从而更新该组件的属性,这些属性会将结果反馈给用户。 3.3.1. 概念 Action:【你的应用程序能做什么】。它是一个由选择单选按钮、复选框或点击按钮触发的事件 / JSON 对象;它包含着需要对状态进行更改的信息,并由被称为操作创建器(action creator)的函数创建。Action 由应用程序的各个部分派发,并由存储空间接收 Store:应用程序状态的唯一位置和权威来源。它是一个包含着状态、函数和其他对象的对象,可以调度和接收操作。Store 的更新能够被订阅 Reducers:返回全新的状态的函数。它们从 Store 接收 Action,并对状态进行适当更改。作为事件监听器,Reducer 会读取 Action 的有效载荷(payload)并更新 Store。 Reducer 接收两个参数:先前的应用程序状态和 Action 3.3.2 中间件 中间件(middleware)是一个函数,它可以访问 Action 和 Store,并且可以在 Action 到达 Reducer 之前执行某些操作。它可以用于日志记录、分析、异步请求等。 Thunk 中间件:允许在操作创建器中传递函数以创建 async Redux、允许编写操作创建器、允许延迟调度操作、允许调度多个操作。优势是 Thunk 中间件可以无需大量模板代码即可实现异步操作、学习难度小、易于使用;缺点是不能直接对操作做出响应、难以处理可能出现的并发问题、是命令式的、不太容易测试和扩展 Saga 中间件:使用称为生成器(generator)的 ES6 功能来实现异步操作、允许以纯函数的形式表达复杂的逻辑、易于测试、允许分离关注点、易于扩展具有副作用的复杂操作、易于通过 try/catch 处理错误;缺点是不适合简单的应用程序、需要更多的模板代码、需要具备生成器的知识 基于 Promise 的中间件 3.3.3. 数据流 React-Redux 应用程序的数据流是单向的。它只朝一个方向流动。 操作创建器(action creator)朝根归纳器(root reducer)流动 根归纳器处理 Action 并返回新的状态到储存空间(store) 存储空间更新用户界面(UI) 用户界面调用操作创建器 为什么要选择单向数据流:双向数据绑定会影响浏览器性能,而且很难跟踪数据流,因此 Redux 的单向数据流解决了这个问题。

2024/1/18
articleCard.readMore

IBM 全栈开发【3】:云原生 + 开发运维 + Agile + NoSQL

近期在学习 IBM 全栈应用开发微学士课程,故此记录学习笔记。 1. 云原生入门 云原生应用是一种构建和运行在云上的应用程序。云原生应用由协同工作的微服务组成,以充分利用云计算的优势。 云计算,简称「云」,是一种基于互联网的计算方式,通过这种方式,共享的软硬件资源和信息可以按需求提供给计算机各种终端和其他设备。 NIST 将其定义为:一种实现对可配置计算资源的共享池进行便捷、按需网络访问的模式,这些资源可快速调配和释放,只需极少的管理工作或服务提供商的互动 计算资源包括网络、服务器、存储、应用程序和服务 云模式由五个基本特征、四种部署模式和三种服务模式组成: 五个基本特征:按需自助服务、广泛网络访问、资源池共享、快速弹性伸缩、可度量服务 四种部署模式:私有云、社区云、公有云、混合云 三种服务模式:基础设施即服务(IaaS)、平台即服务(PaaS)、软件即服务(SaaS) 1.1. 现代软件开发 以服务形式交付 集中托管并通过互联网访问 网络应用程序、SaaS 1.2. 云原生堆栈 应用程序代码:持有云原生应用程序 应用程序运行时:中间件 应用程序和数据服务:数据库、消息队列、缓存 调度和协调:控制面板,比如 Kubernetes 云基础设施:定义环境 1.3. 使用案例 云中的一切都应采用云原生方法构建,以充分利用云的优势。 应用代码需要使用以下工具: 标准化的日志 标准化的事件 多个微服务和云原生应用程序可使用的标准目录 微服务的标准化跟踪 1.4. CNCF 云原生计算基金会(CNCF)是一个非营利性组织,致力于促进云原生计算的采用和技术进步。CNCF 的使命是通过开源、中立和可持续的项目来推动云原生计算的采用。 CNCF 通过为参与公司的项目提供营销和支持来帮助他们。CNCF 还通过培训和认证来支持开发人员。 1.5. 混合云 混合云是指在私有云和公有云之间创建连接的计算环境。 混合云允许应用程序和数据在私有云和公有云之间移动。 公共云由外部提供商管理,在一个硬件平台上有多个租户,可包括多个地点的服务器;私有云由企业管理,通常在企业的数据中心中,由企业拥有和使用。 内部部署的私有云属于单个组织,由其托管和管理自己的云空间。 1.6. 实践与方法 测试驱动开发(TDD):在编写代码之前编写测试 行为驱动开发(BDD):关注从外部看到的系统行为,而非系统内部的工作方式 持续集成/持续交付(CI/CD):一种软件开发方式;你可以构建和部署软件,以便随时将其发布到生产中 敏捷开发(Agile):一种迭代的项目管理方法,可帮助团队快速响应并向客户交付价值 Scrum:一种进行增量产品开发的管理框架 微服务架构:一种单一应用程序由多个松散且可独立部署的小型服务组成的方法,每个服务通过 API 进行通信 容器(Container):独立的、包罗万象的可执行软件单元 2. 开发运维和 CI / CD 2.1. 开发运维(DevOps) DevOps 是一种软件开发方法,旨在通过自动化软件交付流程来加快软件交付速度。DevOps 是开发(Dev)和运维(Ops)的组合。 Patrick Debois 是 DevOps 的创始人。 他如此形容 DevOps:「敏捷开发环境的延伸,旨在加强整个软件交付的过程。「 DevOps 推动文化和技术变革,促进开发和运维团队之间的协作,简化流程,自动化任务,并持续交付高质量的软件。 DevOps 提高了效率和客户价值,要求组织学习和文化转型。 其核心目标是自动化软件交付。 DevOps 诞生前,开发和运维团队之间的沟通和协作很少。开发团队开发软件,然后将其交付给运维团队,运维团队负责部署和维护软件。这种模式导致了开发和运维团队之间的隔阂,从而导致了低效率、低质量的软件交付。 各自为政行不通,因此 DevOps 的目标是将开发和运维团队合并为一个团队,并遵循敏捷开发原则,以便更快地交付更高质量的软件。 2.1.1. DevOps 的要求 改变文化:重视开放、信任和透明度 新的应用设计:无需为增加一项功能而重新部署整个系统 利用自动化:加快并提高应用交付的一致性 可编程平台:持续部署 2.1.2. 这些不是 DevOps! DevOps 不单单是简单地将开发和运营结合起来 DevOps 不是一种工具或技术 DevOps 不是一个独立的团队 DevOps 不是一刀切的解决方案 DevOps 不只是自动化 2.1.3. 工具 版本控制 管理和跟踪更改 实现团队协作 维护记录 Git 实现了无缝代码管理 GitLab 和 GitHub 是两个流行的 Git 托管服务 CI/CD 自动构建、测试和部署流程 整合变更并自动发布 CI 工具:构建和测试 Concourse CI Circle CI Travis CI CD 工具:自动化应用发布 Spinnaker Go CD Argo CD CI / CD 工具 Jenkins Tekton GitLab CI/CD GitHub Actions 配置管理 自动配置软件和基础设施,确保一致性并简化环境管理 Ansible Chef Puppet SaltStack 容器化和容器协调 可在容器中打包和部署应用程序,从而实现轻松扩展和高效资源管理 Docker Kubernetes 监控和日志记录 监视应用程序性能、健康状况并分析日志 Prometheus Grafana ELK Stack Mesmo New Relic 合作和交流 促进团队成员之间有效的团队合作、知识共享和协调 Slack Microsoft Teams Atlassian Jira Trello Asana 2.2. CI/CD 持续集成(CI)确保变更通过必要的测试;将测试变更集成到主分支中。 持续交付(CD)确保快速安全的部署;将变更交付到类似生产的环境中。 CI / CD 不是一个过程,而是两个不同的过程:CI 不断将代码整合回 main/master/trunk 分支,CD 将整合后的代码部署到生产环境。 CI 包括计划、编码、构建和测试阶段 CD 包括发布、部署和运行阶段 持续部署(Continuous Deployment)是生产自动化,专门用于实际持续推送到生产环境的情况。 它与持续交付不同,后者是将代码部署到类似生产的环境中,比如开发服务器,但不会自动部署到生产环境。 2.2.1. CI 的优势 更快的代码更改反应时间 更改代码 测试代码 构建变更 快速向客户交付解决方案 降低代码集成风险 集成小的内容 无需在代码库中集成大量代码 只需集成 10-50 行代码 更高的代码质量 PR(Pull Request):开发人员将代码更改推送到代码库中的分支,然后请求其他开发人员审查和合并代码更改 当有人提出 PR 时,就可以对代码进行审查 版本控制中的代码工作 2.3. TDD 测试驱动开发(TDD)是一种软件开发方法,它要求在编写代码之前编写测试。 编写失败的测试用例 编写足够的代码使测试通过 重构代码 重复 TDD 意味着测试用例驱动代码的设计和开发,使开发人员能够更快地编写代码。 2.4. BDD 行为驱动开发(BDD)是一种软件开发方法,它关注从外部看到的系统行为,而非系统内部的工作方式。 在软件测试中执行 BDD 的适当级别是集中测试、系统测试和验收测试。 3. Agile 敏捷开发是一种迭代的项目管理方法,可帮助团队快速响应并向客户交付价值。 Agile 强调了: 适应性规划 渐进式发展 早期交付 持续改进 敏捷开发宣言: 个人和互动高于流程和工具 可以工作的软件高于详尽的文档 客户合作高于合同谈判 响应变化高于遵循计划 符合敏捷开发宣言的迭代式软件开发方法强调灵活性、互动性和高透明度,使用小型、同地办公、跨职能、自组织的团队。 主要启示:按需建设,而非按计划建设。 3.1. Scrum Agile 和 Scrum 不是同一个东西! 两者的区别为: Agile 是一种工作哲学(非规范性的) Scrum 是一种工作方法(增加流程) Scrum 是一种渐进式产品开发的管理框架。 规定了小型、跨职能、自我组织的团队 提供角色、会议、规则和工件的结构 使用固定长度的迭代,称为 Sprint 目标是在每次迭代中构建一个可交付的产品增量 3.1.1. Sprint Sprint 是设计、代码、测试和部署周期中的一次迭代。 每个 Sprint 都应有一个目标,且通常持续两周。 3.1.2. Scrum 流程 产品积压(Product Backlog):产品所有者(Product Owner)创建产品积压,其中包含想在产品中实现且未在 Sprint 中实施的所有功能 按重要性和业务价值排序,最上方的功能最详细 积压工作细化(Backlog Refinement):团队和产品所有者一起讨论产品积压中的功能,以便团队了解功能的细节 Sprint 计划:定义在 Sprint 阶段可以交付的工作以及如何完成这些工作 产品所有者、Scrum 主管(Scrum Master)和开发团队参与 目标在于定义每个 Sprint 的明确业务目标 Sprint 积压(Sprint Backlog):要比产品积压小得多,仅包含团队在下个 Sprint 中(未来两周内)要完成的功能 每日 Scrum:每个人都应回答三个问题 昨天做了什么? 今天要做什么? 遇到了什么问题? 有价值的产品 3.1.3. 团队 适当的组织是成功的关键,这往往意味着现有团队可能需要重组。 团队应当是松散耦合、又紧密配合的。这代表着每个团队都有着自己的任务,同时还要与业务保持一致。 Scrum 强调自主权,这同时是一种鼓励团队成员自我管理的方法,也是速度和质量的保证。 决策在团队中就地做出,同时还让团队成员为自己构建的产品承担完全的责任。 自主权最大限度地减少了交接和等待,让团队成员能够快速做出决策并采取行动。 3.2. Agile 不是…… Agile 不仅仅只是迭代。在每次迭代的最后,团队都必须在部署应用程序后从客户那里收到反馈。 Agile 不是瀑布式软件开发生命周期的新版本。每次 Sprint 阶段都不应当是采取传统的开发方法,并且不能只有开发人员参与,它涉及一个跨职能的团队。 3.3. 鸡汤 不要在最不了解的时候决定一切,应当根据所了解的情况制定计划。 随着了解的增多而调整计划,你的估计会更加准确。 如何失败:不对员工进行培训就安排他们担任新职务。 3.3.1. 职务之间的区别 产品经理(Product Manager):管理预算的业务人员,主要负责业务运营方面。 产品所有者(Product Owner):项目中有远见的人,带领团队进行一系列旨在实现 Sprint 目标的实验。 项目经理(Project Manager):让每个人都按照固定计划行事的任务负责人。 Scrum 主管(Scrum Master):让团队专注于当前 Sprint 的教练,帮助团队解决问题、消除障碍并保持团队的高效率。 开发团队(Development Team):仅由开发人员组成的团队。 Scrum 团队(Scrum Team):跨职能团队,包括开发人员、测试人员、安全人员、业务分析师、运营人员等。 3.4. 用户故事 用户故事(User Story)是一种简短的、简单的描述,代表团队在一次迭代中可以交付的一小部分业务价值。 用户故事往往包含了以下几个部分: 为谁而设? 他们需要什么? 为什么他们需要? 他们从中获得的商业价值是什么? 用户故事的内容应是需求和业务价值的简要说明,包含了所有的假设和细节,即「完成」的定义。 用户故事记录了一个角色为实现目标而提出的功能要求,即:作为一个【角色】我需要【功能】以便【目标】。 3.4.1. 验收标准 记录「完成」的定义至关重要,要让利益相关者、客户和开发人员都能轻松描述「完成」的定义。 也就是:给定【条件】,当【事件】发生时,我希望【结果】。 3.4.2. INVEST 原则 独立性(Independent):用户故事应该是独立的,这样就可以按照任何顺序进行开发 可协商性(Negotiable):用户故事应该是可协商的,这样就可以在开发过程中进行更改 有价值(Valuable):用户故事应该是有价值的,这样就可以在每次迭代中交付价值 可估算(Estimable):用户故事应该是可估算的,这样就可以在每次迭代中估算工作量 小型(Small):用户故事应该是小型的,这样就可以在每次迭代中交付 可测试(Testable):用户故事应该是可测试的,这样就可以在每次迭代中测试 3.4.3. Epic Epic 是一种大型用户故事,它可以进一步分解为较小的用户故事。 当一个用户故事的范围过大时,就会被称为 Epic。当优先级较低、定义较少时,积压项目往往会以 Epic 的形式存在。 3.4.4. 故事点 故事点(Story Points)是一种估算工作量、估算特定用户故事实施难度的指标。它是总体工作量的抽象度量,而非时间单位。 我们需要承认人类非常不善于估算时间,因此要使用相对估算。许多工具采取了 Fibonacci 数列,因为它们是一种指数级增长的数列。用更抽象的概念来比喻的话,就是衣服的尺码。 例如,在第一个 Sprint 中,团队估算一个用户故事的故事点为 M 尺码,这可能会花费两天时间,也可能会花费一周时间。在第二个 Sprint 中,团队对故事点的估算有了更精准的认知,因此在估算另一个用户故事时,会根据花费在第一个用户故事上的时间,以及第二个用户故事的复杂程度,来估算第二个用户故事的故事点。 切记,绝对不要将故事点与时间混淆。 4. NoSQL NoSQL 这个概念在一次关于分布式数据库的开源活动中被提出。全称是 Not Only SQL,意思是「不仅仅是 SQL」。 NoSQL 是一种非关系型数据库,它不使用 SQL 作为查询语言,而是使用其他语言。没有了标准的行和列,NoSQL 数据库可以存储各种类型的数据,且在存储和查询数据的方式上更加灵活。 NoSQL 设计用于大型数据集的高性能和灵活性,它们通常用于 Web 应用程序和大数据应用程序。 4.1. MongoDB MongoDB 是一种开源的文档和 NoSQL 数据库,它使用类似于 JSON 对象和 Python 字典的文档来存储数据。 { "firstName": "John", "lastName": "Doe", "email": "john.doe@email.com", "studentId": 20217484} 集合(Collection)是一组文档,类似于关系数据库中的表。比方说,你可以创建一个名为 students 的集合,其中包含所有学生的文档。 数据库(Database)是一组集合,类似于关系数据库中的数据库。比方说,你可以创建一个名为 school 的数据库,其中包含所有学生的集合。 4.1.1. MongoDB 的优势 在读取和写入数据时建立数据模型,而非传统的先创建模式、再创建表格 让用户能够引入任何结构化或非结构化数据 通过保存多份数据副本,提高数据的可用性 MongoDB 适合用于大型和非结构化、复杂、灵活、高度可扩展的应用程序。 在传统的关系数据库中,你需要先设计,再写代码。例如: CREATE TABLE Students (FirstName varchar(255), LastName varchar(255), Email varchar(255), StudentId int);INSERT INTO StudentsVALUES ("John", "Doe", "john.doe@email.com", 20217484); 而在 MongoDB 中,你可以直接写代码,无需设计复杂的表格定义。例如: db.persons.insertOne({"firstName": "John", "lastName": "Doe", "email": "john.doe@email.com", "studentId": 20217484}) 4.1.2. 案例 假设你正经营一家快递公司,因此需要存储送货地址。随着 2020 年送货流程的巨大变化,你需要存储更多的数据,比如该客户是否选用了无接触送货。 使用 MongoDB 可以轻松存储这些数据;如果使用的是关系数据库,你可能需要重新设计表格,以便存储新的数据。 4.1.3. 查询和分析 MongoDB 提供了一种查询语言,称为 MongoDB 查询语言(MQL)。MQL 拥有多种操作符,可用于查询和分析数据。 如果查询还要更复杂,你可以使用 MongoDB 聚合管道(Aggregation Pipeline)。 以下是一些实践练习: 启动 MongoDB Shell mongo 连接到 MongoDB 服务器 mongosh -u root -p xxx --authenticationDatabase admin local 列出所有数据库 show dbs 创建一个名为 school 的数据库 use school 创建一个名为 students 的集合 db.createCollection("students") 列出所有集合 show collections 向 students 集合中插入一条文档 db.students.insertOne({"firstName": "John", "lastName": "Doe"}) 数插入了多少条文档 db.students.count() 列出所有文档 db.students.find() 更新一条文档 db.students.updateOne({"firstName": "John"}, {$set: {"lastName": "Smith"}}) 删除一条文档 db.students.deleteOne({"firstName": "John"}) 从服务器断开连接 exit 4.1.4. Mongo Shell Mongo Shell 是 MongoDB 的命令行接口,它允许你与 MongoDB 服务器进行交互。 Mongo Shell 也是一种 JavaScript 解释器,它允许你在 MongoDB 服务器上运行 JavaScript 代码。 连接到集群: mongosh "mongodb://USER:PASSWORD@url/test"

2024/1/11
articleCard.readMore

IBM 全栈开发【2】:网页三剑客

近期在学习 IBM 全栈应用开发微学士课程,故此记录学习笔记。 1. 云编程入门 SASS:语法强大的样式表(Syntactically Awesome Style Sheets)。 CSS 的扩展 可以使用变量、嵌套规则、内联导入等方法来保持条理清晰 更快、更轻松地创建样式表 LESS:动态样式语言(Leaner Style Sheets)。 增强 CSS 向后兼容 CSS Less.js 是一种 JavaScript 工具,可将 LESS 样式转换为 CSS 样式 1.1. JavaScript 框架 1.1.1. Angular 开源 由 Google 维护 让网站快速高效地呈现 HTML 页面 内置路由器、表单验证工具 1.1.2. React 由 Facebook 开发和维护 用于构建和渲染网页组件的 JavaScript 库 1.1.3. Vue 由社区维护,其主要重要是视图层,包括用户界面、按钮和可视化组件 灵活、可扩展,能与其他框架很好地集成 适应性强 1.2. 代码库 jQuery:简化 DOM 操作的 JavaScript 库 Email-validator:验证电子邮件地址的构造是否正确有效 Apache Commons Proper:可重复使用的 Java 组件 1.3. CI/CD CI 指的是「持续集成」,CD 指的是「持续交付」或「持续部署」。 CI / CD 是开发团队的最佳实践,可帮助团队更快地交付高质量的软件。 通过构建自动化服务器实施,CI 确保所有代码组件顺利协同工作,而 CD 则确保代码在构建后自动部署到生产环境。 1.4. 构建工具 构建工具是一种将源代码转换为二进制文件以便安装或部署的工具。 它在有许多相互连接的项目的环境中非常有用,因为它可以自动化构建过程,从而减少了手动操作的数量。 构建自动化实用程序:通过编译和链接源代码来生成可执行文件 构建自动化服务器:按计划或触发式执行构建自动化实用程序 Webpack:模块打包器,将应用程序的所有模块打包到一起 Babel:JavaScript 编译器,将 ES6 + 代码转换为向后兼容的 JavaScript 代码 Web Assembly:一种可移植、体积小、加载快的二进制格式,可在 Web 上运行 1.4.1. 云应用程序软件包管理器 npm:Node.JS 的包管理器 Maven:Java 的包管理器 Gradle:Java 的构建自动化工具 RubyGems:Ruby 的包管理器 Pip:Python 的包管理器 Conda:Python 的包管理器 1.5. API、路由和端点 API(应用程序编程接口)是一组定义了应用程序如何与其他软件组件进行交互的规则。它处理数据的请求和响应。 路由是一种将 URL 映射到应用程序中的特定端点的方法。它是通往网站的路径。 端点可以是 API 也可以是路由。它是应用程序中的特定位置,用于处理特定的请求。 1.6. ORM ORM(对象关系映射)是一种将对象模型映射到关系模型的技术。它可用于连接数据库和检索正确的数据,并可隐藏查询数据库的部分复杂性。 2. HTML 概述 2.1. HTML5 规范 定义了 HTML5,可以用 HTML 或者 XML 编写 定义了一种可与早期 HTML 实现互操作的模式 2.2. HTML5 的新特性 改进了语义元素 包括了 API,如网络存储、地理定位、拖放和多媒体 对网页进行了分类 开发了跨浏览器和跨平台的应用程序 创造了引人入胜的用户体验 关键主题: 语法与 HTML4 和 XHTML1 文档兼容 将用户代理和作者的一致性要求分开 用户代理:支持早期用户代理规范的元素和属性 作者:删除了一些元素和属性以简化语言 2.3. 元素 元素是 HTML 文档的构建块,由开始标记、结束标记和内容组成。 HTML5 新增了许多元素,如: <section>:定义文档中的节 <article>:定义独立的内容 <aside>:定义页面内容之外的内容 等等等等 2.4. DOM DOM(文档对象模型)是一种将 HTML 文档表示为树结构的方法。它定义了访问和操作 HTML 文档的标准。 属性描述 head返回文档的 <head> 元素 title设置或返回文档的 <title> 元素 images返回文档中的所有 <img> 元素 lastModified返回文档最后修改的日期和时间 scripts返回文档中的所有 <script> 元素 getElementById()返回第一个具有指定 ID 的元素 getElementByTagName()返回具有指定标签名称的所有元素的集合 open()打开一个流,以收集来自任何 write() 或 writeln() 方法的输出 write()向文档写入 HTML 表达式或 JavaScript 代码 close()关闭由 open() 方法打开的输出流 例子: <script type="text/javascript"> function textChecker() { const textField1 = document.getElementById('textField1'); if (textField1.value === '') { alert('Please type in the field'); } else { alert('You entered: ' + textField1.value); } }</script><body><input type='text' id='textField1'><input type='button' onClick='textChecker()' value='Submit'></body> 2.4.1. DOM 树访问器 每个 HTML 和 XML 文档都由一个文档对象来表示,DOM 树访问器可用于访问和操作该文档对象。 2.5. XML XML(可扩展标记语言)是一种用于定义其他标记语言的元语言。它是一种可扩展的标记语言,可用于定义其他标记语言。 例子: <?xml version="1.0" encoding="UTF-8"?><html xmlns="https://www.w3.org/1999/xhtml"> <head> <meta http-equiv="Content-Type" content="application/xhtml+xml; charset=ISO-8859-1" /> <title>Example document</title> </head> <body> <p> Example paragraph. </p> </body></html> 内容类型必须指定为 XML 媒体类型。 那 HTML 和 XHTML 哪个更好呢? XHTML 中标签必须小写、代码必须格式正确;HTML 中标签可以大写,代码格式不重要、允许使用不匹配的引号、非结束元素和非包含元素。 2.6. 脚本 脚本是一种用于控制应用程序行为的编程语言。它可以嵌入到 HTML 中,也可以作为外部文件引用。 脚本提供了交互式用户体验,用于各种用途,如表单验证、动画、游戏、动态更新等。 开发者可以使用 <script> 元素将脚本嵌入到 HTML 中。 2.7. iframe iframe 是一种 HTML 元素,用于在网页中嵌入另一个网页。它是一种内联框架,可用于在网页中显示其他网页。 iframe 可以用于显示广告、嵌入视频、显示其他网站的内容等。 例子: <iframe src=""></iframe> 为了防止授予隐式权限,iframe 可以使用 sandbox 属性。 <iframe src="" sandbox></iframe> 2.8. 浏览器 并非所有浏览器都完全支持 HTML5 和 CSS3。比方说 <input type="date">,在不同浏览器中的显示效果是不一样的。 可以使用 JavaScript 来检测浏览器是否支持某些功能。 例子: <body> <form id='thisform'></form> <script> const datepicker = document.createElement('input'); const formelement = document.getElementById('thisform'); datepicker.setAttribute('type', 'date'); formelement.appendChild(datepicker); if (datepicker.type === 'text') { alert('Date input not supported'); } </script></body> 3. CSS 概述 & HTML5 元素 3.1. HTML5 结构元素 <article>:代表独立的内容,如博客文章、报纸文章、杂志文章或论坛帖子;和 <time pubdate ...> 属性一起使用可以表示发布日期 <section>:代表文档中的节或主题,如章节、页眉、页脚或文档中的其他部分;它可以包含标题 <header>:代表文档或节的标题;它可以包含标题 <footer>:代表文档或节的页脚;它可以包含页脚 3.1.1. 内容分组 <div>:提供了灵活的控制,可将样式应用于文档的各个部分;除了对内容进行分组之外,它没有任何特定的含义 <aside>:代表页面内容之外的内容,如侧栏、广告、导航链接、引用或其他类似的内容 <nav>:代表导航链接的部分,如菜单、目录或索引 <figure>:代表独立的内容,如图表、图像、照片、代码清单、音频或视频 <figcaption>:代表 <figure> 元素的标题 3.2. CSS CSS 设计了 HTML 文档的外观和样式。 在 CSS 中,子元素通常继承了父元素的样式。 CSS 可以作为 HTML 标签、文档标题、文档头部、文档内部或外部的样式表来实现。 数据与设计分离,这意味着可以在不更改 HTML 的情况下更改样式。 使用 HTML 向浏览器发送数据,并使用 CSS 对数据进行设计,这允许在不进行设计的情况下根据特殊的无障碍需求呈现网页。 将 CSS 应用于 HTML 文档有三个方法,它们的顺序决定了优先级: 内联样式:将样式应用于单个元素 内部样式表:将样式应用于整个文档 外部样式表:将样式应用于多个文档 3.2.1. 布局 流式布局(Fluid Layout):使用百分比来定义宽度,使网页能够适应不同的屏幕尺寸 元素的尺寸根据浏览器窗口的大小而变化 优点:适应性强 缺点:设计困难 固定布局(Fixed Layout):使用像素来定义宽度,使网页在不同的屏幕尺寸下保持固定的宽度 元素的尺寸不会随着浏览器窗口的大小而变化 优点:设计简单 缺点:不适应不同的屏幕尺寸 3.2.2. CSS 框架 CSS 框架是一种用于设计网页的 CSS 库。它们提供了一组可重复使用的 CSS 类,可用于设计网页。 不使用任何框架的情况下,开发者需要编写大量的 CSS 代码来设计网页。优点是可以完全控制网页的设计,缺点是需要花费大量的时间和精力 例如: a { color: red; text-decoration: underline;}a:hover { color: rgb(185, 28, 28);} 实用优先的框架:Tailwind CSS,它提供了可以使用的 CSS 属性。优点是给予了开发者更多的控制权 例如: <a href="..." class="underline text-red-500 hover:text-red-700">Dangerous Link</a> 组件框架:Bootstrap,它提供了一系列可重复使用的预定义组件。优点是可以快速设计网页,缺点是开发者无法完全控制网页的设计 例如: <a href="..." class="link-danger">Dangerous Link</a> 4. 网络应用程序的 JavaScript 编程 4.1. JavaScript 的性质 JavaScript 是一种脚本语言,用于控制应用程序的行为。它是一种解释性语言,不需要编译。 源自 ECMAScript 标准 最初设计用于在 NetScape Navigator 中使用 与 Java 无关! JavaScript 解释器嵌入在浏览器中,为静态网页内容添加动态功能 核心功能为异步 JavaScript 和 XML(AJAX) 4.2. 变量作用域 变量作用域是指变量在程序中可见的区域。在 JavaScript 中,变量可以是全局的或局部的。 全局变量:在函数外部声明的变量 局部变量:在函数内部声明的变量 不使用 var 关键字声明的变量是全局变量,即使它们在函数内部声明。其值为 undefined。 4.3. 函数 例子: function add(n, m) { return n + m;}var x = add(1,2); // returns 3x = add(1.23, 3.45); // returns 4.68x = add("hello", "world"); // returns "helloworld" function Car(make, model, year) { this.make = make; this.model = model; this.year = year; this.getName = function () { return this.make + ' ' + this.model + ' ' + this.year; }}var c = new Car ("Meridian", "Saber GT", 2012);alert(c.getName()); // displays "Meridian Saber GT 2012" 4.4. API 常用的 API: 文档元素Window document.getElementById()element.innerHTMLwindow.open document.getElementByTagName()element.stylewindow.onload document.createElement()element.setAttributewindow.scrollTo parentNode.appendChild()element.getAttribute 例子: // how to retrieve all the image elements from a web pagevar imgSet = document.getElementsByTagName("img");var output = "";for (var i = 0; i < imgSet.length; i++) { output += "<p>Source for image "; output += i; output += ": "; output += imgSet[i].src; output += "<\/p>";}document.write(output); <!-- How to add a node to a document --><head> <script> function addPara() { const newPara = document.createElement("p"); const newText = document.createTextNode("Hello World!"); newPara.appendChild(newText); document.body.appendChild(newPara); } </script></head><body onload="addPara()"></body> <script> onload = (function () { addPara(); })(); function addPara() { const newPara = document.createElement("p"); newPara.innerHTML = "Hello world!"; document.body.appendChild(newPara); }</script> 4.5. HTML 中的脚本 脚本为作者提供了一种以高度交互方式扩展 HTML 文档的方法,例如: 在加载 HTML 文档后运行 验证表单并处理输入的内容 由影响文档的事件触发 在 HTML 页面中动态创建内容 4.5.1. 脚本不可用时 如果浏览器不支持脚本或者脚本被禁用,可以使用 <noscript> 元素来提供替代内容。 <noscript> <p>JavaScript is not enabled!</p></noscript> 4.6. DOM 万维网联盟(W3C)定义了 DOM(文档对象模型)标准,用于访问和操作 HTML 文档。DOM 规范有四级: DOM Level 0:最初的 DOM 规范,定义了 HTML 和 JavaScript 之间的接口 DOM Level 1:定义了 HTML 和 XML 文档的核心接口 DOM Level 2:包括了样式表模型和用于操作附加到文档中的样式信息的接口 DOM Level 3:指定了内容模型和文件验证 DOM Level 4:添加了突变观察器,以替代突变事件 4.6.1. 浏览器的基本 DOM 模型 window 对象:代表浏览器窗口 位于对象层级的顶端 浏览器加载页面时自动创建 可通过 JavaScript 代码访问属性和函数 在客户端 JavaScript 中,window 对象是全局对象 history 对象:允许模拟点击浏览器的前进和后退按钮 location 对象:代表当前加载的文档的 URL navigator 对象:代表浏览器的信息 screen 对象:代表用户的屏幕信息 document 对象:代表当前加载的文档 4.6.2. DOM 第二级 DOM 第二级添加了回车、制表符和空格。 节点类型整数值节点名称节点值描述 元素1Tag namenull任意 HTML 标签 属性2Attribute nameAttribute value键值对 文本3#textText content元素包含的文本 注释8#commentText commentHTML 注释 文档9#documentnull文档对象 文档类型10DOCTYPEnullDTD 规范 Fragment11#document fragmentnull文档外的节点 DOM 二级节点对象属性和相应的数据类型: 对象属性数据类型描述 nodeName字符串参考上表 nodeValue字符串参考上表 nodeType整数整数常数;参考上表 parentNode对象最近的包含对象 childNodes数组所有的子节点 firstChild对象第一个子节点 lastChild对象最后一个子节点 AttributesNodeMap属性的数组

2024/1/10
articleCard.readMore

IBM 全栈开发【1】:云计算

近期在学习 IBM 全栈应用开发微学士课程,故此记录学习笔记。 1. 云计算 云计算是一种能够方便地按需通过网络访问可配置计算资源的共享池的模型,这些资源可以快速地提供和释放,而且可以以服务的方式提供。 按需自助服务:除了故障排除和安全漏洞修补之外,用户可以全天候自助访问计算资源。例如:24 小时 ATM 机 广泛的网络接入:云计算资源、公共云服务、多设备访问、私有云上的内联网 资源池:节约成本、计算资源服务于多个用户、云资源可动态分配给不同的用户、不受地理位置限制 快速弹性:根据需求增减计算资源 计量服务:按使用量付费、实用计量 1.1. 云计算的发展 虚拟机:同一物理硬件上存在多个不同的计算环境 虚拟化:通信和计算革命的巨大催化剂、共享主机环境、虚拟专用服务器 虚拟机管理程序(Hypervisor):小型软件层,可使多个操作系统同时运行、共享相同的物理计算资源 1.2. 云服务提供商 阿里巴巴云:提供全面的全球云计算服务、一系列产品和服务 AWS:以按表计量、即用即付地方式向个人、公司和政府组织提供云计算平台 谷歌云计算:提供基础设施、平台和无服务器计算环境 谷歌应用引擎是一个用于在谷歌管理的数据中心开发和托管 Web 应用程序的平台 IBM 云:跨越公共、私有和混合环境的全栈云平台 微软 Azure:灵活的云平台,可通过微软管理的数据中心构建、测试、部署和管理应用程序 Oracle 云:主要以 SaaS 和数据库为重点的云计算服务提供商 Salesforce:专注于客户关系管理(CRM)的云计算公司 SAP:以在云中运行企业应用程序、客户关系管理和供应链管理为重点的云计算公司 1.3. 云技术应用 利用云技术实现以下目标的案例研究: 更好的客户服务 消除创新障碍 实现企业规模 加速增长 1.4. IoT 物联网(IoT)是指通过互联网连接的物理设备和传感器的网络,这些设备和传感器可以收集和交换数据。 物联网改变了我们日常生活的许多方式,例如: 智能家居 智能驾驶 通过物联网设备收集的数据在云端存储和处理,因为物联网设备可能处于移动状态,云端可作为最接近的收集点,最大限度地减少延迟。 1.5. AI 云端提供可扩展地按需资源 AI 对来自物联网设备的数据采取行动 基于 AI 响应的物联网行为 简单来说:物联网传递数据,AI 为数据提供洞察力,而这两种技术都利用了云端的可扩展性和处理能力。 1.6. 区块链 区块链是一种安全的分布式开放技术,有助于加快流程、降低成本、增强安全性和可靠性,并在交易应用中建立透明度和可追溯性。 区块链提供可信的、去中心化的记录 云端提供全球分布式、可扩展和具有成本效益的计算资源 AI 为从收集到的数据中进行分析和决策提供支持 2. 云计算模型 2.1. 服务模式 基础设施即服务(IaaS):云提供商管理物理资源、数据中心、网络和虚拟化层,用户负责管理操作系统、中间件、应用程序和数据 软件即服务(SaaS):云提供商托管并管理应用程序、数据、基础设施和平台资源 平台即服务(PaaS):云提供商管理平台基础设施,包括操作系统、开发工具、数据库、业务分析和计算资源;PaaS 的用户通常是开发人员 2.1.1. IaaS 客户可根据自己的选择在云提供商提供的地区、可用性区域和区域中创建或配置虚拟机 云提供商通常为客户提供跟踪和监控云服务性能和使用情况以及管理灾难恢复的工具 云基础设施: 物理数据中心 计算 网络 存储 对象存储 使用案例: 使团队能够更快地建立测试和开发环境 帮助开发人员更专注于业务逻辑,而不是基础设施 业务连续性和灾难恢复需要大量的技术和人员投资,而 IaaS 可以帮助企业降低成本 更快地部署网络应用程序 随着需求的波动上下扩展基础设施 解决涉及数百万个变量和计算的复杂问题 挖掘海量数据集,找出有价值的趋势和模式 提供所需的高性能计算并使其经济实惠 关注问题: 在配置和管理上缺乏透明度 工作负载的可用性和性能依赖于第三方

2024/1/10
articleCard.readMore

JS 逆向网易云音乐

该文章记录了我如何逆向网易云 并制作一个简易的播放器(鸽了)。 逆向网易云 用浏览器打开网易云的网站,随意挑选一首歌曲进行播放,同时检查网络选项卡中的响应。我们能看到媒体类型中有一个 .m4a 文件。直接用 Python 的 requests 爬取下来进行播放,够厚道,爬下来就能听。 当然在页面源代码里找这个 .m4a 文件是找不到的,不然也不会说要 逆向 网易云了。我们该如何找到这个 .m4a 文件的 URL 呢?继续看看网络选项卡里的请求: 赫然出现这么一个请求,返回的 JSON 数据中有 .m4a 的链接。嗯,就是你了!看看负载: csrf_token 为空,重点是表单数据的 params 和 encSecKey。其中的内容都是一大串反正不是明文的东西,那么逆向的目标就是找到这两个参数的生成方式。 原本想要直接在源代码里一个个找过去这两个参数,后来突然想到客户端反正都会朝 https://music.163.com/weapi/song/enhance/player/url/v1?csrf_token= 发送请求,那么直接在 XHR / 提取断点里选择所有包含着 v1?csrf_token= 的 URL 不就行了。 关注 arguments 的构造此时成为了我们的目标。 用调用堆栈陆续向上查看,发现 params 和 encSecKey 依旧是加密过后的。哎,加把劲吧,继续向上查看。 一直到这一步…… 观察其前面的代码能发现一个非常有意思的事情: 变量 bKC1x 被赋值 e1x.data 被赋值时,bKC1x 被用到了,参数中还赫然写着 params 和 encSecKey 根据我们之前查看的调用堆栈,chF5K(X2x, e1x) 的第二个参数必定是 params 和 encSecKey 的组合 这说明什么?说明 bKC1x 就是生成 params 和 encSecKey 的关键!我们仔细分析一下它的构造: var bKC1x = window.asrsea(JSON.stringify(i1x), bvh3x(["流泪", "强"]), bvh3x(Rf5k.md), bvh3x(["爱心", "女孩", "惊恐", "大笑"])); 其中第三个参数所使用的 Rf5k.md 的内容如下: 很想吐槽…… 这些字符串的意义是什么??? bKC1x 本身会生成一个字典,其中包含 encText 和 encSecKey 两个键。而屡次出现的 bvh3x() 函数的构造如下: var bvh3x = function(chL5Q) { var m1x = []; j1x.bg2x(chL5Q, function(chK5P) { m1x.push(Rf5k.emj[chK5P]) }); return m1x.join("")}; 它的运作差不多是这样子的。 Rf5k.emj 是一个字典,构造非常激寒: …… 谁来告诉我这些键到底都是什么意思??? 也就是说 bvh3x() 函数接收参数,按照每个元素的字符串、对比 Rf5k.emj 字典中的键来返回对应的值,最后拼接成一个列表。 知道了 bKC1x 中后三个参数的生成方式,我们便只需要知道第一个参数 —— JSON.stringify(i1x) —— 是什么来头的了: 手到擒来,这个 id 不就是歌曲 id 么?! 参数已经全部知晓,最后要看网易云如何加密它们。window.asrsea() 函数作为加密这些参数的老大哥,它的构造如下: function d(d, e, f, g) { var h = {} , i = a(16); return h.encText = b(d, g), h.encText = b(h.encText, i), h.encSecKey = c(i, e, f), h} d() 函数需要好好说道说道。接收的四个参数我们已述说过,这里不废话了。d() 先创建了一个空字典 h,接着创建了一个 i。要知道 i 的值,我们需要知道 a() 的内容: function a(a) { var d, e, b = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", c = ""; for (d = 0; a > d; d += 1) e = Math.random() * b.length, e = Math.floor(e), c += b.charAt(e); return c} 变量 d、e、b 都是字符串 for 循环中 d 变成整数 0,每次循环加 1,直到 a 的值,也就是上面 d() 函数中传入的整数 16 e 在每次循环都是一个随机数,有着 b 的长度,并且会被 Math.floor() 函数取整 c 是一个空字符串,每次循环都会被 b 的第 e 个字符所填充 用人话来说便是:a() 函数会 生成一个长度为 16 的随机字符串。 回到 d() 函数,h.encText 的值是 b(d, g),而 b() 函数的构造如下: function b(a, b) { var c = CryptoJS.enc.Utf8.parse(b) , d = CryptoJS.enc.Utf8.parse("0102030405060708") , e = CryptoJS.enc.Utf8.parse(a) , f = CryptoJS.AES.encrypt(e, c, { iv: d, mode: CryptoJS.mode.CBC }); return f.toString()} CryptoJS 是一个 JavaScript 的加密算法库,这里不做过多介绍,后面会讲到如何引用。 该函数先是声明了变量 c、d 和 e 这三个由不同的值被 UTF-8 编码后的字符串。接着声明了变量 f —— 一个由 AES.CBC 模式加密的 e —— 其密钥为 c、偏移量为 d。返回的 f 的字符串形式最终变成了我们的 encText。 值得注意的是,在 d() 函数里,h.encText又 被塞进 b() 函数里加密了一次。 而 h.encSecKey 由 c() 函数生成,它的构造如下: function c(a, b, c) { var d, e; return setMaxDigits(131), d = new RSAKeyPair(b,"",c), e = encryptedString(d, a)} 在 c() 函数里,先是调用了 setMaxDigits() 函数;这个函数接收一个需要加密的字符串 a,一个公钥 b,以及生成 RSA 密钥对所需的另一个参数 c。它先是使用 setMaxDigits() 函数设置了 BigInt 可操作的最大位数为 131。然后使用提供的公钥 b 和参数 c 生成 RSA 密钥对 d。最后,它使用生成的密钥对加密了字符串 a。 问题又双叒叕来了,什么是 setMaxDigits()?什么是 BigInt?莫急,再再再来看一眼网易云的源代码: setMaxDigits() 和以上提到的函数们有一个天大的区别:它是全局函数,无论在哪里都能被调用。而这种全局函数的定义在网易云的源代码中只有一处:第 89 行。我们可以直接把这第 89 行的内容全部复制粘贴下来使用。 展开 function RSAKeyPair(a, b, c) { this.e = biFromHex(a), this.d = biFromHex(b), this.m = biFromHex(c), this.chunkSize = 2 * biHighIndex(this.m), this.radix = 16, this.barrett = new BarrettMu(this.m)}function twoDigit(a) { return (10 > a ? "0" : "") + String(a)}function encryptedString(a, b) { for (var f, g, h, i, j, k, l, c = new Array, d = b.length, e = 0; d > e; ) c[e] = b.charCodeAt(e), e++; for (; 0 != c.length % a.chunkSize; ) c[e++] = 0; for (f = c.length, g = "", e = 0; f > e; e += a.chunkSize) { for (j = new BigInt, h = 0, i = e; i < e + a.chunkSize; ++h) j.digits[h] = c[i++], j.digits[h] += c[i++] << 8; k = a.barrett.powMod(j, a.e), l = 16 == a.radix ? biToHex(k) : biToString(k, a.radix), g += l + " " } return g.substring(0, g.length - 1)}function decryptedString(a, b) { var e, f, g, h, c = b.split(" "), d = ""; for (e = 0; e < c.length; ++e) for (h = 16 == a.radix ? biFromHex(c[e]) : biFromString(c[e], a.radix), g = a.barrett.powMod(h, a.d), f = 0; f <= biHighIndex(g); ++f) d += String.fromCharCode(255 & g.digits[f], g.digits[f] >> 8); return 0 == d.charCodeAt(d.length - 1) && (d = d.substring(0, d.length - 1)), d}function setMaxDigits(a) { maxDigits = a, ZERO_ARRAY = new Array(maxDigits); for (var b = 0; b < ZERO_ARRAY.length; b++) ZERO_ARRAY[b] = 0; bigZero = new BigInt, bigOne = new BigInt, bigOne.digits[0] = 1}function BigInt(a) { this.digits = "boolean" == typeof a && 1 == a ? null : ZERO_ARRAY.slice(0), this.isNeg = !1}function biFromDecimal(a) { for (var d, e, f, b = "-" == a.charAt(0), c = b ? 1 : 0; c < a.length && "0" == a.charAt(c); ) ++c; if (c == a.length) d = new BigInt; else { for (e = a.length - c, f = e % dpl10, 0 == f && (f = dpl10), d = biFromNumber(Number(a.substr(c, f))), c += f; c < a.length; ) d = biAdd(biMultiply(d, lr10), biFromNumber(Number(a.substr(c, dpl10)))), c += dpl10; d.isNeg = b } return d}function biCopy(a) { var b = new BigInt(!0); return b.digits = a.digits.slice(0), b.isNeg = a.isNeg, b}function biFromNumber(a) { var c, b = new BigInt; for (b.isNeg = 0 > a, a = Math.abs(a), c = 0; a > 0; ) b.digits[c++] = a & maxDigitVal, a >>= biRadixBits; return b}function reverseStr(a) { var c, b = ""; for (c = a.length - 1; c > -1; --c) b += a.charAt(c); return b}function biToString(a, b) { var d, e, c = new BigInt; for (c.digits[0] = b, d = biDivideModulo(a, c), e = hexatrigesimalToChar[d[1].digits[0]]; 1 == biCompare(d[0], bigZero); ) d = biDivideModulo(d[0], c), digit = d[1].digits[0], e += hexatrigesimalToChar[d[1].digits[0]]; return (a.isNeg ? "-" : "") + reverseStr(e)}function biToDecimal(a) { var c, d, b = new BigInt; for (b.digits[0] = 10, c = biDivideModulo(a, b), d = String(c[1].digits[0]); 1 == biCompare(c[0], bigZero); ) c = biDivideModulo(c[0], b), d += String(c[1].digits[0]); return (a.isNeg ? "-" : "") + reverseStr(d)}function digitToHex(a) { var b = 15 , c = ""; for (i = 0; 4 > i; ++i) c += hexToChar[a & b], a >>>= 4; return reverseStr(c)}function biToHex(a) { var d, b = ""; for (biHighIndex(a), d = biHighIndex(a); d > -1; --d) b += digitToHex(a.digits[d]); return b}function charToHex(a) { var h, b = 48, c = b + 9, d = 97, e = d + 25, f = 65, g = 90; return h = a >= b && c >= a ? a - b : a >= f && g >= a ? 10 + a - f : a >= d && e >= a ? 10 + a - d : 0}function hexToDigit(a) { var d, b = 0, c = Math.min(a.length, 4); for (d = 0; c > d; ++d) b <<= 4, b |= charToHex(a.charCodeAt(d)); return b}function biFromHex(a) { var d, e, b = new BigInt, c = a.length; for (d = c, e = 0; d > 0; d -= 4, ++e) b.digits[e] = hexToDigit(a.substr(Math.max(d - 4, 0), Math.min(d, 4))); return b}function biFromString(a, b) { var g, h, i, j, c = "-" == a.charAt(0), d = c ? 1 : 0, e = new BigInt, f = new BigInt; for (f.digits[0] = 1, g = a.length - 1; g >= d; g--) h = a.charCodeAt(g), i = charToHex(h), j = biMultiplyDigit(f, i), e = biAdd(e, j), f = biMultiplyDigit(f, b); return e.isNeg = c, e}function biDump(a) { return (a.isNeg ? "-" : "") + a.digits.join(" ")}function biAdd(a, b) { var c, d, e, f; if (a.isNeg != b.isNeg) b.isNeg = !b.isNeg, c = biSubtract(a, b), b.isNeg = !b.isNeg; else { for (c = new BigInt, d = 0, f = 0; f < a.digits.length; ++f) e = a.digits[f] + b.digits[f] + d, c.digits[f] = 65535 & e, d = Number(e >= biRadix); c.isNeg = a.isNeg } return c}function biSubtract(a, b) { var c, d, e, f; if (a.isNeg != b.isNeg) b.isNeg = !b.isNeg, c = biAdd(a, b), b.isNeg = !b.isNeg; else { for (c = new BigInt, e = 0, f = 0; f < a.digits.length; ++f) d = a.digits[f] - b.digits[f] + e, c.digits[f] = 65535 & d, c.digits[f] < 0 && (c.digits[f] += biRadix), e = 0 - Number(0 > d); if (-1 == e) { for (e = 0, f = 0; f < a.digits.length; ++f) d = 0 - c.digits[f] + e, c.digits[f] = 65535 & d, c.digits[f] < 0 && (c.digits[f] += biRadix), e = 0 - Number(0 > d); c.isNeg = !a.isNeg } else c.isNeg = a.isNeg } return c}function biHighIndex(a) { for (var b = a.digits.length - 1; b > 0 && 0 == a.digits[b]; ) --b; return b}function biNumBits(a) { var e, b = biHighIndex(a), c = a.digits[b], d = (b + 1) * bitsPerDigit; for (e = d; e > d - bitsPerDigit && 0 == (32768 & c); --e) c <<= 1; return e}function biMultiply(a, b) { var d, h, i, k, c = new BigInt, e = biHighIndex(a), f = biHighIndex(b); for (k = 0; f >= k; ++k) { for (d = 0, i = k, j = 0; e >= j; ++j, ++i) h = c.digits[i] + a.digits[j] * b.digits[k] + d, c.digits[i] = h & maxDigitVal, d = h >>> biRadixBits; c.digits[k + e + 1] = d } return c.isNeg = a.isNeg != b.isNeg, c}function biMultiplyDigit(a, b) { var c, d, e, f; for (result = new BigInt, c = biHighIndex(a), d = 0, f = 0; c >= f; ++f) e = result.digits[f] + a.digits[f] * b + d, result.digits[f] = e & maxDigitVal, d = e >>> biRadixBits; return result.digits[1 + c] = d, result}function arrayCopy(a, b, c, d, e) { var g, h, f = Math.min(b + e, a.length); for (g = b, h = d; f > g; ++g, ++h) c[h] = a[g]}function biShiftLeft(a, b) { var e, f, g, h, c = Math.floor(b / bitsPerDigit), d = new BigInt; for (arrayCopy(a.digits, 0, d.digits, c, d.digits.length - c), e = b % bitsPerDigit, f = bitsPerDigit - e, g = d.digits.length - 1, h = g - 1; g > 0; --g, --h) d.digits[g] = d.digits[g] << e & maxDigitVal | (d.digits[h] & highBitMasks[e]) >>> f; return d.digits[0] = d.digits[g] << e & maxDigitVal, d.isNeg = a.isNeg, d}function biShiftRight(a, b) { var e, f, g, h, c = Math.floor(b / bitsPerDigit), d = new BigInt; for (arrayCopy(a.digits, c, d.digits, 0, a.digits.length - c), e = b % bitsPerDigit, f = bitsPerDigit - e, g = 0, h = g + 1; g < d.digits.length - 1; ++g, ++h) d.digits[g] = d.digits[g] >>> e | (d.digits[h] & lowBitMasks[e]) << f; return d.digits[d.digits.length - 1] >>>= e, d.isNeg = a.isNeg, d}function biMultiplyByRadixPower(a, b) { var c = new BigInt; return arrayCopy(a.digits, 0, c.digits, b, c.digits.length - b), c}function biDivideByRadixPower(a, b) { var c = new BigInt; return arrayCopy(a.digits, b, c.digits, 0, c.digits.length - b), c}function biModuloByRadixPower(a, b) { var c = new BigInt; return arrayCopy(a.digits, 0, c.digits, 0, b), c}function biCompare(a, b) { if (a.isNeg != b.isNeg) return 1 - 2 * Number(a.isNeg); for (var c = a.digits.length - 1; c >= 0; --c) if (a.digits[c] != b.digits[c]) return a.isNeg ? 1 - 2 * Number(a.digits[c] > b.digits[c]) : 1 - 2 * Number(a.digits[c] < b.digits[c]); return 0}function biDivideModulo(a, b) { var f, g, h, i, j, k, l, m, n, o, p, q, r, s, c = biNumBits(a), d = biNumBits(b), e = b.isNeg; if (d > c) return a.isNeg ? (f = biCopy(bigOne), f.isNeg = !b.isNeg, a.isNeg = !1, b.isNeg = !1, g = biSubtract(b, a), a.isNeg = !0, b.isNeg = e) : (f = new BigInt, g = biCopy(a)), new Array(f,g); for (f = new BigInt, g = a, h = Math.ceil(d / bitsPerDigit) - 1, i = 0; b.digits[h] < biHalfRadix; ) b = biShiftLeft(b, 1), ++i, ++d, h = Math.ceil(d / bitsPerDigit) - 1; for (g = biShiftLeft(g, i), c += i, j = Math.ceil(c / bitsPerDigit) - 1, k = biMultiplyByRadixPower(b, j - h); -1 != biCompare(g, k); ) ++f.digits[j - h], g = biSubtract(g, k); for (l = j; l > h; --l) { for (m = l >= g.digits.length ? 0 : g.digits[l], n = l - 1 >= g.digits.length ? 0 : g.digits[l - 1], o = l - 2 >= g.digits.length ? 0 : g.digits[l - 2], p = h >= b.digits.length ? 0 : b.digits[h], q = h - 1 >= b.digits.length ? 0 : b.digits[h - 1], f.digits[l - h - 1] = m == p ? maxDigitVal : Math.floor((m * biRadix + n) / p), r = f.digits[l - h - 1] * (p * biRadix + q), s = m * biRadixSquared + (n * biRadix + o); r > s; ) --f.digits[l - h - 1], r = f.digits[l - h - 1] * (p * biRadix | q), s = m * biRadix * biRadix + (n * biRadix + o); k = biMultiplyByRadixPower(b, l - h - 1), g = biSubtract(g, biMultiplyDigit(k, f.digits[l - h - 1])), g.isNeg && (g = biAdd(g, k), --f.digits[l - h - 1]) } return g = biShiftRight(g, i), f.isNeg = a.isNeg != e, a.isNeg && (f = e ? biAdd(f, bigOne) : biSubtract(f, bigOne), b = biShiftRight(b, i), g = biSubtract(b, g)), 0 == g.digits[0] && 0 == biHighIndex(g) && (g.isNeg = !1), new Array(f,g)}function biDivide(a, b) { return biDivideModulo(a, b)[0]}function biModulo(a, b) { return biDivideModulo(a, b)[1]}function biMultiplyMod(a, b, c) { return biModulo(biMultiply(a, b), c)}function biPow(a, b) { for (var c = bigOne, d = a; ; ) { if (0 != (1 & b) && (c = biMultiply(c, d)), b >>= 1, 0 == b) break; d = biMultiply(d, d) } return c}function biPowMod(a, b, c) { for (var d = bigOne, e = a, f = b; ; ) { if (0 != (1 & f.digits[0]) && (d = biMultiplyMod(d, e, c)), f = biShiftRight(f, 1), 0 == f.digits[0] && 0 == biHighIndex(f)) break; e = biMultiplyMod(e, e, c) } return d}function BarrettMu(a) { this.modulus = biCopy(a), this.k = biHighIndex(this.modulus) + 1; var b = new BigInt; b.digits[2 * this.k] = 1, this.mu = biDivide(b, this.modulus), this.bkplus1 = new BigInt, this.bkplus1.digits[this.k + 1] = 1, this.modulo = BarrettMu_modulo, this.multiplyMod = BarrettMu_multiplyMod, this.powMod = BarrettMu_powMod}function BarrettMu_modulo(a) { var i, b = biDivideByRadixPower(a, this.k - 1), c = biMultiply(b, this.mu), d = biDivideByRadixPower(c, this.k + 1), e = biModuloByRadixPower(a, this.k + 1), f = biMultiply(d, this.modulus), g = biModuloByRadixPower(f, this.k + 1), h = biSubtract(e, g); for (h.isNeg && (h = biAdd(h, this.bkplus1)), i = biCompare(h, this.modulus) >= 0; i; ) h = biSubtract(h, this.modulus), i = biCompare(h, this.modulus) >= 0; return h}function BarrettMu_multiplyMod(a, b) { var c = biMultiply(a, b); return this.modulo(c)}function BarrettMu_powMod(a, b) { var d, e, c = new BigInt; for (c.digits[0] = 1, d = a, e = b; ; ) { if (0 != (1 & e.digits[0]) && (c = this.multiplyMod(c, d)), e = biShiftRight(e, 1), 0 == e.digits[0] && 0 == biHighIndex(e)) break; d = this.multiplyMod(d, d) } return c}var maxDigits, ZERO_ARRAY, bigZero, bigOne, dpl10, lr10, hexatrigesimalToChar, hexToChar, highBitMasks, lowBitMasks, biRadixBase = 2, biRadixBits = 16, bitsPerDigit = biRadixBits, biRadix = 65536, biHalfRadix = biRadix >>> 1, biRadixSquared = biRadix * biRadix, maxDigitVal = biRadix - 1, maxInteger = 9999999999999998;setMaxDigits(20),dpl10 = 15,lr10 = biFromNumber(1e15),hexatrigesimalToChar = new Array("0","1","2","3","4","5","6","7","8","9","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z"),hexToChar = new Array("0","1","2","3","4","5","6","7","8","9","a","b","c","d","e","f"),highBitMasks = new Array(0,32768,49152,57344,61440,63488,64512,65024,65280,65408,65472,65504,65520,65528,65532,65534,65535),lowBitMasks = new Array(0,1,3,7,15,31,63,127,255,511,1023,2047,4095,8191,16383,32767,65535); 至此,整个网易云逆向的思路已清晰。我们可以开始用 Python 来实现它了。 用 JS 生成数据 既然我们已经明白了网易云的加密方式,那么我们就可以先用 JS 来生成 params 和 encSecKey,然后再传到 Python 来发出请求。 我使用的是 PyCharm;你可以新建一个 .js 文件并且使用 node.exe 来运行 JS 代码。 首要的便是引入 CryptoJS,与 Python 导包一样,我们需要先下载它。在终端中输入: npm install crypto-js 之后便是引入它: var CryptoJS = require("crypto-js"); 接着复制粘贴之前说的那一大串全局函数以及 a()、b()、c()、d() 函数下去。 d() 函数所需要的变量中有三个是固定的,我们可以先定义好: key_2 = '010001';key_3 = '00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7';key_4 = '0CoJUm6Qyw8W8jud'; 而第一个参数中歌曲的 id 是动态的,我们可以先填写一个固定的 id 进去: key_1 = '{"ids": "[2094817741]", "level": "standard", "encodeType": "aac", "csrf_token": ""}' 最后打印一下结果: console.log(d(key_1, key_2, key_3, key_4)); params 和 encSecKey 就这么简单地生成出来了。不过仅是生成出来是不够的,我们还需要把它们传到 Python 里去。 用 Python 发出请求 为了让 Python 那边取到的数据即拿即用,我悄咪咪地加了一个函数: function main(key) { result = d(key, key_2, key_3, key_4); return { 'params': result['encText'], 'encSecKey': result['encSecKey'] }} 参数 key 的内容会在 Python 中进行修改。 那么要如何让 Python 调用这个函数呢?我们可以使用 PyExecJS 这个库: import execjsjs_code = open('文件.js', mode='r', encoding='utf-8').read()ctx = execjs.compile(js_code) 这样我们便可以调用 .js 文件中的 main() 函数了: song_id = input('输入歌曲id:')key = '{"ids": "[%s]", "level": "standard", "encodeType": "aac", "csrf_token": ""}' % song_idresult = ctx.call('main', key) 给网易云发送请求: form_data = { 'params': result['params'], 'encSecKey': result['encSecKey']}response = requests.post( url='https://music.163.com/weapi/song/enhance/player/url/v1?csrf_token=', headers=headers, # 自己定义 data=form_data).json()m4a_url = response['data'][0]['url'] 拿到了我们的 .m4a 链接后,该怎么做嘛…… 我想也不用多说了吧。 m4a_response = requests.get(m4a_url, headers=headers).contentif not os.path.exists('music'): os.mkdir('music')with open('music/%s.m4a' % song_id, mode='wb') as fp: fp.write(m4a_response) 网易云逆向大功告成! 顺便逆向个歌曲 id 不过分吧 在网易云中,当用户进行搜索时会出现这么一个请求:https://music.163.com/weapi/search/suggest/web?csrf_token=。和 .m4a 文件请求一样,这个请求也需要 params 和 encSecKey 两个参数。我们可以使用同样的方法来生成它们。 该请求会根据用户输入的关键词返回搜索结果: title = input('请输入歌曲名:')search_key = '{"s": "%s", "limit": "8", "csrf_token": ""}' % titleresult = ctx.call('main', search_key) 然后我们就可以拿到搜索结果了: response = requests.post( url='https://music.163.com/weapi/search/suggest/web?csrf_token=', params=result, headers=headers).json()return response['result']['songs'] # 返回歌曲列表,response['result']['songs'][数字]['id']就是我们上面自己定义的song_id,完全可以串起来用

2023/11/28
articleCard.readMore

JS 逆向有道翻译 & wxPython 制作便宜翻译器

该文章讲述了如何逆向有道翻译,并且使用 wxPython 库来制作一个便宜翻译器。 事先说明: 本文仅供学习交流,严禁用于商业用途,否则后果自负! 本文仅供学习交流,严禁用于商业用途,否则后果自负! 本文仅供学习交流,严禁用于商业用途,否则后果自负! 你要先有 Python、requests 库和一定的 JS 基础 你要先知道如何使用网页开发者工具 逆向有道翻译 我们首要知道的是有道翻译客户端是如何发送请求给服务器的,才能知道如何逆向它。 随便在有道翻译上输入一个单词,再在网络选项卡里翻找,你会发现有一个请求是这样的: 该请求发送的表单数据中赫然出现了我们输入的单词,这就是我们要找的东西了。 再来看看这个请求的响应: 嗯…… 是一大串加密过的东西,完全看不懂捏。解密的事情之后再说,我们先来想:要如何发出请求才能得到类似的响应呢? 重新来看该请求的表单数据,参数 i 顾名思义便是我们输入的单词的明文,而参数 mysticTime 一眼就能看出是一个时间戳。我们可以此时再输入一个单词,来看看表单数据的哪些参数是不变的、哪些是变化的。 除了时间戳外,还有个参数 sign 也是动态的。也就是说整个请求中,仅有 sign 和 mysticTime 是动态的。又因为 mysticTime 是一个时间戳,所以我们只需要关注、逆向 sign 就行了。 既然客户端需要发送请求给服务器,那自然就需要一个加密算法来加密 sign、便于服务器解密。我们可以在网页开发者工具中找到这个加密算法。有道翻译还是很厚道的,一搜索「sign」这个字眼就能找到客户端封装表单数据的函数: 是不是很熟悉?这个函数中的每个变量都能在「webtranslate」这个请求的表单数据里找到。 让我们来分析一下这个函数: o 是一个时间戳 sign 值由 k(o, e) 得到,o 是时间戳、e 我们暂且不知道 再来看看 k 函数,还真别说,就在 E 函数的上面: function k(e, t) { return j(`client=${d}&mysticTime=${e}&product=${u}&key=${t}`)} 需要注意一点,sign: k(o, e) 中 o 是时间戳、e 是不知名的变量;而到 k 函数中,e 是时间戳,t 才是不知名的变量。 切记不要搞混了! 能看出,被传入 k 函数中的不知名的变量实际上是 key,并且还多了两个不知名的变量 d 和 u。我们可以直接打断点,看看这些变量的值: 再走一步我们便能看到 k 函数的两个参数。e 属实是时间戳,千万不要混淆了! 停在该断点,我们可以看到 d 和 u 的值: 相当于在 k 函数中,会生成这么一个字符串:client=fanyideskweb&mysticTime=${t}&product=webfanyi&key=fsdsogkndfokasodnaso,仅有时间戳是动态,我们无脑塞时间戳就完事了。 但是!这个字符串被生成后,还会被传入 j 函数进行加密,所以我们还需要看看 j 函数的内容: function j(e) { return i.a.createHash("md5").update(e.toString()).digest("hex")} 嗯…… 无比熟悉有木有,这就是 MD5 加密算法;j 函数把 k 函数拼凑的字符串进行了 MD5 加密(注意是 hex 格式的),然后返回加密后的结果。 继续走两步,E 函数到了尾声,果断打开控制台查询一下 sign 的值,正是加密后的内容,而不是一串明文: 继续码字的时候页面被刷新了,所以 sign 值和用上面的时间戳以及 key 加密出来的结果不同,懒得再截图了,大家自己试试吧。 至此我们已经搞清楚了有道翻译客户端是拿着什么参数去请求服务器的,接下来我们可以用 Python 来模拟这个请求。 Python 模拟请求 比起直接用 requests.post(),我们这次要使用 requests.session.post(),否则不会成功;headers 和 data 之后再怼: session = requests.session()session.get('https://fanyi.youdao.com/')res = session.post('https://dict.youdao.com/webtranslate') headers 的内容可以直接从开发者工具那边复制,别忘了加上 Referer。 data 的内容我们需要模拟一下: # 时间戳# 因为JS的时间戳是毫秒级的,而Python的是秒级的,所以要乘以1000t = str(int(time.time() * 1000))# 加密和构建sign值s = f'client=fanyideskweb&mysticTime={t}&product=webfanyi&key=fsdsogkndfokasodnaso'sign = hashlib.md5(s.encode()).hexdigest()data={ 'i': word, # 要翻译的单词 'from': 'auto', 'domain': 0, 'dictResult': 'true', 'keyid': 'webfanyi', 'sign': sign, 'client': 'fanyideskweb', 'product': 'webfanyi', 'appVersion': '1.0.0', 'vendor': 'web', 'pointParam': 'client,mysticTime,product', 'mysticTime': t, 'keyfrom': 'fanyi.web', 'mid': 1, 'screen': 1, 'model': 1, 'network': 'wifi', 'abtest': 0, 'yduuid': 'abcdefg'} 接着来看一下响应的内容~ 居然是一大串看不懂的东西?!当然,相信大家看到这一大串中结尾的 = 时,应该就知道这是 base64 编码了。我们可以用 Python 来解码,不过还要插一句题外话:这个是 base64 变种,叫做 URL-safe base64,它把 + 和 / 换成了 - 和 _。这个变种的诞生是为了让 base64 编码后的内容能够安全地放在 URL 中。 简单来说就是:我们还需要把 - 和 _ 换回来才能解码: res = res.replace('-', '+').replace('_', '/')data = base64.b64decode(res) 再次运行,好家伙,得到了一串更过分的字节。 解密 既然有道翻译客户端已经准备好了表单数据,那证明这之后它就会发送请求给服务器,我们且看客户端是如何发送请求、又如何处理响应的。 继续往下走一步,我们就能看到客户端发送请求的代码了: 中间的链接是不是很熟悉?再看看右边的 E(t),这正是会返回 sign 值的 E 函数。再用控制台一一检查过去,会发现老面孔和新面孔: (e,t) 是 key 值 a["d"] 返回一个 Promise 对象,等下细说 e 是表单数据的一部分,里面包含了我们要翻译的单词的明文 n["a"] 可以不深究,你只需要知道它可以把多个对象合并成一个。事实上,它的作用就是把 e 和 E(t) 返回的两个表单数据合并成一个 这里说一下 a["d"],它的结构是这样的: function l(e, t, o) { return new Promise((n,c)=>{ a["a"].post(e, t, o).then(e=>{ n(e.data) } ).catch(e=>{ c(e) } ) } )} e 是请求的链接 t 是表单数据 o 是请求头,也就是上上图中那个孤零零的 "Content-Type": "application/x-www-form-urlencoded" 如果你不了解 Promise,那么你可以把它理解成一个异步函数,它的作用是等待 a["a"].post(e, t, o) 这个请求完成,然后返回响应的内容。 如果这个请求成功了,那么 e 就是响应的内容,给到 n();如果这个请求失败了,那么 e 就是错误信息,给到 c()。 n() 和 c() 的内容都是空函数,所以我们不需要管它们。 那么 a["d"] 返回的 Promise 对象去了哪里咧?回到客户端发送请求的那一步(也就是上上图的 I = (e,t)=>略略略),看开发者工具的 调用堆栈。 调用堆栈:在开发者工具中你可以看到每个函数被调用的顺序。 点击 Po 便会自动引导到这个函数的定义处: Mo["a"].getTextTranslateResult() 正是函数 I! 刚才提到的 a["d"] 所返回的 Promise 对象去了此处的 Mo["a"].getTextTranslateResult(),服务器成功响应,走到 then,也就是我们解密的关键:有道翻译客户端对这个响应做了什么处理? 我们来看一下 then 中的代码: .then(o=>{ Mo["a"].cancelLastGpt(); const n = Mo["a"].decodeData(o, Ko["a"].state.text.decodeKey, Ko["a"].state.text.decodeIv) , a = n ? JSON.parse(n) : {}; 0 === a.code ? e.success && t(e.success)(a) : e.fail && t(e.fail)(a)}) Mo["a"].cancelLastGpt() 不需要管,感兴趣的可以自己去看看 const n = Mo["a"].decodeData...略 才是我们要看的,decodeData,是不是立即就明白这行代码的作用了? 有道翻译客户端使用了 Ko["a"].state.text.decodeKey 和 Ko["a"].state.text.decodeIv 来解密响应的内容。Key 和 Iv,好好好,这不就是 AES 加密算法吗?! 打开控制台打印一下这两个变量的值: 万事俱备,只欠东风,让我们最后看一眼 Mo["a"].decodeData() 函数: 接收 t、o、n 三个参数,分别代表了 base64 编码后的数据、用于解密的 key 和用于解密的 iv 分别将解密用的 key 和 iv 转化为 16 进制的字节 用 key 和 iv 创建一个 AES 解密器 r 用解密器 r 解密 base64 编码后的数据 t,得到 JSON 数据,也就是原始的响应数据 最终返回明文 搞清楚逻辑后我们便可以继续用 Python 解密: def digest_key(value): md5_new = hashlib.md5() md5_new.update(val.encode()) return md5_new.digest()o = 'ydsecret://query/key/B*RGygVywfNBwpmBaZg*WT7SIOUP2T0C9WHMZN39j^DAdaZhAnxvGcCY6VYFwnHl'n = 'ydsecret://query/iv/C@lZe2YzHtZ2CYgaXKSVfsb7Y4QWHjITPPZ0nQp87fBeJ!Iv6v^6fvi2WN@bYpJ4'key = digest_key(o)iv = digest_key(n)aes = AES.new(key, AES.MODE_CBC, iv)data = aes.decrypt(data).decode() 得到的结果: 制作翻译器 经过以上逆向过程,我们还可以制作一个翻译器,让它能够翻译我们输入的单词。 首先我们需要一个 GUI 库,这里我选择了 wxPython。安装方法: pip install wxPython 其次我们需要定义一个窗口类,该类继承自 wx.Frame: class MyFrame(wx.Frame): def __init__(self, parent, title): # 调用父类的构造函数 super(MyFrame, self).__init__(parent, title=title, size=(400, 250)) # 创建一个面板 panel = wx.Panel(self) # 左侧文本框 input_text = wx.TextCtrl(panel, style=wx.TE_MULTILINE, size=(150, 150)) # 右侧文本框 output_text = wx.TextCtrl(panel, style=wx.TE_MULTILINE | wx.TE_READONLY, size=(150, 150)) # 按钮 button = wx.Button(panel, label='翻译', size=(50, 25)) # 水平布局 sizer = wx.BoxSizer(wx.HORIZONTAL) # 添加输入框和输出框 sizer.Add(input_text, 1, wx.EXPAND) sizer.Add(output_text, 1, wx.EXPAND) # 垂直布局 inner_sizer = wx.BoxSizer(wx.VERTICAL) # 添加水平布局和按钮 inner_sizer.Add(sizer, 0, wx.ALL | wx.EXPAND, 10) inner_sizer.Add(button, 0, wx.ALIGN_CENTER) # 设置面板的布局 panel.SetSizerAndFit(inner_sizer) 运行一下,布局长这样: 用户需要在左侧输入单词、点击按钮,右侧才能够显示翻译的结果。因此我们还需要给按钮绑定一个事件: # ...继def __init__()的内容... # 绑定按钮事件 self.Bind(wx.EVT_BUTTON, self.on_button_click, button)# 当按钮被点击时,执行该函数def on_button_click(self, event): # 获取输入框和输出框 input_text = self.FindWindowById(event.GetId()-2) output_text = self.FindWindowById(event.GetId()-1) # 获取输入框的内容 word = input_text.GetValue() 接下来的内容我相信大家都心知肚明,正是上述的所有内容的结合体。我把那些代码封装成了函数塞在按钮事件中,大家则可以自己动动手、试试看(绝对不是因为我懒)。 # 继def on_button_click()的内容# 发送请求res = request(word)# 将响应用base64解码data = decode_result(res)# 解密解码后的响应,获得JSON数据中的翻译结果word = decrypt_data(data)# 设置输出框的内容output_text.SetValue(word) 完工!

2023/10/30
articleCard.readMore

STA 2000 笔记

统计学笔记,仅作为个人学习记录,不保证正确性。 Index Index Chapter 1: Defining and Collecting Data 1.1 Variables 1.1.1 Measurement Scales 1.2 Population and Sample 1.2.1 Parameter and Statistic 1.2.2 Sources of Data 1.2.3 Probability Sample Chapter 2: Organizing and Visualizing Variables 2.1 Organizing Categorical Data 2.2 Organizing Numerical Data 2.3 Visualizing Categorical Data 2.4 Visualizing Numerical Data 2.4.1 Visualing Two Numerical Variables Chapter 3: Numerical Descriptive Measures 3.1 Measures of Central Tendency 3.1.1 Mean 3.1.2 Median 3.1.3 Mode 3.2 Measures of Variation 3.2.1 Range 3.2.2 Sample Variation 3.2.3 Sample Standard Deviation 3.2.4 Coefficient of Variation 3.2.5 Z-Score 3.3 Shape of a Distribution 3.3.1 Skewness 3.3.2 Kurtosis 3.4 Quartile Measures 3.4.1 Locating Quartiles 3.4.2 Interquartile Range 3.5 Five Number Summary 3.5.1 Relationships Among the Five Number Summary and Distribution Shape 3.5.2 Boxplot 3.6 Numerical Descriptive Measures for Populations 3.6.1 Population Mean 3.6.2 Population Variance 3.6.3 Population Standard Deviation 3.7 Empirical Rule 3.8 Chebyshev's Rule 3.9 Covariance 3.10 Correlation Coefficient 3.10.1 Features of the Correlation Coefficient 3.11 Pitfalls in Numerical Descriptive Measures Chapter 4: Basic Probability 4.1 Events 4.2 Probability 4.2.1 Three Approaches to Assigning Probability 4.2.2 Simple Probability 4.2.3 Joint Probability 4.2.4 Marginal Probability 4.2.5 General Addition Rule 4.2.6 Conditional Probability 4.2.7 Independent Events 4.2.8 Multiplication Rule Chapter 5: Discrete Probability Distributions 5.1 Expected Value 5.2 Variance and Standard Deviation Chapter 6: The Normal Distribution 6.1 Continuous Probability Distributions 6.2 Normal Distribution 6.2.1 Density Function 6.2.2 Standardized Normal 6.2.3 Normal Probabilities Chapter 7: Sampling Distributions 7.1 Sample Mean Sampling Distribution 7.1.1 Standard Error of the Mean 7.1.2 Z-Value for Sampling Distribution of Mean 7.1.3 Sampling Distribution Properties 7.1.4 Central Limit Theorem 7.2 Population Proportions 7.2.1 Sampling Distribution of the Sample Proportion 7.2.2 Z-Value for Proportions Chapter 8: Confidence Interval Estimation 8.1 Point and Interval Estimates 8.2 Confidence Intervals Chapter 9: Fundamentals of Hypothesis Testing: One-Sample Tests 9.1 The Null Hypothesis 9.2 The Alternative Hypothesis 9.3 The Hypothesis Testing Process 9.3.1 The Test Statistic and Critical Values 9.3.2 Risks in Decision Making Using Hypothesis Testing 9.3.3 Level of Significance and the Rejection Region 9.4 Hypothesis Tests for the Mean 9.4.1 Z Test of Hypothesis for the Mean Chapter 10: Two-Sample Tests and One-Way ANOVA 10.1 Two-Sample Tests 10.1.1 Difference Between Two Means ----- Chapter 1: Defining and Collecting Data 1.1 Variables Categorical(qualitative): a variable that can be placed into a specific category, according to some characteristic or attribute. Nominal: no natural ordering of the categories Ordinal: natural ordering of the categories Numerical(quantitative): a variable that can be measured numerically. Discrete: arise from a counting process Continuous: arise from a measuring process 1.1.1 Measurement Scales Interval scale: an ordered scale in which the difference between measurements is a meaningful quantity but the measurements do not have a true zero point. Ratio scale: an ordered scale in which the difference between measurements is a meaningful quantity and the measurements have a true zero point. 1.2 Population and Sample Population: the set of all elements of interest in a particular study. contains all measurements of interest to the investigator Sample: a subset of the population. a part of the population selected for analysis 1.2.1 Parameter and Statistic Population parameter: a numerical measure that describes an aspect of a population. Sample statistic: a numerical measure that describes an aspect of a sample. 1.2.2 Sources of Data Primary sources: data that are generated by the investigator conducting the study. from a political survey collected from an experiment observed data Secondary sources: data that were produced by someone other than the investigator conducting the study. analyzing census data examining data from print journals or data published on the Internet 1.2.3 Probability Sample In a probability sample, items in the sample are chosen on the basis of known probabilities. Simple random sample: every individual or item from the frame has an equal chance of being selected Systematic sample: the items are selected according to a specified time or item interval in the sampling frame divide frame of NNN individuals into groups of kkk individuals: k=Nnk=\frac{N}{n}k=nN​ Stratified sample: divide population into two or more subgroups(strata)according to some characteristic that is important to the study Cluster sample: population is divided into several "clusters" or sections, then some of those clusters are randomly selected and all members of the selected clusters are used as the sample ----- Chapter 2: Organizing and Visualizing Variables 2.1 Organizing Categorical Data Summary table: tallies the frequencies or percentages of items in a set of categories so that you can see differences between categories Contingency table: a table that classifies sample observations according to two or more identifiable categories so that the relationship between the categories can be studied 2.2 Organizing Numerical Data Ordered array: a sequence of data, in rank order, from the smallest value to the largest value. Frequency distribution: a summary table in which the data are arranged into numerically ordered classes 2.3 Visualizing Categorical Data Bar chart: visualizes a categorical variable as a series of bars Pie chart: a circle broken up into slices that represent categories Doughnut chart: the outer part of a circle broken up into pieces that represent categories Pareto chart: a vertical bar chart, where categories are shown in descending order of frequency Side by side bar chart: a bar chart that compares two or more categories Doughnut chart(contingency): a doughnut chart that compares two or more categories 2.4 Visualizing Numerical Data Stem-and-leaf display: a simple way to see how the data are distributed and where concentrations of data exist Histogram: a vertical bar chart of the data in a frequency distribution Percentage polygon: formed by having the midpoint of each class represent the data in that class and then connecting the sequence Of midpoints at their respective class percentages 2.4.1 Visualing Two Numerical Variables Scatter plot: used for numerical data consisting of paired observations taken from two numerical variables Time series plot: used to study patterns in the values of a numeric variable over time ----- Chapter 3: Numerical Descriptive Measures Central tendency: the extent or inclination to which the values of a numerical variable group or cluster around a typical or central value. 3.1 Measures of Central Tendency Measure of central tendency: a single value that attempts to describe a set of data by identifying the central position within that set of data. 3.1.1 Mean Xˉ=∑i=1nXin=X1+X2+⋯+Xnn\bar{X} = \frac{\sum_{i=1}^{n} X_i}{n} = \frac{X_1 + X_2 + \cdots + X_n}{n}Xˉ=n∑i=1n​Xi​​=nX1​+X2​+⋯+Xn​​ Xˉ\bar{X}Xˉ -> sample mean; pronounced "X-bar" nnn -> sample size XiX_iXi​ -> the iiith value in the sample XnX_nXn​ -> the observed values 3.1.2 Median Sample size is odd: Median=x+12thposition\text{Median} = \frac{x+1}{2}^{th} \text{position}Median=2x+1​thposition Sample size is even: Median=n2thandn2+1thpositions\text{Median} = \frac{n}{2}^{th} \text{and} \frac{n}{2} + 1^{th} \text{positions}Median=2n​thand2n​+1thpositions 3.1.3 Mode Mode: the value that occurs most frequently in a data set. 3.2 Measures of Variation Measure of variation: gives information on the spread or variability or dispersion of the data values. 3.2.1 Range Range=Maximum−Minimum\text{Range} = \text{Maximum} - \text{Minimum}Range=Maximum−Minimum 3.2.2 Sample Variation Sample variance: average of squared deviations of values from the mean. S2=∑i=1n(Xi−Xˉ)2n−1S^2 = \frac{\sum_{i=1}^{n}(X_i - \bar{X})^2}{n-1}S2=n−1∑i=1n​(Xi​−Xˉ)2​ xˉ\bar{x}xˉ -> arithmetic mean nnn -> sample size XiX_iXi​ -> the iiith value in the sample 3.2.3 Sample Standard Deviation S=∑i=1n(Xi−Xˉ)2n−1S = \sqrt{\frac{\sum_{i=1}^{n}(X_i - \bar{X})^2}{n-1}}S=n−1∑i=1n​(Xi​−Xˉ)2​ ​ 3.2.4 Coefficient of Variation CV=SXˉ×100%CV = \frac{S}{\bar{X}} \times 100\%CV=XˉS​×100% 3.2.5 Z-Score Z-Score: the number of standard deviations that a given value XXX is above or below the mean. a data value is considered an extreme outlier if its Z-Score is less than -3 or greater than +3 Z=X−XˉSZ = \frac{X - \bar{X}}{S}Z=SX−Xˉ​ 3.3 Shape of a Distribution 3.3.1 Skewness Skewness: a measure of the degree of asymmetry of a distribution. 3.3.2 Kurtosis Kurtosis: a measure of the degree of peakedness of a distribution. 3.4 Quartile Measures Quartile: a value that divides a data set into four groups containing(as far as possible)an equal number of observations. Q1Q_1Q1​ -> first quartile Q2Q_2Q2​ -> second quartile; the median Q3Q_3Q3​ -> third quartile 3.4.1 Locating Quartiles Q1=n+14Q_1 = \frac{n+1}{4}Q1​=4n+1​ ranked value Q2=n+12Q_2 = \frac{n+1}{2}Q2​=2n+1​ ranked value Q3=3(n+1)4Q_3 = \frac{3(n+1)}{4}Q3​=43(n+1)​ ranked value nnn -> the number of observed values 3.4.2 Interquartile Range Interquartile range: the difference between the third and first quartiles. IQR=Q3−Q1IQR = Q_3 - Q_1IQR=Q3​−Q1​ 3.5 Five Number Summary Five number summary: the five numbers that help describe the center, spread and shape of data. minimum\text{minimum}minimum Q1Q_1Q1​ Q2Q_2Q2​ / Median Q3Q_3Q3​ maximum\text{maximum}maximum 3.5.1 Relationships Among the Five Number Summary and Distribution Shape Left-SkewedSymmetricRight-Skewed Median−minimum>maximum−MedianMedian - \text{minimum} > \text{maximum} - MedianMedian−minimum>maximum−MedianMedian−minimum≈maximum−MedianMedian - \text{minimum} \approx \text{maximum} - MedianMedian−minimum≈maximum−MedianMedian−minimum<maximum−MedianMedian - \text{minimum} < \text{maximum} - MedianMedian−minimum<maximum−Median Q1−minimum>maximum−Q3Q_1 - \text{minimum} > \text{maximum} - Q_3Q1​−minimum>maximum−Q3​Q1−minimum≈maximum−Q3Q_1 - \text{minimum} \approx \text{maximum} - Q_3Q1​−minimum≈maximum−Q3​Q1−minimum<maximum−Q3Q_1 - \text{minimum} < \text{maximum} - Q_3Q1​−minimum<maximum−Q3​ Median−Q1>Q2−MedianMedian - Q_1 > Q_2 - MedianMedian−Q1​>Q2​−MedianMedian−Q1≈Q2−MedianMedian - Q_1 \approx Q_2 - MedianMedian−Q1​≈Q2​−MedianMedian−Q1≈Q2−MedianMedian - Q_1 \approx Q_2 - MedianMedian−Q1​≈Q2​−Median 3.5.2 Boxplot Boxplot: a graphical display of the five number summary. 3.6 Numerical Descriptive Measures for Populations 3.6.1 Population Mean μ=∑i=1NXiN\mu = \frac{\sum_{i=1}^{N} X_i}{N}μ=N∑i=1N​Xi​​ μ\muμ -> population mean NNN -> population size XiX_iXi​ -> the iiith value in the population 3.6.2 Population Variance σ2=∑i=1N(Xi−μ)2N\sigma^2 = \frac{\sum_{i=1}^{N}(X_i - \mu)^2}{N}σ2=N∑i=1N​(Xi​−μ)2​ 3.6.3 Population Standard Deviation σ=∑i=1N(Xi−μ)2N\sigma = \sqrt{\frac{\sum_{i=1}^{N}(X_i - \mu)^2}{N}}σ=N∑i=1N​(Xi​−μ)2​ ​ 3.7 Empirical Rule Empirical rule: approximates the variation of data in a symmetric mound-shaped distribution. 68% of the data values lie within one standard deviation of the mean 95% of the data values lie within two standard deviations of the mean 99.7% of the data values lie within three standard deviations of the mean 3.8 Chebyshev's Rule Chebyshev's rule: applies to any data set, regardless of the shape of the distribution. at least 1−1k21-\frac{1}{k^2}1−k21​ of the data values lie within kkk standard deviations of the mean, where kkk is any value greater than 1 3.9 Covariance Covariance: a measure of the linear association between two variables. Cov(X,Y)=∑i=1n(Xi−Xˉ)(Yi−Yˉ)n−1Cov(X,Y)= \frac{\sum_{i=1}^{n}(X_i - \bar{X})(Y_i - \bar{Y})}{n-1}Cov(X,Y)=n−1∑i=1n​(Xi​−Xˉ)(Yi​−Yˉ)​ Cov(X,Y)>0Cov(X,Y)> 0Cov(X,Y)>0 -> positive covariance; as XXX increases, YYY increases Cov(X,Y)<0Cov(X,Y)< 0Cov(X,Y)<0 -> negative covariance; as XXX increases, YYY decreases Cov(X,Y)=0Cov(X,Y)= 0Cov(X,Y)=0 -> no linear relationship between XXX and YYY 3.10 Correlation Coefficient Correlation coefficient: a measure of the linear association between two variables. r=Cov(X,Y)SXSYr = \frac{Cov(X,Y)}{S_X S_Y}r=SX​SY​Cov(X,Y)​ Cov(X,Y)=∑i=1n(Xi−Xˉ)(Yi−Yˉ)n−1Cov(X,Y)= \frac{\sum_{i=1}^{n}(X_i - \bar{X})(Y_i - \bar{Y})}{n-1}Cov(X,Y)=n−1∑i=1n​(Xi​−Xˉ)(Yi​−Yˉ)​ SX=∑i=1n(Xi−Xˉ)2n−1S_X = \sqrt{\frac{\sum_{i=1}^{n}(X_i - \bar{X})^2}{n-1}}SX​=n−1∑i=1n​(Xi​−Xˉ)2​ ​ SY=∑i=1n(Yi−Yˉ)2n−1S_Y = \sqrt{\frac{\sum_{i=1}^{n}(Y_i - \bar{Y})^2}{n-1}}SY​=n−1∑i=1n​(Yi​−Yˉ)2​ ​ 3.10.1 Features of the Correlation Coefficient The population coefficient of correlation, ρ\rhoρ, is a measure of the linear association between two variables. The sample coefficient of correlation, rrr, is a measure of the linear association between two variables. 3.11 Pitfalls in Numerical Descriptive Measures Data analysis is objective Data interpretation is subjective ----- Chapter 4: Basic Probability Sample space: the set of all possible outcomes of an experiment. 4.1 Events Simple event: an event described by a single characteristic or an event that is a set of outcomes of an experiment. Joint event: an event described by two or more characteristics. Complement of an event A: all events that are not part of event A 4.2 Probability Probability: the numerical value representing the chance, likelihood, or possibility that a certain event will occur. always between 0 and 1 Impossible event: an event that has no chance of occurring. Certain event: an event that is sure to occur. Mutually exclusive events: events that cannot occur at the same time. Collectively exhaustive events: the set of events that covers the entire sample space. one of the events must occur 4.2.1 Three Approaches to Assigning Probability A priori probability: a probability assignment based upon prior knowledge of the process involved. P(A)=number of outcomes in Atotal number of outcomesP(A)= \frac{\text{number of outcomes in A}}{\text{total number of outcomes}}P(A)=total number of outcomesnumber of outcomes in A​ Example: randomly selecting a day from the year 2019. What is the probability that the day is in January? P(January)=31365P(\text{January})= \frac{31}{365}P(January)=36531​ Empirical probability: a probability assignment based upon observations obtained from probability experiments. P(A)=number of times A occursnumber of times the experiment is repeatedP(A)= \frac{\text{number of times A occurs}}{\text{number of times the experiment is repeated}}P(A)=number of times the experiment is repeatednumber of times A occurs​ Example: P(male taking stats)=number of males taking statstotal number of peopleP(\text{male taking stats})= \frac{\text{number of males taking stats}}{\text{total number of people}}P(male taking stats)=total number of peoplenumber of males taking stats​ Subjective probability: a probability assignment based upon judgment. P(A)=degree of belief that A will occurdegree of belief that A will occur+degree of belief that A will not occurP(A)= \frac{\text{degree of belief that A will occur}}{\text{degree of belief that A will occur} + \text{degree of belief that A will not occur}}P(A)=degree of belief that A will occur+degree of belief that A will not occurdegree of belief that A will occur​ differs from person to person 4.2.2 Simple Probability Simple probability: the probability of a single event occurring. P(A)=number of outcomes satisfying Atotal number of outcomesP(A)= \frac{\text{number of outcomes satisfying A}}{\text{total number of outcomes}}P(A)=total number of outcomesnumber of outcomes satisfying A​ 4.2.3 Joint Probability Joint probability: the probability of two or more events occurring simultaneously. P(A∩B)=number of outcomes satisfying A and Btotal number of outcomesP(A \cap B)= \frac{\text{number of outcomes satisfying A and B}}{\text{total number of outcomes}}P(A∩B)=total number of outcomesnumber of outcomes satisfying A and B​ 4.2.4 Marginal Probability Marginal probability: the probability of a single event occurring without regard to any other event. P(A)=number of outcomes satisfying Atotal number of outcomes=P(A∩B)+P(A∩Bc)P(A)= \frac{\text{number of outcomes satisfying A}}{\text{total number of outcomes}} = P(A \cap B)+ P(A \cap B^c)P(A)=total number of outcomesnumber of outcomes satisfying A​=P(A∩B)+P(A∩Bc) BBBBcB^cBcTotal AAAP(A∩B)P(A \cap B)P(A∩B)P(A∩Bc)P(A \cap B^c)P(A∩Bc)P(A)P(A)P(A) AcA^cAcP(Ac∩B)P(A^c \cap B)P(Ac∩B)P(Ac∩Bc)P(A^c \cap B^c)P(Ac∩Bc)P(Ac)P(A^c)P(Ac) TotalP(B)P(B)P(B)P(Bc)P(B^c)P(Bc)1 P(A∩B)P(A \cap B)P(A∩B) and P(A∩Bc)P(A \cap B^c)P(A∩Bc) are joint probabilities P(Ac)P(A^c)P(Ac) and P(Bc)P(B^c)P(Bc) are marginal probabilities 4.2.5 General Addition Rule General addition rule: the probability of the union of two events is the sum of the probabilities of the two events minus the probability of their intersection. P(A∪B)=P(A)+P(B)−P(A∩B)P(A \cup B)= P(A)+ P(B)- P(A \cap B)P(A∪B)=P(A)+P(B)−P(A∩B) If AAA and BBB are mutually exclusive, then P(A∩B)=0P(A \cap B)= 0P(A∩B)=0. Therefore, the general addition rule becomes: P(A∪B)=P(A)+P(B)P(A \cup B)= P(A)+ P(B)P(A∪B)=P(A)+P(B) 4.2.6 Conditional Probability Conditional probability: the probability of an event occurring given that another event has already occurred. The condition probability of AAA given that BBB has occured: P(A∣B)=P(A∩B)P(B)P(A|B)= \frac{P(A \cap B)}{P(B)}P(A∣B)=P(B)P(A∩B)​ The condition probability of BBB given that AAA has occured: P(B∣A)=P(A∩B)P(A)P(B|A)= \frac{P(A \cap B)}{P(A)}P(B∣A)=P(A)P(A∩B)​ where P(A∩B)P(A \cap B)P(A∩B) is the joint probability of AAA and BBB P(A)P(A)P(A) -> marginal probability of AAA P(B)P(B)P(B) -> marginal probability of BBB 4.2.7 Independent Events Independent events: two events are independent if the occurrence of one event does not affect the probability of the occurrence of the other event. P(A∣B)=P(A) ; P(B∣A)=P(B)P(A|B)= P(A)\space \text{;} \space P(B|A)= P(B)P(A∣B)=P(A) ; P(B∣A)=P(B) events AAA and BBB are independent when the probability of one event is not affects by the fact that the other event has occurred 4.2.8 Multiplication Rule Multiplication rule: the probability of the intersection of two events is the product of the probability of the first event and the conditional probability of the second event given that the first event has occurred. P(A∩B)=P(A)×P(B∣A)P(A \cap B)= P(A)\times P(B|A)P(A∩B)=P(A)×P(B∣A) P(A∣B)=P(A∩B)P(B)=P(A)×P(B∣A)P(B)P(A|B)= \frac{P(A \cap B)}{P(B)} = \frac{P(A)\times P(B|A)}{P(B)}P(A∣B)=P(B)P(A∩B)​=P(B)P(A)×P(B∣A)​ If AAA and BBB are independent, then P(B∣A)=P(B)P(B|A)= P(B)P(B∣A)=P(B), and the multiplication rule becomes: P(A∩B)=P(A)×P(B)P(A \cap B)= P(A)\times P(B)P(A∩B)=P(A)×P(B) ----- Chapter 5: Discrete Probability Distributions Probability distribution: a listing of all the outcomes of an experiment and the probability associated with each outcome. 5.1 Expected Value Expected value: the mean of a probability distribution -> μ\muμ. μ=∑i=1nxiP(xi)\mu = \sum_{i=1}^{n} x_i P(x_i)μ=i=1∑n​xi​P(xi​) xix_ixi​ -> the iiith value in the probability distribution P(xi)P(x_i)P(xi​) -> the probability associated with the iiith value in the probability distribution nnn -> the number of values in the probability distribution μ\muμ -> the expected value 5.2 Variance and Standard Deviation Variance: the average of the squared deviations of the possible values from the expected value. σ2=∑i=1n(xi−μ)2P(xi)\sigma^2 = \sum_{i=1}^{n}(x_i - \mu)^2 P(x_i)σ2=i=1∑n​(xi​−μ)2P(xi​) xix_ixi​ -> the iiith value in the probability distribution P(xi)P(x_i)P(xi​) -> the probability associated with the iiith value in the probability distribution nnn -> the number of values in the probability distribution Standard deviation: the square root of the variance. ----- Chapter 6: The Normal Distribution 6.1 Continuous Probability Distributions Continuous variable: a variable that can assume any value on a continuum(can assume an uncountable number of values) For example, the thickness of an item, the time required to complete a task. 6.2 Normal Distribution Normal Distribution: bell shaped symmetrical mean, median and mode are equal The location is determined by the mean, μ\muμ, and the spread is determined by the standard deviation, σ\sigmaσ. The random variable has an infinite theoretical range: (−∞,∞)(-\infty, \infty)(−∞,∞). 6.2.1 Density Function Probability density function: a function whose integral is calculated to find probabilities associated with a continuous random variable. f(x)=1σ2πe−12(x−μσ)2f(x)= \frac{1}{\sigma \sqrt{2\pi}} e^{-\frac{1}{2}(\frac{x-\mu}{\sigma})^2}f(x)=σ2π ​1​e−21​(σx−μ​)2 eee -> the mathematical constant approximately equal to 2.71828 π\piπ -> the mathematical constant approximately equal to 3.14159 μ\muμ -> the mean of the distribution σ\sigmaσ -> the standard deviation of the distribution xxx -> the value of the continuous variable 6.2.2 Standardized Normal Standardized normal distribution: a normal distribution with a mean of 0 and a standard deviation of 1. z=x−μσz = \frac{x-\mu}{\sigma}z=σx−μ​ Its density function is: f(z)=12πe−12z2f(z)= \frac{1}{\sqrt{2\pi}} e^{-\frac{1}{2}z^2}f(z)=2π ​1​e−21​z2 eee -> the mathematical constant approximately equal to 2.71828 π\piπ -> the mathematical constant approximately equal to 3.14159 zzz -> the standardized value of the continuous variable 6.2.3 Normal Probabilities Probability is measured by the area under the curve. Uniform Distribution: a continuous probability distribution in which the probability of observing a value x in any interval of equal length is the same for each interval of the same length. Also known as rectangular distribution. Exponential Distribution: a continuous probability distribution that is used to describe the time between events that occur at a constant average rate and are independent of each other. The exponential distribution is skewed to the right. ----- Chapter 7: Sampling Distributions Sampling distribution: a distribution of all of the possible values of a sample statistic for a given sample size selected from a population. Xˉ=∑i=1nXin\bar{X} = \frac{\sum_{i=1}^{n} X_i}{n}Xˉ=n∑i=1n​Xi​​ Assume there is a population: population size N=4N=4N=4 variable of interest is, XXX, age of individuals values of XXX: 18,20,22,2418, 20, 22, 2418,20,22,24(years) μ=18+20+22+244=21\mu = \frac{18+20+22+24}{4} = 21μ=418+20+22+24​=21 σ=∑i=1n(Xi−μ)2N=2.236\sigma = \sqrt{\frac{\sum^n_{i=1}(X_i-\mu)^2}{N}} = 2.236σ=N∑i=1n​(Xi​−μ)2​ ​=2.236 7.1 Sample Mean Sampling Distribution 7.1.1 Standard Error of the Mean Standard error of the mean: the standard deviation of the sampling distribution of the sample mean. σXˉ=σn\sigma_{\bar{X}} = \frac{\sigma}{\sqrt{n}}σXˉ​=n ​σ​ 7.1.2 Z-Value for Sampling Distribution of Mean Z=Xˉ−μσXˉ=Xˉ−μσnZ = \frac{\bar{X}-\mu}{\sigma_{\bar{X}}} = \frac{\bar{X}-\mu}{\frac{\sigma}{\sqrt{n}}}Z=σXˉ​Xˉ−μ​=n ​σ​Xˉ−μ​ Xˉ\bar{X}Xˉ -> sample mean μ\muμ -> population mean σXˉ\sigma_{\bar{X}}σXˉ​ -> standard error of the mean 7.1.3 Sampling Distribution Properties The mean of the sampling distribution of the sample mean is equal to the mean of the population As nnn increases, the standard error of the mean(σXˉ\sigma_{\bar{X}}σXˉ​)decreases μXˉ=μ\mu_{\bar{X}} = \muμXˉ​=μ 7.1.4 Central Limit Theorem Central limit theorem: the sampling distribution of the sample mean is approximately normal for a sufficiently large sample size. as the sample size gets large enough, the sampling distribution of the sample mean becomes almost normal regardless of shape of population Central limit theorem is used when the population is not normal. How large is large enough? for most distributions, n>30n \gt 30n>30 is sufficient for fairly symmetric distributions, n>15n \gt 15n>15 is sufficient for a normal population distribution, the sampling distribution of the sample mean is always normally distributed 7.2 Population Proportions π\piπ: the proportion of the population having some characteristic. Sample proportion(ppp): provides an estimate of π\piπ: p=number of items in the population having the characteristictotal number of items in the populationp = \frac{\text{number of items in the population having the characteristic}}{\text{total number of items in the population}}p=total number of items in the populationnumber of items in the population having the characteristic​ 0≤p≤10 \leq p \leq 10≤p≤1 ppp is approximately distributed as a normal distribution when nnn is large 7.2.1 Sampling Distribution of the Sample Proportion σp=π(1−π)n\sigma_p = \sqrt{\frac{\pi(1-\pi)}{n}}σp​=nπ(1−π)​ ​ π\piπ -> population proportion nnn -> sample size σp\sigma_pσp​ -> standard error of the sample proportion ppp -> sample proportion 7.2.2 Z-Value for Proportions Z=p−πσp=p−ππ(1−π)nZ = \frac{p-\pi}{\sigma_p} = \frac{p-\pi}{\sqrt{\frac{\pi(1-\pi)}{n}}}Z=σp​p−π​=nπ(1−π)​ ​p−π​ ----- Chapter 8: Confidence Interval Estimation 8.1 Point and Interval Estimates Point estimate: a single number. Confidence interval: a set of two plausible values at a specified confidence level that contains true parameter. 8.2 Confidence Intervals Interval estimate: provides more information about a population characteristic than does a point estimate -> confidence intervals. ----- Chapter 9: Fundamentals of Hypothesis Testing: One-Sample Tests Hypothesis: a claim or assertion about a population parameter. Example: The mean diameter of a manufactured bolt is 303030mm -> H0:μ=30H_0:\mu=30H0​:μ=30 9.1 The Null Hypothesis begin with the assumption that the null hypothesis is true similar to the notion of innocent until proven guilty represents the current belief in a situation always contains ===, or ≤\leq≤, or ≥\geq≥ sign may or may not be rejected 9.2 The Alternative Hypothesis is the opposite of the null hypothesis the mean diameter of a manufactured bolt is not equal to 303030mm challenges the status quo never contains the ===, or ≤\leq≤, or ≥\geq≥ sign may or may not be proven is generally the hypothesis that the researcher is trying to prove 9.3 The Hypothesis Testing Process The population mean age is 505050. H0:μ=50H_0: \mu = 50H0​:μ=50 H1:μ≠50H_1: \mu \neq 50H1​:μ=50 Suppose the sample mean age was Xˉ=20\bar{X} = 20Xˉ=20. This is lower than the claimed mean population age of 505050. If the null hypothesis were true, the probability of getting such a different sample mean would be very small, so you reject the null hypothesis. In other words, getting a sample mean of 202020 is so unlikely if the population mean was 505050, you conclude that the population mean must not be 505050. 9.3.1 The Test Statistic and Critical Values If the sample mean is close to the stated population mean, the null hypothesis is not rejected. If the sample mean is far from the stated population mean, the null hypothesis is rejected. How far is "far enough" to reject H0H_0H0​? The critical value of a test statistic creates a "line in the sand" for decision making -- it answers the question of how far is far enough. 9.3.2 Risks in Decision Making Using Hypothesis Testing Type I error: rejecting the null hypothesis when it is true a "false alarm" the probability of a Type I Error is α\alphaα called level of significance of the test set by researcher in advance Type II error: failing to reject the null hypothesis when it is false a "missed opportunity" the probability of a Type II Error is β\betaβ the confidence coefficient is (1−α)(1-\alpha)(1−α): the probability of not rejecting H0H_0H0​ when it is true the confidence level of a hypothesis test is (1−α)×100%(1-\alpha)\times 100 \%(1−α)×100% the power of a statistical test (1−β)(1-\beta)(1−β) is the probability of rejecting H0H_0H0​ when it is false Type I and Type II errors cannot happen at the same time. A Type I error can only occur if H0H_0H0​ is true A Type II error can only occur if H0H_0H0​ is false Factors affecting Type II Error: All else equal…… β↑\beta \uparrowβ↑ when the difference between hypothesized parameter and its true value ↓\downarrow↓ β↑\beta \uparrowβ↑ when α↓\alpha \downarrowα↓ β↑\beta \uparrowβ↑ when σ↑\sigma \uparrowσ↑ β↑\beta \uparrowβ↑ when n↓n \downarrown↓ 9.3.3 Level of Significance and the Rejection Region H0:μ=30H_0: \mu = 30H0​:μ=30 H1:μ≠30H_1: \mu \neq 30H1​:μ=30 Level of significance = α\alphaα This is a two-tail test because there is a rejection region in both tails. 9.4 Hypothesis Tests for the Mean 9.4.1 Z Test of Hypothesis for the Mean Convert sample statistic(Xˉ\bar{X}Xˉ)to a ZSTATZ_{\text{STAT}}ZSTAT​ test statistic. For a two-tail test for the mean, σ\sigmaσ known: determine the critical ZZZ values for a specified level of significance α\alphaα from a table or by using computer software Decision rule: if the test statistic falls in the rejection region, reject H0H_0H0​ otherwise do not reject H0H_0H0​ ----- Chapter 10: Two-Sample Tests and One-Way ANOVA 10.1 Two-Sample Tests 10.1.1 Difference Between Two Means To test hypothesis or form a confidence interval for the difference between two population means, μ1−μ2\mu_1 - \mu_2μ1​−μ2​. The point estimate for the difference is: Xˉ1−Xˉ2\bar{X}_1 - \bar{X}_2Xˉ1​−Xˉ2​

2023/10/17
articleCard.readMore

ECO 1001 笔记

微观经济学笔记,仅作为个人学习记录,不保证正确性。 目录 目录 第三章:市场 3.1 市场的定义 3.2 市场需求 3.2.1 需求表 3.2.2 需求曲线 3.2.3 需求的变化 3.2.4 需求曲线的移动 3.2.5 移动 vs. 沿着曲线的移动 3.3 市场供给 3.3.1 供给表 3.3.2 供给曲线 3.3.3 供给的变化 3.3.4 供给曲线的移动 3.3.5 移动 vs. 沿着曲线的移动 3.4 市场均衡 3.4.1 供给过剩 3.4.2 需求不足 3.4.3 市场均衡的变化 3.4.4 需求和供给的变化 第四章:弹性 4.1 弹性的定义 4.2 需求的价格弹性 4.2.2 计算价格弹性 4.2.3 价格弹性的分类 4.2.4 需求价格弹性的用处 4.2.5 需求弹性和总收入 4.3 供给的价格弹性 4.4 需求的交叉价格弹性 4.5 需求的收入弹性 4.6 四种弹性的总结 第五章:效率 5.1 支付和销售意愿 5.2 计算过剩 5.2.1 计算消费者过剩 5.2.2 计算生产者过剩 5.2.3 计算总过剩 5.3 市场均衡和效率 5.4 无谓损失 5.5 丢失的市场 第六章:政府干预 6.1 价格控制 6.2 价格上限 6.2.1 价格上限对福利的影响 6.2.2 非约束性价格上限 6.3 价格下限 6.3.1 价格下限对福利的影响 6.3.2 无效的价格下限 6.4 税收和补贴 6.5 税收 6.5.1 卖方纳税的影响 6.5.2 买方纳税的影响 6.5.3 税收对买卖双方的影响 6.5.4 税收发生率 6.6 补贴 6.6.1 补贴造成的损失 6.6.2 政府补贴支出 6.6.3 补贴带来的消费者 / 生产者过剩 6.7 税收或补贴的影响 6.8 长期影响与短期影响 第七章:消费者行为 7.1 效用基础 7.1.1 效用函数 7.1.2 边际效用 7.1.3 效用 7.1.4 边际效用递减 7.2 带约束条件的效用最大化 7.2.1 预算约束 7.2.2 总效用最大化 7.2.3 应对收入变化 7.2.4 应对价格变化 7.3 效用和社会 7.4 效用、利他主义和互惠 第十二章:生产成本 12.1 收入、成本和利润 12.1.1 固定成本和可变成本 12.1.2 显性和隐性成本 12.1.3 经济和会计利润 12.2 总产量、边际产品和平均产品 12.2.1 生产函数 12.2.2 平均和边际产品 12.3 生产成本 12.3.1 成本曲线 12.3.2 边际成本曲线和平均成本曲线 12.4 长期成本 12.5 规模报酬 12.6 长期平均总成本 第十三章:完全竞争 13.1 完全竞争市场 13.1.1 市场力量 13.1.2 完全竞争市场的收入 13.2 利润和生产决策 13.2.1 利润 13.2.2 决定何时操作 13.2.3 短期供给曲线和停产规则 13.2.4 长期供给曲线和停产规则 13.3 企业和市场供应曲线 13.3.1 长期供应 13.3.2 长期经济利润 13.3.3 因生产成本变化而进入市场 13.3.4 应对需求的变化 Chapter 14: Monopoly 14.1 Monopolists and Demand Curve 14.2 Monopoly Revenue 14.3 Monopoly Profit-maximizing Quantity 14.4 Problems with Monopoly 14.5 Comparing Market Characteristics Chapter 17: International Trade 17.1 Roots of Comparative Advantage 17.2 From Autarky to Free Trade 17.2.1 Net-Importer or Net-Exporter 17.2.2 Big Economy, Small Economy 17.3 Restrictions on Trade 17.4 Tariffs 17.4.1 Domestic Welfare Effects of a Tariff 17.5 Quotas 17.6 International Labor and Capital ----- 第三章:市场 3.1 市场的定义 市场是指买卖某种特定商品或服务的买家和卖家。 其中一种特殊的市场是竞争市场,它有以下特征: 标准化商品 / Standardized goods 无成本交易 / No transaction costs 充分信息 / Full information 参与者是价格接受者 / Participants are price takers 3.2 市场需求 消费者决定了市场需求,市场需求是所有消费者需求的总和。 需求量(Quantity demand)是指消费者愿意购买的某种商品的数量。 需求定律(Law of demand):需求量随价格的上升而下降,随价格的下降而上升。 3.2.1 需求表 需求表(Demand schedule):描述了在不同价格下,消费者愿意购买的商品数量。 3.2.2 需求曲线 需求曲线(Demand curve):描述了在不同价格下,消费者愿意购买的商品数量。 在需求曲线上,所有的其他非价格因素都保持不变。 3.2.3 需求的变化 五大非价格因素分别是: 收入 / Income 相关商品的价格 / Price of related goods 消费者偏好 / Consumer preferences 买家数量 / Number of buyers 预期 / Expectations 当这些非价格因素发生变化时: 如果是正面变化,需求曲线向右移动,需求增加 如果是负面变化,需求曲线向左移动,需求减少 3.2.4 需求曲线的移动 当需求增加时,需求曲线向右移动;当需求减少时,需求曲线向左移动。 3.2.5 移动 vs. 沿着曲线的移动 需求曲线的移动和沿着曲线的移动是不同的。 当非价格因素发生变化时,需求曲线会移动,而当价格发生变化时,需求曲线沿着曲线移动。 3.3 市场供给 生产者决定了市场供给,市场供给是所有生产者供给的总和。 供给量(Quantity supplied)是指生产者愿意出售的某种商品的数量。 供给定律(Law of supply):供给量随价格的上升而上升,随价格的下降而下降。 3.3.1 供给表 供给表(Supply schedule):描述了在不同价格下,生产者愿意出售的商品数量。 3.3.2 供给曲线 供给曲线(Supply curve):描述了在不同价格下,生产者愿意出售的商品数量。 在供给曲线上,所有的其他非价格因素都保持不变。 3.3.3 供给的变化 五大非价格因素分别是: 生产要素价格 / Prices of inputs 生产技术 / Technology 生产者的数量 / Number of producers 相关商品的价格 / Price of related goods 预期 / Expectations 当这些非价格因素发生变化时: 如果是正面变化,供给曲线向右移动,供给增加 如果是负面变化,供给曲线向左移动,供给减少 3.3.4 供给曲线的移动 当供给增加时,供给曲线向右移动;当供给减少时,供给曲线向左移动。 3.3.5 移动 vs. 沿着曲线的移动 供给曲线的移动和沿着曲线的移动是不同的。 当非价格因素发生变化时,供给曲线会移动,而当价格发生变化时,供给曲线沿着曲线移动。 3.4 市场均衡 市场均衡(Market equilibrium):市场需求和市场供给相等的价格。在市场均衡下,需求量等于供给量、消费者愿意购买的商品数量等于生产者愿意出售的商品数量。 如果市场并不在均衡状态,那么需求量和供给量就不相等,价格就会发生变化,直到市场达到均衡状态。 假设价格高于均衡价格,那么就会发生供给过剩(Surplus);假设价格低于均衡价格,那么就会发生需求不足(Shortage)。 3.4.1 供给过剩 供给过剩(Surplus):价格高于均衡价格,供给量大于需求量。 当价格减少时,供给量减少,需求量增加。 价格会持续下降,直到 QS=QD=Q∗Q_S=Q_D=Q^*QS​=QD​=Q∗。 3.4.2 需求不足 需求不足(Shortage):价格低于均衡价格,需求量大于供给量。 当价格增加时,供给量增加,需求量减少。 价格会持续上升,直到 QS=QD=Q∗Q_S=Q_D=Q^*QS​=QD​=Q∗。 3.4.3 市场均衡的变化 均衡价格和数量会随着需求和供给的变化而变化。 如果有一个非价格因素发生变化,那这就会影响到市场均衡。 3.4.4 需求和供给的变化 无论是需求还是供给发生变化,都会对均衡价格和数量产生明确的影响。 曲线变化价格变化数量变化 供给减少上升下降 供给增加下降上升 需求减少下降下降 需求增加上升上升 非价格因素是有可能同时影响到需求和供给的: 需求和供给同时变化、曲线移动 全新的均衡点会出现 供给变化需求变化价格变化数量变化 减少减少?下降 减少增加上升? 增加增加?上升 增加减少下降? ------- 第四章:弹性 4.1 弹性的定义 弹性(Elasticity):描述了需求或供给对价格变化的敏感程度。 该概念衡量了对以下变化的响应: 商品价格 相关商品的价格 收入 4.2 需求的价格弹性 需求价格弹性(Price elasticity of demand):需求对价格变化的敏感程度。 需求价格弹性介绍了需求量随价格的变化而变化的程度。 消费者对价格变化的敏感程度取决于: 商品是否是必需品 商品是否有替代品 商品占消费者预算的比例 时间 市场范围 4.2.2 计算价格弹性 中点法(Midpoint method):计算价格弹性的方法。 价格弹性 = 需求量变化的百分比价格变化的百分比 =(Q2−Q1)/[(Q2+Q1)/2](P2−P1)/[(P2+P1)/2]\text {价格弹性}=\frac {\text {需求量变化的百分比}}{\text {价格变化的百分比}}=\frac {(Q_2 - Q_1)/[(Q_2 + Q_1)/2]}{(P_2 - P_1)/[(P_2 + P_1)/2]}价格弹性 = 价格变化的百分比需求量变化的百分比​=(P2​−P1​)/[(P2​+P1​)/2](Q2​−Q1​)/[(Q2​+Q1​)/2]​ 中点弹性(Midpoint elasticity):任意两个数的差除以它们的平均值。 4.2.3 价格弹性的分类 价格弹性的分类: 弹性(Elastic):价格的变化会导致需求量相对较大百分比的变化 非弹性(Inelastic):价格的变化会导致需求量相对较小百分比的变化 在极端情况下,需求可以是完全弹性(Perfectly elastic)或完全非弹性(Perfectly inelastic)。 在这两个极端情况之间,弹性还可以分为: 弹性(Elastic):价格弹性大于 1 单位弹性(Unit elastic):价格弹性等于 1 非弹性(Inelastic):价格弹性小于 1 4.2.4 需求价格弹性的用处 在知道一个商品的需求是否是弹性的情况下,可以: 允许管理者确定价格上涨是否会导致总收入(Total revenue)上升或下降 总收入是一家公司从销售商品和服务中获得的金额 总收入 = 价格 × 需求量 4.2.5 需求弹性和总收入 需求弹性和总收入的关系: 4.3 供给的价格弹性 供给价格弹性(Price elasticity of supply):供给对价格变化的敏感程度。 供给价格弹性永远是正数,因为供给量和价格总是正相关的。 生产者对价格变化的敏感程度取决于: 生产者是否能够轻易地改变生产量 时间 生产要素的可用性 4.4 需求的交叉价格弹性 需求交叉价格弹性(Cross-price elasticity of demand):当一个商品的价格发生变化时,另一个商品的需求量发生变化的程度。 中点法可以计算商品 A 的需求量和商品 B 的价格之间的弹性: 交叉价格弹性 = 商品 A 的需求量变化的百分比商品 B 的价格变化的百分比 =(QA2−QA1)/[(QA2+QA1)/2](PB2−PB1)/[(PB2+PB1)/2]\text {交叉价格弹性}=\frac {\text {商品 A 的需求量变化的百分比}}{\text {商品 B 的价格变化的百分比}}=\frac {(Q_{A2}-Q_{A1})/[(Q_{A2}+Q_{A1})/2]}{(P_{B2}-P_{B1})/[(P_{B2}+P_{B1})/2]}交叉价格弹性 = 商品 B 的价格变化的百分比商品 A 的需求量变化的百分比​=(PB2​−PB1​)/[(PB2​+PB1​)/2](QA2​−QA1​)/[(QA2​+QA1​)/2]​ 需求交叉价格弹性可以是正数或负数。如果大于 0,那么两个商品是替代品;如果小于 0,那么两个商品是互补品。 4.5 需求的收入弹性 需求收入弹性(Income elasticity of demand):当消费者的收入发生变化时,需求量发生变化的程度。 中点法可以计算商品的需求量和消费者的收入之间的弹性: 收入弹性 = 商品的需求量变化的百分比消费者的收入变化的百分比 =(Q2−Q1)/[(Q2+Q1)/2](I2−I1)/[(I2+I1)/2]\text {收入弹性}=\frac {\text {商品的需求量变化的百分比}}{\text {消费者的收入变化的百分比}}=\frac {(Q_2 - Q_1)/[(Q_2 + Q_1)/2]}{(I_2 - I_1)/[(I_2 + I_1)/2]} 收入弹性 = 消费者的收入变化的百分比商品的需求量变化的百分比​=(I2​−I1​)/[(I2​+I1​)/2](Q2​−Q1​)/[(Q2​+Q1​)/2]​ 需求收入弹性可以是正数或负数。如果大于 0,那么商品是正常品;如果大于 1,那么商品是奢侈品;如果小于 0,那么商品是劣等品。 4.6 四种弹性的总结 弹性公式负数正数更弹性更不弹性 需求价格弹性需求量百分比变化价格百分比变化 \frac {\text {需求量百分比变化}}{\text {价格百分比变化}} 价格百分比变化需求量百分比变化​永远从不替代品和奢侈品必需品和劣等品 供给价格弹性供给量百分比变化价格百分比变化 \frac {\text {供给量百分比变化}}{\text {价格百分比变化}} 价格百分比变化供给量百分比变化​从不永远短期长期 需求交叉价格弹性商品 A 的需求量百分比变化商品 B 的价格百分比变化 \frac {\text {商品 A 的需求量百分比变化}}{\text {商品 B 的价格百分比变化}} 商品 B 的价格百分比变化商品 A 的需求量百分比变化​互补品替代品近乎完全的替代品以及强的互补品无关商品 收入弹性商品的需求量百分比变化消费者的收入百分比变化 \frac {\text {商品的需求量百分比变化}}{\text {消费者的收入百分比变化}} 消费者的收入百分比变化商品的需求量百分比变化​劣等品正常品奢侈品必需品 ------- 第五章:效率 5.1 支付和销售意愿 消费者往往愿意支付比市场价格更高的价格,如果价格低于他们的最大支付意愿(Willingness to pay)。 生产者同样愿意以低于市场价的价格出售,如果价格高于他们的最小销售意愿(Willingness to sell)。 自愿交换能创造价值,并能使参与其中的每个人过得更好。 5.2 计算过剩 当消费者在低于市场价格的价格下购买商品时,就会发生消费者过剩。 消费者过剩(Consumer surplus):消费者从购买中获益的衡量标准。 当生产者在高于市场价格的价格下销售商品时,就会发生生产者过剩。 生产者过剩(Producer surplus):生产者从销售中获益的衡量标准。 过剩(Surplus): 买家或卖家愿意交易的价格与实际价格之间的差异。 5.2.1 计算消费者过剩 消费者过剩可以通过求个人的消费者过剩的和来计算。 5.2.2 计算生产者过剩 生产者过剩可以通过求个人的生产者过剩的和来计算。 5.2.3 计算总过剩 总过剩(Total surplus):每个人从参与商品或服务交换中获得的综合利益。 5.3 市场均衡和效率 市场均衡是当总过剩最大化时的均衡。 5.4 无谓损失 无谓损失(Deadweight loss):当市场不在均衡状态时,会发生的损失。 无谓损失被定义为当商品的购买量和销售量低于市场均衡量时、会导致的总过剩损失。 5.5 丢失的市场 当潜在买家和卖家想要进行的一些交易没有发生时,市场就会丢失。 这会因为以下原因发生: 公共政策 税收 准确信息或沟通的缺乏 促进交换的技术的缺乏 ----- 第六章:政府干预 6.1 价格控制 价格控制(Price control):政府对价格的干预。 价格控制可以被划分为两种类型: 价格上限 价格下限 6.2 价格上限 价格上限(Price ceiling):政府规定的商品的最高价格。 假设价格上限低于市场均衡价格,那么就会发生供给不足。 6.2.1 价格上限对福利的影响 价格上限会造成死重损失,并将生产者的福利转移给消费者。 因为价格上限会导致供给不足,所以政府会采取以下措施: 平等分配商品 先到先得 分配给政府优先考虑的人或者卖方的亲朋好友 短缺会导致人们采取寻租行为(Rent-seeking),例如贿赂分配商品的人。 6.2.2 非约束性价格上限 价格上限不一定会导致供给不足。有时候,价格上限会高于市场均衡价格。 6.3 价格下限 价格下限(Price floor):政府规定的商品的最低价格。 假设价格下限高于市场均衡价格,那么就会发生供给过剩。 6.3.1 价格下限对福利的影响 价格下限会造成死重损失,并将消费者的福利转移给生产者。 6.3.2 无效的价格下限 价格下限不一定会导致供给过剩。有时候,价格下限会低于市场均衡价格。 6.4 税收和补贴 税收(Taxes):政府对商品或服务的销售征收的费用。 补贴(Subsidies):政府对商品或服务的销售提供的补助。 税收和补贴可用于纠正市场失灵,并提供激励或抑制措施,使生产量高于或低于均衡生产量。 6.5 税收 税收有着两大影响: 抑制被征税商品的生产和消费 通过继续买卖商品者支付的费用增加政府收入 征税将减少消费,提供新的公共收入来源。 6.5.1 卖方纳税的影响 假设政府对每售出一个单位征收 0.20 美元的税,卖方必须支付。这会对市场产生什么影响? 新的供给曲线会在所有价格上增加 0.20 美元,即税额 税收在买方价格和卖方价格之间制造了一个楔子 均衡数量从 3000 万减少到 2500 万 可以计算出对卖方征税的税收收入和无谓损失: 产生的税收为 税收收入 = 税收 × 数量 \text {税收收入}=\text {税收} \times \text {数量} 税收收入 = 税收 × 数量 税收收入是消费者和生产者向政府的转移 无谓损失来自销售数量的损失 6.5.2 买方纳税的影响 假设政府对售出的每件产品征收 0.20 美元的税,买方必须支付。这会对市场产生什么影响? 新的需求曲线降低了 0.20 美元,也就是税额 税收在买方价格和卖方价格之间制造楔子 均衡数量从 3000 万减少到 2500 万 6.5.3 税收对买卖双方的影响 无论对买方还是卖方征税,税收都会产生四种相同的影响: 均衡数量减少 买方为每个购买单位支付更多,而卖方获得更少 税楔(Tax wedge)形成,相当于买方支付的价格与卖方收到的价格之间的差额 政府获得的收入等于税额乘以新的均衡数量 税收造成无谓损失 6.5.4 税收发生率 假设政府对售出的每件产品征收 0.20 美元的税,卖方必须支付。谁来承担税收负担或税收发生率? 税收发生率(Tax incidence)等于消费者和生产者盈余中用于税收的损失 哪一方市场的价格弹性更大,哪一方就会承担更少的负担 6.6 补贴 补贴有着两大主要影响: 鼓励受补贴物品的生产和消费 政府通过补贴向继续销售商品的生产者提供资金 补贴会增加商品的消费。 6.6.1 补贴造成的损失 假设政府对每售出一件产品补贴 0.35 美元,卖方也获得了补贴。这会对市场产生什么影响? 补贴导致生产过剩 生产过剩造成福利损失 按无谓损失计算 6.6.2 政府补贴支出 对卖方征税造成的自重损失可计算如下: 政府补贴支出 = 补贴 × 数量 \text {政府补贴支出}=\text {补贴} \times \text {数量}政府补贴支出 = 补贴 × 数量 6.6.3 补贴带来的消费者 / 生产者过剩 消费者和生产者过剩都会增加,但不足以抵消补贴成本和无谓损失。 6.7 税收或补贴的影响 能否提前预测税收或补贴对均衡数量的影响?答案是可以的,只要供求的价格弹性已知。 6.8 长期影响与短期影响 政府的税收和补贴政策会对市场产生长期影响和短期影响。 汽油和其价格控制就是一个例子。 在短期内,驾驶习惯很难改变,生产商需要时间来提高产量,因此对数量的影响很小 而从长远来看,驾驶习惯可以改变,生产者可以提高产量。 因此对数量的影响很大。 由于买方和卖方需要时间对价格变化做出反应,有时价格控制的全部效果只有在长期内才能显现出来。 ----- 第七章:消费者行为 7.1 效用基础 效用(Utility):消费者从消费中获得的满足程度。 理性个体在做出选择时会让效用最大化。例如,如果在接下来的一个小时里踢足球比打棒球产生的效用更大,那么理性的人会选择去踢足球。 7.1.1 效用函数 效用是难以衡量的。但是,可以通过观察消费者的选择来揭示他们的喜好。 然而,揭示喜好原则在分析他人会如何作出选择时并不总是有效。相反,需要一种更正式的方法。 效用函数(Utility functions)有助于系统分析选择。它用于计算某个人从消费某种商品和服务组合中获得的总效用。 效用函数会量化偏好,效用测量是相对的而不是绝对的。例如,假设莎拉每吃一份奶酪通心粉就会得到 3 个效用值,西兰花得到 2 个效用值,冰淇淋得到 8 个效用值,那么如果她吃了一份通心粉、两份西兰花和两份冰淇淋,她的总效用就是 23。 7.1.2 边际效用 边际效用(Marginal utility):消费一件商品或服务会增加的效用。 当个人继续从事某项活动或消费更多某种商品或服务时,下一个单位的效用将会不如上一个单位的大。 边际效用递减(Diminish marginal utility)原理:连续消费一个单位的商品或服务所获得的额外效用,往往小于消费前一个单位或服务所获得的效用。 7.1.3 效用 每多消费一个单位的商品所带来的效用可以用一个数字来表示。 在吃完第 8 勺冰淇淋之前,每增加一勺冰淇淋都能提供效用 在吃第 8 勺时,边际效用转为负值 总效用是该勺之前的边际效用之和 7.1.4 边际效用递减 可以绘制出效用函数和边际效用。当总效用最大或边际效用为零时,个人的效用最大化。 7.2 带约束条件的效用最大化 人们有很多需求,但会受到时间和金钱的限制。理性的个体在这些约束条件下通过将资源用于产生最高可能总效用的组合来实现效用最大化。 预算约束(Budget constraint)提供了消费者在收入一定的情况下可以购买的商品和服务的所有可能组合。 7.2.1 预算约束 以电影票价 15 美元、音乐会票价 30 美元和收入 120 美元来显示两种商品的预算约束。 A 点:所有收入都花在电影上(8 张电影票) B 点:收入用于购买两种商品(4 张电影票和 2 张音乐会门票) C 点:用于音乐会的所有收入(4 张音乐会门票) 7.2.2 总效用最大化 以下是消费一定数量的音乐会和电影票所产生的相关效用: 为了使总效用最大化,需要确定每个可行捆绑的总效用,然后相互比较。 包含 1 张音乐会门票和 6 张电影票的捆绑包 B 的总效用最高,是首选捆绑包 在此预算下,理性的消费者会将所有收入用于购买电影票和音乐会门票的组合,从而使效用最大化 7.2.3 应对收入变化 当一个人的收入增加时,就会有更多的商品和服务可以负担得起。自然,当收入减少时,能买得起的捆绑商品就会减少,消费者就有可能需要不得不减少某些商品的消费。 收入变化表现为整个预算线向外移动。 假设某人的收入从 120 美元增至 180 美元。 收入增加使个人能够购买更多商品 整条预算线向外转移 7.2.4 应对价格变化 当价格发生变化时,个人的预算约束会受到两方面的影响: 由于价格下降导致有效财富增加、消费发生变化,从而产生收入效应(Income effect) 替代效应(Substitution effect)是指商品相对价格的变化导致的消费变化 消费某种商品的机会成本随价格变化而变化 假设电影票的价格从 15 美元降至 10 美元。 当一种商品的价格发生变化时,预算约束向外旋转 新的预算线展示了现在可用的新捆绑包 预算线斜率的变化反映了两种商品相对价格的变化 7.3 效用和社会 人们从各种来源获得效用: 外部感知:他人对你购物的看法、他人有多少…… 内部偏好:你喜欢如何花钱…… 外部感知和内部偏好都有助于决策。 7.4 效用、利他主义和互惠 效用最大化包括个人给予他人。利他主义(Altruism)是一种行动动机,在这种动机下,一个人的效用增加,仅仅是因为其他人的效用增加了。 利他和自私或注重形象的动机完全可以共存。 效用最大化建议人们通过惩罚不良行为和奖励良好行为来获得效用。 互惠(Reciprocity)是以类似的行动回应他人的行动 ----- 第十二章:生产成本 12.1 收入、成本和利润 一所公司的目标是实现利润最大化: 利润 = 总收入−总成本 \text {利润} = \text {总收入} - \text {总成本}利润 = 总收入−总成本 总收入(Total revenue)是企业从销售商品和服务中获得的金额,计算方法是销售数量乘以为每个单位支付的价格: 总收入 = 价格 × 销售数量 \text {总收入} = \text {价格} \times \text {销售数量}总收入 = 价格 × 销售数量 总成本(Total cost)是企业为生产商品或服务所投入的成本 总收入的计算很简单,而成本则更为复杂和难以计算。 12.1.1 固定成本和可变成本 企业的总成本定义为: 总成本 = 固定成本 + 可变成本 \text {总成本} = \text {固定成本} + \text {可变成本}总成本 = 固定成本 + 可变成本 固定成本(Fixed costs)是不取决于产出数量的成本。 生产开始前的一次性预付款,如购买设备 可变成本(Variable costs)是指取决于产出数量的成本。 包括原材料和劳动力成本 12.1.2 显性和隐性成本 企业经营的机会成本由两部分组成: 显性成本(Explicit costs)是企业为生产商品或服务而支付的现金支出,由固定成本和可变成本组成 隐形成本(Implicit costs)是企业为生产商品或服务而支付的非现金支出 12.1.3 经济和会计利润 公司报告利润时,提供的是会计利润(Accounting profits): 会计利润 = 总收入−显性成本 \text {会计利润} = \text {总收入} - \text {显性成本}会计利润 = 总收入−显性成本 会计利润可能会误导企业的真实经营状况。 为了考虑隐性成本,经济利润(Economic profit)进一步减去了隐性成本: 经济利润 = 会计利润−隐性成本 \text {经济利润} = \text {会计利润} - \text {隐性成本}经济利润 = 会计利润−隐性成本 12.2 总产量、边际产品和平均产品 企业创造价值的方式是将不同的成分组合在一起,创造出消费者需要的商品或服务。 生产函数(Production function):投入量与产出量之间的关系。 边际产品(Marginal product):增加一单位投入量所产生的额外产出。 边际产品递减(Diminishing marginal product)原理指出,投入品的边际产品随着投入品数量的增加而减少 平均产品是总产量除以工人人数。 12.2.1 生产函数 生产函数可以直观地表示出来。同时,边际产品是总产量曲线的斜率。 当产出很低时,每个新增工人的边际产品都高于前一个工人 随着工人的增加,边际产品开始减少 12.2.2 平均和边际产品 边际产品递减原则和平均产品递减原则都可以直观地表示出来。这会确定边际产品和平均产品之间的关系。 当新增工人的边际产品大于现有平均产品时,平均产品就会增加 当边际产品曲线与平均产品曲线交叉时,平均产品也开始减少 当新增工人的边际产品少于现有平均产品时,平均产品就会减少 12.3 生产成本 当企业通过调整投入品的使用来增加产出时,就会产生与这一决策相关的成本。 产出与成本之间的关系是: 平均固定成本 = 固定成本产出 \text {平均固定成本} = \frac {\text {固定成本}}{\text {产出}}平均固定成本 = 产出固定成本​ 平均可变成本 = 可变成本产出 \text {平均可变成本} = \frac {\text {可变成本}}{\text {产出}}平均可变成本 = 产出可变成本​ 平均总成本 = 平均固定成本 + 平均可变成本 \text {平均总成本} = \text {平均固定成本} + \text {平均可变成本}平均总成本 = 平均固定成本 + 平均可变成本 边际成本 = 变化的总成本变化的产出 \text {边际成本} = \frac {\text {变化的总成本}}{\text {变化的产出}}边际成本 = 变化的产出变化的总成本​ 下表提供了一家披萨店的生产情况和成本,该披萨店需要租赁 300 美元的店面, 工人工资为每人 200 美元。 12.3.1 成本曲线 成本函数可以直观表示: 可变成本(VC)曲线最初变得不那么陡峭,反映出前几名员工的边际产品不断增加 由于边际产品递减原则的作用,可变成本曲线逐渐变得陡峭 平均成本也可以直观地表现出来: 平均固定成本(AFC)呈下降趋势 相同成本分摊到更多产出单位 平均可变成本(AVC)呈 U 型 先减后增,反映投入的边际产品 平均总成本(ATC)曲线呈 U 形 平均固定成本下降,平均可变成本上升 边际成本也可以直观地表示出来: 边际成本(MC)曲线呈 U 型,是边际产品曲线的反形状 每增加一个投入单位,无论其对生产的贡献如何,成本都是一样的 随着劳动边际产品最初的增加,边际成本会下降 随着边际产品的减少,边际成本也会增加 12.3.2 边际成本曲线和平均成本曲线 边际成本和平均总成本之间的关系可以直观地建立起来: 当生产另一个单位的边际成本小于平均总成本时,多生产一个单位都会减少平均总成本 当生产另一个单位的边际成本高于平均总成本时,多生产一个单位将增加平均总成本 边际成本曲线在最低点与平均总成本曲线相交 12.4 长期成本 长期成本(Long-run costs)是企业在所有投入品都是可变的情况下的成本。例如,可以调整工厂尺寸以增加或减少产能。 在短期内,固定投入无法调整。 当企业调整其中一项长期成本时,整个固定成本曲线都会移动,因为企业的效率更高,可以生产更高的产出。 12.5 规模报酬 成本与产出之间的关系取决于生产规模。 如果扩大生产规模以获得更高的产量,可以降低平均总成本的最低值,那么就会产生规模经济(Economies of scale)。 如果扩大生产规模以获得更高的产量,会提高平均总成本的最低值,那么就会出现规模不经济(Diseconomies of scale)。 当平均总成本的最小值不取决于产出量时,规模收益不变(Constant returns to scale)。 当平均总成本达到最小值时,就实现了有效规模(Efficient scale) 12.6 长期平均总成本 长期平均总成本曲线由所有可能的短期平均总成本曲线组合而成,而短期平均总成本曲线由更改生产规模而产生。 不同规模的企业会面临的短期平均总成本 通过扩大或缩小生产规模,企业可以沿着长期平均总成本曲线从一条短期平均总成本曲线移动到另一条短期平均总成本曲线 ----- 第十三章:完全竞争 13.1 完全竞争市场 The characteristics of a perfectly competitve market: 完全竞争市场(Perfectly competitive market):市场中有许多买家和卖家,每个人都是价格接受者(price taker)。 完全竞争市场的特征: 买方和卖方都是价格接受者 商品是标准化的 买方和卖方都有充分的信息 不存在交易成本 13.1.1 市场力量 市场力量(Market power):影响价格的能力。 大多数卖家和买家都无法自行定价,尽管他们可能有一定的定价能力。 13.1.2 完全竞争市场的收入 在完全竞争的市场中,生产者可以在不影响市场价格的情况下,想卖多少就卖多少。 总收入 = 价格−卖出的数量 \text {总收入} = \text {价格} - \text {卖出的数量}总收入 = 价格−卖出的数量 平均收入 = 总收入卖出的数量 \text {平均收入} = \frac {\text {总收入}}{\text {卖出的数量}}平均收入 = 卖出的数量总收入​ 边际收入 = 总收入的变化卖出数量的变化 \text {边际收入} = \frac {\text {总收入的变化}}{\text {卖出数量的变化}}边际收入 = 卖出数量的变化总收入的变化​ 因此: 价格 = 平均收入 = 边际收入 \text {价格} = \text {平均收入} = \text {边际收入}价格 = 平均收入 = 边际收入 13.2 利润和生产决策 13.2.1 利润 企业追求利润最大化。在竞争激烈的市场中,定价企业影响利润的唯一选择就是生产数量。 竞争性企业的利润最大化规则是生产出 边际收入 = 边际成本 \text {边际收入} = \text {边际成本} 边际收入 = 边际成本 的产出量。 13.2.2 决定何时操作 在决定生产数量时,企业还必须决定是否: 继续生产 暂时关闭 完全退出市场 而这一切都是根据利润来决定的: 利润 = 总收入−总成本 \text {利润} = \text {总收入} - \text {总成本}利润 = 总收入−总成本 当企业停产时,就可以避免产生可变成本: 固定成本是不可避免的,因此在决定是否停产时不考虑这些成本 生产的短期决策取决于可变成本,而生产的长期决策取决于总成本。 假设价格跌至 400。生产 边际收入 = 边际成本 \text {边际收入} = \text {边际成本} 边际收入 = 边际成本 的数量可能会使损失最小化。 13.2.3 短期供给曲线和停产规则 短期停产规则:如果 利润 < 平均可变成本 \text {利润} < \text {平均可变成本} 利润 < 平均可变成本 那就不生产。 13.2.4 长期供给曲线和停产规则 由于所有成本在长期内都是可变的,因此,如果 利润 > 平均总成本 \text {利润} > \text {平均总成本} 利润 > 平均总成本,长期停产规则就是退出市场。 13.3 企业和市场供应曲线 企业的供给曲线就是其边际成本。市场供给曲线就是各公司供给曲线的总和。 13.3.1 长期供应 短期供应与长期供应的主要区别在于:企业在长期内可以进入和退出市场。 如果存在正经济利润: 利润 > 平均总成本 \text {利润} > \text {平均总成本} 利润 > 平均总成本 新公司进入以获取利润 市场供应曲线向外移动,直至 利润 = 平均总成本 \text {利润} = \text {平均总成本} 利润 = 平均总成本 所有企业的经济利润为零 而如果存在负经济利润: 利润 < 平均总成本 \text {利润} < \text {平均总成本} 利润 < 平均总成本 部分企业退出以避免损失 市场供应曲线向内移动,直至 利润 = 平均总成本 \text {利润} = \text {平均总成本} 利润 = 平均总成本 所有企业的经济利润为零 市场进入和退出过程导致了完全竞争市场中的企业长期经济利润为零。 长期中,利润 =min⁡(平均总成本)= 边际收入 = 边际成本 \text {利润} = \min {(\text {平均总成本})} = \text {边际收入} = \text {边际成本} 利润 =min(平均总成本)= 边际收入 = 边际成本 会计利润长期为正数 企业赚取会计利润,以补偿其机会成本 由于所有企业都在 利润 =min⁡(平均总成本)\text {利润} = \min {(\text {平均总成本})} 利润 =min(平均总成本) 这一点上运营,因此竞争市场中的企业都以有效规模运营。 鉴于 利润 =min⁡(平均总成本)\text {利润} = \min {(\text {平均总成本})} 利润 =min(平均总成本),在长期内,任何数量的价格都是相同的。 如果有任何因素导致市场平衡偏离这一价格,那么由此产生的正或负利润都会导致企业进入或退出市场,直到恢复零经济利润为止 为什么长期市场供给曲线不应该向上倾斜,但却向上倾斜了? 竞争性市场理论认为,市场供应曲线应始终保持完全弹性。 大多数长期市场供给曲线(Long-run market supply curves)都是向上倾斜的。这是由于假设所有公司都具有相同的成本结构。 如果新进入者的成本高于现有企业,则价格必须上涨到足以吸引新企业进入市场的程度。 13.3.2 长期经济利润 竞争性市场理论认为,从长远来看,所有企业的经济利润都应为零。 实际上,只有市场上效率最低的企业的 价格 =min⁡(平均总成本)\text {价格} = \min {(\text {平均总成本})} 价格 =min(平均总成本)。 通常而言,这是最后进入市场的公司 平均总成本较低的企业效率较高,从长期来看可获得正经济利润 13.3.3 因生产成本变化而进入市场 技术和生产能力的提高降低了边际成本和平均总成本。 创新型企业寻求更好的生产工艺和新技术,从而以更低的成本生产商品 正利润会吸引投资者 价格随生产成本下降 13.3.4 应对需求的变化 假设对完全竞争商品的需求增加,短期和长期会发生什么情况? ----- Chapter 14: Monopoly Monopoly: a firm that is the only producer of a good or service with no close substitutes. a firm is a perfect monopoly if it controls the entire market a firm has monopoly power if it can manipulate the price Monopolies exist because of barriers to entry that prevent other firms from entering the market. Scarce resourcesEconomies of scale Governmental interventionAggressive business tactics Natural monopoly: a market where a single firm can produce the entire market quantity demanded at a lower cost than multiple firms. 14.1 Monopolists and Demand Curve Monopolies differ from perfectly competitive firms with regard to their demand curves. 14.2 Monopoly Revenue Monopolist produces more of a good -> market price is driven down. Therefore, producing an additional unit of output has two effects on revenue: Quantity effect: total revenue increases Price effect: total revenue decreases In monopoly markets, a firm is the only producer and faces a downward-sloping demand curve. 14.3 Monopoly Profit-maximizing Quantity Monopolist maximizes profit by producing the quantity where marginal revenue equals marginal cost. 14.4 Problems with Monopoly Monopoly power benefits monopolists but causes social welfare losses. Monopolists produce at a lower quantity than the efficient level. total surplus is not maximized producer surplus increases consumer surplus decreases the loss of total surplus is a deadweight loss equal to the total surplus under perfect competition minus the total surplus under monopoly Deadweight loss=Total surplus under perfect competition−Total surplus under monopoly\text{Deadweight loss} = \text{Total surplus under perfect competition} - \text{Total surplus under monopoly}Deadweight loss=Total surplus under perfect competition−Total surplus under monopoly 14.5 Comparing Market Characteristics CharacteristicPerfect competitionMonopoly Number of firmsManyOne Price taker or makerPrice takerPrice maker Marginal revenueEqual to priceLess than price ----- Chapter 17: International Trade A country has an absolute advantage if it can produce more of a good or service than other countries. A country has a comparative advantage if it can produce a good or service at a lower opportunity cost than other countries. Example: U.S. and Bangladeshi produce wheat and T-shirts. without specialization and trade -> able to produce on their PPF -> still inefficient. with specialization and trade -> coordinate their production and produce more goods. 17.1 Roots of Comparative Advantage Comparative advantage: the ability to produce a good or service at a lower opportunity cost than other producers. Characteristics that affect the costs of production: Technology Factor endowment Natural resources and climate 17.2 From Autarky to Free Trade Autarky economy: an economy that is self-contained and does not engage in trade with outsiders. does not export or import any goods or services imports: goods and services that are produced in other countries and consumed domestically exports: goods and services that are produced domestically and consumed in other countries the domestic price and quantity is determined by the intersection of the domestic supply and domestic demand curves When an economy decides to engage in trade, the domestic price and quantity change. if the world price is less than autarky domestic price: domestic price decreases to equal the world price excess demand occurs imports make up the difference between domestic supply and demand, eliminating the shortage Consumer and producer surpluses are affected. 17.2.1 Net-Importer or Net-Exporter the world price is greater than autarky domestic price domestic producers can earn higher profits by exporting their goods abroad the country is a net-exporter of airplanes 17.2.2 Big Economy, Small Economy if a country is relatively "big", then it can influence the world price when the U.S. moves from autarky to free trade, the world demand curve shifts to the right because U.S. consumers have entered the market world demand shifts right world supply shifts right on net, because world demand shifted more than world supply, the world price increases For many goods, the price goes down, and the country as a whole is better off, but producers lose out. 17.3 Restrictions on Trade Laws limiting trade are referred to as trade protection: protectionism: a preference for policies that limit trade trade liberalization: policies and actions that reduce trade restrictions 17.4 Tariffs Tariff: a tax targeted at certain imports. its purpose is to reduce the quantity of imports to protect domestic producers tariff increases the world price for domestic consumers tariff decreases the amount of shortage made up by imports 17.4.1 Domestic Welfare Effects of a Tariff Example: A tariff of $t\$t$t per unit is imposed on foreign copper. MeasureBeforeAfterChange CSA+B+C+D+E+FA + B + C + D + E + FA+B+C+D+E+FA+BA + BA+B−(C+D+E+F)-(C+D+E+F)−(C+D+E+F) PSGGGG+CG+CG+C+C+C+C Gov't RevEEE+E+E+E DWLD+FD+FD+F+(D+F)+(D+F)+(D+F) 17.5 Quotas Quota: a limit on the amount of a particular good that can be imported. Quota rents: profits earned by foreign firms or governments under a quota. A quota has the following effects: decreases imports increases import price increases producer surplus by CCC decreases consumer surplus by C+D+E+FC + D + E + FC+D+E+F foreign companies earn quota rents of EEE deadweight loss of D+FD + FD+F occurs 17.6 International Labor and Capital Although countries gain from trade liberalization, certain segments of the population lose out. Free trade increases demand for factors of production that are domestically abundant, and it increases the supply of factors that are domestically scarce. causes factor prices to converge across countries owners of domestically scarce factors of production lose due to increased competition, and owners of domestically abundant factors gain from increased demand Each country has its own set of laws and policies governing the economy. The World Trade Organization is an international organization designed to monitor and enforce trade agreements while also promoting free trade import standards and the fair trade movement are ways in which countries

2023/10/17
articleCard.readMore

CS50AI Python 笔记

仅作个人用途。CS50AI 课程的笔记。 0. Search 什么能够制造 AI: 搜索:寻找问题的解决方案 知识:展示信息和相关知识 不确定性:使用概率来解决不确定的事件 优化:不单是找到解决问题的正确方案,而是找到更好更棒的解决方案 学习:基于对数据和经验的获取而提高性能 神经网络:一种受人脑启发的程序结构,能够有效地执行任务 语言:处理由人类产生和理解的自然语言 搜索 搜索问题设计了一个代理,它: 被赋予一个初始状态 被赋予一个目标状态 返回一个如何从前者到后者的解决方案 解决搜索问题 解决方案是从初始状态到目标状态的一连串行动。 而最佳解决方案,是一个在所有解决方案中具有最低路径成本的解决方案。 在搜索过程中,数据通常被存储在一个节点(node)中。节点是一个数据结构,包含: 一个状态 父节点 应用于父节点状态的行动,以达到当前节点的目的 从初始状态到这个节点的路径成本 节点包含的信息对搜索算法的目的很有用。不过节点仅持有信息。为了实际搜索,我们使用前沿(frontier),即管理节点的机制。 前沿开始时包含一个初始状态和一个空的已探索集合,然后重复直到达到一个解决方案: 如果前沿是空的: 停止。这个问题没有解决方案 从前沿中移除一个节点,该节点会被考虑 如果节点持有目标状态: 返回解决方案。停止 否则: 扩展节点(找到所有可以从这个节点到达的新节点) 将产生的节点添加到前沿 将当前节点添加到已探索集合中 上述没有提到哪个节点应该被移除。这个选择对解决方案的质量和实现的速度都有影响。有多种方法可以解决哪些节点应该被首先考虑: 堆栈/深度优先搜索(stack/depth-first search) 在尝试另一个方向之前会穷尽每一个方向 前沿被作为一个堆栈数据结构来管理 「后进先出」 当节点被添加到前沿之后,第一个要移除和考虑的节点是最后被添加的节点 在第一个方向上尽可能地深入,而把所有其他方向留给以后 例子:你在寻找钥匙 选择从裤子开始搜索 先翻遍每一个口袋 当裤子的每个口袋里都找完了,再停止对裤子的搜索 搜索其他地方 优点: 在最好的情况下,速度最快 缺点: 找到的解决方案有可能并不是最优的 在最坏的情况下,可能在找到解决方案之前就探索了所有可能的路径,导致花费了更长的时间 def remove(self): if self.empty(): # 如果前沿是空的,终止搜索 raise Exception("空前沿") else: # 保存最新添加的节点 node = self.frontier[-1] # 从前沿里移除最新添加的节点 self.frontier = self.frontier[:-1] return node 队列/广度优先搜索(queue/breadth-first search) 同时遵循多个方向 在每个可能的方向上走一步,然后在每个方向上走第二步 前沿被作为一个队列数据结构来管理 「先进先出」 所有的新节点都是排队增加的 节点被考虑的依据是哪一个先被添加 例子:你又在找钥匙 从裤子开始搜索 从裤子右边口袋里寻找 在抽屉里看一看 在桌子上看 用尽了所有的位置之后,回到裤子,寻找下一个口袋 优点: 保证能找到最优解 缺点: 运行的时间比最小时间长 在最坏的情况下,这个算法需要尽可能长的时间来运行 def remove(self): if self.empty(): # 如果前沿为空,终止搜索 raise Exception('空前沿') else: # 保存第一个被添加的节点 node = self.frontier[0] # 从前沿中移除第一个节点 self.frontier = self.frontier[1:] return node 贪婪的最佳优先搜索(greedy best-first search) 广度优先和深度优先都是无信息的搜索算法。这些算法不利用任何它们没有通过自己的探索而获得的关于问题的知识。然而,关于问题的一些知识实际上是可用且常用的。 扩展了最接近目标的节点 该行为由启发式函数(heuristic function)决定 函数估计下一个节点离目标有多近,但可能是错误的 A* 搜索(A* search) 作为贪婪的最佳优先算法的发展,A* 搜索不仅考虑了启发式函数 h(n)h(n)h(n),还考虑了直到当前位置的累计成本 g(n)g(n)g(n)。结合这两个值,该算法确定解决方案的成本更精准,还会优化其在路上的选择。 该算法会一直跟踪 到现在位置的路径成本 + 到目标的估计成本。一旦超过之前某个选项的估计成本,该算法就会抛弃当前的路径,回到之前的选项,从而避免自己走一条被 h(n)h(n)h(n) 错误地标记为最佳、实际为漫长且低效的路径。 这个算法也依赖着启发式。某些情况下,效率要比贪婪的最佳优先搜索低,甚至低于无信息的算法。为了使 A* 搜索成为最优,启发式函数应该是: 可接受的 / 永远不会高估真实成本 一致性 h(n)<=h(n′)+ch(n)<= h(n')+ ch(n)<=h(n′)+c 对抗性搜索(adversarial search) 该搜索中,算法面临着一个试图实现相反目标的对手。例如井字棋。 极小化极大(minimax) 对抗性搜索中的一种算法: 将一方的获胜条件表示为 -1,另一方的获胜条件为 +1 进一步的行动将由这些条件驱动 井字棋 AI: s0s_0s0​ 代表初始状态(一个空的 3x3 棋盘) Players(s):参数 s 是一个状态;返回哪个玩家的回合(X 或者 O) Actions(s):参数 s 是一个状态;返回当前状态所有有效的移动(棋盘上哪些格子是空闲的) Results(s,a):参数 s 和 a 分别是一个状态和一个行动;返回一个全新的状态(在状态 s 上执行行动 a 后的棋盘) Terminal(s):参数 s 是一个状态;检查是否是棋盘中最后一次行动(是否有人胜出或平局);如果游戏结束返回 True,反之返回 False Utility(s):参数 s 是一个临终状态;返回该状态的效用值:-1、0、1 根据状态(本回合轮到谁),该算法可以知道当前的玩家在进行优化游戏时是否会选择导致了一个较低 / 较高数值的状态的行动 在最小化和最大化之间交替进行 为每个可能的行动所导致的状态创造值 Function Max-Value(state):v = -∞if Terminal(state):return Utility(state)for action in Actions(state):v = Max(v,Min-Value(Result(state,action)))return v Function Min-Value(state): v = ∞ if Terminal(state): return Utility(state) for action in Actions(state): v = Min(v,Max-Value(Result(state,action))) return v Alpha-beta 剪枝(alpha-beta pruning) 作为优化极小化极大的一种方式,Alpha-beta 剪枝跳过了一些明显不利的递归计算。 在确定了一个行动的值后,如果有初步证据表明之后的行动可以使对手得到比已经确定的行动更好的分数,那么就没有必要进一步研究这个行动,因为它将决定性地比之前确定的行动更不利。 例子: 一个最大化的棋手知道:在下一步,最小化的棋手将试图达到最低分数。 假设最大化的棋手有三个可能的行动,第一个行动的值为 4。为此,如果当前棋手实施了该行动,棋手会生成最小化者的行动值,因为他知道最小化者会选择最低的行动。 然而,在完成对最小化者的所有可能行动的计算之前,棋手看到其中一个选项的值为 3。这意味着对于最小化者来说,没有理由继续探索其他可能的行动。尚未取值的行动的值并不重要,无论是 10 还是 - 10。 如果值是 10,最小化者就会选择最低的选项,也就是 3,这已经比预先确定的 4 更糟糕。因此,此时计算最小化者的额外可能行动与最大化者无关,因为最大化者已经有一个明确的更好的选择,即 4。 深度有限的极小化极大(depth-limited minimax) 井字棋一共有 255168 个可能,而国际象棋有 1029010^{290}10290 个可能。到目前为止,极小化极大算法要求生成从某一点到目标条件的所有假设。虽然计算所有的井字棋游戏假设对现代计算机来说并不困难,但如果在国际象棋里呢? 深度限制的极小化极大在停止前只考虑先定义的棋步数,而不去考虑临终状态。然而这并不允许为每个行动获取一个精确的数值,因为假想游戏的终点还未达到。 为了处理这个问题,深度有限的极小化极大依赖于一个评估函数,该函数估计了从给定状态开始的游戏的预期效用,或者换句话说,为状态赋值。 例子: 在国际象棋中,一个效用函数将棋盘的当前配置作为输入,试图评估其预期效用(基于每个玩家拥有的棋子和它们在棋盘上的位置),然后返回一个正值或负值,代表棋盘对一个玩家与另一个玩家的有利程度。 这些值可以用来决定正确的行动。评估函数越好,依赖它的极小化极大算法就越好。 作业 Degrees 根据 Six Degrees of Kevin Bacon 游戏,好莱坞电影界的任何人都可以在 6 个步骤内与 Kevin Bacon 联系起来,其中每个步骤都包括找到两个演员都出演的电影。 问题:通过选择一连串的电影,找到任何两个演员之间的最短路径。 例子:Jennifer Lawrence 和 Tom Hanks 的最短路径为 2。 Jennifer Lawrence 和 Kevin Bacon 都出演了《X-Men: First Class》 Kevin Bacon 和 Tom Hanks 都出演了《Apollo 13》 分发代码 large 目录下的 csv 数据文件 small 目录下的 csv 数据文件(用于测试): 在 small 目录下的 people.csv 中,能看到每个人都有一个独特的 id、他们的名字和生日;在 movies.csv 中,每个电影都有一个独特的 id、它们的标题和发布的年份;在 stars.csv 中,每一行都是一组人的 id 和电影的 id。 例子:人的 id 为 102,电影的 id 为 104257。这代表了 Kevin Bacon 出演过《A Few Good Men》。 degrees.py。在最上方一些数据结构被定义(names、people、movies),这些数据结构将保存从 CSV 文件中的信息。 names 字典,将名字映射到一组对应的 id(可能有多个演员有相同的名字) people 字典,将人 id 映射到另外一个字典(存有名字、出生年份、主演的所有电影的集合) movies 字典,将电影 id 映射到另外一个字典(存有标题、发行年份、所有出演演员的 id) 文件已经事先定义了一个函数 load_data(),它从 CSV 文件里加载数据,并存在上述这些数据结构中。 主函数 main() 首先将数据加载到内存中(加载数据的目录需要通过命令行参数指定)。之后函数会提示用户输入两个名字,另一个函数 person_id_for_name() 会检索这个名字并返回 id(如果有多个演员叫这个名字,就会提示用户找出正确的 id)。然后主函数会调用 shortest_path() 来计算这两个人之间的最短路径,并打印出这个路径。 然而,shortest_path() 要你来写。 目标 完成 shortest_path() 的实现,使其返回源 id 到目标 id 的最短路径 假设有一条从源 id 到目标 id 的路径,你的函数应该返回一个列表,每个元素是一个元组:(movie_id, person_id) 如果 shortest_path() 返回了 [(1, 2), (3, 4)],那就意味着源 id 和人 2 一起出演了电影 1,人 2 在电影 3 中和人 4 出演,而人 4 是目标 如果从源 id 到目标 id 之间有多条最小长度的路径,你的函数可以返回其中的任何一条 如果两个演员之间没有可能的路径,你的函数应该返回 None 你可以调用 neighbors_for_person(),该函数接收一个人的 id 作为参数,并返回装有 (movie_id, person_id) 元组的集合(这些人都和提供的人出演过电影) 作业 Tic-Tac-Toe 分发代码 项目中有两个主要文件:runner.py 和 tictactoe.py,后者包含了所有玩游戏、做出最佳动作的逻辑,而前者包含了所有运行游戏的代码。 tictactoe.py 定义了三个变量:X、O、EMPTY,代表了棋盘上可能出现的移动。 函数 initial_state() 返回棋盘的起始状态。将棋盘表示为三个列表以代表三行,其中每个内部列表都包含了我们刚定义的变量的值。 目标 完成 player()、actions()、result()、winner()、terminal()、utility(),和 minimax() 这些函数。 player() 接受棋盘状态作为输入,然后返回当前回合属于哪个棋手 棋盘初始化时,棋手 X 先手下棋 action() 返回一个装有在当前棋盘上可以采取的所有行动的集合 每个行动是一个元组 (i, j),其中 i 对应行,j 对应一行中的单元格 可以采取的行动代表的是在棋盘上任何尚未有 X 或者 O 的单元格 result() 接受一个棋盘和一个行动作为输入,然后返回一个新的棋盘状态 如果动作并不有效,程序应该引发一个异常 winner() 接受一个棋盘作为输入。如果已经有人胜出,返回赢家 假设棋手 X 赢了,就返回 X 胜利目标:在水平、垂直或对角线上连续走了三步 如果平手,则返回 None terminal() 接受一个棋盘作为输入,然后返回一个布尔值,表示游戏是否结束 utility() 接受一个结束了的棋盘作为输入,并输出该棋盘的效用 如果棋手 X 胜利,效用为 1;如果棋手 O 胜利,效用为 -1;如果平局,效用为 0 只有在 terminal(board) 为 True 时才会调用该函数 minimax() 接受一个棋盘作为输入,并返回棋手在棋盘上的最佳行动 返回的应该是最优行动 (i, j) 如果多个行动是最优的,那么任意一个行动都可以被返回 如果棋盘是结束了的棋盘,返回 None 1. Knowledge 人类根据现有的知识进行推理并得出结论。人工智能也可以从知识里得出结论。 什么是」基于知识进行推理以得出结论「? 考虑以下这些句子: 如果没有下雨的话,哈利今天就会去拜访海格了 哈利今天拜访了海格或邓布利多,但没有同时拜访 哈利今天拜访了邓布利多 根据这三个句子,我们可以回答」今天下雨了吗?「这个问题,尽管没有一个单独的句子告诉我们关于今天是否下雨的信息。我们可以这样做:看句子 3,得知哈利拜访了邓布利多;看句子 2,得知哈利拜访了海格或邓布利多。因此我们可以得出结论: 哈利没有拜访海格 现在再去看句子 1,我们明白,如果没有下雨,哈利就会去拜访海格。了解了句子 4 后,我们可以得出结论: 今天下雨了 逻辑,我们使用了逻辑来得出结论。人工智能该如何使用逻辑在现有信息的基础上得出新的结论? 句子是人工智能存储知识并使用它来推断新信息的方式。 命题逻辑 命题逻辑以命题为基础,即可以是真的、也可以是假的、关于世界的陈述。例如上述的句子 1~5。 命题符号 最常见的是字母(P、Q、R),用于表示一个命题 逻辑连接词 连接命题符号的逻辑符号,以便以更复杂的方式对世界进行推理 ¬,」不「符号,用于反转命题的真值。例如 P 是」正在下雨「,¬P 就是」没有下雨「 ^,」和「符号,用于连接两个不同的命题 ∨,「或」符号,只要其中一边是真,那就是真 1. 排他性“或”:如果`P^Q`为真,那`P∨Q`就是假;排他性语句只要求其中一个参数为真2. 包容性“或”:只要`P`、`Q`、`P^Q`为真,那就是真我们会更倾向于使用包容性“或”。> 更多的例子:>> - 包容性“或”:为了吃甜点,你必须清理你的房间或者修剪草坪> - 如果你清理了房间,还修剪了草坪,你仍然会吃到甜点> - 排他性“或”:甜点的话,你可以吃饼干或者吃冰淇淋> - 你不能同时吃到饼干和冰淇淋> - 排他性“或”通常被简称为XOR,符号是⊕ →,「暗示」符号,表示「如果 P 那么 Q」的结构。例如 P 是「正在下雨」,Q 是「我在室内」,那么 p→Q 意味着「如果正在下雨,那么我在室内」。这个案例中,P 被称为前项(antecedent),Q 被称为后项(consequent) 如果前项为真,后项为真,那么整个暗示就为真 如果前项为真,后向为假,那么整个暗示就为假 然而,当前项为假,后项为真时,暗示总是真的 ↔,「双条件」符号,或「如果 & 只有当」。P↔Q 相当于 P→Q 和 Q→P。引用上述的例子,P↔Q 就是「如果正在下雨,那么我在室内」和「如果我在室内,那么正在下雨」 模型 模型是对每个命题的真值的分配,而命题是关于世界的陈述,可以是真也可以是假。然而,关于世界的知识就体现在这些命题的真值中。模型是提供关于世界的信息的真值分配。 例如,P 是「正在下雨」,Q 是「今天是周二」,一个模型可以是以下的真值赋值:{P=True, Q=False}。这个模型意味着正在下雨,但今天不是周二。在这种情况下还有更多可能的模型,例如 {P=True, Q=True},也就是现在既下雨,也是周二。 事实上,可能的模型的数量是命题的数量的两倍。如果我们有两个命题,那就有 222^222 个可能的模型。 知识库 知识库是一组被基于知识的代理所知道的句子,也就是人工智能以命题逻辑句子的形式提供的关于世界的知识,可以用来对世界进行额外的推断。 蕴涵(⊨) 如果 α⊨β,那么在任何 α 为真的世界中,β 也是真的。 例如 α 是「这是一月中的一个周二」,β 是「这是一个周二」,然后 α⊨β。那么我们能知道这是一个在一月内的周二,并且这是一个周二 蕴含和暗示不同。暗示是两个命题间的逻辑连接词,而蕴含是一种关系,意味着如果 α 中所有的信息都是真,那么 β 中所有的信息也都是真 推理 推理是指从旧的句子中推导出新的句子的过程。 例如上述的哈利波特例子中,句子 4~5 是句子 1~3 中推断出来的。 有多种方法可以在现有知识的基础上推断出新的知识。首先,我们将考虑模型检查算法(Model Checking algorithm)。 为了确定 KB⊨α(换言之,回答问题「我们能否根据我们的知识库得出 α 为真的结论」): 列举所有可能的模型 如果每个模型中 KB 为真,那么 α 也都是真的,因此 KB⊨α 再考虑这个例子: P 是「今天是星期二」,Q 是「现在在下雨」,R 是「哈利要去跑步」 KB:(P^¬Q) → R,用话说就是:P 和 ¬Q 暗示了 R。P 是真,Q 是假,那么 R 是真还是假?KB 是否 ⊨R? 先列举出所有可能的模型 命题值 P QRfalse false false P QRfalse false true P QRfalse true false P QRfalse true true P QRtrue false false P QRtrue false true P QRtrue true false P QRtrue true true 通过每一个模型,检查它在我们的知识库中是否为真 首先,我们知道 P 为真,因此所有 P 为假的模型里,KB 都为假。同样地,我们知道 Q 为假,因此所有 Q 为真的模型里,KB 都为假。最终我们只剩下两个模型,P 都是真且 Q 都是假。我们知道 R 是真,因此最后 R 为假的模型中,KB 自然也是假 再来看看我们的表,我们只有一个 KB 为真的模型。根据蕴含的含义,得出 KB⊨R 这些知识和逻辑可以被这样写: from logic import *# 创建新的类,每个类都有一个名字或符号,代表命题rain = Symbol("rain") # 在下雨hagrid = Symbol("hagrid") # 哈利见海格dumbledore = Symbol("dumbledore") # 哈利见邓布利多# 将句子保存进知识库# 先使用“和”逻辑,因为每个命题都代表了我们已知为真的知识knowledge = And( Implication(Not(rain), hagrid), # ¬(在下雨)→(哈利见海格) Or(hagrid, dumbledore), # (哈利见海格)∨(哈利见邓布利多) Not(And(hagrid, dumbledore)), # ¬(哈利见海格^哈利见邓布利多),但没有见两人 dumbledore # 哈利见邓布利多(事实)) 要运行模型检查算法,我们需要这些信息: 知识库,用于推理 一个查询,或我们感兴趣的命题是否被 KB 所包含 一个装有所有被使用的符号(或原子命题)的列表(也就是我们案例中的 rain、hagrid、dumbledore) 模型 def check_all(knowledge, query, symbols, model): # 如果模型有分配给每个符号 # 逻辑:从一个符号列表开始。函数递归时,每次的调用都会从列表中删除一个符号,并从中生成模型。当符号列表为空时,我们知道我们已经完成了对符号的每一个可能的真值分配的模型生成 if not symbols: # 如果在模型中的KB为真,查询也就是真 if knowledge.evaluate(model): return query.evaluate(model) return True else: # 选择剩余的未使用符号的其中一个 remaining = symbols.copy() p = remaining.pop() # 创建一个符号为真的模型 model_true = model.copy() model_true[p] = True # 创建一个符号为假的模型 model_false = model.copy() model_false[p] = False # 确保两个模型中都有蕴含关系 return( check_all(knowledge, query, remaining, model_true) and check_all(knowledge, query, remaining, model_false) ) 我们只对 KB 为真的模型感兴趣。如果 KB 为假,那么这个模型就和我们的案例无关 例子: P 为「哈利扮演 seeker」、Q 为「奥利弗扮演 keeper」,R 为「格兰芬多获胜」 KB 规定 P Q (P ^ Q) → R 也就是 P 为真,Q 为真,那么 R 也为真 想象一个模型,哈利扮演的是 beater 而非 seeker,也就是 P 为假。这种情况下我们并不关心格兰芬多是否胜利(即 R 是否为真)。因为我们只对 P 和 Q 为真的模型感兴趣 此外,check_all() 的工作方式是递归的。也就是说它会选择一个符号,创建两个模型,其中一个符号为真,另一个符号为假,然后再次调用自己,现在有两个模型因这个符号的真值分配而不同。这个函数会一直这样做,直到所有的符号都在模型中被分配了真值、让列表中的符号为空。一旦列表为空,函数会在每个实例中检查模型中的 KB 是否为真。如果为真,函数就会检查查询是否为真,如前面所述 知识工程 知识工程是弄清如何在人工智能中表示命题和逻辑的过程。 来看看例子:游戏 Clue。 在这个游戏中,一个人用一个工具在一个地方犯下了一起谋杀案 人、工具、地点都用卡片表示 每个类别的卡片被随机抽取并装入一个信封,由参与者来揭开谁是凶手 参与者通过揭开卡片并从这些线索中推断出信封里一定有什么 我们将使用模型检查算法来揭开这个谜团。在我们的模型中,我们把我们知道的、与谋杀案有关的项目标记为真,其余的都为假。 假设我们的游戏是这样的: 有三个人:芥末、梅子、猩红 有三个工具:刀、左轮手枪、扳手 有三个地点:舞厅、厨房、图书馆 我们可以通过添加游戏规则开始创建我们的知识库: 我们肯定的是:有一个人是凶手,使用的是一种工具,而且谋杀发生在一个地点。命题逻辑: (芥末∨梅子∨猩红) (刀∨左轮手枪∨扳手) (舞厅∨厨房∨图书馆) 游戏开始时,每个玩家会看到一个人、一个工具、一个地点,并且得知这些与谋杀没有联系。玩家们不会共享从卡片中看到的信息。假设我们的玩家得到了芥末、厨房和左轮手枪的卡片。因此我们可以添加这些命题逻辑进我们的知识库: ¬(芥末) ¬(厨房) ¬(左轮手枪) 在游戏的其他情况下,人们可以进行猜测,提出人、工具、地点的一种组合。假设推测是「猩红用扳手在图书馆作案」。如果这个推测是错误的,那么可以推导出以下内容: (¬猩红∨¬图书馆∨¬扳手) 现在有人给我们看到了梅子的卡片,我们可以添加这个到我们的知识库: ¬(梅子) 这时我们可以得出结论,凶手是猩红,因为我们有证据表明另外两个人不是 只要在增加一个知识,比如说,不是舞厅,就可以给我们更多的信息: ¬(舞厅) 现在利用之前的多个数据,我们可以推断出是猩红在图书馆内用刀杀了人。然而这个猜测是错的,因为我们没有证据表明凶器具体是什么。最终的结论是刀 整个流程在 Python 中就是: # 添加线索至知识库knowledge = And( # 添加游戏起始条件 Or(mustard, plum, scarlet), Or(ballroom, kitchen, library), Or(knife, revolver, wrench), # 添加我们所看到的第一组卡片 Not(mustard), Not(kitchen), Not(revolver), # 添加他人举出的猜测 Or(Not(scarlet), Not(library), Not(wrench)), # 添加我们之后得知的线索 Not(plum), Not(ballroom)) 我们还可以看看另外一个逻辑谜题: 有四个不同的人:吉尔德洛依、波莫纳、密涅瓦、霍勒斯 他们被分配到四个不同的学院:格兰芬多、赫夫帕夫、拉文克劳、斯莱特林 每个学院正好有一个人 首先,这个谜题的条件很难被命题逻辑来表示,因为每一个可能的分配都是一个命题。其次为了表示每个人都属于某个学院,就需要 Or() 来包含所有每个人可能的学院分配情况。接着,为了说明如果有一个人被分配到一个学院,他们就不会被分配到其他学院,我们还要给每个人写一大堆命题。 另一种可以用命题逻辑解决的谜题是 Mastermind。 玩家 1 按照一定的顺序排列颜色,玩家 2 要猜出该顺序 每个回合里,玩家 2 做出猜测,玩家 1 给出一个数字,表示玩家 2 猜对了多少种颜色 假设玩家 2 猜测「红蓝绿黄」,玩家 1 给出数字 2 玩家 2 仅切换 2 个颜色的位置:「蓝红绿黄」,玩家 1 给出数字 0 借此得知,红蓝原先的位置是正确的,这时切换另外 2 个颜色的位置:「红蓝黄绿」,玩家 1 给出数字 4,代表游戏结束。 用命题逻辑来表示这一点,需要我们有 (颜色的数量)2(\text {颜色的数量})^2(颜色的数量)2 个原子命题。因此在有四种颜色的情况下,我们将有红 0、红 1、红 2、红 3、蓝 0…… 这些命题代表着颜色和位置 用命题逻辑表示游戏规则,也就是每个位置只有一种颜色,而且没有颜色重复,并将其加入知识库 最后将我们拥有的所有线索添加到知识库中 推理规则 模型检查不是一种有效的算法,因为它必须在给出答案之前考虑每一种可能的模型。 如果是 KB 为真的模型,那么查询 R 也是真的 推理规则允许我们在现有知识的基础上生成新的信息,而无需考虑每一个可能的模型。推理规则通常使用一个横条来表示,横条将上面的部分(前提)和下面的部分(结论)分开。前提是我们拥有的任何知识,而结论是基于前提可以产生的知识。 肯定前件 肯定前件(Modus Ponens)就是在我们知道一个暗示、其前项是真的,那么后项也是真的。 连词消除 也被称为和消除(And Elimination)。 如果一个「和」的命题为真,那么其中任何一个原子命题也是真。 例子: 已知:哈利是罗恩和赫敏的朋友 结论:哈利是赫敏的朋友 双重否定 一个命题如果被否定了两次,那它就是真。 例子:哈利没有通过考试是不真实的。 (哈利没有通过考试)是不真实的 ¬(哈利没有通过考试) ¬(¬(哈利没有通过考试)) 双重否定抵消了彼此,让该命题「哈利通过了考试」变为真 暗示消除 暗示相当于被否定的前项和后项之间的「或」关系。 例子:以下等同于彼此 如果下雨了,那么哈利在室内 (没有下雨)或(哈利在室内) 由于 P→Q 和 P∨Q 有相同的真值分配,因此在逻辑上是等价的。 另一种思考方式是如果满足两个可能的条件中的任何一个,暗示就是真的: 如果前项为假,那么暗示为真 ¬P∨Q:如果 P 为假,那么这个命题总是真的 如果前项为真,只有当后项为真时,暗示才为真 如果 P 和 Q 都是真,那么 ¬P∨Q 就为真 如果 P 为真,Q 为假,那么 ¬P∨Q 就为假 双条件消除 一个双条件命题等同于一个暗示命题和其翻转命题用「和」连接在一起。 例子:以下等同于彼此。 只有在哈利在室内的时候,外面才会下雨 (如果下雨了,那么哈利在室内)和(如果哈利在室内,那么外面在下雨) 德摩根定律 有可能把一个「和」连接词变成一个「或」连接词。例如:「哈利和罗恩都通过了测试,这不是真的」,可以得出结论:「哈利通过考试不是真的」或者「罗恩通过考试不是真的」。也就是说,要使前面的「和」命题为真,「或」命题中的至少一个命题必须为真。 同样地,也可以得出相反的结论。例如命题「哈利或罗恩通过测试是不真实的」可以被改写为「哈利没有通过测试」和「罗恩没有通过测试」。 分配律 一个由两个元素组成的命题,如果使用「和」或「或」连接词来分组,那就可以被分配或分解成由「和」和「或」组成的更小的单元。 知识和搜索问题 推理可以被看作是一个具有以下属性的搜索问题: 初始状态:起始初始库 行动:推理规则 过渡模型:推理中的新知识库 目标测试:检查我们要证明的语句是否在知识库内 路径成本函数:证明中的步骤数量 这些都表示了搜索算法的多功能性,使我们能够在现有知识的基础上利用推理规则得出新的信息。 归结原理 归结原理(resolution)是一个强大的推理规则。如果「或」命题中的两个原子命题中的一个为假,那么另外一个就必须为真。 归结原理依赖于互补文字(complementary literals),即两个相同的原子命题,其中一个被否定,另一个没有被否定。 互补文字使我们能够通过解析推理产生新的句子。因此推理算法通过定位互补文字来生成新的知识。 一个从句是一个字词的二元链接(一个命题符号或者一个命题符号的否定)。分句由一个「或」逻辑连接词(P∨Q∨R)连接。另一方面,连词由「和」逻辑连接词相连的的命题组成(P^Q^R)。 分句允许我们把任何逻辑语句转换成合取范式(CNF),也就是分句的连接,例如: (A∨B∨C)^(D∨¬E)^(F∨G) 将命题转换为合取范式的步骤: 消除双条件;将 α↔β 变成 (α→β)^(β→α) 消除暗示;将 α→β 变成 ¬α∨β 使用德摩根定律,将否定向内移动,直到只有文字被否定;将 ¬(α∨β) 变为 ¬α∨¬β 更多的例子:(P∨Q)→R ¬(P∨Q)∨R,消除暗示 (¬P∨¬Q)∨R,德摩根定律 (¬P∨R)^(¬Q∨R),分配律 在这一点上,我们可以对合取范式运行推理算法。偶尔在解析推理的过程中我们会遇到这样的情况:一个子句包含两次相同的文字。我们会需要使用一个叫做分解(factoring)的过程,将重复的文字删除。 例子: (P∨Q∨S)^(¬P∨R∨S) 允许我们通过解析推断 (Q∨S∨R∨S)。重复的 S 会被删除,得到 (Q∨R∨S)。 消除一个文字和它的否定,也就是 ¬P 和 P,可以得到空的分句。空句总是假,因为 P 和 ¬P 不可能都是真: 确定 KB 是否 ⊨α: 检查:(KB^¬α) 是否是矛盾的? 如果是,则 KB⊨α 如果否,则没有蕴含关系 矛盾证明是计算机科学中经常使用的一种工具。如果我们的知识库为真,而它与 ¬α 相矛盾。这意味着 ¬α 为假。因此 α 绝对为真。 确定 KB 是否 ⊨α: 将 (KB^¬α) 转换为合取范式 继续检查,看我们能否用归结原理产生一个新的分句 如果产生了一个空句,代表我们得到了一个矛盾,从而证明 KB⊨α 如果没有产生矛盾,也没有更多的子句可以被推断出来,也就没有蕴含关系 例子:(A∨B)^(¬B∨C)^(¬C) 是否蕴含 A? 用矛盾法证明。假设 A 为假,那就会得出 (A∨B)^(¬B∨C)^(¬C)^(¬A) 既然我们知道 C 为假(因为 ¬C),那么 (¬B∨C) 的唯一可能是 B 也是假的,这样 ¬B 就为真(将 ¬B 加入到我们的知识库) (A∨B) 的唯一可能是 A 为真(将 A 加入我们的知识库) 现在知识库中有两个互补的文字:A 和 ¬A。我们得到了一个空的集合,根据定义,空的集合为假,所以我们得到了一个矛盾 一阶逻辑 一阶逻辑(First-Order Logic)是另一种类型的逻辑,它允许我们比命题逻辑更简洁地表达更复杂的想法。一阶逻辑使用两种类型的符号:常量符号和谓词符号。 常量符号代表对象 谓词符号更像是关系或函数,接受一个参数并返回一个真或假的值 回到逻辑谜题:在霍格沃茨有不同的人和房子的分配。常量符号是人或者学院,而谓词符号就是一些持有常量符号真或假的属性。 例子: 使用句子 Person(Minerva) 来表达 Minerva 是一个人 使用句子 House(Gryffindor) 来表达格兰芬多是一个学院 所有的逻辑连接词在一阶逻辑中的使用方式与命题逻辑的相同。 例子: ¬House(Minerva) 表达了 Minerva 不是一个学院 一个谓词符号也可以接受两个或以上的参数,并表达它们之间的关系。 例子: BelongsTo 表达了两个参数之间的关系,即人和人所属的房子 Minerva 属于格兰芬多可以写成:BelongsTo(Minerva, Gryffindor) 全称量化 量化是一种工具,可以在一阶逻辑中用来表示句子而不使用特定的常数符号。全称量化使用符号 ∀ 来表达」对于所有「(for all)。 例子:∀x.BelongsTo(x, Gryffindor) -> ¬BelongsTo(x, Hufflepuff),表达了对于所有符号来说,如果这个符号属于格兰芬多,那么它就不属于赫夫帕夫。 存在量化 存在量化是一个与全称量化平行的概念。然而,全称量化被用来创建对所有 x 都为真的句子,存在量化被用来创建对至少一个 x 为真的句子。存在量化使用符号 ∃ 来表示。 例子:∃x.House(x)^BelongsTo(Minerva, x),意味着至少有一个符号既是学院,Minerva 也属于它。 换句话说,这表达了 Minerva 属于一个学院的想法。 存在量化和全称量化可以在同一个句子中使用。 例子:∀x.Person(x) -> (∃x.House(y)^BelongsTo(x,y)) 表达的意思是如果 x 是一个人,那么至少有一个学院 y 是这个人的归属。 换句话说,这个句子意味着每个人都属于一个学院。 测验 考虑以下逻辑句子: 如果赫敏在图书馆内,那么哈利就在图书馆内 赫敏在图书馆内 罗恩在图书馆并且罗恩不在图书馆内 哈利在图书馆内 哈利不在图书馆内或赫敏在图书馆内 罗恩在图书馆内或赫敏在图书馆内 哪个逻辑蕴含为真: 句子 6 蕴含了句子 3 句子 6 蕴含了句子 2 句子 2 蕴含了句子 5 句子 5 蕴含了句子 6 句子 1 蕴含了句子 4 句子 2 蕴含了句子 2 表达式 A⊕B 表示句子 "A 或 B,但不是全部为真"。以下哪个在逻辑上等同于 A⊕B: (A ∨ B) ∧ ¬ (A ∧ B) (A ∨ B) ∧ (A ∧ B) (A ∧ B) ∨ ¬ (A ∨ B) (A ∨ B) ∧ ¬ (A ∨ B) 答案:(A ∨ B) ∧ ¬ (A ∧ B) 解析:(A 或者 B)和 不是(A 和 B) R 是」现在下雨「,C 是」现在多云「,S 是」现在晴天「。哪个表达了」如果现在下雨,那么现在是多云而不是晴天「: (R → C) ∧ ¬S R → C → ¬S R ∧ C ∧ ¬S R → (C ∧ ¬S) (C ∨ ¬S) → R 答案:R → (C ∧ ¬S) 解析:(现在下雨)暗示(现在多云 和 不是(现在晴天)) Student(x) 代表了」x 是一个学生「;Course(x) 代表了」x 是一个课程「;Enrolled(x,y) 代表了」x 入学了 y「。哪个一阶逻辑句子代表了」哈利和赫敏同时入学了的课程「: ∃x. Enrolled(Harry, x) ∨ Enrolled(Hermione, x) ∀x. Enrolled(Harry, x) ∨ Enrolled(Hermione, x) ∀x. Course(x) ∧ Enrolled(Harry, x) ∧ Enrolled(Hermione, x) ∃x. Course(x) ∧ Enrolled(Harry, x) ∧ Enrolled(Hermione, x) ∀x. Enrolled(Harry, x) ∧ ∀y. Enrolled(Hermione, y) ∃x. Enrolled(Harry, x) ∧ ∃y. Enrolled(Hermione, y) 答案:∃x. Course(x) ∧ Enrolled(Harry, x) ∧ Enrolled(Hermione, x) 解析:(x 是一个课程)和(哈利入学了 x)和(赫敏入学了 x);存在量化:至少有一个课程,并且哈利属于它,赫敏也属于它 作业 Knights 1978 年,逻辑学家 Raymond Smullyan 出版了《这本书叫什么名字?》。这是一本逻辑谜题书。在这本书中,有一类谜题被 Smullyan 称作为」骑士和无赖「(Knights and Knaves)。 在这个谜题中,以下信息被给出: 每个角色要么是骑士,要么就是无赖 骑士总是说真话,而无赖总是撒谎 这个迷题的目的是,给定一组由每个角色说出的句子,对于每个角色,确定该角色是骑士还是无赖。 例如单个角色 A,它说」我既是骑士也是无赖「。根据逻辑,我们可以推断这句话不可能为真。因此 A 一定是无赖。 分发代码 瞅瞅 logic.py 文件,它定义了一些用于不同类型的逻辑连接词的类。这些类可以互相组成,例如 And(Not(A), Or(B, C)) 这样的表达式代表了一个逻辑句子,说明 A 不是真的,而 B 或 C 是真。 该文件还有一个函数 model_check(),它接收一个知识库和一个查询作为参数。知识库是一个单一的逻辑句子:如果多个逻辑句子是已知的,它们就可以被连接到一个 And() 中。model_check() 递归地考虑了所有可能的模型。如果知识库包含了查询,就返回 True,否则返回 False。 现在来看看 puzzle.py。在顶部有六个命题符号被定义,例如 AKnight 表示」A 是一个骑士「,而 AKnave 表示」A 是一个无赖「。我们也同样为人物 B 和 C 定义了命题符号。 下面还有四个不同的知识库:knowledge0、knowledge1、knowledge2、knowledge3,它们将分别包含推导谜题所需的知识。 puzzle.py 的主函数在所有谜题上循环,并使用模型检查来计算,给定该谜题的知识并打印出模型检查算法能够得出的任何结论。 目标 将知识添加到知识库中,以解决下列谜题: A 说」我既是骑士也是无赖「 A 说」我们都是骑士「;B 没有说话 A 说「我们是同类人」;B 说「我们是不同类的人」 A 要么说了「我是一个骑士」,要么说了「我是一个无赖」,但是你不知道;B 说「A 说了他是一个无赖」;C 说「A 是一个骑士」 作业 minesweeper 扫雷是一个益智游戏,由一个单元格组成,其中一些单元格中隐藏着地雷。点击含有地雷的单元格会引爆地雷,并导致用户输掉游戏。点击一个安全的单元格会显示一个数字,表明有多少个相邻的单元格含有炸弹。 命题逻辑 你在这个项目中的目标是建立一个能玩扫雷游戏的人工智能。我们可以用一种方法来表示人工智能对扫雷游戏的了解,那就是让每个单元格成为一个命题变量,如果该单元格含有地雷,则为真,否则就是假。 人工智能会知道每次点击安全的单元格时显示的数字。 用上图作为例子,我们知道八个相邻的单元格中会有一个是地雷。因此我们可以写一个像下面这样的逻辑表达式来表示相邻单元格中的一个是地雷:Or(A,B,C,D,E,F,G,H)。 但实际上我们知道的要比这个表达式所说的要多。上面这个逻辑句子表达的是这八个变量中至少有一个是真的,但我们可以做一个比这更有力的声明:我们知道这八个变量中只有一个是真的。那就是: Or( And(A, Not(B), Not(C), Not(D), Not(E), Not(F), Not(G), Not(H)), And(Not(A), B, Not(C), Not(D), Not(E), Not(F), Not(G), Not(H)), And(Not(A), Not(B), C, Not(D), Not(E), Not(F), Not(G), Not(H)), And(Not(A), Not(B), Not(C), D, Not(E), Not(F), Not(G), Not(H)), And(Not(A), Not(B), Not(C), Not(D), E, Not(F), Not(G), Not(H)), And(Not(A), Not(B), Not(C), Not(D), Not(E), F, Not(G), Not(H)), And(Not(A), Not(B), Not(C), Not(D), Not(E), Not(F), G, Not(H)), And(Not(A), Not(B), Not(C), Not(D), Not(E), Not(F), Not(G), H)) 嗯…… 是不是太复杂了?这只是为了表达一个单元格中有 1。如果单元格中有 2 或者 3 或者其他的值,表达式可能会更长。 试图对这种类型的问题进行模型检查,也会很快变得难以解决:在一个 8x8 的网格上,也就是简单模式会用的尺寸,我们会有 64 个变量,因此有 2642^{64}264 个可能的模型需要检查。对于一台计算机来说,在任何合理的时间内计算的数量都太多了。对于这个问题,我们需要一个更好的知识来表示。 知识表示 又称知识重呈、知识表现、知识表征…… 我们将像这样表示人工智能的每一句知识:{A,B,C,D,E,F,G,H}=1。 这个表达式中的每个逻辑句子都有两个部分:黑板上涉及该句子的一组单元格,以及一个数字计数,代表这个单元格中有多少是地雷的计数。上面的逻辑句子表示了「在这八个单元格中正好有一个是地雷」。 分发代码 这个项目有两个主要文件:runner.py 和 minesweeper.py。前者已被实现,后者包含了游戏本身和 AI 玩游戏的所有逻辑。 minesweeper.py 定义了三个类:Minesweeper、Sentence、MinesweeperAI。第一个处理游戏的玩法,第二个表示一个逻辑句子,第三个处理根据知识推断出要做的动作。 Minesweeper 类已被实现。每个单元格是一对 (i,j),其中 i 是行号(从 0 到 高度-1),j 是列号(从 0 到 宽度-1)。 Sentence 类被用来表示背景中描述的逻辑句子的形式。每个句子中都有一组单元格,以及这些单元格中有多少个地雷的计数。该类还包含函数 known_mines() 和 known_safes() 用于确定句子中的任何单元格是否为已知的地雷或已知的安全区。它还包含了 mark_mine() 和 mark_safe() 用来更新句子,以应对关于一个单元格的新信息。 最后,MinesweeperAI 类将实现一个可以玩扫雷的 AI。这个 AI 类记录了一些数值: self.moves_made 包含了一个装有所有已经点击过的单元格的集合,所以 AI 知道不要再去选择那些单元格 self.mines 包含了一个装有所有已知是地雷的单元格的集合 self.safes 包含了一个装有所有已知是安全的的单元格的集合 self.knowledge 包含了一个装有所有人工智能已知为真的句子的列表 mark_mine() 将一个单元格添加到 self.mines 中。mark_safe() 同理。 2. Uncertainty 在现实里,人工智能往往只拥有对世界的部分知识,剩余的空间则都是不确定性(uncertainty)。尽管如此,我们仍希望我们的人工智能在这些情况下做出最佳的决策。 例如:在预测天气时,人工智能能拥有今天的天气信息,但无法百分之百准确地预测明天的天气 不过我们还是可以在有限信息和不确定性地情况下创建出可以做出最佳决策的人工智能。 概率 不确定性可以被表示为一些事件及其发生的可能性或概率。 可能的世界 每种可能的情况都可以被看作是一个世界,用小写希腊字母 ω 来表示。 例如:投掷骰子时可以产生六个可能的世界 为了表示某个世界的概率,我们使用 P(ω)。 概率的公理 概率的公式为 0 < P(ω) < 1,每个表示概率的值必须在 0 和 1 之间。0 是不可能发生的事件,比如投掷骰子时得到了点数 7;1 是一定会发生的事件,比如投掷一个标准骰子得到一个小于 10 的点数。通常值越高,事件发生的可能性就越大。每个可能事件的概率加起来等于 1。 用标准骰子掷出一个数字 R 的概率可以表示为 P(R)。在我们的例子中,P(R) = 1/6,因为有六个可能的世界,每个都同样可能发生。现在考虑一下投掷两个骰子的事件,一下就有了 36 种可能的事件。 但是,如果我们试图预测两个骰子的和会发生什么?在这种情况下,我们只有 11 个可能的值。 要得到一个事件的概率,我们要将它发生的世界数除以所有可能的世界数。例如,投掷两个骰子时会有 36 个可能的世界。只有在其中一个世界里,当两个骰子都掷出 6 时,我们才能得到 12 的总和。因此 P(12) = 1/36。 例子:P(7) 的概率是多少?7 出现在六个世界中,因此 P(7) = 6/36 = 1/6 无条件概率 无条件概率是在没有其他证据的情况下对一个命题的信度。我们迄今为止提出的所有问题都是无条件概率的问题,因为掷骰子的结果不依赖于先前的事件。 条件概率 条件概率是在已经揭示了一些证据的情况下对一个命题的信度。如前言所述,人工智能可以利用部分信息对未来做出有根据的猜测。为了使用这些信息,它们会影响事件在未来发生的概率,我们依赖于条件概率。 条件概率用 P(a | b) 来表示,意思是「在我们知道事件 b 已经发生的情况下,事件 a 发生的概率」。 例如:如果昨天下雨了,今天下雨的概率是多少? -> P(今天下雨 | 昨天下雨) 换句话说,给定 b 为真的 a 的概率等同于 a 和 b 都为真的概率除以 b 的概率。 一种直观的推理方法是这样的:「我们对 a 和 b 都为真的事件感兴趣(分子),但只来自我们知道 b 为真的世界(分母)」 计算一下 P(总和为12 | 在骰子上掷出6)。首先我们要把我们的世界限制在第一个骰子的点数为 6 的世界。其次我们要得出在我们限制了问题的世界中,事件 a(总和为 12)发生了多少次(除以 P(b),即第一个骰子掷出 6 的概率)。 随机变量 随机变量是概率论中具有可能取值域的变量。 例如:为了表示掷骰子时的可能结果,我们可以定义一个随机变量 Roll,它可以取值 {0, 1, 2, 3, 4, 5, 6};为了表示航班的状态,我们可以定义一个变量 Flight,它可以取值 {准时, 延误, 取消} 通常我们会对每个值发生的概率感兴趣,这一点可以使用概率分布来表示: P(Flight = 准时) = 0.6 P(Flight = 延误) = 0.3 P(Flight = 取消) = 0.1 概率分布可以更简洁地表示为向量: P(Flight) = <0.6, 0.3, 0.1> 为了使这种表示法可解释,值必须有一个固定的顺序(在我们的例子里,就是准时、延误、取消) 独立性 独立性指的是一个事件的发生不影响另一个事件的概率。 例如:当投掷两个骰子时,每个骰子的结果都与另一个骰子独立。这与依赖事件相反,比如早上的云和下午的雨(如果早上多云,那么下午下雨的可能性就更大) 独立性可以用数学定义:当且仅当 a 和 b 的概率等于 a 的概率乘以 b 的概率时,事件 a 和 b 是独立的:P(a ∧ b) = P(a)P(b)。 贝叶斯定理 贝叶斯定理通常用于概率论中计算条件概率。给定 a 的 b 的概率等于给定 b 的 a 的概率乘以 b 的概率,除以 a 的概率。 例如:计算如果早上有云,下午下雨的概率(也就是 P(雨 | 云)) 80% 的下午下雨始于早晨的多云,即 P(云 | 雨) 40% 的日子是早上多云,即 P(云) 10% 的日子是下午下雨,即 P(雨) 应用贝叶斯定理,(0.1)(0.8)/(0.4) = 0.2。也就是说,如果早上多云,那么下午下雨的概率为 20% 知道了 P(a | b) 后还可以让我们计算出 P(b | a)。知道一个可见效果给定一个未知原因的条件概率(P(可见效果 | 未知原因))可以让我们计算出未知原因给定可见效果的概率(P(未知原因 | 可见效果))。 例如:我们可以通过医学试验来了解 P(医学检查结果 | 疾病),在那里我们测试患有疾病的人并看看测试多久能检测到。知道这一点,我们可以计算出 P(疾病 | 医学检查结果),即宝贵的诊断信息 联合概率 联合概率是多个事件都发生的可能性。考虑一下关于早上多云和下午下雨的概率的例子: C = cloud,0.4 C = ¬cloud,0.6 R = rain,0.1 R = ¬rain,0.9 我们无法说早上多云是否与下午下雨的可能性有关。为了能够这样做,我们需要看两个变量的所有可能结果的联合概率: C = cloudC = ¬cloud R = rain0.080.02 R = ¬rain0.320.58 使用联合概率,我们可以推导出条件概率。 例如:我们对下午下雨时早上多云的概率分布感兴趣。P(C | 雨) = P(C, 雨)/P(雨)(在概率中,逗号和 ∧ 可以互换使用) 在最后一个方程中,可以将 P(雨) 视为乘以 P(C, 雨) 的某个常数 因此我们可以重写 P(C, 雨)/P(雨) = αP(C, 雨) 或 α<0.08, 0.02> 提取 α 后,我们得到了 C 在下午下雨时可能值的概率比例 也就是说,如果下午下雨,那么早上多云和早上没有云的概率比例是 0.08 : 0.02 注意 0.08 和 0.02 并不加起来等于 1;然而,由于这是随机变量 C 的概率分布,我们知道它们应该加起来等于 1 因此,我们需要通过计算 α 使 α0.08 + α0.02 = 1 来归一化这些值。最后,我们可以说 P(C | 雨) = <0.8, 0.2> 概率规则 否定:P(¬a) = 1 - P(a) 源于所有可能的世界的概率总和为 1,而补充文字 a 和 ¬a 包括了所有的可能的世界 容斥:P(a ∨ b) = P(a) + P(b) - P(a ∧ b) a 或 b 为真的世界等于所有 a 为真的世界加上 b 为真的世界 然而在这种情况下,有些世界会被多次计算(即 a 和 b 都为真的世界)。为了消除这种重叠,我们减去一次 a 和 b 都为真的世界 例如:我有 80% 的时间吃冰淇淋、70% 的时间吃饼干。如果我们在计算今天吃冰淇淋或饼干 P(冰淇淋 ∨ 饼干) 时不减去 P(冰淇淋 ∧ 饼干),我们就会错误地得到 0.7 + 0.8 = 1.5,这与概率在 0 和 1 之间的公理相互矛盾 边缘化:P(*a*) = P(*a, b*) + P(*a, ¬b*) b 和 ¬b 是不相交的概率,或者说,b 和 ¬b 同时发生的概率是 0。我们也知道 b 和 ¬b 加起来是 1。因此当 a 发生时,b 可以发生也可以不发生。当我们取 a 和 b 时发生的概率加上 a 和 ¬b 同时发生的概率时,我们最终得到了 a 的概率 这个方程的左边意味着」随机变量 X 取值为 xᵢ 的概率「 例如:对于我们前面提到的变量 C,两个可能的值分别是早上多云和早上没云 方程的右边则是」xᵢ 和随机变量 Y 的每个值的所有联合概率之和「 例如:P(C = 多云) = P(C = 多云, R = 下雨) + P(C = 多云, R = ¬下雨) = 0.08 + 0.32 = 0.4 条件化:P(a) = P(a | b)P(b) + P(a | ¬b)P(¬b) 与边缘化相似,事件 a 发生的概率等于给定 b 的 a 的概率乘以 b 的概率,加上给定 ¬b 的 a 的概率乘以 ¬b 的概率 随机变量 X 以概率等于 xᵢ 给定随机变量 Y 的每个值的概率之和乘以变量 Y 取该值的概率。记住 P(a | b) = P(a, b)/P(b),如果我们将这个表达式乘以 P(b),我们就会得到 P(a, b),之后的步骤便和边缘化一样 贝叶斯网络 贝叶斯网络是一种表示随机变量之间依赖关系的数据结构,其特性如下: 是有向图,图上的每个节点都代表一个随机变量 从 X 到 Y 的箭头表示 X 是 Y 的父节点,即 Y 的概率分布取决于 X 的值 每个节点 X 都有概率分布 P(X | Parents(X)) 考虑这个例子,它涉及影响了我们是否按时赴约的变量: Rain 是这个网络中的根节点。这意味着它的概率分布不依赖于任何先前的事件。在我们的例子中,Rain 是一个随机变量,可以取值 {none, light, heavy},具有以下概率分布: 值概率 none0.7 light0.2 heavy0.1 在我们的例子中,Maintenance 编码是否有火车轨道维护,取值 {yes, no}。Rain 是 Maintenance 的父节点,这意味着 Maintenance 的概率分布受 Rain 的影响 nonelightheavy yes0.40.20.1 no0.60.80.9 Train 变量编码火车是否准时或延误,取值 {on time, delayed}。注意,Maintenance 和 Rain 都有箭头指向 Train,这意味着它们都是 Train 的父节点,它们的值会影响 Train 的概率分布 none + yesnone + nolight + yeslight + noheavy + yesheavy + no on time0.80.90.60.70.40.5 delayed0.20.10.40.30.60.5 Appointment 是一个随机变量,代表我们是否出席了预约,取值 {attend, miss}。它的唯一父节点是 Train 根据贝叶斯网络,父节点只包括直接关系。Maintenance 确实会影响 Train 是否准时,而 Train 是否准时会影响到我们是否出席了预约。然而,最终直接影响我们出席预约的机会的是火车是否准时到达 例如:如果火车准时到达,那么即使下大雨并且轨道在维修,也不会影响我们是否赶上预约 on timedelayed attend0.90.6 miss0.10.4 例如:如果我们想要找到在没有维修、没有下雨的一天,火车晚点时错过预约的概率,也就是 P(light, no, delayed, miss),我们将计算以下内容: P(light)P(no | light)P(delayed | light, no)P(miss | delayed) 每个单独概率的值都可以在上面的概率分布中找到,然后将这些值相乘就能得到 P(no, light, delayed, miss) 推理 早些时候我们通过蕴涵来推理,意味着我们可以根据我们已有的信息明确地得出新信息,也可以根据概率推断新信息。虽然这无法让我们确定新信息,但可以让我们找出某些值地概率分布。 推理具有多个属性: 查询变量 X:我们想要计算概率分布的变量 证据变量 E:一个或多个一观察到的事件 e 的变量 例如:我们可能观察到有轻微的雨。这个观察帮助我们计算火车延迟的概率 隐藏变量 Y:不是查询变量,也没有被观察过的变量 例如:在火车站台上,我们可以观察到是否在下雨,但是无法得知路的下方是否有维修工作。因此在这种情况下,维修工作是一个隐藏变量 目标:计算 P(X | e) 例如:给予我们知道有轻微的雨的证据变量 E,计算火车变量(查询变量)的概率分布 举个例子,我们想要计算在有轻微的雨和没有轨道维修的证据下、预约变量的概率分布。也就是说,我们知道有轻微的雨并且没有轨道维修,我们想要确定我们参加预约和错过预约的概率分别是多少,也就是 P(预约 | 有雨,无维修)。根据联合概率部分,我们知道我们可以将预约随机变量的可能值表示为一个比例,将 P(预约 | 有雨,无维修) 重写为 αP(预约,有雨,无维修)。如果预约的父节点只有火车变量,而没有雨或者维修,我们该如何计算预约的概率分布? 在这里,我们将使用边缘化。P(预约,有雨,无维修) 的值等同于 α[P(预约,有雨,无维修,延误) + P(预约,有雨,无维修,准时)]。 枚举推理 枚举推理是一种根据观察到的证据 e 和一些隐藏变量 Y 来找到变量 X 的概率分布的过程。 在这个方程中,X 代表查询变量,e 代表观察到的证据,y 代表所有隐藏变量的值,α 对结果进行归一化,使得我们得到的概率总和为 1。 用文字解释这个方程,它表示给定 e 的情况下 X 的概率分布等于 X 和 e 的归一化概率分布。为了得到这个分布,我们对 X、e 和 y 的归一化概率求和,其中 y 每次取隐藏变量 Y 的不同值。 Python 中存在多个库以简化概率推理的过程。我们将使用 pomegranate 库来看看如何将上述数据表示为代码: 创建节点并为每个节点提供概率分布 from pomegranate import *# 雨节点没有父节点rain = Node(DiscreteDistribution({ "none": 0.7, "light": 0.2, "heavy": 0.1}), name="rain")# 轨道维修节点是基于雨的条件概率maintenance = Node(ConditionalProbabilityTable([ ["none", "yes", 0.4], ["none", "no", 0.6], ["light", "yes", 0.2], ["light", "no", 0.8], ["heavy", "yes", 0.1], ["heavy", "no", 0.9]], [rain.distribution]), name="maintenance")# 火车节点是基于雨和维修的条件概率train = Node(ConditionalProbabilityTable([ ["none", "yes", "on time", 0.8], ["none", "yes", "delayed", 0.2], ["none", "no", "on time", 0.9], ["none", "no", "delayed", 0.1], ["light", "yes", "on time", 0.6], ["light", "yes", "delayed", 0.4], ["light", "no", "on time", 0.7], ["light", "no", "delayed", 0.3], ["heavy", "yes", "on time", 0.4], ["heavy", "yes", "delayed", 0.6], ["heavy", "no", "on time", 0.5], ["heavy", "no", "delayed", 0.5],], [rain.distribution, maintenance.distribution]), name="train")# 预约节点是基于火车的条件概率appointment = Node(ConditionalProbabilityTable([ ["on time", "attend", 0.9], ["on time", "miss", 0.1], ["delayed", "attend", 0.6], ["delayed", "miss", 0.4]], [train.distribution]), name="appointment") 通过添加所有节点来创建模型,然后通过在它们之间添加边来描述哪个节点是哪个其他节点的父节点(贝叶斯网络) # 创建一个贝叶斯网络并添加状态model = BayesianNetwork()model.add_states(rain, maintenance, train, appointment)# 给节点之间添加用来连接的边model.add_edge(rain, maintenance)model.add_edge(rain, train)model.add_edge(maintenance, train)model.add_edge(train, appointment)# 最终确定模型model.bake() 为了询问某个事件的概率有多大,我们使用我们感兴趣的值来运行模型。在这个例子中,我们想要问的是没有雨、没有轨道维修、火车准时到达并且我们准点参加会议的概率是多少 # 基于提供的观察,计算概率probability = model.probability([["none", "no", "on time", "attend"]])print(probability) 否则我们可以使用该程序在给定一些观察到的证据时提供所有变量的概率分布。在下面的情况中,我们知道火车延误了。根据这个信息,我们计算并打印变量 Rain、Maintenance 和 Appointment 的概率分布 # 基于火车延误的证据进行预测计算predictions = model.predict_proba({ "train": "delayed"})# 打印每个节点的预测结果for node, prediction in zip(model.states, predictions): if isinstance(prediction, str): print(f"{node.name}: {prediction}") else: print(f"{node.name}") for value, probability in prediction.parameters[0].items(): print(f" {value}: {probability:.4f}") 上述代码使用了枚举推理。然而这种计算概率的方式是低效的,特别是当模型中有许多变量时。另一种方法是放弃精确推理,转而采用近似推理。通过这样做,我们在生成的概率中失去了一些精度,但通常这种不精确性是可以忽略的。相反我们获得了一种可扩展的计算概率的方法。

2023/9/5
articleCard.readMore

在云服务器上搭建 Nostr 中继器

如何在云服务器上搭建 Nostr 中继器,并在 Nostr 客户端中连接使用。 我在这个文章 还未写完 的时候就 鸽 掉了 Nostr 的搭建! 强烈建议不要根据这个教程来搭建 Nostr 中继器! 你需要先知道: 我使用的云服务器的提供商为 DigitalOcean 我使用的云服务器的系统为 Ubuntu 22.04(LTS)x64 我使用的云服务器的配置为 1 个 CPU、1GB 内存,以及 25GB 固态 我使用的云服务器有 ipv6 地址和固定公共 IP 地址 你需要有一个域名 创建用户 创建一个不是 root 的用户: adduser 用户名 如果执行这个指令后系统要求你创建一个密码,那就不需要执行下面这个指令;反之,执行该命令给该用户设置密码: passwd 用户名 将该用户添加到 sudo 组中,从而授予其 sudoer 权限: usermod -aG sudo 用户名 从 root 切换到我们创建的用户: su 用户名 安装 Docker sudo apt updatesudo apt install nodejs npm nginx certbot python3-certbot-nginx# 下载 Docker 的 GPG 密钥sudo mkdir -p /etc/apt/keyringscurl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg# 安装 Dockerecho "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"echo "sudo tee /etc/apt/sources.list.d/docker.list > /dev/null"sudo chmod a+r /etc/apt/keyrings/docker.gpgsudo apt updatesudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin 下载 nostream cd /home/用户名git clone https://github.com/Cameri/nostream.git 配置 nginx # 删除默认的 nginx 配置文件rm -rf /etc/nginx/sites-available/default# 通过 nano 进入新的配置文件sudo nano /etc/nginx/sites-available/default 将以下内容复制粘贴到这个新的配置文件: server{ server_name 子域名.域名.后缀; location / { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $host; proxy_pass http://127.0.0.1:8008; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; }} 最后重启一下 nginx: sudo service nginx restart 设置域名的 DNS 在你的域名 DNS 设置中,你需要添加两个记录: 启动 Nostr 中继器 sudo certbot --nginx -d 子域名.域名.后缀cd ~/nostream# 开启 TMUX 会话# 用于分离和维护进程运行tmux# 启动中继器sudo chmod 666 /var/run/docker.socknpm run docker:compose:start

2023/8/24
articleCard.readMore

PC 通过 Proxmox 安装 MacOS

使用 Proxmox VE 在 PC 上安装 MacOS。 我在这个文章 还未写完 的时候就 鸽 掉了 MacOS 的安装! 该教程暂且只写到了安装 Proxmox VE 和安装 MacOS 13.0 Ventura,并未对之后的操作进行详细说明。 强烈建议不要根据这个教程来安装 MacOS! 前言 阅读该文章之前,你需要: 有一个至少 1GB 以上的 USB 有 UltroISO 或者类似的启动盘制作工具 知道如何进入 BIOS 界面 给要装上 Proxmox VE 的电脑连接上 网线!不要 无线连接! 另外一台可以联网的电子设备,无线连也可以,这台不挑 建议在开始安装 Proxmox VE 之前就拿这台电子设备下载 MacOS ISO 文件。13GB 下载起来还是蛮花时间的。跳转到安装 MacOS? 清楚该文章的日期是 2023 年 8 月 16 日,且我安装的并非最新版本而是 7.4! 不装 8.0 的原因:安装 8.0 时卡在加载驱动无法继续,换成 7.4 后秒装好,真是醉了… ———————— 安装 Proxmox VE 因为一些原因,安装 Proxmox VE 时截下来的图片都不见了,所以这段就只能用文字描述。 进入 Proxmox VE 官网,下载 Proxmox VE ISO Installer。 使用 UltroISO 制作启动盘。 重启电脑、进入 BIOS 界面。 先进入 Advanced 界面,开启 Intel Virtualization Technology。 电脑再次重启后进入 BIOS,在 Boot Menu 中通过 USB 启动。 看到硕大的 PROXMOX 后,选择 Install Proxmox VE。 安装结束后点击右下角的 I agree。 接下来的界面中,需要按照自己的配置依次选择目标硬盘、国家地区、键盘布局、密码邮箱、网络管理、主机名、IP 地址、网关、DNS。绝大多数都可以默认跳过,故仅展示部分界面,才不是重装了太多次完全忘记拍下来了呢! 设置结束后等配置,配置结束后等电脑重启,电脑重启后会自动启动 Proxmox VE,放着不管即可。 之后 Proxmox VE 会显示一个地址。这个地址的一部分正是之前设置的 IP 地址。 启动同互联网下的另一个电子设备,打开浏览器访问该地址。 用户名是 root,密码则是先前我们设置的那个。 顺带一提 Proxmox VE 是有中文的。 登陆成功。至此 Proxmox VE 安装好了。 ———————— 安装 MacOS 我测试安装的版本为 13.0 Ventura Beta。因此以下的设置无法保证对其他版本的 MacOS 同样有效。 你可以在 archive.org 下载它或者其他版本。 依旧是在另一台电子设备上。现在我们需要将 MacOS ISO 文件上传到 Proxmox VE。 打开节点的 local 存储空间后点击 ISO Images > Upload > 找到 MacOS ISO 文件 > Upload。 看到 TASK OK 就代表上传成功。 想要启动 MacOS,还需要从 GitHub 上安装一个工具:KVM-Opencore。 直接进到 Release 并下载 OpenCore-v20.iso.gz(最新版本)。 解压该文件,获得崭新 ISO 文件一个。 使用和 MacOS ISO 文件一样的方法将其上传到 Proxmox VE。 上传完成后,点击页面右上角的 Create VM。 给该虚拟机随便取一个名字。 点击 OS 页面: 在 ISO image 一栏选择 OpenCore-v20.iso 在 Type 一栏选择 Other 点击 System 页面: 在 Graphic card 一栏选择 VMware compatible 在 Machine 一栏选择 q35 在 BIOS 一栏选择 OVMF(UE5FI) 在 EFI Storage 一栏选择 local-lvm 取消勾选 Pre-Enroll keys 在 SCSI Controller 一栏选择 VirtIO SCSI 点击 Disks 页面: 在 Bus / Device 一栏选择 VirtIO Block 在 Disk size(GiB)一栏输入 64 在 Cache 一栏选择 White back(unsafe) 点击 CPU 页面: 在 Cores 一栏根据你自己的配置输入核的数量(我输入了 4) 在 Type 一栏选择 host 点击 Memory 页面。至少也需要 4GB 的内存,我输入了 6GB。 点击 Network 页面: 在 Model 一栏选择 VMWare vmxnet3 最终创建虚拟机。 这时能在左侧看到我们的 Mac 虚拟机,点击后选择 Hardware > Add > CD/DVD Drive。 在 Storage 一栏中选择 local 在 ISO image 一栏中选择我们的 MacOS ISO 文件,然后点击 Add 选择 Options,选中 Boot Order 后点击上方的 Edit 来修改虚拟机的 Boot 顺序。 拖拽 OpenCore 的 ISO 到第一位,接着拖拽 MacOS ISO 到第二位。拖拽完后点击 OK。 点击左侧的节点 > Shell。 安装 MacOS 之前我们还需要再修改一些配置文件。在 Shell 中输入以下命令: nano /etc/pve/qemu-server/VM的ID.conf如果没有设置的话,默认的 ID是100,所以就是100.conf 进入该配置文件后,需要复制粘贴参数信息。 如果你是英特尔处理器: args: -device isa-applesmc,osk="ourhardworkbythesewordsguardedpleasedontsteal(c)AppleComputerInc" -smbios type=2 -device usb-kbd,bus=ehci.0,port=2 -global nec-usb-xhci.msi=off -global ICH9-LPC.acpi-pci-hotplug-with-bridge-support=off -cpu host,vendor=GenuineIntel,+invtsc,+hypervisor,kvm=on,vmware-cpuid-freq=on 如果你是 AMD 处理器: args: -device isa-applesmc,osk="ourhardworkbythesewordsguardedpleasedontsteal(c)AppleComputerInc" -smbios type=2 -device usb-kbd,bus=ehci.0,port=2 -global nec-usb-xhci.msi=off -global ICH9-LPC.acpi-pci-hotplug-with-bridge-support=off -cpu Haswell-noTSX,vendor=GenuineIntel,+invtsc,+hypervisor,kvm=on,vmware-cpuid-freq=on 我是英特尔处理器,复制粘贴完事。 接着将文件中出现的两条 media=cdrom 替换为 cache=unsafe。 按 Ctrl+O > Enter 保存,接着按 Ctrl+X 退出。 回到 Mac 虚拟机,点击右上方的 Start 启动虚拟机,然后选中左侧的 Console。 回车进入 UEFI Interface Shell,输入以下命令: fs0:System\Library\CoreServices\Boot.efi 熟悉的苹果图标出现! 点击 Disk Utility,找到 VirtIO Block 硬盘并点击 Erase。 修改硬盘的名称,点击 Erase。 结束后左上角关闭 Disk Utility,接着点击 Install macOS Ventura。 同意协议后,选择我们刚才格式化的硬盘来安装。 回车继续安装,差不多要等半个小时左右。 期间可能会弹出多次下图的界面,回车就行。(我弹出了很多次…… 还重装了一遍…) 经过多次硬盘弹出洗礼,终于看到了代表着胜利的橘色! 之后的自行设置。 安装 OpenCore Configurator 前往 OpenCore Configurator 的网站 来安装 OpenCore Configurator。 下载后可以在右下角找到它的图标,开启。(可能需要进入系统设置的 Privacy & Security 中打开,因为它既不是 App Store 里的应用,也不来自于已被识别的开发者) 这个文章没写完的时候我就鸽了 MacOS 的安装,所以这个部分暂且不写、如果以后再捣鼓的话再补上。

2023/8/17
articleCard.readMore

BUFFALO LinkStation Pro Duo【1】初步设置

BUFFALO LinkStation Pro Duo 的相关教程第 1 节:映射。 首先,什么是 LinkStation Pro Duo?(以下节选自 BUFFALO 的亚太官网) LinkStation Pro Duo 是一款高性能双盘位 RAID 网络存储设备,它包含一些列特色功能,是存储、共享、服务和保护你最重要数据的最佳设备。能够同时备份多台计算机和苹果机,即便不在家也通过因特网连续访问自己网络中存储的数据 想必能搜到这个文章的人都多多少少不需要以上的介绍。 几日前我从老师手中白嫖到这么一台 NAS,型号为 LS - WTGL/R1-V3 F/W3.10。 不过 LinkStation Pro Duo 的教程在网上实在是少之又少,不少教程还因为年代久远导致无法参考。很难想象我在拿到这台 NAS 后就因为无法正常映射而查资料查了快一天的时间才解决。 先来看看我们的 LinkStation Pro Duo。包装中自带一个 CD 供用户安装所需的软件(还有 Memeo 数据备份,Win 系统要比 Mac 多一个 Adobe Reader),可惜 CD 的时代已经过去,我的笔记本电脑并没有光驱。 前期准备 一台 LinkStation Pro Duo(型号和我的不同也没关系,顶多是网页 UI 布局的区别) NAS Navigator2 官方下载链接:点我 点击 Utilities → 根据你的系统选择前两个下载连接。我是 Win10 系统所以自然下载第一个 初步连接 首先先把 NAS 连接上电源和 LAN。 将开关调到 ON。 坐等 NAS 前面的这两个灯亮起。 坐到我们的电脑前(和 NAS 在同一个网络下)打开 NAS Navigator2,能看到我们的 NAS 出现。 映射 右键 NAS 的图标,选择 Map Share。如果没有任何错误弹出,那可以无视下面的步骤直接开跳。 选择看到这里就代表你见到了 Could not map the share. Create a folder named「share」before mapping the drive 这样的弹窗。我们先进入 NAS 的设置页面:右键 NAS 图标,选择 Open Settings。 用户名默认是 admin,密码默认是 password(传统艺能)。 首先先设置 RAID,点击 RAID Setup 下的 RAID Array 1。 在进行 RAID Array 设置之前,你需要先明白 RAID 在这里是什么用的。 在 BUFFALO 亚太官网中提到: 「LinkStation Pro Duo 包含两个内置硬盘机架,安装两个硬盘后可支持 RAID 0 / 1 系统。当使用 RAID1 系统时,可便捷安装最多两个硬盘,并自动复制(不包含硬盘)。使用 LinkStation Pro Duo 设备时,可随时添加第二个硬盘,在 RAID1 系统中设置而不会损坏任何数据。轻松省时备份和保护你的数据。」 也就是说:RAID 0 代表 将所有硬盘合并为单一阵列,能够最大化存储容量,不具有冗余功能,而 RAID 1 代表着 备份两个硬盘,为了应对意外故障,能够精准复制数据。 我在这里设置的是 RAID 1,你不需要一定跟着我的来做,只是以下的步骤我都会以「设置 RAID Array 为 1」的前提来进行。 确保 RAID Mode 为 RAID1、Disk Structure 下的硬盘都被勾选后,点击 Setup RAID Array。 之后会出现弹窗让你确认,还有个验证码需要跟着输入,快速过完确认流程后耐心等待 RAID Array 被构建。 构建完 RAID Array 之后,我们会立即收到一个来自于 NAS Navigator2 的桌面通知,表示 RAID Array 1 正在被同步。 同时设置页面在我们点击 Continue 之后、主页出现了 Checking RAID Array 1 的选项。点击后我们能看到 RAID Array 1 的同步状态(对,我们真的是要等 160+ 分钟。这个时间段我们可以先吃个饭~)。 顺带一提,这个页面并非实时修改内容。要查看现在的同步状态,我们要先去到主页面,再点击 Checking RAID Array 1 导回该页面才能看到内容变动。 同步完成后,主页面的 HDD Space Used 会变为单个 RAID Array 1。 现在点击 Shared Folders 来创建一个名为 share 的文件夹。 成功添加。 现在回到 NAS Navigator2,重新右键 NAS 图标并点击 Map Share。可算是映射成功了。 右键 NAS 图标并点击 Browse Share,系统会自动打开文件夹。当然也可以选择在文件管理器里自己打开。 结束语 好的,大功告成!你已经完成了初步设置。只是现在这个 NAS 仅能在同互联网的情况下进行存储,而我们往往都是希望在外面访问它。 后续关于外网访问的教程可能很快就能端上来,也有可能成为巨坑。 总之祝大家玩 NAS 玩的开心、玩出新奇。

2023/7/28
articleCard.readMore

树莓派 Pico 通过有源蜂鸣器播放音频

如何使用 MicroPython 在树莓派 Pico 上通过有源蜂鸣器播放音频。 目录 目录 前言 准备 导入库 音符与频率 题外话 设置蜂鸣器 编写代码 获取乐谱 播放音频 播放乐谱 最终效果 ———————— 前言 我的树莓派 Pico 早在过年时就到手了,但是一直没有时间玩,最近闲到开始生一种叫做 senioritis 的病,才开始捣鼓起来。 买的时候贪方便,直接购入了已焊接的板子,如下图所示: 不过本文内容专注于 MicroPython,所以不会细讲硬件部分。如何配置树莓派 Pico 和安装 IDE 等基础内容也不会讲,可以自行搜索。 本文使用的 IDE 为 Thonny 本文乐谱均来自于 Online Sequencer 这个网站 ———————— 准备 导入库 import timefrom machine import PWM, Pin time 库自不用多说,是用来延时的 machine 库则包含了和特定电路板上的硬件有关的方法,例如我们刚刚导入的 PWM 和 Pin。详细的内容可以参考 MicroPython 官方文档 音符与频率 tones = { 'C0': 16, 'C#0': 17, 'D0': 18, 'D#0': 19, 'E0': 21, 'F0': 22, 'F#0': 23, 'G0': 24, 'G#0': 26, 'A0': 28, 'A#0': 29, 'B0': 31, 'C1': 33, 'C#1': 35, 'D1': 37, 'D#1': 39, 'E1': 41, 'F1': 44, 'F#1': 46, 'G1': 49, 'G#1': 52, 'A1': 55, 'A#1': 58, 'B1': 62, 'C2': 65, 'C#2': 69, 'D2': 73, 'D#2': 78, 'E2': 82, 'F2': 87, 'F#2': 92, 'G2': 98, 'G#2': 104, 'A2': 110, 'A#2': 117, 'B2': 123, 'C3': 131, 'C#3': 139, 'D3': 147, 'D#3': 156, 'E3': 165, 'F3': 175, 'F#3': 185, 'G3': 196, 'G#3': 208, 'A3': 220, 'A#3': 233, 'B3': 247, 'C4': 262, 'C#4': 277, 'D4': 294, 'D#4': 311, 'E4': 330, 'F4': 349, 'F#4': 370, 'G4': 392, 'G#4': 415, 'A4': 440, 'A#4': 466, 'B4': 494, 'C5': 523, 'C#5': 554, 'D5': 587, 'D#5': 622, 'E5': 659, 'F5': 698, 'F#5': 740, 'G5': 784, 'G#5': 831, 'A5': 880, 'A#5': 932, 'B5': 988, 'C6': 1047, 'C#6': 1109, 'D6': 1175, 'D#6': 1245, 'E6': 1319, 'F6': 1397, 'F#6': 1480, 'G6': 1568, 'G#6': 1661, 'A6': 1760, 'A#6': 1865, 'B6': 1976, 'C7': 2093, 'C#7': 2217, 'D7': 2349, 'D#7': 2489, 'E7': 2637, 'F7': 2794, 'F#7': 2960, 'G7': 3136, 'G#7': 3322, 'A7': 3520, 'A#7': 3729, 'B7': 3951, 'C8': 4186, 'C#8': 4435, 'D8': 4699, 'D#8': 4978, 'E8': 5274, 'F8': 5588, 'F#8': 5920, 'G8': 6272, 'G#8': 6645, 'A8': 7040, 'A#8': 7459, 'B8': 7902, 'C9': 8372, 'C#9': 8870, 'D9': 9397, 'D#9': 9956, 'E9': 10548, 'F9': 11175, 'F#9': 11840, 'G9': 12544, 'G#9': 13290, 'A9': 14080, 'A#9': 14917, 'B9': 15804} 这个字典包含了从 C0 到 B9 的所有音符的频率,单位为 Hz。这些频率之后会被用来设置 PWM 的频率。 题外话 如果不懂音符,但又想要照着现实中的乐谱来弹奏,那该怎么办?(当然,如果你懂的话,可以直接跳到 这个位置) 先来看看一个简单的乐谱长什么样子。有请我们的主角 ——《小星星》: 首先,乐谱每一行都有数个小节,被竖线隔开。 每一行的最左边都有一个数字,代表了这一行的拍子。例如这个乐谱的拍子是 4 / 4 拍,也就是说每一小节有四拍,每一拍是一个四分音符。 那什么又是四分音符?这又要从音符的时值说起了。 音符的时值分为全音符、二分音符、四分音符、八分音符、十六分音符、三十二分音符等等。为了直接分辨,上图: 现在回到《小星星》的乐谱上,我们可以看到,第一小节正好有四个四分音符,而第二小节则有两个四分音符和一个二分音符: 那么在实际演奏中,这两个小节的区别是什么呢? 四个四分音符实际演奏:啊 啊 啊 啊; 两个四分音符 & 一个二分音符实际演奏:啊 啊 啊 ——。 也就是说,在这个谱子里一个二分音符相当于把两个四分音符融合在一起,拉了个长音。 说完时值,现在来看五线谱中各个音符所处的位置。想必你也发现了,有些音符在线上、有些音符在线下、有些音符在中间、有些音符在上下两个线之间…… 这是为什么呢? 这又要说到音符的音高了。这里要注意一点,《小星星》的乐谱中标记了 1 = C,也就是 C 大调。C 大调由 C、D、E、F、G、A、B 七个音组成,况且还是唯一一个没有升降号的大调。 钢琴上不是有白键和黑键吗?白键总体分为 C、D、E、F、G、A、B 七个音,而黑键总体分为 C#、D#、F#、G#、A# 五个音。 # 符号代表了升号,也就是把这个音调提高半个音阶。比如 C# 是 C 的升半音,也是 C 和 D 之间的音。 还有一个名为 b 的符号,代表了降号。和 #符号相反,b 符号是把这个音调降低半个音阶。Db 是 D 的降半音,是 C 和 D 之间的音,也是 C#。 我们说了这么多 C、D、E、F、G、A、B,但是在《小星星》里,我们只看到了数字。这是因为在简谱中,用以表示音的高低的符号是阿拉伯数字。1 代表了 C,2 代表了 D,3 代表了 E,以此类推。 而在现实生活中,我们最常使用的还是唱名,由 do、re、mi、fa、so、la、si 来表示音的高低。do 代表了 C,re 代表了 D,mi 代表了 E,以此类推。 我们已经知道了如何用阿拉伯数字辨认音的高低,但是这仅能在简谱中使用。我们依旧还是要学会如何看五线谱。C 大调的音阶是这样的: 来看《小星星》的第一小节。不需要看阿拉伯数字,这次也可以参照上一张图判断出这是 CCGG 了: 但是在我们先前的 音符对频率字典 里,C、D、E、F、G、A、B 后面还跟了多个数字。比如 C4、C5、C6…… 这些数字代表了这个音的频率,也就是音的高低。C4 通常被认为是中央 C,C5 是高一个八度的 C,C6 是再高一个八度的 C,以此类推。 八度指的是音的高低,在频率上是 2 倍的关系。比如 C4 的频率是 261.63Hz,C5 的频率是 523.25Hz,C6 的频率是 1046.50Hz,以此类推。 不过我们不需要那么详细的小数,只要四舍五入到整数即可。 为了方便,我们直接使用中央 C 作为基准,也就是 C4。这样,我们就可以把《小星星》的乐谱和音符结合起来了: C4 C4 G4 G4 A4 A4 G4- F4 F4 E4 E4 D4 D4 C4- G4 G4 F4 F4 E4 E4 D4- G4 G4 F4 F4 E4 E4 D4- C4 C4 G4 G4 A4 A4 G4- F4 F4 E4 E4 D4 D4 C4- 这不是最终我们会使用的乐谱,只是为了方便理解而已。 设置蜂鸣器 来设置蜂鸣器的引脚。我习惯使用 GP15(参考 先前的图片,GP15 引脚已经和蜂鸣器连接好了)。你自然也可以根据自己的需要更改引脚: buzzer = PWM(Pin(15))buzzer.freq(50000)buzzer.duty_u16(int(65536 * 0.2)) 我们先创建了一个 PWM 对象,然后设置了 PWM 周期的频率为 50000Hz(不发出任何声音),最后设置了占空比为 20%。 ———————— 编写代码 获取乐谱 我选择用超级马里奥的 经典曲子 来演示。Online Sequencer 的乐谱可以直接复制粘贴,如下: Online Sequencer:616413:0 F#5 1 7;0 E6 1 7;2 F#5 1 7;2 E6 1 7;6 F#5 1 7;6 E6 1 7;10 F#5 1 7;10 C6 1 7;12 F#5 1 7;12 E6 1 7;16 G5 1 7;16 B5 1 7;16 G6 1 7;24 G5 1 7;32 E5 1 7;32 C6 1 7;38 C5 1 7;38 G5 1 7;44 G4 1 7;44 E5 1 7;50 C5 1 7;50 A5 1 7;54 D5 1 7;54 B5 1 7;58 C#5 1 7;58 A#5 1 7;60 C5 1 7;60 A5 1 7;64 C5 1 7;64 G5 1 7;66 G5 1 7;66 E6 1 7;69 B5 1 7;69 G6 1 7;72 C6 1 7;72 A6 1 7;76 A5 1 7;76 F6 1 7;78 B5 1 7;78 G6 1 7;82 A5 1 7;82 E6 1 7;86 E5 1 7;86 C6 1 7;88 F5 1 7;88 D6 1 7;90 D5 1 7;90 B5 1 7;96 E5 1 7;96 C6 1 7;102 C5 1 7;102 G5 1 7;108 G4 1 7;108 E5 1 7;114 C5 1 7;114 A5 1 7;118 D5 1 7;118 B5 1 7;122 C#5 1 7;122 A#5 1 7;124 C5 1 7;124 A5 1 7;128 C5 1 7;128 G5 1 7;130 G5 1 7;130 E6 1 7;133 B5 1 7;133 G6 1 7;136 C6 1 7;136 A6 1 7;140 A5 1 7;140 F6 1 7;142 B5 1 7;142 G6 1 7;146 A5 1 7;146 E6 1 7;150 E5 1 7;150 C6 1 7;152 F5 1 7;152 D6 1 7;154 D5 1 7;154 B5 1 7;164 E6 1 7;164 G6 1 7;166 D#6 1 7;166 F#6 1 7;168 D6 1 7;168 F6 1 7;170 B5 1 7;170 D#6 1 7;174 C6 1 7;174 E6 1 7;178 E5 1 7;178 G#5 1 7;180 F5 1 7;180 A5 1 7;182 G5 1 7;182 C6 1 7;186 C5 1 7;186 A5 1 7;188 E5 1 7;188 C6 1 7;190 F5 1 7;190 D6 1 7;196 E6 1 7;196 G6 1 7;198 D#6 1 7;198 F#6 1 7;200 D6 1 7;200 F6 1 7;202 B5 1 7;202 D#6 1 7;206 C6 1 7;206 E6 1 7;210 F6 1 7;210 G6 1 7;210 C7 1 7;214 F6 1 7;214 G6 1 7;214 C7 1 7;216 F6 1 7;216 G6 1 7;216 C7 1 7;228 E6 1 7;228 G6 1 7;230 D#6 1 7;230 F#6 1 7;232 D6 1 7;232 F6 1 7;234 B5 1 7;234 D#6 1 7;238 C6 1 7;238 E6 1 7;242 E5 1 7;242 G#5 1 7;244 F5 1 7;244 A5 1 7;246 G5 1 7;246 C6 1 7;250 C5 1 7;250 A5 1 7;252 E5 1 7;252 C6 1 7;254 F5 1 7;254 D6 1 7;260 G#5 1 7;260 D#6 1 7;266 F5 1 7;266 D6 1 7;272 C5 1 7;272 E5 1 7;272 C6 1 7;292 E6 1 7;292 G6 1 7;294 D#6 1 7;294 F#6 1 7;296 D6 1 7;296 F6 1 7;298 B5 1 7;298 D#6 1 7;302 C6 1 7;302 E6 1 7;306 E5 1 7;306 G#5 1 7;308 F5 1 7;308 A5 1 7;310 G5 1 7;310 C6 1 7;314 C5 1 7;314 A5 1 7;316 E5 1 7;316 C6 1 7;318 F5 1 7;318 D6 1 7;324 E6 1 7;324 G6 1 7;326 D#6 1 7;326 F#6 1 7;328 D6 1 7;328 F6 1 7;330 B5 1 7;330 D#6 1 7;334 C6 1 7;334 E6 1 7;338 F6 1 7;338 G6 1 7;338 C7 1 7;342 F6 1 7;342 G6 1 7;342 C7 1 7;344 F6 1 7;344 G6 1 7;344 C7 1 7;356 E6 1 7;356 G6 1 7;358 D#6 1 7;358 F#6 1 7;360 D6 1 7;360 F6 1 7;362 B5 1 7;362 D#6 1 7;366 C6 1 7;366 E6 1 7;370 E5 1 7;370 G#5 1 7;372 F5 1 7;372 A5 1 7;374 G5 1 7;374 C6 1 7;378 C5 1 7;378 A5 1 7;380 E5 1 7;380 C6 1 7;382 F5 1 7;382 D6 1 7;388 G#5 1 7;388 D#6 1 7;394 F5 1 7;394 D6 1 7;400 E5 1 7;400 C6 1 7;416 G#5 1 7;416 C6 1 7;418 G#5 1 7;418 C6 1 7;422 G#5 1 7;422 C6 1 7;426 G#5 1 7;426 C6 1 7;428 A#5 1 7;428 D6 1 7;432 G5 1 7;432 E6 1 7;434 E5 1 7;434 C6 1 7;438 E5 1 7;438 A5 1 7;440 C5 1 7;440 G5 1 7;448 G#5 1 7;448 C6 1 7;450 G#5 1 7;450 C6 1 7;454 G#5 1 7;454 C6 1 7;458 G#5 1 7;458 C6 1 7;460 A#5 1 7;460 D6 1 7;462 G5 1 7;462 E6 1 7;480 G#5 1 7;480 C6 1 7;482 G#5 1 7;482 C6 1 7;486 G#5 1 7;486 C6 1 7;490 G#5 1 7;490 C6 1 7;492 A#5 1 7;492 D6 1 7;496 G5 1 7;496 E6 1 7;498 E5 1 7;498 C6 1 7;502 E5 1 7;502 A5 1 7;504 C5 1 7;504 G5 1 7;512 F#5 1 7;512 E6 1 7;514 F#5 1 7;514 E6 1 7;518 F#5 1 7;518 E6 1 7;522 F#5 1 7;522 C6 1 7;524 F#5 1 7;524 E6 1 7;528 G5 1 7;528 B5 1 7;528 G6 1 7;536 G5 1 7;544 E5 1 7;544 C6 1 7;550 C5 1 7;550 G5 1 7;556 G4 1 7;556 E5 1 7;562 C5 1 7;562 A5 1 7;566 D5 1 7;566 B5 1 7;570 C#5 1 7;570 A#5 1 7;572 C5 1 7;572 A5 1 7;576 C5 1 7;576 G5 1 7;578 G5 1 7;578 E6 1 7;581 B5 1 7;581 G6 1 7;584 C6 1 7;584 A6 1 7;588 A5 1 7;588 F6 1 7;590 B5 1 7;590 G6 1 7;594 A5 1 7;594 E6 1 7;598 E5 1 7;598 C6 1 7;600 F5 1 7;600 D6 1 7;602 D5 1 7;602 B5 1 7;608 E5 1 7;608 C6 1 7;614 C5 1 7;614 G5 1 7;620 G4 1 7;620 E5 1 7;626 C5 1 7;626 A5 1 7;630 D5 1 7;630 B5 1 7;634 C#5 1 7;634 A#5 1 7;636 C5 1 7;636 A5 1 7;640 C5 1 7;640 G5 1 7;642 G5 1 7;642 E6 1 7;645 B5 1 7;645 G6 1 7;648 C6 1 7;648 A6 1 7;652 A5 1 7;652 F6 1 7;654 B5 1 7;654 G6 1 7;658 A5 1 7;658 E6 1 7;662 E5 1 7;662 C6 1 7;664 F5 1 7;664 D6 1 7;666 D5 1 7;666 B5 1 7;672 C6 1 7;672 E6 1 7;674 A5 1 7;674 C6 1 7;678 E5 1 7;678 G5 1 7;684 E5 1 7;684 G#5 1 7;688 F5 1 7;688 A5 1 7;690 C6 1 7;690 F6 1 7;694 C6 1 7;694 F6 1 7;696 F5 1 7;696 A5 1 7;704 G5 1 7;704 B5 1 7;706 F6 1 7;706 A6 1 7;709 F6 1 7;709 A6 1 7;712 F6 1 7;712 A6 1 7;714 E6 1 7;714 G6 1 7;717 D6 1 7;717 F6 1 7;720 C6 1 7;720 E6 1 7;722 A5 1 7;722 C6 1 7;726 F5 1 7;726 A5 1 7;728 E5 1 7;728 G5 1 7;736 C6 1 7;736 E6 1 7;738 A5 1 7;738 C6 1 7;742 E5 1 7;742 G5 1 7;748 E5 1 7;748 G#5 1 7;752 F5 1 7;752 A5 1 7;754 C6 1 7;754 F6 1 7;758 C6 1 7;758 F6 1 7;760 F5 1 7;760 A5 1 7;768 G5 1 7;768 B5 1 7;770 D6 1 7;770 F6 1 7;774 D6 1 7;774 F6 1 7;776 D6 1 7;776 F6 1 7;778 C6 1 7;778 E6 1 7;781 B5 1 7;781 D6 1 7;784 G5 1 7;784 C6 1 7;786 E5 1 7;790 E5 1 7;792 C5 1 7;800 C6 1 7;800 E6 1 7;802 A5 1 7;802 C6 1 7;806 E5 1 7;806 G5 1 7;812 E5 1 7;812 G#5 1 7;816 F5 1 7;816 A5 1 7;818 C6 1 7;818 F6 1 7;822 C6 1 7;822 F6 1 7;824 F5 1 7;824 A5 1 7;832 G5 1 7;832 B5 1 7;834 F6 1 7;834 A6 1 7;837 F6 1 7;837 A6 1 7;840 F6 1 7;840 A6 1 7;842 E6 1 7;842 G6 1 7;845 D6 1 7;845 F6 1 7;848 C6 1 7;848 E6 1 7;850 A5 1 7;850 C6 1 7;854 F5 1 7;854 A5 1 7;856 E5 1 7;856 G5 1 7;864 C6 1 7;864 E6 1 7;866 A5 1 7;866 C6 1 7;870 E5 1 7;870 G5 1 7;876 E5 1 7;876 G#5 1 7;880 F5 1 7;880 A5 1 7;882 C6 1 7;882 F6 1 7;886 C6 1 7;886 F6 1 7;888 F5 1 7;888 A5 1 7;896 G5 1 7;896 B5 1 7;898 D6 1 7;898 F6 1 7;902 D6 1 7;902 F6 1 7;904 D6 1 7;904 F6 1 7;906 C6 1 7;906 E6 1 7;909 B5 1 7;909 D6 1 7;912 G5 1 7;912 C6 1 7;914 E5 1 7;918 E5 1 7;920 C5 1 7;928 G#5 1 7;928 C6 1 7;930 G#5 1 7;930 C6 1 7;934 G#5 1 7;934 C6 1 7;938 G#5 1 7;938 C6 1 7;940 A#5 1 7;940 D6 1 7;944 G5 1 7;944 E6 1 7;946 E5 1 7;946 C6 1 7;950 E5 1 7;950 A5 1 7;952 C5 1 7;952 G5 1 7;960 G#5 1 7;960 C6 1 7;962 G#5 1 7;962 C6 1 7;966 G#5 1 7;966 C6 1 7;970 G#5 1 7;970 C6 1 7;972 A#5 1 7;972 D6 1 7;974 G5 1 7;974 E6 1 7;992 G#5 1 7;992 C6 1 7;994 G#5 1 7;994 C6 1 7;998 G#5 1 7;998 C6 1 7;1002 G#5 1 7;1002 C6 1 7;1004 A#5 1 7;1004 D6 1 7;1008 G5 1 7;1008 E6 1 7;1010 E5 1 7;1010 C6 1 7;1014 E5 1 7;1014 A5 1 7;1016 C5 1 7;1016 G5 1 7;1024 F#5 1 7;1024 E6 1 7;1026 F#5 1 7;1026 E6 1 7;1030 F#5 1 7;1030 E6 1 7;1034 F#5 1 7;1034 C6 1 7;1036 F#5 1 7;1036 E6 1 7;1040 G5 1 7;1040 B5 1 7;1040 G6 1 7;1048 G5 1 7;1056 C6 1 7;1056 E6 1 7;1058 A5 1 7;1058 C6 1 7;1062 E5 1 7;1062 G5 1 7;1068 E5 1 7;1068 G#5 1 7;1072 F5 1 7;1072 A5 1 7;1074 C6 1 7;1074 F6 1 7;1078 C6 1 7;1078 F6 1 7;1080 F5 1 7;1080 A5 1 7;1088 G5 1 7;1088 B5 1 7;1090 F6 1 7;1090 A6 1 7;1093 F6 1 7;1093 A6 1 7;1096 F6 1 7;1096 A6 1 7;1098 E6 1 7;1098 G6 1 7;1101 D6 1 7;1101 F6 1 7;1104 C6 1 7;1104 E6 1 7;1106 A5 1 7;1106 C6 1 7;1110 F5 1 7;1110 A5 1 7;1112 E5 1 7;1112 G5 1 7;1120 C6 1 7;1120 E6 1 7;1122 A5 1 7;1122 C6 1 7;1126 E5 1 7;1126 G5 1 7;1132 E5 1 7;1132 G#5 1 7;1136 F5 1 7;1136 A5 1 7;1138 C6 1 7;1138 F6 1 7;1142 C6 1 7;1142 F6 1 7;1144 F5 1 7;1144 A5 1 7;1152 G5 1 7;1152 B5 1 7;1154 D6 1 7;1154 F6 1 7;1158 D6 1 7;1158 F6 1 7;1160 D6 1 7;1160 F6 1 7;1162 C6 1 7;1162 E6 1 7;1165 B5 1 7;1165 D6 1 7;1168 G5 1 7;1168 C6 1 7;1170 E5 1 7;1174 E5 1 7;1176 C5 1 7;0 D4 1 7;2 D4 1 7;6 D4 1 7;10 D4 1 7;12 D4 1 7;24 G4 1 7;32 G4 1 7;38 E4 1 7;44 C4 1 7;50 F4 1 7;54 G4 1 7;58 F#4 1 7;60 F4 1 7;64 E4 1 7;66 C5 1 7;69 E5 1 7;72 F5 1 7;76 D5 1 7;78 E5 1 7;82 C5 1 7;86 A4 1 7;88 B4 1 7;90 G4 1 7;96 G4 1 7;102 E4 1 7;108 C4 1 7;114 F4 1 7;118 G4 1 7;122 F#4 1 7;124 F4 1 7;128 E4 1 7;130 C5 1 7;133 E5 1 7;136 F5 1 7;140 D5 1 7;142 E5 1 7;146 C5 1 7;150 A4 1 7;152 B4 1 7;154 G4 1 7;160 C4 1 7;166 G4 1 7;172 C5 1 7;176 F4 1 7;182 C5 1 7;184 C5 1 7;188 F4 1 7;192 C4 1 7;198 E4 1 7;204 G4 1 7;206 C5 1 7;220 G4 1 7;224 C4 1 7;230 G4 1 7;236 C5 1 7;240 F4 1 7;246 C5 1 7;248 C5 1 7;252 F4 1 7;256 C4 1 7;260 G#4 1 7;266 A#4 1 7;278 G4 1 7;280 G4 1 7;284 C4 1 7;288 C4 1 7;294 G4 1 7;300 C5 1 7;304 F4 1 7;310 C5 1 7;312 C5 1 7;316 F4 1 7;320 C4 1 7;326 E4 1 7;332 G4 1 7;334 C5 1 7;348 G4 1 7;352 C4 1 7;358 G4 1 7;364 C5 1 7;368 F4 1 7;374 C5 1 7;376 C5 1 7;380 F4 1 7;384 C4 1 7;388 G#4 1 7;394 A#4 1 7;400 C5 1 7;406 G4 1 7;408 G4 1 7;412 C4 1 7;416 G#3 1 7;422 D#4 1 7;428 G#4 1 7;432 G4 1 7;438 C4 1 7;444 G3 1 7;448 G#3 1 7;454 D#4 1 7;460 G#4 1 7;464 G4 1 7;470 C4 1 7;476 G3 1 7;480 G#3 1 7;486 D#4 1 7;492 G#4 1 7;496 G4 1 7;502 C4 1 7;508 G3 1 7;512 D4 1 7;514 D4 1 7;518 D4 1 7;522 D4 1 7;524 D4 1 7;536 G4 1 7;544 G4 1 7;550 E4 1 7;556 C4 1 7;562 F4 1 7;566 G4 1 7;570 F#4 1 7;572 F4 1 7;576 E4 1 7;578 C5 1 7;581 E5 1 7;584 F5 1 7;588 D5 1 7;590 E5 1 7;594 C5 1 7;598 A4 1 7;600 B4 1 7;602 G4 1 7;608 G4 1 7;614 E4 1 7;620 C4 1 7;626 F4 1 7;630 G4 1 7;634 F#4 1 7;636 F4 1 7;640 E4 1 7;642 C5 1 7;645 E5 1 7;648 F5 1 7;652 D5 1 7;654 E5 1 7;658 C5 1 7;662 A4 1 7;664 B4 1 7;666 G4 1 7;672 C4 1 7;678 F#4 1 7;680 G4 1 7;684 C5 1 7;688 F4 1 7;692 F4 1 7;696 C5 1 7;698 C5 1 7;700 F4 1 7;704 D4 1 7;710 F4 1 7;712 G4 1 7;716 B4 1 7;720 G4 1 7;724 G4 1 7;728 C5 1 7;730 C5 1 7;732 G4 1 7;736 C4 1 7;742 F#4 1 7;744 G4 1 7;748 C5 1 7;752 F4 1 7;756 F4 1 7;760 C5 1 7;762 C5 1 7;764 F4 1 7;768 G4 1 7;774 G4 1 7;776 G4 1 7;778 A4 1 7;781 B4 1 7;784 C5 1 7;788 G4 1 7;792 C4 1 7;800 C4 1 7;806 F#4 1 7;808 G4 1 7;812 C5 1 7;816 F4 1 7;820 F4 1 7;824 C5 1 7;826 C5 1 7;828 F4 1 7;832 D4 1 7;838 F4 1 7;840 G4 1 7;844 B4 1 7;848 G4 1 7;852 G4 1 7;856 C5 1 7;858 C5 1 7;860 G4 1 7;864 C4 1 7;870 F#4 1 7;872 G4 1 7;876 C5 1 7;880 F4 1 7;884 F4 1 7;888 C5 1 7;890 C5 1 7;892 F4 1 7;896 G4 1 7;902 G4 1 7;904 G4 1 7;906 A4 1 7;909 B4 1 7;912 C5 1 7;916 G4 1 7;920 C4 1 7;928 G#3 1 7;934 D#4 1 7;940 G#4 1 7;944 G4 1 7;950 C4 1 7;956 G3 1 7;960 G#3 1 7;966 D#4 1 7;972 G#4 1 7;976 G4 1 7;982 C4 1 7;988 G3 1 7;992 G#3 1 7;998 D#4 1 7;1004 G#4 1 7;1008 G4 1 7;1014 C4 1 7;1020 G3 1 7;1024 D4 1 7;1026 D4 1 7;1030 D4 1 7;1034 D4 1 7;1036 D4 1 7;1048 G4 1 7;1056 C4 1 7;1062 F#4 1 7;1064 G4 1 7;1068 C5 1 7;1072 F4 1 7;1076 F4 1 7;1080 C5 1 7;1082 C5 1 7;1084 F4 1 7;1088 D4 1 7;1094 F4 1 7;1096 G4 1 7;1100 B4 1 7;1104 G4 1 7;1108 G4 1 7;1112 C5 1 7;1114 C5 1 7;1116 G4 1 7;1120 C4 1 7;1126 F#4 1 7;1128 G4 1 7;1132 C5 1 7;1136 F4 1 7;1140 F4 1 7;1144 C5 1 7;1146 C5 1 7;1148 F4 1 7;1152 G4 1 7;1158 G4 1 7;1160 G4 1 7;1162 A4 1 7;1165 B4 1 7;1168 C5 1 7;1172 G4 1 7;1176 C4 1 7;: 真是一大串呢~ 稍微分析一下 Online Sequencer 乐谱的格式: Online Sequencer:616413: 没有什么用,要被除掉 0 F#5 1 7; 才是重点。0 是音符所处的时间位置,F#5 是音符的音高,1 是音符的持续时间,7 是播放用的乐器类型。Online Sequencer 乐谱中每个音符都是这样的格式,用分号隔开 还要注意最后有一个 :,这个也要除掉 那么来编写一下将 Online Sequencer 乐谱转换为 Python 字典的代码吧。 先将 Online Sequencer 乐谱中需要被去除的部分去除掉,然后将乐谱中的音符部分根据 ; 分割开来: song = song_file.split(":")[2]notes_list = song.split(";")[:-1] 接着把音符的时间位置提取出来,设为字典的键,音符的音高、持续时间和乐器类型设为字典的值: notes_dict = {}for note in notes_list: note_parts = note.split() beat = float(note_parts[0]) # 如果该时间位置是初次被添加到字典当中 if beat not in notes_dict: # 添加该音符的信息到字典里 notes_dict[beat] = note_parts[1:] # 如果该时间位置并不是第一次被添加到字典当中 else: # 如果该音符的音高比字典中已有的音符的音高更高 if tones[note_parts[1]] > tones[notes_dict[beat][0]]: # 将该音符的信息覆盖到字典里 notes_dict[beat] = note_parts[1:] 由于是单个蜂鸣器播放,所以无法同时播放多个音符。当同个时间位置有多个音符时,我们只能选择音高最高的音符来播放。 至此,我们已经有了一个 Python 字典作为我们的乐谱。 播放音频 想要让蜂鸣器发出声音来很简单,代码如下: buzzer.freq(tones[tone])time.sleep(duration) buzzer.freq() 会设置蜂鸣器的频率,time.sleep() 会让程序暂停一段时间(在我们这个例子中,就是用来拉长音的)。 播放乐谱 结合上面两个部分,我们可以让字典中的音符一个一个地播放出来。但在这之前,还需要设置乐谱的播放速度。 bpm = 199 # 每分钟节拍数time_signature = [4, 4] # 拍号beat_duration = (60 / bpm) / time_signature[0] # 每拍的持续时间 聊 乐谱知识 时说到过,拍号决定了每一小节有多少拍,什么音符的时值是一拍。从我们拿来马里奥曲子的 网站 里能看到,马里奥曲子的 bpm 是 199、拍号是 4 / 4。 进行一小些计算,算出每一拍的持续时间为 (60 / 199) / 4。 继续编写播放乐谱的代码: # 从第0拍开始播放current_beat = float(0)# 播放到乐谱中最后一个音符的时间位置while current_beat <= max(notes_dict.keys()): # 取出当前时间位置的音符的持续时间(持续了多少拍),乘以每拍的持续时间,得到音符的持续时间(持续了多少秒) note_duration = float(notes_dict[current_beat][1]) * beat_duration # 如果当前时间位置有音符,就播放 if current_beat in notes_dict: note_name = notes_dict[current_beat][0] print("第" + str(current_beat) + "拍:播放音符 " + note_name + " 中,持续时间为 " + str(note_duration) + " 秒。") buzzer.freq(tones[note_name]) time.sleep(note_duration) current_beat += float(notes_dict[current_beat][1]) buzzer.freq(50000) # 如果当前时间位置没有音符,就暂停 else: print("第" + str(current_beat) + "拍:暂停,持续时间为" + str(note_duration) + " 秒。") buzzer.freq(50000) time.sleep(beat_duration) current_beat += 1print("播放结束。")buzzer.freq(50000) 一个简单的播放乐谱的程序便完成了。 最终代码可以在 这个仓库 里找到。 ———————— 最终效果 该视频实际上并未是最终版本的效果,但没有差别多少。

2023/5/16
articleCard.readMore

DiskGenius 分配空闲空间至 C 盘

如何使用 DiskGenius 将空闲空间添加至爆满的 C 盘。 前言 省流:C 盘满了。老是忘记 DiskGenius 怎么操作。故给未来的自己写个小教程。 DiskGenius 如何下载就不说了,直接开始分配。 分配空闲空间 打开我们的 DiskGenius,先把我们的 E 盘「切」掉一块,供后续喂给 C 盘。 右键 E 盘,点击 Resize partition。 先切个 20GB 出来。 在 Space of Front Part 处输入 20.00GB,然后右侧的下拉栏改成 Merge to C盘。 确认、确认、确认…… 注意图 2 是确认重启计算机。 计算机重启后再次查看 C 盘容量。不是红的了呢~

2023/4/20
articleCard.readMore

GPT-3 API 笔记

GPT-3 和 GPT-3.5-Turbo API 的学习日志,仅作个人用途。 目录 目录 事先准备 创建微调模型 查看微调工作 删除微调模型 使用微调模型 ------------------ 事先准备 pip install openai# 使用bash...export OPENAI_API_KEY="你的OpenAI API KEY" 创建微调模型 注意,GPT-3.5-Turbo API 目前 不支持 微调! 该栏块说的 API 统一默认为 GPT-3! 微调 GPT-3 所需要的数据必须是 JSONL 文件,格式为: {"prompt": "提白文本", "completion": "想要生成的文本"} 假设已经有一个 CSV、TSV、XLSX 或 JSON 文件作为数据库,则可以使用以下命令行来重新格式化数据: openai tools fine_tunes.prepare_data -f 文件名 有了 JSONL 文件后,开始微调: openai api fine_tunes.create -t JSONL文件名 -m 模型名 模型名分为:ada、babbage、curie、davinci。 从左到右,速度越来越慢,能力越来越强。 注意: 一定要仔细查看微调模型的价格表! 模型训练使用 Ada$0.0004 / 1K tokens$0.0016 / 1K tokens Babbage$0.0006 / 1K tokens$0.0024 / 1K tokens Curie$0.0030 / 1K tokens$0.0120 / 1K tokens Davinci$0.0300 / 1K tokens$0.1200 / 1K tokens 有时事件流可能会被中断,使用以下命令行来恢复微调: openai api fine_tunes.follow -i 微调工作ID 事件流被中断后会自动生成一个命令供你复制粘贴运行。 查看微调工作 列出所有被创建的微调工作: openai api fine_tunes.list 检索一个微调工作的状态和其他信息: openai api fine_tunes.get -i 微调工作ID 取消一个微调工作: openai api fine_tunes.cancel -i 微调工作ID 删除微调模型 有光就有影子,有创建就有删除: openai api models.delete -i 微调模型 Python 也可以删除一个微调模型: import openaiopenai.Model.delete(微调模型) ------------------ 使用微调模型 你可以直接使用微调成功后,自动生成的命令行: openai api completions.create -m 微调模型 -p 提白 当然主要的还是我们的 Python,这是官网示范: import openaiopenai.Completion.create( model=FINE_TUNED_MODEL, prompt=YOUR_PROMPT) openai.Completion.create() 返回一个字典: { "choices": [ { "finish_reason": "length", "index": 数字, "logprobs": null, "text": "字符串" } ], "created": 数字, "id": "字符串", "model": "微调模型", "object": "text_completion", "usage": { "completion_tokens": 数字, "prompt_tokens": 数字, "total_tokens": 数字 }} 也就是说,只要拿到 openai.Completion.create()['choices'][0]['text'],就拿到了模型返回的字符串,我们打印这个字符串就好了。 稍作修改一番: import openaiimport prompt_toolkitwhile True: user_input = prompt_toolkit.prompt('> ') response = openai.Completion.create( model=微调模型, prompt=user_input ) print(response['choices'][0]['text']) 一个简易的控制台机器人就写好了。

2023/3/10
articleCard.readMore

Bot Framework SDK 学习日志

仅作个人用途,微软 Azure 的机器人框架 SDK Python 分支的学习日志。 目录 目录 机器人初识 机器人交互 机器人应用程序结构 机器人逻辑 机器人适配器 轮次上下文 中间件 活动处理堆栈 机器人模板 机器人加深认识 管理状态 活动处理程序 对话库 三大对话框 使用对话框 配置机器人 ----------------------------- 机器人初识 机器人交互 机器人交互涉及到活动的交换,而这些活动在轮次中进行处理。 活动(activities):活动是用户或者 通道(channel) 与机器人之间的交互。 轮次(turns):一轮次的对话包含了用户传给机器人的活动,也包含了机器人发给用户的即时响应(也是活动)。 类似于回合制战斗,速度快的我方先行采取一个行动后,速度慢的对面再采取一个行动,双方行动过后该轮次(或称回合)结束。 机器人应用程序结构 * 机器人(bot)* 类,用于处理机器人应用的聊天推理 识别 & 解释用户的输入 对输入进行推理 & 执行相关任务 生成响应(如:机器人正在干什么) * 适配器(adapter)* 类,用于处理与通道的连接 提供用于处理来自用户通道的请求的方法 提供用于对用户通道生成请求的方法 包含一个中间件通道,包括机器人轮次处理程序外部的轮次处理 调用机器人的轮次处理程序 捕获不在轮次处理程序中处理的错误 机器人每个轮次还需要检索和存储 状态(state)。状态通过 存储(storage)、机器人状态(bot state) 和 属性访问器(property accessor) 类进行处理。 机器人逻辑 活动处理程序(activity handler),提供事件驱动模型,其中传入的活动类型 & 子类型是 事件(event)。 对话库(dialog library),提供基于状态的模型用于管理与用户进行的长时间聊天。 机器人适配器 适配器提供用于启动轮次的 过程活动(process activity) 方法。 将请求正文和请求头用于参数 检查身份验证头是否有效 为轮次创建一个 上下文(context) 对象 上下文对象包含有关活动的信息 通过中间件管道发送上下文对象 将上下文对象发送到机器人对象的 轮次处理程序(turn handler) 适配器还可以: 格式化 & 发送响应活动 公开机器人连接器(Bot Connector) REST API 提供的其他方法 捕获在轮次中不会被捕获到的错误 & 异常 轮次上下文 轮次上下文(turn context) 对象提供有关活动的信息。 例如发送方和接收方、通道、处理该活动所需的其他数据 轮次上下文不仅将 入站活动(inbound activity) 传递到所有的中间件组件和应用程序逻辑,还提供了所需要的机制让中间件组件和机器人逻辑发送 出站活动(outbound activity)。 中间件 SDK 的中间件由一组线性组件构成,其中每一个组件都会按照顺序执行并有一个操作活动的机会。 中间件管道的最后一个阶段:回调机器人类中的轮次处理程序(已经被适配器的过程活动方法注册)。中间件执行被适配器调用的 on turn 方法。 轮次处理程序采用轮次上下文作为参数。在轮次处理程序函数内运行的应用程序逻辑会处理入站活动的内容,并生成活动作为响应,在轮次上下文中调用 send activity 方法来发送出站活动。调用 send activity 方法会导致中间件组件在出站活动上被调用。 中间件组件于轮次处理程序函数之前和之后执行。这些执行在本质上是套娃。 活动处理堆栈 通道终结点向 Azure 机器人服务发送 HTTP POST 信息 Azure 机器人服务处理活动,发送给适配器和轮次上下文 适配器和轮次上下文调用 on turn 方法,发送给机器人 机器人调用 send activity 方法,一个个返回给通道终结点 通道终结点发送回状态码 200,机器人同理 机器人模板 资源预配 一个特定于语言的 HTTP 终结点实现,可以将传入的活动路由到一个适配器 一个适配器对象 一个机器人对象 ----------------------------- 机器人加深认识 管理状态 之前说过,机器人本质上是没有状态的。状态并不是必需的,部分机器人可以不需要状态(也就是用户不提供信息)就正常运行;部分机器人则必须提供了状态才能提供有用的聊天信息,例如以前收到的有关用户的数据。 状态就像是记忆,提供给了机器人后便能让机器人记住有关用户或者本次聊天的信息。 存储层(storage layer),在后端实际存储状态信息的一层。采用物理存储,如:内存、Azure 服务器、第三方服务器。 内存存储:临时存储,机器人一重开就清除 Azure Blob 存储:连接到 Azure Blob 存储对象数据库 Azure Cosmos DB 分区存储:连接到分区的 Cosmos DB NoSQL 数据库 状态管理(state management),自动在基础存储层中读取 & 写入机器人的状态。状态以 状态属性(state properties) 的键值对形式存储。 状态属性被集结到有范围的「桶」(帮助组织这些属性的集合)内,SDK 的三个桶分别是:用户状态(user state)、聊天状态(conversation state)、私人聊天状态(private conversation state)。这些桶又是 bot state 类的子类。 用户状态适合用于跟踪有关用户的信息,如:用户的姓名 聊天状态适合用于跟踪聊天的上下文,如:机器人是否向用户提出了问题,这个问题又是啥 私人聊天状态适合用于支持群组聊天的通道,如:课堂抢答机器人(聚合每位学生的成绩,最终用私聊方式将信息发送给相应的学生) 状态属性访问器(state property accessors),用于实际读取 & 写入某个状态属性,提供了 get、set 和 delete 方法用于从轮次内部访问状态属性。 访问器创建需要用到属性名称。之后便可以使用访问器来获取和处理机器人状态的该属性。 访问器允许 SDK 从基础存储获取状态 & 更新机器人的状态缓存(机器人维护的本地缓存,用于存储状态对象和允许在不访问基础存储的情况下执行读取 & 写入操作)。 get 方法,从状态缓存请求属性。如果在缓存中就返回属性,否则从状态管理对象获取该属性 set 方法,使用新属性值更新状态缓存 delete 方法,从缓存和基础存储中删除属性 save changes 方法(状态管理对象的),检查状态缓存中属性所有的更改,并将属性写入存储 要注意,set 方法记录更新的状态后,该状态属性尚未保存到持久性存储,只是保存到机器人的状态缓存内而已。 对话框(dialog) 库使用在机器人的 会话状态(conversation state) 上定义的对话框状态属性访问器来保留对话在会话中的位置。对话框状态属性还允许每个对话框在轮次之中存储临时信息。 对话框管理器(dialog manager) 使用用户和会话状态管理对象提供内存范围(这些内存范围可以用于自适应对话框)。 活动处理程序 生成机器人时,用于处理和响应消息的机器人逻辑将进入 on_message_activity 处理程序。同样,用于处理正在添加到聊天中的成员的逻辑将进入 on_members_added 处理程序。每当一个成员加入到聊天,这个处理程序就会被调用。 机器人逻辑处理来自单个或多个通道的传入活动,并在响应中发生传出活动。 在 Python 里,要使用 ActivityHandler 派生机器人类,前者为不同类型的活动定义各种各样的处理程序,例如上文的 on_message_activity 处理程序。 事件Handler说明 已收到任一活动类型on_turn根据收到的活动类型,调用其他处理程序。 已收到消息活动on_message_activity处理 message 活动。 已收到聊天更新活动on_conversation_update_activity收到 conversationUpdate 活动时,如果除了机器人以外的成员加入或者退出聊天,则调用某个处理程序。 非机器人成员加入了聊天on_members_added_activity处理加入聊天的成员。 非机器人成员退出了聊天on_members_removed_activity处理退出聊天的成员。 已收到事件活动on_event_activity收到 event 活动时,调用特定于事件类型的处理程序。 已收到令牌响应事件活动on_token_response_event处理令牌响应时间。 已收到非令牌响应事件活动on_event_activity处理其他类型的事件。 已收到消息回应活动on_message_reaction_activity收到 messageReaction 活动时,如果已经在消息中添加 & 删除一个或多个回应,则调用处理程序。 消息回应已添加到消息on_reaction_added处理添加到消息的回应。 从消息中删除了消息回应on_reaction_removed处理从消息中删除的回应。 已收到安装更新活动on_installation_update对于 installationUpdate 活动,根据机器人是「已安装」还是「已卸载」来调用处理程序。 安装了机器人on_installation_update_add添加逻辑来确定何时在组织单位中安装了机器人。 卸载了机器人on_installation_update_remove添加逻辑来确定何时在组织单位中卸载了机器人。 已收到其他活动类型on_unrecognized_activity_type处理未经处理的任何活动类型。 每个处理程序都有一个 turn_context,用于提供有关对应于入站 HTTP 请求的传入活动的信息。 示例: 处理 on_members_added 来发送欢迎信息,并处理 on_message 来当复读机 class EchoBot(ActivityHandler): async def on_members_added_activity( self, members_added: [ChannelAccount], turn_context: TurnContext ): """ 每当一个成员加入聊天,就发送`Hello and Welcome`。 """ for member in members_added: if member.id != turn_context.activity.recipient.id: await turn_context.send_activity('Hello and Welcome!') async def on_message_activity( self, turn_context: TurnContext ): """ 每收到一个消息,就发送`Echo: {消息}` """ return await turn_context.send_activity( MessageFactory.text(f'Echo: {turn_context.activity.text}') ) 对话库 对话框提供管理与用户长期对话的方法。 每一个对话框都代表了一个会话任务(运行完成后可以返回收集到的信息) 每一个对话框都代表了一个基本的控制流单元:可以开始、继续和停止;暂停和恢复;或被取消 对话框类似于编程语言中的方法或者函数。启动对话框时可以传入参数,且该对话框之后可以在结束时生成一个返回值 对话框可以实现 多轮会话(multi-turn conversation),所以对话框依赖于跨多个轮次的 持久性状态(persisted state)。如果对话框中没有状态,机器人就会不知道它在会话中所处的位置,也不知道它已经收集好的信息。 因此,想要在会话中保留对话框的位置,就要在每个轮次中检索对话框状态并保存到内存。这一操作由(机器人的会话状态定义的)对话框状态属性访问器处理。 类说明 对话框集(Dialog set)定义一组对话框,这些对话框可以相互引用 & 协同工作。 对话框上下文(Dialog context)包含有关所有正在活动中的对话框的信息。 对话框实例(Dialog instance)包含有关单个正在活动中的对话框的信息。 对话框轮次结果(Dialog turn result)包含活动的或最近的活动对话框中的状态信息。如果活动对话框已经结束,则包含其返回值。 为了简化管理机器人聊天,对话框库提供了一些对话框类型: 类型说明 对话框(Dialog)所有对话框的基类。 容器对话框(Container dialog)所有容器对话框的基类。 组件对话框(Component dialog)一种通用类型的容器对话框。它封装了一组对话框作为一个整体重复使用集。 组件对话框启动后,将以其集合中的指定对话框开头。内部进程完成后,组件对话框便结束。 瀑布对话框(Waterfall dialog)定义一系列步骤,使机器人能够引导用户完成线性流程。 提示对话框(Prompt dialogs)要求用户输入并返回结果。 三大对话框 组件对话框 是一种容器对话框,允许集合中的对话框调用集合中的其他对话框,如:瀑布式对话框调用提示对话框。 组件对话框还提供了一种创建独立对话框以及处理特定场景的策略,将一个大的对话框集分解成更易于管理的片段。每个片段又都有着自己的对话框集,并避免与包含它的对话框集发生任何名称冲突。 提示对话框 是一个旨在向用户询问特定类型信息的对话框,如:一个日期。 瀑布式对话框 是对话的具体实现,通常用于收集用户的信息,或者引导用户完成一系列的任务。对话的每一步都被实现为一个需要 瀑布式步骤上下文(waterfall step context) 作为参数的异步函数。 每一步,机器人提示用户输入,或者可以开启一个子对话框,等待回应,然后将结果传递给下一步。第一个函数的结果被作为参数传给下一个函数,以此类推。 对话框上下文开始瀑布 瀑布 #1:第一次提示 瀑布 #2:处理来自第一个提示的结果,并开始第二次提示 瀑布 #3:处理来自第二个提示的结果,结束对话(堆栈入口消失) 瀑布式对话框的上下文被存储在瀑布式步骤上下文中。这个步骤上下文与对话上下文类似,提供对当前轮次上下文和状态的访问。使用瀑布式步骤上下文对象来与瀑布式步骤中的对话框集进行交互。 对话框的返回值可以在瀑布式步骤中处理,也可以从机器人的轮次处理程序中处理(一般只需要在机器人的轮次论及中检查对话框轮次结果的状态)。 瀑布步骤上下文包含以下属性: Options:包含对话框的输入信息 Values:包含可以添加到上下文中的信息,并被带入后续步骤中 Result:包含前一个步骤的结果 Python 的 next 方法可以在同一轮次内继续进行瀑布式对话框的下一步,也就是在需要时跳过某个步骤。 提示(Prompt) 提供了一种简单的方法来询问用户的信息并评估他们的反应。 提示本质上就是一个两步的对话框。首先提示会要求输入,然后返回有效值,或者从头开始重新提示。 调用提示时,可以在 提示选项(prompt options) 中指定要提示的文本、如果验证失败了的重新提示,以及回答提示的选择。 一般而言,提示和重新提示属性都属于活动。 当提示被创建时,也可以选择为提示添加自定义验证(如:小于 18 岁就不行的年龄验证)。提示先行检查它是否接收到了一个有效的数字,然后运行自定义验证。假如验证失败了,就重新提示。 当一个提示完成时,就会明确地返回所要求的结果值。 提示说明返回值 附件提示(Attachment prompt)要求一个或多个附件,例如文档或者图片。一个 附件(attachment) 对象的集合 选项提示(Choice prompt)从一系列的选项中要求选择一个。一个找到的选项对象 确认提示(Confirm prompt)请求提供 Yes 或 No一个布尔值 日期时间提示(Date-time prompt)请求提供一个日期时间一个日期时间解析对象的集合 数字提示(Number prompt)请求提供一个数字一个数字值 文本提示(Text prompt)请求提供一个常规的文字输入一个字符串 步骤上下文的 prompt 方法的第二个参数要求提供一个 prompt options 对象,该对象包含了这些属性: 属性描述 初始提示(Prompt / Initial prompt)发送给用户的初始活动,用来征求用户的输入 重试提示(Retry prompt)如果用户的第一个输入没有得到验证,就发送该活动 选项(Choices)一个供用户选择的选项列表,和选项提示配合使用 验证(Validations)用于自定义验证器的额外参数 样式(Style)定义选项提示或确认提示的选项将如何呈现给用户 始终应当指定好初始提示和重试提示。假设用户的输入无效,重试提示就会发送给用户;假设重试提示没有被指定,则会发送初始提示。 但是假设发回给用户的活动来自于验证器,就不会发送重试提示。 一个验证器函数带有一个 提示验证器上下文(prompt validator context) 参数,并返回一个布尔值,代表了输入是否通过了验证。 提示验证器上下文包含了这些属性: 属性描述 上下文(Context)机器人当前的轮次上下文 识别(Recognized)一个带有被识别器处理过的用户输入信息的 提示识别器结果(prompt recognizer result) 选项(Options)包含了在调用中提供的提示选项,以启动提示 而提示识别器结果有这些属性: 属性描述 成功(Succeeded)表示识别器是否能够解析输入的内容 值(Value)识别器的返回值。如果必要,验证码可以修改此值 使用对话框 位于堆栈最顶层的被视为活动中的对话框,对话框上下文会将所有的输入引向这个活动中的对话框。 对话框开始 对话框被推入堆栈,成为活动中的对话框 对话框结束(被 replace dialog 方法移除)或者另一个对话框被推入堆栈并成为活动中的对话框 ----------------------------- 按照微软官方文档,我的 Python 版本为 3.8.3。 配置机器人 在 Python 中,机器人的配置文件为 config.py。 配置格式:XXX = os.environ.get(标识属性, 标识值)。 import osclass DefaultConfig: PORT = 3978 APP_ID = os.environ.get('MicrosoftAppId', '') APP_PASSWORD = os.environ.get('MicrosoftAppPassword', '') 标识属性标识值 MicrosoftAppTypeMultiTenant(多租户) MicrosoftAppId机器人的应用 ID MicrosoftAppPassword机器人的应用密码 MicrosoftAppTenantId多租户可无视

2023/3/8
articleCard.readMore

通过 Docker 在移动端使用 VSCode

利用 Docker 和 code-server 在浏览器中访问 Visual Studio Code,从而在移动端使用它。 开始阅读前,需要知道: 鉴于我的电脑为 Windows 10,安装 Docker 时使用的自然也会是 Windows 版本 Docker 镜像将会拉取的是最新版的 Ubuntu 安装 Docker Desktop 进入 Docker Desktop 下载网址,点击 Download Docker Desktop。 安装完成后点击 Close 关闭界面。 下图为 Docker Desktop 的界面: 在终端内输入 Docker 检查版本的命令,检查是否安装成功: 创建容器 打开任一终端,拉取 Ubuntu 镜像: docker pull ubuntu 继续在终端内创建空容器: docker run -it --name CodeServer ubuntu:latest /bin/bash 此时终端的目标路径变为了 root@巴拉巴拉巴拉:/#,输入以下命令安装 curl 库: apt-get updateapt install curl 安装 code-server 详情请查看 code-server 仓库。 依然是在终端内,执行以下命令来安装: curl -fsSL https://code-server.dev/install.sh | sh 为了能够让其他设备也可以访问网页,需要执行以下命令: code-server --link 我在这里换了个终端,实际上不需要更换。 进入最后一行提供的网址,进入 GitHub 页面登录。 我因为先前登陆过,直接导向了 IDE 网址。 最终会提供给你一个 IDE 网址。 进入该网址,便会看到熟悉的 Visual Studio Code。 移动端访问 到这儿就已经很明显要怎么做了。 进入手机的浏览器,输入上面的 IDE 网址,就可以在手机上使用 Visual Studio Code 了。

2023/1/27
articleCard.readMore

ffmpy3 批量合并 ts 文件

如何使用 Python 的 ffmpy3 包来批量合并 ts 文件至单个 mp4 文件。 开始阅读前,需要知道: ffmpy3 是 FFmpeg 的 Python 包装器 ffmpy3 根据提供的参数和选项来编译 FFmpeg 命令行 ffmpy3 使用 安装 ffmpy3 包 使用 pip 来安装: pip install ffmpy3 ffmpy3 简单例子 import ffmpy3ff = ffmpy3.FFmpeg ( inputs = {'输入文件': '参数1'}, outputs = {'输出文件': '参数2'}) 最终得出结果相当于在终端里输入: FFmpeg 参数1 -i 输入文件 参数2 输出文件 批量合并 ts 文件 路径构造 ├───文件夹│ Python文件.py│ file.txt│ 文件A.ts│ 文件B.ts│ 文件C.ts│ 文件D.ts... file.txt 在 file.txt 内部写下 ts 文件名: file '文件A.ts'file '文件B.ts'file '文件C.ts'file '文件D.ts' 注意: 使用单引号,而非双引号,后者会导致报错! 引号内的路径使用相对路径 Python 文件 使用这段代码来批量合并 ts 文件: ff = ffmpy3.FFmpeg( inputs={f'file.txt': '-f concat'}, outputs={f'文件名.mp4': '-c copy'})ff.run() 上述代码相当于在终端中输入: FFmpeg -f concat -i file.txt -c copy 文件名.mp4

2023/1/21
articleCard.readMore

使用 Flask-Mail 和 Ionos 邮箱发送邮件

该文档讲述了如何使用 Flask-Mail 库和 Ionos 的邮箱来发送邮件。 开始阅读前,需要知道: Flask 是一个使用 Python 编写的 Web 应用框架 Flask-Mail 提供了接口以让 Web 应用程序向客户端发送邮件 Flask 和 Flask-Mail 都可以使用 pip install 来安装 Ionos 是一家网络托管公司,提供域名注册、SSL 证书、电子邮件等服务 Ionos 的电子邮件可在域名注册后获得 配置 Flask-Mail 请先确保你已经从 Ionos 购买 了一个域名,且创建了一个邮箱。 在 app.py 中输入以下几行进行配置。 Ionos 邮箱可在 Ionos 的邮箱概述 中找到。 app.config['MAIL_SERVER'] ='smtp.ionos.com'app.config['MAIL_PORT'] = 587app.config['MAIL_USERNAME'] = '你的Ionos邮箱'app.config['MAIL_PASSWORD'] = '你的Ionos邮箱密码'app.config['MAIL_USE_TLS'] = Trueapp.config['MAIL_USE_SSL'] = False 配置完毕后加上这行代码。 该行代码必须写在以上配置的后面,否则会出错。 mail = Mail(app) 发送邮件 导入 Flask-Mail 包。 from flask_mail import Mail, Message 将 URL 与函数绑定。 @app.route('/')def index(): return "Hey!" 创建 Message 实例和邮件内容。 msg = Message("这里是主题", sender="你的Ionos邮箱", recipients=['收件人的邮箱'])msg.body = "这里是邮件内容" 使用 Mail 实例发送邮件。 mail.send(msg) 整体代码如下。 @app.route('/')def index(): msg = Message("这里是主题", sender="你的Ionos邮箱", recipients=['收件人的邮箱']) msg.body = "这里是邮件内容" mail.send(msg) return "Hey!"

2022/11/7
articleCard.readMore

Speller 笔记

CS50 计算机科学入门第 5 周作业 Speller 笔记,仅作个人用途。 目录 目录 作业介绍 DICTIONARY.H DICTIONARY.C SPELLER.C 作业目的 规则 作业介绍 在 speller.c 文件中,学生需要编写一个程序,该程序的目的是在将磁盘上的单词字典加载到内存中后对文件进行拼写检查。 同时,这个字典是在一个叫做 dictionary.c 的文件中实现的。其中的函数原型不在 dictionary.c 中定义,而是在 dictionary.h 中。这样 speller.c 和 dictionary.c 都可以 #include 这个文件。 DICTIONARY.H dictionary.h #include 了一个叫做 stdbool.h 的文件,它定义了 bool 本身。 学生还要注意 #define 的使用,这是一个 预处理器指令,定义了一个名为 LENGTH 的常数(constant),其值为 45。常数无法在代码中被改变, clang 会在你自己的代码中用 45 替换掉所有提到的 LENGTH。换句话说,常数并非一个变量,而是一个「查找和替换」。 最后,学生要注意五个函数的原型: check 、 hash 、 load 、 size 和 unload。其中 check 、 hash 和 load 使用指针作为参数(有 * 符号)。 DICTIONARY.C 在 dictionary.c 的顶部定义了一个叫做 node 的结构(struct),它代表了哈希表中的一个节点。 一个全局指针数组(global pointer array) table 也被声明,它将代表你用来跟踪字典中的单词的哈希表。这个数组包含 26 (暂时) 个节点指针,以便于下面描述的默认哈希函数相匹配。这个数字能够被改变,取决于学生自己想如何实现哈希。 注意到程序的代码里只有一个基于单词首字母的样本算法来实现哈希。学生该做的便是尽可能巧妙地重新实现这些函数。 SPELLER.C speller.c 不需要被编辑。通过一个叫做 getrusage 的函数,程序将对学生的 check 、 load 、 size 和 unload 的实现进行「基准测试」,也就是对执行进行计时。 还需要注意的是程序如何逐字逐句地传递要检查的某个文件的内容。最终,程序会报告该文件中的每个拼写错误,以及一系列的统计数据。 作业目的 学生的目的是尽可能有效地使用哈希表来实现 load 、 hash 、 size 、 check 和 unload,并使每个函数运行所需的时间都达到最小。 规则 speller.c 和 Makefile 不能被修改 dictionary.c 可以被修改,但五个函数的声明无法被修改 可以在 dictionary.c 中添加新的函数和变量 dictionary.c 中 N 的值可以被修改 dictionary.h 可以被修改,但五个函数的声明无法被修改 check 的实现必须是不区分大小写的。如果「foo」在 dictionary 中,那么 check 应该在任何字母大写的情况下返回 true check 的检查实现应该只对实际在 dictionary 中的单词返回 true 可以假设传递给程序的任何字典的结构与作业提供的完全一样:按字母顺序从上到下排序、每行一个词、每行以 \n 结尾。也可以假定字典至少包含一个词、没有一个词的长度超过 LENGTH 、没有一个词会出现一次以上、每个词只包含小写字母、可能还有单引号,以及没有一个词以单引号开头 可以假设 check 仅让包含字母字符和单引号的单词通过 程序可能只接受文本和可选的字典作为输入。学生可能倾向于对默认字典进行预处理,以便得出一个理想的哈希函数,但学生不能为了获得该优势而将任何这种预处理的输出保存在磁盘上以及程序后续运行时重新将其载入内存 必须不泄露任何内存,一定要用 valgrind 检查泄露 学生所写的哈希函数最终应该是学生自己的,而非在网上搜索的

2022/10/5
articleCard.readMore

AP 计算机科学原理课笔记

高中大学选修计算机科学原理课(CSP)课堂笔记,仅作个人用途。 目录 目录 AP 考试语言 Assignment, Display, and Input Arithmetic Operators and Numeric Procedures Relational and Boolean Operators AP 考试语言 Assignment, Display, and Input On the AP ExamIn Snap!备注 a <- expression- set a to expressiona 是用户自定义的 global variable DISPLAY(expression)- say expression - think expression - write expression size n INPUT()- ask expression and wait - answer Arithmetic Operators and Numeric Procedures On the AP ExamIn Snap!备注 a + b a - b a * b a / b- a + b - a - b - a x b - a / b a MOD b- a mod b RANDOM(a, b)- pick random a to b Relational and Boolean Operators On the AP ExamIn Snap!备注 a = b a ≠ b a > b a < b a ≥ b a ≤ b- a = b - a > b - a < b在 Snap!中,≠、≥和≤符号并不存在

2022/10/4
articleCard.readMore

AP 政治课笔记

高中大学选修政治课课堂笔记,仅作个人用途。 目录 目录 意识形态 社会政策 政治党派 经济政策 政策制定 政治文化与社会化 美国人对政府和政治的态度 政治社会化 意识形态的变化 政治事件对意识形态的影响 公众观点 衡量民意 美国民主的基础 民主理想 国会 国会行为 总统通讯 扩大总统权力 对总统职位的制约 总统的权力 意识形态 社会政策 单词释义 Government Intervention- regulatory actions taken in order to affect decisions made by individuals, groups, or organizations regarding social and economic matters Individual Liberties- personal freedoms that the government cannot abridge, particularly those guarantees found in the Bill of Rights Right To Privacy- the right to be free of government scrutiny into one's private beliefs and behavior Social Policy- public policy related to health care, human services, criminal justice, inequality, education, and labor SocialEconomic More Control1. Affirmative action is a good thing 2. Marriage should be between a man and a woman 3. We need tougher penalties and policing to deal with crime1. Everyone has a right to quality healthcare 2. Rent control is a good thing 3. Social safety nets are crucial 4. Every worker has the right to a living wage Less Control1. Marijuana should be legalized 2. Laws restriction gun rights are unconstitutional and won't have the intended impact 3. Same-sex couples should have full marriage rights1. Government intervention in the free market will slow the economy, hurting everyone 政府应在多大程度上进行干预,以促进社会和经济平等? 政府政策应在多大程度上尊重个人隐私或促进传统道德? 为了保护公共安全而限制个人自由是否是可以允许的? 公民如何回答以上问题,可以显示出他们对政府在塑造社会方面的适当作用的意见的尖锐分歧,这被称为社会政策(social policy)。 政治党派 美国的两个主要政党 ———— 民主党和共和党 ———— 分别与自由派(liberal)和保守派(conservative)的意识形态紧密对应。这些意识形态影响着美国的政策辩论,而这些辩论往往涉及到政府对经济或社会行为的适当干预。 尽管个人的意识形态与他们的政党选择之间有很强的相关性,但美国人在经济和社会问题上持有一系列观点,并不能只是整齐地归入简单的「左 - 右」。 Liberal Ideologies tend to favor more government intervention in order to promote social and economic equality 更多支持废除死刑;认为死刑代表了残酷的惩罚,因此比起死刑更多会支持预防犯罪的政策 认为强大的联邦政府更能保证社会内的平等 支持保底周薪(wages) 认为如果没有政府干涉,商业会压榨员工导致经济不平衡 反对政府干涉私人生活,例如限制了同性婚姻的法律或政策 支持合法化大麻 认为政府应当提供大范围的社会服务以保证社会平等和幸福 Conservative Ideologies tend to oppose government intervention in order to promote social and economic equality 支持死刑 认为自由市场会奖励有天赋且努力工作的人 反对政府限制 Bill of Rights,例如用武器保护自己 反对堕胎;比起 pro-liberty 更 pro-life 强烈支持政府花钱在军事 & 国际安全上 多数认为大麻合法化会导致社会不纪律 & 不安全 支持禁带有危险主题的书;认为这些书籍不应该免费提供给年轻孩子,并认为孩子父母应该负责孩子阅读的书籍类型 认为政府应当是小规模的,主要于州或当地运营 Social conservatives 认为政府应该支持 traditional morality,例如限制堕胎、同性婚姻 Libertarian Party / Ideologies 美国第三大政派 反对政府通过任何与经济和社会有关的政策 认为政府的存在只应该是为了保护私人财产和其他小事 支持合法化赌博 反对扩大政府以解决社会和经济问题 认为政府不应该有支持或反对宗教的行为 Progressive Ideologies 认为政府应当解决过去的错误并改革导致了不利因素的系统问题 为了重建社会和系统机制,会支持更强的联邦计划 Communitarians 支持将社区需要放在个人需要之上的法律 多数在经济上自由,但社会上保守 为了保护社区,有些支持死刑 Democratic Party 美国最大的政派之一 the world's oldest active political party would be most likely to oppose reduced sentencing for nonviolent felony offenders(非暴力重罪罪犯) would be most likely to oppose reduced sentencing for nonviolent felony offenders(非暴力重罪罪犯) Republican Party 美国最大的政派之一 would most likely to support opening public lands for ranching and oil exploration would most likely to support opening public lands for ranching and oil exploration Green Party 美国第四大政派 支持更强大的联邦政府 其候选人通常支持基层民主(grassroots democracy)、非暴力、社会正义,以及环境主义 Nationalists 支持促进国家利益 通常认为自己的国家比他国优越 因为了最大化国家利益,可能会支持死刑 经济政策 单词释义 The Federal Reserve- also called "The Fed" - an independent federal agency that determines US monetary policy with the goal of stabilizing the banking system and promoting economic growth Fiscal Policy government decisions about how to influence the economy by taxing and spending 由国会控制 指政府如何对公民征税和花钱,或政府的资金进出预算 Monetary Policy government decisions about how to influence the economy using control of the money supple and interest rates 由 the Federal Reserve 决定 指政府如何通过调整货币供应量和利率来控制货币价值 Keynesian Economics an economic philosophy that encourages government spending(through the creation of jobs or the distribution of unemployment benefits)in order to promote economic growth 认为当经济糟糕时,政府应该多干预市场 于 1929-1939 的大萧条时期中盛行;以政府支出和管控来促进经济 例如政府盖太阳能农场以创造更多的工作 Supply-Side Economics an economic philosophy that encourages tax cuts and deregulation in order to promote economic growth 认为当经济糟糕时,政府应该少干预市场 libertarian 相比较于 conservative 会更反对政府干预市场,他们觉得政府就不应该干预市场 1970 年时 conservative 开始反对政治支出,认为减税和放松管制才能促进繁荣 反对政府消费 强调降低税收和以低价格刺激商品和服务的供应来增加就业 也称作为 Trickle Down Policy 为了经济增长提议减少商业税,因此商业能够用省下来的钱投资以增加更多的工作 政策制定 单词释义例子 Policy Mood- measure of the public's preferences toward policy choices - economic and cultural attitudes Position Issues- divide voters, opinions don't overlap - an issue that divides voters1. gun control 2. death penalty Valence Issues- most voters agree with the values if not the approach - an issue most voters will agree with1. economic prosperity 2. caring for the elderly 公共政策反映了当时选择参与政治的公民的态度和信念,因此随着公民态度和信念的变化,政策也在逐年变化。 政党将政策问题的解决方案作为其政纲的一部分,但这些解决方案也会随着公众态度的变化而变化。当政党进行研究和观察民意调查时,他们会调整他们提出的政策解决方案,以最好地反映他们期望的选民的需求。 在所有这些政策议程波动的基础上,公众倾向于对总统的做法失去信心。总统在任时间越长,他们的意识形态立场就会失去支持。两届总统在任的第七年是意识形态支持率最低的时候。 也就是说,总统在职的时间越长,公众就越希望有新的解决方案,从而导致公众偏向相反党派的意识形态。 政府不会一次只针对一项政策,因为这样做的效率并不高。 当针对某项的公众支持率上升时,国会成员便会引入与该项相关的法律。 政治文化与社会化 美国人对政府和政治的态度 大多数意识形态范围内的美国人相信着的核心价值观(core values): 机会平等(equality of opportunity) 法治(the rule of law) 有限政府(limited government) 自力更生/个人主义(self-reliance / individualism) 自由企业(free enterprise) 但是对于这些价值观含义的解释可能会有很大的差异,不同的公民对某些价值观的重视程度高于其他价值观。 而这些对价值观的解释和相对重要性的差异形成了公民对政府作用的信念,并在决定人们在重大选举中支持哪一个政党方面发挥了作用。 单词释义 American Political Culture- the value that influence individuals' attitudes and beliefs about the relationship between citizens and the federal government Equality of Opportunity- the belief that each person should have the same opportunities to advance in society - everyone should have the opportunity to be healthy, regardless of their family income - all should have ability to compete on a level playing field where success is determined by hard work and talent Free Enterprise- the belief in the right to compete freely in a market government by supply and demand with limited government involvement - the government should not involve itself in citizens' health; that would give it too much power over their private lives - an economic system of which the government does not put their hands on private company Equality of Opportunity- the belief that each person should have the same opportunities to advance in society - everyone should have the opportunity to be healthy, regardless of their family income - all should have ability to compete on a level playing field where success is determined by hard work and talent Free Enterprise- the belief in the right to compete freely in a market government by supply and demand with limited government involvement - the government should not involve itself in citizens' health; that would give it too much power over their private lives - an economic system of which the government does not put their hands on private company Ideology- the beliefs and ideas that help to shape political opinion and policy Individualism- the principle of valuing individual rights over those of the government, with a strong emphasis on individual initiative and responsibility - values of individual rights / freedom over the government - protecting personal freedoms from government interference - must be balanced with responsibility of government to provide order and stability Limited Government- a political system in which there are restrictions placed on the government to protect individual rights and liberties - limitations on the power of government Rule of Law- the principle that government is based on a body of law applied equally and fairly to every citizen, not on the whims of those in charge, and that no one is above the law, including the government - principle by which all people and institutions are equally accountable towards the law Individualism- the principle of valuing individual rights over those of the government, with a strong emphasis on individual initiative and responsibility - values of individual rights / freedom over the government - protecting personal freedoms from government interference - must be balanced with responsibility of government to provide order and stability Limited Government- a political system in which there are restrictions placed on the government to protect individual rights and liberties - limitations on the power of government Rule of Law- the principle that government is based on a body of law applied equally and fairly to every citizen, not on the whims of those in charge, and that no one is above the law, including the government - principle by which all people and institutions are equally accountable towards the law 价值观含义的解释差异举例: A 认为 individualism 指的是政府不应该干涉美国公民的任何经济或社会习惯 B 认为 individualism 指的是政府不应该干涉由 Bill of Rights 保护的权利;但如果有商业或州侵犯了这些权利,政府应当干涉 政治社会化 单词释义例子 单词释义例子 ------------ Demographic Characteristics- socioeconomic characteristics of a population that influence how individuals tend to vote and whether they identify with a political party1. age, race, gender, religion, marital status, occupation, education level and more Globalization- the growth of an interconnected world economy and culture, fueled by lowered trade barriers between nations and advances in communications technology - has influenced American politics by increasing the extent to which the United States influences, and is influenced by, the values of other countries Party Identification- an individual's sense of loyalty to a specific political party Political Socialization- the process by which a person develops political values and beliefs, including through interactions with family, friends, school, religious and civic groups, and the media - complicated manner in which an individual's sense of politic identity, politic party affiliation, and values related to government are shaped by broader culture Polarization- the divergence of political attitudes away from the centre, towards ideological extremes Political Efficacy- the belief that an individual can impact political outcomes Influence of factors on political beliefs 因素解析 家人- 最能影响到个体政治社会化的因素,包括意识形态和政党偏向 - 孩童会通过讨论和媒体重复性地受父母的观点影响 朋友- 与朋友相处会影响到态度培养 - 没有家人那么有影响力 教育等级- 公立学校的作用便是教授基本政府、民主观点、爱国主义,以及促进参政 - 教育等级越高,越可能参政,投票率也会因政治效能(political efficacy)而变高 社会群体- 成员通常已经有了共同的价值观和人生目标 - 群体中的社会互动对成员的政党归属和对政策问题的看法有影响 宗教群体- 群体中的领导将直接讨论特定的政策问题 - 通过群体在特定政策问题所站的立场来影响个体的政治思想 地理- 来自特定地区的人们倾向于有着共同的观点 - 乡村和城市的住民也可以有相同的利益 年龄- 在人生的不同阶段中个体的政治态度都会发生可预测的变化 媒体- 让个体对政治方面的背景信息、政府的价值有了更全面的认知 - 可以让个体选择信息来源,例如有着共同价值观的信息来源 | Political Socialization | - the process by which a person develops political values and beliefs, including through interactions with family, friends, school, religious and civic groups, and the media - complicated manner in which an individual's sense of politic identity, politic party affiliation, and values related to government are shaped by broader culture | | | Polarization | - the divergence of political attitudes away from the centre, towards ideological extremes | | | Political Efficacy | - the belief that an individual can impact political outcomes | | Influence of factors on political beliefs 因素解析 家人- 最能影响到个体政治社会化的因素,包括意识形态和政党偏向 - 孩童会通过讨论和媒体重复性地受父母的观点影响 朋友- 与朋友相处会影响到态度培养 - 没有家人那么有影响力 教育等级- 公立学校的作用便是教授基本政府、民主观点、爱国主义,以及促进参政 - 教育等级越高,越可能参政,投票率也会因政治效能(political efficacy)而变高 社会群体- 成员通常已经有了共同的价值观和人生目标 - 群体中的社会互动对成员的政党归属和对政策问题的看法有影响 宗教群体- 群体中的领导将直接讨论特定的政策问题 - 通过群体在特定政策问题所站的立场来影响个体的政治思想 地理- 来自特定地区的人们倾向于有着共同的观点 - 乡村和城市的住民也可以有相同的利益 年龄- 在人生的不同阶段中个体的政治态度都会发生可预测的变化 媒体- 让个体对政治方面的背景信息、政府的价值有了更全面的认知 - 可以让个体选择信息来源,例如有着共同价值观的信息来源 Influence of demographic factors on party identification 特征在统计上更有可能被认为为民主党在统计上更有可能被认为为共和党 性别- 女- 男 收入- 低收入- 高收入 婚姻状态- 单身 - 离婚 - 守寡- 已婚 职业状态- 蓝领- 白领 - 居家工作的女性 种族- African Americans - Hispanic Americans- Whites 宗教- 犹太人 - 无宗教信仰者- 新教徒(基督教徒) - 天主教徒 地区- Northeasterners - Westerners- Midwesterners - Southerners 年龄- 年轻或年老的美国人- 中年美国人 性别- 女- 男 收入- 低收入- 高收入 婚姻状态- 单身 - 离婚 - 守寡- 已婚 职业状态- 蓝领- 白领 - 居家工作的女性 种族- African Americans - Hispanic Americans- Whites 宗教- 犹太人 - 无宗教信仰者- 新教徒(基督教徒) - 天主教徒 地区- Northeasterners - Westerners- Midwesterners - Southerners 年龄- 年轻或年老的美国人- 中年美国人 Union Membership- union members- non-union members 父母亲的政治立场- parents strongly aligned with Democratic Party- parents strongly aligned with Republican Party 父母亲的政治立场- parents strongly aligned with Democratic Party- parents strongly aligned with Republican Party Political Socialization one example of the process of political socialization in school is students are required to take an American government course to graduate from high school media, family, and school are the major agents of political socializations Liberty V.S. Order must be balanced in order for society to function optimally policy outcomes reflect preference of voters and policymakers at any given time changing power of various groups leads to policy changes for example: Alcohol prohibition in 1920 got repealled in 1933 excessive personal liberty will lead to chaos, while excessive social order will stifle personal liberty and creativity Culture War deep divisions and increase in polarization between Americans who wish to return to an idealized culture based on traditional values and Americans who favor change 于保守派和自由派之间的政治极化(political polarization)和冲突便导致了文化战争(culture war) 政治组织和其利益群体通过媒体带动了积极性 / 热情(enthusiasm)和政党支持,并驱使了政治极化,让公民们越来越能接受极端立场 意识形态的变化 单词释义例子 Generational Effects- experiences shared by a group of people who came of age together that affect their political attitudes- wars and economic recessions that hit one generation particularly hard have lasting effects on the political attitudes of that generations as its members progress through life Lifecycle Effects- changes over the course of an individual's lifetime, which affect their political attitudes and participation- as individuals develop from young people to adults to senior citizens, their concerns and values change Period Effects- major events and social trends that affect the political attitudes of the entire population- the terrorist attacks on September 11 and the Watergate scandal had lasting effects on the political attitudes of those who lived through them 政治事件对意识形态的影响 单词释义 Formative Age- young adulthood, between ages 18 and 24, when many people form long-lasting political attitudes Party Realignment- a sharp change in the issues or voting blocs that a party represents Period Effects- major events and social trends that affect the political attitudes of the entire population 除开导致了政治社会化的因素外,政治事件也能够强烈地影响到个体的意识形态。 部分社会学家认为,绝大多数人的政治观念会在 18 到 24 岁之间建立完成。而在这个年龄段内发生的重要事件都会对人们的三观产生强烈的影响。 例如在大萧条中成年的人(18 到 24 岁)会比其他没有在经济危机中成年的人更显现出在消费观念上的保守。 公众观点 衡量民意 投票(polls)是一个用于衡量民意的常用方法。而民意代表了公众如何看待问题、选举人,以及官员。 Types of Polls 单词释义 Opinion Poll- determine beliefs citizens have Benchmark Polls- used at beginning of a campaign that can be used later for comparison Tracking Polls- ask individuals the same questions at different time intervals to measure data trends over time Entrance Polls- given to voters before voting Exit Polls- given to voters after voting Push Polls- propaganda technique designed to manipulate voter opinion with bias questions or by spreading false information Sampling 单词释义 Sample- a small group to study to make predictions about the larger population - the group of people a researcher surveys to gauge the whole population's opinion - Sampling(Margin of)Error- potential discrepancy between poll results and actual opinion - as the sample size increases, the sampling error decreases - the predicted difference between the average opinion expressed by survey respondents and the average opinion in the population Mass Survey- typically use 1,000 to 2,000 respondents Random Sample- each member of a population mush have an equal probability of selection Representative Sample- sample must accurately and proportionally mirror the diversity of the population being surveyed - politic scientist mathematically weight certain demographic characteristics - a relatively small number of respondents who accurately reflect the variety of opinions, demographics, etc. in the broader population Sampling Technique- the process by which pollsters select respondents to a survey or the sample population for a poll Random Sample- a random selection from a population, random sampling techniques ensures an equal probability of individuals being selected for a survey or poll Question Design 单词释义 Forced Choice > Open Ended- produce more accurate results and simplify data calculation and analysis Question Wording- should be concise and neutral in tone to avoid influencing responses - no emotionally charged language Question order can influence result of future poll questions Some polls force respondents to answer questions about things they don't know "I don't know" options can increase accuracy of results Focus Groups small group of people who participate in a structured discussion led by a moderator to discover insight into public opinion can provide qualitative data and insight into voter perception of issues do not provide quantitative data about the broader population Polls and Elections polls and focus group data used to form messaging in advertising and preparation for public debates tailor policy stances to align with voter opinion candidates with high polling ratings will be invited to debates, therefore(free)media coverage and fundraising increase | 单词 | 释义 | | ---- | ---- | | Bandwagon Effect | - as the candidate popularity increases, number of others getting on the bandwagon increases as well | Polling and Public Policy politicians use polling to figure how public feels about proposed or existing public policies, and what problems and solutions are favored by the public politicians up for reelection are more impacted by public opinion Polling Reliability and Veracity(Validity) 单词释义 Reliability- consistency or repeatability of a survey Veracity(Validity)- data accuracy Predictive Validity- how accurate a poll is at forecasting future behaviors Polling Accuracy- does poll accurate measure the full topic area being studied 美国民主的基础 民主理想 美国政治建立在 limited government 的思想之上,包含了 natural rights 、popular sovereignty 、republicanism,以及 social contract。 该思想认为政府应当有种种限制才能够保护个体的权利和公民的自由。 美国在宣言独立时便在思考除了君主制外的政府机制,以及什么样的政府才能够在不侵犯公民的个体自由的情况下保护他们。最终得到的结果便是 limited government,以及其的四个子思想。 美国政府建立起前,构造政府的人决定看向政治哲学家 ———— Thomas Hobbes 和 John Locke。 Natural Rights 美国宣言独立的最主要问题便是英国的君主制侵犯了美国人民的 natural rights。 Natural rights sometimes called unalienable rights are rights the Framers believed all people are born with and can never give up John Locke 认为 natural rights 是 个体的人生、自由,和所有物的权利。这一思想感染了 Thomas Jefferson,使他描绘 natural rights 为 个体的人生、自由,和追求幸福的权利。 Social Contract 为了政府去保护美国公民的 natural rights,美国公民也要视政府为有权柄权威的。而 social contract 便是美国社会赞同牺牲部分自由,以换取联邦政府的保护。 Thomas Hobbes 认为 social contract 是政府的基础。 Popular Sovereignty Popular sovereignty the idea that the government's power comes from the will of the people or the "consent of the governed" 当政府开始去侵犯公民的意愿,那公民有权利去改变这一切。正因为英国君主制的权利愈来愈强大,且不再聆听殖民者的意愿,才导致了殖民者后来的反叛和脱离。构思美国政府的人认为,这些殖民者拥有反叛的权利。 政府权力在政党之间转移时,也表明了人民的意愿也发生了变化。政党是美国政治的一大重要角色,因为它们代表了人民对政府的意愿和观点。 Republicanism 构思美国政府的人认为政府的最佳形态应是民选领导人来代表人民的利益。这也就是 republicanism,共和主义。 然而最初的领导人投票仅向拥有土地的白人男性开放,直到 200 年后才向全部的美国公民开放。 独立宣言和宪法都为美国的民主政府提供了其意识形态的基础:独立宣言提供了 popular sovereignty 的基础,而宪法勾勒出了美国政府系统的蓝图。 The Declaration of Independence 参与了写独立宣言的 Thomas Jefferson 因受 John Locke 的启发,认为所有人一出生都拥有着 natural rights,以及政府是人民和领导者之间的 social contract。 他写下了独立宣言的草稿,并让 John Adams 和 Benjamin Franklin 来进行修改。简单而言,独立宣言就是一份对英国君主制的抱怨清单。但在更高的层面上,独立宣言表达的思想是美国民主价值观的灵感来源。 The Constitution 尽管独立宣言是美国民主的灵感来源,但它并没有写下政府具体是什么样的。革命后美国政府首先有的政府系统是 Articles of Confederation,也就是联邦条例。该系统将大多数的权利都给予在了州政府的手中。 因为种种原因,联邦条例并不强悍,反而非常的弱小。很明显这是一个失败品,美国需要一个更强大的政府系统。 各州的代表举行了 Constitutional Convention 以探讨什么政府既强大到可以支撑美国的需求,又不会强大的十分强横。 在 George Washington 的领导下,代表们最终得出了最终版本的宪法,且该宪法深受 James Madison 和 Alexander Hamilton 的影响。 单词释义 Constitutional Convention- also called the Philadelphia Convention - a meeting of delegates from 12 of the 13 states to revise or replace the Articles of Confederation with a new Constitution featuring a stronger central government Limited Government- a political system in which the government's power is restricted by laws or a written Constitution Natural Rights- the right to life, liberty, and property, which no government may take away Republicanism- the principles of governing through elected representatives Social Contract- an agreement between people and government in which citizens consent to be governed so long as the government protects their natural rights 人名贡献 John Adams- Massachusetts statesman and leader in the movement for American independence - aided Jefferson in drafting the Declaration of Independence Ben Franklin- Pennsylvania statesman and leader in the movement for American independence - aided Jefferson in drafting the Declaration of Independence Alexander Hamilton- New York statesman who promoted replacing the Articles of Confederation with a stronger central government - coauthored the Federalist Papers, which argued in favor of ratifying the Constitution Thomas Jefferson- principal author of the Declaration of Independence James Madison- Virginia statesman and major contributor to the US Constitution - coauthored the Federalist Papers and wrote the Bill of Rights George Washington- Revolutionary War general who presided over the Constitutional Convention 政府权力与个体权利 当构思美国政府的人们打算将联邦条例替换掉时,联邦党人(Federalist)和反联邦党人(Anti-Federalist)辩论着不同结构的政府。 单词释义 Articles of Confederation- the first government system of the United States, which lasted from 1776 until 1789 - the Articles placed most power in the hands of state governments - government under the Articles lacked an executive or a judicial branch Confederation Congress- the central government under the Articles of Confederation, composed of delegates chosen by state governments - each state had one vote in the Congress, regardless of its population - the Congress had difficulty legislating as the Articles required nine of the thirteen states to vote to approve any measure, and a unanimous vote in order to amend the Articles themselves 辩论中有两份重要的文档:Federalist No. 10 和 Brutus No. 1。前者是由 James Madison 编写的,持强大的中央政府能够控制所有由派系带来的影响这一观点;后者则认为一个强大的中央政府并不能满足所有美国公民的需求。 民主类型 当美国建国时,建国者们创建的是 Democratic Republic,一种权力来源于人民,但人民的利益由民选官员代表的政府系统。 美国政府有着许多不同的级别和部门,而这些级别和部门任何美国公民都有可能接近。政治理论家们对于美国公民是否可以影响政府行为这一观点上催生出了三种流行的民主模式,即 Participatory 、 Pluralist,和 Elite。 Participatory Democracy a model of democracy in which citizens have the power to decide directly on policy and politicians are responsible for implementing those policy decisions 参与式民主:公民有权做出政策决定。 参与式民主强调公民广泛参与政治,只是公民并非负责制定政策决定,而是影响它们。负责执行政策决定的依然是政客。 美国并没有纯粹的参与式民主,但是能在地方政府和州政府中看到参与式民主的影子。 例如市政厅会议(Town hall meetings):政治家和选民当面讨论感兴趣的话题和即将到来的立法。 倡议(Initiatives)和公投(Referendums)也是地方政府和州政府允许公民影响政策决定的两个方法。 倡议允许公民绕过他们的州立法机构,将拟议的法律放在选票上。一些州甚至允许公民在选票上提出宪法修正案。公投则允许选民批准或废除州立法机构的一项法案。 Pluralist Democracy a model of democracy in which no one group dominates politics and organized groups compete with each other to influence policy 多元化民主:没有任何一个团体主导政治,有组织的团体相互竞争以影响政策。 与参与式民主不同的是,多元化民主是个体们通过围绕着共同事业而形成的团体来进行政策决策影响工作。 支持多元化民主的理论家们认为,人们会自行选择他们想花时间的事业,然后支持这些团体。同时这些团体会获得知名政治家的支持而竞争。这些政治家们则提倡他们的利益。 美国政治体系中最明显的带有多元化民主元素的例子便是利益团体。他们是试图影响政策制定者的群体,以支持他们在特定的共同利益或关切问题上的立场。 例如全国步枪协会 NRA 和全国妇女组织 NOW,这些团体以许多方式影响着政策制定者,比如通过金钱捐赠、游说和在国会听证会(Congressional hearings)上影响政治家。 Elite Democracy a model of democracy in which a small number of people, usualyy whose who are wealthy and well-educated, influence political decision making 精英民主:由富有或受过良好教育的少数人来影响政治决策。 提倡精英民主的制宪者(Framers)认为对政治的参与应仅限于一小群高度知情的人,而他们能够为所有公民做出最佳的决定。 选举团(Electoral College)的结构中有着精英民主的影子。尽管人民通过选举产生了总统候选人,但选举团对潜在的多数人暴政起到了制约作用。 单词释义 Democracy- a system of government in which the power of the government is vested in the people, who rule directly or through elected representatives Participatory Democracy- a form of democracy that emphasizes broad, direct participation in politics and civil society, in which most or all citizens participate in politics directly Elite Democracy- a form of democracy in which a small number of people, usually those who are wealthy and well-educated, influence political decisionmaking 文档描述 Federalist No. 10- an essay written by James Madison, in which he argued that a strong representative government would be able to control the effects of factions Brutus No. 1- an Anti-Federalist essay which argued against a strong, central government based on the belief that it would not be able to meet the needs of all U.S. citizens Constitution- the fundamental laws and principles that govern the United States - the document was the result of several compromises between Federalists and Anti-Federalists surrounding the ratification of the Constitution 国会 国会行为 TermDefinition Gridlock- when the government is unable to reach compromises or make policy decisions Partisan- a firm supporter of one political party Redistricting- the process of adjusting electoral districts in the United States Gerrymandering- the act of changing the boundaries of an electoral district to favor one party over another Dividing Government- when one party controls one or more houses in the legislative branch while the other party controls in executive branch "Lame Duck"- an elected official who continues to hold political office during the period between the election and the inauguration of their successor Trustee- a member of Congress who takes into account the views of their constituents and use their own judgment to decide how to vote Delegate- a member of Congress who always follows their constituents』voting preferences Politico- a member of Congress who acts as a delegate on issues that their constituents care about, and as a trustee on issues that their constituents don』t care about 重要案件 Baker v. Carr(1961) 法院裁定 Tennessee 自 1901 年以来都没有重新选区的行为违反了宪法 法院确立「一人一票」原则 —— 选区应按照比例来划分 —— 且法院有权审查各州的重新选区问题 Shaw v. Reno(1993) 该案件确立,尽管立法重新划分选区必须考虑到种族问题并遵守 1965 年的《投票权法》(the Voting Rights Act of 1965),但不能超过避免种族失衡的合理必要范围 选区划分的重要性: 重新划分选区会影响到选区对部分国会议员的「安全性」 假设一个国会议员位于安全区,那 TA 就会基于大多数选民的政党在选举中获胜。这也会导致该国会议员有可能因为觉得自己更有权力,而采取选民不喜欢的立场 总统通讯 美国宪法 II 第三款规定总统为首席立法者(Chief Legislator),也就是说,总统有权指定政策并影响国会试图通过的法案。 通信技术的进步改变了总统与其他政府部门和选民的关系,增加了总统对立法议程的影响力。 TermDefinition State of the Union- an annual presidential report required by the Constitution, conventionally delivered as a speech to Congress since 1913 and televised since 1947 - the president can use the State of the Union to set their policy agenda and recommend policies to members of Congress Bully Pulpit- Theodore Roosevelt』s notion of the presidency as a platform from which the president could promote an agenda directly to the public 扩大总统权力 总统应该有多少权力? 强大的行政人员可以采取快速果断的行动,这对于应对时事很重要 但如果总统权力太大,国会和人民可能缺乏追究 TA 责任的能力 TermDefinitionExamples Formal Powers- powers expressly granted to the president under Article II of the Constitution1. making treaties 2. commanding the military 3. appointing Supreme Court justices 4. vetoing legislation Informal Powers- powers claimed by presidents as necessary in order to execute the law1. issuing executive orders and negotiating executive agreements Single Executive- an executive branch led by a single person Twenty-Second Amendment(1951)- applies term limits to the office of the president - no one may be elected president more than twice, or serve as president longer than ten years War Powers Act(1973)- also called the War Powers Resolution - limits the president』s power to deploy US armed forces - every president since Nixon has contested the War Powers Act as an infringement of their role as Commander in Chief of the armed forces Federalist No. 70(1788) 由 Hamilton 撰写。他认为,单个行政人员(single executive)是美国行政部门的最佳形式 Hamilton 认为,与一大群领导人相比,一位总统可以更快地采取行动,并在必要时更加保密 同时,与委员会(council)相比,单个行政人员比民主主义的危害更小,因为识别和罢免一个腐败的领导人要比在多个领导人中发现谁是不良行为者更容易 对总统职位的制约 TermDefinition Presidential Nomination- a president』s formal proposal of a candidate to fill a position, such as a cabinet member or Supreme Court justice Confirmation- senate approval of a presidential nomination Executive Order- a rule or order issued by the president without the cooperation of Congress that carries the force of law 总统的权力 制宪者希望行政部门有足够的权力采取行动,因此在宪法 II 中规定美国的行政权属于总统。 宪法 II 中概述的权力被称为正式权力(formal powers),而其他未被概述的权力则称为非正式权力(informal powers)。 TermDefinitionExample Cabinet- a group of presidential advisers1. the heads of the executive departments 2. the attorney general 3. other officials chosen by the president Executive Agreement- an international agreement between the president and another country, which does not require the consent of the Senate Executive Order- a presidential order to the executive branch that carries the force of law - the Supreme Court can rule executive orders unconstitutional Pocket Veto- an indirect veto, which the president can use by neither signing or vetoing a bill passed by Congress fewer than 10 days before it adjourns Signing Statement- a presidential statement upon signing a bill into law, which explains how a president』s administration intends to interpret the law State of the Union Address- the president』s annual message to a joint session of Congress, which includes recommended legislation and evaluations of the nation』s top priorities and economic health Veto- the president』s constitutional right to reject a law passed by Congress - Congress may override the president』s veto with a two-thirds vote 总统的正式权力 CategoryPowers Executive- take care that the laws be faithfully executed - nominate officials(with Senate confirmation) - request written opinions from administrative officials - fill administrative vacancies during congressional recesses Foreign Policy- act as Commander in Chief of the armed forces - make treaties(with Senate ratification) - nominate ambassadors(with Senate confirmation) - receive ambassadors - confer diplomatic recognition on other governments Judicial- grant reprieves and pardons for federal offenses(except impeachment) - nominate federal judges(with Senate confirmation) Legislative- recommend legislation to Congress - present information on the State of the Union to Congress - convene Congress on extraordinary occasions - adjourn Congress if House and Senate cannot agree - veto legislation(Congress may overrule with supermajority) 总统的非正式权力 PowerDefinition Bargaining and Persuasion- setting priorities for Congress and attempting to get majorities to put through the president』s legislative agenda Issuing Executive Orders- regulations to run the government and direct the bureaucracy Issuing Signing Statements- giving the president's intended interpretation of bills passed by Congress Negotiating Executive Agreements- agreements with heads of foreign governments that are not ratified by the Senate

2022/10/1
articleCard.readMore

Hoka-bot 功能列表

欢迎使用 Hoka-bot! 该文档将列出 Hoka-bot (以下称 Hoka)的功能以及详细介绍。 阅读前,需要: 查看该文档的日期是否已经过时 ———————— 目录 目录 介绍 如何使用 Hoka 功能管理 功能列表 rauthman 插件功能列表 ———————— 介绍 Hoka,或称 霍卡,是一个 QQ 群聊机器人。 Hoka 的部署教程,看了或许能够更了解 Hoka。 如何使用 Hoka 在 Hoka 所在的群聊中发送命令 hoka help,能够得到帮助菜单插件的帮助 发送命令 hoka help list,能够得到 Hoka 现有的插件列表 功能管理 Hoka 的 rauthman 插件提供了插件授权管理的功能,也就是说, Hoka 的部分功能在管理员没有开启前是无法使用的。 不受 rauthman 插件影响的插件有: help | 帮助菜单 admin | 简易群管 contact | 联系管理员 eventdone | 好友申请 read_60s | 60秒读世界 而 admin 插件自有一套独立的权限系统,其权限系统仅对该插件的功能生效。 ———————— 功能列表 插件名功能命令备注 abbrrebly缩写猜测缩写 <文本> admin简易群管群管初始化初始化该群聊 禁 <@> <秒数>禁言,0 秒为解禁 解 <@>解禁 /all全体禁言 改 <@> <群名片>更改群名片 头衔 <@> <头衔>更改群头衔 删头衔 踢 <@> 黑 <@>拉黑 撤回需要回复信息 管理员 + / - <@>添加或移除管理员 查看词条查看本群加群自动审批的词条 词条 + / - <文本> 所有词条超级用户独占 指定词条 + / - <群号> <词条>超级用户独占 查看分管分管是可以接受加群处理结果消息的用户 所有分管 群管接收如关闭则审批结果不会发送给超级用户 分管 + / -添加或移除分群管理员 记录本群记录本群的聊天记录 停止记录本群 群词云生成该群的群词云 简单违禁词 严格违禁词 更新违禁词库 开关 <功能名>1. 管理、踢、禁、改、基础群管 2. 加群、审批、加群审批、自动审批 3. 词云、群词云、wordcloud 4. 违禁词、违禁词检测 开关状态 bilibili_analysisB 站视频解析<B 站视频链接、小程序、BV 号等> bilibili_coverB 站视频封面提取提取封面 <视频链接、AV 号、BV 号> bilibili_viodeB 站视频分享卡片<B 站视频 ID> cataas随机猫咪来点猫咪 contact联系管理员hoka 私聊 / contact <文本>发送私聊给超级用户; 会取 QQ 号 ELF_RSS2RSS 订阅add/添加订阅/sub <订阅名> <RSS 地址>RSS 地址看 这里 rsshub_add <RSSHub 路由名> <订阅名> deldy/删除订阅/drop <订阅名> show_all/showall/select_all/selectall/所有订阅 <关键词> show / 查看订阅 <订阅名> change/修改订阅/moddy <订阅名> <属性>=<值>属性表看 这里 eventdone好友申请超级用户独占 game群组游戏装弹 / 轮盘 <子弹> <金额>开始一场轮盘游戏; 子弹数必须大于 0 小于 7 接受 / 拒绝接受或拒绝他人的轮盘邀请 开枪 结算跳过该次轮盘游戏,直接进到结算画面; 发送该指令的人会自动算为输家 我的战绩 金币/胜场/败场/欧洲人/慈善家 排行 hoka 抽卡 / 十连抽卡一次花费 100 金币 卡池 <星数>/全部/UP查看卡池 我的背包 售卖 <数量> 个 <星数> 星三星卡售价 25 金币; 四星卡售价 50 金币; 五星卡售价 250 金币; hoka 签到 hoka 打工 hoka 找工作 / 职业 <职业名>职业: 1. 爱豆 2. 传教士 3. 家庭教师 4. 皮条客 hoka 辞职一周仅能辞职一次 职业信息 我的职业 help帮助菜单hoka help hoka help list查看全部插件 hoka help <插件名>查看指定插件帮助菜单 logoLogo 制作hoka ph / phlogo <左> <右>PornHub 风格 Logo hoka yt / ytlogo <左> <右>YouTube 风格 Logo hoka 5000 兆 <左> <右>5000 兆风格 Logo hoka douyin / dylogo <文字>抖音风格 Logo hoka google / gglogo <文字>谷歌风格 Logo memes表情包制作表情包制作 hoka <表情包名> <文字> read_60s60 秒读世界需要联系管理员手动开启 setu色卡查询setu <张数> <关键词>r18 需要管理员手动开启 withdraw信息撤回bot 撤回 / withdraw <倒数第 x 条信息> <回复需要撤回的信息「bot 撤回」> rauthman 插件功能列表 功能名 不同于 插件名 指令备注 功能 开启 / 关闭 <功能名> 功能查询获取该群组已开启的功能 功能全局查询获取 Bot 所有的功能

2022/8/18
articleCard.readMore

学术评估测验 - 总结

该文档是给自己复习用的。 并不包含所有的知识点,且知识点不一定正确。 目录 目录 单词 语法 句子排序 表达风格 增减句子 特殊单复数 两者并列 时态 固定搭配 比较对象的一致性 标点符号 连词 & 逻辑词 句子合并 代词 逻辑主语一致 从句 数学 标准差 标准误 弧长 百分比差别 角度 弧度 二次函数 单位换算 方程组 实验对照组 平均值 函数表达式 二次方程的根 单词 ———————— 单词 字母单词意思 Aaccredited经认可的 accumulated积聚、堆积 acquaintances认识的人、熟人 adaptable能适应的、适合的、可修改的 address发表、对付、着手处理、解决 adopting采取、接受、收养、正式通过、过继 advocate提倡者、拥护者 afflict使苦恼、折磨 agile敏捷的、灵巧的 ambitious有野心的 amid在其中 anguish剧痛、痛苦 answer回答、反驳、适应、符合 anticipated预期的、期望的 antifungal抗真菌的 antique古董、古物 aphids蚜虫 apparatus装置、器具 appearance外观、外表 arousal唤醒、觉醒 ascertain查明 asserted明确肯定、断言 assessing评价、评估 assuring使人放心的、使人有自信的 astonish使惊讶 astrophysicists天体物理学家 asymmetric不对称的 augment增长、增加 autobiographical自传的 auxiliary辅助的、备用的 avian鸟类的 Bbeanstalks豆茎 betokened预示 biomarkers生物标记 birthed生成了、生育了 blunt坦率地、钝的 bolder更大胆的 borrowing借款、贷款 boudoir闺房 breed饲养、繁殖 broad-bean蚕豆 Ccarriage马车、客车 casual偶然的、不经意的 causal因果的 Centenary一百年 chambers房间、室 chickadees山雀 citation引用 cite引用 civility礼貌、礼仪 clamber攀登、爬上 cobbled together拼凑起来的 coined创造了、创设了 cognition认识、识别 coherently结合地 commemoration纪念、庆典 commitment委托 compelled强迫、迫使 compensation补偿、工资 component元件、组件、成分 compounds化合物 comprehended理解、领会 comprehension理解、包含 conceived构想、设想 concentrations浓度 concoction调制、调和 conduit导管、水管 conglomerations团块、聚集 conquests征服、获取 consensual交感的、在两方愿意下成立的 consistent一致的、并立的 conspicuous显眼的 constellation星座 cosmos宇宙 counterintuitive违反直觉的 cultivation培养、耕作 curatorial管理者的 Ddecommissioned停用、退役 delineation略图、描述 demanded要求 demeanour行为、举止 depleting耗尽、使枯竭 deprivation剥夺 derived导出的、衍生的 devoted投入 differing不同 dilate扩大、膨胀、详述 diminished减弱的 disfluencies不流利 disparity不一致、差异 dispersal散布、传播 displacements换置、取代 disproportion不均衡 dissolution分解、溶解 distanced远离、疏离 ditched抛弃 diverse不同的、变化多的 divulge泄露、透露 domestication驯养 dropped降低、放弃 Eefforts努力 elicited引出、探出 elusive难懂的、易忘的 embodied cognition体化认知 emergent紧急的、以外的 emit发出、发射 endeavor努力、尽力 endurance忍耐 engrossed全神贯注的 enormous巨大的、庞大的 enshrouding掩盖、遮掩 ensuring确保、使(人)获得、使安全 envisioned想象、展望 equilibrium平衡、均衡 established(幼苗)成活 evoke唤起 excluded被排除、不包括 exerted外露的 existing现存的、现有的 Ffaint模糊的 fashionable时髦的 fathom摸索 feral凶猛的 fickle变幻无常的、浮躁的、薄情的 fierce凶猛的 filaments丝状物 firm坚定的、坚强的 fixation定置、固定 flockmates同伴 foppishness浮华的 foraging搜寻 forgeries伪造 fostered培养、促进 freelance自由职业 fusion聚变 Ggained获得、得到 genetic profile基因图谱、遗传基因 genome基因组 given鉴于 grapple抓住、掌握 gratify使高兴、使满足 grieve使悲伤 guarantees担保 Hhampers妨碍、束缚、限制 harbored庇护 harmonious和谐的 hastily匆忙地、急躁地 heritage遗产、继承物 hippocampus海马 homogeneously同一的 hyphae菌丝 Iimplication牵连、含义 inclination倾向、趋向 indissoluble不能分解的、坚固的 indulge放任、迁就 inland reservoirs内陆水库 infer推断 infrared红外线的 innate先天的 inoculated接种 inscrutable难以了解的 insolent粗野的、无礼的 insuring保证、确保 integrate复杂地 intensity强度 interpersonal人与人之间的 intimacies亲密、亲切 intimate私人的 intoxicated极其兴奋的 invoke实行、调用、恳请、祈求 irremediably无可救药地 irrespective不顾一切的 irritate激怒 Jjibes相符 jolting震动 junked丢弃、废弃 juvenile少年的 Llegumes豆类蔬菜 leguminous豆科的 lens透镜、镜头、晶状体 lilt轻快的调子或动作 Mmalleable有延展性的、易适应的 meaty似肉的、多肉的 Medieval中世纪的 mesh网状物、陷阱 mesocosms中型实验生态系 metabolism新陈代谢 microbial微生物的 mimicked模仿过的 miraculous奇迹的、不可思议的 mobbing聚众扰乱 monoculture单一栽培、单一农作物 mortified使受辱 mutual相互的、共有的 Nnovel新奇的、异常的 Oobstacle障碍、妨碍物 occasions机会、场合 occupy占领 odorless没有气味的 ongoing进行的、前进的 opposes反对、抵制 ordinary平凡、普通 orientation定方位、方向 Ppaleontologists古生物学者 pang剧痛、悲痛 panoply全套披甲 parasites寄生虫 parids山雀、雏鸟 patches斑块 pathogens病原体 pelvis骨盆 penetrated穿透 perceived感觉、认为 perceptibly显然地 perch栖木、高位 perched栖息着的 perils极大危险 perspective观点的、透视的 pertained涉及、关于 phenomenon现象、迹象 pigmentation着色 piqued伤害…… 的自尊心 plagues折磨、使得灾祸 pledged保证、许诺、发誓 pollsters民意调查者 polycultural多文化的 porous多孔的、能渗透的 prescriptive规定的、指定的 preserving保留、保存 presumably推测上、大概 presuming假定、推测、擅自、意味着、相信 pretending假装、伪装 prevailing盛行的、优势的 prevalence流行、盛行 probes探针、探头、探测仪 prodigious惊人的 prone有…… 倾向的 proscriptive禁止的、放逐的 prospective潜在的、有望的 purpose目的、用途、效果、意向 Rrational理性的、合理的 reassert再断言、重复主张 recedes逐渐远离 recruit征募、招聘 removed移除 repercussions反响 report报告、报道 resists阻止、抵抗、对抗 retrieval取回、恢复 revered崇敬的 revival复兴、苏醒 ripple effect连锁反应、骨牌效应、波纹效应 rudimentary基本的、初步的 Ssanction认可、核准、同意、裁决 scarcely简直不、一定不、仅仅 scenario情节、剧本 scrutinize仔细检查 secrete隐藏 securing固定住的 seductive诱惑的、引人注意的 self-referential自我指认的 separated分离、分开 sever切断、脱离 sight视野 silhouette轮廓、剪影 site地址 solemn严肃的、庄严的 solidarity团结 solitude孤独、孤寂 stabilized稳定 stead用处、好处 sterile无菌的 sticklebacks棘鱼 stimuli刺激物 Styrofoam泡沫聚苯乙烯 subdues征服、克制、制服 subterranean地下的、秘密的 subtly巧妙地 supercilious傲慢的、自大的 surface tension表面张力 surviving依然健在的、未死的 symbiotic共生的 Ttactile触觉的 temporal暂时的 tendencies趋向 tentative犹豫不定的 theorize理论化 threads纤维 toil苦干、跋涉 tonal音调的 traverse穿过、经过 Uunamimously全体一致地、无异议地 unbeknownst最不为人知的 undertake保证、承担、同意、接受 表从事 unimpeded无阻的 universally普遍的、一般的 unlike不像 unparalleled无比的、无双的 unparatable不可口的、使人不快的 unspecified未特别指出的、未特别提到的 utterly完全地、全然 Vvacant空置的 vexed使烦恼、使苦恼 violated违反、妨碍 viscoelastic粘弹性的 vividness鲜艳、逼真 volatile可变的、不稳定的 挥发物 Wwasps黄蜂 whence从何处、从哪个 winced畏缩、退避 withheld保留、扣留 Yyardstick计算标准 语法 句子排序 有时句子排序可以仅靠看主语来得出答案。例如当插入语的主语是代词时,就要排除掉没有出现过该指代的物体或人的句子。 表达风格 表达风格的题不能使用带有修辞的选项,要使用带书面表达的。 书面表达:find one's way [to / into] sth:成功地达到了某个效果 增减句子 插入句对句子而言是否有意义,例如尾句的插入句对该段落的总结有没有意义。 特殊单复数 mice 是 mouse 的复数。 两者并列 两个 that 从句的并列:"... that... and that ...." 时态 一般时进行时完成时完成进行时 现在一般现在时现在进行时现在完成时现在完成进行时 过去一般过去时过去进行时过去完成时过去完成进行时 将来一般将来时将来进行时将来完成时将来完成进行时 过去将来一般过去将来时过去将来进行时过去将来完成时过去将来完成进行时 一般现在时: teach teaches usually every day 一般过去时: taught yesterday just now 一般将来时: will teach be going to teach shall teach later in the future 一般过去将来时: would teach should teach 宾语从句中,例 I knew he would come 虚拟语气中,例 If I were you, I would go should 少用 现在进行时: am teaching is teaching are teaching now 过去进行时: was teaching were teaching at that time while 将来进行时: will be teaching be going to teaching shall be teaching 同 一般将来时,只是强调动作的进行状态 过去将来进行时: would be teaching should be teaching 同 一般过去将来时,只是强调动作的进行状态 现在完成时: has taught have taught already just yet 过去完成时: had taught by the end of last year/term 从句是过去式 将来完成时: will have taught be going to have taught shall have taught by then by the end of next month/year 过去将来完成时: would have taught should have taught 虚拟语气中,表示对过去已经发生的事情的建议 I would have gone there, if I had been invited You shouldn't have done it 现在完成进行时: has been teaching have been teaching since for 过去完成进行时: had been teaching 同 过去完成时,只是强调动作的过程以及延续性 将来完成进行时: will have been teaching be going to have been teaching shall have been teaching 很少使用 过去将来完成进行时: would have been teaching should have been teaching 同 过去将来完成时,但这个很少用 时态题不需要也不可能完全和上下句时态相同,所以不能盲目地看上下句,而是要看这句话在上下文里表达的是什么语义逻辑 固定搭配 prevents sth from be subject to 受到…… 影响、受…… 支配、服从于…… be subjected to 使遭受(糟糕的东西、坏东西) and which 而其中、和其中、以及哪些 by which 由……、凭…… distinct from 不同于…… dreamed up 梦见了 for which 为其中的……、为之…… slunk out 鬼鬼祟祟地从某处离开 比较对象的一致性 先要找出这句话要比较谁和谁,其次查看比较对象的单复数,最终找出相对应的代词替代后者比较对象。 标点符号 双逗号、双破折号:形成插入语冒号:连接两个完整句,后句为前句的补充单逗号:无法连接两个完整句 连词 & 逻辑词 although though 尽管 让步与转折 given that 鉴于 thus 因此 因果 namely 换句话说、即 rather 而是 regardless of 无论怎样 subsequent to 之后的 crucially 关键是 句子合并 表达正确,即意思应和原句相同 尽可能简洁,即句子结构以简单句为最简洁 最简洁的句子结构通常不用倒装 同一概念表达了一次以上的话,简洁性就会下降 代词 such 指示代词 前一句话需要提到 such 后面接的名词 逻辑主语一致 分词作状语,其逻辑主语为句子主语 例如:Hearing the news, she felt her blood rushing to her face. 句子主语 she 是 hearing 的逻辑主语 从句 状语从句备注 be it by ...倒装 在句末时前面可以放 破折号 ———————— 数学 标准差 Standard Derivation 基础数据分析 概率论 衡量一个数据的大小需要根据它和均值之间差了多少个标准差。 标准差集中:数据越聚在一起(特别是在数据的正中间),标准差越小标准差分散:数据分布得越平均,或者很多数据在两边,标准差越大 标准误 Standard Error 统计推断 关键词:confidence intervalmargin of error sample size 越大, margin or error 越小 弧长 Arc Length 几何 圆 公式:(Θ/360)(2πr) 百分比差别 Percent Increase 比例 公式:(大数字 - 小数字) / 小数字 角度 Degrees 几何 π 是 180 度 弧度 Radians 几何 π 弧度 = 180 角度 二次函数 Quadratic Function 代数 函数 开口朝上为正数,开口朝下为负数。 m (x^2) -> 没有最大值-m (x^2) -> 最大值为 0-(x^2) + m -> 最大值为 m-(x + m)^2 -> 最大值为 0 单位换算 Unit 单位 英制单位 方程组 System of Equations 代数 方程组无解:两个方程代表的直线斜率相同 实验对照组 想要从两组中得到某物的因果关系,那需要做到随机分组。 平均值 Mean 基础数据分析 数据组 A 和数据组 B 近乎一样,仅是多了一个最小值,那么 A 的平均值一定比 B 的小。 函数表达式 Function Expression 代数 当 f(x) 等于一个分数,且分母中包含 x 时,先要让分母等于 0 ,排除掉不可能的 x 。 二次方程的根 Roots of Quadratic Equation 代数 韦达定理:二次方程的根的总和为 -(b/a)二次方程的根的积为 c/a 单词 英文解析例 ……is twice as large as……前者是后者的两倍 ……is n less than……前者比后者少 n

2022/8/16
articleCard.readMore

Linux Desmume 安装

该文档讲述了如何在 Linux 系统下安装最新版本的 Desmume。 开始阅读前,需要知道: Desmume 是一个 NDS 模拟器 NDS 是任天堂在 04 年发售的便携式游戏机,具有双屏幕显示、触摸屏等功能或特色 该文档搬运且汉化了 Wiki 上的安装方法:用 Git 安装 ———————— 获取源码 如果没有 Git 进入 Desmume 的 GitHub 仓库。 点击绿色的 Code 按钮,点击 Download ZIP。 解压后备用。 反之有 Git 打开终端,输入 git clone https://github.com/TASVideos/desmume ———————— 安装 打开终端,输入以下: cd desmume/desmume/src/frontend/posix./autogen.sh./configuremakesudo make install 如果想使用 offscreen mesa 作为替补 3D 引擎,改用 ./configure --enable-osmesa。 如果想启用 HUD 显示,则用 ./configure --enable-hud。 安装成功,在终端中输入 desmume 或在应用中点击其图标即可开启。

2022/8/2
articleCard.readMore

Hoka-bot 部署教程

该文档将讲述如何部署 Hoka-bot(以下称为 Hoka)。 在开始本教程前,需要知道该教程: 基于 Python3.9 基于 Linux 系统 / Ubuntu(我是 20.04.1 版本) 是 6 月 30 日 版本的 Hoka-bot 部署教程 开始部署之前 理所当然的,你需要将 Hoka 的代码克隆至本地。 进入 Hoka 的仓库。 如下图所示,克隆或下载代码的压缩文件: 得到的文件夹应如下图: 关于 bot 代码结构可以看看 这篇 Nonebot2 教程 你已经得到了一个 Hoka!但他还是个空壳,无法立马投入使用。 ———————— 配置 Hoka 的环境配置文件 Hoka 部署最为紧要的部分是 .env 文件,也就是 环境配置文件。 进入 .env 文件进行编辑: ENVIRONMENT=devHOST=127.0.0.1PORT=56766LOG_LEVEL=INFOFASTAPI_RELOAD=trueSUPERUSERS = [""]NICKNAME = ["hoka"]COMMAND_START = ["hoka", ""]COMMAND_SEP = ["*"]GOCQ_ACCOUNR = [{ "uin": , "password": "", "protocol": 1}]GOCQ_URL = https://github.com/Mrs4s/go-cqhttp/releases/download/v1.0.0-rc3/go-cqhttp_linux_amd64.tar.gzAPSCHEDULER_AUTOSTART = trueAPI_TIMEOUT = 3600RAM_CMD = 功能RAM_ADD = 开启RAM_RM = 关闭RAM_SHOW = 查询RAM_AVAILABLE = 全局查询GAME_PATH = ''MAX_BET_GOLD = 10000READ_QQ_FRIENDS = []READ_QQ_GROUPS = []READ_INFORM_TIME = [{"HOUR": 9, "MINUTE": 5}] 环境默认为 dev,即 development、开发环境; 可更改为 prod,即 production、生产环境; ENVIRONMENT=dev 如下,这里分别是 HOST 监听IP/主机名、PORT 监听端口、LOG LEVEL 日志等级 和 FASTAPI_RELOAD 的配置: 下面的端口如果已被使用可以修改为其他数字,其它则都不需要碰。 HOST=127.0.0.1PORT=56766LOG_LEVEL=INFOFASTAPI_RELOAD=true 配置 SUPERUSERS 超级用户:超级用户是 Hoka 的管理员,有着最高权限。 这里推荐只写你自己的 QQ 号; 配置 NICKNAME bot昵称:不建议修改,因为我都是直接在代码里写的 Hoka,改了没啥用; 配置 COMMAND_START 命令前缀:不建议修改,但也不是不能改; 配置 COMMAND_SEP 命令隔开符:不咋用到这个,随意更改。 SUPERUSERS = [""]NICKNAME = ["hoka"]COMMAND_START = ["hoka", ""]COMMAND_SEP = ["*"] 配置 Go-CQHTTP插件: 介于该插件的前端可以登录账号,你不需要配置 GOCQ_ACCOUNR; 该插件将会下载 GOCQ_URL 中填写的地址,什么都不输入将自动使用该插件默认的镜像站以及最新版本。 GOCQ_ACCOUNR = [{ "uin": , "password": "", "protocol": 1}]GOCQ_URL = https://github.com/Mrs4s/go-cqhttp/releases/download/v1.0.0-rc3/go-cqhttp_linux_amd64.tar.gz 不需要管这俩。 APSCHEDULER_AUTOSTART = trueAPI_TIMEOUT = 3600 rauthman 权限管理插件 的配置项: 其实只是指令名更改,不建议修改。 RAM_CMD = 功能RAM_ADD = 开启RAM_RM = 关闭RAM_SHOW = 查询RAM_AVAILABLE = 全局查询 game 群组游戏插件 的配置项: GAME_PATH 不要碰; MAX_BET_GOLD 为俄罗斯轮盘中赌注的上限,可以调低防止一夜暴富一夜回到解放前。 GAME_PATH = ''MAX_BET_GOLD = 10000 read_60s 60秒读世界插件 的配置项: READ_QQ_FRIENDS 为每日定时发送的 QQ 好友号; READ_QQ_GROUPS 为每日定时发送的 QQ 群聊号; READ_INFORM_TIME 为每日定时发送的时间,按照本地时间为标准,如下便是每日早上 9 点 5 分。 READ_QQ_FRIENDS = []READ_QQ_GROUPS = []READ_INFORM_TIME = [{"HOUR": 9, "MINUTE": 5}] 你已经配置好了环境配置文件! ———————— 配置 Hoka 的群组游戏 需要配置的只有群组卡池。 进入 src/plugins/game/gacha.py,找到 class NormalData: class NormalData: sixList = [] fiveList = [] fourList = [] threeList = [] onelist = ['虾虾'] fiveListUp = [] sixListUP = [] sixList 为卡池的六星角色,fiveList 为五星… 以此类推。 oneList 建议保留为虾虾,因为虾虾这个名字也在代码内,当然你也可以自己一次性全部修改。 fiveListUp 和 sixListUP 都是概率 UP 角色。 填写格式像这样:sixList = ['名字', '另一个名字']。 ———————— 配置 Go-CQHTTP & 运行 Hoka 首先需要运行一次 Hoka。 在有 bot.py 的主文件夹内开终端输入 python3.9 bot.py。 在终端中找到这么一行: [INFO] nonebot_plugin_gocqhttp | Startup complete, Web UI has served to http://127.0.0.1:56766/go-cqhttp/ 进入后面的这个地址,页面应如下: 点击左上角的 添加账号。 依次输入 bot 的 QQ 号、密码,并选择登录设备类型。个人通常使用 Android Phone。 提交后关闭页面,回到终端关闭过程,再次输入 python3.9 bot.py。 此时终端会多出来自于 nonebot_plugin_gocqhttp 的信息,并开始登录 bot 账号。 在网络诊断完成后,Hoka 便运行成功,可以使用了。 ———————— 后言 你已经学会如何配置 & 部署 Hoka 了!

2022/6/30
articleCard.readMore

Nonebot beta1 教程

Nonebot2 在更新到 beta1 版本后许多地方和 alpha16 版本有许多差异,包括但不限于适配器、依赖的更改。 Nonebot2 是 Python 聊天机器人框架,目前仅支持 Python 3.7.3 以上的版本 开始本教程前,需要知道该教程: Python 版本 ≥ 3.7 基于 Windows 10 版本(Win11 也可以但我只能说快逃) 使用了 pip 用于安装 使用了脚手架 nb-cli 使用了 onebot.v11 适配器 创建项目之前 在创建机器人项目前需要安装: 创建项目之前 下载 Python 安装 pip 安装 nb-cli 下载 go-cqhttp 创建项目 新建项目 配置项目 用下机器人 深入插件 什么是插件 创建插件 用别人的插件 事件响应器 处理流程 插件实例 而这些都会讲到,已经安装过了的建议 空降。 ———————— 下载 Python 进入 Python 官网。 选择 3.7.3 版本以上的进行下载。 我的 Python 版本是 3.9.9,选择上图中的 Windows installer(64-bit) 进行下载。 下载完成后点进 python-3.9.9-amd64.exe,勾选 Add Python 3.9 to PATH 后点击 Install Now。 当然,你也可以自定义路径,但一定要勾选 PATH。 等待下载…… 下载完毕后开个 CMD 输入 python --version,出现 Python 3.9.9 就代表能用了。 ———————— 安装 pip pip 是 Python 包管理工具,用来下载 Python 的包。 进入 PyPI,选择最新版本的 pip。 选择 Download Files,下载 pip-22.0.2.tar.gz 进行解压。 解压后进入文件夹内,右键空白处打开 CMD,输入 python setup.py install 等待安装…… 安装完成后开个 CMD 输入 pip --version,出现 pip 22.0.2 from 路径…… 时就代表能用了。 ———————— 安装 nb-cli Nonebot2 提供了相当便利的工具 nb-cli,安装它也会直接安装 nonebot 2.0.0-beta1。 开个 CMD 输入 pip install nb-cli 出现 Successfully installed nb-cli…… 就代表 pip 安装 nb-cli 成功了。 ———————— 下载 go-cqhttp 做机器人只有 Nonebot2 是不足够的,Nonebot2 仅能做到处理机器人上报的事件。 机器人的实际工作流程为: 协议端 上报数据给 后端驱动; 后端驱动 将原始数据转交给 协议适配 处理; 协议适配,或称为 bot,将数据转化为 event 转交给 Nonebot2; Nonebot2 处理 event。 很显然,我们只有处理 event 的 Nonebot2。 获取数据的工作便交到了 go-cqhttp 身上:它相当于你的另一个 QQ,接收到信息后便转交到 Nonebot2 手上等着处理完和发送。 进入 go-cqhttp 仓库,下载最新版本的 releases。 选择 go-cqhttp_windows_amd64.exe 进行下载和备用。 ———————— 创建项目 现在该下的都下载完了,是时候创建机器人项目了: 新建项目 配置项目 用下机器人 ———————— 新建项目 新建一个文件夹,右键打开 CMD,输入 nb create。 Project Name:,输入你想给你机器人取的名字。 Where to store the plugin?,这意味着机器人插件的存放路径,建议选择 In a "src" folder。 Which builtin plugin(s)……巴拉巴拉,这就是在问你你的机器人要不要 Nonebot2 内嵌的插件,建议只选择 echo 插件。 需要注意的是,选择内嵌插件时要按下空格再回车继续。 Which adapter(s)……巴拉巴拉,选择你想要的协议适配,这里要选择 OneBot V11,别忘了空格再回车。 期间会进行适配器等的安装,结束后你的文件夹内便会出现机器人名字的文件夹,差不多长这样: 机器人名字├── src│ └── plugins├── .env├── .env.dev├── .env.prod├── .gitignore├── bot.py├── docker-compose.yml├── Dockerfile├── pyproject.toml└── README.md src/plugins 为 Nonebot2 插件存放处,插件则是 Nonebot2 的核心,可以实现模块化、功能扩展等 .env.* 等文件是项目的配置文件,内存有变量 ENVIRONMENT,是 Nonebot2 启动时就会寻找的系统环境变量 .gitignore,如果不上传到 git 仓库的话可以无视 bot.py 是是启动 Nonebot2 时默认的入口文件 pyproject.toml 是项目插件配置文件 Dockerfile 和 docker-compose.yml 皆为 Docker 镜像配置文件 ———————— 配置项目 .env 文件是基础的环境配置文件,不管是什么环境都会被加载,不过还是会被 .env.{环境} 文件所覆盖。 而环境有: 开发环境 development 或 dev 生产环境 production 或 prod 等等等等,就不一一列举了。 打开 .env 文件写入:ENVIRONMENT=dev。 开发环境将会报告错误日志,这也会将环境变量的加载导向 .env.dev 文件。 打开 .env.dev 文件,写入以下配置: HOST=127.0.0.1PORT=xxxxSUPERUSERS=["xxxxx"]NICKNAME=["xxx"]COMMAND_START=["/",""]COMMAND_SEP=["x"] 其中,HOST 为 Nonebot2 监听的 IP /主机名 PORT 为监听的端口,这里可以随意选择一个四位数 SUPERUSERS 为超级用户,后面会说作用。值是一个 QQ 号,建议使用大号 NICKNAME 为机器人的昵称 COMMAND_START 为命令起始字符,后面会说作用,这里先用 "/" COMMAND_SEP 为命令分割字符 第 2 步完成后对 Nonebot2 的基础配置就完成了,但我们还未配置 go-cqhttp。 再新建一个文件夹,里边存放我们之前下载的 go-cqhttp_windows_amd64.exe,同时我建议将该 exe 文件重命名为 go-cqhttp.exe。 新建一个 config.yml 文件,作为 go-cqhttp 的配置文件。 配置信息前往 go-cqhttp 文档 内进行复制粘贴 servers 下面选择 ws-reverse,如下图 修改配置信息: account 下的 uin 需要填写机器人所用的 QQ 账号 password 需要填写 QQ 账号的密码 servers 下的 port 需要填写配置 Nonebot2 时填写的四位数 PORT 启动 go-cqhttp.exe。百分百会弹出生成。bat 文件的窗口,直接关闭后会发现文件夹内多出了一个 go-cqhttp.bat,以后启动 go-cqhttp 都需要用到这个文件。 启动后 go-cqhttp 会进行登录,出现 [INFO]: アトリは、高性能ですから! 时便是登陆成功,要开始连接到 Nonebot2 了。 因为我们 Nonebot2 还未开启,此时的 go-cqhttp 只会一直弹出黄色的连接失败。 ———————— 用下机器人 一切准备成功,机器人已经可以使用了! 使用机器人时,Nonebot2 和 go-cqhttp 都需要 一直开启。对于不想长期开着电脑的小伙伴,建议使用云服务器。 打开存放 bot.py 的文件夹,右键开启 CMD,输入 nb run。 打开存放 go-cqhttp.bat 的文件夹,左键打开。 等待第 1 步的 CMD 出现了 [INFO] nonebot | OneBot V11 | Bot QQ账号 connected[INFO] websockets | connection open 便意味着机器人可以使用了。 快马加鞭通过大号给机器人账号发送 /echo 喵 这边是因为我的机器人命令起始字符为 "hoka",你也可以修改成自己喜欢的 机器人一旦复读了,便真正的代表着,你成功的搭建了一个 QQ 机器人。 好了,现在新手教程结束了,来试试这些插件吧。jpg ———————— 深入插件 咳咳,恭喜你获得了一个刚出生的 QQ 机器人!但它九年义务教育还未完成…… 对于一个功能完善的机器人而言,完善的插件系统 是不可缺少的。 我们接下来的才是 Nonebot2 的重头戏:插件讲解。 创建项目之前 下载 Python 安装 pip 安装 nb-cli 下载 go-cqhttp 创建项目 新建项目 配置项目 用下机器人 深入插件 什么是插件 创建插件 用别人的插件 事件响应器 处理流程 插件实例 ———————— 什么是插件 仔细读的都知道,插件是 Nonebot2 的核心。插件既可以是一个模块,也可以是一个包。 插件的存放路径是 src/plugins,在这个路径下,任何 .py 文件 或 有__init__.py 文件的文件夹 都算为插件。 插件名称不可以重复。 多说不如少做,瞅一眼 插件实例,说不定能够更理解插件的用处。 ———————— 创建插件 在机器人文件夹里右键开启 CMD,输入 nb plugin create。 Project Name:,输入插件的名称 Where to store the plugin?,自处是询问插件存放的路径,选择 src/plugins 即可。 Do you want to load……巴拉巴拉。如果你想在你创建的这个插件里再套娃一个插件,就选 y;反之就 n。正常都是不用套娃。 没了,你的插件创建完成噜。 进入 src/plugins 里边,就能看到插件名称的文件夹,里边分别躺着 init.py 和 config.py 文件。 前者用于编写插件内容,相当于插件的入口文件;后者则是插件的配置文件。 其实直接在文件夹内新建也可以,就提一嘴。 ———————— 用别人的插件 要是自己代码写不明白,岂不是永远都没法整一个机器人了? 没有的事。Nonebot2 具有一个专属的 商店,其包含了开源的 驱动器 适配器 插件 机器人 使用他人的插件很简易,方法也多,就是别忘了用了后给他们一个 STAR ~ …… pip 安装法: 举例:NoneBot 离线文档【nonebot_plugin_docs】 这个插件在加载后会输出一个可以 离线打开 的 Nonebot2 网址。 开个 CMD,输入 pip install nonebot_plugin_docs 等待安装完成…… 来到机器人文件夹下的 bot.py,找到上图这个位置。 nonebot.load_builtin_plugins("echo") 意思便是 Nonebot2 加载了内嵌的 echo 插件,也就是我们之前试用机器人时用的 echo 功能。 nonebot.load_from_toml("pyproject.toml") 加载了配置内插件存放路径下所有的插件,也就是 src/plugins。 实际上,插件的加载也有前后之分。按照上图的顺序来说,机器人启动时都会事先加载 echo 插件,其次才是 src/plugins 下的插件。 按照你想要的顺序,在 bot.py 里加入 nonebot.load_plugin("nonebot_plugin_docs") 注意,一定要是 _,而不是 -。 启动机器人,加载插件时应当会出现一句 [INFO] nonebot_plugin_docs | Nonebot docs will be running at: http://localhost:端口/website/ 有了便是加载成功,没有也没关系,必定会有一大串红红绿绿的错误日志等待着你。回头看看哪里疏漏,修复了就成。 …… 脚手架安装法: 举例:定时任务【nonebot_plugin_apscheduler】 该插件为定时任务插件,使用方式见 其仓库。 进入有 pyproject.toml 的机器人文件夹内,右键开启 CMD,输入 nb plugin install nonebot_plugin_apscheduler 等待安装完成…… 完成后便能在 pyproject.toml 里面发现多了一句 plugins = ["nonebot_plugin_apscheduler"] 要是没有也没关系,还是那句话,一大串错误日志等待着你。看看哪里出了问题,修复了就成。 …… GitHub 下载法: 这个法子的好处是可以自行修改源代码,不过手要勤奋点了。 GitHub 账号如何注册这里就不写了。喂明明连 Python 怎么安装都写了 举例:Nonebot2 消息撤回插件【nonebot_plugin_withdraw】 进入 其仓库,找到上图这个地方,选择 Download ZIP。 单单解压上图选中的这个文件夹在你的 src/plugins 下。 解压时如果出现同名文件夹套文件夹,将被套的文件夹移上一级即可。 启动机器人,出现 [SUCCESS] nonebot | Succeeded to import "nonebot_plugin_withdraw" 这句便代表成功力。 没有的话,错误日志。jpg 如果文件不是很多的话,可以直接复制粘贴文件内容。 ———————— 事件响应器 事件响应器,或 Matcher,是响应接收到的事件的单元。 多说不如少做,我们来写一个 接收到 "hi" 这个词时就作出响应 的事件响应器吧。 定义辅助函数即可在插件内创建事件响应器。 不过事先我们得从 nonebot 主模块里导出辅助函数。 按照上边的教程,随便建立一个插件,进入其 init.py 文件,写入 from nonebot import on_command on_command 就是这里我们要用的辅助函数,它会创建命令消息事件响应器。 因为我们要在接收到 "hi" 时就有响应,"hi" 就是这里的命令消息。 写入 from nonebot.permission import SUPERUSER 该步骤实际为可选,SUPERUSER 我们在上边配置。env 文件时有提到过,即超级用户。 在 Nonebot2 中,超级用户就像是 QQ 群聊中的管理员,享受着高贵的特权。 被设置了超级用户权限的事件响应器 只会 对超级用户账号作出响应。 写入 matcher = on_command("hi", permission=SUPERUSER, priority=10) matcher 在这里是事件响应器的名称,可以随便取,就是在同个文件里不能重名。 "hi" 指定了 "hi" 为命令消息,只有在接收到命令消息时机器人才会响应。 permission=SUPERUSER 设置了超级用户权限,只有在超级用户发送 "hi" 时机器人才会响应。 priority=10 定义了该事件响应器的优先级。优先级数字越小越先响应,以 1 开始。可以不填,这边提一嘴。 和优先级一样可选的变量还有:匹配规则、阻断等等。 辅助函数大全可见 官方网址。 不过现在只是建立了一个 可以响应 的事件响应器,处理流程还没写呢。 ———————— 处理流程 事件的处理流程由处理依赖组成,即 Dependent。 而装饰器能够装饰一个函数,让其自动转换为 Dependent 对象并加入事件响应器的处理流程中。 装饰器分为: handle;最基本的装饰器 receive;在 handle 的基础上会中断当前事件处理流程,并等待一个新的事件 got;和 receive 相似但用于接收消息,贴近于对话形式会话 由于我们的例子是 接收到 "hi" 这个词时就作出响应,这里用 handle 装饰器即可。 在事件响应器下面写入 @matcher.handle() matcher 依旧是事件响应器的名称。 这里的 handle 为装饰器。 写入 async def func(): 我们定义了一个名为 func 的函数,而在 handle 装饰器下,func 函数已经变成了 Dependent 对象。 在 QQ 里面,我们要如何才能知道发了 "hi" 后,机器人到底有没有作出响应? 我的建议是让机器人也发送什么,比如 "喵"。 在 func 函数内写入 await matcher.send("喵"),即这个名为 matcher 的事件响应器响应后发送了 "喵"。 到这里,处理流程也写好了,不过还是缺些什么才能作为一个可运作的插件。 ———————— 插件实例 虽然处理流程写出模子来了,但问题也来了:这么写是 没法获取上下文信息 的。 获取上下文信息的重点就在 func 函数里,该函数需要从 Onebot.11 适配器里导出的 Bot 参数。 分别添加 from nonebot.params import State from nonebot.typing import T_State from nonebot.adapters.onebot.v11 import Bot, Event 在 func 函数括号内添加 bot: Bot, event: Event, state: T_State = State() 对于更多的参数,见 官方网址。 Bot 基类用于处理上报消息、提供 API 调用接口。 Event 基类提供了关键信息的方法,如获取事件消息内容。 T_State 表明了该事件处理状态为 State 类型。 State 则是 Nonebot2 beta1 版本新添的依赖,T_State 必须要陪着一个 State。 对于我们的例子来说,除了 Bot 实际都是可选项,只是提一嘴。 上图便是一个小型插件的完整样貌。 启动机器人,快马加鞭使用大号向机器人账号发送 "hi",便会收获一个你自己写出来的 猫咪女仆 聊天机器人。

2022/2/2
articleCard.readMore