2024年8月

前言

在《
[apue] 进程控制那些事儿
》一文的"进程创建-> exec -> 解释器文件"一节中,曾提到脚本文件的识别是由内核作为 exec 系统调用处理的一部分来完成的,并且有以下特性:

  • 指定解释器的以 #!  (shebang) 开头的第一行长度不得超过 128
  • shebang 最多只能指定一个参数
  • shebang 指定的命令与参数会成为新进程的前 2 个参数,用户提供的其它参数依次往后排

这些特性是如何实现的?带着这个疑问,找出系统对应的内核源码看个究竟。

源码定位

和《
[kernel] 带着问题看源码 —— 进程 ID 是如何分配的
》一样,这里使用
bootlin
查看内核 3.10.0 版本源码,脚本文件是在 execve 时解析的,所以先搜索 sys_ execve:

整个调用链如下:

sys_execve -> do_execve -> do_execve_common -> search_binary_handler-> load_binary -> load_script (binfmt_script.c)

为了快速进入主题,前面咱们就不一一细看了,主要解释一下 search_binary_handler。

Linux 中加载不同文件格式的方式是可扩展的,这主要是通过内核模块来实现的,每个模块实现一个格式,新的格式可通过编写内核模块实现快速支持而无需修改内核源码。刚才浏览代码的时候已经初窥门径:

这是目前内核内置的几个模块

  • binfmt_elf:最常用的 Linux 二进制可执行文件
  • binfmt_elf_fdpic:缺失 MMU 架构的二进制可执行文件
  • binfmt_em86:在 Aplha 机器上运行 Intel 的 Linux 二进制文件
  • binfmt_aout:Linux 老的可执行文件
  • binfmt_script:脚本文件
  • binfmt_misc:一种新机制,支持运行期文件格式与应用对应关系的绑定

基本可以归纳为三大类:

  • 可执行文件
  • 脚本文件 (script)
  • 机制拓展 (misc)

其中 misc 机制类似 Windows 上文件通过后缀与应用进行绑定的机制,它除了通过后缀,还可以通过检测文件中的 Magic 字段,作为判断文件类型的依据。目前的主要应用方向是跨架构运行,例如在 x86 机器上运行 arm64、甚至 Windows 程序 (wine),相比编写内核模块,便利性又降低了一个等级。

本文主要关注脚本文件的处理过程。

binfmt

内核模块本身并不难实现,以 script 为例:

static struct linux_binfmt script_format = {
	.module		= THIS_MODULE,
	.load_binary	= load_script,
};

static int __init init_script_binfmt(void)
{
	register_binfmt(&script_format);
	return 0;
}

static void __exit exit_script_binfmt(void)
{
	unregister_binfmt(&script_format);
}

core_initcall(init_script_binfmt);
module_exit(exit_script_binfmt);

主要是通过 register_binfmt / unregister_binfmt 来插入、删除 linux_binfmt 信息节点。

/*
 * This structure defines the functions that are used to load the binary formats that
 * linux accepts.
 */
struct linux_binfmt {
	struct list_head lh;
	struct module *module;
	int (*load_binary)(struct linux_binprm *);
	int (*load_shlib)(struct file *);
	int (*core_dump)(struct coredump_params *cprm);
	unsigned long min_coredump;	/* minimal dump size */
};

linux_binfmt 的内容不多,而且回调函数不用全部实现,没有用到的留空就完事了。下面看下插入节点过程:

static LIST_HEAD(formats);
static DEFINE_RWLOCK(binfmt_lock);

void __register_binfmt(struct linux_binfmt * fmt, int insert)
{
	BUG_ON(!fmt);
	write_lock(&binfmt_lock);
	insert ? list_add(&fmt->lh, &formats) :
		 list_add_tail(&fmt->lh, &formats);
	write_unlock(&binfmt_lock);
}

/* Registration of default binfmt handlers */
static inline void register_binfmt(struct linux_binfmt *fmt)
{
	__register_binfmt(fmt, 0);
}

利用 linux_binfmt.lh 字段 (list_head) 实现链表插入,链表头为全局变量 formats。

search_binary_handler

再看 search_binary_handler 利用 formats 遍历链表的过程:

retval = -ENOENT;
for (try=0; try<2; try++) {

最多尝试 2 次

    read_lock(&binfmt_lock);
	list_for_each_entry(fmt, &formats, lh) {

加锁;通过 formats 遍历整个链表

        int (*fn)(struct linux_binprm *) = fmt->load_binary;
		if (!fn)
			continue;
		if (!try_module_get(fmt->module))
			continue;
		read_unlock(&binfmt_lock);
		bprm->recursion_depth = depth + 1;

检查内核模块是否 alive;执行 load_binary 前解锁 formats 链表以便嵌套;更新嵌套深度

        retval = fn(bprm);
		bprm->recursion_depth = depth;
		if (retval >= 0) {
			if (depth == 0) {
				trace_sched_process_exec(current, old_pid, bprm);
				ptrace_event(PTRACE_EVENT_EXEC, old_vpid);
			}
			put_binfmt(fmt);
			allow_write_access(bprm->file);
			if (bprm->file)
				fput(bprm->file);
			bprm->file = NULL;
			current->did_exec = 1;
			proc_exec_connector(current);
			return retval;
		}

恢复嵌套尝试;执行成功,提前退出

        read_lock(&binfmt_lock);
		put_binfmt(fmt);
		if (retval != -ENOEXEC || bprm->mm == NULL)
			break;
		if (!bprm->file) {
			read_unlock(&binfmt_lock);
			return retval;
		}
	}

执行失败,重新加锁;如果非 ENOEXEC 错误,继续尝试下个 fmt

    read_unlock(&binfmt_lock);
	break;
}

遍历完毕,退出

其中 list_for_each_entry 就是 Linux 对 list 遍历的封装宏:

/**
 * list_for_each_entry	-	iterate over list of given type
 * @pos:	the type * to use as a loop cursor.
 * @head:	the head for your list.
 * @member:	the name of the list_struct within the struct.
 */
#define list_for_each_entry(pos, head, member)				\
	for (pos = list_entry((head)->next, typeof(*pos), member);	\
	     &pos->member != (head); 	\
	     pos = list_entry(pos->member.next, typeof(*pos), member))

本质是个 for 循环。另外,之前的 for (try < 2) 其实并不生效,因为总会被末尾的 break 打断。

不过这里提示了一点 load_binary 的写法:当该接口返回 -ENOEXEC 时,表示这个文件“不合胃口”,请继续遍历 formats 列表尝试,下面在解读 load_script 时可以多加留意。

另外 binfmt 是可以嵌套的,假设在调用一个脚本,它使用 awk 作为解释器,那么整个执行过程看起来像下面这样:

execve (xxx.awk) -> load_script (binfmt_script) -> load_elf_binary (binfmt_elf)

这是因为 awk 作为可执行文件,本身也需要 binfmt 的处理,稍等就可以在 load_script 中看到这一点。

目前 Linux 没有对嵌套深度施加限制。

源码分析

经过一番背景知识铺垫,终于可以进入 binfmt_script 好好看看啦:

static int load_script(struct linux_binprm *bprm)
{
	const char *i_arg, *i_name;
	char *cp;
	struct file *file;
	char interp[BINPRM_BUF_SIZE];
	int retval;

	if ((bprm->buf[0] != '#') || (bprm->buf[1] != '!'))
		return -ENOEXEC;

脚本不以 #! 开头的,忽略;注意 interp 数组的长度:#define BINPRM_BUF_SIZE 128,这是 shebang 不能超过 128 的来源

    /*
	 * This section does the #! interpretation.
	 * Sorta complicated, but hopefully it will work.  -TYT
	 */

	allow_write_access(bprm->file);
	fput(bprm->file);
	bprm->file = NULL;

系统已读取文件头部的一部分字节到内存,脚本文件用完了,释放

    bprm->buf[BINPRM_BUF_SIZE - 1] = '\0';
	if ((cp = strchr(bprm->buf, '\n')) == NULL)
		cp = bprm->buf+BINPRM_BUF_SIZE-1;
	*cp = '\0';

最多截取前 127 个字符,并向前搜索 shebang 结尾 (\n),若有,则设置新的结尾到那里

    while (cp > bprm->buf) {
		cp--;
		if ((*cp == ' ') || (*cp == '\t'))
			*cp = '\0';
		else
			break;
	}
	for (cp = bprm->buf+2; (*cp == ' ') || (*cp == '\t'); cp++);
	if (*cp == '\0') 
		return -ENOEXEC; /* No interpreter name found */

前后 trim 空白字符,如果没有任何内容,忽略;注意初始时 cp 指向字符串尾部,结束时,cp 指向有效信息头部

    i_name = cp;
	i_arg = NULL;
	for ( ; *cp && (*cp != ' ') && (*cp != '\t'); cp++)
		/* nothing */ ;
	while ((*cp == ' ') || (*cp == '\t'))
		*cp++ = '\0';
	if (*cp)
		i_arg = cp;
	strcpy (interp, i_name);

跳过命令名;忽略空白字符;剩下的若有内容全部作为一个参数;命令名复制到 interp 数组中备用

    /*
	 * OK, we've parsed out the interpreter name and
	 * (optional) argument.
	 * Splice in (1) the interpreter's name for argv[0]
	 *           (2) (optional) argument to interpreter
	 *           (3) filename of shell script (replace argv[0])
	 *
	 * This is done in reverse order, because of how the
	 * user environment and arguments are stored.
	 */
	retval = remove_arg_zero(bprm);
	if (retval)
		return retval;
	retval = copy_strings_kernel(1, &bprm->interp, bprm);
	if (retval < 0) return retval; 
	bprm->argc++;
	if (i_arg) {
		retval = copy_strings_kernel(1, &i_arg, bprm);
		if (retval < 0) return retval; 
		bprm->argc++;
	}
	retval = copy_strings_kernel(1, &i_name, bprm);
	if (retval) return retval; 
	bprm->argc++;
	retval = bprm_change_interp(interp, bprm);
	if (retval < 0)
		return retval;

删除 argv 的第一个参数,分别将命令名 (i_name)、参数 (i_arg 如果有的话)、脚本文件名 (bprm->interp) 放置到 argv 前三位。

注意这里调用的顺序恰好相反:bprm->interp、i_arg、i_name,这是由于 argv 在进程中特殊的存放方式导致,参考后面的解说;
最后更新 bprm 中的命令名

    /*
	 * OK, now restart the process with the interpreter's dentry.
	 */
	file = open_exec(interp);
	if (IS_ERR(file))
		return PTR_ERR(file);

	bprm->file = file;
	retval = prepare_binprm(bprm);
	if (retval < 0)
		return retval;

通过命令名指定的路径打开文件,并设置到当前进程,准备加载前的各种信息,包括预读文件的头部的一些内容

    return search_binary_handler(bprm);
}

使用新命令的信息继续搜索 binfmt 模块并加载之

这里主要补充一点,对于 shebang 中的命令名字段,中间不能包含空格,否则会被提前截断,即使使用引号包围也不行 (解析代码根本未对引号做处理),下面是个例子:

> pwd
/ext/code/apue/07.chapter/test black
> ls -lh
total 52K
-rwxr-xr-x 1 yunhai01 DOORGOD 48K Aug 23 19:17 echo
-rwxr--r-- 1 yunhai01 DOORGOD  47 Aug 23 19:17 echo.sh
> cat echo.sh
#! /ext/code/apue/07.chapter/test black/demo

> ./echo a b c
argv[0] = ./echo
argv[1] = a
argv[2] = b
argv[3] = c
> ./echo.sh a b c
bash: ./echo.sh: /ext/code/apue/07.chapter/test: bad interpreter: No such file or directory

文件头预读

这里主要解释两点,一是 prepare_binprm 会预读文件头部的一些数据,供后面 binfmt 判断使用:

/* 
 * Fill the binprm structure from the inode. 
 * Check permissions, then read the first 128 (BINPRM_BUF_SIZE) bytes
 *
 * This may be called multiple times for binary chains (scripts for example).
 */
int prepare_binprm(struct linux_binprm *bprm)
{
	umode_t mode;
	struct inode * inode = file_inode(bprm->file);
	int retval;

	mode = inode->i_mode;
	if (bprm->file->f_op == NULL)
		return -EACCES;

    ...

	/* fill in binprm security blob */
	retval = security_bprm_set_creds(bprm);
	if (retval)
		return retval;
	bprm->cred_prepared = 1;

	memset(bprm->buf, 0, BINPRM_BUF_SIZE);
	return kernel_read(bprm->file, 0, bprm->buf, BINPRM_BUF_SIZE);
}

目前这个 BINPRM_BUF_SIZE 的长度也是 128:

#define BINPRM_BUF_SIZE 128

在 do_execve_common 中也会调用这个接口来为第一次 binfmt 识别做准备:

/*
 * sys_execve() executes a new program.
 */
static int do_execve_common(const char *filename,
				struct user_arg_ptr argv,
				struct user_arg_ptr envp)
{
	struct linux_binprm *bprm;
	struct file *file;
	struct files_struct *displaced;
	bool clear_in_exec;
	int retval;
	const struct cred *cred = current_cred();

    ...

	file = open_exec(filename);
	retval = PTR_ERR(file);
	if (IS_ERR(file))
		goto out_unmark;

	sched_exec();

	bprm->file = file;
	bprm->filename = filename;
	bprm->interp = filename;

	retval = bprm_mm_init(bprm);
	if (retval)
		goto out_file;

	bprm->argc = count(argv, MAX_ARG_STRINGS);
	if ((retval = bprm->argc) < 0)
		goto out;

	bprm->envc = count(envp, MAX_ARG_STRINGS);
	if ((retval = bprm->envc) < 0)
		goto out;

	retval = prepare_binprm(bprm);
	if (retval < 0)
		goto out;

没错,就是这里了

    retval = copy_strings_kernel(1, &bprm->filename, bprm);
	if (retval < 0)
		goto out;

	bprm->exec = bprm->p;
	retval = copy_strings(bprm->envc, envp, bprm);
	if (retval < 0)
		goto out;

	retval = copy_strings(bprm->argc, argv, bprm);
	if (retval < 0)
		goto out;

	retval = search_binary_handler(bprm);
	if (retval < 0)
		goto out;

    ...
}

argv 调整

另外一点是 argv 在内存中的布局,参考之前写的《
[apue] 进程环境那些事儿
》,这里直接贴图:

命令行参数与环境变量是放在进程高地址空间的末尾,以 \0 为间隔的字符串。由于有高地址“天花板”在存在,这里必需先根据字符串长度定位到起始位置,再复制整个字符串,此外为了保证 argv[0] 地址小于 argv[1],整个数组也需要从后向前遍历。这里借用之前写的一个例子证明这一点:

#include <stdio.h>
#include <stdlib.h> 

int data1 = 2;
int data2 = 3;
int data3;
int data4;

int main (int argc, char *argv[])
{
  char buf1[1024] = { 0 };
  char buf2[1024] = { 0 };
  char *buf3 = malloc(1024);
  char *buf4 = malloc(1024);
  printf ("onstack %p, %p\n",
    buf1,
    buf2);

  extern char ** environ;
  printf ("env %p\n", environ);
  for (int i=0; environ[i] != 0; ++ i)
    printf ("env[%d] %p\n", i, environ[i]);

  printf ("arg %p\n", argv);
  for (int i=0; i < argc; ++ i)
    printf ("arg[%d] %p\n", i, argv[i]);

  printf ("onheap %p, %p\n",
    buf3,
    buf4);

  free (buf3);
  free (buf4);

  printf ("on bss %p, %p\n",
    &data3,
    &data4);

  printf ("on init %p, %p\n",
    &data1,
    &data2);

  printf ("on code %p\n", main);
  return 0;
}

随便给一些参数让它跑个输出:

> ./layout a b c d
onstack 0x7fff2757a970, 0x7fff2757a570
env 0x7fff2757aea8
env[0] 0x7fff2757b4fb
env[1] 0x7fff2757b511
env[2] 0x7fff2757b534
env[3] 0x7fff2757b544
env[4] 0x7fff2757b558
env[5] 0x7fff2757b566
env[6] 0x7fff2757b587
env[7] 0x7fff2757b5af
env[8] 0x7fff2757b5c7
env[9] 0x7fff2757b5e7
env[10] 0x7fff2757b5fa
env[11] 0x7fff2757b608
env[12] 0x7fff2757bcc0
env[13] 0x7fff2757bcc8
env[14] 0x7fff2757be1d
env[15] 0x7fff2757be3b
env[16] 0x7fff2757be59
env[17] 0x7fff2757be6a
env[18] 0x7fff2757be81
env[19] 0x7fff2757be9b
env[20] 0x7fff2757bea3
env[21] 0x7fff2757beb3
env[22] 0x7fff2757bec4
env[23] 0x7fff2757bee0
env[24] 0x7fff2757bf13
env[25] 0x7fff2757bf36
env[26] 0x7fff2757bf62
env[27] 0x7fff2757bf83
env[28] 0x7fff2757bfa1
env[29] 0x7fff2757bfc3
env[30] 0x7fff2757bfce
arg 0x7fff2757ae78
arg[0] 0x7fff2757b4ea
arg[1] 0x7fff2757b4f3
arg[2] 0x7fff2757b4f5
arg[3] 0x7fff2757b4f7
arg[4] 0x7fff2757b4f9
onheap 0x1056010, 0x1056420
on bss 0x6066b8, 0x6066bc
on init 0x606224, 0x606228
on code 0x40179d

重点看下 argv 与 envp 的地址,envp 高于 argv;再看各个数组内部的情况,索引低的地址也低。结合之前的内存布局图,就需要这样排布各个参数:

  • 先排布 envp,envp 内部从后向前遍历
  • 后排布 argv,argv 内部从后向前遍历

代码也确实是这样写的:

    retval = copy_strings(bprm->envc, envp, bprm);
	if (retval < 0)
		goto out;

	retval = copy_strings(bprm->argc, argv, bprm);
	if (retval < 0)
		goto out;

上面这段之前在 do_execve_common 中展示过,先排布 envp 后排布 argv,再看数组内部的处理:

/*
 * 'copy_strings()' copies argument/environment strings from the old
 * processes's memory to the new process's stack.  The call to get_user_pages()
 * ensures the destination page is created and not swapped out.
 */
static int copy_strings(int argc, struct user_arg_ptr argv,
			struct linux_binprm *bprm)
{
	struct page *kmapped_page = NULL;
	char *kaddr = NULL;
	unsigned long kpos = 0;
	int ret;

	while (argc-- > 0) {

倒序遍历数组

        const char __user *str;
		int len;
		unsigned long pos;
        ret = -EFAULT;

		str = get_user_arg_ptr(argv, argc);
		if (IS_ERR(str))
			goto out;

		len = strnlen_user(str, MAX_ARG_STRLEN);
		if (!len)
			goto out;

		ret = -E2BIG;
		if (!valid_arg_len(bprm, len))
			goto out;

		/* We're going to work our way backwords. */
		pos = bprm->p;
		str += len;
		bprm->p -= len;

计算当前字符串长度并预留位置,注意复制时可能存在跨页情况,字符串也是从尾向头分割为一块块复制的

        while (len > 0) {
			int offset, bytes_to_copy;

			if (fatal_signal_pending(current)) {
				ret = -ERESTARTNOHAND;
				goto out;
			}
			cond_resched();

			offset = pos % PAGE_SIZE;
			if (offset == 0)
				offset = PAGE_SIZE;

			bytes_to_copy = offset;
			if (bytes_to_copy > len)
				bytes_to_copy = len;

			offset -= bytes_to_copy;
			pos -= bytes_to_copy;
			str -= bytes_to_copy;
			len -= bytes_to_copy;

			if (!kmapped_page || kpos != (pos & PAGE_MASK)) {
				struct page *page;

				page = get_arg_page(bprm, pos, 1);
				if (!page) {
					ret = -E2BIG;
					goto out;
				}

				if (kmapped_page) {
					flush_kernel_dcache_page(kmapped_page);
					kunmap(kmapped_page);
					put_arg_page(kmapped_page);
				}
				kmapped_page = page;
				kaddr = kmap(kmapped_page);
				kpos = pos & PAGE_MASK;
				flush_arg_page(bprm, kpos, kmapped_page);
			}
			if (copy_from_user(kaddr+offset, str, bytes_to_copy)) {
				ret = -EFAULT;
				goto out;
			}
		}
	}
	ret = 0;

复制单个字符串,字符串可能非常大,一个就好几页,干活的主要是 copy_from_user

out:
	if (kmapped_page) {
		flush_kernel_dcache_page(kmapped_page);
		kunmap(kmapped_page);
		put_arg_page(kmapped_page);
	}
	return ret;
}

出错处理

了解了 argv 与 envp 的布局后,突然发现在数组前插入元素反而简单了,不过需要先将第一个元素删除,这里 Linux 使用了一个 trick:直接移动 argv 指针 (bprm->p) 略过第一个参数:

/*
 * Arguments are '\0' separated strings found at the location bprm->p
 * points to; chop off the first by relocating brpm->p to right after
 * the first '\0' encountered.
 */
int remove_arg_zero(struct linux_binprm *bprm)
{
	int ret = 0;
	unsigned long offset;
	char *kaddr;
	struct page *page;

	if (!bprm->argc)
		return 0;

	do {
		offset = bprm->p & ~PAGE_MASK;
		page = get_arg_page(bprm, bprm->p, 0);
		if (!page) {
			ret = -EFAULT;
			goto out;
		}
		kaddr = kmap_atomic(page);

		for (; offset < PAGE_SIZE && kaddr[offset];
				offset++, bprm->p++)
			;

		kunmap_atomic(kaddr);
		put_arg_page(page);

		if (offset == PAGE_SIZE)
			free_arg_page(bprm, (bprm->p >> PAGE_SHIFT) - 1);
	} while (offset == PAGE_SIZE);

	bprm->p++;
	bprm->argc--;
	ret = 0;

out:
	return ret;
}

经过更新后,bprm->p 指向了第二个参数,argc 减少了 1,后面新参数插入时,会自动覆盖它:

    retval = copy_strings_kernel(1, &bprm->interp, bprm);
	if (retval < 0) return retval; 
	bprm->argc++;
	if (i_arg) {
		retval = copy_strings_kernel(1, &i_arg, bprm);
		if (retval < 0) return retval; 
		bprm->argc++;
	}
	retval = copy_strings_kernel(1, &i_name, bprm);
	if (retval) return retval; 
	bprm->argc++;

copy_string_kernel 将基于 kernel 获取的源字符串调用 copy_strings,因此一切又回到了前面的逻辑,这里只要保持参数倒序处理即可,这段代码之前在 load_script 时展示过,这回大家看明白了吗?

总结

开头提出的三个问题:

  • 指定解释器的以 shebang 开头的第一行长度不得超过 128
  • shebang 最多只能指定一个参数
  • shebang 指定的命令与参数会成为新进程的前 2 个参数,用户提供参数依次后排

都一一得到了解答,其中 shebang 长度 128 这个限制,和整个 execve 预读长度 ()BINPRM_BUF_SIZE) 息息相关,也与 binfmt_misc 规定的格式相关,看起来不好随便突破。

另外通过通读源码,得到了以下额外的知识:

  • 解释器文件可嵌套,且没有深度限制
  • 解释器第一行中的命令名不能包含空白字符
  • 命令行参数在内存空间是倒排的,这一点貌似主要是为了在数组头部插入元素更便利,如果有需求要在数组尾部插入元素,可能就得改为正排了

最后对于 shebang 支持多个 arguments 这一点,目前看只要修改 binfmt_scrpts,应该是可以实现的,这个课题就留给感兴趣的读者作为作业吧,哈哈~

参考

[1].
linux下使用binfmt_misc设定不同二进制的打开程序

[2].
Linux中的binfmt-misc原理分析

[3].
binfmt.d 中文手册

[4].
Linux 的 binfmt_misc (binfmt) module 介紹

[5].
Linux系统的可执行文件格式详细解析

[6].
Kernel Support for miscellaneous Binary Formats (binfmt_misc)

PyJWT

python-jose
是两个用于处理 JSON Web Tokens (JWT) 的 Python 库。它们都有助于生成、解码、验证和管理 JWT,但它们在功能范围和设计哲学上有一些重要的区别。本篇介绍它们之间的一些差异,以及在项目中使用FastAPI+
python-jose
来处理访问令牌的生成以及一些例子代码供参考。

1、
PyJWT

python-jose的差异

PyJWT

PyJWT
是一个专门处理 JWT 的 Python 库,它旨在简化 JWT 的创建和验证。

特点:

  • 专注于 JWT
    : PyJWT 专门用于 JWT 的处理,不提供其他类型的加密或签名功能。
  • 简单易用
    : PyJWT 提供了简单的 API,用于创建和验证 JWT。
  • 支持常见的签名算法
    : 包括 HMAC (HS256, HS384, HS512) 和 RSA (RS256, RS384, RS512)。
  • 轻量级
    : 由于 PyJWT 专注于 JWT,依赖少,安装和使用都很简便。

主要用法示例:

importjwtimportdatetime#创建一个JWT
payload ={"user_id": 123,"exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1)
}
secret
= 'your-secret-key'token= jwt.encode(payload, secret, algorithm='HS256')#解码JWT decoded = jwt.decode(token, secret, algorithms=['HS256'])print(decoded)

python-jose

python-jose
是一个更广泛的加密库,它不仅支持 JWT,还支持多种 JOSE (JSON Object Signing and Encryption) 标准,包括 JWS (JSON Web Signature)、JWE (JSON Web Encryption)、JWK (JSON Web Key)、JWA (JSON Web Algorithms) 等。

特点:

  • 全面的 JOSE 支持
    : 除了 JWT,
    python-jose
    还支持其他 JOSE 标准,因此功能更强大、更灵活。
  • 多种加密与签名算法
    : 支持比 PyJWT 更多的算法,如直接加密 (dir)、对称密钥加密 (A256KW, A192KW, A128KW),以及 RSA 和 ECDSA 的多种模式。
  • 丰富的功能
    : 提供更细粒度的控制,适合需要复杂加密和签名操作的应用场景。
  • 相对复杂
    : 由于功能更广泛,
    python-jose
    的使用复杂度比 PyJWT 更高。

主要用法示例:

from jose importjwtfrom jose.exceptions importJWTError#创建一个JWT
payload ={"user_id": 123,"exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1)
}
secret
= 'your-secret-key'token= jwt.encode(payload, secret, algorithm='HS256')#解码JWT try:
decoded
= jwt.decode(token, secret, algorithms=['HS256'])print(decoded)exceptJWTError as e:print(f"Token is invalid: {e}")

差异总结

  • 功能范围
    :
    PyJWT
    专注于 JWT,适合需要简单 JWT 处理的项目;
    python-jose
    则支持整个 JOSE 标准,适合需要更复杂加密和签名操作的项目。
  • 易用性
    :
    PyJWT
    API 简单,易于上手;
    python-jose
    更强大,但同时也更复杂。
  • 算法支持
    :
    python-jose
    支持的算法更广泛,尤其是在需要高级加密或签名场景时更具优势。
  • 使用场景
    : 如果你的项目只需要生成和验证 JWT,
    PyJWT
    是一个不错的选择;如果你需要全面的 JOSE 支持,包括 JWS、JWE 等,或者需要复杂的加密和签名,
    python-jose
    是更好的选择。

2、使用
python-jose
处理 JWT

在使用
python-jose
处理 JWT 时,捕获和处理异常是一个重要的环节。

1)
安装
python-jose

首先,确保你已经安装了
python-jose

pip install python-jose

2)
使用
python-jose
的 JWT 模块

以下是一个使用
python-jose
的 JWT 处理的示例,包括如何捕获异常:

from jose importjwt, JWTErrorfrom jose.exceptions importExpiredSignatureError#定义密钥和有效负载
secret = 'your-secret-key'payload={"user_id": 123,"exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1)
}
#生成 JWT token = jwt.encode(payload, secret, algorithm='HS256')#解码 JWT 并处理可能的异常 try:
decoded
= jwt.decode(token, secret, algorithms=['HS256'])print(decoded)exceptExpiredSignatureError:print("Token has expired")exceptJWTError:print("Token is invalid")

在处理
python-jose
的 JWT 时,正确地捕获和处理异常是关键。确保你的环境和工具能够正确识别异常类型,将有助于你更好地管理 JWT 错误。

如果我们需要再JWT的playload里面承载更多的信息,可以再claim中声明键值即可,你可以使用
python-jose

FastAPI
来实现 JWT 令牌生成操作。如下所示代码。

使用
FastAPI

python-jose
生成 JWT 令牌:

from fastapi importFastAPI, Depends, HTTPException, status, Requestfrom fastapi.responses importJSONResponsefrom jose importJWTError, jwtfrom datetime importdatetime, timedelta

app
=FastAPI()#配置项,通常从配置文件中加载 JWT_SECRET_KEY = 'your_jwt_secret_key'JWT_ISSUER= 'your_issuer'JWT_AUDIENCE= 'your_audience'JWT_EXPIRED_DAYS= 7ALGORITHM= 'HS256' defgenerate_token(user_info: dict, role_type: str):#获取IP地址 ip = user_info.get('ip', '')#定义声明 claims ={'id': user_info['id'],'email': user_info['email'],'name': user_info['name'],'nickname': user_info.get('nickname', ''),'phone_number': user_info.get('mobile_phone', ''),'gender': user_info.get('gender', ''),'full_name': user_info.get('full_name', ''),'company_id': user_info.get('company_id', ''),'company_name': user_info.get('company_name', ''),'dept_id': user_info.get('dept_id', ''),'dept_name': user_info.get('dept_name', ''),'role_type': role_type,'ip': ip,'mac_addr': '', #无法获得Mac地址 'channel': ''}#定义token过期时间 expiration = datetime.utcnow() + timedelta(days=JWT_EXPIRED_DAYS)#创建JWT token to_encode ={**claims,'iss': JWT_ISSUER,'aud': JWT_AUDIENCE,'exp': expiration
}

token
= jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=ALGORITHM)return token
@app.post('/token')
async
defget_token(request: Request):#模拟的用户信息 user_info ={'id': 123,'email': 'user@example.com','name': 'John Doe','nickname': 'Johnny','mobile_phone': '123-456-7890','gender': 'Male','full_name': 'Johnathan Doe','company_id': 'ABC123','company_name': 'ABC Corp','dept_id': 'Dept001','dept_name': 'IT','ip': request.client.host
}
role_type
= 'Admin'token=generate_token(user_info, role_type)

headers
={'access-token': token,'Authorization': f'Bearer {token}'}return JSONResponse(content={'token': token}, headers=headers)if __name__ == "__main__":importuvicorn
uvicorn.run(app, host
="127.0.0.1", port=8000)

解释

  1. FastAPI
    : 用于构建快速、现代的 Web API。
  2. Request
    : 从
    FastAPI
    中导入,用于获取客户端的 IP 地址。
  3. JWT 配置
    : 使用常量配置 JWT 密钥、发行者、受众、加密算法和过期时间。
  4. generate_token
    函数

    :
    • 构建 JWT 的
      claims
      ,包含用户信息和额外的字段,如 IP 地址、角色类型等。
    • 设置
      exp
      字段定义 JWT 的过期时间。
    • 使用
      jwt.encode
      创建并签名 JWT。
  5. get_token
    路由

    :
    • 模拟从请求中获取用户信息(包括 IP 地址)。
    • 调用
      generate_token
      生成 JWT。
    • 将 JWT 放入响应头中,返回给客户端。

3、在api中如何实现AllowAnonymous和验证授权

在 Python 的
FastAPI
框架中,你可以通过以下方式实现类似于 ASP.NET 中的
AllowAnonymous
和授权验证功能。

1)
实现 JWT 授权验证中间件

首先,你需要一个依赖项来检查请求中是否包含有效的 JWT 令牌。如果令牌无效或缺失,依赖项将拒绝请求。通过这种方式,只有标记为“允许匿名”的路由才会跳过验证。

2)
安装依赖

确保安装了
python-jose
用于处理 JWT,以及
fastapi

3)
创建授权依赖

你可以创建一个名为
get_current_user
的依赖项,用于验证 JWT 令牌并提取用户信息。如果没有提供或验证失败,抛出
HTTPException

from fastapi importDepends, HTTPException, statusfrom jose importJWTError, jwtfrom typing importOptional

JWT_SECRET_KEY
= 'your_jwt_secret_key'ALGORITHM= 'HS256' def get_current_user(token: str =Depends(oauth2_scheme)):try:
payload
= jwt.decode(token, JWT_SECRET_KEY, algorithms=[ALGORITHM])
user_id: str
= payload.get("id")if user_id isNone:raiseHTTPException(
status_code
=status.HTTP_401_UNAUTHORIZED,
detail
="Could not validate credentials",
headers
={"WWW-Authenticate": "Bearer"},
)
returnpayloadexceptJWTError:raiseHTTPException(
status_code
=status.HTTP_401_UNAUTHORIZED,
detail
="Could not validate credentials",
headers
={"WWW-Authenticate": "Bearer"},
)

get_current_user
:用于解析和验证 JWT。如果令牌无效或缺失,会抛出
HTTPException
,返回 401 状态码。

具体使用的时候,我们可能把用户信息缓存在Redis里面提高处理效率。

4)
实现
AllowAnonymous
功能


FastAPI
中,你可以通过
Depends
来实现条件授权。对于需要授权的路由,只需将
get_current_user
作为依赖传递给路由函数。而无需授权的路由,可以直接定义。

from fastapi importFastAPI, Depends, HTTPException, statusfrom fastapi.security importOAuth2PasswordBearer

app
=FastAPI()

oauth2_scheme
= OAuth2PasswordBearer(tokenUrl="token")#允许匿名访问的路由 @app.get("/public")defread_public_data():return {"message": "This is a public endpoint"}#需要授权的路由 @app.get("/protected")def read_protected_data(current_user: dict =Depends(get_current_user)):return {"message": f"Hello, {current_user['name']}"}#模拟登录生成 JWT 令牌的路由 @app.post("/token")deflogin():#这里省略了身份验证过程,只是直接生成一个 JWT 令牌 token = jwt.encode({"id": 1, "name": "John Doe"}, JWT_SECRET_KEY, algorithm=ALGORITHM)return {"access_token": token, "token_type": "bearer"}
  • 公共路由 (
    /public
    )

    :可以直接访问,不需要任何身份验证。
  • 受保护路由 (
    /protected
    )

    :必须提供有效的 JWT 令牌才能访问。
  • 登录路由 (
    /token
    )

    :生成一个 JWT 令牌,可以用于受保护的路

Depends(get_current_user)
:在需要保护的路由中,通过依赖项注入
get_current_user
,确保只有通过身份验证的用户才能访问。


FastAPI
中,建议通过依赖项注入(
Depends
)来获取当前用户的信息,而不是直接访问
request.user
。这种方式更加灵活并且与
FastAPI
的设计哲学更一致。

在具体项目中,我们为了方便,往往通过中间件的方式进行定义和处理授权的过程。

通过authentiate函数处理验证用户令牌的有效性。

我们一般再main.py入口中加入中间件的处理即可。

    #JWT auth, required
app.add_middleware(
AuthenticationMiddleware,
backend
=JwtAuthMiddleware(),
on_error
=JwtAuthMiddleware.auth_exception_handler,
)

4、一些错误处理

当你在解码 JWT 时遇到 "Invalid audience" 错误,通常意味着在生成或解码 JWT 时,
aud
(audience) 声明没有正确设置或验证。以下是解决这个问题的步骤和说明:

1)
理解
aud
(Audience) 声明

  • aud
    是 JWT 中的一个可选声明,通常用于指定 JWT 的接收者(受众)。在解码 JWT 时,
    jwt.decode
    会检查这个声明是否与预期的值匹配。

设置和检查
aud
声明,

生成 Token 时设置
aud

如果你希望 JWT 包含
aud
声明,可以在生成 token 时传递它。例如:

to_encode ={"sub": "user_id","aud": "your_audience",  #设置aud声明
    "exp": datetime.utcnow() + timedelta(minutes=30)
}

token
= jwt.encode(to_encode, settings.TOKEN_SECRET_KEY, algorithm=settings.TOKEN_ALGORITHM)

解码 Token 时验证
aud

在解码 JWT 时,指定
audience
参数以匹配生成时的
aud

try:
payload
=jwt.decode(
token,
settings.TOKEN_SECRET_KEY,
algorithms
=[settings.TOKEN_ALGORITHM],
audience
="your_audience" #验证aud声明 )print(f"Decoded payload: {payload}")exceptJWTError as e:print(f"Token decode failed: {e}")

2)Token decode failed: Subject must be a string.

"Token decode failed: Subject must be a string" 错误通常是由于
sub
(subject) 声明的值不是字符串引起的。
sub
是 JWT 中常用的一个声明,用来标识 token 的主体,比如用户 ID 或用户名。JWT 标准要求
sub
的值必须是字符串。

检查
sub
声明的值

首先,确保在生成 token 时,
sub
声明的值是一个字符串:

to_encode ={"sub": str(user_id),  #确保 user_id 是字符串
    "aud": "your_audience",  #其他字段
    "exp": datetime.utcnow() + timedelta(minutes=30)
}

token
= jwt.encode(to_encode, settings.TOKEN_SECRET_KEY, algorithm=settings.TOKEN_ALGORITHM)

如果
sub
的值是一个非字符串类型(如整数或其他对象),请将其转换为字符串:

user_id = 123  #假设 user_id 是一个整数
to_encode ={"sub": str(user_id),  #将 user_id 转换为字符串
    "aud": "your_audience","exp": datetime.utcnow() + timedelta(minutes=30)
}

token
= jwt.encode(to_encode, settings.TOKEN_SECRET_KEY, algorithm=settings.TOKEN_ALGORITHM)

3)schema如何移除敏感字段

在处理数据模型和模式(schema)时,特别是当涉及到敏感信息(如密码、身份信息等)时,有时需要从输出或序列化结果中移除这些敏感字段。根据你使用的库或框架,处理敏感字段的方法会有所不同。以下是一些常见的方法来移除敏感字段:

使用 Pydantic 的
exclude
参数

如果你在使用 Pydantic(例如在 FastAPI 中),你可以使用模型的
dict
方法的
exclude
参数来排除敏感字段。

from pydantic importBaseModelclassUser(BaseModel):
username: str
email: str
password: str
#敏感字段 user= User(username="user1", email="user1@example.com", password="secret")#创建一个不包含敏感字段的字典 user_dict = user.dict(exclude={"password"})print(user_dict)

使用 SQLAlchemy 的
__mapper_args__

如果你使用 SQLAlchemy 并且希望从序列化结果中排除敏感字段,可以使用模型的
__mapper_args__
进行配置。例如:

from sqlalchemy importColumn, Stringfrom sqlalchemy.ext.declarative importdeclarative_base

Base
=declarative_base()classUser(Base):__tablename__ = 'users'id= Column(String, primary_key=True)
username
=Column(String)
email
=Column(String)
password
= Column(String) #敏感字段 defto_dict(self):#移除敏感字段 return {c.name: getattr(self, c.name) for c in self.__table__.columns if c.name != 'password'}

user
= User(id="1", username="user1", email="user1@example.com", password="secret")print(user.to_dict())

自定义序列化方法

如果你使用自定义模型或类,并且没有使用特定的库,你可以实现自定义序列化方法来排除敏感字段。

classUser:def __init__(self, username, email, password):
self.username
=username
self.email
=email
self.password
= password #敏感字段 defto_dict(self):#移除敏感字段 return{"username": self.username,"email": self.email
}

user
= User(username="user1", email="user1@example.com", password="secret")print(user.to_dict())

前言

在平时的开发中随着我们系统应用不断地迭代变的复杂,对应用的实时监控变得越来越重要。实时监控不仅可以帮助我们快速定位问题,还能在出现问题时及时采取措施,减少业务中断的时间。

本文将介绍一个名为WatchDog的.NET开源实时应用监控系统,它可以帮助我们轻松实现对.NET应用的实时监控。

项目介绍

WatchDog是一个开源(MIT License)、免费的实时应用监控系统,专为ASP.NET Core Web应用程序和API设计。我们可以实时记录和查看应用程序中的消息、事件、HTTP请求和响应,以及运行时捕获的异常。

一个高效的监控系统不仅能提高应用的可用性和可靠性,还能帮助我们更快地解决问题。WatchDog是一款专为.NET应用设计的开源实时应用监控系统,它提供了一系列强大的功能来帮助我们监控应用的状态和性能。

功能特点

  • 实时日志记录
    :能够实时记录HTTP请求、响应以及运行时捕获的异常。
  • 代码内日志记录
    :支持在代码中记录消息和事件。
  • 日志视图查看
    :提供易于使用的日志查看界面。
  • 日志搜索功能
    :支持对HTTP请求和异常日志进行搜索。
  • HTTP日志筛选
    :可根据HTTP方法和状态码筛选HTTP日志。
  • 日志视图认证
    :提供日志视图的访问权限控制。
  • 自动日志清理
    :支持自动清除旧的日志记录。
  • 报警通知
    :支持通过邮件、短信等多种方式发送报警通知,确保能够及时得到反馈。
  • 灵活配置
    :可以根据应用需求进行灵活的配置,适应不同的监控需求。
  • 易于集成
    :通过简单的API调用即可集成到现有的.NET应用中。

项目技术栈

  • 后端
    :基于.NET Core 或 .NET 5及以上版本。
  • 前端
    :使用 React 或 Angular等前端框架搭建的用户界面。
  • 数据库
    :支持MySQL、PostgreSQL等关系型数据库,以及MongoDB等NoSQL数据库。
  • 官方支持 .NET 8
    :新增对 .NET 8 的官方支持。
  • .NET 8 中的 Output Cache 支持
    :增加了对 .NET 8 中使用 Output Cache 的支持。
  • 使用正则表达式黑名单
    :新增了使用正则表达式进行黑名单过滤的功能。

工作原理

WatchDog 利用 SignalR 实现了实时监控,并使用LiteDb作为无需配置的类似MongoDB的服务器端数据库,同时也支持使用外部数据库(如MSSQL、MySQL、PostgreSQL、MongoDB)。

1、
支持.NET版本

.NET Core 3.1 及更高版本。

2、下载源码

通过下载源码,可以进行学习和应用,具体操作如下图所示:

安装与配置

1、WatchDog 安装

搜索
WatchDog.NET
NuGet包进行安装,具体如下图所示:

2、WatchDog 服务注册

在ASP.NET Core Web API 的
Program.cs

Startup.cs
中注册 WatchDog 服务。

从ILogger 记录日志到 WatchDog

还可以将来自.NET的
ILogger
的日志记录到 WatchDog 中。

适用于 .NET 6 及以上版本

builder.Services.AddWatchDogServices(opt =>{  
opt.IsAutoClear
= true;
opt.ClearTimeSchedule
=WatchDogAutoClearScheduleEnum.Monthly;
opt.DbDriverOption
=WatchDogDbDriverEnum.Mongo;
opt.SetExternalDbConnString
= "mongodb://localhost:27017";
});
builder.Logging.AddWatchDogLogger();

对于 .NET Core 3.1


Program.cs
类的
CreateHostBuilder
方法中配置日志记录,并添加
.AddWatchDogLogger()

Host.CreateDefaultBuilder(args)
.ConfigureLogging( logging
=>{
logging.AddWatchDogLogger();
})
.ConfigureWebHostDefaults(webBuilder
=>{
webBuilder.UseStartup
<Startup>();
});

3、添加异常记录器

在主要的WatchDog中间件之前添加异常日志记录器,最好将其放置在中间件的最上面,以便捕捉可能的早期异常。

app.UseWatchDogExceptionLogger();

...

app.UseWatchDog(opt
=>{
opt.WatchPageUsername
= "admin";
opt.WatchPagePassword
= "Qwerty@123";
...
});

4、设置自动清除日志(可选)

该功能可在特定时间后自动清除日志。

注意
:当
IsAutoClear
设置为
true
时,默认的清除计划时间为每周。

如需覆盖默认设置,请按照以下方式进行配置:

services.AddWatchDogServices(opt =>{ 
opt.IsAutoClear
= true;
opt.ClearTimeSchedule
=WatchDogAutoClearScheduleEnum.Monthly;
});

5、设置日志记录到外部数据库(可选)

将日志记录到数据库,如SQL Server (MSSQL)、MySQL、PostgreSQL 或 MongoDB,可以根据需求配置。

  • 添加数据库连接字符串
    :提供数据库的连接字符串。
  • 选择数据库驱动选项
    :根据所使用的数据库类型选择合适的驱动程序。
services.AddWatchDogServices(opt =>{
opt.IsAutoClear
= true;
opt.SetExternalDbConnString
= "Server=localhost;Database=testDb;
User Id=postgres;Password=root;
";
opt.DbDriverOption
=WatchDogDbDriverEnum.PostgreSql;
});

6、设置访问日志的账号密码

app.UseWatchDog(opt =>{ 
opt.WatchPageUsername
= "admin";
opt.WatchPagePassword
= "Qwerty@123";
});

注意:如果你的项目使用权限验证,那么
app.UseWatchDog();
应该在
app.UseRouting()
,
app.UseAuthentication()
, 和
app.UseAuthorization()
之后按顺序添加。

7、配置说明和示例

  • 黑名单
    :要忽略的路由、路径或端点列表(应为逗号分隔的字符串,如下所示)。
  • 序列化器
    :如果不使用默认的全局 JSON 序列化器/转换器,请指定类型。
  • CORS策略
    :如果项目使用了跨源资源共享(CORS),请指定策略名称。
  • 使用Output缓存
    :如果项目使用了ASP.NET Output缓存。功能仅适用于 .NET 8 及以上版本。
  • 使用正则表达式黑名单
    :启用使用正则表达式来黑名单化请求路由、路径或端点。

示例配置

app.UseWatchDog(opt =>{ 
opt.WatchPageUsername
= "admin";
opt.WatchPagePassword
= "Qwerty@123";//Optional opt.Blacklist = "Test/testPost, api/auth/login";//Prevent logging for specified endpoints opt.Serializer =WatchDogSerializerEnum.Newtonsoft;//If your project use a global json converter opt.CorsPolicy = "MyCorsPolicy";
opt.UseOutputCache
= true;
opt.UseRegexForBlacklisting
= true;
});

8、记录消息/事件

WatchLogger.Log("...Test Log...");
WatchLogger.LogWarning(JsonConvert.Serialize(model));
WatchLogger.LogError(res.Content, eventId: reference);

9、查看日志和异常

启动服务器并访问
/watchdog
来查看日志。

示例

https://myserver.com/watchdoghttps://localhost:[your-port]/watchdog

项目效果

1、WatchDog登录

2、WatchDog 日志

3、请求日志

4、消息/事件

项目地址

Github:
https://github.com/IzyPro/WatchDog

总结

WatchDog 是一款强大且易于使用的.NET开源实时应用监控系统。通过使用WatchDog,我们可以轻松实现对.NET应用的实时监控,提高应用的稳定性和可靠性。希望这篇文章能够帮助你更好地理解和使用WatchDog。

最后

如果你觉得这篇文章对你有帮助,不妨点个赞支持一下!你的支持是我继续分享知识的动力。如果有任何疑问或需要进一步的帮助,欢迎随时留言。

也可以加入微信公众号
[DotNet技术匠]
社区,与其他热爱技术的同行一起交流心得,共同成长!

随着技术的不断进步,开发者们面临着如何在复杂的技术环境中创造出卓越应用的挑战。
在当今的科技浪潮中,如何抓住创新的机遇?
HarmonyOS NEXT的发布,带来了全新的机遇和功能。本文将探讨开发者如何充分利用这些功能,提升开发效率,实现创新,并在竞争激烈的市场中脱颖而出。

HarmonyOS NEXT的核心功能概述

多设备无缝连接

在如今的数字时代,用户对设备间无缝切换的期望越来越高。那么,开发者如何确保用户在多个设备间获得一致的体验?
HarmonyOS NEXT的多设备无缝连接功能正是为了解决这一需求而生
。通过跨设备的实时数据同步和统一的操作界面,开发者可以让用户在智能手机、平板电脑、智能手表等多种设备之间无缝切换。这不仅极大地提升了用户满意度,还为应用在用户日常生活中的使用频率带来了显著提升。

这一功能的实际应用场景包括什么呢?例如,当用户在手机上观看视频时,他们可以随时切换到平板或电视,视频播放进度会自动同步,无需手动操作。这种无缝体验不仅简化了用户的操作步骤,还增强了设备间的互动性,使得用户能够更便捷地享受内容。
开发者在设计应用时,可以利用这一功能,创造出更加流畅和自然的用户体验

AI深度集成

AI技术早已成为推动应用智能化的核心动力。但在开发过程中,如何真正利用AI提升应用的智能化水平?
HarmonyOS NEXT内置了强大的AI功能
,如AIGC(AI生成内容)图像生成和AI声音修复。这些功能不仅使应用更加智能,还大大缩短了开发时间,提高了开发效率。

在具体的应用场景中,AI深度集成如何发挥作用?
举个例子
,在图像处理应用中,AI图像生成功能能够自动将用户的普通照片转化为风格化的艺术作品,无需用户具备专业的编辑技能。对于音乐类应用,AI声音修复技术可以自动去除背景噪音,增强音质,从而为用户提供更佳的听觉体验。开发者们可以借助这些AI工具,创造出更加创新且吸引人的应用,为用户提供更优质的服务。

增强的API接口

开发者日常工作中,最关心的问题之一是什么?无疑是API接口的易用性和功能性。HarmonyOS NEXT推出了一系列全新、增强的API接口,不仅简化了开发流程,还提供了更强的兼容性和灵活性。
这些API接口为开发者提供了更丰富的工具,帮助他们更快速地实现复杂的功能

以一个电商应用为例,开发者可以利用这些增强的API接口,快速实现跨平台支付、实时库存更新以及个性化推荐等功能。
这些功能不仅让开发过程变得更加高效,还提升了用户体验的质量
。对于开发者而言,掌握这些增强的API接口,就像拥有了一把通向更高效开发的大门钥匙。


开发者工具和资源

开发者工具

在应用开发中,开发工具的重要性不言而喻。那么,什么样的工具能够真正提升开发效率?
HarmonyOS NEXT为开发者提供了一整套精良的工具
,包括强大的IDE(集成开发环境)、便捷的调试工具,以及完善的测试平台。这些工具相辅相成,助力开发者更快地实现创意,缩短开发周期。

具体来说,IDE的直观界面和智能代码补全功能,可以显著减少代码编写的时间。而调试工具的实时反馈功能,使得开发者能够迅速发现并修复代码中的问题。
这一整套工具的协同作用,极大地提升了开发效率,使开发者能够专注于创造更加出色的应用

技术文档和支持

技术文档和支持体系对开发者来说有多重要?
HarmonyOS NEXT的技术文档不仅详细介绍了各项功能和最佳实践
,还通过实例说明帮助开发者更快上手。此外,活跃的开发者社区和全天候的技术支持,成为了开发者强有力的后盾。
这使得开发者在遇到问题时能够迅速找到解决方案,确保项目顺利进行

想象一下,在开发过程中遇到问题时,能够立刻找到详尽的解决方案,这对于项目的成功至关重要。
HarmonyOS NEXT的技术支持不仅提供了及时的帮助,还通过社区分享和反馈机制,让开发者们能够共同成长


案例分析

小米智能家居应用的跨设备连接

在实际应用中,HarmonyOS NEXT的跨设备连接功能如何帮助企业取得成功?
小米通过HarmonyOS NEXT实现了智能家居应用的跨设备无缝连接
。这一技术突破,成功解决了用户在多个设备间切换时的延迟问题,使得用户体验大幅提升。

具体而言,小米的智能家居应用通过HarmonyOS NEXT的跨设备连接功能,用户可以在手机、平板、电视之间无缝切换控制智能设备,如灯光、空调等。
实时的设备状态同步和统一的用户界面设计,不仅让用户操作更为简便,还提高了用户对品牌的忠诚度

网易云音乐的AI声音修复功能

如何通过AI功能提升产品的市场竞争力?
网易云音乐采用了HarmonyOS NEXT的AI声音修复功能
,成功提升了用户的听觉体验。这项技术不仅在技术层面取得了突破,还为网易云音乐赢得了市场份额的显著提升。

通过AI声音修复功能,网易云音乐能够有效消除录音中的背景噪音和音质失真问题。
这一创新功能不仅提升了用户的使用体验,还大大节省了人力成本,使网易云音乐在市场竞争中脱颖而出


跨设备应用开发

跨设备功能实现

开发支持多设备协同的应用有多重要?
在设计和实现上,虽然存在一定的复杂性,但HarmonyOS NEXT已经为开发者铺平了道路
。开发者可以利用系统提供的工具和API,轻松实现设备间的无缝连接。

在实际开发中,开发者需要充分理解用户需求,结合HarmonyOS NEXT的技术优势,
创造出流畅、直观的用户体验
。例如,在一个跨设备的游戏应用中,玩家可以在手机上开始游戏,然后在平板或电视上继续,游戏进度无缝衔接。
这一功能极大提升了用户的参与感和体验

开发示例

具体的实现过程会是什么样的呢?为了帮助开发者更好地理解如何在实际中应用这些功能,以下是一个具体的代码示例。
假设正在开发一个需要在手机和电视之间共享视频播放进度的应用
,通过HarmonyOS NEXT提供的API,可以实现两者之间的实时同步。

java
复制代码
// 代码示例:跨设备视频进度同步public void syncVideoProgress(Device targetDevice, int progress) {
    targetDevice.sendMessage("videoProgress", progress);
}

这一简单的代码示例展示了如何在不同设备间实现功能同步,
为开发者提供了极大的灵活性,让用户能够享受更好的使用体验


AI功能的应用

AIGC图像生成

AI生成内容(AIGC)技术在近几年迅速发展
,HarmonyOS NEXT将这一技术直接集成到系统中,使得图像生成变得更加简单和高效。

在摄影类应用中,开发者可以利用AIGC功能,为用户生成风格化的照片。
这一创新功能不仅增加了应用的互动性,还能让用户在社交媒体上分享独特的内容
,进而提高应用的曝光度和用户粘性。想象一下,用户可以随时将普通照片转化为艺术作品,这无疑会为应用带来更高的用户活跃度。

AI声音修复

音频质量对用户体验的重要性不言而喻,尤其是在音乐和语音应用中。
HarmonyOS NEXT提供的AI声音修复功能,能够自动去除背景噪音,增强音频清晰度

这一功能特别适用于社交平台、视频制作和音乐播放应用,为用户提供更加纯净和专业的音质体验。以网易云音乐为例,AI声音修复功能的应用,极大提升了用户的听觉体验。
这一功能不仅为用户提供了更加清晰的音质,还帮助应用在竞争中脱颖而出


结论与实践建议

在竞争激烈的应用市场中,开发者如何才能立于不败之地?

在如今这个技术快速迭代的时代,应用市场的竞争可谓异常激烈。那么,开发者们如何才能脱颖而出,站稳脚跟?
HarmonyOS NEXT 的推出,为开发者带来了强大的工具和前所未有的技术支持,让他们能够在瞬息万变的市场中占据有利位置
。通过掌握这些技术,开发者不仅可以在短时间内实现从 0 到 1 的突破,更能创造出真正打动用户、具有长期生命力的产品。

然而,仅仅依赖技术的优势并不足够。开发者需要具备敏锐的市场洞察力和对用户需求的深刻理解,才能将技术转化为实际的市场价值。
正是因为 HarmonyOS NEXT 提供了多设备无缝连接、AI 深度集成等创新功能,开发者才能更好地捕捉用户需求,并迅速做出反应
。当技术与市场需求紧密结合,开发者才能在竞争中始终保持领先。


实践建议

作为开发者,如何才能最大化地利用 HarmonyOS NEXT 提供的丰富功能和工具?这不仅仅是一个技术问题,更是关于战略和实践的深刻思考。

首先,
深入学习和理解系统提供的 API 和工具,是实现高效开发的基础
。开发者应该通过官方文档、培训资源和案例学习,全面掌握 HarmonyOS NEXT 的技术细节。只有在对这些工具有了深刻的理解后,才能在开发过程中灵活运用,并迅速解决遇到的问题。

其次,
多设备无缝连接和 AI 集成功能是 HarmonyOS NEXT 的核心亮点
,开发者应当充分利用这些功能来提升应用的用户体验。例如,可以在应用中加入 AI 驱动的智能推荐系统,或者通过多设备协同实现无缝的跨平台体验。这样的功能不仅能够增强用户粘性,还能为应用增添独特的竞争优势。

此外,
积极参与开发者社区也是至关重要的一步
。开发者社区是一个共享知识和经验的平台,通过与其他开发者交流,分享开发心得和问题解决方案,开发者能够不断提升自己的技能和知识储备。这种持续的学习和交流,不仅能帮助开发者应对技术挑战,还能让他们保持对最新技术趋势的敏感度,迅速适应市场变化。

最后,开发者应该从实际案例中获得灵感,将理论应用到实践中。
通过运用 HarmonyOS NEXT 的新功能来开发创新性的应用,开发者不仅能增强自身的竞争力,还能为用户带来前所未有的数字体验
。在这个过程中,开发者应该始终关注用户反馈,不断优化和迭代自己的产品,以满足用户不断变化的需求。


通过这些具体的实践建议,开发者们将能够更好地利用 HarmonyOS NEXT 所提供的强大功能,创造出更具创新性和竞争力的应用。未来,随着更多开发者的加入和技术的进一步发展,
HarmonyOS NEXT 将引领更多技术创新,为全球用户带来更加丰富和智能的数字体验
。这不仅是对开发者的激励,更是对整个行业未来发展的美好展望。


下期预告:问界M7 Pro——解读华为智能汽车的最新里程碑

在智能出行领域,华为再度发力!2024年8月,问界M7 Pro重磅发布,搭载了最新的鸿蒙智行系统,智能驾驶体验再上新台阶。下一期,我们将深入解读这款备受瞩目的智能SUV,探讨其在智能座舱、自动驾驶、生态联动等方面的技术亮点,揭示华为如何通过鸿蒙生态圈打造更安全、更智能的出行未来。

敬请期待,我将带您走进问界M7 Pro的世界,揭示这款车背后的创新密码!

如果你了解过 Kafka,那么它用到的一个性能优化技术可能会引起你的注意 -- 操作系统的零拷贝(zero-copy)优化。

零拷贝操作可以避免对数据的非必要拷贝,当然,并非是说完全没有拷贝。

在 Kafka 的场景下,操作系统可以从 page cache 拷贝数据到 socket buffer,直接绕过 Kafka broker 这个 Java 程序。这可以节省一些额外的拷贝,节省一些用户态和内核态的切换。让我们看一个例子。

传统拷贝

如果您的应用程序要从磁盘读取文件并通过网络发送它,则可能会进行一堆不必要的拷贝,以及用户态/内核态的切换。

Kafka 传统拷贝

一些术语:

  • read buffer: 读缓冲区,操作系统的 page cache
  • socket buffer: 套接字缓冲区,OS 用于管理数据包的字节缓冲区
  • NIC buffer: 网卡中的字节缓冲区
  • DMA copy: DMA 是 Direct Memory Access 的缩写,是内存控制器的一个功能,可以避免 CPU 的干预,允许硬件(图形卡、声卡、网卡等)直接访问内存 (RAM) 里的某些数据

在这个例子中,我们有 4 次模式切换(用户态和内核态之间的切换)和 4 次数据拷贝。

  • 应用程序(这里指 Kafka)利用 DMA copy 从磁盘 load 数据到 read buffer(
    用户态->内核态
  • read buffer 到应用程序的缓存区(
    内核态->用户态
  • 应用程序要发数据到网络上,实际是先写到 socket buffer(
    用户态->内核态
  • socket buffer 到 NIC buffer(响应数据写完之后,由内核态返回用户态)

零拷贝

Kafka 零拷贝

为了减少拷贝,把数据从磁盘直接发向网络,那 Kafka 在存储数据的时候,就要保证存储的数据格式和将要发出的 response 格式一致。

在传统拷贝模式下,第二步、第三步没啥意义,因为 Kafka 没有对数据做额外处理,只是简单转发。那能否从磁盘直接发向网络呢?答案是肯定的。通过零拷贝技术,磁盘上的数据还是要先进入 read buffer,然后不用再拷贝到应用程序的缓存区,而是直接拷贝到 NIC buffer,图上的步骤 2:Appends just file descriptors,只是把文件描述符交给了 Socket buffer,实际数据并没有拷贝给 Socket buffer。这就是所谓的 scatter-gather 操作(也称为 Vectorized I/O),scatter-gather 是仅将 read buffer 数据指针存储在 socket buffer 中,并让 DMA 直接从内存读取数据的行为。

最终结果如何呢?

  • 4 次模式切换变成了 2 次
  • 2 次 DMA 拷贝,仍然是 2 次
  • 1 次微小的指针拷贝

Kafka 传统拷贝和零拷贝对比

在 Kafka 中

你可能听过 Kafka 因为零拷贝实现了高性能,但是理想很丰满现实很骨感,零拷贝技术在大部分 Kafka 集群中并没有那么大的影响力。

  • CPU 很少成为瓶颈。网络饱和的速度要快得多,因此在大多数情况下,内存中副本的缺失并不会带来多大的影响。
  • 启用加密和 SSL/TLS 已经禁止 Kafka 使用零拷贝

原文:​​
​https://2minutestreaming.beehiiv.com/p/apache-kafka-zero-copy-operating-system-optimization
​​​
译者:巴辉特,极客时间专栏《运维监控系统实战笔记》作者,Open-Falcon、Nightingale 开源项目发起人,目前创业中,作为
Flashcat
联合创始人,专攻监控/可观测性方向。欢迎和我一起探讨监控/可观测性相关技术和产品。