zoukankan      html  css  js  c++  java
  • Appium自动化封装教案

    导入如下包

    框架背景

    前面我们已经学习了Appium各种元素定位,手势操作、数据配置、Pageobject设计模式等等。但是前面的功能都是比较零散的,没有整体融合起来,实际项目实践过程中我们需要综合运用,那么本章节我们将结合之前所学的内容,从0到1搭建一个完整的自动化测试框架。

    框架功能

    • 业务功能的封装
    • 测试用例封装
    • 测试包管理
    • 截图处理
    • 断言处理
    • 日志获取
    • 测试报告生成
    • 数据驱动
    • 数据配置

    测试案例

    测试环境

    • Win10 64Bit
    • Appium 1.7.2
    • 考研帮App Android版3.1.0
    • 夜神模拟器 Android 5.1.1

    覆盖用例

    1.登录场景

    用户名

    密码

    自学网2018

    zxw2018

    自学网2017

    zxw2017

    666

    222

    2.注册场景

    注册一个新的账号(账户和密码可以随机生成),完善院校和专业信息 (如:院校:上海-同济大学 专业:经济学类-统计学-经济统计学)

    框架设计图

    代码实现

    driver配置封装

    kyb_caps.yaml 配置表

    platformName: Android
    #模拟器
    platformVersion: 5.1.1
    deviceName: 127.0.0.1:62025
    
    #mx4真机
    #platformVersion: 5.1
    #udid: 750BBKL22GDN
    #deviceName: MX4
    
    appname: kaoyan3.1.0.apk
    noReset: False
    unicodeKeyboard: True
    resetKeyboard: True
    
    appPackage: com.tal.kaoyan
    appActivity: com.tal.kaoyan.ui.activity.SplashActivity
    ip: 127.0.0.1
    port: 4723

    desired_caps.py

    import yaml
    import logging.config
    from appium import webdriver
    import os
    
    CON_LOG = '../config/log.conf'
    logging.config.fileConfig(CON_LOG)
    logging = logging.getLogger()
    
    def appium_desired():
    
        with open('../config/kyb_caps.yaml','r',encoding='utf-8') as file:
            data = yaml.load(file)
    
        desired_caps={}
        desired_caps['platformName']=data['platformName']
    
        desired_caps['platformVersion']=data['platformVersion']
        desired_caps['deviceName']=data['deviceName']
    
        base_dir = os.path.dirname(os.path.dirname(__file__))
        app_path = os.path.join(base_dir, 'app', data['appname'])
        desired_caps['app'] = app_path
    
        desired_caps['noReset']=data['noReset']
    
        desired_caps['unicodeKeyboard']=data['unicodeKeyboard']
        desired_caps['resetKeyboard']=data['resetKeyboard']
    
        desired_caps['appPackage']=data['appPackage']
        desired_caps['appActivity']=data['appActivity']
    
        logging.info('start run app...')
        driver = webdriver.Remote('http://'+str(data['ip'])+':'+str(data['port'])+'/wd/hub', desired_caps)
    
        driver.implicitly_wait(5)
        return driver
    
    
    if __name__ == '__main__':
        appium_desired()
    
        # with open('../config/kyb_caps.yaml','r',encoding='utf-8') as file:
        #    data = yaml.load(file)
    
        #base_dir = os.path.dirname(os.path.dirname(__file__))
        #app_path = os.path.join(base_dir, 'app', data['appname'])
        #print(app_path)
    相对路径符号含义
    1. “.”表示当前目录
    2. “..” 表示当前目录的上一级目录。
    3. “./”表示当前目录下的某个文件或文件夹,视后面跟着的名字而定
    4. “../”表示当前目录上一级目录的文件或文件夹,视后面跟着的名字而定。

    基类封装

    baseView.py

    class BaseView(object):
        def __init__(self,driver):
            self.driver=driver
    
        def find_element(self,*loc):
            return self.driver.find_element(*loc)
    
        def find_elements(self,*loc):
            return self.driver.find_elements(*loc)
    
        def get_window_size(self):
            return self.driver.get_window_size()
    
        def swipe(self,start_x, start_y, end_x, end_y, duration):
            return self.driver.swipe(start_x, start_y, end_x, end_y, duration)

    common公共模块封装

    公共方法封装 : common_fun.py

    from  baseView.baseView import BaseView
    from common.desired_caps import appium_desired
    from selenium.common.exceptions import NoSuchElementException
    import logging.config
    from selenium.webdriver.common.by import By
    import os
    import time
    import csv
    
    
    class Common(BaseView):
    
        #取消升级和跳过引导按钮
        cancel_upgradeBtn=(By.ID,'android:id/button2')
        skipBtn=(By.ID,'com.tal.kaoyan:id/tv_skip')
    
        # 登录后浮窗广告取消按钮
        wemedia_cacel=(By.ID, 'com.tal.kaoyan:id/view_wemedia_cacel')
    
        def check_updateBtn(self):
            logging.info("============check_updateBtn===============")
    
            try:
                element = self.driver.find_element(*self.cancel_upgradeBtn)
            except NoSuchElementException:
                logging.info('update element is not found!')
            else:
                logging.info('click cancelBtn')
                element.click()
    
        def check_skipBtn(self):
            logging.info("==========check_skipBtn===========")
            try:
                element = self.driver.find_element(*self.skipBtn)
            except NoSuchElementException:
                logging.info('skipBtn element is not found!')
            else:
                logging.info('click skipBtn')
                element.click()
    
        def get_screenSize(self):
            '''
            获取屏幕尺寸
            :return: 
            '''
            x = self.get_window_size()['width']
            y = self.get_window_size()['height']
            return (x, y)
    
    
        def swipeLeft(self):
            logging.info('swipeLeft')
            l = self.get_screenSize()
            y1 = int(l[1] * 0.5)
            x1 = int(l[0] * 0.95)
            x2 = int(l[0] * 0.25)
            self.swipe(x1, y1, x2, y1, 1000)
    
    
    
        def getTime(self):
            self.now = time.strftime("%Y-%m-%d %H_%M_%S")
            return self.now
    
        def getScreenShot(self, module):
            time = self.getTime()
            image_file= os.path.dirname(os.path.dirname(__file__)) + '/screenshots/%s_%s.png' % (module, time)
    
            logging.info('get %s screenshot' % module)
            self.driver.get_screenshot_as_file(image_file)
    
        def check_market_ad(self):
            '''检测登录或者注册之后的界面浮窗广告'''
            logging.info('=======check_market_ad=============')
            try:
                element=self.driver.find_element(*self.wemedia_cacel)
            except NoSuchElementException:
                pass
            else:
                logging.info('close market ad')
                element.click()
        
        def get_csv_data(self,csv_file,line):
            '''
            获取csv文件指定行的数据
            :param csv_file: csv文件路径
            :param line: 数据行数
            :return: 
            '''
            with open(csv_file, 'r', encoding='utf-8-sig') as file:
                reader=csv.reader(file)
                for index, row in enumerate(reader,1):
                    if index == line:
                        return row
        
    
    if __name__ == '__main__':
        driver=appium_desired()
        # c=Common(driver)
        # c.check_updateBtn()
        # # c.check_skipBtn()
        # c.swipeLef()
        # c.swipeLef()
        # c.getScreenShot("startApp")

    业务模块封装

    1.登录页面业务逻辑模块

    loginView.py

    import logging
    from common.desired_caps import appium_desired
    from common.common_fun import Common,By
    from selenium.common.exceptions import NoSuchElementException
    
    class LoginView(Common):
        #登录界面元素
        username_type=(By.ID,'com.tal.kaoyan:id/login_email_edittext')
        password_type=(By.ID,'com.tal.kaoyan:id/login_password_edittext')
        loginBtn=(By.ID,'com.tal.kaoyan:id/login_login_btn')
    
        #个人中心元素
        username=(By.ID,'com.tal.kaoyan:id/activity_usercenter_username')
        button_myself=(By.ID,'com.tal.kaoyan:id/mainactivity_button_mysefl')
    
        # 个人中心下线警告提醒确定按钮
        commitBtn = (By.ID, 'com.tal.kaoyan:id/tip_commit')
    
        #退出操作相关元素
        settingBtn = (By.ID, 'com.tal.kaoyan:id/myapptitle_RightButtonWraper')
        logoutBtn=(By.ID,'com.tal.kaoyan:id/setting_logout_text')
        tip_commit=(By.ID,'com.tal.kaoyan:id/tip_commit')
    
        def login_action(self,username,password):
            self.check_updateBtn()
            self.check_skipBtn()
    
            logging.info('============login_action==============')
            logging.info('username is:%s' % username)
            self.driver.find_element(*self.username_type).send_keys(username)
    
            logging.info('password is:%s' % password)
            self.driver.find_element(*self.password_type).send_keys(password)
    
            logging.info('click loginBtn')
            self.driver.find_element(*self.loginBtn).click()
            logging.info('login finished!')
    
        def check_account_alert(self):
            '''检测账户登录后是否有账户下线提示'''
            logging.info('====check_account_alert======')
            try:
                element = self.driver.find_element(*self.commitBtn)
            except NoSuchElementException:
                pass
            else:
                logging.info('click commitBtn')
                element.click()
    
    
        def check_loginStatus(self):
            logging.info('==========check_loginStatus===========')
            self.check_market_ad()
            self.check_account_alert()
            
            try:
                self.driver.find_element(*self.button_myself).click()
                self.driver.find_element(*self.username)
            except NoSuchElementException:
                logging.error('login Fail!')
                self.getScreenShot('login Fail')
                return False
            else:
                logging.info('login success!')
                l.logout_action()
                return True
    
        def logout_action(self):
            logging.info('=========logout_action==========')
            self.driver.find_element(*self.settingBtn).click()
            self.driver.find_element(*self.logoutBtn).click()
            self.driver.find_element(*self.tip_commit).click()
    
    if __name__ == '__main__':
        driver=appium_desired()
        l=LoginView(driver)
        l.login_action('自学网2018','zxw2018')
        l.check_loginStatus()

    注册页面业务逻辑封装

    registerView.py

    import logging
    from common.desired_caps import appium_desired
    from common.common_fun import Common,By,NoSuchElementException
    import random
    
    class RegisterView(Common):
        #登录界面注册按钮
        register_text=(By.ID,'com.tal.kaoyan:id/login_register_text')
    
        #头像设置相关元素
        userheader=(By.ID,'com.tal.kaoyan:id/activity_register_userheader')
        item_image=(By.ID,'com.tal.kaoyan:id/item_image')
        saveBtn=(By.ID,'com.tal.kaoyan:id/save')
    
        # 注册-个人信息界面元素
        register_username=(By.ID,'com.tal.kaoyan:id/activity_register_username_edittext')
        register_password=(By.ID,'com.tal.kaoyan:id/activity_register_password_edittext')
        register_email=(By.ID,'com.tal.kaoyan:id/activity_register_email_edittext')
        register_btn=(By.ID,'com.tal.kaoyan:id/activity_register_register_btn')
    
    
        #完善信息列表元素
        perfectinfomation_school=(By.ID,'com.tal.kaoyan:id/perfectinfomation_edit_school_name')
        perfectinfomation_major=(By.ID,'com.tal.kaoyan:id/activity_perfectinfomation_major')
        perfectinfomation_goBtn=(By.ID,'com.tal.kaoyan:id/activity_perfectinfomation_goBtn')
    
    
    
        #院校列表元素
        forum_title=(By.ID,'com.tal.kaoyan:id/more_forum_title')
        university=(By.ID,'com.tal.kaoyan:id/university_search_item_name')
    
    
        #专业列表元素
        major_subject_title= (By.ID, 'com.tal.kaoyan:id/major_subject_title')
        major_group_title= (By.ID, 'com.tal.kaoyan:id/major_group_title')
        major_search_item_name= (By.ID, 'com.tal.kaoyan:id/major_search_item_name')
    
        # 个人中心元素
        username = (By.ID, 'com.tal.kaoyan:id/activity_usercenter_username')
        button_myself = (By.ID, 'com.tal.kaoyan:id/mainactivity_button_mysefl')
    
    
        def register_action(self,register_username,register_password,register_email):
            self.check_cancelBtn()
            self.check_skipBtn()
    
            logging.info('=========register_action===========')
            self.driver.find_element(*self.register_text).click()
    
            #头像设置
            logging.info('set userheader')
            self.driver.find_element(*self.userheader).click()
            self.driver.find_elements(*self.item_image)[10].click()
            self.driver.find_element(*self.saveBtn).click()
    
    
            #用户名密码填写
            logging.info('register username is %s' %register_username)
            self.driver.find_element(*self.register_username).send_keys(register_username)
    
            logging.info('register_password is %s' %register_password)
            self.driver.find_element(*self.register_password).send_keys(register_password)
    
            logging.info('register_email is %s' %register_email)
            self.driver.find_element(*self.register_email).send_keys(register_email)
    
            logging.info('click register button')
            self.driver.find_element(*self.register_btn).click()
    
            # 判断是否进入到完善信息界面--注册太频繁会被限制无法进入该界面
            try:
                self.driver.find_element(*self.perfectinfomation_school)
            except NoSuchElementException:
                logging.error('register Fail!')
                self.getScreenShot('register Fail')
                return False
            else:
                self.add_register_info()
                #注册结果判断
                if self.check_registerStatus():
                    return True
                else:
                    return False
    
    
        def add_register_info(self):
            logging.info('===========add_register_info===========')
    
            # 院校选择:上海——同济大学
            logging.info("select school...")
            self.driver.find_element(*self.perfectinfomation_school).click()
            self.driver.find_elements(*self.forum_title)[1].click()
            self.driver.find_elements(*self.university)[1].click()
    
            #专业选择:经济学类-统计学-经济统计学
            logging.info("select major...")
            self.driver.find_element(*self.perfectinfomation_major).click()
            self.driver.find_elements(*self.major_subject_title)[1].click()
            self.driver.find_elements(*self.major_group_title)[2].click()
            self.driver.find_elements(*self.major_search_item_name)[1].click()
    
            self.driver.find_element(*self.perfectinfomation_goBtn).click()
    
        def check_register_status(self):
            self.check_market_ad()
            logging.info('==========check_registerStatus===========')
    
            try:
                self.driver.find_element(*self.button_myself).click()
                self.driver.find_element(*self.username)
            except NoSuchElementException:
                logging.error('register Fail!')
                self.getScreenShot('register_Fail')
                return False
            else:
                logging.info('register success!')
                self.getScreenShot('register_success')
                return True
    
    
    if __name__ == '__main__':
        driver=appium_desired()
        register=RegisterView(driver)
    
        username='zxw2018'+'FLY'+str(random.randint(1000,9000))
        password='zxw'+str(random.randint(1000,9000))
        email='51zxw'+str(random.randint(1000,9000))+'@163.com'
    
        register.register_action(username,password,email)

    data数据封装

    使用背景

    在实际项目过程中,我们的数据可能是存储在一个数据文件中,如txt,excel、csv文件类型。我们可以封装一些方法来读取文件中的数据来实现数据驱动。

    案例

    将测试账号存储在account.csv文件,内容如下:

    自学网2017

    zxw2017

    自学网2018

    zxw2018

    666

    222

     
     
    enumerate()简介

    enumerate()是python的内置函数

    • enumerate在字典上是枚举、列举的意思
    • 对于一个可迭代的(iterable)/可遍历的对象(如列表、字符串),enumerate将其组成一个索引序列,利用它可以同时获得索引和值
    • enumerate多用于在for循环中得到计数。
    enumerate()使用

    如果对一个列表,既要遍历索引又要遍历元素时,首先可以这样写:

    list = ["", "", "一个", "测试","数据"]
        for i in range(len(list)):
            print(i,list[i])
    >>>
    0 这
    12 一个
    3 测试
    4 数据

    上述方法有些累赘,利用enumerate()会更加直接和优美:

    list1 = ["", "", "一个", "测试","数据"]
        for index, item in enumerate(list1):
            print(index,item)
    >>>
    0 这
    12 一个
    3 测试
    4 数据
    数据读取方法封装
    import csv
    
         def get_csv_data(csv_file,line):
            with open(csv_file, 'r', encoding='utf-8-sig') as file:
                reader=csv.reader(file)
                for index, row in enumerate(reader,1):
                    if index == line:
                        return row
    
        csv_file='../data/account.csv'
        data=get_csv_data(csv_file,3)
        print(data)
    utf-8与utf-8-sig两种编码格式的区别

    UTF-8以字节为编码单元,它的字节顺序在所有系统中都是一样的,没有字节序的问题,也因此它实际上并不需要BOM(“ByteOrder Mark”)。但是UTF-8 with BOM即utf-8-sig需要提供BOM。

    config文件配置

    日志文件配置 log.config

    [loggers]
    keys=root,infoLogger
    
    [logger_root]
    level=DEBUG
    handlers=consoleHandler,fileHandler
    
    [logger_infoLogger]
    handlers=consoleHandler,fileHandler
    qualname=infoLogger
    propagate=0
    
    [handlers]
    keys=consoleHandler,fileHandler
    
    [handler_consoleHandler]
    class=StreamHandler
    level=INFO
    formatter=form02
    args=(sys.stderr,)
    
    [handler_fileHandler]
    class=FileHandler
    level=INFO
    formatter=form01
    args=('../logs/runlog.log', 'a')
    
    [formatters]
    keys=form01,form02
    
    [formatter_form01]
    format=%(asctime)s %(filename)s[line:%(lineno)d] %(levelname)s %(message)s
    
    [formatter_form02]
    format=%(asctime)s %(filename)s[line:%(lineno)d] %(levelname)s %(message)s

    测试用例封装

    1.测试用例执行开始结束操作封装 myunit.py

    import unittest
    from common.desired_caps import appium_desired
    import logging
    from  time import sleep
    
    class StartEnd(unittest.TestCase):
    
        def setUp(self):
            logging.info('======setUp=========')
            self.driver=appium_desired()
    
    
        def tearDown(self):
            logging.info('======tearDown=====')
            sleep(5)
            self.driver.close_app()

    2.注册用例:test_register.py

    from common.myunit import StartEnd
    from businessView.registerView import RegisterView
    import logging
    import random
    import unittest
    
    
    class RegisterTest(StartEnd):
    
        def test_user_register(self):
            logging.info('=========test_user_register======')
            r=RegisterView(self.driver)
    
            username = 'zxw2018' + 'FLY' + str(random.randint(1000, 9000))
            password = 'zxw' + str(random.randint(1000, 9000))
            email = '51zxw' + str(random.randint(1000, 9000)) + '@163.com'
    
            self.assertTrue(r.register_action(username, password, email))
    
    
    if __name__ == '__main__':
        unittest.main()

    3.登录用例:test_login.py

    from common.myunit import StartEnd
    from businessView.loginView import LoginView
    import unittest
    import logging
    
    
    class LoginTest(StartEnd):
        csv_file = '../data/account.csv'
    
        # @unittest.skip("test_login_zxw2017")
        def test_login_zxw2017(self):
            logging.info('==========test_login_zxw2017========')
            l=LoginView(self.driver)
            data = l.get_csv_data(self.csv_file,1)
    
            l.login_action(data[0],data[1])
            self.assertTrue(l.check_loginStatus())
    
        # @unittest.skip('skip test_login_zxw2018')
        def test_login_zxw2018(self):
            logging.info('=========test_login_zxw2018============')
            l=LoginView(self.driver)
            data = l.get_csv_data(self.csv_file,2)
    
            l.login_action(data[0],data[1])
            self.assertTrue(l.check_loginStatus())
    
        # @unittest.skip("test_login_erro")
        def test_login_erro(self):
            logging.info('=======test_login_erro=========')
            l=LoginView(self.driver)
            data = l.get_csv_data(self.csv_file, 3)
    
            l.login_action(data[0], data[1])
            self.assertTrue(l.check_loginStatus(),msg='login fail!')
    
    if __name__ == '__main__':
        unittest.main()

    执行测试用例&报告生成

    BSTestRunner下载地址

    run.py

    import unittest
    from BSTestRunner import BSTestRunner
    import time
    import logging
    
    #指定测试用例和测试报告的路径
    test_dir = '../test_case'
    report_dir = '../reports'
    
    #加载测试用例
    discover = unittest.defaultTestLoader.discover(test_dir, pattern='test_login.py')
    
    
    #定义报告的文件格式
    now = time.strftime("%Y-%m-%d %H_%M_%S")
    report_name = report_dir + '/' + now + ' test_report.html'
    
    #运行用例并生成测试报告
    with open(report_name, 'wb') as f:
        runner = BSTestRunner(stream=f, title="Kyb Test Report", description="kyb Andriod app Test Report")
        logging.info("start run testcase...")
        runner.run(discover)

    注意:

    pattern参数可以控制运行不同模块的用例,如下所示表示运行指定路径以test开头的模块

    discover = unittest.defaultTestLoader.discover(test_dir, pattern='test*.py')

    Bat批处理执行测试

    前面脚本开发阶段我们都是使用pycharm IDE工具来运行脚本,但是当我们的脚本开发完成后,还每次打开IDE来执行自动化测试就不合理了,因为不仅每次打开比较麻烦,而且pycharm内存资源占用比较“感人”!这样非常影响执行效率。 针对这种情况,我们可以使用cmd命令或者封装为bat批处理脚本来运行。

    启动appium服务

    start_appium.bat

    @echo off
    appium
    pause

    @echo off 为关闭“回显”,让命令行界面显得整洁一些。

    执行测试用例

    run.bat

    @echo off
    d:
    cd D:kyb_testProject	est_run
    C:Python35python.exe run.py
    pause
    注意事项:

    1.执行之前需要在run.py脚本添加如下内容:

    import sys
    path='D:\kyb_testProject\'
    sys.path.append(path)

    项目在IDE(Pycharm)中运行和我们在cmd中运行的路径是不一样的,在pycharm中运行时, 会默认pycharm的目录+我们的工程所在目录为运行目录。

    而在cmd中运行时,会以我们的工程目录所在目录来运行。在import包时会首先从pythonPATH的环境变量中来查看包,如果没有你的PYTHONPATH中所包含的目录没有工程目录的根目录,那么你在导入不是同一个目录下的其他工程中的包时会出现import错误。

    2.以上脚本编码格式必须为utf-8

     

     

    自动化测试平台

    前面我们已经开发完测试脚本,也使用bat批处理来封装了启动Appium服务和运行测试用例。但是还是不够自动化,比如我想每天下班时自动跑一下用例,或者当研发打了新包后自动开始运行测试脚本测试新包,那么该如实现呢?

    持续集成(Continuous integration)

    持续集成是一种软件开发实践,即团队开发成员经常集成他们的工作,通过每个成员每天至少集成一次,也就意味着每天可能会发生多次集成。每次集成都通过自动化的构建(包括编译,发布,自动化测试)来验证,从而尽早地发现集成错误。

    Jenkins简介

    Jenkins是一个开源软件项目,是基于Java开发的一种持续集成工具,用于监控持续重复的工作,旨在提供一个开放易用的软件平台,使软件的持续集成变成可能。

    下载与安装

    下载地址:https://jenkins.io/download/

    下载后安装到指定的路径即可,默认启动页面为localhots:8080,如果8080端口被占用无法打开,可以进入到jenkins安装目录,找到jenkins.xml配置文件打开,修改如下代码的端口号即可。

    <arguments>-Xrs -Xmx256m -Dhudson.lifecycle=hudson.lifecycle.WindowsServiceLifecycle -jar "%BASE%jenkins.war" --httpPort=8080 --webroot="%BASE%war"</arguments>

    构建触发器

    1. 触发远程构建:如果您想通过访问一个特殊的预定义URL来触发新构建,请启用此选项。
    2. Build after other projects are built:在其他项目触发的时候触发,里面有分为三种情况,也就是其他项目构建成功、失败、或者不稳定的时候触发项目;
    3. Build periodically 定时构建
    4. GitHub hook trigger for GITScm polling,根源Git的源码更新来触发构建
    1. Poll SCM:定时检查源码变更(根据SCM软件的版本号),如果有更新就checkout最新code下来,然后执行构建动作。如下图配置:
      */5 * * * * (每5分钟检查一次源码变化)

      jenkins定时构建语法

      * * * * *

    (五颗星,中间用空格隔开)

    • 第一个*表示分钟,取值0~59
    • 第二个*表示小时,取值0~23
    • 第三个*表示一个月的第几天,取值1~31
    • 第四个*表示第几月,取值1~12
    • 第五个*表示一周中的第几天,取值0~7,其中0和7代表的都是周日
    使用案例

    每天下午下班前18点定时构建一次

    0 18 * * *

    每天早上8点构建一次

    0 8 * * *

    30分钟构建一次:

    H/30 * * * *

    补充资料:Python邮件发送

    参考资料

    1. https://blog.csdn.net/vernice/article/details/46873169
    2. https://blog.csdn.net/churximi/article/details/51648388
    3. https://www.cnblogs.com/robert-zhang/p/9060365.html
    4. https://baike.baidu.com/item/Jenkins/10917210?fr=aladdin
    5. https://baike.baidu.com/item/持续集成/6250744
    6. https://www.cnblogs.com/caoj/p/7815820.html
  • 相关阅读:
    如何自建appender扩展Log4j框架
    在O(1)时间删除链表结点
    My First GitHub
    JAVA序列化和反序列化
    [转]Vim 复制粘帖格式错乱问题的解决办法
    Centos清理内存 内存回收释放及内存使用查看的相关命令
    Spark HA 的搭建
    Ambari安装
    Hadoop HA的搭建
    Hadoop32位和64位的查询
  • 原文地址:https://www.cnblogs.com/zhenyu1/p/12590186.html
Copyright © 2011-2022 走看看