Food Truck 是一个基于 Javascript(Typescript)
开发的餐车点餐系统, 该系统包括了用户短信注册, 点餐, 下单支付的完整流程.
将开发过程分为7个步骤, 每个步骤在 Github
的代码仓库中都有对应的分支(从 phase/1 到 phase/7), 方便参考.
Food Truck is an online food ordering app based on Javascript(Typescript)
, this system covers the flow from registering, menu navigating and placing orders. I split this flow into 7 steps, each step corresponds to a branch of this repository.
-
Day 1 搭建前后端项目脚手架 Building scaffolding for the back-end and the front-end projects
-
Day 3 前端的 UI 设计和开发 Designing and developing UI at the front-end
-
Day 7 持续集成 Continuous integration and continuous deployment (CI/CD)
虽然说7天从头到尾开发一个全栈的应用有些夸张, 但是本文展示了如何运用成熟的云原生技术, 高效的搭建一个应用可能性. 这是一个针对有一定开发经验的初级教程, 所以并不会深入讨论每一个技术点, 但是会较全面的覆盖开发一个全栈应用的所有方面, 也包括持续集成(CI/CD
) 方面的内容.
Although it is an exaggeration to develop a full-stack application in 7 days, but this article shows how to use mature cloud technology to build an application possibility. This is a primary tutorial for new bees with a little development experience, so it will not deep dive every technical point, but it will cover all aspects of developing a full-stack application, including continuous integration (CI/ CD
).
服务端采用了 Node.js
和 Koa2
框架. 用户鉴权是基于 AWS
的 Cognito
, 注册登入涉及的后台 Lambda Function 是在前端的工程里, 由 AWS Amplify
提供支持. 支付采用了 Stripe
提供的服务.
The server uses the Node.js
and Koa2
frameworks. User authentication is based on Cognito
of AWS
, the backend Lambda Function involved in registration and login is in the front-end project and is supported by AWS Amplify. Payment uses the service provided by Stripe
.
前端采用 Vue3
, 考虑第三方库的兼容性问题, 没有采用 Vite
作为开发和打包工具.
The front-end uses Vue3
作为抛砖引玉, 这篇文章提供一个大家可以学习交流的起点, 有兴趣的同学欢迎加微信和笔者互相交流.
As an introduction, this article provides a starting point for everyone to learn and communicate. It is welcome to add WeChat to communicate with the author.
相关的代码仓库如下:
The relevant code repositories are as follows:
后端代码仓库 Back-end
前端代码仓库 Front-end
共享库 Shared library
代码仓库暂时不公开, 有需要交流学习的同学可以添加联系方式。
Day 1 搭建前后端项目脚手架
”工欲善其事,必先利其器“
If a worker wants to do his job well, he must first sharpen his tools
- 高效的开发环境和顺畅的持续集成流程决定了开发的效率. 在 MacOS 下建议安装
iTerm2
,Oh-My-Zsh
并配置Powerlevel10K
的 theme. 这里不详细展开这些工具的安装了, 有兴趣的同学可以上网搜一下. 这是我的开发环境.
An efficient development environment and a smooth continuous integration process guarantees the efficiency of development. Under MacOS, it is recommended to install iTerm2
, Oh-My-Zsh
and then configure the theme of Powerlevel10K
. The installation of these tools will not be discussed here, if you are interested in these tools, you can google it. This is my development environment.
- 如果是一个团队参与一个项目的开发, 在开发时统一代码规范也是很重要的, 这个章节会重点描述
ESLint
和Prettier
的配置来规范团队的代码.
It is very important to unify the code style during development if a team is involved in a project. This section will focus on the configuration of ESLint
and Prettier
to standardize the team's code style.
从零到一创建一个后端的工程(create backend project from scratch)
- 先在
Github
上创建一个仓库ft-node
create ft-node
repository on Github
first
- 初始化本地
git
仓库, 并推送到Github
仓库中
initialize the local repository, and push it onto the remote repository under Github
mkdir backend && cd backend
echo "# ft-node" >> README.md
git init
git add README.md
git commit -m "first commit"
git remote add origin git@github.com:quboqin/ft-node.git
git push -u origin main
复制代码
- 添加
.gitignore
add .gitignore
file
# .gitignore
# Dependency directory
node_modules
# Ignore built ts files
dist
复制代码
- 初始化
npm
和Typescript
项目
Intialize the node.js project with npm
, and configure Typescript
# 创建代码目录
mkdir src
# 初始化 npm 项目
npm init -y
# 在初始化 Typescript 项目前先在本地安装 typescript
npm install typescript --save-dev
# 初始化 typescript 项目
npx tsc --init --rootDir src --outDir dist \
--esModuleInterop --target esnext --lib esnext \
--module commonjs --allowJs true --noImplicitAny true \
--resolveJsonModule true --experimentalDecorators true --emitDecoratorMetadata true \
--sourceMap true --allowSyntheticDefaultImports true
复制代码
- 安装
Koa
,Koa Router
和@Koa/cors
(支持跨域), 以及对应的类型依赖
npm i koa koa-router @koa/cors
npm i @types/koa @types/koa-router types/koa__cors --save-dev
复制代码
- 创建一个简单
Koa
服务
- 在 src 目录下创建 server.ts 文件
// server.ts
import Koa from 'koa'
import Router from 'koa-router'
import cors from '@koa/cors'
const app = new Koa()
const router = new Router()
app.use(cors())
router.get('/checkHealth', async (ctx) => {
ctx.body = {
code: 0,
data: 'Hello World!',
}
})
app.use(router.routes())
app.listen(3000)
console.log('Server running on port 3000')
复制代码
- 测试
npx tsc
node dist/server.js
Server running on port 3000
复制代码
- 安装
ts-node
和nodemon
, Running the server with Nodemon and TS-Node, 并修改package.json
中的脚本
npm i ts-node nodemon --save-dev
# 如果 typescript 安装在全局环境下
# npm link typescript
复制代码
修改 package.json
"scripts": {
"dev": "nodemon --watch 'src/**/*' -e ts,tsx --exec ts-node ./src/server.ts",
"build": "rm -rf dist && tsc",
"start": "node dist/server.js"
},
复制代码
- 配置
ESLint
和Prettier
- 安装
eslint
和相关依赖
npm i eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin --save-dev
复制代码
- eslint: The core ESLint linting library
- @typescript-eslint/parser: The parser that will allow ESLint to lint TypeScript code
- @typescript-eslint/eslint-plugin: A plugin that contains a bunch of ESLint rules that are TypeScript specific
- 添加 .eslintrc.js 配置文件
可以通过, 以交互的方式创建
npx eslint --init
复制代码
但是建议手动在项目的 root 目录下创建这个文件
// .eslintrc.js
module.exports = {
parser: '@typescript-eslint/parser', // Specifies the ESLint parser
parserOptions: {
ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features
sourceType: 'module', // Allows for the use of imports
},
extends: [
'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin
],
rules: {
// Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs
// e.g. "@typescript-eslint/explicit-function-return-type": "off",
},
}
复制代码
- 添加
Prettier
npm i prettier eslint-config-prettier eslint-plugin-prettier --save-dev
复制代码
- prettier: The core prettier library
- eslint-config-prettier: Disables ESLint rules that might conflict with prettier
- eslint-plugin-prettier: Runs prettier as an ESLint rule
In order to configure prettier, a .prettierrc.js file is required at the root project directory. Here is a sample .prettierrc.js file:
// .prettierrc.js
module.exports = {
tabWidth: 2,
semi: false,
singleQuote: true,
trailingComma: 'all',
}
复制代码
Next, the .eslintrc.js
file needs to be updated:
// eslintrc.js
extends: [
"plugin:@typescript-eslint/recommended", // Uses the recommended rules from the @typescript-eslint/eslint-plugin
"plugin:prettier/recommended" // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array.
],
复制代码
- Automatically Fix Code in VS Code
- 安装
eslint
扩展, 这里不需要安装prettier
扩展
- 安装
并点击右下角激活 eslint 扩展
- 在 VSCode 中创建 Workspace 的配置
{
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
}
复制代码
- Run ESLint with the CLI
- A useful command to add to the
package.json
scripts is a lint command that will run ESLint.
- A useful command to add to the
{
"scripts": {
"lint": "eslint '*/**/*.{js,ts,tsx}' --quiet --fix"
}
}
复制代码
- 添加
.eslintignore
# don't ever lint node_modules
node_modules
# don't lint build output (make sure it's set to your correct build folder name)
dist
复制代码
- Preventing ESLint and formatting errors from being committed
npm install husky lint-staged --save-dev
复制代码
To ensure all files committed to git don't have any linting or formatting errors, there is a tool called lint-staged that can be used. lint-staged allows to run linting commands on files that are staged to be committed. When lint-staged is used in combination with husky, the linting commands specified with lint-staged can be executed to staged files on pre-commit (if unfamiliar with git hooks, read about them here).
To configure lint-staged and husky, add the following configuration to the package.json
file:
{
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.{js,ts,tsx}": [
"eslint --fix"
]
}
}
复制代码
初始化 Vue3
前端
- 通过 Vue Cli 创建前端项目
# 参看 vue cli 的版本
vue --version
# 更新一下最新版本
npm update -g @vue/cli
# 创建项目
vue create frontend
复制代码
- 用
Vue Cli
创建的脚手架已经配置好大部分ESLint
和Prettier
, 这里只要统一一下前后端的Prettier
设置
- 添加 .prettierrc.js 文件
// .prettierrc.js
module.exports = {
tabWidth: 2,
semi: false,
singleQuote: true,
trailingComma: 'all',
}
复制代码
- 同样修改 workspace 的配置
{
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
}
复制代码
- 运行 npm run lint
前端引入 Axois, 访问接口(health interface)
- 安装 axios 模块
npm i axios --save
复制代码
- 封装 axios 模块, 设置默认 URL
// src/utils/axios.ts
axios.defaults.baseURL = 'http://localhost:3000'
复制代码
- 封装 src/apis/health.ts 文件
// src/apis/health.ts
import { result } from '@/utils/axios'
export function checkHealth<U>(): Promise<U | void> {
return result('get', '/checkHealth')
}
复制代码
- 在 App.vue 中调用 checkHealth 接口
<script lang="ts">
import { defineComponent, onMounted } from 'vue'
import { checkHealth } from '@/apis/health'
export default defineComponent({
name: 'App',
setup() {
const init = async () => {
checkHealth()
}
onMounted(init)
},
})
</script>
复制代码
这个阶段完成了一个最简单 APP 的前后端工程的搭建. 我这里规范了前后端的代码, 同学可以根据自己团队的代码规范添加不同的规则.
Javascript & Typescript Essential
Day 2 重构后端项目 前端引入状态管理
重构服务端
引入服务端日志库 logger
和 winston
# logger 作为记录请求的中间件
npm install koa-logger --save
npm install @types/koa-logger --save-dev
# winston 用于后端其他地方的日志记录
npm install winston --save
复制代码
效果如下
引入 dotenv
, 从环境变量和环境变量配置文件读取配置
- node 的后端服务是在运行时动态加载这些环境变量的,代码里我采用了 dotenv 模块来加载环境变量
npm install dotenv --save
复制代码
- 读取环境变量
// src/config/index.ts
import { config as dotenv } from 'dotenv'
const env = dotenv({ path: `.env.${process.env.NODE_ENV}` })
if (env.error) throw env.error
复制代码
- ** 两个注意点 **
- 如果
NODE_ENV
设置为production
,npm ci
不会安装devDependencies
中的依赖包,如果在运行的EC2
上编译打包,编译会报错。所以打包编译我放在了Github Actions
的容器中了,所以避免了这个问题
We have installed all our packages using the --save-dev flag. In production, if we use npm install --production these packages will be skipped.
- 操作系统下设置的环境变量会覆盖 .env 文件中定义的环境变量, 一般会把敏感的信息, 例如密码和密钥直接定义在系统的环境变量里, 而不是放在
.env
的文件下, 这样避免将代码提交到公开的代码仓库带来安全的隐患.
- 配置文件的加载逻辑都放到
config
目录下
服务端拆解 App 和 Server
index.ts
只保留了启动http
服务和其他连接数据库的服务
// src/index.ts
import * as http from 'http'
import app from './app'
import wsLogger from './utils/ws-logger'
async function initializeDB(): Promise<void> {
wsLogger.info(`Connect database...`)
return
}
async function bootstrap(port: number) {
const HOST = '0.0.0.0'
return http.createServer(app.callback()).listen(port, HOST)
}
try {
;(async function (): Promise<void> {
await initializeDB()
const HTTP_PORT = 3000
await bootstrap(HTTP_PORT)
wsLogger.info(`🚀 Server listening on port ${HTTP_PORT}!`)
})()
} catch (error) {
setImmediate(() => {
wsLogger.error(
`Unable to run the server because of the following error: ${error}`,
)
process.exit()
})
}
复制代码
app.ts
负责各种中间件的加载, 并安装bodyParser
npm install koa-bodyparser --save
npm install @types/koa-bodyparser --save-dev
复制代码
// src/app.ts
import Koa from 'koa'
import cors from '@koa/cors'
import logger from 'koa-logger'
import bodyParser from 'koa-bodyparser'
import wrapperRouter from './apis'
const app = new Koa()
app.use(cors())
app.use(logger())
app.use(bodyParser())
const wpRouter = wrapperRouter()
app.use(wpRouter.routes()).use(wpRouter.allowedMethods())
export default app
复制代码
搭建 RCS
模型, 将 server.ts
拆成 http
服务, Koa2
中间件和 koa-router
三个模块, 符合单一职责的设计原理
the logic should be divided into these directories and files.
- Models - The schema definition of the Model
- Routes - The API routes maps to the Controllers
- Controllers - The controllers handles all the logic behind validating request parameters, query, Sending Responses with correct codes.
- Services - The services contains the database queries and returning objects or throwing errors
router
拆解到apis
目录下, 按模块提供不同的接口, 并通过动态的方式加载
// src/apis/index.ts
import * as fs from 'fs'
import * as path from 'path'
import Router from 'koa-router'
import { serverConfig } from '../config/server'
const { apiVersion } = { apiVersion: serverConfig.apiVersion }
export default function wrapperRouter(): Router {
const router = new Router({
prefix: `/api/${apiVersion}`,
})
const baseName = path.basename(__filename)
fs.readdirSync(__dirname)
// 过滤掉 apis 根目录下的 index.js 和 index.js.map 文件
.filter(
(file) =>
file.indexOf('.') !== 0 && file !== baseName && !file.endsWith('.map'),
)
.forEach((file) => {
import(path.join(__dirname, file)).then((module) => {
router.use(module.router().routes())
})
})
return router
}
复制代码
- apis 下各个子模块的拆解如下图
构建前端路由, 引入 Vue 3.0 的状态管理 Provide 和 Inject
用 Vue3 的 Provide 和 Inject 替代 Vuex
参考 Using provide/inject in Vue.js 3 with the Composition API
用 npm 搭建共享的 Lib, 共享数据对象(Building and publishing an npm typescript package)
参考 Step by step: Building and publishing an NPM Typescript package.
- 创建目录
mkdir lib && cd lib
复制代码
- 初始化
git
仓库
git init
git checkout -b main
echo "# Building and publishing an npm typescript package" >> README.md
git add . && git commit -m "Initial commit"
复制代码
- 初始化
npm
项目
npm init -y
echo "node_modules" >> .gitignore
npm install --save-dev typescript
复制代码
- 配置
tsconfig.json
{
"compilerOptions": {
"target": "es2017",
"module": "commonjs",
"outDir": "./lib",
"sourceMap": true,
"strict": true,
"noImplicitAny": true,
"declaration": true,
"strictPropertyInitialization": false,
"experimentalDecorators": true /* Enables experimental support for ES7 decorators. */,
"emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */
},
"include": ["src"],
"exclude": ["node_modules", "**/__tests__/*"]
}
复制代码
- ignore
/lib
echo "/lib" >> .gitignore
复制代码
- 配置
eslint
- 安装 eslint 和 typescript 相关依赖
npm i eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin --save-dev
复制代码
- 创建
.eslintrc.js
// .eslintrc.js
module.exports = {
parser: '@typescript-eslint/parser',
extends: ['plugin:@typescript-eslint/recommended', 'prettier/@typescript-eslint', 'plugin:prettier/recommended'],
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module',
},
rules: {},
}
复制代码
- 安装
prettier
npm i prettier eslint-config-prettier eslint-plugin-prettier -D
复制代码
- 创建
.prettierrc.js
// .prettierrc.js
module.exports = {
semi: false,
trailingComma: 'all',
singleQuote: true,
printWidth: 120,
tabWidth: 2,
}
复制代码
- 配置 vscode workspace
{
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
}
复制代码
- 添加 .eslintignore
node_modules
/lib
复制代码
- 配置 npm 库的输出文件
However, blacklisting files is not a good practice. Every new file/folder added to the root, needs to be added to the .npmignore file as well! Instead, you should whitelist the files /folders you want to publish. This can be done by adding the files property in
package.json
:
"files": [
"lib/**/*"
],
复制代码
- Setup Testing with Jest
- 安装
Jest
npm install --save-dev jest ts-jest @types/jest
复制代码
- Create a new file in the root and name it jestconfig.json:
{
"transform": {
"^.+\\.(t|j)sx?$": "ts-jest"
},
"testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
"moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"]
}
复制代码
- Remove the old test script in
package.json
and change it to:
"test": "jest --config jestconfig.json",
复制代码
- 准备一个待测试的函数
index.ts
// index.ts
export const Greeter = (name: string): string => `Hello ${name}`
复制代码
- Write a basic test
In the src folder, add a new folder called tests and inside, add a new file with a name you like, but it has to end with
test.ts
, for examplegreeter.test.ts
// greeter.test.ts
import { Greeter } from '../index';
test('My Greeter', () => {
expect(Greeter('Carl')).toBe('Hello Carl');
});
复制代码
then try to run
npm test
复制代码
- Use the magic scripts in NPM
- 修改脚本
package.json
"prepare": "npm run build",
"prepublishOnly": "npm test && npm run lint",
"preversion": "npm run lint",
"version": "git add -A src",
"postversion": "git push && git push --tags",
"patch": "npm version patch && npm publish"
复制代码
- Updating your published package version number
To change the version number in package.json, on the command line, in the package root directory, run the following command, replacing <update_type> with one of the semantic versioning release types (patch, major, or minor):
npm version <update_type>
复制代码
- Run npm publish.
- Finishing up
package.json
"description": "share library between backend and frontend",
"main": "lib/index.js",
"types": "lib/index.d.ts",
复制代码
- Publish you package to NPM!
- run
npm login
╰─ npm login ─╯
npm notice Log in on https://registry.npmjs.org/
Username: quboqin
Password:
Email: (this IS public) qubo.qin.2018@gmail.com
Logged in as quboqin on https://registry.npmjs.org/.
复制代码
- install
quboqin-lib
on both sides(frontend and backend), and add test code
npm i quboqin-lib
复制代码
Day 3 前端的界面设计和开发
创建数据模型, 更新 quboqin-lib
前端引入 Tailwind CSS UI 库
- 用
Figma
设计一个 UI 的原型
2. 安装 Tailwind
这是一个基于 utility-first
思想的 UI 库, 在它基础上上可以方便搭建 UI 界面, 基本不用在代码里写一行 css
. 也方便后期维护
安装中要注意 PostCSS 7 的兼容性
- 安装
npm install -D tailwindcss@npm:@tailwindcss/postcss7-compat postcss@^7 autoprefixer@^9
复制代码
- Add Tailwind as a PostCSS plugin
Add tailwindcss and autoprefixer to your PostCSS configuration.
// postcss.config.js
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
}
}
复制代码
- Customize your Tailwind installation
npx tailwindcss init
复制代码
- Include Tailwind in your CSS
/* index.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
复制代码
then import this file in main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './index.css'
createApp(App).use(router).mount('#app')
复制代码
- When building for production, be sure to configure the purge option to remove any unused classes for the smallest file size:
// tailwind.config.js
module.exports = {
purge: [
'./src/**/*.html',
'./src/**/*.js',
],
darkMode: false, // or 'media' or 'class'
theme: {
extend: {},
},
variants: {},
plugins: [],
}
复制代码
- 其他组件库
可以用 Tailwind
开发 UI 的组件, 这个工程里为了快速开发, 这里还引入了 Vant
, 这是一个较早支持 Vue 3 的 UI 库
# Install Vant 3 for Vue 3 project
npm i vant@next -S
复制代码
- 用到了
materialdesignicons
, 在html
中添加
<link href="https://cdn.jsdelivr.net/npm/@mdi/font@5.x/css/materialdesignicons.min.css" rel="stylesheet">
复制代码
封装 Axios
和接口, 提供 Mock
数据
// src/apis/goods.ts
import goods from '@/mock/goods.json'
export function getAllGoods(): Promise<Good[] | void> {
return result('get', '/goods', null, goods as Good[])
}
复制代码
本地支持HTTPS[Server]
- 在项目的
root
目录下创建local-ssl
子目录, 在local-ssl
子目录下,新建一个配置文件req.cnf
[req]
distinguished_name = req_distinguished_name
x509_extensions = v3_req
prompt = no
[req_distinguished_name]
C = US
ST = California
L = Folsom
O = MyCompany
OU = Dev Department
CN = www.localhost.com
[v3_req]
keyUsage = critical, digitalSignature, keyAgreement
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
DNS.1 = www.localhost.com
DNS.2 = localhost.com
DNS.3 = localhost
复制代码
- 在
local-ssl
目录下创建本地证书和私钥
openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout cert.key -out cert.pem -config req.cnf -sha256
复制代码
- 修改
server
端代码支持本地https
// src/index.ts
interface CERT_FILE {
key: string
cert: string
ca?: string
}
const certPaths: Record<string, CERT_FILE> = {}
certPaths[`${SERVER.LOCALHOST}`] = {
key: './local-ssl/cert.key',
cert: './local-ssl/cert.pem',
}
const httpsOptions = {
key: fs.readFileSync(certPaths[config.serverConfig.server].key),
cert: fs.readFileSync(certPaths[config.serverConfig.server].cert),
}
return https.createServer(httpsOptions, app.callback()).listen(port, HOST)
复制代码
- 启动
https
服务后,仍然报错
curl https://localhost:3000/api/v1/users
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.haxx.se/docs/sslcerts.html
curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above
复制代码
在浏览器链接该 https 服务,也报错误,下载证书,双击导入密钥管理器,手动让证书受信
- 前端接口也改成
https
访问
// src/utils/axios.ts
axios.defaults.baseURL = 'https://localhost:3000/api/v1'
复制代码
Day 4 手机登录和注册
用户鉴权和手机登录用到了 AWS
的 Cognito
和 Amplify
等服务
在前端工程里初始化 Amplify
和 Cognito
- 初始化
Amplify
amplify init
复制代码
默认创建了 dev
后端环境
- 添加
Auth
服务
amplify add auth
复制代码
**一定要选择 Manual Configuration
**, 具体参数如下
否则 Cognito 后台配置会默认为 Username 方式鉴权, 而且无法修改
- 添加
PreSignup
的函数[选择Manual Configuration
可以在命令行里添加, 这步可以跳过]
amplify fucntion add
复制代码
-
更新这个四个
Lambda Functions
的逻辑 -
推送
dev
环境
amplify push
复制代码
- 进入
AWS Console
的Cognito
, 手动绑定PreSignup
函数[选择Manual Configuration
在命令行里已经绑定, 这步可以跳过]
- 给
CreateAuthChallenge
添加发送短信权限
进入 Lambda
的 后台页面,找到对应环境下 dev
的 foodtruck4d6aa6e5CreateAuthChallenge
函数
找到对应的 Role,在Role的后台添加 SNS 权限
- 配置 SNS
- 前端添加
Amplify
模块
- 安装依赖包
npm i aws-amplify aws-amplify-vue --save
复制代码
- 初始化
Amplify
配置
// main.ts
import Amplify from 'aws-amplify'
import awsconfig from './aws-exports'
Amplify.configure(awsconfig)
复制代码
** 在 tsconfig.json 中添加
"allowJs": true,
复制代码
添加 aws-exports.d.ts
// eslint-disable-next-line @typescript-eslint/ban-types
declare const awsmobile: {}
export default awsmobile
复制代码
这两步都要, 否则打包会报以下错误
TS7016: Could not find a declaration file for module './aws-exports'. '/Users/qinqubo/magic/projects/full-stack/food-truck/frontend/src/aws-exports.js' implicitly has an 'any' type.
4 |
5 | import Amplify from 'aws-amplify'
> 6 | import awsconfig from './aws-exports'
| ^^^^^^^^^^^^^^^
7 | Amplify.configure(awsconfig)
8 |
9 | import './index.css'
复制代码
- 团队其他成员如果在新的环境下开发
clone 代码仓库后, 在项目根目录下
amplify init
复制代码
Do you want to use an existing environment
选 Yes
后端工程添加 JWT
模块
- 添加
JWT
的中间件的依赖
npm i jsonwebtoken jwk-to-pem --save
npm i @types/jsonwebtoken @types/jwk-to-pem --save-dev
复制代码
- 添加
JWT
中间件, 检查cognito
的有效性
// src/jwt/cognito.ts
复制代码
- 修改 app.ts
// src/app.ts
const unprotectedRouter = wrapperRouter(false)
const protectedRouter = wrapperRouter(true)
app.use(unprotectedRouter.routes()).use(unprotectedRouter.allowedMethods())
app.use(jwtCognito(config.awsCognitoConfig))
app.use(protectedRouter.routes()).use(protectedRouter.allowedMethods())
export default app
复制代码
- apis 目录下分受保护的和不受保护的两类目录, 并修改路由加载逻辑
// src/apis/index.ts
export default function wrapperRouter(isProtected: boolean): Router {
const router = new Router({
prefix: `/api/${apiVersion}`,
})
const subFolder = path.resolve(
__dirname,
isProtected ? './protected' : './unprotected',
)
// Require all the folders and create a sub-router for each feature api
fs.readdirSync(subFolder).forEach((file) => {
import(path.join(subFolder, file)).then((module) => {
router.use(module.router().routes())
})
})
return router
}
复制代码
用 Postman 测试
- 设置Content-Type为application/json
- 设置Token
- Query parameters
https://localhost:3000/api/v1/goods
https://localhost:3000/api/v1/users
复制代码
Day 5 数据库的支持
Postgres
和数据模型
- 在后端项目安装
postgres
相关package
npm i typeorm reflect-metadata pg --save
复制代码
- 改造共享库
- 在共享库安装
typeorm
npm i typeorm --save
复制代码
之后更新前后端的依赖
- 在后端工程里安装
uuid
npm i uuid --save
npm i @types/uuid --save-dev
复制代码
- 通过 docker 连接数据库
- 添加 docker-compose.yml
version: '3.1'
services:
db:
image: postgres:10-alpine
ports:
- '5432:5432'
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: apidb
admin:
image: adminer
restart: always
depends_on:
- db
ports:
- 8081:8080
复制代码
- 添加 postgres 配置和连接代码
// src/config/index.ts
export async function initializePostgres(): Promise<Connection | void> {
if (postgresConfig) {
try {
return createConnection({
type: 'postgres',
url: postgresConfig.databaseUrl,
ssl: postgresConfig.dbsslconn
? { rejectUnauthorized: false }
: postgresConfig.dbsslconn,
synchronize: true,
logging: false,
entities: postgresConfig.dbEntitiesPath,
})
} catch (error) {
logger.error(error)
}
}
}
复制代码
- 导入
goods
数据
npm run import-goods
复制代码
Jest
测试服务层和数据库
- 安装
Jest
npm i jest @types/jest ts-jest --save-dev
复制代码
- 配置 Jest
// jest.config.js
/* eslint-disable max-len */
module.exports = {
clearMocks: true,
maxWorkers: 1,
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: [
'**/__tests__/**/*.[jt]s?(x)',
'!**/__tests__/coverage/**',
'!**/__tests__/utils/**',
'!**/__tests__/images/**',
// "**/?(*.)+(spec|test).[tj]s?(x)"
],
globalSetup: './src/utils/jest.setup.ts',
}
复制代码
// ormconfig.js
module.exports = {
name: 'default',
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'user',
password: 'pass',
database: 'test',
logging: false,
synchronize: true,
entities: ['node_modules/quboqin-lib/lib/**/*.js'],
}
复制代码
- 在 src/tests 下添加两个测试用例
axois 不直接支持 URL的 Path Variable, .get('/:phone', controller.getUser) 路由无法通过 axois 直接访问, 修改 getUsers, 通过 Query Parameter 提取 phone
- Path parameters
** axois发出的请求不支持这种方式 **,可以在浏览器和Postman里测试这类接口
https://localhost:3000/api/v1/addresses/24e583d5-c0ff-4170-8131-4c40c8b1e474
复制代码
对应的route是
router
.get('/:id', controller.getAddress)
复制代码
下面的控制器里演示是如何取到参数的
public static async getAddress(ctx: Context): Promise<void> {
const { id } = ctx.params
const address: Address = await postgre.getAddressById(id)
const body = new Body()
body.code = ERROR_CODE.NO_ERROR
if (address) {
body.data = address
} else {
body.code = ERROR_CODE.UNKNOW_ERROR
body.message = `the card you are trying to retrieve doesn't exist in the db`
}
ctx.status = 200
ctx.body = body
}
复制代码
- 如何现在Postman里测试接口
设置Content-Type为application/json
设置Token
- Query parameters
https://localhost:3000/api/v1/addresses?phone=%2B13233013227&id=24e583d5-c0ff-4170-8131-4c40c8b1e474
复制代码
- Koa2 对应 Axios 返回的值, 以下是伪代码
// Koa ctx: Context
interface ctx {
status,
body: {
code,
data,
message
}
}
// Axois response
interface response {
status,
data
}
复制代码
response.data 对应 ctx.body, 所以在 Axios 获取 response 后, 是从 response.data.data 得到最终的结果
function get<T, U>(path: string, params: T): Promise<U> {
return new Promise((resolve, reject) => {
axios
.get(path, {
params: params,
})
.then((response) => {
resolve(response.data.data)
})
.catch((error) => {
reject(error)
})
})
}
复制代码
Day 6 手机支付
服务端的实现
- 引入
stripe
库
npm i stripe --save
复制代码
- 配置
stripe
// src/utils/stripe.ts
import Stripe from 'stripe'
import { config } from '../config'
import { PAYMENT_TYPE } from 'quboqin-lib/lib/card'
export const stripe = new Stripe(
config.paymentType === PAYMENT_TYPE.STRIPE_ONLINE
? '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'
: 'sk_test_DU1VhfGGYrEv5EvJS2my4yhD',
{ apiVersion: '2020-08-27' },
)
复制代码
- 在订单接口中发起支付调用
前端的实现
- 在
html
文件里添加stripe
全局模块
<script src="https://js.stripe.com/v3/"></script>
复制代码
- 添加
stripe
的封装
// scr/utils/stripe.js
// eslint-disable-next-line no-undef
export const stripe = Stripe(
process.env.VUE_APP_ONLINE_PAYMENT === '1'
? 'pk_live_51HZCSNLZTTlHwkSOdYTRnFdh0AxF7JNwXShbMrKfEPzxnXLPzGz0hXJJzKxybnWngvF89FKJRXxnr2fo8zpNlZ5700Nm864NNM'
: 'pk_test_GrY8g9brTmOaRZ6XKks0woG0',
)
export const elements = stripe.elements()
const elementStyles = {
base: {
color: '#32325D',
fontWeight: 500,
fontFamily: 'Source Code Pro, Consolas, Menlo, monospace',
fontSize: '16px',
fontSmoothing: 'antialiased',
'::placeholder': {
color: '#CFD7DF',
},
':-webkit-autofill': {
color: '#e39f48',
},
},
invalid: {
color: '#E25950',
'::placeholder': {
color: '#FFCCA5',
},
},
}
const elementClasses = {
focus: 'focused',
empty: 'empty',
invalid: 'invalid',
}
// export const cardIBan = elements.create('iban', {
// ...{
// style: elementStyles,
// classes: elementClasses,
// },
// ...{
// supportedCountries: ['SEPA'],
// placeholderCountry: 'US',
// hideIcon: true,
// },
// })
export const cardNumber = elements.create('cardNumber', {
style: elementStyles,
classes: elementClasses,
})
export const cardExpiry = elements.create('cardExpiry', {
style: elementStyles,
classes: elementClasses,
})
export const cardCvc = elements.create('cardCvc', {
style: elementStyles,
classes: elementClasses,
})
复制代码
- 添加开发环境配置
.env
.env.development
改造 axois 读取配置
// src/utils/axios.ts
const port = process.env.VUE_APP_PORT
const url = process.env.VUE_APP_BASE_URL
axios.defaults.baseURL = port ? `${url}:${process.env.VUE_APP_PORT}` : `${url}`
axios.defaults.timeout = process.env.VUE_APP_TIMEOUT
? +process.env.VUE_APP_TIMEOUT
: 5000
axios.defaults.headers.post['Content-Type'] =
'application/x-www-form-urlencoded;charset=UTF-8'
复制代码
- 修改 CreditCardDetail.vue
Day 7 持续集成
前端编译环境和 Heroku
, Amplify
, 自建 Oracle VPS
的部署
部署到 Heroku
- 创建
staging
和production
两个独立的 APP
与服务端部署不一样,两个环境都是在Webpack
打包时注入了环境变量,两个环境都需要走 CI 的编译打包流程,所以通过Pipeline
的Promote
是没有意义的。我们这里创建两个独立的 APP:[staging-ft, production-ft] - 为每个 APP 添加编辑插件, 因为是静态部署,比
Node Server
多了一个插件
heroku buildpacks:add heroku/nodejs
heroku buildpacks:add https://github.com/heroku/heroku-buildpack-static
复制代码
并且要添加 static.json
文件在项目的根目录下
{
"root": "dist",
"clean_urls": true,
"routes": {
"/**": "index.html"
}
}
复制代码
- 在两个 APP 的设置中绑定关联的 Github 仓库和分支
这里的 VUE_APP_URL
是在 编译 时候,覆盖 axios 默认的 BASE_URL,指向对应的 Node Server,不同的分支也可以有不同的 Value. 与 Amplify 不同, 这里要设置 NODE_ENV=production
,设置了这个后 npm ci 不会影响 install devDependencies
下的模块
通过 Amplify
部署
创建 amplify.yaml
文件,修改 build 的脚本
version: 0.2
backend:
phases:
build:
commands:
- '# Execute Amplify CLI with the helper script'
- amplifyPush --simple
frontend:
phases:
preBuild:
commands:
- npm ci
build:
commands:
- nvm use $VERSION_NODE_10
- node -v
- if [ "${AWS_BRANCH}" = "aws-staging" ]; then echo "staging branch" && npm run build:staging; elif [ "${AWS_BRANCH}" = "aws-production" ]; then echo "production branch" && npm run build:production; else echo "test branch" && npm run build:mytest; fi
artifacts:
baseDirectory: dist
files:
- '**/*'
cache:
paths:
- node_modules/**/*
复制代码
当推送到对应的分支后,Amplify 会调用这个脚本执行编译,打包和部署
设置环境相关的变量
这里的 VUE_APP_URL
是在 编译 时候,覆盖 axios 默认的 BASE_URL,指向对应的 Node Server,不同的分支也可以有不同的 Value
- 注意不要添加
NODE_ENV=production
,设置了这个后 npm ci 不会install devDependencies
下的模块,会导致npm run build
报错无法找到vue-cli-service
Vue
的Webpack
会根据--mode [staging | production ]
找到对应的.env.\*
文件, 在这些中再声明NODE_ENV=production
创建 Amplify
角色
创建 Amplify APP 时,好像没有自动创建关联的 Role, 我手动创建了一个
部署到 Oracle CentOS 8
服务器中的 Nginx
下
- 配置 Nginx 支持单个端口对应多个二级域名的静态服务
- 编辑 /etc/nginx/nginx.conf 支持同一个端口,不同的静态服务器
server {
listen 80;
server_name test.magicefire.com;
root /usr/share/nginx/html/test;
location / {}
}
server {
listen 80;
server_name staging.magicefire.com;
root /usr/share/nginx/html/staging;
location / {}
}
server {
listen 80;
server_name production.magicefire.com;
root /usr/share/nginx/html/production;
location / {}
}
复制代码
建立对应的目录,在目录下放测试 html
- 修改
Cloudflare
,添加三条A
记录,支持VPS
的IP
- 通过
Let's Encrypt
修改nginx
的https
支持
安装 certbot
见 Node Server
的部署
certbot -nginx
复制代码
- 在 .github/workflows 下添加 Github Actions, 编写 Github Actions 部署脚本
注意不要添加 NODE_ENV=production,设置了这个后 npm ci 不会 install devDependencies 下的模块,会导致 npm run build 报错无法找到 vue-cli-service
Vue 的 Webpack 会根据 --mode [staging | production ] 找到对应的 .env.* 文件, 在这些中再声明 NODE_ENV=production
- 在 Github 的仓库设置中,给 Actions 用到的添加加密的 Secrets
DEPLOY_ORACLE=/usr/share/nginx/html
后端运行环境和 Heroku/EC2
部署
运行时根据环境加载配置项
- 在 package.json 的脚本中通过 NODE_ENV 定义环境 [development, test, staging, production]
- 安装 dotenv 模块, 该模块通过 NODE_ENV 读取工程根目录下对应的 .env.[${NODE_ENV}] 文件, 加载该环境下的环境变量
- 按 topic[cognito, postgres, server, etc] 定义不同的配置, 并把 process.env.IS_ONLINE_PAYMENT 之类字符串形式的转成不同的变量类型
- 可以把环境变量配置在三个不同的level
- 运行机器的 SHELL 环境变量中, 主要是定义环境的 NODE_ENV 和其他敏感的密码和密钥
- 在以 .${NODE_ENV} 结尾的 .env 文件中
- 最后汇总到 src/config 目录下按 topic 区分的配置信息
- 代码的其他地方读取的都是 src/config 下的配置信息
- 在后端环境下设置 NODE_ENV 有一个副作用,在 Typescript 编译打包前
** 如果 NODE_ENV 设置为 production, npm ci
不会安装 devDependencies 中的依赖包,如果在运行的 EC2 上编译打包,编译会报错。所以打包编译我放在了Github Actions 的容器中了,所以避免了这个问题 **
We have installed all our packages using the --save-dev flag. In production, if we use npm install --production these packages will be skipped.
Amplify
创建线上用户库
- 在前端,通过 Amplify 添加线上环境
amplify env add prod
复制代码
- 检查状态, 并部署到
Amplify
后台
amplify status
复制代码
amplify push
复制代码
进入 AWS Cognito
后台, 会增加一个线上的 UserPool
-
添加
foodtruck2826b8ad2826b8adCreateAuthChallenge-prod
的短信权限 -
修改线上配置文件中
AWS_COGNITO_USER_POOL_ID
// .env.oracle
// .env.staging
// .env.production
AWS_COGNITO_USER_POOL_ID=ap-northeast-1_2ae9TN9FH
复制代码
主要区分三个模块在不同环境的部署
- Postgres 数据库
- 支付
- 用户鉴权
修改 HTTPS 的证书加载
Heroku 部署
Heroku 是我们介绍的三种 CI/CD 流程中最简单的方式
- 创建一条 Pipeline, 在 Pipeline 下创建 staging 和 production 两个应用
2. 在 APP 的设置里关联 Github 上对应的仓库和分支
- APP staging 选择 heroku-staging 分支
- APP production 选择 heroku-production 分支
- 为每个 APP 添加 heroku/nodejs 编译插件
heroku login -i
heroku buildpacks:add heroku/nodejs -a staging-api-food-truck
复制代码
- 设置运行时的环境变量
这里通过 SERVER 这个运行时的环境变量,告诉 index.ts 不要加载 https 的服务器, 而是用 http 的服务器。
** Heroku 的 API 网关自己已支持 https,后端起的 node server 在内网里是 http, 所以要修改代码 换成 http server,否者会报 503 错误**
5. 修改 index.ts 文件,在 Heroku 下改成 HTTP
6. APP production 一般不需要走 CI/CD 的流程,只要设置 NODE_ENV=production,然后在 APP staging 验证通过后, promote 就可以完成快速部署。
7. 查看 heroku 上的日志
heroku logs --tail -a staging-api-food-truck
复制代码
AWS EC2 部署
在 AWS 上搭建环境和创建用户和角色
CodeDeploy
We'll be using CodeDeploy for this setup so we have to create a CodeDeploy application for our project and two deployment groups for the application. One for staging and the other for production.
- To create the api-server CodeDeploy application using AWS CLI, we run this on our terminal:
aws deploy create-application \
--application-name api-server \
--compute-platform Server
复制代码
- Before we run the cli command to create the service role, we need to create a file with IAM specifications for the role, copy the content below into it and name it code-deploy-trust.json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "",
"Effect": "Allow",
"Principal": {
"Service": [
"codedeploy.amazonaws.com"
]
},
"Action": "sts:AssumeRole"
}
]
}
复制代码
- We can now create the role by running:
aws iam create-role \
--role-name CodeDeployServiceRole \
--assume-role-policy-document file://code-deploy-trust.json
复制代码
- After the role is created we attach the AWSCodeDeployRole policy to the role
aws iam attach-role-policy \
--role-name CodeDeployServiceRole \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSCodeDeployRole
复制代码
- To create a deployment group we would be needing the service role ARN.
aws iam get-role \
--role-name CodeDeployServiceRole \
--query "Role.Arn" \
--output text
复制代码
The ARN should look something like arn:aws:iam::403593870368:role/CodeDeployServiceRole
6. Let's go on to create a deployment group for the staging and production environments.
aws deploy create-deployment-group \
--application-name api-server \
--deployment-group-name staging \
--service-role-arn arn:aws:iam::403593870368:role/CodeDeployServiceRole \
--ec2-tag-filters Key=Name,Value=staging,Type=KEY_AND_VALUE
复制代码
aws deploy create-deployment-group \
--application-name api-server \
--deployment-group-name production \
--service-role-arn arn:aws:iam::403593870368:role/CodeDeployServiceRole \
--ec2-tag-filters Key=Name,Value=production,Type=KEY_AND_VALUE
复制代码
进入 Console -> Code Deploy 确认
创建 S3 Bucket
创建一个名为 node-koa2-typescript 的 S3 Bucket
aws s3api create-bucket --bucket node-koa2-typescript --region ap-northeast-1
复制代码
Create and Launch EC2 instance
完整的演示,应该创建 staging 和 production 两个 EC2 实例,为了节省资源,这里只创建一个实例
- 创建一个具有访问 S3 权限的角色 EC2RoleFetchS3
2. In this article, we will be selecting the Amazon Linux 2 AMI (HVM), SSD Volume Type.
- 绑定上面创建的角色,并确认开启80/22/3001/3002几个端口
- 添加 tag,key 是 Name,Value 是 production
- 导入用于 ssh 远程登入的公钥
- 通过 ssh 远程登入 EC2 实例,安装 CodeDeploy Agent
安装步骤详见 CodeDeploy Agent
3. 通过 ssh 安装 Node.js 的运行环境
- 通过 NVM 安装 Node.js
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.35.3/install.sh | bash
source ~/.bash_profile
nvm list-remote
nvm install 14
复制代码
- 安装 PM2 管理 node 的进程
npm i pm2 -g
复制代码
- 在项目的根目录下创建 ecosystem.config.js 文件
module.exports = {
apps: [
{
script: './dist/index.js',
},
],
}
复制代码
- 在 EC2 的实例 ec2-user 账号下设置环境变量, 编辑 ~/.bash_profile
export NODE_ENV=production
export SERVER=AWS
复制代码
这里放置 NODE_ENV 和具有敏感信息的环境变量, 这里 SERVER=AWS 只是演示
创建用于 Github Actions 部署脚本的用户组和权限
- 在 IAM 中创建以一个 CodeDeployGroup 用户组,并赋予 AmazonS3FullAccess and AWSCodeDeployFullAccess 权限
- 在 CodeDeployGroup 添加一个 dev 用户,记录下 Access key ID 和 Secret access key
编写 Github Actions 脚本
- 在工程的根目录下创建 .github/workflows/deploy-ec2.yaml 文件
deploy-ec2.yaml 的作用是,当修改的代码提交到 aws-staging 或 aws-production,触发编译,打包,并上传到 S3 的 node-koa2-typescript bucket, 然后再触发 CodeDeploy 完成后续的部署。所以这个 Github Action 是属于 CI 的角色,后面的 CodeDeploy 是 CD 的角色。
2. 在 Github 该项目的设置中添加 Environment secrets, 将刚才 dev 用户的 Access key ID 和 Secret access key 添加进Environment secrets
添加 appspec.yml 及相关脚本
CodeDeploy 从 S3 node-koa2-typescript bucket 中获取最新的打包产物后,上传到 EC2 实例,解压到对应的目录下,这里我们指定的是 /home/ec2-user/api-server。CodeDeploy Agent 会找到该目录下的 appspec.yml 文件执行不同阶段的 Hook 脚本
version: 0.0
os: linux
files:
- source: .
destination: /home/ec2-user/api-server
hooks:
AfterInstall:
- location: aws-ec2-deploy-scripts/after-install.sh
timeout: 300
runas: ec2-user
ApplicationStart:
- location: aws-ec2-deploy-scripts/application-start.sh
timeout: 300
runas: ec2-user
复制代码
aws-ec2-deploy-scripts/application-start.sh 启动了 Node.js 的服务
#!/usr/bin/env bash
source /home/ec2-user/.bash_profile
cd /home/ec2-user/api-server/
pm2 delete $NODE_ENV
pm2 start ecosystem.config.js --name $NODE_ENV
复制代码
在 EC2 实例下安装免费的域名证书,步骤详见Certificate automation: Let's Encrypt with Certbot on Amazon Linux 2
- 去 Cloudflare 添加 A 记录指向这台 EC2 实例,指定二级域名是 aws-api
2. 安装配置 Apache 服务器,用于证书认证
3. Install and run Certbot
sudo certbot -d aws-api.magicefire.com
复制代码
根据提示操作,最后证书生成在 /etc/letsencrypt/live/aws-api.magicefire.com/ 目录下
4. 因为我们启动 Node 服务的账号是 ec2-user, 而证书是 root 的权限创建的,所以去 /etc/letsencrypt 给 live 和 archive 两个目录添加其他用户的读取权限
sudo -i
cd /etc/letsencrypt
chmod -R 755 live/
chmod -R 755 archive/
复制代码
- Configure automated certificate renewal
部署和验证
- 如果没有 aws-production 分支,先创建该分支,并切换到该分支下,合并修改的代码,推送到Github
git checkout -b aws-production
git merge main
git push orign
复制代码
- 触发 Github Actions
- 编译,打包并上传到 S3 后,触发 CodeDeploy
- 完成后在浏览器里检查
或用 curl 在命令行下确认
curl https://aws-api.magicefire.com:3002/api/v1/health
复制代码
- 因为我们没有创建 EC2 的 staging 实例,如果推送到 aws-staging 分支,CodeDeploy 会提示以下错误
过程回顾
Postgres VPS 和 Heroku 部署[Server]
在 Heroku 上部署 Postgres
- Provisioning Heroku Postgres
heroku addons
heroku addons:create heroku-postgresql:hobby-dev
复制代码
- Sharing Heroku Postgres between applications
heroku addons:attach my-originating-app::DATABASE --app staging-api-node-server
复制代码
- 导入商品到线上 table
npm run import-goods-heroku-postgre
复制代码
近期评论