vue3 前端项目使用 protobuf

首先,安装 vite-plugin-proto 这个插件。

yarn add vite-plugin-proto

然后,配置 vite.config.ts

import { defineConfig } from 'vite'
import proto from 'vite-plugin-proto'

export default defineConfig(async () => {
  return {
    plugins: {
      proto({
        basePath: './src/http/proto',
        parseOptions: {
          keepCase: true,
        },
      }),
    }
  }
})

其中 basePath 是 proto 文件目录。parseOptions 就是 protobufjs 的选项,keepCase 设为 true 是为了保持 protobuf 定义文件的命名方式,否则下划线会自动转为驼峰。

接着就可以在项目里直接导入 proto 文件了:

import def from '@/http/proto/test.proto'
console.log(def)

这里拿到的就是前端可以使用的 protobuf 对象了(不是最终 JSON 结构)。

为了能够方便地使用,比如查找数据结构、序列化和反序列化,我还在 AI 的帮助下封装了一个服务。

import proto from 'protobufjs'

export default class ProtoService {
  /**
   * 根对象
   */
  root: protobuf.Root
  /**
   * 指定的 service 对象,可以没有,并且很多 proto 文件就是没有 service
   */
  service?: protobuf.Service
  /**
   * 缓存的方法
   */
  methodTypes: Map<
    string,
    {
      requestType: protobuf.Type
      responseType: protobuf.Type
    }
  > = new Map()

  constructor(json: protobuf.INamespace, serviceName?: string) {
    this.root = proto.Root.fromJSON(json)
    if (serviceName) {
      this.service = this.root.lookupService(serviceName)

      // 预加载所有方法的类型
      Object.entries(this.service.methods).forEach(([methodName, method]) => {
        this.methodTypes.set(methodName, {
          requestType: this.root.lookupType(method.requestType),
          responseType: this.root.lookupType(method.responseType),
        })
      })
    }
  }

  /**
   * service 下指定方法的默认数据
   * @param methodName
   * @returns
   */
  getMethodDefaults(methodName: string) {
    if (!this.service) return

    const method = this.service?.methods[methodName]

    if (!method) {
      throw new Error(`Method ${methodName} not found`)
    }

    const requestType = this.root.lookupType(method.requestType)
    const responseType = this.root.lookupType(method.responseType)

    return {
      request: this.createCompleteDefault(requestType),
      response: this.createCompleteDefault(responseType),
    }
  }

  /**
   * service 下的所有方法对应的默认数据
   * @returns
   */
  getAllMethodDefaults() {
    const defaults: Record<string, any> = {}

    if (!this.service) return defaults

    Object.keys(this.service.methods).forEach((methodName) => {
      defaults[methodName] = this.getMethodDefaults(methodName)
    })

    return defaults
  }

  // 获取方法的类型定义
  getMethodTypes(methodName: string) {
    if (!this.service) return

    const method = this.service.methods[methodName]
    if (!method) {
      throw new Error(`Method ${methodName} not found`)
    }

    return {
      requestType: this.root.lookupType(method.requestType),
      responseType: this.root.lookupType(method.responseType),
    }
  }

  /**
   * 创建默认对象
   * @param type
   * @returns
   */
  createCompleteDefault(type: protobuf.Type | string) {
    if (typeof type === 'string') {
      type = this.findType(type)
    }
    return ProtoTypeHelper.createCompleteDefault(type)
  }

  // 查找类型
  findType(type: string) {
    const res = this.root.lookupType(type)
    if (!res) {
      throw new Error(`Type ${type} not found`)
    }
    return res
  }

  // 查找枚举
  findEnum(name: string) {
    const res = this.root.lookupEnum(name)
    if (!res) {
      throw new Error(`Enum ${name} not found`)
    }
    return res
  }

  /**
   * 获取枚举选项
   * @param nameOrEnum
   * @returns
   */
  getEnumOptions(nameOrEnum: string | protobuf.Enum) {
    if (typeof nameOrEnum === 'string') {
      nameOrEnum = this.findEnum(nameOrEnum)
    }
    const enumOptions = Object.entries(nameOrEnum.values).map((item) => ({
      label: item[0],
      value: item[1],
    }))
    return enumOptions
  }

  // 序列化数据
  serialize(
    type: protobuf.Type | string,
    data: any,
    options = {
      validateBeforeSerialize: true,
    },
  ) {
    if (typeof type === 'string') {
      type = this.findType(type)
    }
    return ProtoMessageHandler.serialize(type, data, options)
  }

  // 反序列化数据
  deserialize(
    type: protobuf.Type | string,
    data: Uint8Array,
    options = {
      verifyAfterDeserialize: true,
      enums: false, // 默认不设置
    },
  ) {
    if (typeof type === 'string') {
      type = this.findType(type)
    }
    return ProtoMessageHandler.deserialize(type, data, options)
  }

  // 规范化数据的方法
  normalizeData(
    type: protobuf.Type | string,
    data: any,
    options = {
      validateBeforeSerialize: true,
      verifyAfterDeserialize: true,
      enums: false,
    },
  ) {
    // 1. 序列化数据
    const serializedData = this.serialize(type, data, {
      validateBeforeSerialize: options.validateBeforeSerialize,
    })

    // 2. 反序列化数据
    const deserializedData = this.deserialize(type, serializedData, {
      verifyAfterDeserialize: options.verifyAfterDeserialize,
      enums: options.enums,
    })

    return deserializedData
  }

  // 序列化请求
  serializeRequest(
    methodName: string,
    data: any,
    options = {
      validateBeforeSerialize: true,
    },
  ): Uint8Array {
    const types = this.methodTypes.get(methodName)
    if (!types) {
      throw new Error(`Method ${methodName} not found`)
    }

    return this.serialize(types.requestType, data, options)
  }

  // 序列化响应
  serializeResponse(
    methodName: string,
    data: any,
    options = {
      validateBeforeSerialize: true,
    },
  ): Uint8Array {
    const types = this.methodTypes.get(methodName)
    if (!types) {
      throw new Error(`Method ${methodName} not found`)
    }

    return this.serialize(types.responseType, data, options)
  }

  // 反序列化请求
  deserializeRequest(
    methodName: string,
    buffer: Uint8Array,
    options = {
      verifyAfterDeserialize: true,
      enums: false,
    },
  ) {
    const types = this.methodTypes.get(methodName)
    if (!types) {
      throw new Error(`Method ${methodName} not found`)
    }

    return this.deserialize(types.requestType, buffer, options)
  }

  // 反序列化响应
  deserializeResponse(
    methodName: string,
    buffer: Uint8Array,
    options = {
      verifyAfterDeserialize: true,
      enums: false,
    },
  ) {
    const types = this.methodTypes.get(methodName)
    if (!types) {
      throw new Error(`Method ${methodName} not found`)
    }

    return this.deserialize(types.responseType, buffer, options)
  }
}

export class ProtoTypeHelper {
  static isEnum(resolvedType: any): boolean {
    return resolvedType instanceof proto.Enum
  }

  static isMessage(resolvedType: any): boolean {
    return resolvedType instanceof proto.Type
  }

  static getDefaultValue(
    field: protobuf.Field,
    typeChain: protobuf.Type[] = [],
  ) {
    if (!field.resolvedType) {
      // 原始类型
      return field.defaultValue
    }

    if (this.isEnum(field.resolvedType)) {
      // 枚举类型返回第一个值或默认值
      const enumValues = Object.values(
        (field.resolvedType as proto.Enum).values,
      )
      return enumValues[0] || 0
    }

    if (this.isMessage(field.resolvedType)) {
      // 如果是循环引用,直接返回默认值
      if (typeChain.includes(field.resolvedType as proto.Type)) {
        return field.defaultValue
      }
      // 消息类型创建新实例
      return this.createCompleteDefault(
        field.resolvedType as proto.Type,
        typeChain,
      )
    }

    return undefined
  }

  static createCompleteDefault(
    type: protobuf.Type,
    typeChain: protobuf.Type[] = [],
  ) {
    const message: Record<string, any> = ProtoMessageHandler.toObject(
      type,
      type.create(),
      {
        defaults: true,
      },
    )
    const newTypeChain = [...typeChain, type]
    Object.entries(type.fields).forEach(([fieldName, field]) => {
      // 如果是 oneof 属性,直接忽略,因为 oneof 包含的属性只能存在一种
      if (field.partOf) return

      if (field.resolvedType) {
        if (field.repeated) {
          // 数组对象默认是空数组
          message[fieldName] = []
          // 默认对象挂到另一个字段--需要处理循环引用
          // message[`_${fieldName}Item`] = this.getDefaultValue(
          //   field,
          //   newTypeChain,
          // )
        } else {
          message[fieldName] = this.getDefaultValue(field, newTypeChain)
        }
      } else {
        message[fieldName] = field.defaultValue
      }
    })

    return message
  }

  static getEnumValues(enumType: protobuf.Enum) {
    return enumType.values
  }

  static getFieldInfo(field: protobuf.Field) {
    const info: any = {
      name: field.name,
      type: field.type,
      repeated: field.repeated,
    }

    if (field.resolvedType) {
      info.resolvedType = this.isEnum(field.resolvedType) ? 'enum' : 'message'
      if (this.isEnum(field.resolvedType)) {
        info.enumValues = this.getEnumValues(field.resolvedType as proto.Enum)
      }
    }

    return info
  }
}

export class ProtoMessageHandler {
  static serialize(
    messageType: protobuf.Type,
    data: any,
    options = {
      validateBeforeSerialize: true,
    },
  ) {
    try {
      if (options.validateBeforeSerialize) {
        const error = messageType.verify(data)
        if (error) {
          throw new Error(`Validation error: ${error}`)
        }
      }

      // 创建一个新的消息实例
      const message = messageType.create(data)

      // 编码为二进制
      return messageType.encode(message).finish()
    } catch (error) {
      console.error('Serialization error:', error)
      throw error
    }
  }

  static deserialize(
    messageType: protobuf.Type,
    buffer: Uint8Array,
    options = {
      verifyAfterDeserialize: true,
      enums: false,
    },
  ) {
    try {
      // 解码
      let decoded = messageType.decode(buffer)

      if (options.verifyAfterDeserialize) {
        const error = messageType.verify(decoded)
        if (error) {
          throw new Error(`Verification error: ${error}`)
        }
      }

      // 枚举转换为 key
      const conversionOptions: protobuf.IConversionOptions = {}
      if (options.enums) {
        conversionOptions.enums = String
      }
      decoded = messageType.toObject(
        decoded,
        conversionOptions,
      ) as protobuf.Message

      return decoded
    } catch (error) {
      console.error('Deserialization error:', error)
      throw error
    }
  }

  static toObject(
    messageType: protobuf.Type,
    message: any,
    options: protobuf.IConversionOptions = {
      defaults: true,
      arrays: true,
      objects: true,
      longs: String,
      enums: String,
      bytes: String,
    },
  ) {
    return messageType.toObject(message, options)
  }
}

然后使用的时候就只需要几行代码就可以了。

const instance = new ProtoService(def, 'Test')
console.log('instance: ', instance)
const defaultReqObj = instance.getMethodDefaults('Update')
defaultReqObj.request.uuid = 1
console.log('default request object: ', defaultReqObj)
const buf = instance.serializeRequest('Update', defaultReqObj?.request)
console.log('serialized buffer: ', buf)
const desrializedObj = instance.deserializeRequest('Update', buf)
// 枚举
const enumOptions = instance.getEnumOptions('Code')
console.log('enum options: ', enumOptions)

好了,以上就是 vue3 前端使用 protobuf 的方式了。

等等,还有两点需要解释。

  1. 问:为什么不使用 protoc 命令行先将 protobuf 转为 js 文件,然后引入呢?网上有部分文章就是这种方式。
    答:因为这种方式不方便,并且这种方式在项目引入 js 的时候可能报错。
  2. 为什么不直接用 ?raw 的形式引入 proto 文件,然后通过 protobufjs 解析呢?
    答:虽然我没尝试,但考虑到 proto 文件自身会引用其他 proto 文件,?raw 的形式只是引入了单个文件,解析可能不会成功。