>  기사  >  백엔드 개발  >  C++ 프로그래머를 위한 프로토콜 버퍼 기본 가이드

C++ 프로그래머를 위한 프로토콜 버퍼 기본 가이드

高洛峰
高洛峰원래의
2016-11-08 10:02:331918검색

이 튜토리얼은 C++ 프로그래머를 위한 프로토콜 버퍼에 대한 기본 소개를 제공합니다. 간단한 예제 애플리케이션을 생성하면 다음이 표시됩니다.

.proto 파일에서 메시지 형식 정의

프로토콜 버퍼 컴파일러 사용

C++ 프로토콜 버퍼 API 사용 메시지 읽기 및 쓰기

이 문서는 C++에서 프로토콜 버퍼를 사용하는 방법에 대한 포괄적인 가이드는 아닙니다. 자세한 내용은 프로토콜 버퍼 언어 가이드 및 인코딩 참조를 참조하세요.

프로토콜 버퍼를 사용하는 이유

다음에 사용할 예는 파일에서 연락처 세부 정보를 읽는 매우 간단한 "주소록" 애플리케이션입니다. 주소록에 있는 각 사람은 이름, ID, 이메일 주소, 연락처를 가지고 있습니다.

구조화된 데이터를 직렬화하고 가져오는 방법은 다음과 같습니다.

기본 메모리 내 데이터 구조를 바이너리 형식으로 보내고 받습니다. 일반적으로 이는 수신/읽기 코드가 메모리 레이아웃, 크고 작은 엔디안 등의 동일한 환경에 대해 컴파일되어야 하기 때문에 취약한 접근 방식입니다. 동시에 파일이 증가하면 원본 형식 데이터가 해당 형식과 관련된 소프트웨어와 함께 빠르게 확산되어 파일 형식 확장이 어려워집니다.

데이터 항목을 문자열(예: 12:3:-23:67의 정수 4개)로 인코딩하는 임시 메서드를 만들 수 있습니다. 일회성 인코딩 및 디코딩 코드를 작성해야 하고 디코딩에는 런타임 비용이 적지만 간단하고 유연한 방법입니다. 이는 매우 간단한 데이터를 인코딩하는 데 가장 적합합니다.

데이터를 XML로 직렬화합니다. XML은 사람이 읽을 수 있는 형식이고 많은 언어용으로 개발된 라이브러리가 있기 때문에 이 접근 방식은 매우 매력적입니다. 다른 프로그램 및 프로젝트와 데이터를 공유하려는 경우 이는 좋은 옵션이 될 수 있습니다. 그러나 XML은 공간 집약적인 것으로 알려져 있으며 인코딩 및 디코딩 시 프로그램에 막대한 성능 저하를 초래합니다. 동시에 XML DOM 트리를 사용하여 작업하는 것은 클래스의 간단한 필드를 조작하는 것보다 더 복잡한 것으로 간주됩니다.

프로토콜 버퍼는 이 문제에 대한 유연하고 효율적이며 자동화된 솔루션입니다. 프로토콜 버퍼를 사용하려면 저장하려는 데이터 구조를 설명하는 .proto 사양을 작성해야 합니다. 프로토콜 버퍼 컴파일러는 .proto 파일을 사용하여 효율적인 바이너리 형식으로 프로토콜 버퍼 데이터의 인코딩 및 디코딩을 자동화하는 클래스를 생성할 수 있습니다. 생성된 클래스는 프로토콜 버퍼의 필드를 구성하기 위한 getter 및 setter를 제공하며, 하나의 단위로서 프로토콜 버퍼 읽기 및 쓰기 세부 사항을 처리합니다. 중요한 것은 프로토콜 버퍼 형식이 형식 확장을 지원하며 코드가 이전 형식으로 인코딩된 데이터를 계속 읽을 수 있다는 것입니다.

예제 코드 위치

예제 코드는 소스 코드 패키지의 "examples" 폴더에 포함되어 있습니다. 코드는 여기에서 다운로드할 수 있습니다.

프로토콜 형식 정의

자신만의 주소록 애플리케이션을 만들려면 .proto로 시작해야 합니다. .proto 파일의 정의는 간단합니다. 직렬화해야 하는 각 데이터 구조에 대한 메시지를 추가한 다음 메시지의 각 필드에 대한 이름과 유형을 지정합니다. 다음은 메시지를 정의하는 .proto 파일 addressbook.proto입니다.

package tutorial; 
message Person { 
  required string name = 1; 
  required int32 id = 2; 
  optional string email = 3; 
  enum PhoneType { 
    MOBILE = 0; 
    HOME = 1; 
    WORK = 2; 
  } 
  message PhoneNumber { 
    required string number = 1; 
    optional PhoneType type = 2 [default = HOME]; 
  } 
  repeated PhoneNumber phone = 4; 
} 
message AddressBook { 
  repeated Person person = 1; 
}

보시다시피 구문은 C++나 Java와 유사합니다. 파일의 각 부분이 수행하는 작업을 살펴보는 것부터 시작하겠습니다.

.proto 파일은 패키지 선언으로 시작하여 서로 다른 프로젝트 간의 이름 충돌을 방지합니다. C++에서는 생성된 클래스가 패키지 이름과 동일한 네임스페이스에 배치됩니다.

다음으로 메시지를 정의해야 합니다. 메시지는 특정 유형의 필드 모음일 뿐입니다. bool, int32, float, double 및 string을 포함한 대부분의 표준 단순 데이터 유형을 필드 유형으로 사용할 수 있습니다. 다른 메시지 유형을 필드 유형으로 사용하여 메시지에 더 많은 데이터 구조를 추가할 수도 있습니다. 위의 예에서 Person 메시지에는 PhoneNumber 메시지가 포함되어 있고 AddressBook 메시지에는 Person 메시지가 포함되어 있습니다. 다른 메시지 내에 중첩된 메시지 유형을 정의할 수도 있습니다. 보시다시피 PhoneNumber 유형은 Person 내에 정의됩니다. 필드 중 하나의 값이 미리 정의된 값 목록의 값이 되도록 하려면 열거형 유형을 정의할 수도 있습니다. 여기서 전화번호가 MOBILE, HOME 또는 WORK 중 하나가 되도록 지정할 수 있습니다.

각 요소의 = 1, = 2 태그는 바이너리 인코딩에 사용되는 고유한 "태그"를 결정합니다. 태그 번호 1-15는 큰 숫자보다 인코딩하는 데 1바이트가 덜 필요하므로 최적화를 위해 자주 사용되거나 반복되는 요소에 이러한 태그를 사용할 수 있으며, 자주 사용되지 않는 요소 또는 선택 요소에 대해 태그 16 이상을 남겨 둘 수 있습니다. 반복 필드의 각 요소에는 레이블 번호를 다시 코딩해야 하므로 반복 필드가 이 최적화에 적합합니다.

각 필드에는 다음 수정자로 주석을 달아야 합니다.

required:必须提供该字段的值,否则消息会被认为是 “未初始化的”(uninitialized)。如果 libprotobuf 以调试模式编译,序列化未初始化的消息将引起一个断言失败。以优化形式构建,将会跳过检查,并且无论如何都会写入该消息。然而,解析未初始化的消息总是会失败(通过 parse 方法返回 false)。除此之外,一个 required 字段的表现与 optional 字段完全一样。

optional:字段可能会被设置,也可能不会。如果一个 optional 字段没被设置,它将使用默认值。对于简单类型,你可以指定你自己的默认值,正如例子中我们对电话号码的 type 一样,否则使用系统默认值:数字类型为 0、字符串为空字符串、布尔值为 false。对于嵌套消息,默认值总为消息的“默认实例”或“原型”,它的所有字段都没被设置。调用 accessor 来获取一个没有显式设置的 optional(或 required) 字段的值总是返回字段的默认值。

repeated:字段可以重复任意次数(包括 0 次)。repeated 值的顺序会被保存于 protocol buffer。可以将 repeated 字段想象为动态大小的数组。

你可以查找关于编写 .proto 文件的完整指导——包括所有可能的字段类型——在 Protocol Buffer Language Guide 里面。不要在这里面查找与类继承相似的特性,因为 protocol buffers 不会做这些。

required 是永久性的

在把一个字段标识为 required 的时候,你应该特别小心。如果在某些情况下你不想写入或者发送一个 required 的字段,那么将该字段更改为 optional 可能会遇到问题——旧版本的读者(LCTT 译注:即读取、解析旧版本 Protocol Buffer 消息的一方)会认为不含该字段的消息是不完整的,从而有可能会拒绝解析。在这种情况下,你应该考虑编写特别针对于应用程序的、自定义的消息校验函数。Google 的一些工程师得出了一个结论:使用 required 弊多于利;他们更愿意使用 optional 和 repeated 而不是 required。当然,这个观点并不具有普遍性。

编译你的 Protocol Buffers

既然你有了一个 .proto,那你需要做的下一件事就是生成一个将用于读写 AddressBook 消息的类(从而包括 Person 和 PhoneNumber)。为了做到这样,你需要在你的 .proto 上运行 protocol buffer 编译器 protoc:

如果你没有安装编译器,请下载这个包,并按照 README 中的指令进行安装。

现在运行编译器,指定源目录(你的应用程序源代码位于哪里——如果你没有提供任何值,将使用当前目录)、目标目录(你想要生成的代码放在哪里;常与 $SRC_DIR 相同),以及你的 .proto 路径。在此示例中:

protoc -I=$SRC_DIR --cpp_out=$DST_DIR $SRC_DIR/addressbook.proto

因为你想要 C++ 的类,所以你使用了 --cpp_out 选项——也为其他支持的语言提供了类似选项。

在你指定的目标文件夹,将生成以下的文件:

addressbook.pb.h,声明你生成类的头文件。

addressbook.pb.cc,包含你的类的实现。

Protocol Buffer API

让我们看看生成的一些代码,了解一下编译器为你创建了什么类和函数。如果你查看 addressbook.pb.h,你可以看到有一个在 addressbook.proto 中指定所有消息的类。关注 Person 类,可以看到编译器为每个字段生成了读写函数(accessors)。例如,对于 name、id、email 和 phone 字段,有下面这些方法:(LCTT 译注:此处原文所指文件名有误,径该之。)

// name 
inline bool has_name() const; 
inline void clear_name(); 
inline const ::std::string& name() const; 
inline void set_name(const ::std::string& value); 
inline void set_name(const char* value); 
inline ::std::string* mutable_name(); 
// id 
inline bool has_id() const; 
inline void clear_id(); 
inline int32_t id() const; 
inline void set_id(int32_t value); 
// email 
inline bool has_email() const; 
inline void clear_email(); 
inline const ::std::string& email() const; 
inline void set_email(const ::std::string& value); 
inline void set_email(const char* value); 
inline ::std::string* mutable_email(); 
// phone 
inline int phone_size() const; 
inline void clear_phone(); 
inline const ::google::protobuf::RepeatedPtrField< ::tutorial::Person_PhoneNumber >& phone() const; 
inline ::google::protobuf::RepeatedPtrField< ::tutorial::Person_PhoneNumber >* mutable_phone(); 
inline const ::tutorial::Person_PhoneNumber& phone(int index) const; 
inline ::tutorial::Person_PhoneNumber* mutable_phone(int index); 
inline ::tutorial::Person_PhoneNumber* add_phone();

正如你所见到,getters 的名字与字段的小写名字完全一样,并且 setter 方法以 set_ 开头。同时每个单一(singular)(required 或 optional)字段都有 has_ 方法,该方法在字段被设置了值的情况下返回 true。最后,所有字段都有一个 clear_ 方法,用以清除字段到空(empty)状态。

数字型的 id 字段仅有上述的基本读写函数(accessors)集合,而 name 和 email 字段有两个额外的方法,因为它们是字符串——一个是可以获得字符串直接指针的mutable_ 的 getter ,另一个为额外的 setter。注意,尽管 email 还没被设置(set),你也可以调用 mutable_email;因为 email 会被自动地初始化为空字符串。在本例中,如果你有一个单一的(required 或 optional)消息字段,它会有一个 mutable_ 方法,而没有 set_ 方法。

repeated 字段也有一些特殊的方法——如果你看看 repeated 的 phone 字段的方法,你可以看到:

检查 repeated 字段的 _size(也就是说,与 Person 相关的电话号码的个数)

使用下标取得特定的电话号码

更新特定下标的电话号码

添加新的电话号码到消息中,之后你便可以编辑。(repeated 标量类型有一个 add_ 方法,用于传入新的值)

为了获取 protocol 编译器为所有字段定义生成的方法的信息,可以查看 C++ generated code reference。

枚举和嵌套类

与 .proto 的枚举相对应,生成的代码包含了一个 PhoneType 枚举。你可以通过 Person::PhoneType 引用这个类型,通过 Person::MOBILE、Person::HOME 和 Person::WORK 引用它的值。(实现细节有点复杂,但是你无须了解它们而可以直接使用)

编译器也生成了一个 Person::PhoneNumber 的嵌套类。如果你查看代码,你可以发现真正的类型为 Person_PhoneNumber,但它通过在 Person 内部使用 typedef 定义,使你可以把 Person_PhoneNumber 当成嵌套类。唯一产生影响的一个例子是,如果你想要在其他文件前置声明该类——在 C++ 中你不能前置声明嵌套类,但是你可以前置声明 Person_PhoneNumber。

标准的消息方法

所有的消息方法都包含了许多别的方法,用于检查和操作整个消息,包括:

bool IsInitialized() const; :检查是否所有 required 字段已经被设置。

string DebugString() const; :返回人类可读的消息表示,对调试特别有用。

void CopyFrom(const Person& from);:使用给定的值重写消息。

void Clear();:清除所有元素为空的状态。

上面这些方法以及下一节要讲的 I/O 方法实现了被所有 C++ protocol buffer 类共享的消息(Message)接口。为了获取更多信息,请查看 complete API documentation for Message。

解析和序列化

最后,所有 protocol buffer 类都有读写你选定类型消息的方法,这些方法使用了特定的 protocol buffer 二进制格式。这些方法包括:

bool SerializeToString(string* output) const;:序列化消息并将消息字节数据存储在给定的字符串中。注意,字节数据是二进制格式的,而不是文本格式;我们只使用 string 类作为合适的容器。

bool ParseFromString(const string& data);:从给定的字符创解析消息。

bool SerializeToOstream(ostream* output) const;:将消息写到给定的 C++ ostream。

bool ParseFromIstream(istream* input);:从给定的 C++ istream 解析消息。

这些只是两个用于解析和序列化的选择。再次说明,可以查看 Message API reference 完整的列表。

Protocol Buffers 和面向对象设计

Protocol buffer 类通常只是纯粹的数据存储器(像 C++ 中的结构体);它们在对象模型中并不是一等公民。如果你想向生成的 protocol buffer 类中添加更丰富的行为,最好的方法就是在应用程序中对它进行封装。如果你无权控制 .proto 文件的设计的话,封装 protocol buffers 也是一个好主意(例如,你从另一个项目中重用一个 .proto 文件)。在那种情况下,你可以用封装类来设计接口,以更好地适应你的应用程序的特定环境:隐藏一些数据和方法,暴露一些便于使用的函数,等等。但是你绝对不要通过继承生成的类来添加行为。这样做的话,会破坏其内部机制,并且不是一个好的面向对象的实践。

写消息

现在我们尝试使用 protocol buffer 类。你的地址簿程序想要做的第一件事是将个人详细信息写入到地址簿文件。为了做到这一点,你需要创建、填充 protocol buffer 类实例,并且将它们写入到一个输出流(output stream)。

这里的程序可以从文件读取 AddressBook,根据用户输入,将新 Person 添加到 AddressBook,并且再次将新的 AddressBook 写回文件。这部分直接调用或引用 protocol buffer 类的代码会以“// pb”标出。

#include <iostream> 
#include <fstream> 
#include <string> 
#include "addressbook.pb.h" // pb 
using namespace std; 
// This function fills in a Person message based on user input. 
void PromptForAddress(tutorial::Person* person) { 
  cout << "Enter person ID number: "; 
  int id; 
  cin >> id; 
  person->set_id(id);   // pb 
  cin.ignore(256, &#39;\n&#39;); 
  cout << "Enter name: "; 
  getline(cin, *person->mutable_name());    // pb 
  cout << "Enter email address (blank for none): "; 
  string email; 
  getline(cin, email); 
  if (!email.empty()) { // pb 
    person->set_email(email);   // pb 
  } 
  while (true) { 
    cout << "Enter a phone number (or leave blank to finish): "; 
    string number; 
    getline(cin, number); 
    if (number.empty()) { 
      break; 
    } 
    tutorial::Person::PhoneNumber* phone_number = person->add_phone();  //pb 
    phone_number->set_number(number);   // pb 
    cout << "Is this a mobile, home, or work phone? "; 
    string type; 
    getline(cin, type); 
    if (type == "mobile") { 
      phone_number->set_type(tutorial::Person::MOBILE); // pb 
    } else if (type == "home") { 
      phone_number->set_type(tutorial::Person::HOME);   // pb 
    } else if (type == "work") { 
      phone_number->set_type(tutorial::Person::WORK);   // pb 
    } else { 
      cout << "Unknown phone type.  Using default." << endl; 
    } 
  } 
} 
// Main function:  Reads the entire address book from a file, 
//   adds one person based on user input, then writes it back out to the same 
//   file. 
int main(int argc, char* argv[]) { 
  // Verify that the version of the library that we linked against is 
  // compatible with the version of the headers we compiled against. 
  GOOGLE_PROTOBUF_VERIFY_VERSION;   // pb 
  if (argc != 2) { 
    cerr << "Usage:  " << argv[0] << " ADDRESS_BOOK_FILE" << endl; 
    return -1; 
  } 
  tutorial::AddressBook address_book;   // pb 
  { 
    // Read the existing address book. 
    fstream input(argv[1], ios::in | ios::binary); 
    if (!input) { 
      cout << argv[1] << ": File not found.  Creating a new file." << endl; 
    } else if (!address_book.ParseFromIstream(&input)) {    // pb 
      cerr << "Failed to parse address book." << endl; 
      return -1; 
    } 
  } 
  // Add an address. 
  PromptForAddress(address_book.add_person());  // pb 
  { 
    // Write the new address book back to disk. 
    fstream output(argv[1], ios::out | ios::trunc | ios::binary); 
    if (!address_book.SerializeToOstream(&output)) {    // pb 
      cerr << "Failed to write address book." << endl; 
      return -1; 
    } 
  } 
  // Optional:  Delete all global objects allocated by libprotobuf. 
  google::protobuf::ShutdownProtobufLibrary();  // pb 
  return 0; 
}

注意 GOOGLE_PROTOBUF_VERIFY_VERSION 宏。它是一种好的实践——虽然不是严格必须的——在使用 C++ Protocol Buffer 库之前执行该宏。它可以保证避免不小心链接到一个与编译的头文件版本不兼容的库版本。如果被检查出来版本不匹配,程序将会终止。注意,每个 .pb.cc 文件在初始化时会自动调用这个宏。

同时注意在程序最后调用 ShutdownProtobufLibrary()。它用于释放 Protocol Buffer 库申请的所有全局对象。对大部分程序,这不是必须的,因为虽然程序只是简单退出,但是 OS 会处理释放程序的所有内存。然而,如果你使用了内存泄漏检测工具,工具要求全部对象都要释放,或者你正在写一个 Protocol Buffer 库,该库可能会被一个进程多次加载和卸载,那么你可能需要强制 Protocol Buffer 清除所有东西。

读取消息

当然,如果你无法从它获取任何信息,那么这个地址簿没多大用处!这个示例读取上面例子创建的文件,并打印文件里的所有内容。

#include <iostream> 
#include <fstream> 
#include <string> 
#include "addressbook.pb.h" // pb 
using namespace std; 
// Iterates though all people in the AddressBook and prints info about them. 
void ListPeople(const tutorial::AddressBook& address_book) {    // pb 
  for (int i = 0; i < address_book.person_size(); i++) {        // pb 
    const tutorial::Person& person = address_book.person(i);    // pb 
    cout << "Person ID: " << person.id() << endl;   // pb 
    cout << "  Name: " << person.name() << endl;    // pb 
    if (person.has_email()) {   // pb 
      cout << "  E-mail address: " << person.email() << endl;   // pb 
    } 
    for (int j = 0; j < person.phone_size(); j++) { // pb 
      const tutorial::Person::PhoneNumber& phone_number = person.phone(j);  // pb 
      switch (phone_number.type()) {    // pb 
        case tutorial::Person::MOBILE:  // pb 
          cout << "  Mobile phone #: "; 
          break; 
        case tutorial::Person::HOME:    // pb 
          cout << "  Home phone #: "; 
          break; 
        case tutorial::Person::WORK:    // pb 
          cout << "  Work phone #: "; 
          break; 
      } 
      cout << phone_number.number() << endl;    // ob 
    } 
  } 
} 
// Main function:  Reads the entire address book from a file and prints all 
//   the information inside. 
int main(int argc, char* argv[]) { 
  // Verify that the version of the library that we linked against is 
  // compatible with the version of the headers we compiled against. 
  GOOGLE_PROTOBUF_VERIFY_VERSION;   // pb 
  if (argc != 2) { 
    cerr << "Usage:  " << argv[0] << " ADDRESS_BOOK_FILE" << endl; 
    return -1; 
  } 
  tutorial::AddressBook address_book;   // pb 
  { 
    // Read the existing address book. 
    fstream input(argv[1], ios::in | ios::binary); 
    if (!address_book.ParseFromIstream(&input)) {   // pb 
      cerr << "Failed to parse address book." << endl; 
      return -1; 
    } 
  } 
  ListPeople(address_book); 
  // Optional:  Delete all global objects allocated by libprotobuf. 
  google::protobuf::ShutdownProtobufLibrary();  // pb 
  return 0; 
}

扩展 Protocol Buffer

或早或晚在你发布了使用 protocol buffer 的代码之后,毫无疑问,你会想要 "改善" protocol buffer 的定义。如果你想要新的 buffers 向后兼容,并且老的 buffers 向前兼容——几乎可以肯定你很渴望这个——这里有一些规则,你需要遵守。在新的 protocol buffer 版本:

你绝不可以修改任何已存在字段的标签数字

你绝不可以添加或删除任何 required 字段

你可以删除 optional 或 repeated 字段

你可以添加新的 optional 或 repeated 字段,但是你必须使用新的标签数字(也就是说,标签数字在 protocol buffer 中从未使用过,甚至不能是已删除字段的标签数字)。

(对于上面规则有一些例外情况,但它们很少用到。)

如果你能遵守这些规则,旧代码则可以欢快地读取新的消息,并且简单地忽略所有新的字段。对于旧代码来说,被删除的 optional 字段将会简单地赋予默认值,被删除的 repeated 字段会为空。新代码显然可以读取旧消息。然而,请记住新的 optional 字段不会呈现在旧消息中,因此你需要显式地使用 has_ 检查它们是否被设置或者在 .proto 文件在标签数字后使用 [default = value] 提供一个合理的默认值。如果一个 optional 元素没有指定默认值,它将会使用类型特定的默认值:对于字符串,默认值为空字符串;对于布尔值,默认值为 false;对于数字类型,默认类型为 0。注意,如果你添加一个新的 repeated 字段,新代码将无法辨别它被留空(被新代码)或者从没被设置(被旧代码),因为 repeated 字段没有 has_ 标志。

优化技巧

C++ Protocol Buffer 库已极度优化过了。但是,恰当的用法能够更多地提高性能。这里是一些技巧,可以帮你从库中挤压出最后一点速度:

尽可能复用消息对象。即使它们被清除掉,消息也会尽量保存所有被分配来重用的内存。因此,如果我们正在处理许多相同类型或一系列相似结构的消息,一个好的办法是重用相同的消息对象,从而减少内存分配的负担。但是,随着时间的流逝,对象可能会膨胀变大,尤其是当你的消息尺寸(LCTT 译注:各消息内容不同,有些消息内容多一些,有些消息内容少一些)不同的时候,或者你偶尔创建了一个比平常大很多的消息的时候。你应该自己通过调用 SpaceUsed 方法监测消息对象的大小,并在它太大的时候删除它。

对于在多线程中分配大量小对象的情况,你的操作系统内存分配器可能优化得不够好。你可以尝试使用 google 的 tcmalloc。

高级用法

Protocol Buffers 绝不仅用于简单的数据存取以及序列化。请阅读 C++ API reference 来看看你还能用它来做什么。

protocol 消息类所提供的一个关键特性就是反射(reflection)。你不需要针对一个特殊的消息类型编写代码,就可以遍历一个消息的字段并操作它们的值。一个使用反射的有用方法是 protocol 消息与其他编码互相转换,比如 XML 或 JSON。反射的一个更高级的用法可能就是可以找出两个相同类型的消息之间的区别,或者开发某种“协议消息的正则表达式”,利用正则表达式,你可以对某种消息内容进行匹配。只要你发挥你的想像力,就有可能将 Protocol Buffers 应用到一个更广泛的、你可能一开始就期望解决的问题范围上。


성명:
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.