本篇实际并不是入门,需要你稍微掌握一些基础知识后才看的懂,很多基础的没有讲。
Ansible 不支持在 windows 上作为控制节点使用,虽然可以安装,但是运行不了: Why no Ansible controller for Windows?。
但是 windows 可以作为被控制的节点来使用。
创建虚拟环境并安装:
python3 -m venv ansible source ansible/bin/activate python3 -m pip install ansible-coreshell
如果是 python3.6
最高只能装 2.11
更高的版本需要升级 python 版本。
因为主要是靠 Python 代码执行,所以这里基础没有讲很多。
ansible 中有下面几种常用的特殊名词:
Task
组成。其中每个 Play
都会指定 Inventory
中的一组服务器。Play
类似,但是在声明时不需要指定 Inventory
,所以一般不会直接写 Play
,而是直接使用 Role
来编写,方便多次复用。除此之外,在 Task 中有下面这些关键词也比较常用:
# Ping all inventory
ansible -m ping all -i inventory.yaml
# Run a play
ansible run play.yaml -i inventory.yamlshell
一个常用的目录结构如下:
. ├── env ├── inventory └── project └── roles └── my_role ├── handlers ├── tasks ├── templates └── varstext
想要创建一个 filter,首先在任意目录中创建一个 python 文件:
def greet(name):
return f"Hello, {name}!"
class FilterModule(object):
def filters(self):
return {
'greet': greet,
}python
上面的代码就实现了一个 filter,然后使用环境变量来指向对应的目录:
export ANSIBLE_FILTER_PLUGINS=/path/to/custom/filter_pluginsbash
使用:
# playbook.yml
---
- hosts: localhost
tasks:
- name: Use global custom greet filter
debug:
msg: "{{ 'World' | greet }}"yaml
输出:
TASK [Use global custom greet filter] ********************************* ok: [localhost] => { "msg": "Hello, World!" }text
注意,这么调用是错误的:
- name: Debug debug: msg: "{{ greet('World') }}"text
必须使用前一种类似管道符的语法。
上面的代码中,我们使用 filter 传递了一个参数进去,然后返回一个值。但是如果要传递多个参数该怎么办?
解决方法如下:
# filter_plugins/custom_filters.py
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
class FilterModule(object):
def filters(self):
return {
'greet': greet,
}python
使用:
# playbook.yml
- hosts: localhost
tasks:
- name: Use custom greet filter with multiple arguments
debug:
msg: "{{ 'World' | greet('Good morning') }}"yaml
巨奇怪有木有…
在前面我们说过可以通过 ansible-runner 来提前获取好参数来提供给 ansible 使用,但是 ansible 自己也可以主动通过调用 Python 脚本来动态获取外部参数。
和 filter 插件一样,创建一个 Python 文件:
# lookup_plugins/my_custom_lookup.py
from ansible.plugins.lookup import LookupBase
class LookupModule(LookupBase):
def run(self, terms, variables=None, **kwargs):
# Custom logic here
return [f"Hello, {terms[0]}!"]python
然后使用环境变量指向这个目录:
export ANSIBLE_LOOKUP_PLUGINS=/path/to/custom/filter_pluginsbash
使用:
# playbook.yml
- hosts: localhost
tasks:
- name: Use custom lookup plugin
debug:
msg: "{{ lookup('my_custom_lookup', 'World') }}"yaml
输出:
TASK [Use custom lookup plugin] ************************************* ok: [localhost] => { "msg": "Hello, World!" }text
这里文档非常🌿🥚,完全没讲每个参数是什么意思,这里就详细记一下,防止以后忘了。
terms
代表在使用 lookup
时后面的列表参数。
使用时这样传:
# In a playbook or template
{{ lookup('my_custom_lookup', 'argument1', 'argument2') }}yaml
terms
就是 ['argument1', 'argument2']
。
这个很好理解,就是可以获取到上下文中的参数:
# In the lookup plugin
def run(self, terms, variables=None, **kwargs):
# 获取上下文中的 my_var 参数
value_from_var = variables.get('my_var')
return [f"{value_from_var}, {terms[0]}"]python
这个可以理解为具名参数,类型是一个字典:
# In a playbook or template
{{ lookup('my_custom_lookup', 'term', option1='value1', option2='value2') }}yaml
对于 option1
和 option2
就可以直接在 kwargs
通过字典的方式获取到。
例如在上面一个 role 的目录中,我们有一个 templates 模板,一般这个文件夹里面放的都是配置文件,如果我们想要一口气全部发送到远程服务器里面,
除了可以一个一个写,还可以这样写:
- name: Transfer Template
with_fileglob:
- "templates/*.j2"
ansible.builtin.template:
src: "{{ item }}"
dest: "/dest/{{ item | template_glob_path_to_dest }}"yaml
这里需要声明一个 filter 来去掉多余的路径:
def template_glob_path_to_dest(string: str):
target = 'templates/'
pos = string.rfind(target)
if pos == -1:
raise RuntimeError('Could not find template relative path')
return string[pos + len(target):-3]
class FilterModule(object):
def filters(self):
return {
'template_glob_path_to_dest': template_glob_path_to_dest
}python
在 task 中注入参数需要使用 set_fact
,而不是 vars
:
- name: My play
hosts: localhost
tasks:
- name: Ping my hosts
set_fact:
who: world
- name: Print message
debug:
msg: "hello {{ who }}"yaml
对于 vars
声明的参数,仅在当前任务中有效。
一般在多个 role 中,可能会出现通用的逻辑,例如多个 Tomcat 应用,每个应用都需要单独的 Tomcat 目录,如果每个服务都写一遍会导致十分臃肿,所以我们完全可以将通用的 role 抽离出来,供其它的 role 使用。
假设我们已经有了一个安装 Tomcat 的 role:roles/common/tasks/main.yaml
, 详细代码见 安装 tomcat。
假设我们有服务 A 和 B 都需要安装 Tomcat,分别编辑 roles/A/meta/main.yaml
和 roles/B/meta/main.yaml
:
dependencies:
- { role: common, service_root: "{{ Values.metadata.rootPath }}/xxx" }yaml
上面的内容两个应用需要指定不同的 service_root
参数,否则对应的 role 只会执行一遍。
common
具体的代码可以看下面的 安装 tomcat
这个例子会在本地缓存一份 tomcat
包,只要文件名称满足 apache-tomcat-*.tar.gz
就可以被自动获取,并安装到远程服务器。
如果本地不存在任何包时,将会自动从远程服务器中下载。
需要提供下面两个参数:
ansible_cache_directory
: 存放 tomcat 包的位置service_root
: 远程服务器的应用根路径创建文件 roles/common/tasks/main.yaml
:
- name: Check Tomcat Exist
stat:
path: "{{ service_root }}/tomcat"
register: tomcat
- name: Init Tomcat
when: not tomcat.stat.exists
import_tasks: install.yaml
- name: Fail if tomcat occupied
when:
- tomcat.stat.exists
- not tomcat.stat.isdir
fail:
msg: "Tomcat directory '{{ tomcat_directory }}' exist, but it's a file!"yaml
具体的安装逻辑(roles/common/tasks/install.yaml
):
- name: Search local Tomcat
vars:
search_path: "{{ ansible_cache_directory }}/apache-tomcat-*.tar.gz"
set_fact:
tomcat_files: "{{ lookup('ansible.builtin.fileglob', search_path, wantlist = True ) }}"
- name: Download tomcat
delegate_to: localhost
when: tomcat_files.__len__() == 0
block:
- shell:
cmd: "mkdir -p {{ ansible_cache_directory }}"
- vars:
dest: "{{ ansible_cache_directory }}/apache-tomcat-10.1.28.tar.gz"
get_url:
url: 'https://mirrors.huaweicloud.com/apache/tomcat/tomcat-10/v10.1.28/bin/apache-tomcat-10.1.28.tar.gz'
checksum: sha512:b3177fb594e909364abc8074338de24f0441514ee81fa13bcc0b23126a5e3980cc5a6a96aab3b49798ba58d42087bf2c5db7cee3e494cc6653a6c70d872117e5
dest: "{{ dest }}"
- vars:
dest: "{{ ansible_cache_directory }}/apache-tomcat-10.1.28.tar.gz"
set_fact:
tomcat_files: "{{ [dest] }}"
rescue:
- name: Tip how to fix
fail:
msg: 'Failed to download Tomcat. You need to download Tomcat manually and then place it in `{{ ansible_cache_directory }}`. Please ensure that the file name follows the pattern `apache-tomcat-*.tar.gz`.'
- name: Fail if multi package
fail:
msg: 'Multiply Tomcat packages found: {{ tomcat_files }}. Either rename it to not follow the pattern `apache-tomcat-*.tar.gz` or keep only one file there.'
when: tomcat_files.__len__() > 1
- name: Send and unzip file.
unarchive:
src: "{{ tomcat_files[0] }}"
dest: "{{ service_root }}"
- name: Adjust folder name
vars:
zip_name: "{{ tomcat_files[0] | to_file_name }}"
shell:
cmd: >
cd {{ service_root }} &&
rm -f {{ service_root }}/{{ zip_name }} &&
mv {{ zip_name[:-7] }} tomcatyaml
install.yaml
每一步具体的功能如下:
Search local Tomcat
:使用 ansible.builtin.fileglob
模块搜索管理节点的缓存目录中的 tomcat 文件,注意需要提供wantlist = True
参数,否则返回的将会是一个用逗号分割的字符串,而不是数据。
Download tomcat
:首先使用 when
判断上一步中搜素到的 tomcat 文件列表是否为空,如果为空,则从远程下载。这里使用 block
将具体的下载任务组合为一个整体,任意一个步骤发生错误都会触发 rescue
中的代码。同时这里使用了 delegate_to: localhost
来将这个任务交给管理节点处理,而不是远程节点。
2.1. 这是一个脚本,确保远程服务器的目录存在
2.2. 从远程下载 tomcat
2.3. 覆盖 tomcat_files
变量,以便后续运行
Fail if multi package
: 判断 tomcat 文件是否有多个,如果有,发出提示并报错返回。
Send and unzip file
:将 tomcat 发送到远程服务器并解压
Adjust folder name
:删除多余的压缩包并且重命名 tomcat 目录以便于后续升级
这里还用到了一个 filter
:to_file_name
。代码如下:
import os
def to_file_name(path: str) -> str:
return os.path.basename(path)
class FilterModule(object):
def filters(self):
return {
'to_file_name': to_file_name,
}python
在这里自定义一个模块,用于递归创建文件夹,如果文件夹已经存在,返回 Unchanged 状态。
这里实际 ansible 已经提供了响应的模块:
- name: Recrusion create directory ansible.builtin.file: path: /opt/app/work state: directoryyaml
# recursion_mkdir.py
import os.path
from ansible.module_utils.basic import AnsibleModule
def run_module():
module_args = dict(
path=dict(type='list', required=True)
)
result = dict(
changed=False
)
module = AnsibleModule(
argument_spec=module_args,
supports_check_mode=True
)
paths = module.params['path']
if isinstance(paths, str):
paths = [paths]
for path in paths:
if not os.path.isdir(path):
os.makedirs(path, exist_ok=True)
result['changed'] = True
module.exit_json(**result)
def main():
run_module()
if __name__ == '__main__':
main()python
上面的代码中,虽然指定了 path
的类型为 list
,但实际上是可以直接传一个字符串进来的,所以在代码中要做兼容。
之后使用环境变量指定模块目录:
ANSIBLE_LIBRARY=/your/module/directory/bash
使用模块:
- name: Create required directory
recursion_mkdir:
path:
- "/opt/app/home"
- "/opt/app/configuration"yaml
起因是我打算使用 shell 模块来启动 tomcat 服务:
- name: 'Restart Tomcat'
shell:
chdir: "{{ service_root }}/{{ tomcat_directory_name }}/bin"
cmd: sh startup.shyaml
结构执行后,ansible 没保存,tomcat 这里没有运行,也没有日志…
最后查了一下,这里是需要用 nohup 直接在外面启动服务:
- name: 'Restart Tomcat'
shell:
chdir: "{{ service_root }}/{{ tomcat_directory_name }}/bin"
cmd: nohup sh startup.sh 2>&1 > last-boot-log.log &yaml
ansible-runner 可以帮助我们通过 Python 代码来调用 ansible 的 API,当需要从外部传入非常多的参数时可以考虑使用这个库。
安装依赖:
# python latest
python3 -m pip install ansible-runner
# python 3.6
python3 -m pip install ansible-runner==2.2.2shell
运行一个 role:
import ansible_runner
ansible_runner.interface.run(
inventory=inventory_str,
private_data_dir='./',
playbook=play_yaml,
extravars={
'USERNAME': data.username,
'PASSWORD': data.password,
'HOST': data.host
}
)python
所有的参数需要自己点开 run
方法看里面的注释。
详见:Introduction to Ansible Runner
在上面,我们有一个 private_data_dir
,只需要将其指向目录结构的根目录,就可以不输入目录,直接使用文件名称就可以读取到相关的文件了。
目前是有一套 webpack 的 vue2 环境,打生产包需要 2 ~ 3 分钟左右,开发启动需要 1 分钟左右。
迁移后,打生产包仅需 1 分钟,开发启动 10 秒左右。
注意,由于 vite v5 版本需要 node18 或者 node20+,所以一般只能升级到 v4,等稳定后再升 v5:从 v4 迁移
vite v4 版本只需要 node14 就可以了,我自己目前的环境是 node12,可以直接升级,不会有很多坑。
安装下面的依赖:
npm install @vitejs/plugin-vue2 vite vite-plugin-html -Dbash
在项目根目录创建 vite-config
目录,然后依次创建下面的文件:
// config.ts
import { UserConfig } from 'vite'
import vue from '@vitejs/plugin-vue2'
import { createHtmlPlugin } from 'vite-plugin-html'
import { resolve } from 'path'
export const env = {
// 页面 context path
base: process.env.NODE_ENV === 'production' ? '/app' : ''
}
const config: UserConfig = {
plugins: [
vue(),
createHtmlPlugin({
entry: 'src/main.js',
template: 'index.html',
inject: {
data: {
base: env.base
}
}
})
],
publicDir: 'static',
resolve: {
alias: {
'@': resolve(__dirname, '../src'),
'~@': resolve(__dirname, '../src')
},
extensions: ['.mjs', '.js', '.ts', '.vue']
}
}
export default configtypescript
// dev.config.js
import { defineConfig } from 'vite'
import baseConfig, { env } from './config'
const PROXY_TARGET = 'https://abc.com'
export default defineConfig({
...baseConfig,
base: env.base,
define: {
'process.env': {
NODE_ENV: 'development',
BASE_API: '/app-api'
}
},
server: {
port: 1002,
proxy: {
'/app-api': {
target: PROXY_TARGET,
changeOrigin: true,
secure: false,
headers: {
host: new URL(PROXY_TARGET).host,
Referer: `${PROXY_TARGET}/app-api/`,
Origin: PROXY_TARGET
}
}
}
},
})typescript
// prod.config.ts
import { defineConfig } from 'vite'
import baseConfig, { env } from './config'
export default defineConfig({
...baseConfig,
base: env.base,
define: {
'process.env': {
NODE_ENV: 'production',
BASE_API: '/app-api'
}
},
esbuild: {
drop: ['debugger']
},
build: {
outDir: "app",
assetsDir: 'static',
cssCodeSplit: true,
emptyOutDir: true,
}
})typescript
不用多说,既然都来搞 vite 升级,肯定都能一眼看懂。
之后修改 pakcage.json 的启动配置:
{
// snip
"scripts": {
"dev": "vite --config vite-config/dev.config.ts",
"start": "npm run dev",
"build": "vite --config vite-config/prod.config.ts build",
"lint": "eslint --fix --ext .js --ext .vue src/"
},
// snip
}json
主要是修改 vue 创建的方式:
new Vue({
router,
store,
i18n,
render: h => h(App)
}).$mount('#app')javascript
然后修改我们的入口 html 文件:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>App</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="renderer" content="webkit">
<meta http-equiv="X-UA-Compatible" content="ie=edge,chrome=1">
<link rel="stylesheet" type="text/css" href="/css/reset.css">
</head>
<body>
<div id="app"></div>
<script>
// 可以注入属性.
window.base = '<%- base %>'
</script>
</body>
</html>html
至此理论上就可以直接启动了,启动后就可以根据编译的报错一个一个改了,后面就讲一下我碰到的坑。
首先 vite 专门有个静态资源目录,如果你用的是我上面的配置,那么静态资源目录就在项目根目录下的 static
目录中。
假设有这样一个文件 static/img/hello.png
,如果想要使用,直接使用根路径引用即可:
<img src="/img/hello.png">html
而不是:
<img src="/static/img/hello.png">
<img src="<context path>/static/img/hello.png">
<img src="<context path>/img/hello.png">html
上面几种写法均为错误写法!
对于第一种和第二种,不需要加上静态资源目录的名称,对于第三种,不需要加上 <context path>
,在打生产包时,如果你在配置文件中配置了 base
属性,vite 会自动给你加上!
此外下面的写法会让 vite 的自动添加路径失效:
<div style="background-image: url('/img/hello.png')"></div>plaintext
这里直接将文件路径写在了 style 中,如果在生产模式下配置了 base,就会导致生产模式无法读取到图片,对于这种写法必须要以 css
的形式写,不能用内联样式!
这里 webpack 有两种用途:
require.context
动态导入模块或文件对于前者,我建议赶快把 nodejs 模块全都换掉,别想着适配了,一般这种情况经常会出现在一些加密算法的库里,直接找到一个适合前端的库替换就行了。
而对于后者,就比较麻烦了…
我的项目里是这样的一个操作:
const requireAll = requireContext => requireContext.keys().map(requireContext)
const req = require.context('./svg', false, /\.svg$/)
requireAll(req)javascript
上面的代码,会将 svg
目录下的所有文件注册为一个组件,然后放在页面上,然后使用 svg 的 use 来引用:
<svg>
<use :xlink:href="iconName"></use>
</svg>html
这里建议直接改造,将图片直接封装成组件使用:
import { defineAsyncComponent } from 'vue'
const components = {}
{
const files = import.meta.glob('./svg/*.svg', {
query: 'component'
})
for (const filesKey in files) {
// remove prefix './svg/' and suffix '.svg'
const key = filesKey.substring(6, filesKey.length - 4)
components[key] = defineAsyncComponent(files[filesKey])
}
}
const getIcon = (name) => {
const entity = components[name]
if (!entity) {
console.warn('Icon not found: ' + name)
}
return entity
}
export default getIconjavascript
这里很容易理解,就是 getIcon
方法只需要传入文件的名称就会返回一个异步组件,然后在外部直接使用就可以了。
然后另外一个问题就来了,defineAsyncComponent
是 vue3 的 API (如果报错了,请把你的 vue2 升级到 2 的最后一个版本),那么我使用也得使用 vue3 的写法 (至少我是没找到怎么用 vue2 的写法来渲染这个组件的…):
<template>
<div
:class="svgClass"
aria-hidden="true"
>
<Icon
width="100%"
height="100%"
/>
</div>
</template>
<script setup>
import getIcon from './index'
const props = defineProps({
iconClass: {
type: String,
required: true
},
className: {
type: String,
default: undefined
}
})
const Icon = getIcon(props.iconClass)
const svgClass = props.className ? 'svg-icon ' + props.className : 'svg-icon'
</script>html
如果直接用 vue3 setup 写法,eslint 可能会报错,但是仍然能够通过编译并使用,这里也需要一起升级一下 eslint。
安装/更新依赖:
npm i eslint@^8 eslint-plugin-vue@^9 vue-eslint-parser@^9 -Dbash
修改 eslint 配置:
module.exports = {
root: true,
parser: 'vue-eslint-parser',
parserOptions: {
sourceType: 'module'
},
env: {
browser: true,
node: true,
es6: true
},
extends: ['eslint:recommended', 'plugin:vue/recommended'],
rules: {
// snip
}
}javascript
主要是注意 parser 那个配置,别的根据自己的需求修改。
虽然是个很奇葩的需求,但是还是写一下。这里要求以 base64 格式导入 src
(非静态资源目录)下的某个文件,默认情况下 vite 肯定是做不到的,这里要求我们自己定义插件:
// base64Loader.ts
import type { Plugin } from 'rollup'
import * as fs from 'fs'
const base64Loader: Plugin = {
name: 'base64-loader',
transform(_: any, id: string) {
const [path, query] = id.split('?')
if (query !== 'base64') return null
const data = fs.readFileSync(path)
const base64 = data.toString('base64')
return `export default '${base64}';`
}
}
export default base64Loadertypescript
这段代码的作用是,如果在导入一个模块时加上了 ?base
,则会以 base 格式进行导入。
然后在 vite 配置文件中配置:
// vite.config.ts
import base64Loader from './base64Loader'
const config: UserConfig = {
plugins: [
// snip
base64Loader
]
// snip
}typescript
因为自己偶尔才会用用 Python 写写脚本,但是每次想写的时候就要查半天的语法…所以在这里记录一下 Python 基础的快速入门的一些东西。
python -m venv <project_name>/.venv source <project_name>/.venv/bin/activateshell
如果你去官网看的话这里是直接将虚拟环境创建在了项目目录中,也就是直接调用了
python -m venv <project_name>
,这样也可以,
但是会导致项目根目录会多出很多虚拟环境的配置文件,并且这些文件都是不会进版本控制的,所以在这里创建一个子文件夹.venv
来管理会更好!
运行完后命令左边会出现<env_name>
:
(my_env) [root@192.168.0.1 my_env]#shell
在这种状况下,所有 Python 环境均与外界隔离,包括 pip
的版本。
使用下面的指令退出虚拟环境:
deactivateshell
由于 .venv
目录一般不会进入版本控制系统,所以如果想要想生产,则需要所有需要的依赖。
使用下面的指令导出/导入依赖:
# 导出
pip freeze >requirements.txt
# 导入
pip install -r requirements.txtbash
更新 pip
:
python3 -m pip install --upgrade pippython
def main():
print("hello")
if __name__ == "__main__":
main()python
from typing import Mapping
def hello(val: str, map: Mapping[str, str]) -> str:
passpython
from enum import Enum
class Server(Enum):
SERVER_1 = ('192.168.0.1', 'root', '123456')
SERVER_2 = ('192.168.0.2', 'root', '123456')
def __init__(self, host: str, user: str, password: str):
self.host = host
self.user = user
self.password = password
printf(Server.SERVER_1.host)python
from typing import Protocol
from typing import List
# 任何拥有 close 方法的实例都可以被推进去
closeable_list: List[Closeable] = []
class Closeable(Protocol):
def close(self) -> None:
passpython
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('192.168.0.1', username='root', password='123456')
# 这里需要看情况决定是否执行 source /etc/profile,这里不会主动加载环境变量,在外面传 env 参数也没用...
_, stdout, _ = ssh.exec_command('source /etc/profile; env')
print(''.join(stdout.readlines()))python
使用前要给用户生成一个 token。
from jenkinsapi.jenkins import Jenkins
JOB_NAME = 'xxx'
SERVER = 'http://192.168.0.1:8080'
jenkins = Jenkins('http://192.168.0.1:8080', 'user', 'token')
params = {
'Branch': 'master'
}
jenkins.build_job(JOB_NAME, params)
job = jenkins[JOB_NAME]
qi = job.invoke(build_params=params)
if qi.is_queued() or qi.is_running():
print('等待任务构建完成...')
qi.block_until_complete()
build = qi.get_build()
if not build.is_good():
raise RuntimeError(f'Build failed, check {server}/job/{JOB_NAME}/{build.buildno}/pipeline-graph/ for more details.')python
import zipfile
import os
import sys
def zip_dir(directory_path: str, output_path=None):
# Get the base name of the directory to include in the zip
base_name = os.path.basename(directory_path)
# Create a zip file at the specified output path
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
# Walk through the directory and add each file or directory to the zip file
for root, dirs, files in os.walk(directory_path):
# Create an archive name with the top-level directory included
arcname_root = os.path.join(base_name, os.path.relpath(root, start=directory_path))
# Add directory entries
if not files and not dirs:
# Handle the case of empty directories
zipf.write(root, arcname=arcname_root + '/')
for file in files:
# Get the full path of the file
full_path = os.path.join(root, file)
# Create the relative path for the file in the zip
arcname = os.path.join(arcname_root, file)
zipf.write(full_path, arcname)python
def input_bool(text: str, default: bool = False):
tip: str
exp: str
if default:
tip = '(Y/n)'
else:
tip = '(y/N)'
out = input(f'{text} {tip}')
if out == '':
return default
elif out == 'Y' or out == 'y':
return True
elif out == 'N' or out == 'n':
return False
else:
return input_bool(text, default)python
因为踩了很多坑,所以记录一下这个项目是怎么搭的。
最后用了这些东西:
因为是nodejs项目,所以这边我还研究了很久怎么在 ts 文件上打断点进行debug,在开发环境下是不会用到 webpack 的。
nodejs版本:18。
先npm init
创建一个package.json
文件,然后安装所需的依赖:
yarn add eslint @typescript-eslint/parser --dev yarn add typescript ts-loader ts-node tsconfig-paths --dev yarn add webpack webpack-cli source-map-support --devshell
之后在package.json
里添加"type": "module"
的属性,这样就可以直接在项目里直接使用ESModules了,这个东西在webpack里天生支持按需导入,不需要额外配置(要了解更多的话可以去搜Tree Sharking
)。
这步比较简单,就直接过了,基本没有什么坑。
// .eslintrc.cjs
module.exports = {
// 下面这行必须加
parser: '@typescript-eslint/parser',
rules: {
// 这些是我常用的一些规则
quotes: ["error", 'single'],
'key-spacing': ["error", { "beforeColon": false }],
semi: [2, 'never'],
'block-spacing': 'error',
'object-curly-spacing': ["error", "always"],
indent: ['error', 2]
},
// 这里也要加,不然用import会报错
parserOptions: {
"ecmaVersion": 7,
"sourceType": "module"
}
};javascript
这里的配置都是可以直接用的,碰到的坑在后面说。
// tsconfig.json
{
"include": ["src/**/*.ts"],
"compilerOptions": {
"module": "esnext",
"lib": ["ES2022"],
"isolatedModules": true,
"esModuleInterop": true,
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"target": "ESNext",
"strict": true,
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"sourceMap": true,
"outDir": "dist/dev",
"paths": {
// 记住这个别名,待会要考
"~/*": ["./src/*"]
}
},
"ts-node": {
"experimentalSpecifierResolution": "node",
"esm": true,
"transpileOnly": true
}
}json
这里碰到的第一个坑,就是后缀问题。
这个问题只有在 nodejs + esm 才会有,什么意思呢,来看下面的代码:
// ------------------------------------------
// util.ts
export default "hello world"
// ------------------------------------------
// index.ts
import util from './util'
console.log(util)javascript
看上去没有什么问题,然后我们用ts-node执行一下:
throw new ERR_MODULE_NOT_FOUND( ^ CustomError: Cannot find module 'xx\src\util' imported from xx\src\index.tsshell
如果这个时候你去网上搜,你基本碰到的回答都是让你加上js
后缀:
// index.ts
import util from './util.js'
console.log(util)javascript
首先不说这个丑的一批,而且我后面还发现这玩意还会导致另外一个bug:在用webpack打包的时候,如果你加了js
后缀,webpack会直接提醒你找不到xx/src/util.js
,坑爹呢这不是!
所以肯定是不能加后缀的,然后我也是在网上翻了好久,才找到这个参数:experimentalSpecifierResolution
,虽然前面带了个experimental
,但其实已经很稳定了,直接在tsconfig.json
中添加配置:
{
// ...
"ts-node": {
// 把值改为node
"experimentalSpecifierResolution": "node",
// 这个忘了当时为啥要加了,不加好像也不会报错
"esm": true
}
}json
或者使用命令行参数:--experimental-specifier-resolution=node
。
加完之后,不带文件后缀也可以成功运行,webpack打包也不会有任何影响。
可以看到我开头提供的tsconfig.json
里面有个这样的配置:
{
"paths": {
// 记住这个别名,待会要考
"~/*": ["./src/*"]
}
}json
例如我们有这样的目录结构:
src ├── util │ └── StringUtils.ts └── index.tstext
我们在index.ts
里面导入StringUtils
就可以这样写:
import StringUtils from '~/util/StringUtils'typescript
这个功能其实可有可无,但是我就是有强迫症,就是不想用相对路径!
首先啥都不加,直接ts-node运行,居然还报错了:
throw new ERR_MODULE_NOT_FOUND(packageName, fileURLToPath(base)); ^ CustomError: Cannot find package '~' imported from 'xxx/src/index.ts'shell
好好好,这都能报错,去查了一下,才知道这玩意是给webpack
那些玩意提供声明的:
// webpack.config.cjs
module.exports = {
resolve: {
extensions: ['.tsx', '.ts', '.js'],
alias: {
'~': path.resolve(__dirname, 'src')
}
},
}javascript
在这里,你只写webpack的别名配置,在 ts 里是会报错的,因为ts才不会管你webpack的配置,所以才需要我们的tsconfig.json
来提供一个声明。
行,报错了我就去搜,几下就搜到了,不就是加个tsconfig-paths
吗,加上命令行参数:-r tsconfig-paths/register
,开跑!
结果万万没想到,又爆了相同的错。。。。
然后又翻了很久的Github(真的很久),终于被我找到了:ESM loader that handles TypeScript path mapping。
复制里面的loader.js
,然后修改启动命令为:node --loader ./scripts/loader.js src/index.ts
,就可以正常使用别名了。
因为我们是nodejs项目,肯定是不能少了打断点调试的。
我们可以直接用tsc编译项目为js代码后,直接用Webstorm进行Debug。
因为Webstorm运行js文件基本不需要配置,直接右键点几下就跑起来了。
但是这样很傻批,我们还要分两步进行,而且 ts 文件的变动可能会导致我们在 js 文件上打的断点消失。
谢天谢地,Webstorm是真的很聪明(牛逼),我们只需要简单配置几下就可以直接在 ts 上打断点运行了。
配完后,直接在ts文件上断点就可以停住。
至于为什么要用 Webpack,是因为我最后不想带着node_modules
这个累赘来上生产,最后直接打包成一个文件多爽,直接node xxx.js
就跑起来了。
这里是我最后用到的配置:
// webpack.config.cjs
const path = require('path');
const webpack = require('webpack')
module.exports = function (env, args) {
return {
entry: './src/index.js',
target: 'node',
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
alias: {
'~': path.resolve(__dirname, 'src')
}
},
output: {
filename: (pathData) => {
return pathData.chunk.name === 'main' ? 'main.cjs' : 'libs.cjs';
},
},
plugins: [
new webpack.SourceMapDevToolPlugin({
exclude: ['libs.cjs']
})
],
optimization: {
splitChunks: {
chunks: 'all'
},
},
};
}javascript
注意文件后缀是cjs,不然用module.exports
会报错。
打包时直接用webpack --mode=production
就可以了。
在Webpack打包后,我们的所有代码都被压缩到一行了,而且变量名都变得六亲不认了,想象一下,假如运行过程中报一个错,你能定位到问题发生在哪吗。。。
所以这个时候我们需要用到 sourcemap 来对我们的代码进行索引。
直接使用 sourcemap 文件是不行的,因为这玩意是给浏览器用到,我们需要导入依赖 source-map-support 来加载sourcemap。
这里你可以把 sourcemap 分离成一个单独的文件,也可以让它内嵌到代码里面。
这里我推荐内嵌到代码里面,便于后面代码分发,没必要分出来。
在 Webpack 添加配置devtool: 'inline-source-map'
。
还没完,也要在tsconfig.json
里面添加"sourceMap": true
的配置,如果少了这一步,最终生成的 sourcemap 行数会对不上,因为这个时候 Webpack 只会对编译后的 js 文件来构建索引,而 ts 编译后的文件中,空行(一行什么内容都没有的)会被删除,因此导致行数对不上。
最后在代码入口添加加载的代码:
import sourceMapSupport from 'source-map-support'
sourceMapSupport.install()typescript
如果你观察生成的文件,会发现生成 sourcemap 会导致文件变得非常大,基本会变大 5 ~ 6 倍左右。
如果你把 sourcemap 分成单独的文件,然后打开开一下,会发现 Webpack 也给 node_modules
里面的代码生成了 sourcemap!
作为一个强迫症患者,我是绝对不能忍受这种情况的!
我们肯定是想给自己的代码生成精准的 sourcemap,而第三方库,可以考虑不生成,或者只使用简单的 sourcemap。
翻了一下 Webpack 文档,发现有个 SourceMapDevToolPlugin 插件可以指定/排除为哪些模块生成 sourcemap。
试了一下,在exclude
属性里面不管怎么填,都无法忽略掉node_modules
。
在查了一下午的文档以及翻看了源码之后,终于知到怎么配了:
// webpack.config.cjs
module.exports = {
output: {
filename: (pathData) => {
return pathData.chunk.name === 'main' ? 'main.cjs' : 'libs.cjs';
},
},
plugins: [
new webpack.SourceMapDevToolPlugin({
exclude: ['libs.cjs']
})
],
optimization: {
splitChunks: {
chunks: 'all'
},
},
}javascript
加上上面的配置,就可以做到把node_modules
里面的代码全部打到libs.cjs
中,而我们的业务代码全部打到main.cjs
中,同时配置我们的SourceMapDevToolPlugin
不为libs.cjs
生成 sourcemap。
之前导入模块我们为了省略后缀,在配置中添加了experimentalSpecifierResolution: node
参数,在node20上,这个参数仍然可用,但是已经有了更好的替代。
文档:Loaders
并且官方也给了一个样例来代替上面的启动参数:commonjs-extension-resolution-loader。
这里直接摆上我用的代码:
// extension-loader.js
/**
* 处理ts-node导入时必须加后缀
*/
// https://github.com/nodejs/loaders-test/blob/main/commonjs-extension-resolution-loader/loader.js
import { isBuiltin } from 'node:module'
import { dirname } from 'node:path'
import { cwd } from 'node:process'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { promisify } from 'node:util'
import resolveCallback from 'resolve/async.js'
const resolveAsync = promisify(resolveCallback)
const baseURL = pathToFileURL(cwd() + '/').href
export async function resolve(specifier, context, next) {
const { parentURL = baseURL } = context
if (isBuiltin(specifier)) {
return next(specifier, context)
}
// `resolveAsync` works with paths, not URLs
if (specifier.startsWith('file://')) {
specifier = fileURLToPath(specifier)
}
const parentPath = fileURLToPath(parentURL)
let url
try {
const resolution = await resolveAsync(specifier, {
basedir: dirname(parentPath),
// For whatever reason, --experimental-specifier-resolution=node doesn't search for .mjs extensions
// but it does search for index.mjs files within directories
extensions: ['.js', '.json', '.node', '.mjs', '.ts'],
})
url = pathToFileURL(resolution).href
} catch (error) {
if (error.code === 'MODULE_NOT_FOUND') {
// Match Node's error code
error.code = 'ERR_MODULE_NOT_FOUND'
}
throw error
}
return next(url, context)
}javascript
// path-loader.js
/**
* 处理ts路径别名报错
*/
import { resolve as resolveTs } from 'ts-node/esm'
import * as tsConfigPaths from 'tsconfig-paths'
import { pathToFileURL } from 'url'
const { absoluteBaseUrl, paths } = tsConfigPaths.loadConfig()
const matchPath = tsConfigPaths.createMatchPath(absoluteBaseUrl, paths)
export async function resolve (specifier, ctx, defaultResolve) {
const match = matchPath(specifier)
let realPath
if (match) {
realPath = pathToFileURL(`${match}`).href
} else {
realPath = specifier
}
const r = await defaultResolve(realPath, ctx)
return resolveTs(r.url, ctx, defaultResolve)
}
export { load, transformSource } from 'ts-node/esm'javascript
// register-hooks.js
import { register } from 'node:module'
register('./extension-loader.js', import.meta.url)
register('./path-loader.js', import.meta.url)javascript
然后把我们的启动命令换成:node --import register-hooks.js src/index.ts
。
移除掉tsconfig.json
里的experimentalSpecifierResolution
,然后就可以正常启动了。