Google 是如何实践 RESTful API 设计的?

Tags: 系统设计 

目录

说明

经手了几个应用层的项目,API设计的都不怎么理想,有的项目乱到一塌糊涂。在参考国外公司的 API 时,发现 Google 在 2017 年公布了 2014 年制定并使用至今的 API Design Guide,这里提炼下要点。

后续:2021 年 Google API Design Guide 编写人之一 JJ Geewax 出版了一本《API Design Patterns》对 API 设计规范进行解释,还发起了 API Improvement Proposals 项目,收集 API 设计规范并提供了检查是否复合规范的 Google API Linter

应用情况怎样?

Google 有的服务遵守了这份规范,比如 Google Cloud APIs、Google Calendar API,有的没有完全准守,比如 Blogger API v3。 采用了该规范的 Google Cloud API 是 Google 整个云服务的接口,具有接口数量多、类别多的特点。可以认为这份规范在谷歌内得到了较多支持,一些服务没有遵循规范可能是历史原因。

是否有实践案例?

Google 开放了一份 protobuf 格式的接口描述文件 googleapis,里面主要是 Google Cloud API 的接口,没有覆盖 google 所有产品的 API(Google 开放的所有 API 汇总在 Google API Explorer 中)。

git clone https://github.com/googleapis/googleapis.git 

googleapis 在文件组织上存在一些不理想的做法:

  • 存放公用定义的目录比如 google/api/、google/rpc/ 和业务接口目录比如 google/cloud/、google/container/ 等平铺在一起,不方便区分以及查找
  • 文件名的命名方式不统一,比如包含 service 定义的 proto 文件,有以下几种方式的命名:
google/firestore/v1/firestore.proto、
google/cloud/aiplatform/v1/{dataset_,endpoint_,feature_store,...}_service.proto
google/actions/sdk/v2/actions_sdk.proto

API 设计的核心原则?

以名词描述的 Resource 为中心,Resource 按父子层级关系进行组织。 为 Resource 赋予统一的标准操作(LIST, GET, CREATE, UPDATE, DELETE),无法用标准操作表示的动作单独设计非标准操作。

Resource URI 命名风格和 URL 风格?

Resource 通过 URI 锚定,URI 采用可多层嵌套 collecionID/resourceId 样式。

//      域名              /父类别         /资源ID              /子类别        /资源ID
//     domain name        /collectionID   /resourceID          /collecionID   /resourceID
//storage.googleapis.com  /buckets        /bucket-id           /objects       /object-id
//mail.googleapis.com     /users          /[email protected]    /settings      /customFrom

URI 体现接口版本号、路径参数,不体现标准的 LIST/GET/CREATE/UPDATE/DELETE 操作(通过 HTTP Method 区分),用后缀的方式体现非标准操作。

# 标准方法通过 HTTP Method 区分
https://service.name/v1/some/resource/name              
# 非标准方法通过 :custermVerb 后缀区分
https://service.name/v1/some/resource/name:customVerb

Package/Service/Method/Enum 的命名方式也有约定,详情见 Naming conventions

Resource 的唯一标识要如何设计?

最常见的做法用一个 int64 数值作为 Resource 的唯一标识,通常称为 Resource ID。Google 采用了另一种做法,用一个字符串类型的 name 作为 Resource 的唯一标识: Resource 的第一个 field 是 string 类型的 name,value 为用“/”分隔的 URI

//calendar.googleapis.com/users/john smith/events/123

这和常规做法很不相同,Google 认为这种格式的字符串才是最完整的表达,单个数值 id 表达不出 Parent ID/Child ID 的层次关系。 具体解释见 Why not use resource IDs to identify a resource?

Resource Name 的这种设计使路径参数捕获方式也相应发生变化:

service LibraryService {
  rpc GetBook(GetBookRequest) returns (Book) {
    option (google.api.http) = {
      //注意看:{} 中的参数 name 匹配的是字符串 shelves/*/books/* ,而不是一个数值字段
      get: "/v1/{name=shelves/*/books/*}" 
    };
  };
  rpc CreateBook(CreateBookRequest) returns (Book) {
    option (google.api.http) = {
      post: "/v1/{parent=shelves/*}/books"
      body: "book"
    };
  };
}

上面的 name 从路径参数中捕获的 value 是一个有层次关系的字符串,这个字符串显然是不能整体作为作为数据库表的主键或者索引。 比较好奇 Google 是怎样写实现代码的,在代码中进行字符串分隔提取出各级 ID,返回时再将多级 ID 拼接成 name ?

是否复用 HTTP Method 的语义?

Google 的答案是:复用

现实中有的项目全部都是 POST 方法,在 URI 中添加 GET/LIST 等动词区分不同的操作,这种做法浪费了 HTTP Method 的表达力,并且增加了 URI 长度。 Google API Design Guide 约定 Resource 上的常规操作 LIST/GET/CREATE/UPDATE/DELETE 分别用对应的 HTTP method 承接,这些操作的 URI 中不包含动词。

Resource 标准操作和 HTTP Method 的映射:

Standard    HTTP                         HTTP                 HTTP
Method      Mapping                      Request Body         Response Body
----------------------------------------------------------------------------------------
List        GET <collection URL>         N/A                  Resource* list
Get         GET <resource URL>           N/A                  Resource*
Create      POST <collection URL>        Resource             Resource*
Update      PUT or PATCH <resource URL>  Resource             Resource*
Delete      DELETE <resource URL>        N/A                  google.protobuf.Empty**

非常规操作无法用 HTTP Method 映射,通过在 URI 中增加后缀动词区分,例如下面的 :customVerb。

https://service.name/v1/some/resource/name:customVerb

这也是一个和平常所见不太相同的做法,比较常见的是下面的做法(/customVerb)。 Google 用:customVerb 的方式大概是为了避免 customVerb 和 ResourceName 冲突,比如 customVerb 同时是一个特殊的 Resource Name 的时候。

https://service.name/v1/some/resource/name/customVerb

是否使用路径参数?

Google 的答案是:使用

严格遵循 URI 是资源定位符的定义,只通过 URI 就能定位目标资源。query parameter 和 request body 中的参数只影响 resource 的呈现(representation)。前面提到过,由于比较特殊的 Resource Name 设计,路径参数的捕获方式也比较特殊。

service LibraryService {
  rpc GetBook(GetBookRequest) returns (Book) {
    option (google.api.http) = {
      //注意看:参数 name 匹配的是字符串 shelves/*/books/* ,而不是一个数值字段
      get: "/v1/{name=shelves/*/books/*}"  
    };
  };
  rpc CreateBook(CreateBookRequest) returns (Book) {
    option (google.api.http) = {
      post: "/v1/{parent=shelves/*}/books"
      body: "book"
    };
  };
}

是否在 URI 中包含版本号?

Google 的答案是:包含

主版本号作为 URI 的前缀,URI 中只包含主版本号,不体现小版本号。

Mark Masse 在 2011 年出版的《REST API Design Rulebook》中提到一个观点:URI 作为 Resource 的标识符号应该唯一的,包含 /V1 /V2 等版本信息会导致一个 Resoruce 有多个标识符,因此建议在 URI 中不包含版本信息。这个观点在实践中比较难采用。

API 一定会变化的,在路径中增加版本前缀带来的便利远远比 URI 唯一重要。 可以把 URI 中的版本前缀看作是 URI 的一部分,而不是 Resource Name 的一部分来保证 Resource Name 的唯一。 Google Design Guide 正是这么做的,下面是 Resource Name 和 URL 的区别:

# ResourceName 不包含版本号
//calendar.googleapis.com/users/john smith/events/123 
# URL 中包含版本号
https://calendar.googleapis.com/v3/users/john%20smith/events/123   

是否复用 HTTP 错误状态码?

Google 的答案是:复用

HTTP 的错误状态码的数量有限,覆盖不了数量众多的业务逻辑错误。为此,有的项目在实践中全部返回 200 OK,然后在 response body 中用定义一个 status 字段表示相应状态。个人认为这种做法不是很好,会影响到网络中的各种 HTTP 缓存设备的判断。

Google API Design Guide 以及 paypal、stripe 等公司的 REST API 接口设计都印证了这一点,正确的做法是: 把业务的错误状态映射到有限的 HTTP 错误状态码,然后在 response body 中解释具体的业务错误码和错误详情。 通用的 rpc 错误码和 HTTP 错误码映射见 google/rpc/code.proto,Response body 中的 Error Status 定义如下:

package google.rpc;

// The `Status` type defines a logical error model that is suitable for
// different programming environments, including REST APIs and RPC APIs.
message Status {
  // A simple error code that can be easily handled by the client. The
  // actual error code is defined by `google.rpc.Code`.
  int32 code = 1;

  // A developer-facing human-readable error message in English. It should
  // both explain the error and offer an actionable resolution to it.
  string message = 2;

  // Additional error information that the client code can use to handle
  // the error, such as retry info or a help link.
  repeated google.protobuf.Any details = 3;
}

接口定义文件的组织方式?

Google API Design Guide 对接口文件的组织方式也做出要求(针对 protobuf)。

  • 按照主版本号分目录,不同版本之间独立
container
├── BUILD.bazel
├── README.md
├── v1
│   ├── BUILD.bazel
│   ├── cluster_service.proto
│   ├── container_gapic.yaml
│   ├── container_grpc_service_config.json
│   └── container_v1.yaml
├── v1alpha1
│   ├── BUILD.bazel
│   └── cluster_service.proto
└── v1beta1
    ├── BUILD.bazel
    ├── cluster_service.proto
    ├── container_gapic.yaml
    ├── container_grpc_service_config.json
    └── container_v1beta1.yaml
  • 重要的内容放在前面,比如接口定义在前,message 定义在后
// Manages workspaces.
service Workspaces {
  // List workspaces.
  rpc ListWorkspaces(ListWorkspacesRequest) returns (ListWorkspacesResponse) {
    option (google.api.http) = {
      get: "/v1/{parent=projects/*/locations/*}/workspaces"
    };
  }

  // Get information about a Workspace.
  rpc GetWorkspace(GetWorkspaceRequest) returns (Workspace) {
    option (google.api.http) = {
      get: "/v1/{name=projects/*/locations/*/workspaces/*}"
    };
  }
  ...省略...
}

// Presents a workspace
message Workspace {
  // The Workspace name in the format of "projects/*/locations/*/workspaces/*".
  string name = 1;
}

// Request message for listing Workspaces.
message ListWorkspacesRequest {
  // The parent used for listing. It should have the format of
  // `projects/{number}/locations/{location}`.
  string parent = 1;
  // The page size for list pagination.
  int32 page_size = 2;
  // The page token for list pagination.
  string page_token = 3;
}
...省略...
  • 共用的 message 可以单独放在 XX_resource.proto/resource.proto 文件

共用的定义怎样放置?

Design Guide 中没有明确说明,参考 googleapis 中的做法。

  • 跨业务共用的定义:单独占用一个顶层目录,例如 googleapis 全局共用定义主要位于以下几个目录:
google/api  这里目录包含的文件不全是公用的,
            可以公用的主要是用来描述 api 到 rpc 映射关系的 annotation.proto 和 http.proto
google/rpc  response 中公用的 google.rpc.Status,任务状态的 XXInfo 和错误码
google/type 公用的类型定义,Color、Date、DateTime、TimeZone 等
google/geo  一个公用的用四点描述的地理平面空间 google.geo.type.Viewport
  • 业务内共用的定义:在对应的业务目录下独占一个目录,例如:
# actions 业务共用 type 目录
google/actions
├── sdk
│   └── v2
└── type  # actions 中共用的定义
    ├── BUILD.bazel
    ├── date_range.proto
    ├── datetime_range.proto
    └── type_aog.yaml
# analytics 共用 data 目录
google/analytics
├── admin
│   ├── BUILD.bazel
│   ├── v1alpha
│   └── v1beta
└── data
    ├── BUILD.bazel
    ├── v1alpha  # 共用的定义也可以按照版本分目录
    └── v1beta

常见场景下的接口设计约定

Google API Design Guide 中针对一些比较具体的场景做了约定,有的约定和平常接触的一些做法不同。

空白响应 - Empty Responses

Delete 操作执行成功,返回空白响应 google.protobuf.Empty,如果是软删除,返回状态发生更新的 Resource。 其它操作即使没有需要返回的内容也返回 xxxResponse,为未来变化预留空间。

区间描述约定 - Representing Ranges

左闭右开:

[start_key, end_key)
[start_time, end_time)

用 Labels 描述动态属性 - Resource Labels

message Book {
  string name = 1;
  map<string, string> labels = 2;
}

分页设计 - List Pagination

Google API Design Guilde 用 page_token 表示第几页,next_page_token 表示下一页页码。我总感觉 page_num 更合适,英语表达方式的原因么?

rpc ListBooks(ListBooksRequest) returns (ListBooksResponse);

message ListBooksRequest {
  string parent = 1;
  int32 page_size = 2;
  string page_token = 3;
}

message ListBooksResponse {
  repeated Book books = 1;
  string next_page_token = 2;
  int32  total_size = 3;
}

异步接口设计 - Long Running Operations

需要较长时间才完成的操作,接口不等待执行结束,直接返回,同时设计一个获取任务状态的方法。如果是 Resource 创建操作,把立即返回的 Resource 标记为未就绪。

跨层级查询- List Sub-Collections/Get Unique Resource From Sub-Collection

比如在所有的shelves 中查询某个 book,用 - 泛指所有:

GET https://library.googleapis.com/v1/shelves/-/books?filter=xxx

同理,跨层级直接读取 Resource:

GET https://library.googleapis.com/v1/shelves/-/books/{id}

结果排序 - Sorting Order

用字段 order_by 描述排序方式,使用 SQL 的语法:

message ListAlertsRequest {
  ...省略...
  string order_by = 5;
  ...省略...
}

SQL 语法描述

foo desc,bar
foo,bar desc

仅校验入参,不执行操作 - Request Validation

用字段 validate_only 描述:

bool validate_only = ...;

重复请求检测 - Request Duplication

用 request_id 提供请求的唯一 ID:

string request_id = ...;

枚举从 0 开始定义,从 1 开始使用 - Enum Default Value

enum Isolation {
  // Not specified.
  ISOLATION_UNSPECIFIED = 0;
  // Reads from a snapshot. Collisions occur if all reads and writes cannot be
  // logically serialized with concurrent transactions.
  SERIALIZABLE = 1;
  // Reads from a snapshot. Collisions occur if concurrent transactions write
  // to the same rows.
  SNAPSHOT = 2;
  ...
}

避免使用 uint32 和 fixed32 - Integer Types

不同的语言对 uint32 和 fixed32 处理方式可能有区别。

只请求部分字段 - Partial Response

用 fields 字段指定需要返回的字段。

GET https://library.googleapis.com/v1/shelves?$fields=shelves.name
GET https://library.googleapis.com/v1/shelves/123?$fields=name

同一资源多种视图 - Resource View

用一个枚举参数指明想要的视图格式,例如下面的 view:

package google.example.library.v1;

service Library {
  rpc ListBooks(ListBooksRequest) returns (ListBooksResponse) {
    option (google.api.http) = {
      get: "/v1/{name=shelves/*}/books"
    }
  };
}

enum BookView {
  // Not specified, equivalent to BASIC.
  BOOK_VIEW_UNSPECIFIED = 0;

  // Server responses only include author, title, ISBN and unique book ID.
  // The default value.
  BASIC = 1;

  // Full representation of the book is returned in server responses,
  // including contents of the book.
  FULL = 2;
}

message ListBooksRequest {
  string name = 1;

  // Specifies which parts of the book resource should be returned
  // in the response.
  BookView view = 2;
}

ETags

遵循 ETags 定义即可。

标记由服务端赋值的字段 - Output Fields

Google 用 google.api.field_behavior 来标记字段是输入赋值还是输出赋值:

import "google/api/field_behavior.proto";

message Book {
  string name = 1;
  Timestamp create_time = 2 [(google.api.field_behavior) = OUTPUT_ONLY];
}

默认存在的资源 - Singleton Resources

默认存在的 Singleton Resources 不需要 Create 和 Delete 方法,它们更像是 parent 的可配置的属性。

rpc GetSettings(GetSettingsRequest) returns (Settings) {
  option (google.api.http) = {
    get: "/v1/{name=users/*/settings}"
  };
}

rpc UpdateSettings(UpdateSettingsRequest) returns (Settings) {
  option (google.api.http) = {
    patch: "/v1/{settings.name=users/*/settings}"
    body: "settings"
  };
}

[...]

message Settings {
  string name = 1;
  // Settings fields omitted.
}

message GetSettingsRequest {
  string name = 1;
}

message UpdateSettingsRequest {
  Settings settings = 1;
  // Field mask to support partial updates.
  FieldMask update_mask = 2;
}

客户端主动断开 - Streaming Half-Close

双向数据的通信连接或者 client 发起的长连接,连接断开时由客户端主动发起 half-close,不需要专门定义一个关闭连接的 message。

用带有域名的 Resource Name 避免跨组织合作时命名冲突 - Domain-scoped names

定义的 Resourcce 时带上所在组织的域名。Domain-scoped names are widely used among Google APIs and Kubernetes APIs, such as:

  • The Protobuf Any type representation: type.googleapis.com/google.protobuf.Any
  • Stackdriver metric types: compute.googleapis.com/instance/cpu/utilization
  • Label keys: cloud.googleapis.com/location
  • Kubernetes API versions: networking.k8s.io/v1
  • The kind field in the x-kubernetes-group-version-kind OpenAPI extension.

多值参数怎么选类型? Bool、Enum 还是 String ?

  • Using bool type if we want to have a fixed design and intentionally don’t want to extend the functionality. For example, bool enable_tracing or bool enable_pretty_print.
  • Using an enum type if we want to have a flexible design but don’t expect the design will change often. The rule of thumb is the enum definition will only change once a year or less often. For example, enum TlsVersion or enum HttpVersion.
  • Using string type if we have an open ended design or the design can be changed frequently by an external standard. The supported values must be clearly documented. For example:

string region_code as defined by Unicode regions.

string language_code as defined by Unicode locales.

被删数据的保留期限 - Data Retention

被删除的资源需要根据情况保留 30 天, 7 天,1天。

  • For user metadata, user settings, and other important information, there should be 30-day data retention. For example, monitoring metrics, project metadata, and service definitions.
  • For large-volume user content, there should be 7-day data retention. For example, binary blobs and database tables.
  • For transient state or expensive storage, there should be 1-day data retention if feasible. For example, memcache instances and Redis servers.

Payloads 上限 - Large Payloads

Request body 和 Response body 不超过 32 MB。32MB is a commonly used limit in many systems.

如果数据量超过 10MB,考虑是否要写入对象存储,通过连接传递数据。

区分 empty value 和 unset value - Optional Primitive Fields

用 protobuf 3 中 optional 字段。

其它便捷操作

怎么避免用两套文件分别描述 API 接口和 RPC 接口?

Google 通过自身平台的 Transcoding HTTP/JSON to gRPC 能力,能够用一套 protobuf 文件同时描述 API 接口和 RPC 接口。 Google 的 Extensible Service Proxy 能够识别 protobuf 文件中用 option 描述的 API 接口和 RPC 接口的映射关系,自动将收到的 HTTP 请求转换为 RPC 请求,不需要再单独定义 API 接口。

如果不使用 google cloud,需要自行寻找解决方法。 Transcoding HTTP/JSON to gRPCgoogle/api/http.proto 介绍了 Google 采用的映射描述方法,样式如下:

// Get information about a Workspace.
rpc GetWorkspace(GetWorkspaceRequest) returns (Workspace) {
  option (google.api.http) = {
    get: "/v1/{name=projects/*/locations/*/workspaces/*}"
  };
}

// Create a Workspace.
rpc CreateWorkspace(CreateWorkspaceRequest) returns (Workspace) {
  option (google.api.http) = {
    post: "/v1/{parent=projects/*/locations/*}/workspaces"
    body: "workspace"
  };
}

// Updates a Workspace.
rpc UpdateWorkspace(UpdateWorkspaceRequest) returns (Workspace) {
  option (google.api.http) = {
    patch: "/v1/{name=projects/*/locations/*/Workspaces/*}"
    body: "workspace"
  };
}

// Deletes a Workspace.
rpc DeleteWorkspace(DeleteWorkspaceRequest) returns (google.protobuf.Empty) {
  option (google.api.http) = {
    delete: "/v1/{name=projects/*/locations/*/workspaces/*}"
  };
}

怎样自动生成 API 接口的代码文件?

不同的 http 框架的代码不同,没有统一的解决方案,目标框架应当提供相应的代码生成工具,或者利用 protoc 的 --plugin= 功能自我实现。目标框架的代码生成工具如果能够识别 Transcoding HTTP/JSON to gRPC 使用的 option 注解,可以避免再写一份 API 接口描述文件。

怎样导入 API 网关?

Google 是借助于 glcoud 平台的 Service Management API 来实现 HTTP API 的转换。google/example/endpointsapis 是一个演示 demo。

用 protoc 生成文件 service.descriptors:

cd googleapis/google/example/endpointsapis
protoc --proto_path=`pwd`/../../../ --include_imports --descriptor_set_out=service.descriptors google/example/endpointsapis/v1/workspace.proto

然后和配置文件 endpointsapis.yaml 一起上传:

gcloud endpoints services deploy service.descriptors endpointsapis.yaml

参考

  1. 李佶澳的博客
  2. Google API Design Guide
  3. Google API Explorer
  4. Google Calendar API
  5. Blogger API v3
  6. Google APIs
  7. googleapis/google/api/http.proto
  8. google/example/endpointsapis
  9. google/rpc/code.proto
  10. Naming conventions
  11. Why not use resource IDs to identify a resource?
  12. Standard fields
  13. Common design patterns
  14. Transcoding HTTP/JSON to gRPC
  15. Google: API Improvement Proposals
  16. Google API Linter

系统设计

  1. 各大云厂商的 API 设计风格
  2. Google 是如何实践 RESTful API 设计的?
  3. Netflix 的异地多活设计: Active-Active for Multi-Regional Resiliency
  4. Facebook 的缓存系统实践经验《Scaling Memcache at Facebook》
  5. 多机数据系统的正确性与一致性
  6. 《大型网站技术架构: 核心原理与案例分析》阅读摘录
  7. 《分布式金融架构课》阅读笔记2: 线性一致的分布式数据系统的实现过程
  8. 《分布式金融架构课》阅读笔记1: 单机&多机并发/多副本读写正确性和一致性
  9. 《消息队列高手课》阅读笔记: Rabbit/Rocket/Kafka/模型/消息事务/保序等
  10. 《消息队列高手课》阅读笔记: Rabbit/Rocket/Kafka/模型/消息事务/保序等
  11. 《Redis核心技术与实践》阅读笔记: 数据类型/存储开销/Rehash/案例等
  12. 《Redis核心技术与实践》阅读笔记: 数据类型/存储开销/Rehash/案例等
  13. 《高并发系统设计40问》阅读笔记: 数据库/缓存/消息队列/分布式服务
  14. 《高并发系统设计40问》阅读笔记: 数据库/缓存/消息队列/分布式服务
  15. 《MySQL实战45讲》阅读笔记: 索引类型/数据可靠性/事务/间隙锁/临时表等
  16. 系统性能分析方法论: 统计图谱工具
  17. 张磊《深入剖析Kubernetes》专栏的阅读笔记
  18. 代理服务软件haproxy、nginx、envoy对比,以及开源的API网关项目对比
  19. 蓝绿部署、金丝雀发布(灰度发布)、A/B测试的准确定义
  20. 阿里巴巴的应用限流和服务降级是怎样实现的?|如何打造平台稳定能力
  21. 陈皓《左耳听风》专栏的阅读笔记(持续更新)
  22. 好雨云帮,一款不错的国产开源PaaS
  23. 怎样为软件的不同版本命名?
  24. 怎样选择开源项目的license?
  25. Glusterfs的架构
  26. 怎样设计一个企业级的PaaS平台?
  27. 几种常见的LDAP系统
  28. DNS SRV介绍(一种用DNS做服务发现的方法)
  29. DNS,DNS-Domain Name System
  30. 思科的网络设备
  31. 虚拟化技术汇总
  32. 认证与授权系统的汇总
  33. 高可用实现方法汇总
  34. 编译器汇总
  35. Linux系统的优化方法
  36. CentOS7的一些变化
  37. 分布式系统的一些知识
  38. 计算机编程语言的特性汇总
  39. 网络通信的一些基础知识
  40. PCIE总线的一些知识
  41. 操作系统的API
  42. 网卡的一些知识
  43. Linux系统的构建过程
  44. 数据结构与算法
  45. CPU的相关知识

推荐阅读

Copyright @2011-2019 All rights reserved. 转载请添加原文连接,合作请加微信lijiaocn或者发送邮件: [email protected],备注网站合作

友情链接:  李佶澳的博客  小鸟笔记  软件手册  编程手册  运营手册  网络课程  收藏文章  发现知识星球  百度搜索 谷歌搜索