云数据库入门
编辑教程云数据库入门
任何一个大型的应用程序和服务,都必须会使用到高性能的数据存储解决方案,用来准确(ACID,原子性Atomicity、一致性Consistency、隔离性Isolation、持久性Durability,可以拓展了解一下)、快速、可靠地存储和检索用户的账户信息、商品以及商品交易信息、产品数据、资讯文章等等等等,而云开发就自带高性能、高可用、高拓展性且安全的数据库。
云数据库的基础知识
在操作数据库时,我们要对数据库database、集合collection、记录doc以及字段field要有一定的了解,首先要记住这些对应的英文单词,当你要操作某个记录doc的字段内容时,就像投送快递一样,要先搞清楚它到底在哪个数据库、在哪个集合、在哪个记录里,一级一级的去找。
操作数据库通常都是对数据库、集合、记录、字段进行增、删、改、查,当你清楚了这些,操作数据库就不会迷糊了。
云数据库与Excel、MySQL的对应理解
我们可以结合Excel以及MySQL(之前没有接触过MySQL也没有关系,只看与Excel的对应就行)来理解云开发的数据库。
云数据库 | MySQL数据库 | Excel文件 |
---|---|---|
数据库database | 数据库 database | 工作簿 |
集合 collection | 表 table | 工作表 |
字段field | 数据列column | 数据表的每一列 |
记录 record/doc | 记录row | 数据表除开第一行的每一行 |
集合的创建与数据类型
我们现在来创建一个books的集合(相当于创建一张Excel表),用来存放图书馆里面书籍的信息,比如这样一本书:
书名title | JavaScript权威指南(第6版) | |
---|---|---|
作者author | 弗兰纳根(David Flanagan) | |
标准书号isbn | 9787111376613 | |
出版信息publishInfo | 出版社press | 机械工业出版社 |
出版年份year | 2012 |
打开云开发控制台的数据库标签,新建集合books,然后选择该集合,给books里添加记录(类似于填写Excel含字段的第一行和其中一行关于书的信息记录),依次添加字段:
字段名:title,类型:string,值: JavaScript权威指南(第6版) | |
---|---|
字段名:author,类型:string,值:弗兰纳根(David Flanagan) | |
字段名:isbn,类型:string,值:9787111376613 | |
字段名:publishInfo,类型:object | |
然后我们再在publishInfo的下面(二级)添加字段press,类型为string,值为:机械工业出版社;year,类型为number,值为:2012 |
数据库的权限控制与安全规则
在数据库创建之后,我们需要在云开发控制台-数据库-集合的权限设置标签对数据库进行权限设置。数据库的权限分为小程序端和服务端(云函数、云开发控制台)。服务端拥有读写所有数据的读写权限,所以这里的权限设置只是在设置小程序端的用户对数据库的操作权限。权限控制分简易权限控制和自定义权限(也就是安全规则),建议开发者用安全规则取代简易的权限控制。
要使用自定义权限(也就是安全规则)来全面取代简易的权限控制,我们需要了解4个简易的权限控制所表示的意思,以及安全规则应该如何一一取代它们,也就是我们在配置集合的权限时,不再选择简易的权限控制,而是统一选择自定义权限,填写与之对应的json规则即可。
安全规则可以让更加灵活而又明确地自定义前端数据库读写权限的能力,通过配置安全规则,开发者可以精细化的控制集合中所有记录的读read、写write权限。其中write权限还可以细分为create新建、update更新、delete删除等权限,还支持比较、逻辑运算符进行更加精细化的权限配置。
所有用户可读,仅创建者可读写:比如用户发的帖子、评论、文章,这里的创建者是指小程序端的用户,也就是存储UGC(用户产生内容)的集合要设置为这个权限;
{
"read": true,
"write": "doc._openid == auth.openid"
}
仅创建者可读写:比如私密相册,用户的个人信息、订单,也就是只能用户自己读与写,其他人不可读写的数据集合;
{
"read": "doc._openid == auth.openid",
"write": "doc._openid == auth.openid"
}
所有人可读:比如资讯文章、商品信息、产品数据等你想让所有人可以看到,但是不能修改的内容;
{
"read": true,
"write": false
}
所有用户不可读写:如后台用的不暴露的数据,只能你自己看到和修改的数据;
{
"read": false,
"write": false
}
小程序端 API 拥有严格的调用权限控制,比如在小程序端A用户是不能修改B用户的数据的,没有这样的权限,在小程序端只能修改非敏感且只是针对单个用户的数据;对于有更高安全要求的数据,我们可以在云函数内通过服务端 API 来进行操作。
如果数据库集合里的数据是通过导入的方式获取的,这个集合的权限默认为“仅创建者可读写”,这个权限在服务端(云函数)可以调用,但是在小程序端可能会返回空数组哦,所以一定要记得根据情况修改权限。
小程序端与云函数的服务端无论是在权限方面、API的写法上(有时看起来一样,但是写法不一样),还是在异步处理上(比如服务端不再使用success、fail、complete回调,而是返回Promise对象),都存在非常多的差异,这一点要分清楚。
查询集合collection里的记录
查询集合collection里的记录是云开发数据库操作最重要的知识,在上一节我们已经将中国城市经济数据china.csv的数据导入到了集合china之中,并已经设置好了集合的权限为“所有人可读,仅创建者可读写”(或使用安全规则),接下来我们就以此为例并结合中国城市经济线上excel版来讲解数据库的查询。在中国城市经济线上excel版以及云开发控制台china集合里,我们可以看到中国332个城市的名称city、省份province、市区面积city_area、建成区面积builtup_area、户籍人口reg_pop、常住人口resident_pop、GDP的数据。
查询中国GDP在3000亿元以上的前10个城市,并要求不显示_id字段,显示城市名、所在省份以及GDP,并按照GDP大小降序排列。
使用开发者工具新建一个chinadata页面,然后再在index.js的onLoad生命周期函数里输入以下代码。操作集合里的数据涉及的知识点非常繁杂,下面的案例相对比较完整,便于大家有一个整体性的理解:
const db = wx.cloud.database() //获取数据库的引用
const _ = db.command //获取数据库查询及更新指令
db.collection("china") //获取集合china的引用
.where({ //查询的条件指令where
gdp: _.gt(3000) //查询筛选条件,gt表示字段需大于指定值。
})
.field({ //显示哪些字段
_id:false, //默认显示_id,这个隐藏
city: true,
province: true,
gdp:true
})
.orderBy('gdp', 'desc') //排序方式,降序排列
.skip(0) //跳过多少个记录(常用于分页),0表示这里不跳过
.limit(10) //限制显示多少条记录,这里为10
.get() //获取根据查询条件筛选后的集合数据
.then(res => {
console.log(res.data)
})
.catch(err => {
console.error(err)
})
大家可以留意一下数据查询的链式写法, wx.cloud.database().collection('数据库名').where().get().then().catch(),前半部分是数据查询时对对象的引用和方法的调用;后半部分是Promise对象的方法,Promise对象是get的返回值。写的时候为了让结构更加清晰,我们做了换行处理,写在同一行也是可以的。
构建查询条件的5个方法
在上面的案例中,就包含了构建查询条件的五个方法: Collection.where()、 Collection.field()、 Collection.orderBy()、 Collection.skip()、 Collection.limit(),这五个方法是可以单独拆开使用的,比如只使用where或只使用field、limit,也可以从这5个中抽几个组合在一起使用,还可以一次查询里写多个相同的方法,比如orderBy、where可以写多次相同的。
不过值得注意的是这5个方法顺序不同查询的结果有时也会有所不同(比如orderBy多次打乱顺序的情况下),查询性能也会有所不同。通常skip最好放在后面,不要让skip略过大量数据。skip().limit()和limit().skip()效果是等价的。构建查询条件的5个方法是基于集合引用Collection的,就拿where来说,不能写成 wx.cloud.database().where(),也不能是 wx.cloud.database().collection("china").doc.where(),只能是 wx.cloud.database().collection("china").where(),也就是只能用于查询集合collection里的记录。
指令查询条件 where,注意在后面我们会介绍的command查询指令比如筛选字段大于/小于/不等于某个值的比较指令,同时满足多个筛选条件的逻辑指令等,以及模糊查询的正则都是写在where内;
指定返回哪些字段field,查询时只需要传入 true|false(或 1|-1)就可以返回或不返回哪些字段,在上面的案例里我们就只返回city、province、gdp三个字段的值:
数据排序orderBy,排序的语法如下,里面为排序的条件,这里的字段名可不受field的限制(不在field内,没有显示,但是还是会起作用): orderBy('字段名', '排序方式')。 排序方式只支持desc降序、asc升序这两种方式,如果字段里面的值时数字就按照大小,如果是字母就按照先后顺序,不支持中文的排序方式。排序支持按多个字段排序,多次调用 orderBy 即可,多字段排序时的顺序会按照 orderBy 调用顺序先后对多个字段排序。如果需要对嵌套字段排序,可以使用点表示法,比如上面的books根据出版年份year从旧到新排序,可以写为 orderBy('publishInfo.year', 'asc')。
分页显示skip,skip常与limit一起用于分页,比如商品列表一页只显示20个商品,第1页显示整个数据的0~20个,那么第2页我们用skip(20)可以跳过第一页的20条数据,第3页则跳过40个数据,第N页则是skip((n-1)*20)个数据:
限制数量上限的limit,集合数据查询的数量上限limit在小程序端最大数量为20,在服务端为100,比如limit(30)在小程序端还是只会显示20条数据,更多数据则需要我们结合分页skip与javascript进行编程处理。
小程序查询数据显示的结果虽然有数量限制,比如服务端为100个,但是排序仍然是基于整个集合的数据进行排序的,并不是只针对这100个数据。
匹配查询
传入的对象的每个 <key, value> 构成一个筛选条件,有多个 <key, value> 则表示需同时满足这些条件,是 与的关系,如果需要 或关系,可使用 command.or
查询指令Command
指令用于查询时,都会写在where内,主要对字段的值进行比较和逻辑的筛选判断。数据库 API 提供了大于、小于等多种查询指令,这些指令都暴露在 db.command 对象上。
指令Command可以分为查询指令和更新指令,这两者的用法有很大的区别,查询指令用于db.collection的where条件筛选,而更新指令则是用于db.collection.doc的update请求的字段的更新里,这两者的区别在后面我们也会反复提及。
比较操作符和逻辑操作符
下面我们把查询指令的比较操作符和逻辑操作符整理成了一张表格,并附上相应的技术文档,方便大家对它们有一个清晰而整体的认识。 查询指令之比较
查询指令之比较 | |||
---|---|---|---|
gt | 大于 | lt | 小于 |
eq | 等于 | neq | 不等于 |
lte | 小于或等于 | gte | 大于或等于 |
in | 在数组中 | nin | 不在数组中 |
查询指令之逻辑 | |||
and | 条件与 | or | 条件或 |
not | 条件非 | nor | 都不 |
查询指令的写法
指令command是基于database数据库引用的,我们以大于gt在小程序端(以大于3000为例)的完整写法为例:
wx.cloud.database().command.gt(3000)
为了简便,通常我们会把 wx.cloud.database()会赋值给一个变量,如 db, db.command又会赋值给 ,使用时最终被简化为 .gt(3000)。通过一层一层的声明变量并赋值,大大简化了指令的写法,大家可以在其他指令都沿用这种写法。
用法丰富的等于指令Command.eq
相比于其他的比较指令等于eq和不等于neq操作符的用法非常丰富,它可以进行数值比较,我们查询某个字段比如GDP等于某个数值如17502.8亿的城市:
.where({
gdp: _.eq(17502.8),
})
它还可以进行字符串的匹配,比如我们查询某个字段比如city完整匹配一个字符串如深圳:
.where({
city: _.eq("深圳"),
})
注意:在查询时,gdp: .eq(17502.8)的效果等同于gdp:17502.8,而city: .eq(“深圳”)等同于city:”深圳”,虽然两种方式查询的结果都是一致的,但是它们的原理不同,前者用的是等于指令,后者用的是传递对象值。
eq还可以用于字段的值是数组以及对象的情况,在后面的章节我们会再来介绍。
字段内的逻辑指令
查询广东省内、GDP在3000亿以上且在1万亿以下的城市。在广东省内也就是让字段province的值等于”广东”,而GDP的要求则是GDP这个字段同时满足大于3000亿且小于1万亿,这时就需要用到and(条件与,也就是且的意思):
.where({
province:_.eq("广东"),
gdp:_.gt(3000).and(_.lt(10000))
})
跨字段的逻辑指令
上面的案例中where内的两个条件, province:.eq("广东")和 gdp:.gt(3000).and(_.lt(10000))带有跨字段的条件与and(也就是且)的关系,那如何实现跨字段的条件或or呢?
查询中国GDP在3000亿元以上且常住人口在500万以上或建城区面积在300平方公里以上的前20个大城市。这里常住人口和建成区面积只需要满足其中一个条件即可,这就涉及到条件或or(注意下面代码的格式写法):
.where(
{
gdp: _.gt(3000),
resident_pop:_.gt(500),
},
_.or([{
builtup_area: _.gt(300)}
]),
)
注意上面三个条件, gdp: .gt(3000)和 residentpop:.gt(500)是逻辑与,而与 builtuparea: .gt(300)}的关系是逻辑或。 .or([{条件一 },{条件二 }])内是一个数组,条件一与条件二又构成逻辑与的关系。
正则查询db.RegExp
正则表达式能够灵活有效匹配字符串,可以用来检查一个串里是否含有某种子串,比如“CloudBase技术训练营”里是否含有”技术”这个词。云数据库正则查询支持UTF-8的格式,可以进行中英文的模糊查询。正则查询也是写在where字段的条件筛选里。
字段字符串的模糊查询
我们可以用正则查询来查询某个字段,比如city城市名称内,包含某个字符串比如”州”的城市:
.where({
city: db.RegExp({
regexp: '州',
options: 'i',
})
})
注意这里的city是字段,db.RegExp()里的regexp是正则表达式,而options是flag,i是flag的值表示不区分字母的大小写。当然我们也可以直接在where内用JavaScript的原生写法或调用 RegExp对象的构造函数。比如上面的案例也可以写成:
//JavaScript原生正则写法
.where({
city:/州/i
})
//JavaScript调用RegExp对象的构造函数写法
.where({
city: new db.RegExp({
regexp: "州",
options: 'i',
})
})
数据库查询的正则表达式也支持模板字符串,比如我们可以先声明const cityname=”州”,然后用模板字符串包住cityname变量:
city: db.RegExp({
regexp:`${cityname}`,
options: 'i',
})
简单的正则表达式入门
正则表达式的用法是非常繁杂的,关于正则表达式的知识可以去搜索了解更多细节。
值得注意的是,在数据库查询时应尽可能避免过度使用正则表达式来做复杂的匹配,尤其是用户访问触发较多的场景,通常情况下数据查询的响应时间(无论是小程序端还是云函数端)最好要低于500ms。
在小程序端新增记录和统计记录
在前面我们已经介绍了集合数据请求的查询方法get,除了get查询外,请求的方法还有add新增,remove删除、update改写/更新、count统计以及watch监听,这些方法都是基于数据库集合的引用Collection的,接下来我们再来介绍如何基于Collection新增记录和统计记录的数量。
基于数据库集合的引用Collection所查询到的记录都是多条记录,也就是说我们可以对N条记录进行增、删、改、查等操作,不过目前还不支持在小程序端进行多条记录的update和remove,只能在云函数端进行这样的操作。
统计记录Collection.count
统计集合记录数或统计查询语句对应的结果记录数。小程序端与云函数端的表现会有如下差异:小程序端:注意与集合权限设置有关,一个用户仅能统计其有读权限的记录数云函数端:因属于管理端,因此可以统计集合的所有记录数。
const db = wx.cloud.database()
const _ = db.command
db.collection("china")
.where({
gdp: _.gt(3000)
})
.count().then(res => {
console.log(res.total)
})
field、orderBy、skip、limit对count是无效的,只有where才会影响count的结果,count只会返回记录数,不会返回查询到的数据。
新增记录Collection.add
在前面我们将知乎日报的数据导入到了zhihu_daily的集合里,接下来,我们就来给zhihu_daily新增记录。
使用开发者工具新建一个zhihudaily的页面,然后在zhihudaily.wxml里输入以下代码,新建一个绑定了事件处理函数为addDaily的button按钮:
<button bindtap="addDaily">新增日报数据</button>
然后再在zhihudaily.js里输入以下代码,在事件处理函数addDaily里调用Collection.add,往集合zhihu_daily里添加一条记录,如果传入的记录对象没有 _id 字段,则由后台自动生成 _id;若指定了 _id,则不能与已有记录冲突。
addDaily(){
db.collection('zhihu_daily').add({
data: {
_id:"daily9718005",
title: "元素,生生不息的宇宙诸子",
images: [
"https://pic4.zhimg.com/v2-3c5d866701650615f50ff4016b2f521b.jpg"
],
id: 9718005,
url: "https://daily.zhihu.com/story/9718005",
image: "https://pic2.zhimg.com/v2-c6a33965175cf81a1b6e2d0af633490d.jpg",
share_url: "http://daily.zhihu.com/story/9718005",
body:"<p><strong><strong>谨以此文,纪念元素周期表发布 150 周年。</strong></strong></p>\r\n<p>地球,世界,和生活在这里的芸芸众生从何而来,这是每个人都曾有意无意思考过的问题。</p>\r\n<p>科幻小说家道格拉斯·亚当斯给了一个无厘头的答案,42;宗教也给出了诸神创世的虚构场景;</p>\r\n<p>最为恢弘的画面,则是由科学给出的,另一个意义上的<strong>生死轮回,一场属于元素的生死轮回</strong>。</p>"
}
})
.then(res => {
console.log(res)
})
.catch(console.error)
}
点击新增日报数据的button,会看到控制台打印的res对象里包含新增记录的_id为我们自己设置的daily9718005。打开云开发控制台的数据库标签,打开集合zhihu_daily,翻到最后一页,就能看到我们新增的记录啦。
_openid与集合权限
注意和导入的数据不同的是,在小程序端新增记录,都会自动添加一个_openid的字段,它的值等于用户 openid,_openid的值是不允许修改的。当我们把集合的权限改为仅创建者可读写,或所有人可读,仅创建者可读写,在小程序端查询或更新记录时,会自动添加一个条件,
.where({
_openid:"当前用户的openid"
})
所以这就是为什么尽管集合里面有数据,但是由于有了这个条件,只要记录里没有_openid或openid不匹配就查询不到记录。
集合请求方法注意事项
get、update、count、remove、add等都是请求,在小程序端可以有callback和promise两种写法,但是在云函数端只能用promise,不能用callback。为了方便,建议大家统一使用promise的写法,也就是then、catch。
get、update、count、remove、add请求不能在一个数据库引用里同时存在。比如不能又是get(),又是count()的,不能这么写:
db.collection('china').where({
_openid: 'xxx',
}).get().count().add()
云函数端操作集合内记录
云函数端调用数据库
在云开发能力章节我们已经介绍过如何在云函数端调用数据库,这里也是一样。新建一个云函数chinadata,然后在 exports.main = async (event, context) => {}输入以下代码,注意是 const db = cloud.database(),wx. cloud.database(),云函数端的数据库引用和小程序端有所不同:
const db = cloud.database()
const _ = db.command
return await db.collection("china")
.where({
gdp: _.gt(3000)
})
.field({
_id: false,
city: true,
province: true,
gdp: true
})
.orderBy('gdp', 'desc')
.skip(0)
.limit(10)
.get()
try/catch async错误处理
当 async 函数中只要一个 await 出现 reject 状态,则后面的 await 都不会被执行。如果有多个 await 则可以将其都放在 try/catch 中。
然后右键chinadata云函数根目录选择在终端中打开,输入npm install,之后上传并部署所有文件。
在前面我们了解到,调用云函数可以使用本地调试、云端测试,我们还可以在小程序端调用云函数,将云函数的数据返回到小程序端来。使用开发者工具在chinadata.wxml里输入以下代码,也就是我们通用点击按钮触发事件处理函数:
<button bindtap="callChinaData">调用chinadata云函数</button>
再在事件处理函数里调用云函数,在chinadata.js里输入getChinaData事件处理函数来调用chinadata云函数:
getChinaData() {
wx.cloud.callFunction({
name: 'chinadata',
success: res => {
console.log("云函数返回的数据",res.result.data)
},
fail: err => {
console.error('云函数调用失败:', err)
}
})
},
在模拟器里点击调用chinadata云函数的button按钮,就能在控制台里看到云函数返回的查询到的结果,大家可以通过setData的方式将查询的结果渲染到小程序页面,这里就不介绍啦。
删除多条数据记录
基于数据库集合的引用Collection,我们可以先匹配 where 语句查询到相关条件的多条记录,再来调用Collection.remove()来进行删除。五个查询方法,skip和limit不支持,field、orderBy没有意义,只有where条件可以用来筛选记录。数据一旦删除就不能再找回了。
我们可以把之前建好的chinadata云函数 exports.main = async (event, context) => {}里的代码修改为如下,即删除省份province为广东的所有数据:
return await db.collection('china')
.where({
province:"广东"
})
.remove()
在模拟器里点击调用chinadata云函数的button按钮,就能在控制台里看到云函数返回的对象,其中包含stats: {removed: 22},即删除了22条数据。
我们可以把之前建好的chinadata云函数 exports.main = async (event, context) => {}里的代码修改为如下,也就是先查询省份province为湖北的记录,给这个记录更新一个字段英文省份名pro-en:
return await db.collection('china')
.where({
province:"湖北"
})
.update({
data: {
"pro-en": "Hubei"
},
})
这里要注意的是,pro-en这个字段之前是没有的,通过Collection.update不只是起到更新的作用,还可以批量新增字段并赋值,也就是update时记录里有相同字段就更新,没有就新增; "pro-en": "Hubei",直接使用pro-en会报错,用双引号效果等价。
如果你想给导入的数据添加_openid字段,只用云函数是没法实现的,因为云函数没有用户的登录态。我们需要先在小程序端调用云函数比如login返回openid,再将openid的值再传给chinadata云函数,才能给记录添加openid。
操作单个记录doc的字段值
在前面我们已经了解了基于集合引用Collection构建查询条件的5个方法,以及一些请求方法,接下来我们来讲一下基于集合记录引用Document的四个请求方法:获取单个记录数据Document.get()、删除单个记录Document.remove()、更新单个记录Document.update()、替换更新单个记录Document.set()。和基于Collection不一样的是,前者的增删改查是可以批量多条的,而基于Document则是操作单条记录。
查询集合collection里的记录常用于获取文章、资讯、商品、产品等等的列表;而查询单个记录doc的字段值则常用于这些列表里的详情内容。如果你在开发中需要增删改查某个记录的字段值,为了方便让程序可以根据_id找到对应的记录,建议在创建记录的时候_id用程序有规则的生成。
查询单个记录doc的字段值
集合里的每条记录都有一个 _id 字段用以唯一标志一条记录,_id 的数据格式可以是number数字,也可以是string字符串。这个_id是可以自定义的,当导入记录或写入记录没有自定义时系统会自动生成一个非常长的字符串。查询记录doc的字段field值就是基于_id的。
比如我们查询其中知乎日报的一篇文章(也就是其中一条记录)的数据,使用开发者工具zhihudaily页面的zhihudaily.js的onLoad生命周期函数里输入以下代码(db不要重复声明):
db.collection('zhihu_daily').doc("daily9718006")
.get()
.then(res => {
console.log('单个记录的值',res.data)
})
.catch(err => {
console.error(err)
})
},
如果集合的数据是导入的,那_id是自动生成的,自动生成的_id是字符串string,所以doc内使用了单引号(双引号也是可以的哦),如果你自定义的_id是number类型,比如自定义的_id为20191125,查询时为doc(20191125)即可,这只是基础知识啦。
删除单条记录
removeDaily(){
db.collection('zhihu_daily').doc("daily9718006")
.remove()
.then(console.log)
.catch(console.error)
}
更新单条记录
updateDaily(){
db.collection('zhihu_daily').doc("daily9718006")
.update({
data:{
title: "【知乎日报】元素,生生不息的宇宙诸子",
}
})
},
替换更新记录
setDaily(){
db.collection('zhihu_daily').doc("daily9718006")
.set({
data: {
"title": "为什么狗会如此亲近人类?",
"images": [
"https://pic4.zhimg.com/v2-4cab2fbf4fe9d487910a6f2c54ab3ed3.jpg"
],
"id": 9717547,
"url": "https://daily.zhihu.com/story/9717547",
"image": "https://pic4.zhimg.com/v2-60f220ee6c5bf035d0eaf2dd4736342b.jpg",
"share_url": "http://daily.zhihu.com/story/9717547",
"body": "<p>让狗从凶猛的野兽变成忠实的爱宠,涉及了宏观与微观上的两层故事:我们如何在宏观上驯养了它们,以及这些驯养在生理层面究竟意味着什么。</p>\r\n<p><img class=\"content-image\" src=\"http://pic1.zhimg.com/70/v2-4147c4b02bf97e95d8a9f00727d4c184_b.jpg\" alt=\"\"></p>\r\n<p>狗是灰狼(Canis lupus)被人类驯养后形成的亚种,至少可以追溯到 1 万多年以前,是人类成功驯化的第一种动物。在这漫长的岁月里,人类的定向选择强烈改变了这个驯化亚种的基因频率,使它呈现出极高的多样性,尤其体现在生理形态上。</p>"
}
})
}
选择支付方式:
备注:
转账时请填写正确的金额和备注信息,到账由人工处理,可能需要较长时间