一、概述
Databus负责从各种异构数据源向目标数据仓库进行数据转化,这个过程是根据不同的实效性来完成的。在构建数据仓库的过程中,Databus扮演着至关重要的角色,可以被视为构建数据仓库的第一步。
如果我们将数据仓库的模型设计比喻为大厦的设计蓝图,那么数据就是砖瓦。而Databus则负责将这些砖瓦按照设计蓝图的要求,构建成坚实的大厦。换句话说,Databus就是实现数据仓库模型设计的过程。
在具体的工作流程中,Databus首先从各种异构数据源中获取原始数据。这些数据源可能是关系型数据库、非关系型数据库、文件系统、Web API等。然后,Databus根据预设的规则和映射关系,将这些原始数据进行清洗、转换和聚合,使其符合目标数据仓库的模型设计。
根据不同的实效性要求,Databus可以按照不同的频率和方式进行数据转化。例如,对于实时数据源,Databus可以实时地捕获变化并立即将其应用到目标数据仓库中。对于批处理数据源,Databus可以按照预设的时间周期(如每天、每周或每月)进行批量转化。
在这个过程中,Databus还可以对数据进行一系列的优化和调整。例如,它可以自动处理重复的数据行、填充缺失值、对数据进行排序和去重等。此外,Databus还可以根据预设的规则进行数据筛选和过滤,只将符合条件的数据应用到目标数据仓库中。
通过这个过程,我们可以将各种异构数据源的数据整合到目标数据仓库中,实现数据的集中管理和统一访问。这样一来,我们就可以更好地利用这些数据进行业务分析和决策支持,推动业务的发展和创新。
二、技术架构
Databus的总体架构,它包括四个关键模块:Web控制台、调度中心、后台任务和监控。
-
Web控制台:用户几乎所有的操作都围绕数据任务流程在Web控制台上进行。这包括流程的开发、测试、发布和运维等。Web控制台提供了一个直观和易于使用的界面,使用户能够轻松地管理和监控他们的数据任务。
-
调度中心:这个模块负责接收Web控制台的所有操作,并将其转化为实际的命令。然后,调度系统根据不同的指令生成不同的流程节点。这些节点可能是简单的同步型任务,也可能是周期性运行的任务,甚至可能是长时间运行的任务(如初始化全量股票数据)。调度中心还负责根据不同的实效性需求进行任务调度。实效性参数是指每隔多长时间调度一次任务。任务类型包括批任务与长任务。开发者通过配置任务实效参数和任务类型后,调度模块会启动不同类型的任务。同时,调度中心还提供命令行界面供查看和操作任务运行状态。
-
后台任务:这些是通过调度中心生成的job,主要为做全量和增量计算而生成的进程job。后台任务负责数据的转化,将数据从各种异构数据源转化为数据仓库内的结构化数据。
-
监控:最后,全量和增量job会生成相应的metrics,以便进行监控和告警。监控模块负责监控每个任务的运行情况和数据仓库内的数据正确性,以便及时发现问题并进行告警通知。这有助于确保数据的准确性和完整性,同时也能帮助开发者更好地理解任务的运行状态和性能表现。
综上所述,Databus的总体架构设计使得用户能够通过Web控制台灵活地管理和监控他们的数据任务。调度中心根据不同的需求生成和调度不同类型的任务。后台任务则负责从各种数据源中提取和转换数据。最后,监控模块确保了任务的正常运行和数据的准确性。这种架构使得Databus能够满足不同的实效性需求,并提供了一个高效、可靠的方式来构建和管理数据仓库。
三、模块简单实现
以下展示的模块实现代码较为简洁,主要目的是为了演示核心功能。
代码结构
.
├── bin
│ ├── __init__.py
│ ├── daily_job.bat -- 调度器启动
│ ├── daily_job.conf -- 调度作业配置
├── common -- 工具包及常量类
│ ├── ConfigUtils.py
│ ├── Constants.py
│ ├── Utils.py
│ ├── __init__.py
├── conf
│ ├── system.conf -- 系统配置文件,配置全局参数及mysql参数等
├── service
│ ├── FundaService.py -- 基本面服务,用于获得财务数据,如:营收、净利润等
│ ├── FutuService.py -- 富图牛牛api接口,用于获得股票数据,如:close价格等
│ ├── MacroService.py -- 宏观服务,用于获得宏观数据如:通胀、利率、M2等
│ ├── PoolStockService.py -- 股票池服务,系统维护的股票列表及相关参数
│ ├── TradeDayService.py -- 交易日服务,基于富图api获得的交易日列表
│ ├── DHandler.py -- 获得日线数据,调用富图api,只是简单封装
│ ├── WHandler.py -- 获得周线数据,同上
│ ├── __init__.py
├── SchDailyMain.py -- 调度器
├── LaunchParam.py -- 启动前的全局参数设置
├── OfflineJob.py -- 离线作业,内部具有多个周期子作业
├── RealtimeJob.py -- 实时作业,准实时获得股票数据
├── __init__.py
调度器
该模块由三个文件组成,包括启动文件、任务配置文件以及任务调度代码。任务调度代码会读取配置文件内容,并将其解析为几种类型的任务,然后启动这些任务。这种设计使得系统具有较高的灵活性和可扩展性,可以轻松地添加、修改或删除任务类型,而无需修改代码本身。
-
daily_job.bat
@echo off
rem @echo off
echo "APP_WORK_HOME=%APP_WORK_HOME% daily_job.bat"
set APP_WORK_HOME=%cd%\..\
D:\soft\Anaconda2\python %APP_WORK_HOME%\SchDailyMain.py daily_job.conf >> d:\home\logs\daily_job.out
rem pause
-
daily_job.conf
#每天运行一次获得最近10条交易日
[trading_days]
cron=-i 1 -h 09 -m 10 -t CN
cmd=OfflineJob.py -t trade_day
# 30分钟一次获得标普500股票最新1条数据
[spx500]
cron=-i 2 -m 30 -t US -s 09:00 -e 16:40
cmd=Offline.py -t us_realtime_data -m US -f SPX_code.txt -l 1
# 1小时获得一次最近1条财报数据
[funda_us]
cron=-i 3 -h 01 -m 01 -t US -s 01:00 -e 23:00
cmd=OfflineJob.py -t funda -s income,cashflow,balance -l 1
# 准获得实时股票数据
[futu_us]
cron=-i 4
cmd=RealtimeJob.py -t futu_stock -m US -e online
-
SchDailyMain.py
# apscheduler调度器
scheduler = BackgroundScheduler()
#logger设置
formatter = logging.Formatter("%(asctime)s - %(thread)d - %(name)s.%(funcName)s:%(lineno)s %(levelname)s - %(message)s")
logging.basicConfig(level=logging.INFO)
# logging root新建console handler
console = logging.StreamHandler(stream=sys.stdout)
console.setLevel(logging.INFO)
console.setFormatter(formatter)
logging.getLogger('apscheduler.scheduler').addHandler(console)
logging.getLogger('apscheduler.executors.default').addHandler(console)
logger_sch = logging.getLogger('SchDailyMain')
logger_sch.addHandler(console)
#调度方法
def tick_delay(task_file_name, section, market_type, path):
p = subprocess.Popen(path, shell=True)
logger_sch.info('app end ' + task_file_name + ',section=' + section)
p.wait()
if __name__ == '__main__':
if os.environ.get('APP_WORK_HOME') is None:
os.environ['APP_WORK_HOME'] = os.getcwd() + os.sep
LaunchParam.initAppParam()
# 获得参数
param_list = sys.argv[1:]
if len(param_list) == 0:
param_list = ['daily_task.conf']
task_file = param_list[0]
print('##### schedule ' + task_file + ' file #####')
# 配置加载
task_file_path = ConfigUtils._global_workdir + os.sep + 'bin' + os.sep + task_file
if os.path.exists(task_file_path) is False:
print(task_file_path + ' is not exist.')
task_conf_file = ConfigParser.ConfigParser()
task_conf_file.read(task_file_path)
#解析每块配置参数
for section in task_conf_file.sections():
cron_param = task_conf_file.get(section, "cron")
cmd_param = task_conf_file.get(section, "cmd")
opts, args = getopt.getopt(cron_param.split(' '), "i:h:m:s:e:t:")
i, h, m, s, e, t = None, None, None, None, None, None, None
for op, value in opts:
if op == '-i':
i = value
elif op == '-h':
h = value
elif op == '-m':
m = value
elif op == '-s':
start = value
elif op == '-e':
end = value
elif op == '-t':
market_type = value
# cmd
cmd = 'D:\soft\Anaconda2\python %s%s' % (ConfigUtils._global_workdir + os.sep, cmd_param)
# 向调度器增加任务
if i == '1': # 每日定时
logger_sch.info('cron task_type=1 ' + section + ' ...')
scheduler.add_job(tick, trigger='cron', hour=h, minute=m, args=[task_file, section, market_type, cmd])
elif i == '2': # 分钟循环调度
logger_sch.info('interval minute task_type=2 ' + section + ' ...')
scheduler.add_job(interval_minute_tick, trigger='cron', minute='*/' + m,
args=[task_file, section, market_type, cmd, start, end])
elif i == '3': # 小时循环调度
logger_sch.info('interval hour taskType=4 ' + section + ' ...')
scheduler.add_job(interval_hour_tick, 'interval', hours=int(h), minutes=int(m),
args=[task_file, section, market_type, start, end])
elif i == '4': # 10秒后延迟运行一次
logger_sch.info('run task task_type=3 ' + section + ' ...')
a_dt = datetime.now() + timedelta(seconds=5)
scheduler.add_job(tick_delay, trigger='date', run_date=a_dt, args=[task_file, section, market_type, cmd])
# 启动调度器,执行任务
try:
scheduler.start()
logger_sch.info("SchDailyMain scheduler start...")
except (KeyboardInterrupt, SystemExit):
scheduler.shutdown()
logger_sch.error("SchDailyMain scheduler exception...")
while True: # 主进程阻塞
time.sleep(999999)
任务
所有Task文件的代码在接收参数方面都遵循类似的模式,首先会初始化应用程序的根目录,然后解析传递过来的参数,最后执行具体的业务逻辑。以下以RealtimeJob.py为例进行说明:
在RealtimeJob.py文件中,首先会设置应用程序的根目录路径,以确保后续文件和资源的正确引用。然后,它会解析传递过来的参数,这些参数可以包括任务类型、任务配置等,以便在后续的逻辑中加以使用。最后,根据解析得到的参数执行具体的业务逻辑。这种设计使得任务文件的代码结构清晰、易于维护和扩展。
import AppContext
import LaunchParam
from springpython.context import ApplicationContext
from util import LogUtils
if __name__ == '__main__':
if os.environ.get('APP_WORK_HOME') is None:
os.environ['APP_WORK_HOME'] = os.getcwd()
LaunchParam.initAppParam()
# 获得参数
list = sys.argv[1:]
if len(list) == 0:
list = ['-t', 'futu_stock', '-m', 'US', '-e', 'test']
# list = ['-t', 'futu_stock', '-m', 'US', '-e', 'online']
# check参数
opts, args = getopt.getopt(list, "t:m:e:z:")
task_name, market_type, env, size = None, None, 'test', None
for op, value in opts:
if op == '-t':
task_name = value
elif op == '-m':
market_type = value
elif op == '-e':
env = value
elif op == '-z':
size = value
# 参数非空判断
if task_name == '':
print('task_name is empty.')
os._exit(-1)
if market_type == '':
print('market_type is empty.')
os._exit(-1)
if size is None:
size = 2
LogUtils.confLogging('RealtimeTask_' + task_name + '_' + market_type + '.log')
logger = logging.getLogger("RealtimeTask")
logger.info('##### RealtimeTask ' + task_name + '_' + market_type + ' start #####')
if task_name.strip().startswith('futu_stock'):
if market_type == 'US':
america_tz = timezone('America/New_York')
else:
america_tz = timezone('Asia/Shanghai')
# 初始化对象容器,相当于java spring ioc container
appContext = ApplicationContext(AppContext.AppContext())
# 初始化futu api
newFutuService = appContext.get_object('newFutuService')
newFutuService.init()
# 初始化交易日service
tradeDayService = appContext.get_object('tradeService')
while True:
now_dt = datetime.now(tz=america_tz)
curDate = now_dt.strftime('%Y-%m-%d')
curHM = now_dt.strftime('%H:%M')
# 获得交易日,非交易日,不调用接口
if market_type == 'SH' or market_type == 'SZ':
tradeDay = tradeDayService.get_trade_day('CN', curDate)
else:
tradeDay = tradeDayService.get_trade_day(market_type, curDate)
planStartHM = '09:00'
if market_type == 'SH' or market_type == 'SZ':
endHM = '15:10'
else:
endHM = '16:10'
if env != 'test':
if tradeDay is None:
logger.info('RealtimeTask ' + market_type.lower() + ' market is not open,sleep half hour.')
time.sleep(1800)
continue
logger.info('start futu get data, param is market_type=' + market_type + ',am curDate=' + curDate + ',curHM=' + curHM)
# 判断是否在开盘时间范围内或者调试环境
while (curHM >= planStartHM and curHM < endHM) or env == 'test':
try:
# 调用futu api获得数据
logger.info('==futu start, params market_type=' + market_type + ',curDate=' + curDate + ',curHM=' + curHM)
# 股票池服务,获得下载股票数据列表
poolStockService = appContext.get_object('poolStockService')
code_db_list, name_dict = poolStockService.findCodeByMarket(market_type)
dHander = appContext.get_object('dHander')
wHander = appContext.get_object('wHander')
dHander.get_cur_kline(market_type, code_db_list, name_dict, size) # 日K
wHander.get_cur_kline(market_type, code_db_list, name_dict, size) # 周K
logger.info('==futu success, params market_type=' + market_type + ',curDate=' + curDate + ',curHM=' + curHM)
# 重置时间
now_dt = datetime.now(tz=america_tz)
curDate = now_dt.strftime('%Y-%m-%d')
curHM = now_dt.strftime('%H:%M')
if env == 'test':
break
else:
time.sleep(random.randint(1, 3)) # futu api接口不能频繁调用
except Exception as e:
logging.error('futu data ' + market_type + ' error, exception=' + traceback.format_exc())
time.sleep(5)
logger.info('exit futu get data, params market_type=' + market_type + ',cn curDate=' + curDate + ',curHM=' + curHM)
if env == 'test':
break
time.sleep(20 * 60)
主要逻辑代码
-
DHandler.py获得K线数据业务
# 调用futu api 获得天级最近kline data
def get_cur_kline(self, market_type, code_list=[], name_dict={}, num=5):
for code in code_list:
ucode = code.upper()
cn_name = name_dict.get(ucode)
if cn_name is None:
cn_name = ''
full_code_name = str(market_type + "." + ucode)
try:
# 调用富图api,先订阅
self._newFutuService.ctx.subscribe([full_code_name], [SubType.K_DAY])
# 获得最近日K线数据
ret_code, ret_data = self._newFutuService.ctx.get_cur_kline(full_code_name, num=num, ktype='K_DAY')
if ret_code != 0:
self.logger.error("openfutu.get_cur_kline error,ret_code!=0, params code=" + full_code_name+', type=K_DAY')
break
# 解析返回值
for index, row in ret_data.iterrows(): # 获取每行的index、row
cycle = CycleData()
cycle.market_type = market_type # 市场类型
dt = row['time_key'].split(' ')[0] #交易日
# 四价、成交量、成交金额不需要
open = round(row['open'], 3)
close = round(row['close'], 3)
high = round(row['high'], 3)
low = round(row['low'], 3)
volume = row['volume']
tur = 0
#保存到数据库
cycle.initDataDay(market_type, ucode, cn_name, dt, close, high, low, open, volume, tur)
self._bizService.saveOrUpdateRecordByTable(cycle, 'day_record')
except Exception as e:
self.logger.error("openfutu.get_cur_kline K_DAY fail, code=%s, exception=%s" % (ucode, traceback.format_exc()))
self.logger.info("dHandler get_cur_kline finish,market_type=" + market_type)