技术分享 – LigaAI 团队博客 https://ligai.cn/blog 以人工智能,赋能项目管理 Fri, 31 May 2024 03:45:11 +0000 zh-CN hourly 1 https://wordpress.org/?v=5.8.4 https://ligai.cn/blog/wp-content/uploads/2021/02/logo_图形-150x150.png 技术分享 – LigaAI 团队博客 https://ligai.cn/blog 32 32 技术分享 | SpringBoot 流式输出时,正常输出后为何突然报错? https://ligai.cn/blog/sharing/1521.html Fri, 31 May 2024 03:45:11 +0000 https://ligai.cn/blog/?p=1521 阅读更多]]>
  • 一个 SpringBoot 项目同时使用了 Tomcat 的过滤器和 Spring 的拦截器,一些线程变量在过滤器中初始化并在拦截器中使用。
  • 该项目需要调用大语言模型进行流式输出。
  • 项目中,笔者使用 SpringBoot 的 ResponseEntity<StreamingResponseBody> 将流式输出返回前端。
  • 问题出现

    问题出现在上述第 3 点:正常输出一段内容后,后台突然报错,而报错内容由拦截器产生

    笔者仔细查看了报错日志,发现只是拦截器的问题:执行时由于某些线程变量不存在而报错。但是,这些线程变量已经在过滤器中初始化了。

    那么问题来了:为什么这个接口明明可以正常通过过滤器和拦截器,并开始正常输出,却又突然在拦截器中报错呢?

    场景重现

    Filter

    @Slf4j
    @Component
    @Order(1)
    public class MyFilter implements Filter {
    
        @Override
        public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
            // 要继续处理请求,必须添加 filterChain.doFilter()
            log.info("doFilter method is running..., thread: {}, dispatcherType: {}", Thread.currentThread(), servletRequest.getDispatcherType()); 
            filterChain.doFilter(servletRequest,servletResponse);
        } 
    }

    Interceptor

    @Slf4j
    public class MyInterceptor implements HandlerInterceptor {
    
        @Override
        public boolean preHandle(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) throws Exception {
            log.info("preHandle method is running..., thread: {}, dispatcherType: {}", Thread.currentThread(), request.getDispatcherType());
            if (DispatcherType.ASYNC == request.getDispatcherType()) {
                log.info("preHandle dispatcherType={}", request.getDispatcherType());
            }
            return true;
        }
    
        @Override
        public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
            log.info("postHandle method is running..., thread: {}", Thread.currentThread());
        }      
    
        @Override
        public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
            log.info("afterCompletion method is running..., thread: {}", Thread.currentThread());
        } 
    }

    WebMvcConfigurer

    @Configuration
    public class WebAppConfigurer implements WebMvcConfigurer {
    
        @Bean
        public MyInterceptor myInterceptor() {
            return new MyInterceptor();
        }
    
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            registry.addInterceptor(myInterceptor()).addPathPatterns("/**");
        }
    
        @Override
        public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
            configurer.setDefaultTimeout(120_000L);
            configurer.registerCallableInterceptors();
            configurer.registerDeferredResultInterceptors();
    
            ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
            executor.setCorePoolSize(5);
            executor.setMaxPoolSize(10);
            executor.setQueueCapacity(100);
            executor.setThreadNamePrefix("web-async-");
            executor.initialize();
            configurer.setTaskExecutor(executor);
        }
    }

    Controller

    @Slf4j
    @RestController
    @RequestMapping("/test-stream")
    public class TestStreamController {
    
        @ApiOperation("流式输出示例")
        @PostMapping(value = "/example", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
        public ResponseEntity<StreamingResponseBody> example() {
            log.info("Stream method is running, thread: {}", Thread.currentThread());
            return  ResponseEntity.status(HttpStatus.OK)
                .contentType(new MediaType(MediaType.TEXT_EVENT_STREAM, StandardCharsets.UTF_8))
                .body(outputStream -> {
                    log.info("Internal stream method is running, thread: {}", Thread.currentThread());
                    try (outputStream) {
                        String msg = "To be or not to be!";
                        outputStream.write(msg.getBytes(StandardCharsets.UTF_8));
                        outputStream.flush();
                    }
                });
        }
    }

    根据以下运行日志,我们可以看到拦截器的 preHandle 确实执行了两次,并且此次调用过程共有 3 个线程(io-14000-exec-1web-async-1io-14000-exec-2)参与了工作。

    2024-05-06 07:35:27.362  INFO 209108 --- [io-14000-exec-1] o.a.c.c.C.[.[localhost].[/java-study]    : Initializing Spring DispatcherServlet 'dispatcherServlet'
    2024-05-06 07:35:27.362  INFO 209108 --- [io-14000-exec-1] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'
    2024-05-06 07:35:27.365  INFO 209108 --- [io-14000-exec-1] o.s.web.servlet.DispatcherServlet        : Completed initialization in 3 ms
    2024-05-06 07:35:27.402  INFO 209108 --- [io-14000-exec-1] com.peng.java.study.web.config.MyFilter  : doFilter method is running..., thread: Thread[http-nio-14000-exec-1,5,main], dispatcherType: REQUEST
    2024-05-06 07:35:28.107  INFO 209108 --- [io-14000-exec-1] c.p.java.study.web.config.MyInterceptor  : preHandle method is running..., thread: Thread[http-nio-14000-exec-1,5,main], dispatcherType: REQUEST
    2024-05-06 07:35:28.121  INFO 209108 --- [io-14000-exec-1] c.p.j.s.w.r.test.TestStreamController    : Stream method is running, thread: Thread[http-nio-14000-exec-1,5,main]
    2024-05-06 07:35:28.152  INFO 209108 --- [    web-async-1] c.p.j.s.w.r.test.TestStreamController    : Internal stream method is running, thread: Thread[web-async-1,5,main]
    2024-05-06 07:35:28.167  INFO 209108 --- [io-14000-exec-2] c.p.java.study.web.config.MyInterceptor  : preHandle method is running..., thread: Thread[http-nio-14000-exec-2,5,main], dispatcherType: ASYNC
    2024-05-06 07:35:28.167  INFO 209108 --- [io-14000-exec-2] c.p.java.study.web.config.MyInterceptor  : preHandle dispatcherType=ASYNC
    2024-05-06 07:35:28.174  INFO 209108 --- [io-14000-exec-2] c.p.java.study.web.config.MyInterceptor  : postHandle method is running..., thread: Thread[http-nio-14000-exec-2,5,main]
    2024-05-06 07:35:28.183  INFO 209108 --- [io-14000-exec-2] c.p.java.study.web.config.MyInterceptor  : afterCompletion method is running..., thread: Thread[http-nio-14000-exec-2,5,main]

    问题分析

    1. 方法调用流程的差异

    众所周知,SpringBoot 的普通输出接口调用流程图如图 1 所示。

    图1-SpringBoot 普通输出调用流程图

    结合日志,我们可以简单画出流式输出接口对应的流程图(图 2)。

    图2-SpringBoot 流式输出调用流程图

    2. 线程的差异

    普通接口的执行时序图如图 3 所示。

    图3-普通接口的时序图

    而流式接口的时序图如图 4 所示。

    图4-流式接口的调用时序图

    解决问题

    通过分析,对流式输出的情况提出两种解决方案:

    1. 将过滤器中的部分业务逻辑迁移到拦截器中。
    2. 根据条件,跳过第二次的拦截器 preHandle 方法。

    笔者选择了第二个方案,实现代码如下。

    @Slf4j
    public class MyInterceptor implements HandlerInterceptor {
    
        @Override
        public boolean preHandle(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) throws Exception {
            log.info("preHandle method is running..., thread: {}, dispatcherType: {}", Thread.currentThread(), request.getDispatcherType());
            // 如果是异步请求,则跳过
            if (DispatcherType.ASYNC == request.getDispatcherType()) {
                log.info("preHandle dispatcherType={}", request.getDispatcherType());
                return true;
            }
            return true;
        }
    
        @Override
        public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
            log.info("postHandle method is running..., thread: {}", Thread.currentThread());     
        }
    
        @Override
        public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
            log.info("afterCompletion method is running..., thread: {}", Thread.currentThread());
        } 
    }

    需要注意,请求线程和回调线程都需考虑清理线程变量,不然会导致内存泄漏。


    >> LigaAI 往期精彩阅读 <<

    用 MVP(最小可行性产品) 做低成本快速验证,为什么不灵了?

    6 大原则!助你构建高绩效的研发强军 | Liga译文

    技术分享 | 弹窗开发中,如何使用 Hook 封装 el-dialog?

    LigaAI x 极狐GitLab,共探 AI 时代研发提效新范式

    欢迎试用 LigaAI-智能研发协作平台,体验智能研发协作,一起变大变强!

    ]]>
    技术分享 | 弹窗开发中,如何使用 Hook 封装 el-dialog? https://ligai.cn/blog/sharing/1509.html Mon, 06 May 2024 08:35:02 +0000 https://ligai.cn/blog/?p=1509 阅读更多]]> 弹窗是前端开发中的一种常见需求。Element UI 框架中的 el-dialog 组件提供了弹窗相关的基本功能,但在实际开发中,我们难免会遇到一些定制化需求,比如对弹窗进行二次封装以便在项目中统一管理样式和行为。

    本文将分享如何使用 useDialog Hook 封装 el-dialog,实现更灵活、更易用的弹窗组件。

    一、问题澄清

    「将一个通用的组件应用在多个页面」是一个很常见的实际场景。

    举个例子:以购买应用程序为例,用户可能在付费页面进行购买操作,也可能在浏览其他页面时触发购买需求,此时就需要弹出对话框引导用户完成购买行为。

    为了实现这一功能,过去通常会采用以下步骤:

    1. 封装购买组件:首先创建一个通用的购买组件,以便在不同页面和场景下复用。
    2. 在付费页面渲染购买组件:将购买组件直接嵌到付费页面中。
    3. 在其他页面使用 el-dialog 展示购买组件:在其他页面通过 el-dialog 控制组件的显示,利用 visible 状态变量(通常是一个 ref 响应式变量)动态控制对话框的弹出与关闭。

    虽然这种方式可以满足功能需求,但随着该组件被越来越多的页面和功能所使用,维护也会愈加复杂繁琐——每增加一个使用页面,都必须重复编写控制显示/隐藏的逻辑代码。

    那么,有没有更好的方法可以简化这个过程?是否可以通过某种方式,用一个单独的函数全局控制购买组件的打开和关闭,从而减少代码重复,降低维护成本?

    二、关于 useDialog Hook

    在 Vue 中,Hook 允许在函数式组件或者 API 中「钩入」Vue 特性。它们通常在组合式 API(Composition API)中使用,这是 Vue 提供的一套响应式和可复用逻辑功能的集合。

    本文提到的 useDialog Hook 就是一个封装了 el-dialog 组件基本功能的自定义 Hook,它还可以提供附加特性以便在项目中管理和展示弹窗。

    三、实现 useDialog Hook

    useDialog Hook 需要达成以下目标:

    1. 满足基础用法,传入 el-dialog 的基础属性以及默认slot显示的内容,导出 openDialogcloseDialog 函数;
    2. 支持 el-dialog 的事件配置;
    3. 支持默认 slot 组件的属性配置;
    4. 支持 el-dialog 其他 slot 配置,例如 headerfooter 等;
    5. 在内容组件中抛出特定事件支持关闭 dialog;
    6. 支持显示内容为 jsx普通文本Vue Component
    7. 支持在显示内容中控制是否可以关闭的回调函数,例如 beforeClose
    8. 支持显示之前钩子,例如 onBeforeOpen
    9. 支持定义和弹出时修改配置属性;
    10. 支持继承 root vue 的 prototype,可以使用如 vue-i18n$t 函数;
    11. 支持 ts 参数提示;

    (一)准备 useDialog.ts 文件实现类型定义

    import type { Ref } from 'vue'
    import { h, render } from 'vue'
    import { ElDialog } from 'element-plus'
    import type {
      ComponentInternalInstance,
    } from '@vue/runtime-core'
    
    type Content = Parameters<typeof h>[0] | string | JSX.Element
    // 使用 InstanceType 获取 ElDialog 组件实例的类型
    type ElDialogInstance = InstanceType<typeof ElDialog>
    
    // 从组件实例中提取 Props 类型
    type DialogProps = ElDialogInstance['$props'] & {
    }
    interface ElDialogSlots {
      header?: (...args: any[]) => Content
      footer?: (...args: any[]) => Content
    }
    interface Options<P> {
      dialogProps?: DialogProps
      dialogSlots?: ElDialogSlots
      contentProps?: P
    }

    (二)实现普通 useDialog 函数

    下面的函数实现了含目标 1、2、3、4、6 和 11 在内的基础用法。

    目标 1:满足基础用法,传入 el-dialog 基础属性及默认 slot 显示的内容,导出 openDialogcloseDialog 函数;
    目标 2:支持 el-dialog 的事件配置;
    目标 3.:支持默认 slot 组件的属性配置;
    目标 4:支持 el-dialog 其他 slot 配置,如 headerfooter 等;
    目标 6:支持显示内容为 jsx普通文本Vue Component
    目标 11:支持 ts 参数提示;

    export function useDialog<P = any>(content: Content, options?: Ref<Options<P>> | Options<P>) {
      let dialogInstance: ComponentInternalInstance | null = null
      let fragment: Element | null = null
    
      // 关闭并卸载组件
      const closeAfter = () => {
        if (fragment) {
          render(null, fragment as unknown as Element) // 卸载组件
          fragment.textContent = '' // 清空文档片段
          fragment = null
        }
        dialogInstance = null
      }
      function closeDialog() {
        if (dialogInstance)
          dialogInstance.props.modelValue = false
      }
    
      // 创建并挂载组件
      function openDialog() {
        if (dialogInstance) {
          closeDialog()
          closeAfter()
        }
    
        const { dialogProps, contentProps } = options
        fragment = document.createDocumentFragment() as unknown as Element
    
        const vNode = h(ElDialog, {
          ...dialogProps,
          modelValue: true,
          onClosed: () => {
            dialogProps?.onClosed?.()
            closeAfter()
          },
        }, {
          default: () => [typeof content === 'string'
            ? content
            : h(content as any, {
              ...contentProps,
            })],
          ...options.dialogSlots,
        })
        render(vNode, fragment)
        dialogInstance = vNode.component
        document.body.appendChild(fragment)
      }
    
      onUnmounted(() => {
        closeDialog()
      })
    
      return { openDialog, closeDialog }
    }

    (三)实现目标 5

    目标 5:在内容组件中抛出特定事件支持关闭 dialog;

    1. 在定义中支持 closeEventName ;
    interface Options<P> {
      // ...
      closeEventName?: string // 新增的属性
    }
    1. 修改 useDialog 函数接收 closeEventName 事件关闭 dialog。
    export function useDialog<P = any>(content: Content, options?: Ref<Options<P>> | Options<P>) {
      // ...
      // 创建并挂载组件
      function openDialog() {
        // ...
        fragment = document.createDocumentFragment() as unknown as Element
        // 转换closeEventName事件
        const closeEventName = `on${upperFirst(_options?.closeEventName || 'closeDialog')}`
    
        const vNode = h(ElDialog, {
          // ...
        }, {
          default: () => [typeof content === 'string'
            ? content
            : h(content as any, {
              ...contentProps,
              [closeEventName]: closeDialog, // 监听自定义关闭事件,并执行关闭
            })],
          ...options.dialogSlots,
        })
        render(vNode, fragment)
        dialogInstance = vNode.component
        document.body.appendChild(fragment)
      }
    
      onUnmounted(() => {
        closeDialog()
      })
    
      return { openDialog, closeDialog }
    }

    (四)实现目标 7、8

    目标 7:支持在显示内容中控制是否可以关闭的回调函数,例如 beforeClose
    目标 8:支持显示之前钩子,例如 onBeforeOpen

    1. 在定义中支持 onBeforeOpenbeforeCloseDialog 默认传给内容组件,有组件调用设置;
    type DialogProps = ElDialogInstance['$props'] & {
      onBeforeOpen?: () => boolean | void
    }
    1. 修改 useDialog 函数接收 onBeforeOpen 事件并传递 beforeCloseDialog
    export function useDialog<P = any>(content: Content, options?: Ref<Options<P>> | Options<P>) {
      // ...
      // 创建并挂载组件
      function openDialog() {
        // ...
        const { dialogProps, contentProps } = options
        // 调用before钩子,如果为false则不打开
        if (dialogProps?.onBeforeOpen?.() === false) {
          return
        }
        // ...
        // 定义当前块关闭前钩子变量
        let onBeforeClose: (() => Promise<boolean | void> | boolean | void) | null
    
        const vNode = h(ElDialog, {
          // ...
          beforeClose: async (done) => {
            // 配置`el-dialog`的关闭回调钩子函数
            const result = await onBeforeClose?.()
            if (result === false) {
              return
            }
            done()
          },
          onClosed: () => {
            dialogProps?.onClosed?.()
            closeAfter()
            // 关闭后回收当前变量
            onBeforeClose = null
          },
        }, {
          default: () => [typeof content === 'string'
            ? content
            : h(content as any, {
              // ...
              beforeCloseDialog: (fn: (() => boolean | void)) => {
                // 把`beforeCloseDialog`传递给`content`,当组件内部使用`props.beforeCloseDialog(fn)`时,会把fn传递给`onBeforeClose`
                onBeforeClose = fn
              },
            })],
          ...options.dialogSlots,
        })
        render(vNode, fragment)
        dialogInstance = vNode.component
        document.body.appendChild(fragment)
      }
    
      onUnmounted(() => {
        closeDialog()
      })
    
      return { openDialog, closeDialog }
    }

    (五)实现目标 9、10

    目标 9:支持定义和弹出时修改配置属性;
    目标 10:支持继承 root vue 的 prototype,可以使用例如 vue-i18n$t 函数;

    // 定义工具函数,获取计算属性的option
    function getOptions<P>(options?: Ref<Options<P>> | Options<P>) {
      if (!options)
        return {}
      return isRef(options) ? options.value : options
    }
    
    export function useDialog<P = any>(content: Content, options?: Ref<Options<P>> | Options<P>) {
      // ...
      // 获取当前组件实例,用于设置当前dialog的上下文,继承prototype
      const instance = getCurrentInstance()
      // 创建并挂载组件,新增`modifyOptions`参数
      function openDialog(modifyOptions?: Partial<Options<P>>) {
        // ...
        const _options = getOptions(options)
        // 如果有修改,则合并options。替换之前的options变量为 _options
        if (modifyOptions)
          merge(_options, modifyOptions)
    
        // ...
    
        const vNode = h(ElDialog, {
          // ...
        }, {
          // ...
        })
        // 设置当前的上下文为使用者的上下文
        vNode.appContext = instance?.appContext || null
        render(vNode, fragment)
        dialogInstance = vNode.component
        document.body.appendChild(fragment)
      }
    
      onUnmounted(() => {
        closeDialog()
      })
    
      return { openDialog, closeDialog }
    }

    通过上面的封装使用 useDialog Hook 后,需要弹窗时,只需要引入该 Hook 并调用 openDialog 方法,非常方便简洁。此外,这样的封装也会让后续修改弹窗逻辑变得更加方便,只需要在 useDialog Hook 中修改,无需逐个重复编辑。

    四、useDialog Hook 案例实操

    下面,我们使用 useDialog Hook 来解决开头提到的应用程序购买问题。

    (一)创建 components/buy.vue 购买组件

    <script lang="ts" setup>
      const props = defineProps({
        from: {
          type: String,
          default: '',
        },
      })
    </script>
    <template>
      我是购买组件
    </template>

    (二)在 pages/subscription.vue 页面中使用 buy.vue 购买组件

    <script lang="ts" setup>
      import Buy from '@/components/buy.vue'
    </script>
    <template>
    
      <Buy from="subscription" />
    
    </template>

    (三)在其他功能页面中弹出 buy.vue 购买组

    <script lang="ts" setup>
      import { useDialog } from '@/hooks/useDialog'
      const Buy = defineAsyncComponent(() => import('@/components/buy.vue'))
    
      const { openDialog } = useDialog(Buy, {
        dialogProps: {
          // ...
          title: '购买'
        },
        contentProps: {
          from: 'function',
        },
      })
    
      const onSomeClick = () => {
        openDialog()
      }
    </script>

    拓展:useDialog Hook 的其他应用

    beforeClose & closeEventName 示例:buy.vue 购买组件

    <script lang="ts" setup>
      const props = defineProps({
        from: {
          type: String,
          default: '',
        },
        beforeCloseDialog: {
          type: Function,
          default: () => true,
        },
      })
    
      const emit = defineEmits(['closeDialog'])
    
      props.beforeCloseDialog(() => {
        // 假如from 为 空字符串不能关闭
        if (!props.from) {
          return false
        }
        return true
      })
    
      // 关闭dialog
      const onBuySuccess = () => emit('closeDialog')
    </script>
    <script lang="ts" setup>
      import { useDialog } from '@/hooks/useDialog'
      const Buy = defineAsyncComponent(() => import('@/components/buy.vue'))
    
      const { openDialog } = useDialog(Buy, {
        dialogProps: {
          // ...
          title: '购买'
        },
        contentProps: {
          from: '',
        },
      })
    
      const onSomeClick = () => {
        openDialog()
      }
    </script>

    总结

    使用 useDialog Hook 封装 el-dialog 可以让前端技术更加有趣简洁。笔者也希望大家能尝试这样的封装方式,让前端代码更加优雅且易于维护。

    优秀的工程师就同优秀的厨师一样,掌握了精妙的烹饪和调味技巧,就能让每道菜都变得美味可口!


    >> LigaAI 往期精彩阅读 <<

    LigaAI x 极狐GitLab,共探 AI 时代研发提效新范式

    宁波银行:在「金融科技」引擎上,沉浸式提效减负

    生成式 AI 如何释放开发者的生产力?

    新晋技术管理者如何推动组织变革?

    欢迎试用 LigaAI-智能研发协作平台,体验智能研发协作,一起变大变强!

    ]]>
    技术分享 | ChatGPT API 调用总超时?解决方案在这 https://ligai.cn/blog/sharing/1334.html Wed, 13 Dec 2023 04:50:57 +0000 https://ligai.cn/blog/?p=1334 阅读更多]]> 问题重现

    在调用 ChatGPT API 并使用流式输出时,我们经常会遇到网络问题导致的超时情况。有趣的是,笔者发现在本地调试遇到的超时,会在 10 分钟后自动恢复(为什么是 10 分钟?我们留到后面解释),但是在服务器上等待一会儿却会失败,报出超时异常(错误代码 502)。

    笔者认为,本地能恢复的原因可能是自动重试,只是重试的时间有点久(ChatGPT API 没有重试功能,这是项目加入的)。服务器返回「502」是因为内容从后台返回到前端需要经过网关层,而网关层超时校验的时间比自动重试的时间(10 分钟)更短,所以撑不到重试就会报超时异常。

    基于以上场景,本文着手解决 ChatGPT API 调用超时问题。

    优化诉求

    • 不向用户展示超时的报错信息。
    • 缩短超时后重试的时间间隔。

    解决思路

    笔者考虑了两种方案。

    一是彻底解决网络问题,但难度有点大。 这属于 OpenAI 服务器问题,即使是部署在国外的服务器也会出现超时的情况。

    二是利用自动重试解决问题。 通过调整超时的时间,提升响应速度,方案可行。

    实施解决方案

    解决过程中,笔者分两步由浅至深地调整了超时时间;如果想直接了解最终方案,请移步「解决方案二」~

    • 运行环境:

    Python: 3.10.7

    openai: 0.27.6

    • 调用方法:

    openai.api_resources.chat_completion.ChatCompletion.acreate

    ( 这是异步调用 ChatGPT 的方法。)

    • 方法调用链路:

    超时参数 ClientTimeout,一共有 4 个属性 totalconnectsock_readsock_connect

    # 方法 -> 超时相关参数
    openai.api_resources.chat_completion.ChatCompletion.acreate -> kwargs
    openai.api_resources.abstract.engine_api_resource.EngineAPIResource.acreate -> params
    openai.api_requestor.APIRequestor.arequest -> request_timeout
    # request_timeout 在这一步变成了 timeout,因此,只需要传参 request_timeout 即可
    openai.api_requestor.APIRequestor.arequest_raw -> request_timeout
    aiohttp.client.ClientSession.request -> kwargs
    aiohttp.client.ClientSession._request -> timeout
        tm = TimeoutHandle(self._loop, real_timeout.total) -> ClientTimeout.total
        async with ceil_timeout(real_timeout.connect): -> ClientTimeout.connect
    # 子分支1
    aiohttp.connector.BaseConnector.connect -> timeout
    aiohttp.connector.TCPConnector._create_connection -> timeout
    aiohttp.connector.TCPConnector._create_direct_connection -> timeout
    aiohttp.connector.TCPConnector._wrap_create_connection -> timeout
        async with ceil_timeout(timeout.sock_connect): -> ClientTimeout.sock_connect
    # 子分支2
    aiohttp.client_reqrep.ClientRequest.send -> timeout
    aiohttp.client_proto.ResponseHandler.set_response_params -> read_timeout
    aiohttp.client_proto.ResponseHandler._reschedule_timeout -> self._read_timeout
        if timeout:
        self._read_timeout_handle = self._loop.call_later(
            timeout, self._on_read_timeout
        ) -> ClientTimeout.sock_read

    解决方案一

    openai.api_requestor.APIRequestor.arequest_raw 方法中的 request_timeout 参数可以传递 connecttotal 参数.

    因此可以在调用 openai.api_resources.chat_completion.ChatCompletion.acreate时,设置 request_time(10, 300)

    #
    async def arequest_raw(
        self,
        method,
        url,
        session,
        *,
        params=None,
        supplied_headers: Optional[Dict[str, str]] = None,
        files=None,
        request_id: Optional[str] = None,
        request_timeout: Optional[Union[float, Tuple[float, float]]] = None,
    ) -> aiohttp.ClientResponse:
        abs_url, headers, data = self._prepare_request_raw(
            url, supplied_headers, method, params, files, request_id
        )
    
        if isinstance(request_timeout, tuple):
            timeout = aiohttp.ClientTimeout(
                connect=request_timeout[0],
                total=request_timeout[1],
            )else:
                timeout = aiohttp.ClientTimeout(
                    total=request_timeout if request_timeout else TIMEOUT_SECS
                )
        ...

    该方案有效,但没有完全生效:它可以控制连接时间和请求的全部时间,但没有彻底解决超时异常,因为「请求连接时间」和「第一个字符读取时间」是两码事。「请求连接时间」基于 total 时间重试(300s),而网关时间并没有设置这么久。

    于是,笔者继续提出「解决方案二」。

    解决方案二

    使用 monkey_patch 方式重写 openai.api_requestor.APIRequestor.arequest_raw 方法,重点在于重写 request_timeout 参数,让其支持原生的 aiohttp.client.ClientTimeout 参数。

    1. 新建 api_requestor_mp.py 文件,并写入以下代码。

    # 注意 request_timeout 参数已经换了,Optional[Union[float, Tuple[float, float]]] -> Optional[Union[float, tuple]]
    async def arequest_raw(
            self,
            method,
            url,
            session,
            *,
            params=None,
            supplied_headers: Optional[Dict[str, str]] = None,
            files=None,
            request_id: Optional[str] = None,
            request_timeout: Optional[Union[float, tuple]] = None,
    ) -> aiohttp.ClientResponse:
        abs_url, headers, data = self._prepare_request_raw(
            url, supplied_headers, method, params, files, request_id
        )
        # 判断 request_timeout 的类型,按需设置 sock_read 和 sock_connect 属性
        if isinstance(request_timeout, tuple):
            timeout = aiohttp.ClientTimeout(
                connect=request_timeout[0],
                total=request_timeout[1],
                sock_read=None if len(request_timeout) < 3 else request_timeout[2],
                sock_connect=None if len(request_timeout) < 4 else request_timeout[3],
            )
        else:
            timeout = aiohttp.ClientTimeout(
                total=request_timeout if request_timeout else TIMEOUT_SECS
            )
        if files:
            # TODO: Use aiohttp.MultipartWriter to create the multipart form data here.
            # For now we use the private requests method that is known to have worked so far.
            data, content_type = requests.models.RequestEncodingMixin._encode_files(  # type: ignore
                files, data
            )
            headers["Content-Type"] = content_type
        request_kwargs = {
            "method": method,
            "url": abs_url,
            "headers": headers,
            "data": data,
            "proxy": _aiohttp_proxies_arg(openai.proxy),
            "timeout": timeout,
        }
        try:
            result = await session.request(**request_kwargs)
            util.log_info(
                "OpenAI API response",
                path=abs_url,
                response_code=result.status,
                processing_ms=result.headers.get("OpenAI-Processing-Ms"),
                request_id=result.headers.get("X-Request-Id"),
            )
            # Don't read the whole stream for debug logging unless necessary.
            if openai.log == "debug":
                util.log_debug(
                    "API response body", body=result.content, headers=result.headers
                )
                return result
            except (aiohttp.ServerTimeoutError, asyncio.TimeoutError) as e:
                raise error.Timeout("Request timed out") from e
            except aiohttp.ClientError as e:
                raise error.APIConnectionError("Error communicating with OpenAI") from e
    
    def monkey_patch():
        APIRequestor.arequest_raw = arequest_raw

    2. 在初始化 ChatGPT API 的文件头部补充:

    from *.*.api_requestor_mp import monkey_patch
    
    do_api_requestor = monkey_patch

    设置参数 request_timeout=(10, 300, 15, 10) 后,再调试就没什么问题了。

    交付测试,通过。

    经验总结

    • 直接看代码、看方法调用链路会有点困难,可以通过异常堆栈来找调用链路,这样更方便。
    • ChatGPT API 暴露的 request_timeout 参数不够用,需要重写;搜索了一下重写方案,了解到 monkey_patch,非常实用。
    • 项目过程中,笔者发现改代码本身不难,难的是知道「改哪里」「怎么改」以及「为什么」。

    >> LigaAI 往期精彩阅读 <<

    在 IDE 插件开发中接入 JCEF 框架 – LigaAI 团队博客 (ligai.cn)

    准「AI 时代」下,如何衡量程序员的工作效率和生产力?

    生成式 AI 如何释放开发者的生产力?

    新晋技术管理者如何推动组织变革?

    欢迎试用 LigaAI-智能研发协作平台,体验智能研发协作,一起变大变强!

    ]]>
    技术分享 | 在 IDE 插件开发中接入 JCEF 框架 https://ligai.cn/blog/sharing/1328.html Fri, 24 Nov 2023 03:52:23 +0000 https://ligai.cn/blog/?p=1328 阅读更多]]> 项目背景

    当前的开发环境存在多种不同语言的 IDE,如 JetBrains 全家桶、Eclipse、Android Studio 和 VS Code 等等。由于每个 IDE 各有其特定的语言和平台要求,因此开发 IDE 插件时,需要投入大量资源才能尽可能覆盖大部分工具。同时,代码难复用、用户体验难统一等问题又会进一步加重资源负担。

    在调研过程中,我们发现如今的大多数开发工具都支持集成 CEF,而 CEF 提供的跨平台解决方案正可以有效解决上述问题。

    关于 CEF 和 JCEF

    CEF(Chromium Embedded Framework)是一个开源项目,它基于 Google Chromium 浏览器引擎,允许开发人员将完整的浏览器功能嵌入到自己的应用程序中。

    通过 CEF,开发者可以利用现代 Web 技术来创建强大的桌面应用程序,并实现与 Web 内容的无缝集成。如此一来,开发者便可以利用 CEF 的功能和灵活性,为各种开发工具提供统一的、高质量的插件体验。

    JCEF(Java Chromium Embedded Framework)是基于 CEF 的一个特定版本,专门为 Java 应用程序而生。本文内容也主要围绕 JCEF 展开。

    JCEF 和其他产品的对比

    • JCEF vs JxBrowser

    JxBrowser 和 JCEF 都允许将 Chromium 浏览器功能嵌入到 Java 应用程序中。其中,JxBrowser 是商业产品,而 JCEF 是开源框架,且商业授权非常友好。

    此外,JxBrowser 在独立的本地进程中启动 Chromium,而 JCEF 则是在 Java 进程内启动。JCEF 会快速初始化 Chromium,同时消耗 Java 进程的内存和 CPU;创建多个 Chromium 实例也会占用更多资源。

    • JCEF vs JavaFX

    JavaFX 使用的内置浏览器组件是 WebView,其在不同平台上的实现有所不同。例如,在 macOS 上使用 WebKit,在 Windows 上默认为 Internet Explorer,而新版本的 JavaFX 则默认使用 JCEF。

    这种不一致性会增加插件适配的难度,降低整体开发效率。

    Java 进程与 JCEF 交互

    如何在 IDE 插件中接入 JCEF?

    下面以 LigaAI Jetbrains 插件为例,介绍集成 JCEF 的过程。

    1. 在 Java 代码里创建相应的 JcefBrowser
    static JBCefBrowser createBrowser(Project project) {
        JBCefClient client = JBCefApp.getInstance().createClient();
        //CefMessageRouter 用于处理来自 Chromium 浏览器的消息和事件,
        //前端代码可以通过innerCefQuery和innerCefQueryCancel发起消息给插件进行处理
        CefMessageRouter.CefMessageRouterConfig routerConfig =
                new CefMessageRouter.CefMessageRouterConfig("innerCefQuery", "innerCefQueryCancel");
        CefMessageRouter messageRouter = CefMessageRouter.create(routerConfig, new MessageRouterHandler());
        client.getCefClient().addMessageRouter(messageRouter);
        //用于处理以http://inner/开头的请求。 用于拦截特定请求,转发请求到本地以获取本地资源
        CefApp.getInstance()
                .registerSchemeHandlerFactory("http", "inner", new DataSchemeHandlerFactory());
        return new JBCefBrowser(client, "");
    }
    1. 加载对应的 URL,渲染页面。
    public static void loadURL(JBCefBrowser browser, String url) {
        //如果不需要设置和浏览器显示相关的,可忽略
        browser.getJBCefClient()
                .addDisplayHandler(settingsDisplayHandler, browser.getCefBrowser());
        browser.loadURL(url);                 
    }
    1. Java 进程拦截前端发起的获取静态资源的请求。如果直接访问外部资源,则不需要做拦截,这一步可忽略。
    import com.intellij.liga.web.WebviewClosedConnection;
    import com.intellij.liga.web.WebviewOpenedConnection;
    import com.intellij.liga.web.WebviewResourceState;
    import com.intellij.openapi.vfs.VfsUtil;
    import com.intellij.openapi.vfs.VirtualFile;
    import org.apache.commons.lang.StringUtils;
    import org.cef.callback.CefCallback;
    import org.cef.handler.CefResourceHandler;
    import org.cef.misc.IntRef;
    import org.cef.misc.StringRef;
    import org.cef.network.CefRequest;
    import org.cef.network.CefResponse;
    
    import java.io.File;
    import java.net.URL;
    
    
    //继承 CefResourceHandler 接口,自定义处理 Chromium 浏览器加载的资源(如网页、图像、样式表等)。
    //通过实现该接口,可以覆盖默认的资源加载行为,并提供自定义的资源加载逻辑。
    public class DataResourceHandler implements CefResourceHandler {
    
        private WebviewResourceState state;
    
        /**
        * 用于处理资源请求,你可以通过该方法获取请求的 URL、请求头部信息,并返回相应的响应结果。
        */
        public boolean processRequest(CefRequest cefRequest, CefCallback cefCallback) {
            String url = cefRequest.getURL();
            //判断请求是否是用于获取内部静态资源的,如果是则拦截请求,并从项目里对应配置获取对应文件返回
            //如果是请求外部资源,则跳过
            if (StringUtils.isNotBlank(url) && url.startsWith("http://inner")) {
                String pathToResource = url.replace("http://inner", "/front/inner");
                pathToResource = pathToResource.split("\\?")[0];
                URL resourceUrl = getClass().getResource(pathToResource);
                VirtualFile f = VfsUtil.findFileByURL(resourceUrl);
                resourceUrl = VfsUtil.convertToURL(f.getUrl());
                try {
                    this.state = (WebviewResourceState) new WebviewOpenedConnection(resourceUrl.openConnection());
                } catch (Exception exception) {
                    //log output
                }
                cefCallback.Continue();
                return true;
            }
            return false;
        }
    
        /**
        * 用于设置资源响应的头部信息,例如 Content-Type、Cache-Control 等。
        */
        public void getResponseHeaders(CefResponse cefResponse, IntRef responseLength, StringRef redirectUrl) {
            this.state.getResponseHeaders(cefResponse, responseLength, redirectUrl);
        }
    
        /**
        * 用于读取资源的内容,可以从这个方法中读取资源的数据并将其传递给浏览器
        */
        public boolean readResponse(byte[] dataOut, int designedBytesToRead, IntRef bytesRead, CefCallback callback) {
            return this.state.readResponse(dataOut, designedBytesToRead, bytesRead, callback);
        }
    
        /**
        * 请求取消
        */
        public void cancel() {
            this.state.close();
            this.state = (WebviewResourceState) new WebviewClosedConnection();
        }
    
    }
    
    //定义处理 Chromium Embedded Framework (CEF) 中的 Scheme(协议)请求
    public class DataSchemeHandlerFactory implements CefSchemeHandlerFactory {
        public CefResourceHandler create(CefBrowser cefBrowser, CefFrame cefFrame, String s, CefRequest cefRequest) {
            return new DataResourceHandler();
        }
    }
    
    
    
    import org.cef.callback.CefCallback;
    import org.cef.handler.CefLoadHandler;
    import org.cef.misc.IntRef;
    import org.cef.misc.StringRef;
    import org.cef.network.CefResponse;
    
    import java.io.InputStream;
    import java.net.URLConnection;
    
    public class WebviewOpenedConnection implements WebviewResourceState {
        private URLConnection connection;
    
        private InputStream inputStream;
    
        public WebviewOpenedConnection(URLConnection connection) {
            this.connection = connection;
            try {
                this.inputStream = connection.getInputStream();
            } catch (Exception exception) {
                System.out.println(exception);
            }
        }
    
        public void getResponseHeaders(CefResponse cefResponse, IntRef responseLength, StringRef redirectUrl) {
            try {
                String url = this.connection.getURL().toString();
                cefResponse.setMimeType(this.connection.getContentType());
                try {
                    responseLength.set(this.inputStream.available());
                    cefResponse.setStatus(200);
                } catch (Exception e) {
                    cefResponse.setError(CefLoadHandler.ErrorCode.ERR_FILE_NOT_FOUND);
                    cefResponse.setStatusText(e.getLocalizedMessage());
                    cefResponse.setStatus(404);
                }
            } catch (Exception e) {
                cefResponse.setError(CefLoadHandler.ErrorCode.ERR_FILE_NOT_FOUND);
                cefResponse.setStatusText(e.getLocalizedMessage());
                cefResponse.setStatus(404);
            }
        }
    
        public boolean readResponse(byte[] dataOut, int designedBytesToRead, IntRef bytesRead, CefCallback callback) {
            try {
                int availableSize = this.inputStream.available();
                if (availableSize > 0) {
                    int maxBytesToRead = Math.min(availableSize, designedBytesToRead);
                    int realNumberOfReadBytes = this.inputStream.read(dataOut, 0, maxBytesToRead);
                    bytesRead.set(realNumberOfReadBytes);
                    return true;
                }
            } catch (Exception exception) {
                //log output
            } finally {
                this.close();
            }
            return false;
        }
    
        public void close() {
            try {
                if (this.inputStream != null)
                    this.inputStream.close();
            } catch (Exception exception) {
                //log output
            }
        }
    }
    1. 前端发送请求调用插件,Java 进程接收并处理。
    //前端示例代码
    <button onclick="callBrowser()">调用浏览器代码</button>
    
    <script>
    function callBrowser() {
      var parameter = "example parameter";
      window.location.href = "innerCefQuery://" + parameter;
    }
    </script>
    
    
    //插件示例代码
    import org.cef.browser.CefBrowser;
    import org.cef.browser.CefFrame;
    import org.cef.callback.CefQueryCallback;
    import org.cef.handler.CefMessageRouterHandlerAdapter;
    
    public class MessageRouterHandler extends CefMessageRouterHandlerAdapter {
        @Override
        public boolean onQuery(CefBrowser browser, CefFrame frame, long query_id, String request,
                               boolean persistent, CefQueryCallback callback) {
            try {
                System.out.println(request);
                callback.success("");
                return true;
            } catch (Exception e) {
                //log output
            }
            return false;
        }
    }
    1. 插件前端代码。
    // java进程调用前端代码
    String script = "window.postMessage('" + JSONObject.toJSONString(scriptObj) + "');";
    browser.executeJavaScript(script, "", 0);
    
    // 前端代码
    function postMessage(data) {
      // 处理从后端传递过来的数据
      console.log('Received message from backend:', data);
      // 在这里进行你希望执行的其他操作
    }

    实现效果

    通过使用 LigaAI IDE 插件,开发者们无需跳转或登录外部系统,在 IDE 内就能查看任务详情、完成工作、更新和同步任务状态、记录并提报完成信息;在享受沉浸式工作的同时,零负担地实现个人目标管理。

    此外,JCEF 为插件开发者提供了一个强大的工具,可以利用 Chromium 浏览器的各种功能和扩展性,以更丰富、更高级的方式提供信息和功能,使编码过程变得容易。

    因此,利用 LigaAI IDE 插件提供的可视化图表,研发团队还可以了解整体编码情况、不同任务类型的耗时分布等,更有针对性地制定优化方案,或调整规划排期。

    常见问题及避坑指南

    1:集成 JCEF,如何使 Web 样式与 IDE 插件整体样式保持统一?

    通过下述方法获取 IDE 的主题模式;

    public static String getGlobalStyle() {
        if (EditorColorsManager.getInstance().isDarkEditor())
            return "dark";
        return "light";
    }

    获取 IDE 内的样式。

    //主要可以查看com.intellij.util.ui.UIUtil和com.intellij.ui.JBColor这两个类
    //获取字体大小
    Font font = UIUtil.getLabelFont();
    //获取背景颜色
    Color bg = JBColor.background();
    //获取字体颜色
    Color labelFontColor = UIUtil.getLabelFontColor(UIUtil.FontColor.NORMAL);
    //获取按钮的背景颜色
    JBColor buttonBg = JBColor.namedColor("Button.default.startBackground",JBUI.CurrentTheme.Focus.defaultButtonColor());
    //获取边框的颜色
    Color border = JBColor.border();

    2:Java 和浏览器之间的交互路由名称不能设置为 cefQuerycefQueryCancel

    这两个为 JCEF 的内置路由,同名会干扰甚至覆盖 JCEF 的内部处理逻辑,有一定概率会导致系统白屏等意外行为和异常情况。

    CefMessageRouter.CefMessageRouterConfig routerConfig =
                new CefMessageRouter.CefMessageRouterConfig("innerCefQuery", "innerCefQueryCancel");

    3:于 JetBrains 插件而言,如果浏览器加载的静态页面数据是打包在插件包内的本地数据,加载过程中获取目标 URL 需要先把目标文件转化为 JetBrains 的虚拟文件,再获取虚拟文件的 URL 作为结果,不然会加载不到目标文件。

    public boolean processRequest(CefRequest cefRequest, CefCallback cefCallback) {
        String url = cefRequest.getURL();
        if (StringUtils.isNotBlank(url) && url.startsWith("http://inner")) {
            String pathToResource = url.replace("http://inner", "/front/inner");
            pathToResource = pathToResource.split("\\?")[0];
            // 这里先获取目标文件,转成虚拟文件,再获取对应URL
            URL resourceUrl = getClass().getResource(pathToResource);
            VirtualFile f = VfsUtil.findFileByURL(resourceUrl);
            resourceUrl = VfsUtil.convertToURL(f.getUrl());
            //
            try {
                this.state = (WebviewResourceState) new WebviewOpenedConnection(resourceUrl.openConnection());
            } catch (Exception exception) {
            }
            cefCallback.Continue();
            return true;
        }
        return false;
    }

    4:插件初始化时,如果浏览器请求 java 的接口较多,或接口速度较慢时,可能会出现白屏。这是因为 onQuery 里复杂的逻辑需要异步处理,不然多个请求会阻塞导致浏览器白屏。

    public class MessageRouterHandler extends CefMessageRouterHandlerAdapter {
        @Override
        public boolean onQuery(CefBrowser browser, CefFrame frame, long query_id, String request,
                               boolean persistent, CefQueryCallback callback) {
            try {
                ApplicationManager.getApplication().invokeLater(() -> {
                    //进行复杂的逻辑
                });
                callback.success("");
                return true;
            } catch (Exception e) {
                //log output
            }
            return false;
        }
    }

    参考资料

    [1] CEF 相关文档:https://github.com/chromiumembedded/cef

    [2] JCEF 源码位置: https://github.com/chromiumembedded/java-cef

    [3] Jetbrains 插件开发文档:https://plugins.jetbrains.com/docs/intellij/welcome.html

    [4] JxBrowser 和 JCEF 的对比:https://dzone.com/articles/jxbrowser-and-jcef


    >> LigaAI 往期精彩阅读 <<

    准「AI 时代」下,如何衡量程序员的工作效率和生产力?

    生成式 AI 如何释放开发者的生产力?

    新晋技术管理者如何推动组织变革?

    向上管理:三个技巧,教会你如何与上级、老板高效协作

    欢迎试用 LigaAI-智能研发协作平台,体验智能研发协作,一起变大变强!

    ]]>
    「程序员转型技术管理」必修的 10 个能力提升方向 https://ligai.cn/blog/sharing/1278.html Fri, 08 Sep 2023 02:51:32 +0000 https://ligai.cn/blog/?p=1278 阅读更多]]> 对许多开发者而言,深耕技术,然后成为技术专家或许是职业发展的唯一答案。但如果你赞同「软件开发只是我众多职业目标中的一个」,也许你可以试试「技术管理之路」。

    我原来觉得和计算机打交道比跟人打交道轻松得多,所以我成了一名软件开发者。一段时间后,我发现自己越来越多地在给别人提供帮助;我喜欢领导项目,热衷于推动更好的代码标准。于是,我几乎毫无挣扎地成为了一名技术管理者。

    尽管这些年,外界有许多声音反复提及「技术管理转型」,但大多数开发者并不清楚「从技术到管理,我需要作出哪些适应和改变?」

    如果你想要尝试探索技术管理之路,首先请你坦诚地回答几个问题:

    • 为什么想要当技术管理者?
    • 想成为哪种技术管理者?想对人负责,对项目负责,还是对业务负责?
    • 你的转型动力是什么?是编写代码和构建软件吗?还是帮他人获得更好的结果、与利益相关者协商交付时间、说服业务团队代码重构并非浪费光阴?

    如果你现在仍然确信技术管理很适合你,那么你需要为此做一些准备——与上层领导或者导师合作,在不甚熟悉的领域向他们寻求帮助。下面介绍十个重点提升方向。

    01 技术领导力

    真正的领导者不需要头衔或权力也能领导团队。拥有华丽头衔或被组织赋予权力的任何人都可以发号施令,但这并不是领导力。领导力的真谛在于你的行动和行为。

    因此,你应该从小事做起:在困难的项目中承担更多责任、主动提供 PR 反馈、及时更新项目情况、对团队或产品工作流程贡献优化建议、为伙伴提供专业指导等等。

    大家不愿面对或因为缺乏专业知识和信心而无法抓住的机会有很多。先确定同事遇到了什么困难,然后挺身而出,主动帮助他们解决问题。

    02 主人翁精神

    技术管理者要勇于承担责任,对自己所做或没做的一切负责,并避免将错误、超时、缺陷等问题归咎于他人。

    出现问题或故障时,技术管理者应该主动、积极地协助修复工作,传授相关的解决和防范之策。为错误找借口或者满腹牢骚对任何人都没有益处,把时间花在交付承诺上吧。如有必要,可以同上级管理者协商一个更合适的交付日期。像经营自己的事业一样运营一个项目,真正地把它放在心上。

    最近,我团队中的一位技术负责人拉取了最新的 Master 分支,发现单元测试覆盖率大幅下降。他没有抱怨,而是先弥补了缺失部分,然后向团队介绍如何正确检查覆盖率,以及如何编写复杂功能的单元测试。有人需要帮助时,他会主动伸以援手,从不指责任何人;团队上下都对他赞赏有加。

    03 人际关系

    技术管理者的人际关系问题,也可以称作办公室政治。如果你不想处理「职场政治」,那一定要再三考虑「这个技术管理者非当不可吗?」

    建立有意义的关系是技术管理者的职责之一。管理,就是通过其他人把事情做成。你需要与其他技术负责人打好关系,因为他们有可能是你未来的战友。在技术分享上发表演讲、举办技术研讨会、指导团队以外的开发者都可以让你结交不少朋友。

    04 技术实力

    技术管理者首先是技术人,然后才是管理者。「成为团队中最厉害的开发者之一」几乎是技术管理者的一项硬性要求,因为不懂编程或不了解技术细节的人无法参与技术讨论。

    所以,除了强大的软件工程背景和实践经验,你还应该保持敏锐的技术嗅觉,并保持过硬的技术实力,以便胜任更高级别的任务。

    05 协作与指导

    团队中任何不具备团队合作精神的「优秀开发者」都是有害无益的。技术管理者总是帮助别人提升技术水平——结对编程、代码审查、演示、开源或者内部源代码项目都是很好的指导方式。

    在实际工作中,可能很少有人会主动向你寻求指导。没关系,机会全靠自己争取!你可以以技术专家的标准要求自己,主动地做上述事情;时间久了,自然会有人开始向你求助。通过为他人解惑,你将可以建立正向的人际关系并赢得团队尊重。

    06 项目管理能力

    保证项目能按时交付是所有管理者的核心职责之一。如果作为开发者,你总是错过交付时间,完不成研发任务,那其他成员就无法信任你。你必须有条不紊地完成工作才行。

    我们都知道软件项目存在很多不确定性,所以工作估算的难度很大。但通过正确的流程,准确估算也并非不可能——你可以不断与上层领导或利益相关者沟通项目进度,了解他们的期望。

    例如,我的团队每周都会做一次状态报告,让项目技术负责人有机会沟通进度、提出阻碍或延期交付的主要原因。

    07 沟通能力

    简洁、清晰的沟通是管理者的必备能力。如果你不能清楚地表达对团队的要求,那还没等工作开始,你的管理者生涯就宣告失败了。沟通的形式有很多种,包括口头的、书面的、甚至肢体语言。请始终致力于全面提高沟通技巧。

    我的团队也曾错过几个交付时间,因为我未能清晰、及时地传达要求。有几次,成员们都不知道谁该做些什么。后来我认识到,依靠项目经理或利益相关者说明项目细节是行不通的。技术管理者必须要自己了解项目,再向研发团队解释并营销项目,激发成员的工作热情。

    08 向上管理

    管理你的上层领导(有时需要管理他们的领导)。这意味着要不断与他们沟通,管理好他们的期望值。相信我,管理者不怎么喜欢意外,无论好坏。

    你需要与上层领导建立信任关系,成为重要的、首选的项目负责人,并按时如约地完成项目。

    09 冲突和矛盾

    无论进行了多少单元测试或集成测试,生产问题都会发生。你肯定希望最大程度地减少缺陷数量,但更重要的是妥善处理生产问题。一个遇到压力就自乱阵脚的人,在他人看来是没有资格担任管理者的。研发团队和其他技术负责人都希望见到一个抗压能力强、能够冷静把控一切的技术管理者。

    我曾经合作过一位非常冷静且情绪稳定的技术管理者。几乎没有任何冲突或压力可以让他崩溃,至少没有人见过他「鸭梨山大」的样子;哪怕凌晨三点要处理生产问题,他也绝不会让人失望。

    而另一位技术管理者总是对交付日期焦虑不已;新功能上线的那天,他还病倒了。他情绪不够稳定,所以周围的人都不愿意和他一起工作。

    这是两个完全相反的例子,但你应该可以猜出谁是更成功的技术负责人。

    10 产品愿景

    技术管理者应该知晓自己负责的每一件事的前因后果,并确保全体成员都能理解「为什么要做它」。

    你必须要传递清楚(通常需要多次说明)我们为什么要开展这个项目?为什么要让这些人参与其中?这个项目又将如何服务于大局,服务于企业/产品愿景?

    研发团队必须足够相信和认可要做的事情,才能有效地开展工作。

    从今天开始,阔步向前

    领导力不是一两个人的特权,所以不要等待,也别犹豫,今天就行动起来,建立自己的技术影响力;加油成为垂直领域的专家,并主动地向同事和伙伴提供帮助。

    努力提高沟通技巧,和当前或未来可能的战友建立良好关系。确保自己能明智地管理自己的时间,保证项目按时交付。

    不要忘记,领导力是以人为本的,所以要真诚地助人成长,让他们做得最好。

    (原文作者为 Alex Bachuk,内容经 LigaAI 翻译调整)


    >> LigaAI 往期精彩阅读 <<

    产品管理经验分享:删掉 500 个产品待办事项后,我逃离了「假敏捷」

    如何用 NPS 确定研发优先级,打破技术与业务的次元壁?

    这 4 个系统可靠性评估指标,可能比 MTTR 更靠谱!

    LigaAI:从效率、度量和价值维度,成为研发团队的智能医生

    了解更多开发者提效、研发效能管理、前沿技术等消息,欢迎关注 LigaAI。欢迎体验我们的产品,期待与你一路同行!

    ]]>
    技术分享 | 如何编写同时兼容 Vue2 和 Vue3 的代码? https://ligai.cn/blog/sharing/1221.html Fri, 02 Jun 2023 12:03:16 +0000 https://ligai.cn/blog/?p=1221 阅读更多]]> LigaAI 的评论编辑器、附件展示以及富文本编辑器都支持在 Vue2(Web)与 Vue3(VSCode、lDEA)中使用。这样不仅可以在不同 Vue 版本的工程中间共享代码,还能为后续升级 Vue3 减少一定阻碍。

    那么,同时兼容 Vue2 与 Vue3 的代码该如何实现?业务实践中又有哪些代码精简和优化的小技巧?让我们先从兼容代码的工程化讲起。

    1. 工程化:编写同时兼容 Vue2 与 Vue3 的代码

    原理上,兼容工作由两部分完成:

    • 编译阶段:负责根据使用的项目环境,自动选择使用 Vue2 或 Vue3 的 API。 使用时,只需要从 Vue-Demi 里面 import 需要使用的 API,就会自动根据环境进行切换;可以分为在浏览器中运行(IIFE)和使用打包工具(cjs、umd、esm)两种情况。
    • 运行阶段:转换 createElement 函数的参数,使 Vue2 与 Vue3 的参数格式一致。 Vue2 和 Vue3 Composition API 的区别非常小,运行时 API 最大的区别在于 createElement 函数的参数格式不一致,Vue3 换成了 React JSX 格式。

    1.1 编译阶段——IIFE

    window中定义一个 VueDemi 变量,然后检查 window 中的 Vue 变量的版本,根据版本 reexport 对应的 API。

    var VueDemi = (function (VueDemi, Vue, VueCompositionAPI) {  
      // Vue 2.7 有不同,这里只列出 2.0 ~ 2.6 的版本
      if (Vue.version.slice(0, 2) === '2.') {
        for (var key in VueCompositionAPI) {
              VueDemi[key] = VueCompositionAPI[key]    
        }    
        VueDemi.isVue2 = true
      } else if (Vue.version.slice(0, 2) === '3.') {
        for (var key in Vue) {
        VueDemi[key] = Vue[key]
        }    
        VueDemi.isVue3 = true
      }  
        return VueDemi
      })(this.VueDemi,this.Vue,this.VueCompositionAPI)

    1.2 编译阶段——打包工具

    利用 npm postinstall 的 hook,检查本地的 Vue 版本,然后根据版本 reexport 对应的 API。

    const Vue = loadModule('vue') // 这里是检查本地的 vue 版本
    if (Vue.version.startsWith('2.')) {
      switchVersion(2)
    }
    else if (Vue.version.startsWith('3.')) {
      switchVersion(3)
    }
    function switchVersion(version, vue) {
      copy('index.cjs', version, vue)
      copy('index.mjs', version, vue)
    }
    // VueDemi 自己的 lib 目录下有 v2 v3 v2.7 三个文件夹,分别对应不同的 Vue 版本,Copy 函数的功能就是把需要的版本复制到 lib 目录下
    // 然后在 package.json 里面指向 lib/index.cjs 和 lib/index.mjs
    function copy(name, version, vue) {
      const src = path.join(dir, `v${version}`, name)
      const dest = path.join(dir, name)
      fs.write(dest, fs.read(src))
    }

    1.3 运行阶段 createElement 函数的区别

    1.3.1 Vue 2

    • attrs 需要写在 attrs 属性中;
    • on: { click=> {}}
    • scopedSlots 写在 scopedSlots 属性中。
    h(LayoutComponent, {
        staticClass: 'button',
        class: { 'is-outlined': isOutlined },
        staticStyle: { color: '#34495E' },
        style: { backgroundColor: buttonColor },
        attrs: { id: 'submit' },
        domProps: { innerHTML: '' },
        on: { click: submitForm },
        key: 'submit-button',
        // 这里只考虑 scopedSlots 的情况了
        // 之前的 slots 没必要考虑,全部用 scopedSlots 是一样的
        scopedSlots: { 
          header: () => h('div', this.header),
          content: () => h('div', this.content),
        },
      }
    );

    1.3.2 Vue 3

    • attrsprops 一样,只需写在最外层;
    • onClick: ()=> {}
    • slot 写在 createElement 函数的第三个参数中。
    h(LayoutComponent, {
        class: ['button', { 'is-outlined': isOutlined }],
        style: [{ color: '#34495E' }, { backgroundColor: buttonColor }],
        id: 'submit',
        innerHTML: '',
        onClick: submitForm,
        key: 'submit-button',
      }, {
        header: () => h('div', this.header),
        content: () => h('div', this.content),
      }
    );

    1.4 完整代码

    import { h as hDemi, isVue2 } from 'vue-demi';
    
    // 我们使用的时候使用的 Vue2 的写法,但是 props 还是写在最外层,为了 ts 的智能提示
    export const h = (
      type: String | Record<any, any>,
      options: Options & any = {},
      children?: any,
    ) => {
      if (isVue2) {
        const propOut = omit(options, [
          'props',
          // ... 省略了其他 Vue 2 的默认属性如 attrs、on、domProps、class、style
        ]);
        // 这里提取出了组件的 props
        const props = defaults(propOut, options.props || {}); 
        if ((type as Record<string, any>).props) {
          // 这里省略了一些过滤 attrs 和 props 的逻辑,不是很重要
          return hDemi(type, { ...options, props }, children);
        }
        return hDemi(type, { ...options, props }, children);
      }
    
      const { props, attrs, domProps, on, scopedSlots, ...extraOptions } = options;
    
      const ons = adaptOnsV3(on); // 处理事件
      const params = { ...extraOptions, ...props, ...attrs, ...domProps, ...ons }; // 排除 scopedSlots
    
      const slots = adaptScopedSlotsV3(scopedSlots); // 处理 slots
      if (slots && Object.keys(slots).length) {
        return hDemi(type, params, {
          default: slots?.default || children,
          ...slots,
        });
      }
      return hDemi(type, params, children);
    };
    
    const adaptOnsV3 = (ons: Object) => {
      if (!ons) return null;
      return Object.entries(ons).reduce((ret, [key, handler]) => {
        // 修饰符的转换
        if (key[0] === '!') {
          key = key.slice(1) + 'Capture';
        } else if (key[0] === '&') {
          key = key.slice(1) + 'Passive';
        } else if (key[0] === '~') {
          key = key.slice(1) + 'Once';
        }
        key = key.charAt(0).toUpperCase() + key.slice(1);
        key = `on${key}`;
    
        return { ...ret, [key]: handler };
      }, {});
    };
    
    const adaptScopedSlotsV3 = (scopedSlots: any) => {
      if (!scopedSlots) return null;
      return Object.entries(scopedSlots).reduce((ret, [key, slot]) => {
        if (isFunction(slot)) {
          return { ...ret, [key]: slot };
        }
        return ret;
      }, {} as Record<string, Function>);
    };

    2. 编码技巧:利用代数数据类型精简代码

    这里跟大家分享我自己总结的用于优化代码的理论工具。温馨提示,可能和书本上的原有概念有些不同。

    于我而言,衡量一段代码复杂度的方法是看状态数量。状态越少,逻辑、代码就越简单;状态数量越多,逻辑、代码越复杂,越容易出错。因此,我认为「好代码」的特征之一就是,在完成业务需求的前提下,尽量减少状态的数量(即大小)。

    那么,什么是状态?在 Vue 的场景下,可以这么理解:

    • data 里面的变量就是状态,props、计算属性都不是状态。
    • Composition API 中 refreactive 是状态,而 computed 不是状态。

    2.1 什么是「状态」?

    状态是可以由系统内部行为更改的数据,而状态大小是状态所有可能的值的集合的大小,记作 size(State)。而代码复杂度 = States.reduce((acc, cur) => acc * size(cur),1)

    2.1.1 常见数据类型的状态大小

    一些常见的数据类型,比如 unit 的状态大小是 1,在前端里可以是 null、undefined;所有的常量、非状态的大小也是 1。而 Boolean的状态大小是 2。

    NumberString 一类有多个或无限个值的数据类型,在计算状态大小时需明确一点,我们只关心状态在业务逻辑中的意义,而不是其具体值,因此区分会影响业务逻辑的状态值即可。

    例如,一个接口返回的数据是一个数字,但我们只关心这个数字是正数还是负数,那么这个数字的状态大小就是 2。

    2.1.2 复合类型的状态大小

    复合类型分为和类型与积类型两种。

    和类型状态大小的计算公式为 size(C) = size(A) + size(B),而积类型状态大小的计算公式为 size(C) = size(A) * size(B)

    了解完代码优化标准后,我们通过一个案例说明如何利用代数数据类型,精简代码。

    2.2 案例:评论编辑器的显示控制

    在 LigaAI 中,每个评论都有两个编辑器,一个用来编辑评论,一个用来回复评论;且同一时间最多只允许存在一个活动的编辑器。

    2.2.1 优化前的做法

    为回复组件定义两个布尔变量 IsShowReplyIsShowEdit ,通过 v-if 控制是否显示编辑器。点击「回复」按钮时,逻辑如下:

    (1) 判断自己的 IsShowReply 是否为 true,如果是,直接返回;

    (2) 判断自己的 IsshowEdit,如果为 true 则修改为 false,关闭编辑评论;

    (3) 依次设置所有其他评论组件的 IsShowReplyIsShowEdit 为 false;

    (4) 修改自己的 IsShowReply 为 true。

    当有 10 个评论组件时,代码复杂度是多少?

    size(CommentComponent) = size(Boolean) * size(Boolean) = 2 * 2 = 4
    size(total) = size(CommentComponent) ^ count(CommentComponent) = 4 ^ 10 = 1048576

    尽管逻辑上互斥,但这些组件在代码层面毫无关系,可以全部设置为 true。如果代码出现问题(包括写错),没处理好互斥,这种情况完全可能出现。处理互斥还涉及查找 dom 和组件,出问题的几率也会大大提高。

    2.2.2 优化后的做法

    store 中定义一个字符串变量 activeCommentEditor,表示当前活动的评论组件及其类型。

    type CommentId = number;
    type ActiveCommentStatus = `${'Edit' | 'Reply'}${CommentId}` | 'Close'; // TS 的模板字符串类型
    let activeCommentEditor: ActiveCommentStatus = 'Close';

    'Close' 外,该变量还由两部分组成。第一部分说明当前是「编辑评论」还是「回复评论」,第二部分说明评论的 id。按钮的回调函数(如点击回复),只需要设置

    activeCommentEditor = `Reply${id}`

    组件使用时,可以这样

    v-if="activeCommentEditor === `Edit${id}`"
    v-if="activeCommentEditor === `Reply${id}`"

    就这么简单,没有判断,没有 dom,没有其他组件。虽然 id 是 number,但于前端而言只是一个常量,所以其大小为 1。那么当有 10 个评论组件时,这段代码的复杂度就是

    size(total) = size('Reply''Edit') * count(Comment) * 1 + size('close') = 2 * 10 * 1 +1 = 21

    在实际使用中,我们发现确实存在 21 种状态;在代码层面,我们也精准控制了这个值只能在这 21 种正确的状态中,所以出错的几率也大大降低(几乎不可能出错)。

    以上就是今天想跟大家分享的 Vue2 和 Vue3 代码兼容的实现和优化方案,后续我们也会分享或补充更多相关案例与完整代码。


    >> LigaAI 往期精彩阅读 <<

    产品经理的 5 种错误打开方式,你中招了吗?

    什么是研发 Lead Time?我终于掰扯明白了!

    研发效能管理中的经典度量——DORA 指标

    清单推荐:常见的研发效能度量指标(科学管理版)

    LigaAI 将持续分享更多成长经验、研发效能管理实践,陪伴每一个研发团队和开发者成长。欢迎关注我们的团队博客或点击 LigaAI-智能研发协作平台,在线申请体验我们的产品。

    ]]>
    技术分享 | 前端进阶:如何在 Web 中使用 C++? https://ligai.cn/blog/sharing/1188.html Fri, 17 Mar 2023 03:21:49 +0000 https://ligai.cn/blog/?p=1188 阅读更多]]> 这是一个关于矩形排样问题和 WebAssembly 初体验的故事,但一切还要从不学无术的小学妹说起……

    1. 问题起因

    小学妹的课题需要写一个程序解决矩形排样(即二维矩形装箱)问题。

    根据给定的一系列矩形,需要将它们打包到指定大小的二维箱子中,且要求任意两个矩形不能相交或包含。

    问:如何排列矩形可使需要的箱子数量最少,且利用率最大?

    这是一个极具现实意义的问题,在工业应用中非常重要,排样结果与经济利益密切相关。

    同时,这也是一个NP-Hard问题——既无法通过一个简单公式计算,也不可能将所有情况枚举(超级计算机也算不过来)。

    2. 解决思路

    小学妹不学无术,而我对算法一窍不通,因此只好借前人经验遮荫避凉。历经重重曲折,终于找到一个 RectangleBinPack 库。它提供了一篇介绍二维矩形装箱问题的各种算法的文章,以及各种算法的具体实现。

    对算法感兴趣的伙伴可以自行获取 Wasm 仓库中的《算法介绍》文件了解。

    Wasm 仓库传送器:https://github.com/ununian/RectangleBinPack-Wasm

    目前了解到,解决二维矩形装箱问题有 4 种算法,分别是:货架算法断头台算法最大矩形算法天际线算法,每个算法都有一些策略选项。

    小课题不用宰牛刀,我将问题简化,在此只考虑一个箱子的情况

    3.方案选择

    该库是用 C++ 写的,但是我对 C++ 并不熟悉,所以需要用我所熟悉的语言使用。在其他语言中使用 C++ 有两种方案:

    第一,直接改写成对应语言,适用于简单的库。虽然这个库很适合直接改写,但却无法(在学妹面前)展现我的高超水平❌

    第二,将 C++ 库编译成静态库,再通过跨语言调用机制直接调用。这一看就是会吸引崇拜目光的高端玩法,I WANT YOU!✅

    那么,要在哪个语言中使用这个库呢?

    第一个想到的是 C#,毕竟在 C# 中调用 C++ 是很常见的操作,也有成熟的 Binding 工具(如 Swig);而且之前也做过这样的尝试,整体准备工作量也会少一点。但使用 C# 有两个问题:

    • 使用过程麻烦。毕竟是桌面程序,涉及分发、安装、兼容性等。
    • 编译结果不跨平台。虽然 C++ 和 C# 本身都能跨平台,但需要针对每个平台都编译一次,而且 C# 的 GUI 部分跨平台写起来有点麻烦……

    紧接着,我想到了 WebAssembly——一个可以完美解决上述问题的方案,既不用担心跨平台,又能直接使用前端技术完成 GUI 部分。方便又高端,还能在小学妹面前装(消音),简直非它莫属。

    4. 具体实现

    4.1 环境需求

    最好使用 Linux 环境,可以避免许多奇怪的问题;如果是 Windows,可以试试 WSL

    安装 Emscripten。具体请参考 Download and install – Emscripten 2.0.27 (dev) documentation

    还需要 Node 以及网页开发相关的工具。

    4.2 项目结构

    .
    ├── XXXX.cpp         // 算法本身的 cpp 文件
    ├── XXXX.h        // 算法本身的头文件
    ├── Warp.cc        // Warp文件,其中描述了需要导出的类和函数、枚举等
    ├── compile.sh        // 编译脚本
    ├── package.json    // package.json 文件,方便发布到 npm 仓库     

    4.3 Warp 文件的编写

    Warp 文件中显式地告诉 Emscripten 需要导出哪些类和函数(这个步骤称为 Binding),让 Emscripten 生成相应的 wasm 代码和 warp 代码,以便在 Web 环境中使用。

    本项目的 Warp 文件是:

    #include "Rect.cpp"
    // ... include<xxx.cpp> 引用各个算法的 cpp 文件
    #include "emscripten/bind.h"      // Emscripten Binding 需要的头文件
    
    using namespace emscripten;        // Emscripten Binding 的命名空间
    using namespace std;            // C++ 标准库命名空间 ,主要是为了使用 vector(可以理解为 C++ 中的可变长度数组)
    using namespace rbp;            // 这个算法库的命名空间 RectangleBinPack
    
    EMSCRIPTEN_BINDINGS(c)            // 表示我们开始编写 Emscripten 的 Binding
    {
            // 下面只要是字符串里面的值都是在 wasm 里面的名字,可以自己取,不要求和 C++ 中的一样。
    
        // 导出 Rect 和 RectSize 的 vector
            register_vector<Rect>("VectorRect");
        register_vector<RectSize>("VectorRectSize");
    
        // Rect.cpp
        class_<RectSize>("RectSize")                // 导出 RectSize 类,他包括
                .constructor<>()            // 一个没有参数的构造函数
                .constructor<int, int>()        // 一个有 2 个参数的构造函数,参数的类型分别是 int 和 int
                .property("width", &RectSize::width)    // 一个实例字段 width,对应的地址是 RectSize 的 width
                .property("height", &RectSize::height);
    
    
             // ...
    
        emscripten::function("IsContainedIn", &IsContainedIn);  // 导出了一个全局的函数
    
        // SkylineBinPack.cpp
            // 导出一个叫做 SkylineBinPack_LevelChoiceHeuristic 的枚举,
            // 他有 2 个值 LevelBottomLeft、LevelMinWasteFit
        enum_<SkylineBinPack::LevelChoiceHeuristic>("SkylineBinPack_LevelChoiceHeuristic")
                .value("LevelBottomLeft", SkylineBinPack::LevelChoiceHeuristic::LevelBottomLeft)
                .value("LevelMinWasteFit", SkylineBinPack::LevelChoiceHeuristic::LevelMinWasteFit);
    
        class_<SkylineBinPack>("SkylineBinPack")
                .constructor<>()
                .constructor<int, int, bool>()
                .function("Init", &SkylineBinPack::Init) // 一个实例函数 Init
                       // 一个实例函数 Insert_Range,对应的是 Insert 函数的某个重载
                .function("Insert_Range",select_overload<void(vector<RectSize> &, vector<Rect> &, SkylineBinPack::LevelChoiceHeuristic)>(&SkylineBinPack::Insert)) 
                .function("Insert_Single",select_overload<Rect(int, int, SkylineBinPack::LevelChoiceHeuristic)>(&SkylineBinPack::Insert))
                .function("Occupancy", &SkylineBinPack::Occupancy);
    }

    Warp 文件的文件名和文件中字符串的具体值都是在 wasm 里的名字,可以自定义,不要求与 C++ 中的一样。

    需要注意,这里直接引入的是 cpp 文件,不是头文件。下面说几个重要部分的处理。

    4.3.1 Vector 的处理

    vector 是 C++ 标准库提供的一个数据结构,是可以动态改变长度的数组。本项目主要用来传递待排版的 RectSize 数组和接收计算结果的 Rect 数组。

    Emscripten 贴心地提供了 vector 自动绑定方法 register_vector,只需传入 vector 的元素类型和导出名字即可。

    4.3.2 枚举的处理

    JS 中没有枚举概念,所以在 JS 使用时需要用 Object 的形式。绑定也很简单,使用 enum_ 指定名称、类型和对应的值就行。

    4.3.3 函数重载的处理

    JS 中没有函数重载的概念,因此导出重载函数需要指定不同的名称,并使用 select_overload 函数找到对应的函数(指定函数的返回值、参数类型即可,没有返回值就是 void)。

    顺带一提,如果有多个构造函数也需要指定构造函数的参数类型(构造函数不能指定名称和返回值)。

    4.4 编译 Wasm

    接下来,将写好的 Warp 文件编译成 Wasm,编译脚本如下:

    emcc --bind -Oz Warp.cc -o dist/Warp.js \
    -s WASM=1 \
    -s MODULARIZE=1
    • --bind 表示需要使用 Embind 的绑定功能。
    • -Oz 表示优化等级,有O0、O1、O2 等,其中 Oz 表示优化等级最高。此处我们无需调试 Wasm,选 Oz 就行。
    • -o 用于指定输出文件。如果指定的文件后缀名是 js,就会生成 wasm 和相应的 js warp 文件(包含一些胶水代码,便于我们使用 wasm)。当然我们也可以指定 html 生成一个 demo 网页;或指定 wasm 只生成 wasm 文件。
    • -s WASM=1 表示编译到 wasm 。如果值为 0 会编译到 asm.js,值为 2 就同时编译成两者。
    • -s MODULARIZE=1 表示生成的 js 文件会导出一个可以传参工厂函数(后续会看到),否则会直接赋值在 window 对象上。

    值得一提的是,-s SINGLE_FILE=1 可以用 base64 的方式将 wasm 嵌入到 warpjs 文件中,使用时只需要引用 js 文件就行。

    4.5 生成对应的 TypeScript 描述文件

    工具地址:https://github.com/ted537/tsembind

    生成 TypeScript 的描述文件在工程使用中非常重要,否则别人根本不知道怎么用(还能减少写文档的工作量),但是目前还没有十全十美的解决方案。

    我选用的工具通过读取 wasm 文件分析里面的导出,因此无法获取函数的形参名字;另外,生成的描述文件还需要小小的「后期加工」:

    直接运行 tsembind ./dist/Warp.js > ./dist/Warp.d.ts ,修改最下面导出的部分,别忘了添加 @types/emscripten

    export interface CustomEmbindModule { 
        // ...
    }
    declare function factory(): Promise<CustomEmbindModule>;
    export default factory;
    
    // =========>
    
    export interface RectangleBinPackModule extends EmscriptenModule {
        // ...
    }
    declare const factory: EmscriptenModuleFactory<RectangleBinPackModule>;
    export default factory;

    4.6 使用 Wasm

    详细的使用方法请参考 Demo 仓库的代码,下面补充一些注意事项。

    import type { RectangleBinPackModule as PackModule } from 'rectanglebinpack-wasm'
    
    // PackWasmInit 就是上面那个工厂函数
    import PackWasmInit from 'rectanglebinpack-wasm';
    
    // 我们需要获取 wasm 文件的路径。我们不需要用打包器的 wasm loader,
    // 只需要这个wasm文件的 url 就行。这里是 vite 的写法,webpack 应该是 file-loader
    import PackWasm from 'rectanglebinpack-wasm/dist/Warp.wasm?url' 
    
    // 方便获取枚举的值,主要是用来规避 ts 的类型检查
    const toEnumValue = (enumObj: any, value: any) => enumObj[value]
    
    export class WasmPackService implements IPackService {
    
      private wasm?: PackModule;
    
      constructor() {
        PackWasmInit({ 
            // 这里非常重要,我们需要告诉工厂方法 wasm 文件的位置在哪,
        // 如果不写,它会去网页的根目录下查找,一般情况下我们不希望这样 
            locateFile: (url) => url.endsWith('.wasm') ? PackWasm : url  
        }).then(wasm => {
          this.wasm = wasm;    // 初始化完成后,就能获取到 wasm 模块的实例了
        })
      }
    
      public async pack(
        source: SourcePanelItem[], // width height 这里因为只考虑 1 个箱子的情况,所以这里肯定只有 1 个数据
        target: TargetPanelItem[], // width height count
        algorithms: Algorithms,    // 算法
        setting: Record<string, boolean | string> // 算法设置
      ) {
          // ...
          const m = this.wasm;
    
          // 首先我们创建一个 RectSize 的 vector,然后把我们需要排版的小矩形都放进去
          const targetSizes = new m.VectorRectSize();    
          target
            .flatMap(t => range(0, Math.max(t.count, 0))
              .map(_ => new m.RectSize(t.width, t.height)))
            .forEach((i) => targetSizes.push_back(i));
    
          // ...
          let resultRects = new m.VectorRect();    // 创建一个用来接收结果的 Rect 的 vector
          switch (algorithms) {
              // ...
              case "Skyline":
                  // 调用天际线算法类的构造函数,并传递一些设置,创建一个算法对象
                  const skyline = new m.SkylineBinPack(sourceWidth, sourceHeight, setting['UseWasteMap'] as boolean);
                  // 调用批量添加函数,函数内部会把结果添加到 resultRects 里面
                  skyline.Insert_Range(
                    targetSizes,
                    resultRects,
                    toEnumValue(m.SkylineBinPack_LevelChoiceHeuristic, setting['LevelChoiceHeuristic'])
                  );
                  // 重要:手动释放 skyline 对象。因为 wasm 需要我们手动管理内存,
              // 所以创建了对象后一定要回收,不存在自动垃圾回收。
                  skyline.delete();
                  break;
          }
          const result: Rect[] = []
          for (let i = 0; i < resultRects.size(); i++) {
            const item = resultRects.get(i);
            result.push({ x: item.x, y: item.y, width: item.width, height: item.height })
          }
          // 获取结果后释放掉 targetSizes、resultRects
          targetSizes.delete();
          resultRects.delete();
    
          return { result }
      }
    }

    4.6.1 内存管理

    Wasm 与 JS 相比最大的区别是对象内存需要手动创建(new 函数)和释放(delete 函数),所以要注意 new 和 delete 的成对使用。

    如果 vector 内存的不是指针,则会自动调用析构函数。

    4.6.2 指定 wasm 文件的 Url

    如果不指定 wasm 文件的 Url,那么 warp 文件会从网站根目录 /xx.wasm 加载。通常我们不希望这样,因此需要在 wasm 加载时通过 locateFile 函数指定 Wasm 文件的 Url。

    建议不要通过 webpack 或者 viteloader 加载 wasm,那样会自动转换成 wasm 模块。只获取 wasm 文件的 url,可以在 vite 中的实在资源名后加上 ?url 或者在 webpack 中加上 !file-loader

    5.总结

    本项目涉及的内容和知识还是蛮多的,包括 C++、编译器、WebAssembly 、loader等。完成过程也踩了不少坑,主要是缺乏可用度高的相关资料——有些要么特别简单,只是导出一个全局函数,要么就很复杂,如 ffmpeg 的 wasm 版本。

    之前一直想学习 WebAssembly,这次也算是借着难得的机会,简单地了解了从编译到使用的全过程。最后的完成效果也很不错,具有一定的实际运用价值,当然小学妹也很满意:)

    后续可以改进的空间主要有两点:

    • 手动写 warp 文件比较麻烦,而且大都是重复的体力劳动。如果能写一个工具,通过分析 C++ 代码,自动生成 warp 文件和 Typescript 定义,或许可以节省很多工作量;具体实现可以参考 Swig 的做法。
    • 之前见过通过 Scope 实现半自动内存管理,或许也可以加进内存管理中使用。

    6.彩蛋 :C# 的做法

    6.1 编写描述文件 Warp.idl

     %module RectangleBinPack
    
     %{
     #include "Rect.h"
     #include "GuillotineBinPack.h"
     #include "SkylineBinPack.h"
     #include "ShelfNextFitBinPack.h"
     #include "ShelfBinPack.h"
     #include "MaxRectsBinPack.h"
     %}
    
     %include <std_vector.i>
     %template(vector_Rect) std::vector<rbp::Rect>;
     %template(vector_RectSize) std::vector<rbp::RectSize>;
    
     %include "Rect.h"
     %include "GuillotineBinPack.h"
     %include "SkylineBinPack.h"
     %include "ShelfNextFitBinPack.h"
     %include "ShelfBinPack.h"
     %include "MaxRectsBinPack.h"

    确实比 Emscripten 方便很多,毕竟更加成熟。再调用

    swig -c++ -csharp Warp.idl

    这一步会生成很多 cs 文件(C# 的源文件)和一个 warp.cxx 文件

    6.2 编译 Dll

    幸运的是,RectangleBinPack 自带了 VisualStudio 的工程文件 RectangleBinPack.sln 。打开后将生成的 warp.cxx 文件加入工程,build 一个 x64 的版本即可。

    6.3 使用

    创建一个 C# GUI 项目,将步骤 6.1 生成的 cs 文件和步骤 6.2 生成的 Dll 复制到目录下(Dll 需要选择较新则复制)。

    下面是部分重要代码:

      public (double, List<Rect>) PackImplement(PackRequestDto dto)
            {
                      // ...
                    var targets = new vector_RectSize(dto.Target.SelectMany(target =>
                        Enumerable.Range(1, target.Count).Select(_ =>
                            new RectSize {width = target.Width, height = target.Height})));
    
                    var resultRects = new vector_Rect();
    
                      // ...
    
                    switch (dto.Algorithms)
                    {
                        case PackAlgorithms.Skyline:
                            var skylineBin = new SkylineBinPack(sourceWidth, sourceHeight,
                                dto.SkylineSetting.UseWasteMap);
                            skylineBin.Insert(targets, resultRects, dto.SkylineSetting.LevelChoiceHeuristic);
    
                            break;
                       // ...
                    }
                    return (occupancy, resultRects.ToList());
                }

    可以看出,C# 和 JS 在调用阶段都差不多,只是 swig 更为贴心地处理了内存管理部分。

    7. 附录

    本文所提到代码资源:

    1. C++库:https://github.com/juj/RectangleBinPack

    2. Wasm:https://github.com/ununian/RectangleBinPack-Wasm

    3. Demo:https://github.com/ununian/RectangleBinPack-Wasm-Demo


    >> LigaAI 往期精彩阅读 <<

    如何科学管理技术团队的研发交付速率?

    从 Netflix 传奇看,结果导向的产品路线图如何制定?(上篇)

    Outcome VS. Output:研发效能提升中,谁会更胜一筹?

    对话 ChatGPT:现象级 AI 应用,将如何阐释「研发效能管理」?

    LigaAI 将持续分享更多研发效能度量体系的搭建经验,以及度量指标管理方法等干货内容,助力研发团队击碎效能瓶颈。关注我们的团队博客或点击 LigaAI-智能研发协作平台,在线申请体验我们的产品。

    ]]>
    「钞能力养成指北」:开年变富第一步,从科学记账开始 https://ligai.cn/blog/sharing/1165.html Mon, 06 Feb 2023 17:00:00 +0000 https://ligai.cn/blog/?p=1165 阅读更多]]>

    写在前面: 面对全面放开后多样的消费机会,开发者们如何在保障品质生活的同时,借助开源工具,全面、科学地规划和管理个人财务,把握资金动向,避开消费陷阱?

    LigaAI 特邀我司 Nerd4me 大佬分享其个人财务管理经验,系列共分「概念篇」和「实战篇」。本篇「实战篇」,将展开介绍开发者如何使用 Beancount 系统且便捷地记账。

    Beancount 是一个基于纯文本的开源记账软件,提供了一系列开箱即用的命令行工具,以及一套简洁、实用且美观的 Web UI。

    其核心记账逻辑是「复式记账法」,即每一笔交易至少关联两个交易账户,一借一贷,数额相等。特别的是, 复式记账法规定「收入为负,支出为正」 —— 在《「钞能力养成指北」前传》中,我们也详细解释了这点,大家可以按需补课。

    👉新手指南:Why Beancount?

    市面上有非常多成熟、知名的记账工具,为什么我仍强烈推荐使用 Beancount?出于以下几点考虑:

    1. Beancount 采用改良版的复式记账方案,「正负」代替「借贷」 对非会计专业的朋友更友好;
    2. 可直接在本地运行。相比基于云端的工具,数据安全更有保障,无需担心个人账务泄漏;
    3. 纯文本文件管理账目,便于记录和存储。 基于 Beancount 语法生成的文件,很容易阅读;
    4. 提供完整的命令行工具集和可视化工具 Fava(内含大量财报模板),支持基于类 SQL 查询,可轻松生成各种复杂的报表数据;
    5. 账本既是用户的输入文件,也是软件的「数据库」,可实现数据和工具的「无痛搬家」

    Beancount 是 Ledger-Like 家族的杰出成员。对比背靠 C++ 的 Ledger,基于 Python 的 Beancount 应用轻便,新增插件或二次开发更简单;多货币支持等丰富而强大的功能,能够很好地覆盖各种记账、查账场景。

    事实上,Beancount 并不知道什么是「货币」。它只是记录了「通货 Commodity」的变化,而通货完全由用户定义。 因此,Beancount 可以记录含货币在内任何东西的变化,如年假天数、股票、航空里程、信用卡积分……

    当然,也可以用来数豆子——这也是「Beancount」名字的由来 >A<

    👉 英勇黄铜:基本环境搭建

    迈出「Beancount 记账」的第一步,需要安装 Python3 环境和对应包。

    pip install beancount fava

    使用下列命令,生成一个官方提供的示例。

    bean-example > example.beancount

    通过 fava 命令运行 Web UI 。默认情况下的 Web UI 会运行在 http://localhost:5000 中。

    fava -H 0.0.0.0 example.beancount

    👉 不屈白银:Beancount 语法入门

    环境搭建后,我们需要制作账本来承载账目信息。Beancount 作者在 Beancount User’s Manual 中提供了非常详尽的使用说明,大家可以根据实际需求自行创建。

    下面以《「钞能力养成指北」前传》中「老王煎饼摊」为例,逐步演示如何使用 Beancount 编写账本并制成报表。

    1. 制作账本

    新建一个纯文本文件并保存为 laowang.bean。在任意文本编辑器打开,只需简单两步就能完成账本内容的编写。

    第一步:设立交易账户

    使用  open  命令设立交易账户,语法结构为开户时间 open 账户类型:账户名称 货币类型;备注

    其中,开户时间应早于该账户关联的首笔交易产生的时间;同时,在复式记账法中,交易账户分为五大类型:

    • Assets – 资产类账户:现金、存款、基金、股票、房子、车子、借出账款等;
    • Liabilities – 负债类账户:信用卡、贷款、花呗、向他人借款等;
    • Income – 收入类账户:工资、奖金、专利、投资收益等;
    • Expenses – 费用类账户:衣食住行等一切花销、过年发出的红包等;
    • Equity – 权益类账户:记账前已有的资产或负债,一般在账户初始化时设置。
    1970-01-01 open Income:Sales CNY
    1970-01-01 open Assets:Bank:Saving:ICBC CNY
    1970-01-01 open Assets:Fixed CNY ;固定资产
    1970-01-01 open Liabilities:CreditCard:ICBC CNY
    1970-01-01 open Expenses:Food CNY

    第二步:编写交易记录

    Beancount 是复式记账工具,其账目分录至少包含三行信息:交易详情、支出详情和收入详情。

    交易详情记录了交易时间、收款人信息和交易备注,格式为 YYYY-MM-DD */! "" ""&nbsp;

    • 「 */! 」为对账标识符: * 表示该交易已确认,! 表示该交易存疑/待确认。
    • 双引号中的为选填内容,分别记录「收款人 Payee」和「交易备注 Narration」。

    支出/收入详情记录出/进账的账户名称、交易数额和货币类型,出账为负,进账为正,正负金额相加为零。

    2023-01-01 * "xx公司" "购买设备"
      Assets:Bank:Saving:ICBC      -1,000.00 CNY
      Assets:Fixed                  1,000.00 CNY
    
    2023-01-01 * "采购食材"
      Liabilities:CreditCard:ICBC   -500.00 CNY
      Expenses:Food                  500.00 CNY

    把「老王煎饼摊」的账本补充完整,就会得到下面这个 laowang.bean 文件。

    1970-01-01 open Income:Sales CNY
    1970-01-01 open Assets:Bank:Saving:ICBC CNY
    1970-01-01 open Assets:Fixed CNY ;固定资产
    1970-01-01 open Liabilities:CreditCard:ICBC CNY
    1970-01-01 open Expenses:Food CNY
    
    2023-01-01 * "xx公司" "购买设备"
      Assets:Bank:Saving:ICBC      -1,000.00 CNY
      Assets:Fixed                  1,000.00 CNY
    
    2023-01-01 * "采购食材"
      Liabilities:CreditCard:ICBC   -500.00 CNY
      Expenses:Food                  500.00 CNY
    
    2023-01-02 * "煎饼销售收入"
      Income:Sales                -1,000.00 CNY
      Assets:Bank:Saving:ICBC      1,000.00 CNY
    
    2023-01-02 * "质量问题食材退货"
      Expenses:Food                 -100.00 CNY
      Liabilities:CreditCard:ICBC    100.00 CNY

    2. 生成财务报表

    使用 Beancount 命令行工具集中的 bean-report 命令,可以实现各类财务报表的自动生成。

    比如, income 子命令能生成损益表,balsheet 可以统计资产负债情况,balances 用于查询各账户余额等。

    (BEANCOUNT) bean-report laowang.bean balances
    Assets:Bank:Saving:ICBC
    Assets:Fixed                   1000.00 CNY
    Equity
    Expenses:Food                   400.00 CNY
    Income:Sales                  -1000.00 CNY
    Liabilities:CreditCard:ICBC    -400.00 CNY

    账户余额表指出,老王持有 1,000 元的固定资产、在食材上花费 400 元、煎饼营业收入 1,000 元、信用卡欠款 400 元。

    对许多非技术背景的朋友来说,用命令行呈现的数据有点不太好阅读。没关系,Beancount 自己会出手:Beancount 为我们提供了一个可视化工具 Fava

    安装 Fava 后,使用 fava laowang.bean 命令启动 Web UI 服务。在浏览器中打开 http://localhost:5000/,就可以获得表格式的报表数据。

    👉 荣耀黄金:进阶场景说明

    在现实生活中,除了「一对一」交易,我们还会遇到很多涉及多个账户的「一对多」和「多对多」的情况。下面几个场景展示了 Beancount 和复式记账法在处理复杂交易方面的优势。

    场景 1 :发工资

    应聘谈薪,我们关心「税前工资」和「到手工资」。那么,每月发放的工资、奖金,缴纳的五险一金和个人所得税等费用,在 Beancount 中应该如何入账呢?

    2023-01-10 * "Some Company" "💰工资2022-12"
      Income:SomeCompany:Salary           -20,000.00 CNY ;应发工资
      Income:SomeCompany:Benefits            -500.00 CNY ;节日福利
      Income:SomeCompany:HousingFund       -2,000.00 CNY ;住房公积金单位扣除
      Assets:Government:HousingFund         4,000.00 CNY ;住房公积金缴纳
      Expenses:Government:SocialSecurity    1,500.00 CNY ;社保缴纳
      Expenses:Government:IncomeTax         3,000.00 CNY ;个税缴纳
      Assets:Bank:Saving:ICBC              14,000.00 CNY ;实发工资

    不难看出,复式记账法的优势之一是能够清晰地展示同一笔交易中,资金在各个账户之间的流动关系,反映资金运动的来龙去脉。

    场景 2 :记录房产

    对于房产、车辆等固定资产,我们不只关心其购入时的价值,也同样在乎其后续的升/贬值情况,即当前市场估值

    Beancount 不预先定义任何货币,我们可以创建人民币 CNY 的变种货币 CNY.UNVEST,赋予房子一个人民币变种价格(即估值价格)

    这样,在以人民币 CNY 展示总资产时,我们既能在价格页查看房子的当前市值估值,也不会影响总资产的统计。

    ;创建房产货币
    2018-06-01 commodity HOUSE.ABC
      name: "房产名称"
    2018-06-01 open Assets:Property:CS:ABC HOUSE.ABC
    
    ;房产购买
    2018-06-01 * "XX地产" "房产名称"
      Assets:Property:CS:ABC                        1 HOUSE.ABC {2,000,000.00 CNY} ;房产购买价格
      Assets:Bank:Saving:ICBC              600,000.00 CNY ;首付款
      Liabilities:Bank:BOC:MortgageLoan  1,400,000.00 CNY ;抵押贷款
    
    ;房产价格
    2018-06-01 price HOUSE.ABC 2,000,000.00 CNY ;买入成本
    2018-06-01 price HOUSE.ABC 2,000,000.00 CNY.UNVEST ;估值价格
    2023-01-17 price HOUSE.ABC 2,500,000.00 CNY.UNVEST ;估值价格

    场景 3 :AA 制消费分摊

    AA 制的费用分摊、信用卡分期还款等交易记账是典型的「一对多」场景。

    小红和小明外出吃饭一共花费 500 元,由小红先行支付;小明几天后想起,将 AA 的费用转账还给小红。在小红的账本中,这笔钱应该这样记录:

    2023-01-13 * "xx 饭店" "和小明吃饭"
      Assets:VA:Wechat              -500.00 CNY ;微信零钱付款
      Expenses:Food:DiningOut        250.00 CNY ;AA我(小红)的一半
      Assets:Receivables:Xiao-Ming   250.00 CNY ;AA小明的一半
    
    2023-01-17 * "小明" "AA吃饭收款"
      Assets:VA:Wechat               250.00 CNY
      Assets:Receivables:Xiao-Ming  -250.00 CNY

    场景 4 :货币转换

    出入境限制全面放开,许多好友也都选择出国游玩散心。在消费记账时,Beancount 如何处理不同货币之间的汇率转化关系?

    在登记交易时,使用 @@ 即可连接两种互相转换的货币。本例中,信用卡支出的 650 元人民币正是由 100 美元转换而来。

    2023-01-17 * "在免税店买东西"
     Assets:Cash                                 -200.00 USD ;现金支付
     Liabilities:CreditCard:ICBC                 -650.00 CNY @@ 100.00 USD ;信用卡付款
     Expenses:Clothing:Pants                     +150.00 USD
     Expenses:Clothing:Shoes                     +150.00 USD

    场景 5 :使用 DSL 进行复杂查询

    除了 bean-report 命令外,Beancount 还提供 bean-query 工具,支持 SQL 语句查询,以满足更复杂的数据统计。感兴趣的朋友可以在 Beancount – Query Language 文档中了解。

    这里分享一下让 Beancount 回答「我都在哪些加油站加过几次油」的操作指令。

    👉 超凡大师:最佳实践分享

    看到这里的朋友们,恭喜你们已经学会了 Beancount 复式记账的常用语法和操作。它们可以满足生活中绝大多数消费和交易的记账需求。下面分享的是一些个人实践的经验总结,希望能更好地帮你开启「Beancount 之旅」。

    1. 账本编辑器的选择

    我选择 VSCode 作为账本文件编辑器,搭配 Lencerf/vscode-beancount 插件一起使用,可以自动为 .bean 或者 .beancount 文件加上语法高亮、补全账户名,还可以实现金额数据的自动对齐。

    具体的配置操作请查看作者在 Github 上提供的帮助文档了解。

    2. 账户开户日期的设立

    前面提到,交易账户的开设日期应早于该账户首笔交易产生的时间。这里也补充一些比较有意思的开户事件,供诸位参考。

    • Expenses 账户可以使用自己的出生日期作为开户日期;
    • Income 账户可以按来源分类后再选择日期,如 Income:SomeCompany:Salary 的开户时间是该公司的入职日期;
    • Assets 和 Liabilities 账户中的借/贷记卡的开设日期,与银行的开户日期保持一致。

    3. 分割账本文件结构

    记账是一个长期习惯,但是随着时间累积,账本文件越变越大,只在一个文件中编写和记录就会变得极其不便。

    因此,在创建账本时,我们可以提前规划账本分类,使用 Beancount 的 include 语法对账本进行结构分割。这样就可以在一个 Beancount 文件中包含其他的文件,就像下面这样:

    .
    ├── 2022
    │   ├── 09.bean
    │   ├── 10.bean
    │   ├── 11.bean
    │   ├── 12.bean
    │   ├── __index.bean
    │   ├── creditcard.bean
    │   ├── event.bean
    │   ├── forecast.bean
    │   ├── income.bean
    │   ├── loan.bean
    │   └── transfer.bean
    ├── 2023
    │   ├── 01.bean
    │   ├── __index.bean
    │   ├── forecast.bean
    │   ├── income.bean
    │   ├── insurance.bean
    │   ├── loan.bean
    │   └── transfer.bean
    ├── account
    │   ├── __index.bean
    │   ├── assets.bean
    │   ├── equity.bean
    │   ├── expenses.bean
    │   ├── income.bean
    │   └── liabilities.bean
    ├── commodity
    │   ├── __index.bean
    │   └── fund.bean
    ├── doc
    │   └── __index.bean
    └── main.bean

    4. 定期对账

    账本的定期对账(或称定期断言,Balance Assertion)就像写代码时的「Ctrl + S」或「Command + S」,无事发生自动隐形,关键时刻出手「救命」。

    断言就是告诉 Beancount,在某个日期前,这个账户有多少余额(设值为 A) 。如果 Beancount 计算的该时间点前的交易余额 B,与余额 A 不相等,那它就会报错。此时,我们只需检查两次断言日期之间的交易,就能快速定位错账。

    使用 balance 命令标记某个日期前的账户余额(区分 bean-report 命令中的 balance 子命令),让 Beancount 自动核对账本中的账户余额是否与实际金额相等。

    2023-01-01 balance Assets:Bank:Saving:ICBC 10,000.00 CNY

    需要注意,断言声明的是所给日期开始前的余额,即当日的交易不算在内。也就是说,上面的断言表示,在 2023 年元旦前,即截止至 2022 年 12 月 31 日,工商银行储蓄账户余额为 10,000 元。

    👉后记

    在 wzyboy 的《Beancount——命令行复式簿记》一文中,我第一次了解到「记账神器 Beancount」;2022 年 9  月前后,我正式使用 Beancount 管理个人财务。如今睡前打开 VSCode 记录当天的资金变动,已经成为固定活动,而在每日的记账、查账中,我也获益良多。

    本文分享的 Beancount 入门级操作指令,可以满足(个人)日常记账的简单需求;Beancount 还有许多证券投资、基金净值更新等高级应用,大家可以自行挖宝~

    新的一年,坚持记账,搞钱发财!


    编者语:# 编程之外 是 LigaAI 开设的全新栏目。在这里,我们将与开发者朋友们一起发现和分享生活中的「技术时刻」,并通过极具创意与实用的「生活代码」,感受「技术改善生活」的真谛。如果你也对「代码提升幸福感」感兴趣,欢迎关注 【LigaAI 公众号】。

    体验新一代智能研发协作,请 点击这里 展开了解。LigaAI 助力开发者扬帆远航,期待与你一路同行!

    ]]>
    「钞能力养成指北」前传:开年变富,如何迈出第一步? https://ligai.cn/blog/sharing/1163.html Fri, 03 Feb 2023 07:11:52 +0000 https://ligai.cn/blog/?p=1163 阅读更多]]>

    写在前面 面对全面放开后多样的消费机会,开发者们如何在保障品质生活的同时,借助开源工具,全面、科学地规划和管理个人财务,把握资金动向,避开消费陷阱?

    LigaAI 特邀我司 Nerd4me 大佬分享其个人财务管理经验,系列共分「概念篇」和「实战篇」。本篇「概念篇」,将详细介绍复式记账法的基本框架。

    一、为什么要记账?

    关于「为什么要记账?」,常见回答有这些:

    • 掌控收支情况,以便更好地制定理财计划;
    • 单纯地作为一种生活记录,以承载记忆;
    • 希望通过记账,改变或调整消费习惯;
    • 鼠鼠穷,希望通过记账寻找可削减的开支;

    在我看来,记账所带来的好处远不止这些。记账能让我们更清晰宏观地了解自身的财务状况,通过合理的财富资源配置(让钱去该去的地方),更好地应对/规避风险,规划投资行为

    一个维护良好的账本能够生成许多有用的账务报表,其中最有用的两个是:

    • 损益表 – Income Statement: 周期性地审阅损益表有助于了解周期时间内的资金流向和盈亏情况。也就是我们常说的:钱从哪来?到哪去?亏了还是赚了?
    • 资产负债表 – Balance Sheet:用于呈现我们拥有多少钱 钱分布在哪里?

    想要更好地维护账本,我们需要采用一种更科学的记账方式——复式记账法。

    二、图论 & 复式记账法

    「有借必有贷,借贷必相等」 是复式记账法最出圈的口诀。它反映了用复式记账法记录的每一条帐目,至少会与两个交易账户和一条交易同时出现。

    如果将「账户」视为「节点」,「交易」看做「有向边」,所有账目都可以被抽象成含若干个节点和有向边的「有向图」,其中有向边的方向是资金的流动方向。

    这就是 Martin Kleppmann 大神在 Accounting for Computer Scientists 中提出的理论。本文将围绕「账簿与图论」揭开复式记账法的神秘面纱。

    👉 账户 = 节点,交易 = 有向边

    老王准备开一个煎饼摊,获得了老婆提供的 5,000 元启动资金支持(转入银行卡)。

    他先花 1,000 元(银行卡支付)购买煎饼设备,又花 500 元(信用卡付款)采购煎饼材料,最后用银行卡支付了 250 元的信用卡账单。

    老王按照「账户 = 节点,交易 = 有向边」的原则,将交易过程抽象成有向图。虽然节点的名称不太符合会计规范,但有向边的方向严格遵循交易逻辑,即资金永远从一个节点指向其他节点。

    👉 标记并更新账户余额

    在会计学中,每个账户都有余额(Balance),而余额的多少完全由进出账户的交易决定。

    账户余额有两个特性在账目准确性的检查方面,非常有用;若违反了其中任何一条,都能说明账目有问题。它们分别是:

    1. 笔交易会同时关联两个账户(进账和出账),其余额此消彼长,所以图中节点的余额之和始终为 0
    2. 所有节点分成两个不相交的子集,子集的余额之和互为相反数

    👉 经营煎饼摊

    老王将自己做的账簿图给老婆看。她修改了一些的专业术语,比如将「启动资金」改成「实收资本」。

    同时,王嫂指出「煎饼设备」的入账方式不对,应该将设备的使用损耗考虑进来,将采购费用分 4 年折旧,因为四年内老王可能将设备转让出去。

    煎饼摊每天的生意都很火爆,经过计算目前已挣得收入 5,000 元。同时,老王对煎饼设备进行改造,并成功申请了专利;设备工厂决定以 10 元/台的价格,购买改良版设备的专利使用权,并为第一批生产计划的 500 台设备预付了 2,500 元。

    另外,老王的朋友看生意不错,也注资了 25,000 元;他终于可以给自己发工资了。

    👉 如何将图转换成「财务报表」?

    老王煎饼摊的故事先讲到这。交易抽象成有向图后,我们如何根据图中的信息,生成损益表和资产负债表?

    01 分类处理

    首先,对图中的节点进行分类、着色处理,使用不同颜色标记不同的含义。

    🟢 绿色表示拥有的东西即资产(Assets) ,例如银行存款、现金或者已经买了且将来可能折旧卖出的东西,就像老王的「煎饼设备」。

    另外,要收回来/还回去的欠款 ,也应做绿色标记。其中,欠你钱的企业/个人称为债务人(Debtors),你为债权人(Creditors);而你要还的钱为负债(Liabilities)。

    🔵 蓝色表示销售产品或知识产权转让的收入(Income)和花出去且永远不会再回来的钱(花费,Expenses) 。购买煎饼设备的费用是绿色的,因为它还能以「设备」形态再次卖出;而食材买回来做成煎饼就没了,因此是蓝色的。

    🌸 粉色表示来自 投资者或自己的钱,即权益(Equity)

    02 损益表

    损益表反映了一定时间内的利润实现情况,也是关于图中蓝色节点的财务报表。

    将图中所有蓝色节点的余额相加,若值为负数,说明煎饼摊「盈利 Profit」,其绝对值为煎饼摊的「净利润 Net Profit」;若值为正数,则代表经营「亏损 Loss」,其绝对值为「净亏损 Net Loss」。

    将蓝色节点的信息整理成下图所示的会计学标准格式。为了易于理解,这里对收入余额取绝对值。

    损益表指出,老王靠销售煎饼和专利转让获得了 10,000 元的收入,产生了 8,750 元的花费,所以煎饼摊的净利润为 1,250 元。

    「损益表」的统计和分析常以月、季度或者年为单位。 通过对比不同时期的数额变化与账户增减,我们可以更全面地了解收支明细,调整开支项目,评估或预测未来的收支水平。

    需要注意的是,由于债务人的存在,「盈利」不能代表「银行账户」的余额增加。 这也是为什么一些财报呈现盈利的企业,最终会因资金流紧张(账上没钱)而无法良好运转。

    03 资产负债表

    资产负债表,又称财务状况表,用于揭秘资产、负债、权益的金额明细和分布状态。

    前文提到,将图中所有节点分成任意两个不相交的集合,集合的账户余额之和为 0 ——这正是资产负债表的由来。

    将蓝色节点(收入和花费,即损益值)和粉色节点(权益)分为一类,其余额之和应与绿色节点(资产和负债)的余额之和互为相反数。

    老王煎饼摊的资产负债表如下,此处数额同样做了绝对值处理。

    三、参考资料

    [1] 复式记账法,又称为复式簿记(Bookkeeping),是商业及其他组织上记录金融交易的标准系统。

    [2] 图论是研究事物之间关系的科学。万事万物皆有千丝万缕的联系,而任何有联系(联接)的事物都可以抽象成图结构,以便展开全面而强大的分析,包括但不仅限于分析节点影响力、寻找关键路径、预测发展情况等等。

    [3] Martin Kleppmann 是剑桥大学「分布式」系统研究人员,著有 Designing Data-Intensive Applications 一书。


    编者语:# 编程之外 是 LigaAI 开设的全新栏目。在这里,我们将与开发者朋友们一起发现和分享生活中的「技术时刻」,并通过极具创意与实用的「生活代码」,感受「技术改善生活」的真谛。如果你也对「代码提升幸福感」感兴趣,欢迎关注 【LigaAI 公众号 – 】

    体验新一代智能研发协作,请 点击这里 展开了解。LigaAI 助力开发者扬帆远航,期待与你一路同行!

    ]]>
    技术分享 | 多个服务器如何跨命名空间,访问公共服务? https://ligai.cn/blog/sharing/1136.html Fri, 16 Dec 2022 03:57:35 +0000 https://ligai.cn/blog/?p=1136 阅读更多]]> 一、问题背景

    在开发某个公共应用时,笔者发现该公共应用的数据是所有测试环境(假设存在 dev/dev2/dev3)通用的。

    这就意味着只需部署一个应用,就能满足所有测试环境的需求;也意味着所有测试环境都需要调用该公共应用,而不同测试环境的应用注册在不同的 Nacos 命名空间。

    二、两种解决方案

    如果所有测试环境都需要调用该公共应用,有两种可行的方案。第一种,将该公共服务同时注册到不同的测试环境所对应的命名空间中。

    第二种,将公共应用注册到单独的命名空间,不同的测试环境能够跨命名空间访问该应用。

    三、详细的问题解决过程

    先行交代笔者的版本号配置。Nacos 客户端版本号为 NACOS 1.4.1;Java 项目的 Nacos 版本号如下。

    最初想法是将该公共应用同时注册到多个命名空间下。在查找资料的过程中,团队成员在 GitHub 上发现了一篇类似问题的博客分享:Registration Center: Can services in different namespaces be called from each other? #1176

    01 注册多个命名空间

    从该博客中,我们看到其他程序员朋友也遇到了类似的公共服务的需求。在本篇文章中,笔者将进一步分享实现思路以及示例代码。

    说明:以下代码内容来自用户 chuntaojun 的分享。

    shareNamespace={namespaceId[:group]},{namespaceId[:group]} 
    @RunWith(SpringRunner.class)
    @SpringBootTest(classes = NamingApp.class, properties = {"server.servlet.context-path=/nacos"},
        webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
    public class SelectServiceInShareNamespace_ITCase {
    
        private NamingService naming1;
        private NamingService naming2;
        @LocalServerPort
        private int port;
        @Before
        public void init() throws Exception{
            NamingBase.prepareServer(port);
            if (naming1 == null) {
                Properties properties = new Properties();
                properties.setProperty(PropertyKeyConst.SERVER_ADDR, "127.0.0.1"+":"+port);
                properties.setProperty(PropertyKeyConst.SHARE_NAMESPACE, "57425802-3058-4507-9a73-3229b9f00a36");
                naming1 = NamingFactory.createNamingService(properties);
    
                Properties properties2 = new Properties();
                properties2.setProperty(PropertyKeyConst.SERVER_ADDR, "127.0.0.1"+":"+port);
                properties2.setProperty(PropertyKeyConst.NAMESPACE, "57425802-3058-4507-9a73-3229b9f00a36");
                naming2 = NamingFactory.createNamingService(properties2);
            }
            while (true) {
                if (!"UP".equals(naming1.getServerStatus())) {
                    Thread.sleep(1000L);
                    continue;
                }
                break;
            }
        }
    
        @Test
        public void testSelectInstanceInShareNamespaceNoGroup() throws NacosException, InterruptedException {
            String service1 = randomDomainName();
            String service2 = randomDomainName();
            naming1.registerInstance(service1, "127.0.0.1", 90);
            naming2.registerInstance(service2, "127.0.0.2", 90);
    
            Thread.sleep(1000);
    
            List<Instance> instances = naming1.getAllInstances(service2);
            Assert.assertEquals(1, instances.size());
            Assert.assertEquals(service2, NamingUtils.getServiceName(instances.get(0).getServiceName()));
        }
    
        @Test
        public void testSelectInstanceInShareNamespaceWithGroup() throws NacosException, InterruptedException {
            String service1 = randomDomainName();
            String service2 = randomDomainName();
            naming2.registerInstance(service1, groupName, "127.0.0.1", 90);
            naming3.registerInstance(service2, "127.0.0.2", 90);
    
            Thread.sleep(1000);
    
            List<Instance> instances = naming3.getAllInstances(service1);
            Assert.assertEquals(1, instances.size());
            Assert.assertEquals(service1, NamingUtils.getServiceName(instances.get(0).getServiceName()));
            Assert.assertEquals(groupName, NamingUtils.getServiceName(NamingUtils.getGroupName(instances.get(0).getServiceName())));
        }
    
    }

    进一步考虑后发现该解决方案可能不太契合当前遇到的问题。公司目前的开发测试环境有很多个,并且不确定以后会不会继续增加。

    如果每增加一个环境,都需要修改一次公共服务的配置,并且重启一次公共服务,着实太麻烦了。倒不如反其道而行,让其他的服务器实现跨命名空间访问公共服务。

    02 跨命名空间访问

    针对实际问题查找资料时,我们找到了类似的参考分享《重写 Nacos 服务发现逻辑动态修改远程服务IP地址》

    跟着博客思路看代码,笔者了解到服务发现的主要相关类是 NacosNamingService, NacosDiscoveryProperties, NacosDiscoveryAutoConfiguration

    然后,笔者将博客的示例代码复制过来,试着进行如下调试:

    @Slf4j
    @Configuration
    @ConditionalOnNacosDiscoveryEnabled
    @ConditionalOnProperty(
            name = {"spring.profiles.active"},
            havingValue = "dev"
    )
    @AutoConfigureBefore({NacosDiscoveryClientAutoConfiguration.class})
    public class DevEnvironmentNacosDiscoveryClient {
    
        @Bean
        @ConditionalOnMissingBean
        public NacosDiscoveryProperties nacosProperties() {
            return new DevEnvironmentNacosDiscoveryProperties();
        }
    
        static class DevEnvironmentNacosDiscoveryProperties extends NacosDiscoveryProperties {
    
            private NamingService namingService;
    
            @Override
            public NamingService namingServiceInstance() {
                if (null != this.namingService) {
                    return this.namingService;
                } else {
                    Properties properties = new Properties();
                    properties.put("serverAddr", super.getServerAddr());
                    properties.put("namespace", super.getNamespace());
                    properties.put("com.alibaba.nacos.naming.log.filename", super.getLogName());
                    if (super.getEndpoint().contains(":")) {
                        int index = super.getEndpoint().indexOf(":");
                        properties.put("endpoint", super.getEndpoint().substring(0, index));
                        properties.put("endpointPort", super.getEndpoint().substring(index + 1));
                    } else {
                        properties.put("endpoint", super.getEndpoint());
                    }
    
                    properties.put("accessKey", super.getAccessKey());
                    properties.put("secretKey", super.getSecretKey());
                    properties.put("clusterName", super.getClusterName());
                    properties.put("namingLoadCacheAtStart", super.getNamingLoadCacheAtStart());
    
                    try {
                        this.namingService = new DevEnvironmentNacosNamingService(properties);
                    } catch (Exception var3) {
                        log.error("create naming service error!properties={},e=,", this, var3);
                        return null;
                    }
    
                    return this.namingService;
                }
            }
    
        }
    
        static class DevEnvironmentNacosNamingService extends NacosNamingService {
    
            public DevEnvironmentNacosNamingService(Properties properties) {
                super(properties);
            }
    
            @Override
            public List<Instance> selectInstances(String serviceName, List<String> clusters, boolean healthy) throws NacosException {
                List<Instance> instances = super.selectInstances(serviceName, clusters, healthy);
                instances.stream().forEach(instance -> instance.setIp("10.101.232.24"));
                return instances;
            }
        }
    
    }

    调试后发现博客提供的代码并不能满足笔者的需求,还得进一步深入探索。

    但幸运的是,调试过程发现 Nacos 服务发现的关键类是 com.alibaba.cloud.nacos.discovery.NacosServiceDiscovery,其中的关键方法是 getInstances()getServices(),即「返回指定服务 ID 的所有服务实例」和「获取所有服务的名称」

    也就是说,getInstances() 方法进行重写肯定能实现本次目标——跨命名空间访问公共服务

    /**
     * Return all instances for the given service.
     * @param serviceId id of service
     * @return list of instances
     * @throws NacosException nacosException
     */
    public List<ServiceInstance> getInstances(String serviceId) throws NacosException {
            String group = discoveryProperties.getGroup();
            List<Instance> instances = discoveryProperties.namingServiceInstance()
                            .selectInstances(serviceId, group, true);
            return hostToServiceInstanceList(instances, serviceId);
    }
    
    /**
     * Return the names of all services.
     * @return list of service names
     * @throws NacosException nacosException
     */
    public List<String> getServices() throws NacosException {
            String group = discoveryProperties.getGroup();
            ListView<String> services = discoveryProperties.namingServiceInstance()
                            .getServicesOfServer(1, Integer.MAX_VALUE, group);
            return services.getData();
    }

    03 最终解决思路及代码示例

    具体的解决方案思路大致如下:

    1. 生成一个共享配置类NacosShareProperties,用来配置共享公共服务的 namespacegroup

    2. 重写配置类 NacosDiscoveryProperties (新:NacosDiscoveryPropertiesV2),将新增的共享配置类作为属性放进该配置类,后续会用到;

    3. 重写服务发现类 NacosServiceDiscovery (新:NacosServiceDiscoveryV2),这是最关键的逻辑;

    4. 重写自动配置类 NacosDiscoveryAutoConfiguration,将自定义相关类比 Nacos 原生类更早的注入容器。

    最终代码中用到了一些工具类,可以自行补充完整。

    /**
     * <pre>
     *  @description: 共享nacos属性
     *  @author: rookie0peng
     *  @date: 2022/8/29 15:22
     *  </pre>
     */
    @Configuration
    @ConfigurationProperties(prefix = "nacos.share")
    public class NacosShareProperties {
    
        private final Map<String, Set<String>> NAMESPACE_TO_GROUP_NAME_MAP = new ConcurrentHashMap<>();
    
        /**
         * 共享nacos实体列表
         */
        private List<NacosShareEntity> entities;
    
        public List<NacosShareEntity> getEntities() {
            return entities;
        }
    
        public void setEntities(List<NacosShareEntity> entities) {
            this.entities = entities;
        }
    
        public Map<String, Set<String>> getNamespaceGroupMap() {
            safeStream(entities).filter(entity -> nonNull(entity) && nonNull(entity.getNamespace()))
                    .forEach(entity -> {
                        Set<String> groupNames = NAMESPACE_TO_GROUP_NAME_MAP.computeIfAbsent(entity.getNamespace(), k -> new HashSet<>());
                        if (nonNull(entity.getGroupNames()))
                            groupNames.addAll(entity.getGroupNames());
                    });
            return new HashMap<>(NAMESPACE_TO_GROUP_NAME_MAP);
        }
    
        @Override
        public String toString() {
            return "NacosShareProperties{" +
                    "entities=" + entities +
                    '}';
        }
    
        /**
         * 共享nacos实体
         */
        public static final class NacosShareEntity {
    
            /**
             * 命名空间
             */
            private String namespace;
    
            /**
             * 分组
             */
            private List<String> groupNames;
    
            public String getNamespace() {
                return namespace;
            }
    
            public void setNamespace(String namespace) {
                this.namespace = namespace;
            }
    
            public List<String> getGroupNames() {
                return groupNames;
            }
    
            public void setGroupNames(List<String> groupNames) {
                this.groupNames = groupNames;
            }
    
            @Override
            public String toString() {
                return "NacosShareEntity{" +
                        "namespace='" + namespace + '\'' +
                        ", groupNames=" + groupNames +
                        '}';
            }
        }
    }
    /**
     * @description: naocs服务发现属性重写
     * @author: rookie0peng
     * @date: 2022/8/30 1:19
     */
    public class NacosDiscoveryPropertiesV2 extends NacosDiscoveryProperties {
    
        private static final Logger log = LoggerFactory.getLogger(NacosDiscoveryPropertiesV2.class);
    
        private final NacosShareProperties nacosShareProperties;
    
        private static final Map<String, NamingService> NAMESPACE_TO_NAMING_SERVICE_MAP = new ConcurrentHashMap<>();
    
        public NacosDiscoveryPropertiesV2(NacosShareProperties nacosShareProperties) {
            super();
            this.nacosShareProperties = nacosShareProperties;
        }
    
        public Map<String, NamingService> shareNamingServiceInstances() {
            if (!NAMESPACE_TO_NAMING_SERVICE_MAP.isEmpty()) {
                return new HashMap<>(NAMESPACE_TO_NAMING_SERVICE_MAP);
            }
            List<NacosShareProperties.NacosShareEntity> entities = Optional.ofNullable(nacosShareProperties)
                    .map(NacosShareProperties::getEntities).orElse(Collections.emptyList());
            entities.stream().filter(entity -> nonNull(entity) && nonNull(entity.getNamespace()))
                    .filter(PredicateUtil.distinctByKey(NacosShareProperties.NacosShareEntity::getNamespace))
                    .forEach(entity -> {
                        try {
                            NamingService namingService = NacosFactory.createNamingService(getNacosProperties(entity.getNamespace()));
                            if (namingService != null) {
                                NAMESPACE_TO_NAMING_SERVICE_MAP.put(entity.getNamespace(), namingService);
                            }
                        } catch (Exception e) {
                            log.error("create naming service error! properties={}, e=", this, e);
                        }
                    });
            return new HashMap<>(NAMESPACE_TO_NAMING_SERVICE_MAP);
        }
    
        private Properties getNacosProperties(String namespace) {
            Properties properties = new Properties();
            properties.put(SERVER_ADDR, getServerAddr());
            properties.put(USERNAME, Objects.toString(getUsername(), ""));
            properties.put(PASSWORD, Objects.toString(getPassword(), ""));
            properties.put(NAMESPACE, namespace);
            properties.put(UtilAndComs.NACOS_NAMING_LOG_NAME, getLogName());
            String endpoint = getEndpoint();
            if (endpoint.contains(":")) {
                int index = endpoint.indexOf(":");
                properties.put(ENDPOINT, endpoint.substring(0, index));
                properties.put(ENDPOINT_PORT, endpoint.substring(index + 1));
            }
            else {
                properties.put(ENDPOINT, endpoint);
            }
    
            properties.put(ACCESS_KEY, getAccessKey());
            properties.put(SECRET_KEY, getSecretKey());
            properties.put(CLUSTER_NAME, getClusterName());
            properties.put(NAMING_LOAD_CACHE_AT_START, getNamingLoadCacheAtStart());
    
    //        enrichNacosDiscoveryProperties(properties);
            return properties;
        }
    }
    /**
     * @description: naocs服务发现重写
     * @author: rookie0peng
     * @date: 2022/8/30 1:10
     */
    public class NacosServiceDiscoveryV2 extends NacosServiceDiscovery {
    
        private final NacosDiscoveryPropertiesV2 discoveryProperties;
    
        private final NacosShareProperties nacosShareProperties;
    
        private final NacosServiceManager nacosServiceManager;
    
        public NacosServiceDiscoveryV2(NacosDiscoveryPropertiesV2 discoveryProperties, NacosShareProperties nacosShareProperties, NacosServiceManager nacosServiceManager) {
            super(discoveryProperties, nacosServiceManager);
            this.discoveryProperties = discoveryProperties;
            this.nacosShareProperties = nacosShareProperties;
            this.nacosServiceManager = nacosServiceManager;
        }
    
        /**
         * Return all instances for the given service.
         * @param serviceId id of service
         * @return list of instances
         * @throws NacosException nacosException
         */
        public List<ServiceInstance> getInstances(String serviceId) throws NacosException {
            String group = discoveryProperties.getGroup();
            List<Instance> instances = discoveryProperties.namingServiceInstance()
                    .selectInstances(serviceId, group, true);
            if (isEmpty(instances)) {
                Map<String, Set<String>> namespaceGroupMap = nacosShareProperties.getNamespaceGroupMap();
                Map<String, NamingService> namespace2NamingServiceMap = discoveryProperties.shareNamingServiceInstances();
                for (Map.Entry<String, NamingService> entry : namespace2NamingServiceMap.entrySet()) {
                    String namespace;
                    NamingService namingService;
                    if (isNull(namespace = entry.getKey()) || isNull(namingService = entry.getValue()))
                        continue;
                    Set<String> groupNames = namespaceGroupMap.get(namespace);
                    List<Instance> shareInstances;
                    if (isEmpty(groupNames)) {
                        shareInstances = namingService.selectInstances(serviceId, group, true);
                        if (nonEmpty(shareInstances))
                            break;
                    } else {
                        shareInstances = new ArrayList<>();
                        for (String groupName : groupNames) {
                            List<Instance> subShareInstances = namingService.selectInstances(serviceId, groupName, true);
                            if (nonEmpty(subShareInstances)) {
                                shareInstances.addAll(subShareInstances);
                            }
                        }
                    }
                    if (nonEmpty(shareInstances)) {
                        instances = shareInstances;
                        break;
                    }
                }
            }
            return hostToServiceInstanceList(instances, serviceId);
        }
    
        /**
         * Return the names of all services.
         * @return list of service names
         * @throws NacosException nacosException
         */
        public List<String> getServices() throws NacosException {
            String group = discoveryProperties.getGroup();
            ListView<String> services = discoveryProperties.namingServiceInstance()
                    .getServicesOfServer(1, Integer.MAX_VALUE, group);
            return services.getData();
        }
    
        public static List<ServiceInstance> hostToServiceInstanceList(
                List<Instance> instances, String serviceId) {
            List<ServiceInstance> result = new ArrayList<>(instances.size());
            for (Instance instance : instances) {
                ServiceInstance serviceInstance = hostToServiceInstance(instance, serviceId);
                if (serviceInstance != null) {
                    result.add(serviceInstance);
                }
            }
            return result;
        }
    
        public static ServiceInstance hostToServiceInstance(Instance instance,
                                                            String serviceId) {
            if (instance == null || !instance.isEnabled() || !instance.isHealthy()) {
                return null;
            }
            NacosServiceInstance nacosServiceInstance = new NacosServiceInstance();
            nacosServiceInstance.setHost(instance.getIp());
            nacosServiceInstance.setPort(instance.getPort());
            nacosServiceInstance.setServiceId(serviceId);
    
            Map<String, String> metadata = new HashMap<>();
            metadata.put("nacos.instanceId", instance.getInstanceId());
            metadata.put("nacos.weight", instance.getWeight() + "");
            metadata.put("nacos.healthy", instance.isHealthy() + "");
            metadata.put("nacos.cluster", instance.getClusterName() + "");
            metadata.putAll(instance.getMetadata());
            nacosServiceInstance.setMetadata(metadata);
    
            if (metadata.containsKey("secure")) {
                boolean secure = Boolean.parseBoolean(metadata.get("secure"));
                nacosServiceInstance.setSecure(secure);
            }
            return nacosServiceInstance;
        }
    
        private NamingService namingService() {
            return nacosServiceManager
                    .getNamingService(discoveryProperties.getNacosProperties());
        }
    }
    /**
     * @description: 重写nacos服务发现的自动配置
     * @author: rookie0peng
     * @date: 2022/8/30 1:08
     */
    @Configuration(proxyBeanMethods = false)
    @ConditionalOnDiscoveryEnabled
    @ConditionalOnNacosDiscoveryEnabled
    @AutoConfigureBefore({NacosDiscoveryAutoConfiguration.class})
    public class NacosDiscoveryAutoConfigurationV2 {
    
        @Bean
        @ConditionalOnMissingBean
        public NacosDiscoveryPropertiesV2 nacosProperties(NacosShareProperties nacosShareProperties) {
            return new NacosDiscoveryPropertiesV2(nacosShareProperties);
        }
    
        @Bean
        @ConditionalOnMissingBean
        public NacosServiceDiscovery nacosServiceDiscovery(
                NacosDiscoveryPropertiesV2 discoveryPropertiesV2, NacosShareProperties nacosShareProperties, NacosServiceManager nacosServiceManager
        ) {
            return new NacosServiceDiscoveryV2(discoveryPropertiesV2, nacosShareProperties, nacosServiceManager);
        }
    }

    本以为问题到这就结束了,但最后自测时发现程序根本不走 Nacos 的服务发现逻辑,而是执行 Ribbon 的负载均衡逻辑com.netflix.loadbalancer.AbstractLoadBalancerRule

    不过实现类是 com.alibaba.cloud.nacos.ribbon.NacosRule,继续基于 NacosRule 重写负载均衡。

    /**
     * @description: 共享nacos命名空间规则
     * @author: rookie0peng
     * @date: 2022/8/31 2:04
     */
    public class ShareNacosNamespaceRule extends AbstractLoadBalancerRule {
    
        private static final Logger LOGGER = LoggerFactory.getLogger(ShareNacosNamespaceRule.class);
    
        @Autowired
        private NacosDiscoveryPropertiesV2 nacosDiscoveryPropertiesV2;
        @Autowired
        private NacosShareProperties nacosShareProperties;
    
        /**
         * 重写choose方法
         *
         * @param key
         * @return
         */
        @SneakyThrows
        @Override
        public Server choose(Object key) {
            try {
                String clusterName = this.nacosDiscoveryPropertiesV2.getClusterName();
                DynamicServerListLoadBalancer loadBalancer = (DynamicServerListLoadBalancer) getLoadBalancer();
                String name = loadBalancer.getName();
    
                NamingService namingService = nacosDiscoveryPropertiesV2
                        .namingServiceInstance();
                List<Instance> instances = namingService.selectInstances(name, true);
                if (CollectionUtils.isEmpty(instances)) {
                    LOGGER.warn("no instance in service {}, then to get share service's instance", name);
                    List<Instance> shareNamingService = this.getShareNamingService(name);
                    if (nonEmpty(shareNamingService))
                        instances = shareNamingService;
                    else
                        return null;
                }
                List<Instance> instancesToChoose = instances;
                if (org.apache.commons.lang3.StringUtils.isNotBlank(clusterName)) {
                    List<Instance> sameClusterInstances = instances.stream()
                            .filter(instance -> Objects.equals(clusterName,
                                    instance.getClusterName()))
                            .collect(Collectors.toList());
                    if (!CollectionUtils.isEmpty(sameClusterInstances)) {
                        instancesToChoose = sameClusterInstances;
                    }
                    else {
                        LOGGER.warn(
                                "A cross-cluster call occurs,name = {}, clusterName = {}, instance = {}",
                                name, clusterName, instances);
                    }
                }
    
                Instance instance = ExtendBalancer.getHostByRandomWeight2(instancesToChoose);
    
                return new NacosServer(instance);
            }
            catch (Exception e) {
                LOGGER.warn("NacosRule error", e);
                return null;
            }
        }
    
    
        @Override
        public void initWithNiwsConfig(IClientConfig iClientConfig) {
    
        }
    
        private List<Instance> getShareNamingService(String serviceId) throws NacosException {
            List<Instance> instances = Collections.emptyList();
            Map<String, Set<String>> namespaceGroupMap = nacosShareProperties.getNamespaceGroupMap();
            Map<String, NamingService> namespace2NamingServiceMap = nacosDiscoveryPropertiesV2.shareNamingServiceInstances();
            for (Map.Entry<String, NamingService> entry : namespace2NamingServiceMap.entrySet()) {
                String namespace;
                NamingService namingService;
                if (isNull(namespace = entry.getKey()) || isNull(namingService = entry.getValue()))
                    continue;
                Set<String> groupNames = namespaceGroupMap.get(namespace);
                List<Instance> shareInstances;
                if (isEmpty(groupNames)) {
                    shareInstances = namingService.selectInstances(serviceId, true);
                    if (nonEmpty(shareInstances))
                        break;
                } else {
                    shareInstances = new ArrayList<>();
                    for (String groupName : groupNames) {
                        List<Instance> subShareInstances = namingService.selectInstances(serviceId, groupName, true);
                        if (nonEmpty(subShareInstances)) {
                            shareInstances.addAll(subShareInstances);
                        }
                    }
                }
                if (nonEmpty(shareInstances)) {
                    instances = shareInstances;
                    break;
                }
            }
            return instances;
        }
    }

    至此问题得以解决。

    Nacos 上配置好共享 namespacegroup 后,就能够进行跨命名空间访问了。

    # nacos共享命名空间配置 示例
    nacos.share.entities[0].namespace=e6ed2017-3ed6-4d9b-824a-db626424fc7b
    nacos.share.entities[0].groupNames[0]=DEFAULT_GROUP
    # 指定服务使用共享的负载均衡规则,service-id是注册到nacos上的服务id,ShareNacosNamespaceRule需要写全限定名
    service-id.ribbon.NFLoadBalancerRuleClassName=***.***.***.ShareNacosNamespaceRule

    注意:如果 Java 项目的 nacos discovery 版本用的是 2021.1,则不需要重写 Ribbon 的负载均衡类,因为该版本的 Nacos 不依赖 Ribbon。

    2.2.1.RELEASE 版本nacos discovery 依赖 Ribbon.

    2021.1 版本nacos discovery 不依赖 Ribbon。

    、总结

    为了达到共享命名空间的预期,构思、查找资料、实现逻辑、调试,前后一共花费 4 天时间。成就感满满的同时,笔者也发现该功能仍存在共享服务缓存等可优化空间,留待后续实现。

    五、参考文献

    [1] Registration Center: Can services in different namespaces be called from each other? [EB/OL]. https://github.com/alibaba/nacos/issues/1176, 2019-05-07/2022-11-29.

    [2] 重写Nacos服务发现逻辑动态修改远程服务IP地址 [EB/OL]. https://www.cnblogs.com/changxy-codest/p/14632574.html, 2021-04-08/2022-11-29.


    >> LigaAI 往期精彩阅读 <<

    半个月上线一个新产品,猴子无限是怎么做的?

    如何基于GitHub Pages+Hexo,搭建个人博客?

    多测试环境的动态伸缩实践

    被忽悠入坑后,我如何让产品「起死回生」?

    了解更多敏捷开发、项目管理、行业动态等消息,关注我们的团队博客或点击 LigaAI-智能研发协作平台,在线申请体验我们的产品。

    ]]>