HBuilderX

HBuilderX

极客开发工具
uni-app

uni-app

开发一次,多端覆盖
uniCloud

uniCloud

云开发平台
HTML5+

HTML5+

增强HTML5的功能体验
MUI

MUI

上万Star的前端框架

HBuiler报错,再打开所有项目只剩下。project文件,怎么恢复

HBuiler报错,再打开所有项目只剩下。project文件,怎么恢复

HBuiler报错,再打开所有项目只剩下。project文件,怎么恢复

关于uni-app使用 uni.$on uni.$emit 进行页面传值的坑!《开发日记》 2020.5.9

uniapp

第一个 APP 快开发完了,结果发现个重大问题!

子页面用 uni.$emit 转值 给父页面,父页面用uni.$on 接收值判断是否刷新面页,正式测试时发页面数据刷新时 onShow 居然会重复多次触发。

找了一整天问题,反复琢磨了 2 天,第 3 天终于找出了问题根源!

uni-app 的 api 文档 里只说了用什么方式传值,但没说需要注意的地方!

使用 uni.$emit 传值后必须在使用页面的onUnload中加入 uni.$off 关闭全局监听才行,否则面子页面无法完全释放,父页面就会多次触发带有 uni.$on 代码的 onShow()。

花了 2 天时间把程序里所有涉及 uni.$emit 的页面加了uni.$off 代码后问题全部解决!

没办法,入坑就入坑,反正比直接搞原生的代码方便很多!

继续阅读 »

第一个 APP 快开发完了,结果发现个重大问题!

子页面用 uni.$emit 转值 给父页面,父页面用uni.$on 接收值判断是否刷新面页,正式测试时发页面数据刷新时 onShow 居然会重复多次触发。

找了一整天问题,反复琢磨了 2 天,第 3 天终于找出了问题根源!

uni-app 的 api 文档 里只说了用什么方式传值,但没说需要注意的地方!

使用 uni.$emit 传值后必须在使用页面的onUnload中加入 uni.$off 关闭全局监听才行,否则面子页面无法完全释放,父页面就会多次触发带有 uni.$on 代码的 onShow()。

花了 2 天时间把程序里所有涉及 uni.$emit 的页面加了uni.$off 代码后问题全部解决!

没办法,入坑就入坑,反正比直接搞原生的代码方便很多!

收起阅读 »

承接各种APP开发,5+插件开发、uniapp插件开发,nvue嵌入插件、及离线打包等业务 qq:37894663

5+App开发 uniapp插件 插件开发 插件 外包

承接各种APP开发,5+插件开发、uniapp插件开发,nvue嵌入插件、及离线打包等业务 qq:37894663

承接各种APP开发,5+插件开发、uniapp插件开发,nvue嵌入插件、及离线打包等业务 qq:37894663

iOS发布证书p12及mobileprovision文件申请文档

iOS证书 iOS iOS打包

iOS发布证书用于上架App Store,当开发好APP后需要上架就要用iOS底部证书有p12及mobileprovision两个文件去打包!

只有用iOS发布证书打包的ipa才能上传到App Store

一、申请iOS发布证书(p12文件)

​​

使用Appuploader软件申请,可以辅助在Windows、linux或mac系统直接申请iOS证书p12,及上传ipa到App Store,最方便在Windows开发上架没有苹果Mac电脑的开发者!

1.1、用苹果开发者账号登录appuploader软件后,选择证书进入,点击右下角+ADD选择

类型:下拉选择发布证书

证书名称:不要中文、不要太长了、随意设置

邮箱:随意

密码:证书的密码、比如设置123这样,不用很复杂,记好、打包时要用、很重要

应用id:这里不用选

点击ok创建。

1.2、创建成功后,找到刚创建的发布证书(iOS Distribution这个类型的就是发布证书,如果之前创建过看过期时间或者ID就知道哪个是新创建的了)!

点击p12 文件,下载保存.p12证书文件到电脑。

二、创建iOS发布描述文件(.mobileprovision文件)

2.1、点击右下角BACK、返回Appuploader首页,选择描述文件进去。

2.2、点击右下角+ADD,进入申请界面!

Type:下拉选择发布版profile

应用id:下拉选择对应的应用id(又称套装id,appid,BundleID,包名)

证书:选中全部就行

Devices:发布版不用选设备

输入名称:不要中文,随意,123之类的就行,注意不要跟之前申请过的名称一样),点击ok创建。

2.3、选择刚创建的发布版描述文件(iOS Distribution这个类型的就是发布描述文件,找刚创建的输入的名称),点击Download下载,保存到电脑

把申请到的iOS证书(.p12)和描述文件(.mobileprovision)这两个文件下载,上传到打包平台打包即可。

继续阅读 »

iOS发布证书用于上架App Store,当开发好APP后需要上架就要用iOS底部证书有p12及mobileprovision两个文件去打包!

只有用iOS发布证书打包的ipa才能上传到App Store

一、申请iOS发布证书(p12文件)

​​

使用Appuploader软件申请,可以辅助在Windows、linux或mac系统直接申请iOS证书p12,及上传ipa到App Store,最方便在Windows开发上架没有苹果Mac电脑的开发者!

1.1、用苹果开发者账号登录appuploader软件后,选择证书进入,点击右下角+ADD选择

类型:下拉选择发布证书

证书名称:不要中文、不要太长了、随意设置

邮箱:随意

密码:证书的密码、比如设置123这样,不用很复杂,记好、打包时要用、很重要

应用id:这里不用选

点击ok创建。

1.2、创建成功后,找到刚创建的发布证书(iOS Distribution这个类型的就是发布证书,如果之前创建过看过期时间或者ID就知道哪个是新创建的了)!

点击p12 文件,下载保存.p12证书文件到电脑。

二、创建iOS发布描述文件(.mobileprovision文件)

2.1、点击右下角BACK、返回Appuploader首页,选择描述文件进去。

2.2、点击右下角+ADD,进入申请界面!

Type:下拉选择发布版profile

应用id:下拉选择对应的应用id(又称套装id,appid,BundleID,包名)

证书:选中全部就行

Devices:发布版不用选设备

输入名称:不要中文,随意,123之类的就行,注意不要跟之前申请过的名称一样),点击ok创建。

2.3、选择刚创建的发布版描述文件(iOS Distribution这个类型的就是发布描述文件,找刚创建的输入的名称),点击Download下载,保存到电脑

把申请到的iOS证书(.p12)和描述文件(.mobileprovision)这两个文件下载,上传到打包平台打包即可。

收起阅读 »

uniapp团队开发APP开发定制

uniapp模板 uniapp App 移动APP

(1)用户的操作习惯。只有符合用户操作习惯的界面和按钮分布才能给用户更好的体验。
(2)用户人群的属性。用户的人群定位,区域定位,年龄层等,决定了APP的使用环境。企业应针对用户去开发和设计合适的功能和界面。
(3)减少APP的访问级别。在移动终端上,如果有太多的访问级别会使用户失去耐心,甚至放弃使用。
(4)功能精简。开发APP不是功能越多越好,页面也不是越炫越好。事实证明,用户更喜欢简单大方的风格。所以企业在考虑功能时,一定要越精简越好,主次分明。
app定制开发,app定制开发项目、APP团队开发
158-3211-5099

继续阅读 »

(1)用户的操作习惯。只有符合用户操作习惯的界面和按钮分布才能给用户更好的体验。
(2)用户人群的属性。用户的人群定位,区域定位,年龄层等,决定了APP的使用环境。企业应针对用户去开发和设计合适的功能和界面。
(3)减少APP的访问级别。在移动终端上,如果有太多的访问级别会使用户失去耐心,甚至放弃使用。
(4)功能精简。开发APP不是功能越多越好,页面也不是越炫越好。事实证明,用户更喜欢简单大方的风格。所以企业在考虑功能时,一定要越精简越好,主次分明。
app定制开发,app定制开发项目、APP团队开发
158-3211-5099

收起阅读 »

优化Hx云打包时建议保存签名密码

HBuilder

云打包时建议保存签名密码,,,
打包五十多次了,每一次都要签名密码实属麻烦,
要么保存到项目id下的云端上。
要么可以保存到本地

云打包时建议保存签名密码,,,
打包五十多次了,每一次都要签名密码实属麻烦,
要么保存到项目id下的云端上。
要么可以保存到本地

uni-app开发小程序总结

小程序

项目构建

使用npm包

使用npm init -y初始化npm项目,然后使用你喜欢的方式(npm、yarn)安装你需要的npm包;非h5平台的项目不支持使用到dom操作的包。

使用小程序组件

使用小程序组件需要在pages.json中进行配置,在具体的页面中引用:

{  
  "path":"pages/Home/index",  
  "style": {  
    "navigationBarTitleText": "首页",  
    "usingComponents": {  
      "van-search": "/wxcomponents/vant-weapp/search/index"  
    }  
  }  
}

在全局使用:

"globalStyle": {  
  "navigationBarTextStyle": "black",  
  "navigationBarTitleText": "uni-app",  
  "navigationBarBackgroundColor": "#F8F8F8",  
  "backgroundColor": "#F8F8F8",  
  "usingComponents": {  
    "van-button": "/wxcomponents/vant-weapp/button/index",  
    "icon": "/wxcomponents/vant-weapp/icon/index",  
    "van-search": "/wxcomponents/vant-weapp/search/index"  
  }  
},

注意 引用的组件名必须为小写,不然可能会出现无法找到组件的问题

条件编译

官网已经有比较详细的说明了,通过特殊的注释语法来实现代码级别的条件编译,语法是以 #ifdef 或 #ifndef 加 %PLATFORM% 开头,以 #endif 结尾。

需要特别说明下的是整体目录条件编译,下项目根目录建立platforms文件夹,然后根据不同的平台建立不同的文件夹(如手机端创建app-plus文件夹),在pages配置path就会根据不同的平台加载不同的页面了。

{  
    "path": "platforms/app-plus/speech/speech",  
    "style": {  
        "navigationBarTitleText": "语音识别"  
    }  
}

上面配置的页面只会在app-plus相关平台加载。

easycom组件模式

hbuilder支持easycom组件模式,一般使用组件我们需要安装、引用、注册三个步骤。easycom组件模式下,你只需将组件下载到components目录下,并符合components/组件名称/组件名称.vue的目录结构,你就可以直接在页面中使用组件,而不用导入,注册;并且没有使用到的组件会在打包时自动剔除。

easycom也支持自定义配置,具体可以查看官方文档

开发项配置

在我们进行开发时,我们可以指定启动页面,启动项目需要在pages.json中配置,可以配置多项,然后运行时进行选择。

"condition" : { //模式配置,仅开发期间生效  
 "current": 0, //当前激活的模式(list 的索引项)  
 "list": [  
   {  
     "name": "Market", //模式名称  
     "path": "pages/Cart/index", //启动页面,必选  
     "query": "" //启动参数,在页面的onLoad函数里面得到  
   }  
 ]

注意:query启动参数并不像官方文档里说的,可以在onLoad函数中获取到,后面我们也会提到这个问题和相应的解决方法。

环境变量

内置环境变量process.env.NODE_ENV,我们可以通过这个之判断当前的运行环境,实现在不同的环境下使用不同的方式。

const isDev = process.env.NODE_ENV === 'development'  
const http = axios.create({  
  baseURL: isDev ? DEV : PRO,  
  timeout: 3000  
})

开发技巧

本地图片

uni-app支持使用本地图片,本地图片可以直接在image标签中引入使用,也可以作为背景图片使用;本地图片会在不支持本地图片的平台(小程序)中,自动转换为base64的格式,但是要求图片必须小于40kb;使用图片的引用路径应该使用~@开头,不然在window中可能出现找不到资源的问题。

通过image标签使用本地图片,图片会以base64的格式存储在打包后的vendor.js文件中,这个文件会在小程序加载时引用,如果使用太多的本地图片会导致小程序首次运行速度变慢;通过背景图片的方式使用,图片会存储在引用文件的样式文件中,会增加包的大小,但是对启动速度的影响不大。

体积较大的图片建议使用网络图片,体积小的图片也应该尽量不要在image标签引用,而是以背景的图片的形式来使用。

小程序分享

小程序分享是通过点击open-typeshare的button或者页面的分享按钮来触发的,分享的内容是页面onShareAppMessage的返回值,返回值包含title-分享标题,path-分享路径,imageUrl-分享图片,onShareAppMessage只能定义在页面上,在组件中定义是没有用的。

如果你分享的信息是动态的,你可以通过按钮的data-绑定数据:

<button open-type="share"   
        :data-title="***"   
        :data-imgurl="***"  
        :data-path="***">  
</button>

然后在回调函数中可以获取数据:

onShareAppMessage(data) {  
    let dataset = data.target.dataset  
    return {  
        title: dataset.title,  
        imageUrl: dataset.imgurl,  
        path: dataset.path  
    }  
},

生命周期

在uni-app中,有些时候生命周期的表现会出人意料,让人措手不及:

通过小程序分享时,我们往往会带上路由参数,uni-app的启动选项也是可以配置参数,正常的路由跳转我们可以在onLoad中获取参数,但是在刚才我们说的两种场景中,在onLoad中我们是无法获取参数的,只能在onLauch中获取,如果需要获得路由参数,我们只能在onLauch中获取,然后通过其他方式(vuex或者其他方式)保存,然后在需要的地方获取使用。

tab页面与其他页面的生命周期也不同一样,tab页面切换不会触发onLoad事件,而打开一般的页面会触发onLoad页面。

back返回上一页时,一般是不会触发onLoad,但是会触发onShow。

在选择图片时,打开相册,再回到小程序会触发小程序应用的onShow。

上传图片

uni中可以使用uni.uploadFile接口上传文件:

uni.chooseImage({  
    success: (chooseImageRes) => {  
        const tempFilePaths = chooseImageRes.tempFilePaths;  
        uni.uploadFile({  
            url: 'https://www.example.com/upload', //仅为示例,非真实的接口地址  
            filePath: tempFilePaths[0],  
            name: 'file',  
            formData: {  
                'user': 'test'  
            },  
            success: (uploadFileRes) => {  
                console.log(uploadFileRes.data);  
            }  
        });  
    }  
});

如果你使用了第三方的对象存储OSS你也可以使用uni.uploadFile实现直传:

  1. 配置Bucket跨域访问;客户端进行表单直传到OSS时,会从浏览器向OSS发送带有Origin的请求消息。OSS对带有Origin头的请求消息会进行跨域规则(CORS)的验证。因此需要为Bucket设置跨域规则以支持Post方法。在OSS管理控制台,修改相关规则配置。
  2. 配置外网域名到小程序的域名白名单;在OSS管理控制台的访问域名区域查看Bucket域名,然后登录小程序平台,配置小程序的上传域名白名单,如果不配置不会影响开发环境的上传,但是发布(包含发布体验版)的小程序会无法上传。
  3. 根据平台要求,生成上传需要的信息,使用uni.uploadFile上传
import md5 from 'md5'  

export default ({ dir, host, expire, signature, policy, accessid }) => {  
  return new Promise((resolve, reject) => {  
    uni.chooseImage({  
      count: 1,  
      success: (res) => {  
        const filePath = res.tempFilePaths[0]  

        const ext = filePath.replace(/([\S\s]+)\.(\S+)$/, '$2')  
        const key = `${dir}/${md5(Math.random())}.${ext}` // 文件名MD5重命名  
        uni.uploadFile({  
          url: host,  
          filePath,  
          name: 'file',  
          formData: {  
            policy,  
            OSSAccessKeyId: accessid,  
            signature,  
            expire,  
            key,  
          },  
          success: () => {  
            resolve(`${host}/${key}`) // 返回图片地址  
          },  
          fail (err) {  
            reject(err)  
          }  
        })  
      },  
      fail (err) {  
        reject(err)  
      }  
    })  
  })  
}

如果使用的是阿里云可以参考这份文档-小程序直传实践

小程序顶部适配

在某些场景下,我们需要自定义标题栏,但是标题栏的高度在不同型号的手机中可能是不相同的,这里我们能可以通过微信小程序右上角的胶囊位置来做适配。

我们可以通过uni.getMenuButtonBoundingClientRect()获取到胶囊的相关信息menuButtonmenuButton包含胶囊的位置信息top、bottom、left、right,和胶囊尺寸信息width、height;我们通过这些信息就可以计算出合适的标题栏高度。

滚动

页面级别的滚动由pages.json中page的style项进行配置,相关值如下:

属性 类型 默认值 描述
disableScroll Boolean false 设置为 true 则页面整体不能上下滚动(bounce效果),只在页面配置中有效,在globalStyle中设置无效
enablePullDownRefresh Boolean false 是否开启下拉刷新,触发页面的onPullDownRefresh
onReachBottomDistance Number 50 页面上拉触底事件触发时距页面底部距离,单位只支持px,会触发页面的onReachBottom

页面生命周期中除了onPullDownRefresh和onReachBottom,还有onPageScroll,用于监听页面滚动,参数是一个对象,包含属性scrollTop,表示页面在垂直方向已滚动的距离(单位px)。

可视化滚动区域需要通过scroll-view来实现,通过css属性overflow设置滚动,在真机上无法滚动。

scroll-view使用竖向滚动的时候需要一个固定高度,这个高度可以是calc高度或者是flex。

scroll-view的滚动条设置,可通过css的-webkit-scrollbar自定义,包括隐藏滚动条。

在ios中如果设置了scroll-view,会出现橡皮筋回弹效果,页面的回弹效果可以通过disableScroll: true取消,但是如果是scroll-view标签,你只能设置scroll-y为false,如果即需要滚动有想要取消回弹效果,可以通过监听scroll-view的相关事件,判断滚动状态,在回弹的情况时,设置scroll-y为false,来阻止回弹。

详情参考:小程序取消橡皮筋回弹效果解决方案及坑总结

uni还提供uni.pageScrollTop方法,将页面滚动到目标位置-说明

其他

开启微信调试工具vConsole,并使用条件编译设置只在小程序中使用:

//#ifdef MP-WEIXIN  
wx.setEnableDebug({  
    enableDebug: false  
})  
//#endif

ios微信/Safari中时间格式化的问题,new Date()要求字符串必须的类似与2018-05-31T00:00:00的格式,日期与时间之间不能是其他字符或者空格,否则结果会是NaN。

使用css样式,扩大可点击区域:

position: relative;  
&:before {  
    content: '';  
    position: absolute;  
    top: -10px; bottom: -10px; left: -10px; right: -10px;  
};
继续阅读 »

项目构建

使用npm包

使用npm init -y初始化npm项目,然后使用你喜欢的方式(npm、yarn)安装你需要的npm包;非h5平台的项目不支持使用到dom操作的包。

使用小程序组件

使用小程序组件需要在pages.json中进行配置,在具体的页面中引用:

{  
  "path":"pages/Home/index",  
  "style": {  
    "navigationBarTitleText": "首页",  
    "usingComponents": {  
      "van-search": "/wxcomponents/vant-weapp/search/index"  
    }  
  }  
}

在全局使用:

"globalStyle": {  
  "navigationBarTextStyle": "black",  
  "navigationBarTitleText": "uni-app",  
  "navigationBarBackgroundColor": "#F8F8F8",  
  "backgroundColor": "#F8F8F8",  
  "usingComponents": {  
    "van-button": "/wxcomponents/vant-weapp/button/index",  
    "icon": "/wxcomponents/vant-weapp/icon/index",  
    "van-search": "/wxcomponents/vant-weapp/search/index"  
  }  
},

注意 引用的组件名必须为小写,不然可能会出现无法找到组件的问题

条件编译

官网已经有比较详细的说明了,通过特殊的注释语法来实现代码级别的条件编译,语法是以 #ifdef 或 #ifndef 加 %PLATFORM% 开头,以 #endif 结尾。

需要特别说明下的是整体目录条件编译,下项目根目录建立platforms文件夹,然后根据不同的平台建立不同的文件夹(如手机端创建app-plus文件夹),在pages配置path就会根据不同的平台加载不同的页面了。

{  
    "path": "platforms/app-plus/speech/speech",  
    "style": {  
        "navigationBarTitleText": "语音识别"  
    }  
}

上面配置的页面只会在app-plus相关平台加载。

easycom组件模式

hbuilder支持easycom组件模式,一般使用组件我们需要安装、引用、注册三个步骤。easycom组件模式下,你只需将组件下载到components目录下,并符合components/组件名称/组件名称.vue的目录结构,你就可以直接在页面中使用组件,而不用导入,注册;并且没有使用到的组件会在打包时自动剔除。

easycom也支持自定义配置,具体可以查看官方文档

开发项配置

在我们进行开发时,我们可以指定启动页面,启动项目需要在pages.json中配置,可以配置多项,然后运行时进行选择。

"condition" : { //模式配置,仅开发期间生效  
 "current": 0, //当前激活的模式(list 的索引项)  
 "list": [  
   {  
     "name": "Market", //模式名称  
     "path": "pages/Cart/index", //启动页面,必选  
     "query": "" //启动参数,在页面的onLoad函数里面得到  
   }  
 ]

注意:query启动参数并不像官方文档里说的,可以在onLoad函数中获取到,后面我们也会提到这个问题和相应的解决方法。

环境变量

内置环境变量process.env.NODE_ENV,我们可以通过这个之判断当前的运行环境,实现在不同的环境下使用不同的方式。

const isDev = process.env.NODE_ENV === 'development'  
const http = axios.create({  
  baseURL: isDev ? DEV : PRO,  
  timeout: 3000  
})

开发技巧

本地图片

uni-app支持使用本地图片,本地图片可以直接在image标签中引入使用,也可以作为背景图片使用;本地图片会在不支持本地图片的平台(小程序)中,自动转换为base64的格式,但是要求图片必须小于40kb;使用图片的引用路径应该使用~@开头,不然在window中可能出现找不到资源的问题。

通过image标签使用本地图片,图片会以base64的格式存储在打包后的vendor.js文件中,这个文件会在小程序加载时引用,如果使用太多的本地图片会导致小程序首次运行速度变慢;通过背景图片的方式使用,图片会存储在引用文件的样式文件中,会增加包的大小,但是对启动速度的影响不大。

体积较大的图片建议使用网络图片,体积小的图片也应该尽量不要在image标签引用,而是以背景的图片的形式来使用。

小程序分享

小程序分享是通过点击open-typeshare的button或者页面的分享按钮来触发的,分享的内容是页面onShareAppMessage的返回值,返回值包含title-分享标题,path-分享路径,imageUrl-分享图片,onShareAppMessage只能定义在页面上,在组件中定义是没有用的。

如果你分享的信息是动态的,你可以通过按钮的data-绑定数据:

<button open-type="share"   
        :data-title="***"   
        :data-imgurl="***"  
        :data-path="***">  
</button>

然后在回调函数中可以获取数据:

onShareAppMessage(data) {  
    let dataset = data.target.dataset  
    return {  
        title: dataset.title,  
        imageUrl: dataset.imgurl,  
        path: dataset.path  
    }  
},

生命周期

在uni-app中,有些时候生命周期的表现会出人意料,让人措手不及:

通过小程序分享时,我们往往会带上路由参数,uni-app的启动选项也是可以配置参数,正常的路由跳转我们可以在onLoad中获取参数,但是在刚才我们说的两种场景中,在onLoad中我们是无法获取参数的,只能在onLauch中获取,如果需要获得路由参数,我们只能在onLauch中获取,然后通过其他方式(vuex或者其他方式)保存,然后在需要的地方获取使用。

tab页面与其他页面的生命周期也不同一样,tab页面切换不会触发onLoad事件,而打开一般的页面会触发onLoad页面。

back返回上一页时,一般是不会触发onLoad,但是会触发onShow。

在选择图片时,打开相册,再回到小程序会触发小程序应用的onShow。

上传图片

uni中可以使用uni.uploadFile接口上传文件:

uni.chooseImage({  
    success: (chooseImageRes) => {  
        const tempFilePaths = chooseImageRes.tempFilePaths;  
        uni.uploadFile({  
            url: 'https://www.example.com/upload', //仅为示例,非真实的接口地址  
            filePath: tempFilePaths[0],  
            name: 'file',  
            formData: {  
                'user': 'test'  
            },  
            success: (uploadFileRes) => {  
                console.log(uploadFileRes.data);  
            }  
        });  
    }  
});

如果你使用了第三方的对象存储OSS你也可以使用uni.uploadFile实现直传:

  1. 配置Bucket跨域访问;客户端进行表单直传到OSS时,会从浏览器向OSS发送带有Origin的请求消息。OSS对带有Origin头的请求消息会进行跨域规则(CORS)的验证。因此需要为Bucket设置跨域规则以支持Post方法。在OSS管理控制台,修改相关规则配置。
  2. 配置外网域名到小程序的域名白名单;在OSS管理控制台的访问域名区域查看Bucket域名,然后登录小程序平台,配置小程序的上传域名白名单,如果不配置不会影响开发环境的上传,但是发布(包含发布体验版)的小程序会无法上传。
  3. 根据平台要求,生成上传需要的信息,使用uni.uploadFile上传
import md5 from 'md5'  

export default ({ dir, host, expire, signature, policy, accessid }) => {  
  return new Promise((resolve, reject) => {  
    uni.chooseImage({  
      count: 1,  
      success: (res) => {  
        const filePath = res.tempFilePaths[0]  

        const ext = filePath.replace(/([\S\s]+)\.(\S+)$/, '$2')  
        const key = `${dir}/${md5(Math.random())}.${ext}` // 文件名MD5重命名  
        uni.uploadFile({  
          url: host,  
          filePath,  
          name: 'file',  
          formData: {  
            policy,  
            OSSAccessKeyId: accessid,  
            signature,  
            expire,  
            key,  
          },  
          success: () => {  
            resolve(`${host}/${key}`) // 返回图片地址  
          },  
          fail (err) {  
            reject(err)  
          }  
        })  
      },  
      fail (err) {  
        reject(err)  
      }  
    })  
  })  
}

如果使用的是阿里云可以参考这份文档-小程序直传实践

小程序顶部适配

在某些场景下,我们需要自定义标题栏,但是标题栏的高度在不同型号的手机中可能是不相同的,这里我们能可以通过微信小程序右上角的胶囊位置来做适配。

我们可以通过uni.getMenuButtonBoundingClientRect()获取到胶囊的相关信息menuButtonmenuButton包含胶囊的位置信息top、bottom、left、right,和胶囊尺寸信息width、height;我们通过这些信息就可以计算出合适的标题栏高度。

滚动

页面级别的滚动由pages.json中page的style项进行配置,相关值如下:

属性 类型 默认值 描述
disableScroll Boolean false 设置为 true 则页面整体不能上下滚动(bounce效果),只在页面配置中有效,在globalStyle中设置无效
enablePullDownRefresh Boolean false 是否开启下拉刷新,触发页面的onPullDownRefresh
onReachBottomDistance Number 50 页面上拉触底事件触发时距页面底部距离,单位只支持px,会触发页面的onReachBottom

页面生命周期中除了onPullDownRefresh和onReachBottom,还有onPageScroll,用于监听页面滚动,参数是一个对象,包含属性scrollTop,表示页面在垂直方向已滚动的距离(单位px)。

可视化滚动区域需要通过scroll-view来实现,通过css属性overflow设置滚动,在真机上无法滚动。

scroll-view使用竖向滚动的时候需要一个固定高度,这个高度可以是calc高度或者是flex。

scroll-view的滚动条设置,可通过css的-webkit-scrollbar自定义,包括隐藏滚动条。

在ios中如果设置了scroll-view,会出现橡皮筋回弹效果,页面的回弹效果可以通过disableScroll: true取消,但是如果是scroll-view标签,你只能设置scroll-y为false,如果即需要滚动有想要取消回弹效果,可以通过监听scroll-view的相关事件,判断滚动状态,在回弹的情况时,设置scroll-y为false,来阻止回弹。

详情参考:小程序取消橡皮筋回弹效果解决方案及坑总结

uni还提供uni.pageScrollTop方法,将页面滚动到目标位置-说明

其他

开启微信调试工具vConsole,并使用条件编译设置只在小程序中使用:

//#ifdef MP-WEIXIN  
wx.setEnableDebug({  
    enableDebug: false  
})  
//#endif

ios微信/Safari中时间格式化的问题,new Date()要求字符串必须的类似与2018-05-31T00:00:00的格式,日期与时间之间不能是其他字符或者空格,否则结果会是NaN。

使用css样式,扩大可点击区域:

position: relative;  
&:before {  
    content: '';  
    position: absolute;  
    top: -10px; bottom: -10px; left: -10px; right: -10px;  
};
收起阅读 »

uni-app第一贴子 初始次使用uni-app开发

初始次使用uni-app开发, 目前仅有几名前端开发人员,记录一下我们遇以的问题。

现阶段遇到的问题:

  1. 修改原生组合,遇到困难,开始没找到方向, 需要进一步学习
  2. 部分css属于不能用,如百分比,要换成rpx,对rpx要进一步学习
  3. 加载的图片宽高不能自适应,要找对应的解决方法

我们要开发的app,要用到部分的原生app功能,如上传图片,以及微信分享,为了确保项目不会半途而废,开发到了后期发现有些功能实现不了,决定要先行验证以下功能:

  1. uni-app加载url的页面,与nvue开发页面交互(数据交互和分享功能)
  2. 原生开发页面实现分享+图片上传的功能
  3. 开放能力,展业app能打开其他app或小程序
  4. 全屏及横屏模式
  5. android和iOS平台编译打包
继续阅读 »

初始次使用uni-app开发, 目前仅有几名前端开发人员,记录一下我们遇以的问题。

现阶段遇到的问题:

  1. 修改原生组合,遇到困难,开始没找到方向, 需要进一步学习
  2. 部分css属于不能用,如百分比,要换成rpx,对rpx要进一步学习
  3. 加载的图片宽高不能自适应,要找对应的解决方法

我们要开发的app,要用到部分的原生app功能,如上传图片,以及微信分享,为了确保项目不会半途而废,开发到了后期发现有些功能实现不了,决定要先行验证以下功能:

  1. uni-app加载url的页面,与nvue开发页面交互(数据交互和分享功能)
  2. 原生开发页面实现分享+图片上传的功能
  3. 开放能力,展业app能打开其他app或小程序
  4. 全屏及横屏模式
  5. android和iOS平台编译打包
收起阅读 »

求 nui 方式弹出输入框

默认话题

求 nui 方式弹出输入框
求 nui 方式弹出输入框
求 nui 方式弹出输入框
求 nui 方式弹出输入框
求 nui 方式弹出输入框

求 nui 方式弹出输入框
求 nui 方式弹出输入框
求 nui 方式弹出输入框
求 nui 方式弹出输入框
求 nui 方式弹出输入框

书本:来吧,证明你爱我的时候到了

项目体验地址:http://at.iunitv.cn/

效果预览:

花絮:

很多小伙伴嘴上说着学不动了,其实身体还是很诚实的。
ceeb653ejw1fc35nya2aij20b40b4my3.jpg

毕竟读书还是有很多好处的:比如让你的脑门散发智慧的光芒,再或者让你有理由说因为读书太忙了所以没有女朋友等等。

006dMd5bgy1fj6q9bw5ozj308c08cq38.jpg

我们也想要借助这个特殊的机会,普及一下Tensorflow相关的知识,我们会用TensorFlow.js做一个图书识别的模型,并在Vue Application中运行,赋予网页识别图书的能力。

本文讲述了AI相关的概念知识和如何运用SSD Mobile Net V1模型进行迁移学习的方法,从而帮助大家完成一个可以在网页上运行的图书识别模型。

【文末有活动哦】

正文:

什么是迁移学习

迁移学习和域适应指的是在一种环境中学到的知识被用在另一个领域中来提高它的泛化性能。——《深度学习》,第 526 页

再简单一点理解,以今天图书识别模型训练为例,我们利用前人训练好的具备图片识别能力的AI模型,保留AI模型中对图片特征提取的能力的基础上再训练,使AI模型具备识别图书的能力。

迁移学习能够大大提高模型训练的速度,并达到相对不错的正确率。

而我们今天所要迁移学习的对象就是SSD Mobile Net V1模型,初次接触神经网络的同学可以将其理解为一种具备图片识别的轻便小巧的AI模型,它能够在移动设备上高效地运行。对这个模型具体的神经网络设计结构感兴趣的同学可以自行搜索。

了解了基本的概念之后,我们便开始动手吧!我们可以基于SSD Mobile Net模型去设计一个属于自己的AI模型,并让它在Vue Application中运行。

Object Detection(目标识别)

本次项目是为了训练一个Object Detection的模型,即目标识别的模型,该模型能够识别并圈选出图片中相应的目标对象。

kites_detections_output.jpg

准备工作

同步开发环境

为了避免小伙伴因为环境问题遇到各种各样的坑,在工作开展之前,我们先跟大家同步一下运行的环境。大家如果要动手去做,也尽量跟我们的运行环境保持一致,这样可以有效避免踩坑,规避“从入门到放弃”的现象。

开发环境

  • 系统Mac OS系统
  • Python版本:3.7.3
  • TensorFlow版本:1.15.2
  • TensorFlowJS版本:1.7.2
  • 开发工具:Pycharm和Webstorm

下载项目

同步完开发环境后,终于要开始动工了。首先我们需要在Github上下载几个项目:

准备图片素材

我们可以通过搜索引擎收集有关图书的图片素材:

其次,我们可以在Github上克隆LabelImg项目,并根据Github的使用说明,按照不同的环境安装运行LabelImg项目,运行后的页面如下:

2.png

然后我们按照以下步骤,将图片格式转换为圈选区域后的XML文件:

  1. 打开图片存放的目录
  2. 选择圈选后的存放目录
  3. 圈选图片目标区域
  4. 设置圈选区域的标签
  5. 保存成XML格式

存放完后我们在存放的目录下会看到许多XML格式的文件,这个文件记录了图片的位置信息、圈选信息和标签信息等,用于后续的模型训练。

配置安装Object Detection的环境

从Github克隆迁移模型训练的项目迁移模型训练项目,注意要在r1.5分支运行,并用PyCharm打开项目。

image-20200420170524470.png

项目的目录环境为上图,首先我们需要下载TensorFlow1.15.2版本:

pip install tensorflow==1.15.2

其次安装依赖包:

sudo pip install pillow  
sudo pip install lxml  
sudo pip install jupyter  
sudo pip install matplotlib

然后通过终端切换到research目录,并执行几行配置命令,具体请参考Github的使用说明:

cd ./research  
protoc object_detection/protos/*.proto --python_out=.  
export PYTHONPATH=$PYTHONPATH:`pwd`:`pwd`/slim

最后我们运行model_builder_test.py文件,如果在终端中看到OK字样,表示配置成功。

python object_detection/builders/model_builder_test.py

将XML格式转换为TensorFlow需要的TFRecord格式

克隆并打开图片格式转换项目,然后我们对该项目加以小改造:

改造文件目录:

  1. 删除annotationsdatatraining目录中的内容
  2. 增加一个xmls目录,用以存放xml文件

image-20200420171606955.png
改造文件:
接着,我们再改造以下2个文件并新增一个文件,方便我们转换图片格式

  1. 改造xml_to_csv.py为:

    import os  
    import glob  
    import pandas as pd  
    import xml.etree.ElementTree as ET  
    import random  
    import time  
    import shutil  
    
    class Xml2Cvs:  
       def __init__(self):  
           self.xml_filepath = r'./xmls'  
           self.save_basepath = r"./annotations"  
           self.trainval_percent = 0.9  
           self.train_percent = 0.85  
    
       def xml_split_train(self):  
    
           total_xml = os.listdir(self.xml_filepath)  
           num = len(total_xml)  
           list = range(num)  
           tv = int(num * self.trainval_percent)  
           tr = int(tv * self.train_percent)  
           trainval = random.sample(list, tv)  
           train = random.sample(trainval, tr)  
           print("train and val size", tv)  
           print("train size", tr)  
           start = time.time()  
           test_num = 0  
           val_num = 0  
           train_num = 0  
           for i in list:  
               name = total_xml[i]  
               if i in trainval:  
                   if i in train:  
                       directory = "train"  
                       train_num += 1  
                       xml_path = os.path.join(os.getcwd(), 'annotations/{}'.format(directory))  
                       if (not os.path.exists(xml_path)):  
                           os.mkdir(xml_path)  
                       filePath = os.path.join(self.xml_filepath, name)  
                       newfile = os.path.join(self.save_basepath, os.path.join(directory, name))  
                       shutil.copyfile(filePath, newfile)  
                   else:  
                       directory = "validation"  
                       xml_path = os.path.join(os.getcwd(), 'annotations/{}'.format(directory))  
                       if (not os.path.exists(xml_path)):  
                           os.mkdir(xml_path)  
                       val_num += 1  
                       filePath = os.path.join(self.xml_filepath, name)  
                       newfile = os.path.join(self.save_basepath, os.path.join(directory, name))  
                       shutil.copyfile(filePath, newfile)  
               else:  
                   directory = "test"  
                   xml_path = os.path.join(os.getcwd(), 'annotations/{}'.format(directory))  
                   if (not os.path.exists(xml_path)):  
                       os.mkdir(xml_path)  
                   test_num += 1  
                   filePath = os.path.join(self.xml_filepath, name)  
                   newfile = os.path.join(self.save_basepath, os.path.join(directory, name))  
                   shutil.copyfile(filePath, newfile)  
    
           end = time.time()  
           seconds = end - start  
           print("train total : " + str(train_num))  
           print("validation total : " + str(val_num))  
           print("test total : " + str(test_num))  
           total_num = train_num + val_num + test_num  
           print("total number : " + str(total_num))  
           print("Time taken : {0} seconds".format(seconds))  
    
       def xml_to_csv(self, path):  
           xml_list = []  
           for xml_file in glob.glob(path + '/*.xml'):  
               tree = ET.parse(xml_file)  
               root = tree.getroot()  
               print(root.find('filename').text)  
               for object in root.findall('object'):  
                   value = (root.find('filename').text,  
                            int(root.find('size').find('width').text),  
                            int(root.find('size').find('height').text),  
                            object.find('name').text,  
                            int(object.find('bndbox').find('xmin').text),  
                            int(object.find('bndbox').find('ymin').text),  
                            int(object.find('bndbox').find('xmax').text),  
                            int(object.find('bndbox').find('ymax').text)  
                            )  
                   xml_list.append(value)  
           column_name = ['filename', 'width', 'height', 'class', 'xmin', 'ymin', 'xmax', 'ymax']  
           xml_df = pd.DataFrame(xml_list, columns=column_name)  
           return xml_df  
    
       def main(self):  
           for directory in ['train', 'test', 'validation']:  
               xml_path = os.path.join(os.getcwd(), 'annotations/{}'.format(directory))  
               xml_df = self.xml_to_csv(xml_path)  
               xml_df.to_csv('data/mask_{}_labels.csv'.format(directory), index=None)  
               print('Successfully converted xml to csv.')  
    
    if __name__ == '__main__':  
       Xml2Cvs().xml_split_train()  
       Xml2Cvs().main()  
  2. 改造generate_tfrecord.py文件,将csv格式转换为TensorFlow需要的record格式:

image-20200420172149654.png

将该区域的row_label改成我们LabelImg中的标签名,因为我们只有一个标签,所以直接修改成book即可。

  1. 新增一个generate_tfrecord.sh脚本,方便执行generate_tfrecord.py文件

    #!/usr/bin/env bash  
    python generate_tfrecord.py --csv_input=data/mask_train_labels.csv  --output_path=data/mask_train.record --image_dir=images  
    python generate_tfrecord.py --csv_input=data/mask_test_labels.csv  --output_path=data/mask_test.record --image_dir=images  
    python generate_tfrecord.py --csv_input=data/mask_validation_labels.csv  --output_path=data/mask_validation.record --image_dir=images  
    

配置Object Decation的环境

export PYTHONPATH=$PYTHONPATH:你的models/research/slim所在的全目录路径

最后我们将图片文件复制到images目录,将xml文件复制到xmls目录下,再执行xml_to_csv.py文件,我们会看到data目录下产生了几个csv格式结尾的文件;这时,我们在终端执行generate_tfrecord.sh文件,TensorFlow所需要的数据格式就大功告成啦。

image-20200420172821520.png

迁移训练模型:

在这个环节我们要做以下几件事:

  • 将刚刚生成好的record文件放到对应目录下
  • 下载SSD Mobile Net V1模型文件
  • 配置book.pbtxt文件和book.config文件
放置record文件和SSD Mobile Net V1模型

为了方便我直接将models/research/object_detection/test_data下的目录清空,放置迁移训练的文件。

首先我们下载SSD Mobile Net V1模型文件

image-20200420174029721.png

我们下载第一个ssd_mobilenet_v1_coco模型即可,下载完毕后,我们解压下载的模型压缩包文件,并将模型相关的文件放在test_datamodel目录下。并将我们刚刚生成的record文件放置在test_data目录下。
image-20200420174238899.png

完成pbtxt和config配置文件

我们在test_data目录下,新建一个book.pbtxt文件,并完成配置内容:

item {  
  id: 1  
  name: 'book'  
}

由于我们只有一个标签,我们就直接配置一个id值为1,name为book的item对象。

由于我们使用SSD Mobile Net V1模型进行迁移学习,因此我们到sample\configs目录下复制一份ssd_mobilenet_v1_coco.config文件并重命名为book.config文件。

image-20200420174718068.png

接着我们修改book.config中的配置文件:

将num_classes修改为当前的标签数量:

image-20200420174843137.png

由于我们只有一个book标签,因此修改成1即可。

修改所有PATH_TO_BE_CONFIGURED的路径:
<center>
<img src="https://user-gold-cdn.xitu.io/2020/4/23/171a5d916b7213e0?w=1410&h=982&f=png&s=147351" style="zoom:50%;" />
</center>

我们将此处的模型文件地址设置成testdata/model/model.ckpt的全路径地址。

image-20200420175241636.png

我们将train_input_readerinput_path设置成mask_train.record的全路径地址;将label_map_path设置成book.pbtxt的全路径地址;将eval_input_readerinput_path设置成mask_test.record的全路径地址。

到目前为止我们所有配置都已经完成啦。接下来就是激动人心的训练模型的时刻。

运行train.py文件训练模型

我们在终端中运行train.py文件,开始迁移学习、训练模型。

python3 train.py --logtostderr --train_dir=./test_data/training/ --pipeline_config_path=./test_data/book.config

其中train_dir为我们训练后的模型存放的目录,pipeline_config_path为我们book.config文件所在的相对路径。

运行命令后,我们可以看到模型在进行一步一步的训练:

image-20200420175814070.png

并在/test_data/training目录下存放训练后的模型文件:

<center>
<img src="https://user-gold-cdn.xitu.io/2020/4/23/171a5db65704e6e0?w=610&h=842&f=png&s=140510" style="zoom:50%;" />
</center>

将ckpt文件转换为pb文件

我们通过export_inference_graph.py文件,将训练好的模型转换为pb格式的文件,这个文件格式在后面我们要用来转换为TensorFlow.js能够识别的文件格式。终于我们见到TensorFlow.js的影子啦。

<center>
<img src="https://user-gold-cdn.xitu.io/2020/4/23/171a5dbee720cc2c?w=440&h=439&f=jpeg&s=20529" style="zoom:50%;" />
</center>

我们执行命令,运行export_inference_graph.py文件:

python export_inference_graph.py --input_type image_tensor --pipeline_config_path ./test_data/book.config --trained_checkpoint_prefix ./test_data/training/model.ckpt-1989 --output_directory ./test_data/training/book_model_test

其中pipeline_config_pathbook.config的相对文件路径,trained_checkpoint_prefix为模型文件的路径,例如我们选择训练了1989步的模型文件,output_directory为我们输出pb文件的目标目录。

运行完后,我们可以看到一个生成了book_model_test目录:

image-20200420181903653.png

将pb文件转换为TensorFlowJs模型

首先我们需要依赖TensorFlowjs的依赖包

pip install tensorflowjs

然后通过命令行转换刚刚生成的pb文件

tensorflowjs_converter --input_format=tf_saved_model --output_node_names='detection_boxes,detection_classes,detection_features,detection_multiclass_scores,detection_scores,num_detections,raw_detection_boxes,raw_detection_scores' --saved_model_tags=serve --output_format=tfjs_graph_model ./saved_model ./web_model

其中我们设置最后两个参数,即saved_model的目录与TensorFlow.js识别模型的输出目录。

运行结束后,我们可以看到一个新生成的web_model目录,其中包括了我们迁移学习训练后的模型。

到这里,模型训练的阶段终于结束了。

9150e4e5gw1fa99psluudj208c08cmx5.jpg

在Vue中运行模型

准备工作

新建Vue项目,在Vue项目的public目录下放入我们训练好的模型,即web_model目录。

image-20200421132233993.png

接着我们借助Tensorflow.js的依赖包,在package.jsondependencies中加入:

"@tensorflow/tfjs": "^1.7.2",  
"@tensorflow/tfjs-core": "^1.7.2",  
"@tensorflow/tfjs-node": "^1.7.2",

然后通过npm命令安装依赖包。

加载模型

在我们的JS代码部分引入TensorFlow的依赖包:

import * as tf from '@tensorflow/tfjs';  
import {loadGraphModel} from '@tensorflow/tfjs-converter';

接着第一步,我们先加载模型文件中的model.json文件:

const MODEL_URL = process.env.BASE_URL+"web_model/model.json";  
this.model = await loadGraphModel(MODEL_URL);

通过loadGraphModel方法,我们加载好训练的模型,再将模型对象打印出来:

image-20200421132921380.png

随后,我们可以看到模型会输出一个长度为4的数组:

  • detection_scores:表示识别对象模型的置信度,置信度越高,则代表模型认为对应区域识别为书本的可能性越高
  • detection_classes:表示模型识别的区域对应的标签,例如在本案例中,识别出来的是book
  • num_detections:表示模型识别出目标对象的个数
  • detection_boxes:表示模型识别出来目标对象的区域,为一个长度为4的数组,分别是:[x_pos,y_pos,x_width,y_height] 。第一个位代表圈选区域左上角的x坐标,第二位代表圈选左上角的y坐标,第三位代表圈选区域的宽度,第四位代表圈选区域的长度。

模型识别

知道了输出值,我们就可以开始将图片输入到模型中,从而得到模型预测的结果:

const img = document.getElementById('img');  
let modelres =await this.model.executeAsync(tf.browser.fromPixels(img).expandDims(0));

我们通过model.executeAsync方法,将图片输入到模型中,从而得到模型的输出值。

结果是我们前文提到的一个长度为4的数组。接着我们通过自定义方法,将得到的结果进行整理,从而输出一个想要的结果格式:

buildDetectedObjects:function(scores, threshold, imageWidth, imageHeight, boxes, classes, classesDir) {  
          const detectionObjects = [];  
          scores.forEach((score, i) => {  
              if (score > threshold) {  
                  const bbox = [];  
                  const minY = boxes[i * 4] * imageHeight;  
                  const minX = boxes[i * 4 + 1] * imageWidth;  
                  const maxY = boxes[i * 4 + 2] * imageHeight;  
                  const maxX = boxes[i * 4 + 3] * imageWidth;  
                  bbox[0] = minX;  
                  bbox[1] = minY;  
                  bbox[2] = maxX - minX;  
                  bbox[3] = maxY - minY;  
                  detectionObjects.push({  
                      class: classes[i],  
                      label: classesDir[classes[i]].name,  
                      score: score.toFixed(4),  
                      bbox: bbox  
                  });  
              }  
          });  

          return detectionObjects  
}

我们通过调用buildDetectedObjects来整理和返回最后的结果。

  • scores:输入模型的detection_scores数组
  • threshold:阈值,即结果score>threshold我们才会将对应的结果放入结果对象detectionObjects
  • imageWidth:图片的宽度
  • imageHeight:图片的长度
  • boxes:输入模型的detection_boxes数组
  • classes:输入模型的detection_classes数组
  • classesDir:即模型标签对象

调用buildDetectedObjects方法示例:

let classesDir = {  
    1: {  
        name: 'book',  
        id: 1,  
        }  
    };  
let res=this.buildDetectedObjects(modelres[0].dataSync(),0.20,img.width,img.height,modelres[3].dataSync(),modelres[1].dataSync(),classesDir);

我们通过modelres[0].dataSync(),来获取对应结果的数组对象,再输入到方法中,从而最终获得res结果对象。

image-20200421140000851.png

最后我们通过Canvas的API,将图片根据bbox返回的数组对象,画出对应的区域即可。由于篇幅原因,就不赘述了,最终效果如下:
image-20200421140314124.png

最后

本案例的模型存在一定的不足,由于训练时间较短,图书的封面类型众多,存在人像、风景图等等的样式,导致模型在识别过程中可能会将少部分的人脸、风景照等图片错误地识别成图书封面。各位小伙伴在训练自己模型的过程中可以考虑优化此问题。

当然,本案例的模型在识别非图书的场景会存在识别不准确的情况,一方面这是因为本案例从网络收集的图书样本具有一定局限性,而且图书的封面类型千差万别,存在人像、风景图等等的样式;另一方面因为本文在仅为起到抛砖引玉的作用,为各位前端小伙伴普及TensorFlow.js相关的知识,并提供训练自己的模型的解决方案,所以在收集样本和模型训练时间较短。感兴趣的小伙伴可以自己琢磨琢磨如何优化样本和在避免过拟合的情况下提高训练时长,从而提高模型对被识别物体的准确性。

我们写下本文仅为起到抛砖引玉的作用,为各位前端小伙伴普及TensorFlow.js相关知识并提供一种AI的解决方案。
我们希望和广大程序员一起学习新知、共同进步,愿每位热爱学习的开发者都能畅游书海,遇见更好的自己!

继续阅读 »

项目体验地址:http://at.iunitv.cn/

效果预览:

花絮:

很多小伙伴嘴上说着学不动了,其实身体还是很诚实的。
ceeb653ejw1fc35nya2aij20b40b4my3.jpg

毕竟读书还是有很多好处的:比如让你的脑门散发智慧的光芒,再或者让你有理由说因为读书太忙了所以没有女朋友等等。

006dMd5bgy1fj6q9bw5ozj308c08cq38.jpg

我们也想要借助这个特殊的机会,普及一下Tensorflow相关的知识,我们会用TensorFlow.js做一个图书识别的模型,并在Vue Application中运行,赋予网页识别图书的能力。

本文讲述了AI相关的概念知识和如何运用SSD Mobile Net V1模型进行迁移学习的方法,从而帮助大家完成一个可以在网页上运行的图书识别模型。

【文末有活动哦】

正文:

什么是迁移学习

迁移学习和域适应指的是在一种环境中学到的知识被用在另一个领域中来提高它的泛化性能。——《深度学习》,第 526 页

再简单一点理解,以今天图书识别模型训练为例,我们利用前人训练好的具备图片识别能力的AI模型,保留AI模型中对图片特征提取的能力的基础上再训练,使AI模型具备识别图书的能力。

迁移学习能够大大提高模型训练的速度,并达到相对不错的正确率。

而我们今天所要迁移学习的对象就是SSD Mobile Net V1模型,初次接触神经网络的同学可以将其理解为一种具备图片识别的轻便小巧的AI模型,它能够在移动设备上高效地运行。对这个模型具体的神经网络设计结构感兴趣的同学可以自行搜索。

了解了基本的概念之后,我们便开始动手吧!我们可以基于SSD Mobile Net模型去设计一个属于自己的AI模型,并让它在Vue Application中运行。

Object Detection(目标识别)

本次项目是为了训练一个Object Detection的模型,即目标识别的模型,该模型能够识别并圈选出图片中相应的目标对象。

kites_detections_output.jpg

准备工作

同步开发环境

为了避免小伙伴因为环境问题遇到各种各样的坑,在工作开展之前,我们先跟大家同步一下运行的环境。大家如果要动手去做,也尽量跟我们的运行环境保持一致,这样可以有效避免踩坑,规避“从入门到放弃”的现象。

开发环境

  • 系统Mac OS系统
  • Python版本:3.7.3
  • TensorFlow版本:1.15.2
  • TensorFlowJS版本:1.7.2
  • 开发工具:Pycharm和Webstorm

下载项目

同步完开发环境后,终于要开始动工了。首先我们需要在Github上下载几个项目:

准备图片素材

我们可以通过搜索引擎收集有关图书的图片素材:

其次,我们可以在Github上克隆LabelImg项目,并根据Github的使用说明,按照不同的环境安装运行LabelImg项目,运行后的页面如下:

2.png

然后我们按照以下步骤,将图片格式转换为圈选区域后的XML文件:

  1. 打开图片存放的目录
  2. 选择圈选后的存放目录
  3. 圈选图片目标区域
  4. 设置圈选区域的标签
  5. 保存成XML格式

存放完后我们在存放的目录下会看到许多XML格式的文件,这个文件记录了图片的位置信息、圈选信息和标签信息等,用于后续的模型训练。

配置安装Object Detection的环境

从Github克隆迁移模型训练的项目迁移模型训练项目,注意要在r1.5分支运行,并用PyCharm打开项目。

image-20200420170524470.png

项目的目录环境为上图,首先我们需要下载TensorFlow1.15.2版本:

pip install tensorflow==1.15.2

其次安装依赖包:

sudo pip install pillow  
sudo pip install lxml  
sudo pip install jupyter  
sudo pip install matplotlib

然后通过终端切换到research目录,并执行几行配置命令,具体请参考Github的使用说明:

cd ./research  
protoc object_detection/protos/*.proto --python_out=.  
export PYTHONPATH=$PYTHONPATH:`pwd`:`pwd`/slim

最后我们运行model_builder_test.py文件,如果在终端中看到OK字样,表示配置成功。

python object_detection/builders/model_builder_test.py

将XML格式转换为TensorFlow需要的TFRecord格式

克隆并打开图片格式转换项目,然后我们对该项目加以小改造:

改造文件目录:

  1. 删除annotationsdatatraining目录中的内容
  2. 增加一个xmls目录,用以存放xml文件

image-20200420171606955.png
改造文件:
接着,我们再改造以下2个文件并新增一个文件,方便我们转换图片格式

  1. 改造xml_to_csv.py为:

    import os  
    import glob  
    import pandas as pd  
    import xml.etree.ElementTree as ET  
    import random  
    import time  
    import shutil  
    
    class Xml2Cvs:  
       def __init__(self):  
           self.xml_filepath = r'./xmls'  
           self.save_basepath = r"./annotations"  
           self.trainval_percent = 0.9  
           self.train_percent = 0.85  
    
       def xml_split_train(self):  
    
           total_xml = os.listdir(self.xml_filepath)  
           num = len(total_xml)  
           list = range(num)  
           tv = int(num * self.trainval_percent)  
           tr = int(tv * self.train_percent)  
           trainval = random.sample(list, tv)  
           train = random.sample(trainval, tr)  
           print("train and val size", tv)  
           print("train size", tr)  
           start = time.time()  
           test_num = 0  
           val_num = 0  
           train_num = 0  
           for i in list:  
               name = total_xml[i]  
               if i in trainval:  
                   if i in train:  
                       directory = "train"  
                       train_num += 1  
                       xml_path = os.path.join(os.getcwd(), 'annotations/{}'.format(directory))  
                       if (not os.path.exists(xml_path)):  
                           os.mkdir(xml_path)  
                       filePath = os.path.join(self.xml_filepath, name)  
                       newfile = os.path.join(self.save_basepath, os.path.join(directory, name))  
                       shutil.copyfile(filePath, newfile)  
                   else:  
                       directory = "validation"  
                       xml_path = os.path.join(os.getcwd(), 'annotations/{}'.format(directory))  
                       if (not os.path.exists(xml_path)):  
                           os.mkdir(xml_path)  
                       val_num += 1  
                       filePath = os.path.join(self.xml_filepath, name)  
                       newfile = os.path.join(self.save_basepath, os.path.join(directory, name))  
                       shutil.copyfile(filePath, newfile)  
               else:  
                   directory = "test"  
                   xml_path = os.path.join(os.getcwd(), 'annotations/{}'.format(directory))  
                   if (not os.path.exists(xml_path)):  
                       os.mkdir(xml_path)  
                   test_num += 1  
                   filePath = os.path.join(self.xml_filepath, name)  
                   newfile = os.path.join(self.save_basepath, os.path.join(directory, name))  
                   shutil.copyfile(filePath, newfile)  
    
           end = time.time()  
           seconds = end - start  
           print("train total : " + str(train_num))  
           print("validation total : " + str(val_num))  
           print("test total : " + str(test_num))  
           total_num = train_num + val_num + test_num  
           print("total number : " + str(total_num))  
           print("Time taken : {0} seconds".format(seconds))  
    
       def xml_to_csv(self, path):  
           xml_list = []  
           for xml_file in glob.glob(path + '/*.xml'):  
               tree = ET.parse(xml_file)  
               root = tree.getroot()  
               print(root.find('filename').text)  
               for object in root.findall('object'):  
                   value = (root.find('filename').text,  
                            int(root.find('size').find('width').text),  
                            int(root.find('size').find('height').text),  
                            object.find('name').text,  
                            int(object.find('bndbox').find('xmin').text),  
                            int(object.find('bndbox').find('ymin').text),  
                            int(object.find('bndbox').find('xmax').text),  
                            int(object.find('bndbox').find('ymax').text)  
                            )  
                   xml_list.append(value)  
           column_name = ['filename', 'width', 'height', 'class', 'xmin', 'ymin', 'xmax', 'ymax']  
           xml_df = pd.DataFrame(xml_list, columns=column_name)  
           return xml_df  
    
       def main(self):  
           for directory in ['train', 'test', 'validation']:  
               xml_path = os.path.join(os.getcwd(), 'annotations/{}'.format(directory))  
               xml_df = self.xml_to_csv(xml_path)  
               xml_df.to_csv('data/mask_{}_labels.csv'.format(directory), index=None)  
               print('Successfully converted xml to csv.')  
    
    if __name__ == '__main__':  
       Xml2Cvs().xml_split_train()  
       Xml2Cvs().main()  
  2. 改造generate_tfrecord.py文件,将csv格式转换为TensorFlow需要的record格式:

image-20200420172149654.png

将该区域的row_label改成我们LabelImg中的标签名,因为我们只有一个标签,所以直接修改成book即可。

  1. 新增一个generate_tfrecord.sh脚本,方便执行generate_tfrecord.py文件

    #!/usr/bin/env bash  
    python generate_tfrecord.py --csv_input=data/mask_train_labels.csv  --output_path=data/mask_train.record --image_dir=images  
    python generate_tfrecord.py --csv_input=data/mask_test_labels.csv  --output_path=data/mask_test.record --image_dir=images  
    python generate_tfrecord.py --csv_input=data/mask_validation_labels.csv  --output_path=data/mask_validation.record --image_dir=images  
    

配置Object Decation的环境

export PYTHONPATH=$PYTHONPATH:你的models/research/slim所在的全目录路径

最后我们将图片文件复制到images目录,将xml文件复制到xmls目录下,再执行xml_to_csv.py文件,我们会看到data目录下产生了几个csv格式结尾的文件;这时,我们在终端执行generate_tfrecord.sh文件,TensorFlow所需要的数据格式就大功告成啦。

image-20200420172821520.png

迁移训练模型:

在这个环节我们要做以下几件事:

  • 将刚刚生成好的record文件放到对应目录下
  • 下载SSD Mobile Net V1模型文件
  • 配置book.pbtxt文件和book.config文件
放置record文件和SSD Mobile Net V1模型

为了方便我直接将models/research/object_detection/test_data下的目录清空,放置迁移训练的文件。

首先我们下载SSD Mobile Net V1模型文件

image-20200420174029721.png

我们下载第一个ssd_mobilenet_v1_coco模型即可,下载完毕后,我们解压下载的模型压缩包文件,并将模型相关的文件放在test_datamodel目录下。并将我们刚刚生成的record文件放置在test_data目录下。
image-20200420174238899.png

完成pbtxt和config配置文件

我们在test_data目录下,新建一个book.pbtxt文件,并完成配置内容:

item {  
  id: 1  
  name: 'book'  
}

由于我们只有一个标签,我们就直接配置一个id值为1,name为book的item对象。

由于我们使用SSD Mobile Net V1模型进行迁移学习,因此我们到sample\configs目录下复制一份ssd_mobilenet_v1_coco.config文件并重命名为book.config文件。

image-20200420174718068.png

接着我们修改book.config中的配置文件:

将num_classes修改为当前的标签数量:

image-20200420174843137.png

由于我们只有一个book标签,因此修改成1即可。

修改所有PATH_TO_BE_CONFIGURED的路径:
<center>
<img src="https://user-gold-cdn.xitu.io/2020/4/23/171a5d916b7213e0?w=1410&h=982&f=png&s=147351" style="zoom:50%;" />
</center>

我们将此处的模型文件地址设置成testdata/model/model.ckpt的全路径地址。

image-20200420175241636.png

我们将train_input_readerinput_path设置成mask_train.record的全路径地址;将label_map_path设置成book.pbtxt的全路径地址;将eval_input_readerinput_path设置成mask_test.record的全路径地址。

到目前为止我们所有配置都已经完成啦。接下来就是激动人心的训练模型的时刻。

运行train.py文件训练模型

我们在终端中运行train.py文件,开始迁移学习、训练模型。

python3 train.py --logtostderr --train_dir=./test_data/training/ --pipeline_config_path=./test_data/book.config

其中train_dir为我们训练后的模型存放的目录,pipeline_config_path为我们book.config文件所在的相对路径。

运行命令后,我们可以看到模型在进行一步一步的训练:

image-20200420175814070.png

并在/test_data/training目录下存放训练后的模型文件:

<center>
<img src="https://user-gold-cdn.xitu.io/2020/4/23/171a5db65704e6e0?w=610&h=842&f=png&s=140510" style="zoom:50%;" />
</center>

将ckpt文件转换为pb文件

我们通过export_inference_graph.py文件,将训练好的模型转换为pb格式的文件,这个文件格式在后面我们要用来转换为TensorFlow.js能够识别的文件格式。终于我们见到TensorFlow.js的影子啦。

<center>
<img src="https://user-gold-cdn.xitu.io/2020/4/23/171a5dbee720cc2c?w=440&h=439&f=jpeg&s=20529" style="zoom:50%;" />
</center>

我们执行命令,运行export_inference_graph.py文件:

python export_inference_graph.py --input_type image_tensor --pipeline_config_path ./test_data/book.config --trained_checkpoint_prefix ./test_data/training/model.ckpt-1989 --output_directory ./test_data/training/book_model_test

其中pipeline_config_pathbook.config的相对文件路径,trained_checkpoint_prefix为模型文件的路径,例如我们选择训练了1989步的模型文件,output_directory为我们输出pb文件的目标目录。

运行完后,我们可以看到一个生成了book_model_test目录:

image-20200420181903653.png

将pb文件转换为TensorFlowJs模型

首先我们需要依赖TensorFlowjs的依赖包

pip install tensorflowjs

然后通过命令行转换刚刚生成的pb文件

tensorflowjs_converter --input_format=tf_saved_model --output_node_names='detection_boxes,detection_classes,detection_features,detection_multiclass_scores,detection_scores,num_detections,raw_detection_boxes,raw_detection_scores' --saved_model_tags=serve --output_format=tfjs_graph_model ./saved_model ./web_model

其中我们设置最后两个参数,即saved_model的目录与TensorFlow.js识别模型的输出目录。

运行结束后,我们可以看到一个新生成的web_model目录,其中包括了我们迁移学习训练后的模型。

到这里,模型训练的阶段终于结束了。

9150e4e5gw1fa99psluudj208c08cmx5.jpg

在Vue中运行模型

准备工作

新建Vue项目,在Vue项目的public目录下放入我们训练好的模型,即web_model目录。

image-20200421132233993.png

接着我们借助Tensorflow.js的依赖包,在package.jsondependencies中加入:

"@tensorflow/tfjs": "^1.7.2",  
"@tensorflow/tfjs-core": "^1.7.2",  
"@tensorflow/tfjs-node": "^1.7.2",

然后通过npm命令安装依赖包。

加载模型

在我们的JS代码部分引入TensorFlow的依赖包:

import * as tf from '@tensorflow/tfjs';  
import {loadGraphModel} from '@tensorflow/tfjs-converter';

接着第一步,我们先加载模型文件中的model.json文件:

const MODEL_URL = process.env.BASE_URL+"web_model/model.json";  
this.model = await loadGraphModel(MODEL_URL);

通过loadGraphModel方法,我们加载好训练的模型,再将模型对象打印出来:

image-20200421132921380.png

随后,我们可以看到模型会输出一个长度为4的数组:

  • detection_scores:表示识别对象模型的置信度,置信度越高,则代表模型认为对应区域识别为书本的可能性越高
  • detection_classes:表示模型识别的区域对应的标签,例如在本案例中,识别出来的是book
  • num_detections:表示模型识别出目标对象的个数
  • detection_boxes:表示模型识别出来目标对象的区域,为一个长度为4的数组,分别是:[x_pos,y_pos,x_width,y_height] 。第一个位代表圈选区域左上角的x坐标,第二位代表圈选左上角的y坐标,第三位代表圈选区域的宽度,第四位代表圈选区域的长度。

模型识别

知道了输出值,我们就可以开始将图片输入到模型中,从而得到模型预测的结果:

const img = document.getElementById('img');  
let modelres =await this.model.executeAsync(tf.browser.fromPixels(img).expandDims(0));

我们通过model.executeAsync方法,将图片输入到模型中,从而得到模型的输出值。

结果是我们前文提到的一个长度为4的数组。接着我们通过自定义方法,将得到的结果进行整理,从而输出一个想要的结果格式:

buildDetectedObjects:function(scores, threshold, imageWidth, imageHeight, boxes, classes, classesDir) {  
          const detectionObjects = [];  
          scores.forEach((score, i) => {  
              if (score > threshold) {  
                  const bbox = [];  
                  const minY = boxes[i * 4] * imageHeight;  
                  const minX = boxes[i * 4 + 1] * imageWidth;  
                  const maxY = boxes[i * 4 + 2] * imageHeight;  
                  const maxX = boxes[i * 4 + 3] * imageWidth;  
                  bbox[0] = minX;  
                  bbox[1] = minY;  
                  bbox[2] = maxX - minX;  
                  bbox[3] = maxY - minY;  
                  detectionObjects.push({  
                      class: classes[i],  
                      label: classesDir[classes[i]].name,  
                      score: score.toFixed(4),  
                      bbox: bbox  
                  });  
              }  
          });  

          return detectionObjects  
}

我们通过调用buildDetectedObjects来整理和返回最后的结果。

  • scores:输入模型的detection_scores数组
  • threshold:阈值,即结果score>threshold我们才会将对应的结果放入结果对象detectionObjects
  • imageWidth:图片的宽度
  • imageHeight:图片的长度
  • boxes:输入模型的detection_boxes数组
  • classes:输入模型的detection_classes数组
  • classesDir:即模型标签对象

调用buildDetectedObjects方法示例:

let classesDir = {  
    1: {  
        name: 'book',  
        id: 1,  
        }  
    };  
let res=this.buildDetectedObjects(modelres[0].dataSync(),0.20,img.width,img.height,modelres[3].dataSync(),modelres[1].dataSync(),classesDir);

我们通过modelres[0].dataSync(),来获取对应结果的数组对象,再输入到方法中,从而最终获得res结果对象。

image-20200421140000851.png

最后我们通过Canvas的API,将图片根据bbox返回的数组对象,画出对应的区域即可。由于篇幅原因,就不赘述了,最终效果如下:
image-20200421140314124.png

最后

本案例的模型存在一定的不足,由于训练时间较短,图书的封面类型众多,存在人像、风景图等等的样式,导致模型在识别过程中可能会将少部分的人脸、风景照等图片错误地识别成图书封面。各位小伙伴在训练自己模型的过程中可以考虑优化此问题。

当然,本案例的模型在识别非图书的场景会存在识别不准确的情况,一方面这是因为本案例从网络收集的图书样本具有一定局限性,而且图书的封面类型千差万别,存在人像、风景图等等的样式;另一方面因为本文在仅为起到抛砖引玉的作用,为各位前端小伙伴普及TensorFlow.js相关的知识,并提供训练自己的模型的解决方案,所以在收集样本和模型训练时间较短。感兴趣的小伙伴可以自己琢磨琢磨如何优化样本和在避免过拟合的情况下提高训练时长,从而提高模型对被识别物体的准确性。

我们写下本文仅为起到抛砖引玉的作用,为各位前端小伙伴普及TensorFlow.js相关知识并提供一种AI的解决方案。
我们希望和广大程序员一起学习新知、共同进步,愿每位热爱学习的开发者都能畅游书海,遇见更好的自己!

收起阅读 »

宽带优化之Flume Avro在个推的实践

引言
带宽不够用,靠这个方法我让数据压缩率达到了80%以上

如何在有限的资源下解决性能瓶颈问题是运维永恒的痛点。这期文章,Mr.Tech 邀请了在性能优化方面有着丰富经验的个推高级运维工程师白子画,为大家分享宽带优化之Flume Avro在个推的实践。

在异地日志数据互传的场景下,我们从传输数据着手,借助Avro的特性使数据压缩率达80%以上,解决了个推在实际生产过程中遇到的带宽不够用的问题。本文我们将向大家介绍Flume Avro在数据传输过程中所承担的不同角色,以及如何保证数据的完整性和传输的高效性,并分享在实际业务中取得的优化效果。

背景
个推作为专业的数据智能服务商,已经成功服务了数十万APP,每日的消息下发量达百亿级别,由此产生了海量日志数据。为了应对业务上的各种需求,我们需要采集并集中化日志进行计算,为此个推选用了高可用的、高可靠的、分布式的Flume系统以对海量日志进行采集、聚合和传输。此外,个推也不断对Flume进行迭代升级,以实现自己对日志的特定需求。

原有的异地机房日志汇聚方式,整个流程相对来说比较简单,A机房业务产生的日志通过多种方式写入该机房Kafka集群,然后B机房的Flume通过网络专线实时消费A机房Kafka的日志数据后写入本机房的Kafka集群,所有机房的数据就是通过相同方式在B机房Kakfa集群中集中化管理。如图一所示:

图一:原有异地日志传输模式

但是随着业务量的不断增加,日志数据在逐渐增多的过程中对带宽要求变高,带宽的瓶颈问题日益凸显。按照1G的专线带宽成本2~3w/月来计算,一个异地机房一年仅专线带宽扩容成本就高达30w以上。对此,如何找到一种成本更加低廉且符合当前业务预期的传输方案呢?Avro有快速压缩的二进制数据形式,并能有效节约数据存储空间和网络传输带宽,从而成为优选方案。

优化思路
Avro简介

Avro是一个数据序列化系统。它是Hadoop的一个子项目,也是Apache的一个独立的项目,其主要特点如下:
● 丰富的数据结构;
● 可压缩、快速的二进制数据类型;
● 可持久化存储的文件类型;
● 远程过程调用(RPC);
● 提供的机制使动态语言可以方便地处理数据。
具体可参考官方网站:http://avro.apache.org/

Flume Avro方案

Flume的RPC Source是Avro Source,它被设计为高扩展的RPC服务端,能从其他Flume Agent 的Avro Sink或者Flume SDK客户端,接收数据到Flume Agent中,具体流程如图二所示:

图二:Avro Source流程

针对该模式,我们的日志传输方案计划变更为A机房部署Avro Sink用以消费该机房Kafka集群的日志数据,压缩后发送到B机房的Avro Source,然后解压写入B机房的Kafka集群,具体的传输模式如图三所示:

图三:Flume Avro传输模式

可能存在的问题

我们预估可能存在的问题主要有以下三点:
● 当专线故障的时候,数据是否能保证完整性;
● 该模式下CPU和内存等硬件的消耗评估;
● 传输性能问题。

验证情况
针对以上的几个问题,我们做了几项对比实验。
环境准备情况说明:

  1. 两台服务器192.168.10.81和192.168.10.82,以及每台服务器上对应一个Kakfa集群,模拟A机房和B机房;
  2. 两个Kafka集群中对应topicA(源端)和topicB(目标端)。在topicA中写入合计大小11G的日志数据用来模拟原始端日志数据。
  3. 192.168.10.82上部署一个Flume,模拟原有传输方式。
  4. 192.168.10.81服务器部署Avro Sink,192.168.10.82部署Avro Source,模拟Flume Avro传输模式。

原有Flume模式验证(非Avro)

监控Kafka消费情况:

81流量统计:

82流量统计:

消费全部消息耗时:20min
消费总日志条数统计:129,748,260
总流量:13.5G

Avro模式验证

配置说明:

Avro Sink配置:

kafkasink 是kafkatokafka的sinks的名字,可配多个,空格分开

kafkatokafka.sources = kafka_dmc_bullet
kafkatokafka.channels = channel_dmc_bullet
kafkatokafka.sinks = kafkasink_dmc_bullet
kafkatokafka.sources.kafka_dmc_bullet.type = org.apache.flume.source.kafka.KafkaSource
kafkatokafka.sources.kafka_dmc_bullet.channels = channel_dmc_bullet
kafkatokafka.sources.kafka_dmc_bullet.zookeeperConnect = 192.168.10.81:2181
kafkatokafka.sources.kafka_dmc_bullet.topic = topicA
kafkatokafka.sources.kafka_dmc_bullet.kafka.zookeeper.connection.timeout.ms = 150000
kafkatokafka.sources.kafka_dmc_bullet.kafka.consumer.timeout.ms = 10000
kafkatokafka.sources.kafka_dmc_bullet.kafka.group.id = flumeavro
kafkatokafka.sources.kafka_dmc_bullet.batchSize = 5000

source kafkasink_dmc_bullet的配置,可配置多个sink提高压缩传输效率

kafkatokafka.sinks.kafkasink_dmc_bullet.type = org.apache.flume.sink.AvroSink
kafkatokafka.sinks.kafkasink_dmc_bullet.hostname = 192.168.10.82
kafkatokafka.sinks.kafkasink_dmc_bullet.port = 55555 //与source的rpc端口一一对应
kafkatokafka.sinks.kafkasink_dmc_bullet.compression-type = deflate //压缩模式
kafkatokafka.sinks.kafkasink_dmc_bullet.compression-level = 6 //压缩率1~9
kafkatokafka.sinks.kafkasink_dmc_bullet.channel = channel_dmc_bullet
kafkatokafka.sinks.kafkasink_dmc_bullet.channel = channel_dmc_bullet
kafkatokafka.sinks.kafkasink_dmc_bullet.requiredAcks = 1
kafkatokafka.sinks.kafkasink_dmc_bullet.batchSize = 5000

source kafkasink_dmc_bullet配的channel,只配一个

kafkatokafka.channels.channel_dmc_bullet.type = memory
kafkatokafka.channels.channel_dmc_bullet.capacity = 100000

kafkatokafka.channels.channel_dmc_bullet.byteCapacity = 10000

kafkatokafka.channels.channel_dmc_bullet.byteCapacityBufferPercentage = 10

kafkatokafka.channels.channel_dmc_bullet.transactionCapacity = 5000
kafkatokafka.channels.channel_dmc_bullet.keep-alive = 60

Avro Source配置:

kafkasink 是kafkatokafka的sinks的名字,可配多个,空格分开

kafkatokafka.sources = kafka_dmc_bullet
kafkatokafka.channels = channel_dmc_bullet
kafkatokafka.sinks = kafkasink_dmc_bullet
kafkatokafka.sources.kafka_dmc_bullet.type = avro
kafkatokafka.sources.kafka_dmc_bullet.channels = channel_dmc_bullet
kafkatokafka.sources.kafka_dmc_bullet.bind = 0.0.0.0
kafkatokafka.sources.kafka_dmc_bullet.port = 55555 //rpc端口绑定
kafkatokafka.sources.kafka_dmc_bullet.compression-type = deflate //压缩模式
kafkatokafka.sources.kafka_dmc_bullet.batchSize = 100

source kafkasink_dmc_bullet的配置

kafkatokafka.sinks.kafkasink_dmc_bullet.type = org.apache.flume.sink.kafka.KafkaSink
kafkatokafka.sinks.kafkasink_dmc_bullet.kafka.partitioner.class = com.gexin.rp.base.kafka.SimplePartitioner
kafkatokafka.sinks.kafkasink_dmc_bullet.channel = channel_dmc_bullet
kafkatokafka.sinks.kafkasink_dmc_bullet.topic = topicB
kafkatokafka.sinks.kafkasink_dmc_bullet.brokerList = 192.168.10.82:9091,192.168.10.82:9092,192.168.10.82:9093
kafkatokafka.sinks.kafkasink_dmc_bullet.requiredAcks = 1
kafkatokafka.sinks.kafkasink_dmc_bullet.batchSize = 500
kafkatokafka.channels.channel_dmc_bullet.type = memory
kafkatokafka.channels.channel_dmc_bullet.capacity = 100000
kafkatokafka.channels.channel_dmc_bullet.transactionCapacity = 1000

监控Kafka消费情况

81流量统计:

82流量统计:

消费全部消息耗时:26min
消费总日志条数统计:129,748,260
总流量:1.69G

故障模拟

  1. 模拟专线故障,在A、B两机房不通的情况下,Avro Sink报错如下:

  2. 监控Kafka消费情况,发现消费者已停止消费:

  1. 故障处理恢复后继续消费剩余日志,经统计,总日志条数为:129,747,255。

结论

  1. 当专线发生故障时,正在网络传输中的通道外数据可能会有少部分丢失,其丢失原因为网络原因,与Avro模式无关;故障后停止消费的数据不会有任何的丢失问题,由于网络原因丢失的数据需要评估其重要性以及是否需要补传。
  2. 流量压缩率达80%以上,同时我们也测试了等级为1~9的压缩率,6跟9非常接近,CPU和内存的使用率与原有传输模式相差不大,带宽的优化效果比较明显。
  3. 传输性能由于压缩的原因适当变弱,单Sink由原先20分钟延长至26分钟,可适当增加Sink的个数来提高传输速率。

生产环境实施结果

实施结果如下:

  1. 由于还有其它业务的带宽占用,总带宽使用率节省了50%以上,现阶段高峰期带宽速率不超过400Mbps;
  2. 每个Sink传输速率的极限大概是3000条每秒,压缩传输速率问题通过增加Sink的方式解决,但会适当增加CPU和内存的损耗。

全文总结
Flume作为个推日志传输的主要工具之一,Source的类型选择尤为重要(如avro、thrif、exec、kafka和spooling directory等等)。无论选择哪种Source,都是为了实现日志数据的高效传输。本文通过Avro的方式,解决了带宽资源瓶颈的问题。

未来,我们希望与更多开发者一起探索如何用更多的技术手段来节约控制成本,并满足更多的业务场景需求。

继续阅读 »

引言
带宽不够用,靠这个方法我让数据压缩率达到了80%以上

如何在有限的资源下解决性能瓶颈问题是运维永恒的痛点。这期文章,Mr.Tech 邀请了在性能优化方面有着丰富经验的个推高级运维工程师白子画,为大家分享宽带优化之Flume Avro在个推的实践。

在异地日志数据互传的场景下,我们从传输数据着手,借助Avro的特性使数据压缩率达80%以上,解决了个推在实际生产过程中遇到的带宽不够用的问题。本文我们将向大家介绍Flume Avro在数据传输过程中所承担的不同角色,以及如何保证数据的完整性和传输的高效性,并分享在实际业务中取得的优化效果。

背景
个推作为专业的数据智能服务商,已经成功服务了数十万APP,每日的消息下发量达百亿级别,由此产生了海量日志数据。为了应对业务上的各种需求,我们需要采集并集中化日志进行计算,为此个推选用了高可用的、高可靠的、分布式的Flume系统以对海量日志进行采集、聚合和传输。此外,个推也不断对Flume进行迭代升级,以实现自己对日志的特定需求。

原有的异地机房日志汇聚方式,整个流程相对来说比较简单,A机房业务产生的日志通过多种方式写入该机房Kafka集群,然后B机房的Flume通过网络专线实时消费A机房Kafka的日志数据后写入本机房的Kafka集群,所有机房的数据就是通过相同方式在B机房Kakfa集群中集中化管理。如图一所示:

图一:原有异地日志传输模式

但是随着业务量的不断增加,日志数据在逐渐增多的过程中对带宽要求变高,带宽的瓶颈问题日益凸显。按照1G的专线带宽成本2~3w/月来计算,一个异地机房一年仅专线带宽扩容成本就高达30w以上。对此,如何找到一种成本更加低廉且符合当前业务预期的传输方案呢?Avro有快速压缩的二进制数据形式,并能有效节约数据存储空间和网络传输带宽,从而成为优选方案。

优化思路
Avro简介

Avro是一个数据序列化系统。它是Hadoop的一个子项目,也是Apache的一个独立的项目,其主要特点如下:
● 丰富的数据结构;
● 可压缩、快速的二进制数据类型;
● 可持久化存储的文件类型;
● 远程过程调用(RPC);
● 提供的机制使动态语言可以方便地处理数据。
具体可参考官方网站:http://avro.apache.org/

Flume Avro方案

Flume的RPC Source是Avro Source,它被设计为高扩展的RPC服务端,能从其他Flume Agent 的Avro Sink或者Flume SDK客户端,接收数据到Flume Agent中,具体流程如图二所示:

图二:Avro Source流程

针对该模式,我们的日志传输方案计划变更为A机房部署Avro Sink用以消费该机房Kafka集群的日志数据,压缩后发送到B机房的Avro Source,然后解压写入B机房的Kafka集群,具体的传输模式如图三所示:

图三:Flume Avro传输模式

可能存在的问题

我们预估可能存在的问题主要有以下三点:
● 当专线故障的时候,数据是否能保证完整性;
● 该模式下CPU和内存等硬件的消耗评估;
● 传输性能问题。

验证情况
针对以上的几个问题,我们做了几项对比实验。
环境准备情况说明:

  1. 两台服务器192.168.10.81和192.168.10.82,以及每台服务器上对应一个Kakfa集群,模拟A机房和B机房;
  2. 两个Kafka集群中对应topicA(源端)和topicB(目标端)。在topicA中写入合计大小11G的日志数据用来模拟原始端日志数据。
  3. 192.168.10.82上部署一个Flume,模拟原有传输方式。
  4. 192.168.10.81服务器部署Avro Sink,192.168.10.82部署Avro Source,模拟Flume Avro传输模式。

原有Flume模式验证(非Avro)

监控Kafka消费情况:

81流量统计:

82流量统计:

消费全部消息耗时:20min
消费总日志条数统计:129,748,260
总流量:13.5G

Avro模式验证

配置说明:

Avro Sink配置:

kafkasink 是kafkatokafka的sinks的名字,可配多个,空格分开

kafkatokafka.sources = kafka_dmc_bullet
kafkatokafka.channels = channel_dmc_bullet
kafkatokafka.sinks = kafkasink_dmc_bullet
kafkatokafka.sources.kafka_dmc_bullet.type = org.apache.flume.source.kafka.KafkaSource
kafkatokafka.sources.kafka_dmc_bullet.channels = channel_dmc_bullet
kafkatokafka.sources.kafka_dmc_bullet.zookeeperConnect = 192.168.10.81:2181
kafkatokafka.sources.kafka_dmc_bullet.topic = topicA
kafkatokafka.sources.kafka_dmc_bullet.kafka.zookeeper.connection.timeout.ms = 150000
kafkatokafka.sources.kafka_dmc_bullet.kafka.consumer.timeout.ms = 10000
kafkatokafka.sources.kafka_dmc_bullet.kafka.group.id = flumeavro
kafkatokafka.sources.kafka_dmc_bullet.batchSize = 5000

source kafkasink_dmc_bullet的配置,可配置多个sink提高压缩传输效率

kafkatokafka.sinks.kafkasink_dmc_bullet.type = org.apache.flume.sink.AvroSink
kafkatokafka.sinks.kafkasink_dmc_bullet.hostname = 192.168.10.82
kafkatokafka.sinks.kafkasink_dmc_bullet.port = 55555 //与source的rpc端口一一对应
kafkatokafka.sinks.kafkasink_dmc_bullet.compression-type = deflate //压缩模式
kafkatokafka.sinks.kafkasink_dmc_bullet.compression-level = 6 //压缩率1~9
kafkatokafka.sinks.kafkasink_dmc_bullet.channel = channel_dmc_bullet
kafkatokafka.sinks.kafkasink_dmc_bullet.channel = channel_dmc_bullet
kafkatokafka.sinks.kafkasink_dmc_bullet.requiredAcks = 1
kafkatokafka.sinks.kafkasink_dmc_bullet.batchSize = 5000

source kafkasink_dmc_bullet配的channel,只配一个

kafkatokafka.channels.channel_dmc_bullet.type = memory
kafkatokafka.channels.channel_dmc_bullet.capacity = 100000

kafkatokafka.channels.channel_dmc_bullet.byteCapacity = 10000

kafkatokafka.channels.channel_dmc_bullet.byteCapacityBufferPercentage = 10

kafkatokafka.channels.channel_dmc_bullet.transactionCapacity = 5000
kafkatokafka.channels.channel_dmc_bullet.keep-alive = 60

Avro Source配置:

kafkasink 是kafkatokafka的sinks的名字,可配多个,空格分开

kafkatokafka.sources = kafka_dmc_bullet
kafkatokafka.channels = channel_dmc_bullet
kafkatokafka.sinks = kafkasink_dmc_bullet
kafkatokafka.sources.kafka_dmc_bullet.type = avro
kafkatokafka.sources.kafka_dmc_bullet.channels = channel_dmc_bullet
kafkatokafka.sources.kafka_dmc_bullet.bind = 0.0.0.0
kafkatokafka.sources.kafka_dmc_bullet.port = 55555 //rpc端口绑定
kafkatokafka.sources.kafka_dmc_bullet.compression-type = deflate //压缩模式
kafkatokafka.sources.kafka_dmc_bullet.batchSize = 100

source kafkasink_dmc_bullet的配置

kafkatokafka.sinks.kafkasink_dmc_bullet.type = org.apache.flume.sink.kafka.KafkaSink
kafkatokafka.sinks.kafkasink_dmc_bullet.kafka.partitioner.class = com.gexin.rp.base.kafka.SimplePartitioner
kafkatokafka.sinks.kafkasink_dmc_bullet.channel = channel_dmc_bullet
kafkatokafka.sinks.kafkasink_dmc_bullet.topic = topicB
kafkatokafka.sinks.kafkasink_dmc_bullet.brokerList = 192.168.10.82:9091,192.168.10.82:9092,192.168.10.82:9093
kafkatokafka.sinks.kafkasink_dmc_bullet.requiredAcks = 1
kafkatokafka.sinks.kafkasink_dmc_bullet.batchSize = 500
kafkatokafka.channels.channel_dmc_bullet.type = memory
kafkatokafka.channels.channel_dmc_bullet.capacity = 100000
kafkatokafka.channels.channel_dmc_bullet.transactionCapacity = 1000

监控Kafka消费情况

81流量统计:

82流量统计:

消费全部消息耗时:26min
消费总日志条数统计:129,748,260
总流量:1.69G

故障模拟

  1. 模拟专线故障,在A、B两机房不通的情况下,Avro Sink报错如下:

  2. 监控Kafka消费情况,发现消费者已停止消费:

  1. 故障处理恢复后继续消费剩余日志,经统计,总日志条数为:129,747,255。

结论

  1. 当专线发生故障时,正在网络传输中的通道外数据可能会有少部分丢失,其丢失原因为网络原因,与Avro模式无关;故障后停止消费的数据不会有任何的丢失问题,由于网络原因丢失的数据需要评估其重要性以及是否需要补传。
  2. 流量压缩率达80%以上,同时我们也测试了等级为1~9的压缩率,6跟9非常接近,CPU和内存的使用率与原有传输模式相差不大,带宽的优化效果比较明显。
  3. 传输性能由于压缩的原因适当变弱,单Sink由原先20分钟延长至26分钟,可适当增加Sink的个数来提高传输速率。

生产环境实施结果

实施结果如下:

  1. 由于还有其它业务的带宽占用,总带宽使用率节省了50%以上,现阶段高峰期带宽速率不超过400Mbps;
  2. 每个Sink传输速率的极限大概是3000条每秒,压缩传输速率问题通过增加Sink的方式解决,但会适当增加CPU和内存的损耗。

全文总结
Flume作为个推日志传输的主要工具之一,Source的类型选择尤为重要(如avro、thrif、exec、kafka和spooling directory等等)。无论选择哪种Source,都是为了实现日志数据的高效传输。本文通过Avro的方式,解决了带宽资源瓶颈的问题。

未来,我们希望与更多开发者一起探索如何用更多的技术手段来节约控制成本,并满足更多的业务场景需求。

收起阅读 »