Làm cách nào để viết một đến nhiều truy vấn trong Dapper.Net?


80

Tôi đã viết mã này để chiếu từ một đến nhiều mối quan hệ nhưng nó không hoạt động:

using (var connection = new SqlConnection(connectionString))
{
   connection.Open();

   IEnumerable<Store> stores = connection.Query<Store, IEnumerable<Employee>, Store>
                        (@"Select Stores.Id as StoreId, Stores.Name, 
                                  Employees.Id as EmployeeId, Employees.FirstName,
                                  Employees.LastName, Employees.StoreId 
                           from Store Stores 
                           INNER JOIN Employee Employees ON Stores.Id = Employees.StoreId",
                        (a, s) => { a.Employees = s; return a; }, 
                        splitOn: "EmployeeId");

   foreach (var store in stores)
   {
       Console.WriteLine(store.Name);
   }
}

Ai có thể phát hiện ra sai lầm

BIÊN TẬP:

Đây là các thực thể của tôi:

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public double Price { get; set; }
    public IList<Store> Stores { get; set; }

    public Product()
    {
        Stores = new List<Store>();
    }
}

public class Store
{
    public int Id { get; set; }
    public string Name { get; set; }
    public IEnumerable<Product> Products { get; set; }
    public IEnumerable<Employee> Employees { get; set; }

    public Store()
    {
        Products = new List<Product>();
        Employees = new List<Employee>();
    }
}

BIÊN TẬP:

Tôi thay đổi truy vấn thành:

IEnumerable<Store> stores = connection.Query<Store, List<Employee>, Store>
        (@"Select Stores.Id as StoreId ,Stores.Name,Employees.Id as EmployeeId,
           Employees.FirstName,Employees.LastName,Employees.StoreId 
           from Store Stores INNER JOIN Employee Employees 
           ON Stores.Id = Employees.StoreId",
         (a, s) => { a.Employees = s; return a; }, splitOn: "EmployeeId");

và tôi thoát khỏi trường hợp ngoại lệ! Tuy nhiên, Nhân viên hoàn toàn không được lập bản đồ. Tôi vẫn không chắc nó gặp vấn đề gì IEnumerable<Employee>trong truy vấn đầu tiên.


1
Các thực thể của bạn trông như thế nào?
gideon 19/02/12

2
Làm thế nào là không hoạt động? Bạn có nhận được một ngoại lệ? Kết quả bất ngờ?
driis

1
Lỗi không có ý nghĩa đó là lý do tại sao tôi không buồn đăng nó. Tôi nhận được: "{" Giá trị không được rỗng. \ R \ nTên tham số: con "}". Dòng gây ra lỗi trong SqlMapper là: "il.Emit (OpCodes.Newobj, type.GetConstructor (BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, Type.EmptyTypes, null));"
TCM

Câu trả lời:


162

Bài đăng này chỉ ra cách truy vấn cơ sở dữ liệu SQL được chuẩn hóa cao và ánh xạ kết quả thành một tập hợp các đối tượng C # POCO lồng nhau cao.

Thành phần:

  • 8 dòng C #.
  • Một số SQL đơn giản hợp lý sử dụng một số phép nối.
  • Hai thư viện tuyệt vời.

Cái nhìn sâu sắc cho phép tôi giải quyết vấn đề này là tách biệt MicroORMkhỏi mapping the result back to the POCO Entities. Do đó, chúng tôi sử dụng hai thư viện riêng biệt:

Về cơ bản, chúng tôi sử dụng Dapper để truy vấn cơ sở dữ liệu, sau đó sử dụng Slapper.Automapper để ánh xạ kết quả thẳng vào POCO của chúng tôi.

Ưu điểm

  • Sự đơn giản . Ít hơn 8 dòng mã của nó. Tôi thấy điều này dễ hiểu hơn, gỡ lỗi và thay đổi.
  • Ít mã hơn . Một vài dòng mã là tất cả Slapper.Automapper cần để xử lý bất cứ điều gì bạn ném vào nó, ngay cả khi chúng tôi có một POCO phức tạp lồng nhau (ví dụ POCO chứa List<MyClass1>mà lần lượt chứa List<MySubClass2>, vv).
  • Tốc độ . Cả hai thư viện này đều có khả năng tối ưu hóa và bộ nhớ đệm đặc biệt để làm cho chúng chạy nhanh như các truy vấn ADO.NET được điều chỉnh thủ công.
  • Tách mối quan tâm . Chúng ta có thể thay đổi MicroORM cho một MicroORM khác và ánh xạ vẫn hoạt động và ngược lại.
  • Tính linh hoạt . Slapper.Automapper xử lý các cấu trúc phân cấp lồng nhau tùy ý, nó không giới hạn ở một vài cấp độ lồng nhau. Chúng tôi có thể dễ dàng thực hiện các thay đổi nhanh chóng và mọi thứ vẫn sẽ hoạt động.
  • Gỡ lỗi . Trước tiên, chúng ta có thể thấy rằng truy vấn SQL đang hoạt động bình thường, sau đó chúng ta có thể kiểm tra xem kết quả truy vấn SQL có được ánh xạ trở lại các Thực thể POCO đích đúng cách hay không.
  • Dễ dàng phát triển trong SQL . Tôi thấy rằng việc tạo các truy vấn phẳng inner joinsđể trả về các kết quả phẳng dễ dàng hơn nhiều so với việc tạo nhiều câu lệnh lựa chọn, với khâu ở phía máy khách.
  • Các truy vấn được tối ưu hóa trong SQL . Trong cơ sở dữ liệu được chuẩn hóa cao, việc tạo một truy vấn phẳng cho phép công cụ SQL áp dụng các tối ưu hóa nâng cao cho toàn bộ cơ sở dữ liệu mà thông thường sẽ không thể thực hiện được nếu nhiều truy vấn riêng lẻ nhỏ được tạo và chạy.
  • Tin cậy . Dapper là hậu thuẫn cho StackOverflow, và, tốt, Randy Burden là một siêu sao. Tôi có cần nói gì thêm không?
  • Tốc độ phát triển. Tôi đã có thể thực hiện một số truy vấn cực kỳ phức tạp, với nhiều cấp độ lồng ghép và thời gian dành cho nhà phát triển khá thấp.
  • Ít lỗi hơn. Tôi đã viết nó một lần, nó chỉ hoạt động và kỹ thuật này hiện đang giúp cung cấp năng lượng cho một công ty FTSE. Có rất ít mã nên không có hành vi bất ngờ.

Nhược điểm

  • Đã trả lại tỷ lệ vượt quá 1.000.000 hàng. Hoạt động tốt khi trả về <100.000 hàng. Tuy nhiên, nếu chúng ta đưa trở lại hơn 1.000.000 hàng, để giảm lưu lượng truy cập giữa chúng ta và máy chủ SQL, chúng ta không nên san bằng nó bằng cách sử dụng inner join(điều này mang lại các bản sao), thay vào đó chúng ta nên sử dụng nhiều selectcâu lệnh và ghép mọi thứ lại với nhau trên phía khách hàng (xem các câu trả lời khác trên trang này).
  • Kỹ thuật này là định hướng truy vấn . Tôi chưa sử dụng kỹ thuật này để ghi vào cơ sở dữ liệu, nhưng tôi chắc chắn rằng Dapper có nhiều khả năng làm điều này với một số công việc bổ sung, vì bản thân StackOverflow sử dụng Dapper làm Lớp truy cập dữ liệu (DAL).

Kiểm tra năng suất

Trong các thử nghiệm của tôi, Slapper.Automapper đã thêm một chi phí nhỏ vào kết quả do Dapper trả về, có nghĩa là nó vẫn nhanh hơn 10 lần so với Entity Framework và sự kết hợp này vẫn khá gần với tốc độ tối đa lý thuyết mà SQL + C # có thể có .

Trong hầu hết các trường hợp thực tế, phần lớn chi phí sẽ nằm trong một truy vấn SQL kém tối ưu và không phải với một số ánh xạ kết quả ở phía C #.

Kết quả kiểm tra hiệu suất

Tổng số lần lặp: 1000

  • Dapper by itself: 1,889 mili giây mỗi truy vấn, sử dụng 3 lines of code to return the dynamic.
  • Dapper + Slapper.Automapper: 2,463 mili giây cho mỗi truy vấn, sử dụng thêm 3 lines of code for the query + mapping from dynamic to POCO Entities.

Ví dụ về công việc

Trong ví dụ này, chúng tôi có danh sách Contactsvà mỗi danh sách Contactcó thể có một hoặc nhiều phone numbers.

Thực thể POCO

public class TestContact
{
    public int ContactID { get; set; }
    public string ContactName { get; set; }
    public List<TestPhone> TestPhones { get; set; }
}

public class TestPhone
{
    public int PhoneId { get; set; }
    public int ContactID { get; set; } // foreign key
    public string Number { get; set; }
}

Bảng SQL TestContact

nhập mô tả hình ảnh ở đây

Bảng SQL TestPhone

Lưu ý rằng bảng này có một khóa ngoại ContactIDtham chiếu đến TestContactbảng (điều này tương ứng với List<TestPhone>trong POCO ở trên).

nhập mô tả hình ảnh ở đây

SQL tạo ra kết quả phẳng

Trong truy vấn SQL của chúng tôi, chúng tôi sử dụng bao nhiêu JOINcâu lệnh mà chúng tôi cần để nhận được tất cả dữ liệu chúng tôi cần, ở dạng phẳng, không chuẩn hóa . Có, điều này có thể tạo ra các bản sao trong đầu ra, nhưng các bản sao này sẽ tự động bị loại bỏ khi chúng ta sử dụng Slapper.Automapper để tự động ánh xạ kết quả của truy vấn này thẳng vào bản đồ đối tượng POCO của chúng ta.

USE [MyDatabase];
    SELECT tc.[ContactID] as ContactID
          ,tc.[ContactName] as ContactName
          ,tp.[PhoneId] AS TestPhones_PhoneId
          ,tp.[ContactId] AS TestPhones_ContactId
          ,tp.[Number] AS TestPhones_Number
          FROM TestContact tc
    INNER JOIN TestPhone tp ON tc.ContactId = tp.ContactId

nhập mô tả hình ảnh ở đây

Mã C #

const string sql = @"SELECT tc.[ContactID] as ContactID
          ,tc.[ContactName] as ContactName
          ,tp.[PhoneId] AS TestPhones_PhoneId
          ,tp.[ContactId] AS TestPhones_ContactId
          ,tp.[Number] AS TestPhones_Number
          FROM TestContact tc
    INNER JOIN TestPhone tp ON tc.ContactId = tp.ContactId";

string connectionString = // -- Insert SQL connection string here.

using (var conn = new SqlConnection(connectionString))
{
    conn.Open();    
    // Can set default database here with conn.ChangeDatabase(...)
    {
        // Step 1: Use Dapper to return the  flat result as a Dynamic.
        dynamic test = conn.Query<dynamic>(sql);

        // Step 2: Use Slapper.Automapper for mapping to the POCO Entities.
        // - IMPORTANT: Let Slapper.Automapper know how to do the mapping;
        //   let it know the primary key for each POCO.
        // - Must also use underscore notation ("_") to name parameters in the SQL query;
        //   see Slapper.Automapper docs.
        Slapper.AutoMapper.Configuration.AddIdentifiers(typeof(TestContact), new List<string> { "ContactID" });
        Slapper.AutoMapper.Configuration.AddIdentifiers(typeof(TestPhone), new List<string> { "PhoneID" });

        var testContact = (Slapper.AutoMapper.MapDynamic<TestContact>(test) as IEnumerable<TestContact>).ToList();      

        foreach (var c in testContact)
        {                               
            foreach (var p in c.TestPhones)
            {
                Console.Write("ContactName: {0}: Phone: {1}\n", c.ContactName, p.Number);   
            }
        }
    }
}

Đầu ra

nhập mô tả hình ảnh ở đây

Hệ thống phân cấp thực thể POCO

Nhìn trong Visual Studio, chúng ta có thể thấy rằng Slapper.Automapper đã điền đúng các Thực thể POCO của chúng tôi, tức là chúng tôi có một List<TestContact>và mỗi thực thể TestContactcó một List<TestPhone>.

nhập mô tả hình ảnh ở đây

Ghi chú

Cả Dapper và Slapper.Automapper đều lưu trữ mọi thứ bên trong để tăng tốc độ. Nếu bạn gặp sự cố bộ nhớ (rất khó xảy ra), hãy đảm bảo rằng bạn thỉnh thoảng xóa bộ nhớ cache cho cả hai vấn đề.

Đảm bảo rằng bạn đặt tên cho các cột quay trở lại, sử dụng ký hiệu gạch dưới ( _) để cung cấp cho Slapper.Automapper manh mối về cách ánh xạ kết quả vào các Thực thể POCO.

Đảm bảo rằng bạn cung cấp cho Slapper.Automapper manh mối về khóa chính cho mỗi Thực thể POCO (xem các dòng Slapper.AutoMapper.Configuration.AddIdentifiers). Bạn cũng có thể sử dụng AttributesPOCO cho việc này. Nếu bạn bỏ qua bước này, thì nó có thể bị sai (về lý thuyết), vì Slapper.Automapper sẽ không biết cách thực hiện ánh xạ đúng cách.

Cập nhật 2015-06-14

Đã áp dụng thành công kỹ thuật này vào cơ sở dữ liệu sản xuất khổng lồ với hơn 40 bảng chuẩn hóa. Nó hoạt động hoàn hảo để ánh xạ một truy vấn SQL nâng cao với hơn 16 inner joinleft joinvào hệ thống phân cấp POCO thích hợp (với 4 cấp độ lồng nhau). Các truy vấn nhanh đến chóng mặt, gần như nhanh bằng cách viết tay nó trong ADO.NET (thường là 52 mili giây cho truy vấn và 50 mili giây để ánh xạ từ kết quả phẳng vào hệ thống phân cấp POCO). Điều này thực sự không có gì mang tính cách mạng, nhưng nó chắc chắn đánh bại Entity Framework về tốc độ và tính dễ sử dụng, đặc biệt nếu tất cả những gì chúng ta đang làm là chạy các truy vấn.

Cập nhật 2016-02-19

Code đã chạy hoàn hảo trong quá trình sản xuất trong 9 tháng. Phiên bản mới nhất của Slapper.Automappercó tất cả các thay đổi mà tôi đã áp dụng để khắc phục sự cố liên quan đến giá trị rỗng được trả về trong truy vấn SQL.

Cập nhật 2017-02-20

Mã đã chạy hoàn hảo trong quá trình sản xuất trong 21 tháng và đã xử lý các truy vấn liên tục từ hàng trăm người dùng trong một công ty FTSE 250.

Slapper.Automappercũng rất tốt để ánh xạ thẳng tệp .csv vào danh sách POCO. Đọc tệp .csv thành danh sách IDictionary, sau đó ánh xạ thẳng vào danh sách POCO đích. Mẹo duy nhất là bạn phải thêm một propery int Id {get; set}và đảm bảo rằng nó là duy nhất cho mọi hàng (nếu không, trình tự động sẽ không thể phân biệt giữa các hàng).

Cập nhật 2019-01-29

Cập nhật nhỏ để thêm nhiều bình luận mã hơn.

Xem: https://github.com/SlapperAutoMapper/Slapper.AutoMapper


1
Tuy nhiên, tôi thực sự không thích quy ước tiền tố tên bảng trong tất cả sql của bạn, nó không hỗ trợ một cái gì đó như "splitOn" của Dapper?
tbone

3
Quy ước tên bảng này được yêu cầu bởi Slapper.Automapper. Có, Dapper có hỗ trợ ánh xạ thẳng tới POCO, nhưng tôi thích sử dụng Slapper.Automapper vì mã rất sạch và có thể bảo trì.
Contango

2
Tôi nghĩ rằng tôi sẽ sử dụng Slapper nếu bạn không phải đặt bí danh cho tất cả các cột - thay vào đó, trong ví dụ của bạn, tôi muốn có thể nói:, splitOn: "PhoneId" - điều đó sẽ không phải là một chút dễ dàng hơn so với việc phải bí danh mọi thứ?
tbone

1
Tôi thực sự thích giao diện của nô lệ, chỉ tự hỏi nếu bạn đã thử tham gia trái khi một người không có số liên lạc? Bạn có một cách tốt để đối phó với điều đó?
Không yêu

1
@tbone splitOn doesnt chứa bất kỳ thông tin về nơi ở đối tượng của bạn yếu tố này thuộc đó là lý do Slapper sử dụng một con đường như thế này
Không yêu

20

Tôi muốn giữ nó càng đơn giản càng tốt, giải pháp của tôi:

public List<ForumMessage> GetForumMessagesByParentId(int parentId)
{
    var sql = @"
    select d.id_data as Id, d.cd_group As GroupId, d.cd_user as UserId, d.tx_login As Login, 
        d.tx_title As Title, d.tx_message As [Message], d.tx_signature As [Signature], d.nm_views As Views, d.nm_replies As Replies, 
        d.dt_created As CreatedDate, d.dt_lastreply As LastReplyDate, d.dt_edited As EditedDate, d.tx_key As [Key]
    from 
        t_data d
    where d.cd_data = @DataId order by id_data asc;

    select d.id_data As DataId, di.id_data_image As DataImageId, di.cd_image As ImageId, i.fl_local As IsLocal
    from 
        t_data d
        inner join T_data_image di on d.id_data = di.cd_data
        inner join T_image i on di.cd_image = i.id_image 
    where d.id_data = @DataId and di.fl_deleted = 0 order by d.id_data asc;";

    var mapper = _conn.QueryMultiple(sql, new { DataId = parentId });
    var messages = mapper.Read<ForumMessage>().ToDictionary(k => k.Id, v => v);
    var images = mapper.Read<ForumMessageImage>().ToList();

    foreach(var imageGroup in images.GroupBy(g => g.DataId))
    {
        messages[imageGroup.Key].Images = imageGroup.ToList();
    }

    return messages.Values.ToList();
}

Tôi vẫn thực hiện một lệnh gọi đến cơ sở dữ liệu và trong khi hiện tại tôi thực hiện 2 truy vấn thay vì một, truy vấn thứ hai đang sử dụng phép nối INNER thay vì phép nối TRÁI kém tối ưu hơn.


5
Tôi thích cách tiếp cận này. Ánh xạ thuần túy và IMHO dễ hiểu hơn.
Avner

1
Có vẻ như điều này sẽ dễ dàng đưa vào một phương thức mở rộng có một vài albmdas, một cho bộ chọn khóa và một cho bộ chọn con. Tương tự như .Join(nhưng tạo ra một đồ thị đối tượng thay vì kết quả phẳng.
AaronLS

8

Một sửa đổi nhỏ cho câu trả lời của Andrew sử dụng Hàm để chọn khóa cha thay vì GetHashCode.

public static IEnumerable<TParent> QueryParentChild<TParent, TChild, TParentKey>(
    this IDbConnection connection,
    string sql,
    Func<TParent, TParentKey> parentKeySelector,
    Func<TParent, IList<TChild>> childSelector,
    dynamic param = null, IDbTransaction transaction = null, bool buffered = true, string splitOn = "Id", int? commandTimeout = null, CommandType? commandType = null)
{
    Dictionary<TParentKey, TParent> cache = new Dictionary<TParentKey, TParent>();

    connection.Query<TParent, TChild, TParent>(
        sql,
        (parent, child) =>
            {
                if (!cache.ContainsKey(parentKeySelector(parent)))
                {
                    cache.Add(parentKeySelector(parent), parent);
                }

                TParent cachedParent = cache[parentKeySelector(parent)];
                IList<TChild> children = childSelector(cachedParent);
                children.Add(child);
                return cachedParent;
            },
        param as object, transaction, buffered, splitOn, commandTimeout, commandType);

    return cache.Values;
}

Ví dụ sử dụng

conn.QueryParentChild<Product, Store, int>("sql here", prod => prod.Id, prod => prod.Stores)

Một điều cần lưu ý với giải pháp này, lớp cha của bạn chịu trách nhiệm khởi tạo thuộc tính con. class Parent { public List<Child> Children { get; set; } public Parent() { this.Children = new List<Child>(); } }
Clay

1
Giải pháp này là tuyệt vời và làm việc cho chúng tôi. Tôi đã phải thêm một séc với children.add để kiểm tra null trong trường hợp không có hàng con nào được trả về.
tlbignerd

7

Theo câu trả lời này, không có một hỗ trợ ánh xạ nào được tích hợp trong Dapper.Net. Các truy vấn sẽ luôn trả về một đối tượng trên mỗi hàng cơ sở dữ liệu. Tuy nhiên, có một giải pháp thay thế được bao gồm.


Tôi xin lỗi nhưng tôi không hiểu làm cách nào để sử dụng nó trong truy vấn của mình? Nó đang cố gắng truy vấn cơ sở dữ liệu 2 lần mà không cần tham gia (và sử dụng mã cứng 1 trong ví dụ). Ví dụ chỉ có 1 thực thể chính được trả về lần lượt chứa các thực thể con. Trong trường hợp của tôi, tôi muốn tham gia dự án (danh sách chứa danh sách nội bộ). Làm cách nào để làm điều đó với liên kết bạn đã đề cập? Trong liên kết có dòng nói: (contact, phones) => { contact.Phones = phones; } Tôi sẽ phải viết một bộ lọc cho điện thoại có contactid khớp với contactid của contact. Điều này là khá kém hiệu quả.
TCM

@Anthony Hãy xem câu trả lời của Mike. Anh ta thực hiện một truy vấn duy nhất với hai tập kết quả và kết hợp chúng sau đó bằng phương thức Bản đồ. Tất nhiên, bạn không cần phải mã hóa giá trị trong trường hợp của mình. Tôi sẽ cố gắng tổng hợp một ví dụ trong vài giờ nữa.
Damir Arh

1
được rồi, cuối cùng thì tôi cũng làm được rồi. Cảm ơn! Không biết điều này sẽ ảnh hưởng như thế nào đến hiệu suất của truy vấn cơ sở dữ liệu gấp 2 lần những gì có thể được thực hiện bằng cách sử dụng một phép nối.
TCM

2
Ngoài ra, tôi không hiểu mình sẽ cần thực hiện những thay đổi gì nếu có 3 bàn: p
TCM

1
điều này hoàn toàn tệ hại .. tại sao trên trái đất tránh tham gia?
GorillaApe 28/10/12

2

Đây là một giải pháp thô sơ

    public static IEnumerable<TOne> Query<TOne, TMany>(this IDbConnection cnn, string sql, Func<TOne, IList<TMany>> property, dynamic param = null, IDbTransaction transaction = null, bool buffered = true, string splitOn = "Id", int? commandTimeout = null, CommandType? commandType = null)
    {
        var cache = new Dictionary<int, TOne>();
        cnn.Query<TOne, TMany, TOne>(sql, (one, many) =>
                                            {
                                                if (!cache.ContainsKey(one.GetHashCode()))
                                                    cache.Add(one.GetHashCode(), one);

                                                var localOne = cache[one.GetHashCode()];
                                                var list = property(localOne);
                                                list.Add(many);
                                                return localOne;
                                            }, param as object, transaction, buffered, splitOn, commandTimeout, commandType);
        return cache.Values;
    }

nó không phải là cách hiệu quả nhất, nhưng nó sẽ giúp bạn thiết lập và chạy. Tôi sẽ thử và tối ưu hóa điều này khi có cơ hội.

sử dụng nó như thế này:

conn.Query<Product, Store>("sql here", prod => prod.Stores);

ghi nhớ các đối tượng của bạn cần triển khai GetHashCode, có lẽ như thế này:

    public override int GetHashCode()
    {
        return this.Id.GetHashCode();
    }

11
Việc triển khai bộ nhớ cache bị lỗi Mã băm không phải là duy nhất - hai đối tượng có thể có cùng mã băm. Điều này có thể dẫn đến một đối tượng danh sách được lấp đầy với các mặt hàng thuộc đối tượng khác ..
stmax

2

Đây là một phương pháp khác:

Order (một) - OrderDetail (nhiều)

using (var connection = new SqlCeConnection(connectionString))
{           
    var orderDictionary = new Dictionary<int, Order>();

    var list = connection.Query<Order, OrderDetail, Order>(
        sql,
        (order, orderDetail) =>
        {
            Order orderEntry;

            if (!orderDictionary.TryGetValue(order.OrderID, out orderEntry))
            {
                orderEntry = order;
                orderEntry.OrderDetails = new List<OrderDetail>();
                orderDictionary.Add(orderEntry.OrderID, orderEntry);
            }

            orderEntry.OrderDetails.Add(orderDetail);
            return orderEntry;
        },
        splitOn: "OrderDetailID")
    .Distinct()
    .ToList();
}

Nguồn : http://dapper-tutorial.net/result-multi-mapping#example---query-multi-mapping-one-to-many

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.