• 设为首页
  • 点击收藏
  • 手机版
    手机扫一扫访问
    迪恩网络手机版
  • 关注官方公众号
    微信扫一扫关注
    迪恩网络公众号

使用protobuf(proto3,C++和go语言)

原作者: [db:作者] 来自: [db:来源] 收藏 邀请

在这里,我先讲述C++使用protobuf,之后,会补充使用go语言使用protobuf

使用protobuf需要有如下步骤:

  1. .proto文件中定义消息(message)格式。
  2. 使用protobuf的编译器编译.proto文件成为相应的语言代码。
  3. 使用对应语言的protobuf API读写消息。
  4. 在这里,我直接使用了官方的示例,之后打算使用grpc简单转写这个示例。官方示例实现了一个称为addressbook的功能,具体包括两部分,第一部分是向addressbook中添加个人信息,第二部分是,读取个人信息。在这里实现的第一步是在.proto中定义个人的结构,当然,如果你想采取自顶向下设计的话,可能会先定义对用户接口。

下面我们看一下定义的.proto的文件的源代码:

// [START declaration]
syntax = "proto3";
package tutorial;

import "google/protobuf/timestamp.proto";
// [END declaration]
 
// [START messages]
message Person {
  string name = 1;
  int32 id = 2;  // Unique ID number for this person.
  string email = 3;

  enum PhoneType {
    MOBILE = 0;
    HOME = 1;
    WORK = 2;
  }

  message PhoneNumber {
    string number = 1;
    PhoneType type = 2;
  }

  repeated PhoneNumber phones = 4;
  google.protobuf.Timestamp last_updated = 5;
}

// Our address book file is just one of these.
message AddressBook {
  repeated Person people = 1;
}
// [END messages]

这里,我们对.proto文件所使用的语法进行简单讲解。

1protobuf使用的.proto文件以包声明开始,包声明和C++中的namespace对应,在某个包声明中定义的消息,会出现在对应的namespace命名空间中。import语句用来导入其他.proto文件中的消息定义,这样就可以在多个.proto文件中定义消息,然后关联使用了。

2)然后,你需要定义消息结构。一个消息包括多个带类型的成员。protobuf有许多标准的简单数据类型,包括bool, int32, floatdouble以及string, protobuf自带的.proto文件中也有一些消息结构定义,例如上面出现的google.protobuf.Timestamp。当然,你也可以根据这些类型,进一步构造其他消息,例如上面的Person包含了PhoneNumber消息,AddressBook包含了Person消息。你也可以在其他消息中定义消息类型,例如上面出现在PhoneNUmberPerson中进行定义。你还可以定义enum类型,例如上面的PhoneType,包含MOBILE,HOMEWORK三个可选值。

=1”, “=2”是用来在二进制编码中标识对应字段的tagtag1-15范围内只需要一个byte来编码,而较大的数字需要两个byte来编码,所以对于常用的那些字段,可以使用1-15范围内的tag

另外,每一个tag可以使用如下修饰符修饰:

1singular: 表示这个字段可以有一个,也可以没有。如果没有的话,在编码的时候,不会占用空间。

2repeated: 表示这个字段会重复0次或者更多次,这个字段里的值会按照顺序编码。

2. 定义完了.proto文件,下一步就是编译这个proto文件,我们假设这个proto文件名为addressbook.proto。为了编译这个文件,运行如下的语句:

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

其中-I指定proto文件所在的位置,$DST_DIR指定生成文件所在的位置,这里--cpp_out表示生成文件为C++文件,生成目录在$DST_DIR$SRC_DIR/addressbook.proto

如果你在proto所在文件调用上述命令,可以简写如下:

protoc --cpp_out=. addressbook.proto

调用上述命令,生成的文件为addressbook.pb.haddressbook.pb.cc。可以推测,对于xxx.proto,生成文件应该为xxx.pb.hxxx.pb.cc

 

下面简单查看一些类的定义:

class Person_PhoneNumber : public ::google::protobuf::Message /* @@protoc_insertion_point(class_definition::tutorial.Person.PhoneNumber) */ {
public:
  Person_PhoneNumber();
  virtual ~Person_PhoneNumber();

  static const ::google::protobuf::Descriptor* descriptor() {
    return default_instance().GetDescriptor();
  }

  // accessors ----------------------------------------------------------------
  // string number = 1;
  void clear_number();
  const ::std::string& number() const;
  void set_number(const ::std::string& value);
  void set_number(::std::string&& value);
  void set_number(const char* value);
  void set_number(const char* value, size_t size);
  ::std::string* mutable_number();
  ::std::string* release_number();
  void set_allocated_number(::std::string* number);

  // .tutorial.Person.PhoneType type = 2;
  void clear_type();
  ::tutorial::Person_PhoneType type() const;
  void set_type(::tutorial::Person_PhoneType value);
};

这里的descriptor函数,可以用于反射处理。proto文件在编译时,会提供比较详细的操作和获取函数,当做普通类处理,也会很方便。另外注意这个函数的命令Person_PhoneNumber。在proto文件中,Person为外部类,PhoneNumber是内嵌在Person中的类,对应生成的类名就是按照上面的规则。注意下mutable_number方法,这个方法在没有设置number的时候也可以调用,在调用时,number会被初始化为空字符串。

enum Person_PhoneType {
  Person_PhoneType_MOBILE = 0,
  Person_PhoneType_HOME = 1,
  Person_PhoneType_WORK = 2,
  ...
};

class Person : public ::google::protobuf::Message /* @@protoc_insertion_point(class_definition: tutorial.Person) */ {
public:
  Person();
  virtual ~Person();

  static const ::google::protobuf::Descriptor* descriptor() {
    return default_instance().GetDescriptor();
  }

  typedef Person_PhoneNumber PhoneNumber;
  typedef Person_PhoneType PhoneType;

  static const PhoneType MOBILE = Person_PhoneType_MOBILE;
  static const PhoneType HOME = Person_PhoneType_HOME;
  static const PhoneType WORK = Person_PhoneType_WORK;

  static inline bool PhoneType_IsValid(int value) {
    return Person_PhoneType_IsValid(value);
  }
  static inline const ::std::string& PhoneType_Name(PhoneType value) {
    return Person_PhoneType_Name(value);
  }
  static inline bool PhoneType_Parse(const ::std::string& name, PhoneType* value) {
    return Person_PhoneType_Parse(name, value);
  }

  // accessors -------------------------------------------
  // repeated .tutorial.Person.PhoneNumber phones = 4;
  int phones_size() const;
  void clear_phones();
  ::tutorial::Person_PhoneNumber* mutable_phones(int index);
  ::google::protobuf::RepeatedPtrField<::tutorial::Person_PhoneNumber>* mutable_phones();
  const ::tutorial::Person_PhoneNumber& phones(int index) const;
  ::tutorial::Person_PhoneNumber* add_phones();
  const ::google::protobuf::RepeatedPtrField<::tutorial::Person_PhoneNumber>& phones() const;

  // string name = 1;
  // string email = 3;

  // .google.protobuf.Timestamp last_updated = 5;
  bool has_last_updated() const;
  void clear_last_updated();
  const ::google::protobuf::Timestamp& last_updated() const;
  ::google::protobuf::Timestamp* release_last_updated();
  ::google::protobuf::Timestamp* mutable_last_updated();
  void set_allocated_last_updated(::google::protobuf::Timestamp* last_updated);

  // int32 id = 2;
  void clear_id();
  ::google::protobuf::int32 id() const;
  void set_id(::google::protobuf::int32 value);
};

这个类的定义和上面的Person_PhoneNumber没有太大的差别,其中的typedef类型重定义和const定义,通过这种方式,来使得PhoneNumber一类的内嵌类使用起来更加自然,更符合.proto文件中的定义。可以查看一下不同类型的成员的不同操作方法。同一个类型的成员,提供的操作方法基本相同。另外注意一点,Person_PhoneNumberPerson类都继承于::google::protobuf::Message

 

标准的Message方法

每一个消息类都有很多别的方法,让你来检查或者操作整个消息,消息类有这些方法,因为继承于Message类,或者直接使用下面的方法,或者重写了虚函数。

1) bool IsInitialialized() const; : 检查是不是所有必需的字段都已经设置, 这个函数是虚函数。

2) string DebugString() const; : 返回一个可读的消息表示,很适合用于调试。这个函数的实现如下:

string Message::DebugString() const {
  string debug_string;
  TextFormat::Printer printer;
  printer.SetExpandAny(true);
  printer.PrintToString(*this, &debug_string);
  return debug_string;
}

输出的大致内容可以参考下面的函数:

void TextFormat::Printer::Print(const Message& message, TextGenerator* generator) const {
  const Descriptor* descriptor = message.GetDescriptor();
  auto itr = custom_message_printers_.find(descriptor);

  if (itr != custom_message_printers_.end()) {
    itr->second->Print(message, single_line_mode_, generator);
    return;
  }

  const Reflection* reflection = message.GetReflection();
  if (descriptor->full_name() == internal::kAnyFullTypeName && expand_any_ &&
      PrintAny(message, generator)) {
    return;
  }

  std::vector<const FieldDescriptor*> fields;

  if (descriptor->options().map_entry()) {
    fields.push_back(descriptor->field(0));
    fields.push_back(descriptor->field(1));
  } else {
    reflection->ListFields(message, &fields);
  }

  if (print_message_fields_in_index_order_) {
    std::sort(fields.begin(), fields.end(), FieldIndexSorter());
  }

  for (int i = 0; i < fields.size(); i++) {
    PrintField(message, reflection, fields[i], generator);
  }

  if (!hide_unknown_fields_) {
    PrintUnknownFields(reflection->GetUnknownFields(message), generator);
  }
}

1) void CopyFrom(const Person& from); : 使用from的值来覆盖现有值,这个函数是虚函数。

2) void Clear(); 清理所有的元素,将消息重置为空值状态,这个函数是虚函数。

 

消息的解析和序列号

每一个消息类都有方法用protobuf二进制格式写入到string或者输出流,也可以从string或者输入流读取数据,来设置值。这些方法都是来自于Message类(或者间接来自于MessageLite)。这些方法包括:

1)bool SerializeToString(string* output) const; :将消息转化成protobuf二进制存储到string中,注意存储的是二进制,而不是文本。

2)bool ParseFromString(const string& data); : 从给定的string中解析消息。

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

4)bool ParseFromIstream(istream* input); : C++ istream中解析消息。

还有一些用于解析和序列号的函数,可以自行查看。

 

3. 使用proto文件编译生成的源码和protobuf官方提供的API接口进行操作

我们先查看一下添加个人的应用:

#include <ctime>
#include <fstream>
#include <google/protobuf/util/time_util.h>
#include <iostream>
#include <string>

#include "addressbook.pb.h"

using namespace std;

using google::protobuf::util::TimeUtil;

// 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);
  cin.ignore(256, '\n');

  cout << "Enter name: ";
  getline(cin, *person->mutable_name());

  cout << "Enter email address (blank for none): ";
  string email;
  getline(cin, email);
  if (!email.empty()) {
    person->set_email(email);
  }

  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_phones();
    phone_number->set_number(number);

    cout << "Is this a mobile, home, or work phone? ";
    string type;
    getline(cin, type);
    if (type == "mobile") {
      phone_number->set_type(tutorial::Person::MOBILE);
    } else if (type == "home") {
      phone_number->set_type(tutorial::Person::HOME);
    } else if (type == "work") {
      phone_number->set_type(tutorial::Person::WORK);
    } else {
      cout << "Unknown phone type.  Using default." << endl;
    }
  }
  *person->mutable_last_updated() = TimeUtil::SecondsToTimestamp(time(NULL));
}

// 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;

  if (argc != 2) {
    cerr << "Usage:  " << argv[0] << " ADDRESS_BOOK_FILE" << endl;
    return -1;
  }

  tutorial::AddressBook address_book;

  {
    // 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)) {
      cerr << "Failed to parse address book." << endl;
      return -1;
    }
  }

  // Add an address.
  PromptForAddress(address_book.add_people());

  {
    // Write the new address book back to disk.
    fstream output(argv[1], ios::out | ios::trunc | ios::binary);
    if (!address_book.SerializeToOstream(&output)) {
      cerr << "Failed to write address book." << endl;
      return -1;
    }
  }

  // Optional:  Delete all global objects allocated by libprotobuf.
  google::protobuf::ShutdownProtobufLibrary();

  return 0;
}

这段代码应该并不难理解,所以,我不打算讲解这个代码的逻辑,这个程序实现的就是根据用户输入的一个个人信息,构建一个Person对象,然后将这个对象信息存储到addressbook文件中。

注意其中的GOOGLE_PROTOBUF_VERIFY_VERSION宏。在使用C++protobuf库之前调用这个宏是一个好的习惯,这个宏可以用来确保你所链接的库与你编译时使用的头文件版本一致。如果发现了版本不一致,程序就会退出。所有的.pb.cc文件开始都会调用这个宏。

另外,关注一下程序结尾处的ShutdownProtobufLibrary()函数调用。这个函数用来删除protobuf库分配的所有全局对象。对于大多数应用来说,这个操作是不必要的,因为程序退出后,系统会回收所有的内存。但是,如果你使用了内存泄露检测,或者说你在写一个会被加载和卸载很多次的库,那么你就可以使用这个函数来清理protobuf分配的资源。

 

读取个人信息的程序,读取上一个程序生成的protobuf序列化文件,然后在控制台输出个人信息。具体代码如下:

#include <fstream>
#include <google/protobuf/util/time_util.h>
#include <iostream>
#include <string>

#include "addressbook.pb.h"

using namespace std;

using google::protobuf::util::TimeUtil;

// Iterates though all people in the AddressBook and prints info about them.
void ListPeople(const tutorial::AddressBook& address_book) {
  for (int i = 0; i < address_book.people_size(); i++) {
    const tutorial::Person& person = address_book.people(i);

    cout << "Person ID: " << person.id() << endl;
    cout << "  Name: " << person.name() << endl;
    if (person.email() != "") {
      cout << "  E-mail address: " << person.email() << endl;
    }

    for (int j = 0; j < person.phones_size(); j++) {
      const tutorial::Person::PhoneNumber& phone_number = person.phones(j);

      switch (phone_number.type()) {
        case tutorial::Person::MOBILE:
          cout << "  Mobile phone #: ";
          break;
        case tutorial::Person::HOME:
          cout << "  Home phone #: ";
          break;
        case tutorial::Person::WORK:
          cout << "  Work phone #: ";
          break;
        default:
          cout << "  Unknown phone #: ";
          break;
      }
      cout << phone_number.number() << endl;
    }
    if (person.has_last_updated()) {
      cout << "  Updated: " << TimeUtil::ToString(person.last_updated()) << endl;
    }
  }
}

// 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;

  if (argc != 2) {
    cerr << "Usage:  " << argv[0] << " ADDRESS_BOOK_FILE" << endl;
    return -1;
  }

  tutorial::AddressBook address_book;

  {
    // Read the existing address book.
    fstream input(argv[1], ios::in | ios::binary);
    if (!address_book.ParseFromIstream(&input)) {
      cerr << "Failed to parse address book." << endl;
      return -1;
    }
  }

  ListPeople(address_book);

  // Optional:  Delete all global objects allocated by libprotobuf.
  google::protobuf::ShutdownProtobufLibrary();

  return 0;
}

下面给出Makefile文件:

.PHONY: all cpp clean

all: cpp
 
cpp:    add_person_cpp    list_people_cpp
go:     add_person_go     list_people_go
gotest: add_person_gotest list_people_gotest

clean:
    rm -f add_person_cpp list_people_cpp     
rm -f protoc_middleman addressbook.pb.cc addressbook.pb.h
    rm -f protoc_middleman_go tutorial/*.pb.go add_person_go list_people_go
    rmdir tutorial 2>/dev/null || true

protoc_middleman: addressbook.proto
    protoc $$PROTO_PATH --cpp_out=. addressbook.proto
    @touch protoc_middleman

protoc_middleman_go: addressbook.proto
    mkdir -p tutorial # make directory for go package
    protoc $$PROTO_PATH --go_out=tutorial addressbook.proto
    @touch protoc_middleman_go

add_person_cpp: add_person.cc protoc_middleman
    pkg-config --cflags protobuf  # fails if protobuf is not installed
    c++ add_person.cc addressbook.pb.cc -o add_person_cpp `pkg-config --cflags --libs protobuf`

list_people_cpp: list_people.cc protoc_middleman
    pkg-config --cflags protobuf  # fails if protobuf is not installed
    c++ list_people.cc addressbook.pb.cc -o list_people_cpp `pkg-config --cflags --libs protobuf`
 
add_person_go: add_person.go protoc_middleman_go
    go build -o add_person_go add_person.go

add_person_gotest: add_person_test.go add_person_go
    go test add_person.go add_person_test.go

编译上述c++程序很简单,在应用程序源码所在的文件夹(同时也是Makefile所在的文件夹)调用make cpp,会创建两个应用:add_person_cpplist_people_cpp。使用方法如下:

$ ./add_person_cpp addressbook.data

$ ./list_people_cpp addressbook.data

程序运行过程中,有提示信息,同时也可以查看源码了解应用,所以就不解释了。

 

扩展protobuf

如果你想要修改protobuf消息结构的定义,并且你希望新的消息可以向后兼容,以前的消息可以向前兼容,那么你需要注意一下几点:

(1)不要改变已有成员的tag数值

(2)你可以添加新的成员,但是必须使用新的tag数值(完全没用过的tag数值,如果有成员被删除,这个成员的tag数值也不可以再用)

如果你遵循这些规则,那么以前的代码可以读取新的消息,虽然会忽略掉新的成员。对于以前的代码,删除掉的singular字段每次都是默认值,删除调用的repeated字段会为空。新的代码可以读取以前的消息,只不过新的singular字段都为默认值,新的repeated字段都为空。

 

关于使用go语言处理protobuf

C++一致的地方,就忽略不讲了,有不太懂的地方,可以参考上面讲解C++的部分。

编译proto文件的命令如下:

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

如果编译在proto文件目录进行,同时想编译到proto目录,可以使用如下命令:

protoc --go_out=. addressbook.proto

编译会生成一个addressbook.pb.go文件。我们简单查看一下addressbook.pb.go文件:

package tutorial

type Person_PhoneType int32

const (
  Person_MOBILE Person_PhoneType = 0
  Person_HOME  Person_PhoneType = 1
  Person_WORK Person_PhoneType = 2
)
 

var Person_PhoneType_name = map[int32]string {
  0: “MOBILE”,
  1: “HOME”,
  2: “WORK”,
}

var Person_PhoneType_value = map[string]int32 {
  “MOBILE”: 0,
  “HOME”: 1,
  “WORK”: 2,
}

// [START messages]
type Person struct {
  Name string `protobuf:”bytes,1,opt,name=name,proto3” json:”name, omitempty”`
  Id int32 `protobuf:”varint,2,opt,name=id,proto3” json:”id,omitempty”`
  Email string `protobuf:”bytes,3,opt,name=email,proto3” json:”email,omitempty”`
  Phones []*Person_PhoneNumber `protobuf:”bytes,4,rep,name=phones,proto3” json:”phones,omitempty”`
  LastUpdated *timestamp.Timestamp `protobuf:”bytes,5,opt,name=last_updated,json=lastUpdated,proto3” json:”last_updated,omitempty”`
  ...
}

func (m *Person) Reset() { *m = Person{} }
func (m *Person) String() string { return proto.CompactTextString(m) }

func (m *Person) XXX_Unmarshal(b []byte) error {
  return xxx_messageInfo_Person.Unmarshal(m, b)
}
func (m *Person) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
  return xxx_messageInfo_Person.Marshal(b, m, deterministic)
}
func (m *Person) XXX_Size() int {
  return xxx_messageInfo_Person.Size(m)
}

var xxx_messageInfo_Person proto.InternalMessageInfo

func (m *Person) GetName() string {
  if m != nil {
    return m.Name
  }
  return “”
}

func (m *Person) GetId() int32 {
  if m != nil {
    return m.Id
  }
  return 0
}

func (m *Person) GetPhones() []*Person_PhoneNumber {
  if m != nil {
    return m.Phones
  }
  return nil
}
 
func (m *Person) GetLastUpdated() *timestamp.Timestamp {
  if m != nil {
    return m_lastUpdated
  }
  return nil
}

type Person_PhoneNumber struct {
  Number string `protobuf:”bytes,1,opt,name=number,proto3” json:”number,omitempty”`
  Type Person_PhoneType `protobuf:”varint,2,opt,name=type,proto3,enum=tutorial.Person_PhoneType” json:”type,omitempty”`
}

type AddressBook struct {
  People []*Person `protobuf:”bytes,1,rep,name=people,proto3” json:”people,omitempty”`
}

其中在proto文件中的package对应于go语言中的package。生成的go代码比较简单,结构体的命名方式和C++中一致,提供的函数很少,甚至连操作成员的函数都没有,这个可能是因为go语言中经常直接访问数据成员的缘故,不过结构体的命名确实不符合go语言的规范。go生成的结构体中的成员tag内容很多,可以用于反射。关于生成go代码的结构体的使用可以查看下面的代码:

p := pb.Person{
        Id:    1234,
        Name:  "John Doe",
        Email: "[email protected]",
        Phones: []*pb.Person_PhoneNumber{
                {Number: "555-4321", Type: pb.Person_HOME},
        },
}

关于上面出现的XXX_UnmarshalXXX_Marshal函数,可以参考以下的代码:

// Marshal takes a protocol buffer message
// and encodes it into the wire format, returning the data.
// This is the main entry point.
func Marshal(pb Message) ([]byte, error) {
    if m, ok := pb.(newMarshaler); ok {
        siz := m.XXX_Size()
        b := make([]byte, 0, siz)
        return m.XXX_Marshal(b, false)
    }    

    if m, ok := pb.(Marshaler); ok {
        // If the message can marshal itself, let it do it, for compatibility.
        // NOTE: This is not efficient.
        return m.Marshal()
    }    

    // in case somehow we didn't generate the wrapper
    if pb == nil {
        return nil, ErrNil
    }    

    var info InternalMessageInfo
    siz := info.Size(pb)
    b := make([]byte, 0, siz)
    return info.Marshal(b, pb, false)
}

 

// Unmarshal parses the protocol buffer representation in buf and places the
// decoded result in pb.  If the struct underlying pb does not match
// the data in buf, the results can be unpredictable.
//
// Unmarshal resets pb before starting to unmarshal, so any
// existing data in pb is always removed. Use UnmarshalMerge
// to preserve and append to existing data.
func Unmarshal(buf []byte, pb Message) error {
    pb.Reset()
    if u, ok := pb.(newUnmarshaler); ok {
        return u.XXX_Unmarshal(buf)
    }   

    if u, ok := pb.(Unmarshaler); ok {
        return u.Unmarshal(buf)
    }   

    return NewBuffer(buf).Unmarshal(pb)
}

查看一下添加个人的应用代码。在go语言中,我们使用


鲜花

握手

雷人

路过

鸡蛋
该文章已有0人参与评论

请发表评论

全部评论

专题导读
上一篇:
GoChannel发布时间:2022-07-10
下一篇:
go语言-golang包-path/filepath包发布时间:2022-07-10
热门推荐
热门话题
阅读排行榜

扫描微信二维码

查看手机版网站

随时了解更新最新资讯

139-2527-9053

在线客服(服务时间 9:00~18:00)

在线QQ客服
地址:深圳市南山区西丽大学城创智工业园
电邮:jeky_zhao#qq.com
移动电话:139-2527-9053

Powered by 互联科技 X3.4© 2001-2213 极客世界.|Sitemap