暂时未有相关云产品技术能力~
实践环境python 3.6.2Joblib简介Joblib是一组在Python中提供轻量级流水线的工具。特别是:函数的透明磁盘缓存和延迟重新计算(记忆模式)简单易用的并行计算Joblib已被优化得很快速,很健壮了,特别是在大数据上,并对numpy数组进行了特定的优化。主要功能输出值的透明快速磁盘缓存(Transparent and fast disk-caching of output value): Python函数的内存化或类似make的功能,适用于任意Python对象,包括非常大的numpy数组。通过将操作写成一组具有定义良好的输入和输出的步骤:Python函数,将持久性和流执行逻辑与域逻辑或算法代码分离开来。Joblib可以将其计算保存到磁盘上,并仅在必要时重新运行:原文:Transparent and fast disk-caching of output value: a memoize or make-like functionality for Python functions that works well for arbitrary Python objects, including very large numpy arrays. Separate persistence and flow-execution logic from domain logic or algorithmic code by writing the operations as a set of steps with well-defined inputs and outputs: Python functions. Joblib can save their computation to disk and rerun it only if necessary:>>> from joblib import Memory >>> cachedir = 'your_cache_dir_goes_here' >>> mem = Memory(cachedir) >>> import numpy as np >>> a = np.vander(np.arange(3)).astype(float) >>> square = mem.cache(np.square) >>> b = square(a) ______________________________________________________________________... [Memory] Calling square... square(array([[0., 0., 1.], [1., 1., 1.], [4., 2., 1.]])) _________________________________________________...square - ...s, 0.0min >>> c = square(a) # The above call did not trigger an evaluation并行助手(parallel helper):轻松编写可读的并行代码并快速调试>>> from joblib import Parallel, delayed >>> from math import sqrt >>> Parallel(n_jobs=1)(delayed(sqrt)(i**2) for i in range(10)) [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0] >>> res = Parallel(n_jobs=1)(delayed(sqrt)(i**2) for i in range(10)) >>> res [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]快速压缩的持久化(Fast compressed Persistence):代替pickle在包含大数据的Python对象上高效工作(joblib.dump&joblib.load)。parallel for loops常见用法Joblib提供了一个简单的助手类,用于使用多进程为循环实现并行。核心思想是将要执行的代码编写为生成器表达式,并将其转换为并行计算>>> from math import sqrt >>> [sqrt(i ** 2) for i in range(10)] [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]使用以下代码,可以分布到2个CPU上:>>> from math import sqrt >>> from joblib import Parallel, delayed >>> Parallel(n_jobs=2)(delayed(sqrt)(i ** 2) for i in range(10)) [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]输出可以是一个生成器,在可以获取结果时立即返回结果,即使后续任务尚未完成。输出的顺序始终与输入的顺序相匹配:输出的顺序总是匹配输入的顺序:>>> from math import sqrt >>> from joblib import Parallel, delayed >>> parallel = Parallel(n_jobs=2, return_generator=True) # py3.7往后版本才支持return_generator参数 >>> output_generator = parallel(delayed(sqrt)(i ** 2) for i in range(10)) >>> print(type(output_generator)) <class 'generator'> >>> print(next(output_generator)) 0.0 >>> print(next(output_generator)) 1.0 >>> print(list(output_generator)) [2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]此生成器允许减少joblib.Parallel的内存占用调用基于线程的并行VS基于进程的并行默认情况下,joblib.Parallel使用'loky'后端模块启动单独的Python工作进程,以便在分散的CPU上同时执行任务。对于一般的Python程序来说,这是一个合理的默认值,但由于输入和输出数据需要在队列中序列化以便同工作进程进行通信,因此可能会导致大量开销(请参阅序列化和进程)。当你知道你调用的函数是基于一个已编译的扩展,并且该扩展在大部分计算过程中释放了Python全局解释器锁(GIL)时,使用线程而不是Python进程作为并发工作者会更有效。例如,在Cython函数的with nogil 块中编写CPU密集型代码。如果希望代码有效地使用线程,只需传递preferre='threads'作为joblib.Parallel构造函数的参数即可。在这种情况下,joblib将自动使用"threading"后端,而不是默认的"loky"后端>>> Parallel(n_jobs=2, prefer=threads')( ... delayed(sqrt)(i ** 2) for i in range(10)) [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]也可以在上下文管理器的帮助下手动选择特定的后端实现:>>> from joblib import parallel_backend >>> with parallel_backend('threading', n_jobs=2): ... Parallel()(delayed(sqrt)(i ** 2) for i in range(10)) ... [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]后者在调用内部使用joblib.Parallel的库时特别有用,不会将后端部分作为其公共API的一部分公开。'loky'后端可能并不总是可获取。一些罕见的系统不支持多处理(例如Pyodide)。在这种情况下,loky后端不可用,使用线程作为默认后端。除了内置的joblib后端之外,还可以使用几个特定于集群的后端:用于Dask集群的Dask后端 (查阅Using Dask for single-machine parallel computing 以获取示例),用于Ray集群的Ray后端用于Spark集群上分发joblib任务的Joblib Apache Spark Backend序列化与进程要在多个python进程之间共享函数定义,必须依赖序列化协议。python中的标准协议是pickle ,但它在标准库中的默认实现有几个限制。例如,它不能序列化交互式定义的函数或在__main__模块中定义的函数。为了避免这种限制,loky后端现在依赖于cloudpickle以序列化python对象。cloudpickle是pickle协议的另一种实现方式,允许序列化更多的对象,特别是交互式定义的函数。因此,对于大多数用途,loky后端应该可以完美的工作。cloudpickle的主要缺点就是它可能比标准类库中的pickle慢,特别是,对于大型python字典或列表来说,这一点至关重要,因为它们的序列化时间可能慢100倍。有两种方法可以更改 joblib的序列化过程以缓和此问题:如果您在UNIX系统上,则可以切换回旧的multiprocessing后端。有了这个后端,可以使用很快速的pickle在工作进程中共享交互式定义的函数。该解决方案的主要问题是,使用fork启动进程会破坏标准POSIX,并可能与numpy和openblas等第三方库进行非正常交互。如果希望将loky后端与不同的序列化库一起使用,则可以设置LOKY_PICKLER=mod_pickle环境变量,以使用mod_pickle作为loky的序列化库。作为参数传递的模块mod_pickle应按import mod_picke导入,并且应包含一个Pickler 对象,该对象将用于序列化为对象。可以设置LOKY_PICKLER=pickle以使用表中类库中的pickling模块。LOKY_PICKLER=pickle的主要缺点是不能序列化交互式定义的函数。为了解决该问题,可以将此解决方案与joblib.wrap_non_picklable_objects() 一起使用,joblib.wrap_non_picklable_objects()可用作装饰器以为特定对下本地启用cloudpickle。通过这种方式,可以为所有python对象使用速度快的picking,并在本地为交互式函数启用慢速的pickling。查阅loky_wrapper获取示例。共享内存语义joblib的默认后端将在独立的Python进程中运行每个函数调用,因此它们不能更改主程序中定义的公共Python对象。然而,如果并行函数确实需要依赖于线程的共享内存语义,则应显示的使用require='sharemem',例如:>>> shared_set = set() >>> def collect(x): ... shared_set.add(x) ... >>> Parallel(n_jobs=2, require='sharedmem')( ... delayed(collect)(i) for i in range(5)) [None, None, None, None, None] >>> sorted(shared_set) [0, 1, 2, 3, 4]请记住,从性能的角度来看,依赖共享内存语义可能是次优的,因为对共享Python对象的并发访问将受到锁争用的影响。注意,不使用共享内存的情况下,任务进程之间的内存资源是相互独立的,举例说明如下:#!/usr/bin/env python # -*- coding:utf-8 -*- import time import threading from joblib import Parallel, delayed, parallel_backend from collections import deque GLOBAL_LIST = [] class TestClass(): def __init__(self): self.job_queue = deque() def add_jobs(self): i = 0 while i < 3: time.sleep(1) i += 1 GLOBAL_LIST.append(i) self.job_queue.append(i) print('obj_id:', id(self), 'job_queue:', self.job_queue, 'global_list:', GLOBAL_LIST) def get_job_queue_list(obj): i = 0 while not obj.job_queue and i < 3: time.sleep(1) i += 1 print('obj_id:', id(obj), 'job_queue:', obj.job_queue, 'global_list:', GLOBAL_LIST) return obj.job_queue if __name__ == "__main__": obj = TestClass() def test_fun(): with parallel_backend("multiprocessing", n_jobs=2): Parallel()(delayed(get_job_queue_list)(obj) for i in range(2)) thread = threading.Thread(target=test_fun, name="parse_log") thread.start() time.sleep(1) obj.add_jobs() print('global_list_len:', len(GLOBAL_LIST))控制台输出:obj_id: 1554577912664 job_queue: deque([]) global_list: [] obj_id: 1930069893920 job_queue: deque([]) global_list: [] obj_id: 2378500766968 job_queue: deque([1]) global_list: [1] obj_id: 1554577912664 job_queue: deque([]) global_list: [] obj_id: 1930069893920 job_queue: deque([]) global_list: [] obj_id: 2378500766968 job_queue: deque([1, 2]) global_list: [1, 2] obj_id: 1554577912664 job_queue: deque([]) global_list: [] obj_id: 1930069893920 job_queue: deque([]) global_list: [] obj_id: 2378500766968 job_queue: deque([1, 2, 3]) global_list: [1, 2, 3] global_list_len: 3通过输出可知,通过joblib.Parallel开启的进程,其占用内存和主线程占用的内存资源是相互独立复用worer池一些算法需要对并行函数进行多次连续调用,同时对中间结果进行处理。在一个循环中多次调用joblib.Parallel次优的,因为它会多次创建和销毁一个workde(线程或进程)池,这可能会导致大量开销。在这种情况下,使用joblib.Parallel类的上下文管理器API更有效,以便对joblib.Parallel对象的多次调用可以复用同一worker池。from joblib import Parallel, delayed from math import sqrt with Parallel(n_jobs=2) as parallel: accumulator = 0. n_iter = 0 while accumulator < 1000: results = parallel(delayed(sqrt)(accumulator + i ** 2) for i in range(5)) accumulator += sum(results) # synchronization barrier n_iter += 1 print(accumulator, n_iter) #输出: 1136.5969161564717 14请注意,现在基于进程的并行默认使用'loky'后端,该后端会自动尝试自己维护和重用worker池,即使是在没有上下文管理器的调用中也是如此笔者实践发现,即便采用这种实现方式,其运行效率也是非常低下的,应该尽量避免这种设计(实践环境 Python3.6)...略Parallel参考文档class joblib.Parallel(n_jobs=default(None), backend=None, return_generator=False, verbose=default(0), timeout=None, pre_dispatch='2 * n_jobs', batch_size='auto', temp_folder=default(None), max_nbytes=default('1M'), mmap_mode=default('r'), prefer=default(None), require=default(None))常用参数说明n_jobs:int, 默认:None并发运行作业的最大数量,例如当backend='multiprocessing'时Python工作进程的数量,或者当backend='threading'时线程池的大小。如果设置为 -1,则使用所有CPU。如果设置为1,则根本不使用并行计算代码,并且行为相当于一个简单的python for循环。此模式与timeout不兼容。如果n_jobs小于-1,则使用(n_cpus+1+n_jobs)。因此,如果n_jobs=-2,将使用除一个CPU之外的所有CPU。如果为None,则默认n_jobs=1,除非在parallel_backend()上下文管理器下执行调用,此时会为n_jobs设置另一个值。backend: str,ParallelBackendBase实例或者None, 默认:'loky'指定并行化后端实现。支持的后端有:loky 在与工作Python进程交换输入和输出数据时,默认使用的loky可能会导致一些通信和内存开销。在一些罕见的系统(如Pyiode)上,loky后端可能不可用。multiprocessing 以前基于进程的后端,基于multiprocessing.Pool。不如loky健壮。threading 是一个开销很低的后端,但如果被调用的函数大量依赖于Python对象,它会受到Python GIL的影响。当执行瓶颈是显式释放GIL的已编译扩展时,threading最有用(例如,with-nogil块中封装的Cython循环或对NumPy等库的昂贵调用)。最后,可以通过调用register_pallel_backend()来注册后端。不建议在类库中调用Parallel时对backend名称进行硬编码,取而代之,建议设置软提示(prefer)或硬约束(require),以便库用户可以使用parallel_backend()上下文管理器从外部更改backend。return_generator: bool如果为True,则对此实例的调用将返回一个生成器,并在结果可获取时立即按原始顺序返回结果。请注意,预期用途是一次运行一个调用。对同一个Parallel对象的多次调用将导致RuntimeErrorprefer: str 可选值 ‘processes’, ‘threads’ ,None, 默认: None如果使用parallel_backen()上下文管理器时没有指定特定后端,则选择默认prefer给定值。默认的基于进程的后端是loky,而默认的基于线程的后端则是threading。如果指定了backend参数,则忽略该参数。require: ‘sharedmem’ 或者None, 默认None用于选择后端的硬约束。如果设置为'sharedmem',则所选后端将是单主机和基于线程的,即使用户要求使用具有parallel_backend的非基于线程的后端。参考文档https://joblib.readthedocs.io/en/latest/https://joblib.readthedocs.io/https://joblib.readthedocs.io/en/latest/parallel.html#common-usage
查看死锁SELECT s.sid "会话ID", s.lockwait "等待锁", s.event "等待的资源/事件", -- 最近等待或正在等待的资源/事件 DECODE(lo.locked_mode, 0, '尚未获得锁', 1, NULL, 2, '行共享锁', 3, '行排它锁', 4, '共享表锁', 5,'共享行排它锁',6, '排它表锁') "锁模式", do.object_name "被锁对象", s.status "会话状态", sq.SQL_TEXT, sq.SQL_FULLTEXT, sq.executions "SQL执行次数", ROUND(sq.elapsed_time/1000000, 2) "SQL执行时间(秒)", DECODE(sq.executions,0,'-',NULL,'-',ROUND(sq.elapsed_time/1000000/sq.executions, 2)) "SQL平均执行时间(秒)", DECODE(sq.executions,0,'-',NULL,'-',ROUND(sq.rows_processed/sq.executions, 2)) "平均返回行数", s.sql_exec_start "SQL开始执行时间", sq.last_active_time "查询计划最后活跃时间", lo.process "操作系统进程ID", s.port "进程端口号", s.program "进程名称", lo.os_user_name "操作系统用户名", s.machine "操作系统机器名称", 'ALTER SYSTEM KILL SESSSION '''||s.sid||','||s.serial#||''';' "终止会话操作" FROM v$sql sq JOIN v$session s on s.sql_hash_value = sq.hash_value JOIN v$locked_object lo on lo.session_id = s.sid JOIN dba_objects do on do.object_id = lo.object_id WHERE s.username='OPT_WMS_USER' ; -- Oracle用户名称,大写说明:如果lockwait值不为空(形如0000001F83D6C748),并且status为ACTIVE,则说明存在死锁event最近等待或正在等待的资源/事件:enq: TX - row lock contention :按模式6等待TX:当会话等待另一个会话已持有的行级锁时发生该事件,即某个用户正在更新、删除另一个会话希望更新、删除的行时,会发生这种情况。这种类型的TX排队等待对应于等待事件 enq:TX - row lock contention。解决方案:已经持有锁的第一个会话执行提交或回查看慢查询查询执行最慢的SQLSELECT * FROM ( SELECT s.sql_text, --s.sql_fulltext, 注释掉该列,可以加快查询速度(如果需要查询完整sql文本,可以考虑通过sql_id二次查询) s.sql_id, s.executions "执行次数", ROUND(s.elapsed_time / 1000000, 2) "总执行时间(秒)", ROUND(s.elapsed_time / 1000000 / s.executions, 2) "平均执行时间", --单位:秒 s.first_load_time "父游标创建时间", s.parsing_user_id "用户id", u.username "用户名" FROM v$sqlarea s LEFT JOIN all_users u ON s.parsing_user_id = u.user_id WHERE s.executions > 0 AND u.username = 'OPT_WMS_USER' --注意 用户名大写 ORDER BY 平均执行时间 DESC) WHERE rownum <= 50说明:为什么不从v$sql统计信息?这是因为即便相同的SQL,每次执行耗时也可能不一样,所以,考虑求平均值,所以需要对SQL分组统计,SQL_TEXT相同,大概率为同一条SQL,所以考虑从按SQL_TEXT分组统计的v$sqlarea读取信息。当然,出于严谨的考虑,也可以不分组统计,把v$sqlarea替换成v$sql就好了。查询SQL执行次数,按次数降序排序SELECT * FROM ( SELECT s.sql_text, --s.sql_fulltext, s.sql_id, s.executions "执行次数", s.last_active_time "最后执行时间", s.first_load_time "父游标创建时间", s.parsing_user_id "执行用户id", u.username "执行用户", RANK() OVER(ORDER BY executions DESC) executions_rank FROM v$sql s LEFT JOIN all_users u ON u.user_id = s.parsing_user_id) T WHERE executions_rank <= 100;注意:之所以从v$sql获取统计数据,是因为这里未对SQL_TEXT做GROUP BY(SQL_TEXT是完整SQL文本前1000个字符,存在截断的可能,按在这个统计可能不准确),就针对每条SQL(不管是否相同)单独统计,当然,也可以考虑按SQL_TEXT分组统计,把v$sql改成v$sqlarea就好了。查看存在TABLE ACCESS FULL行为的SQLSELECT s.sql_text, s.sql_fulltext, sp.sql_id FROM v$sql_plan sp LEFT JOIN v$sql s on sp.sql_id = s.sql_id WHERE sp.operation = 'TABLE ACCESS' AND sp.options = 'FULL' AND sp.object_owner = 'OPT_WMS_USER_B' --注意 用户名大写 --可选查询条件V$SQLV$SQL列出了关于共享SQL区,不含GROUP BY子句的统计,输入的原始SQL文本的每个子项各占一行。通常在查询执行结束时更新V$SQL中展示的统计信息,然而针对长耗时查询,每5秒更新一次。这样更容易在运行期间查看长时间运行的SQL语句带来的影响ColumnDatatypeDescriptionSQL_TEXTVARCHAR2(1000)当前游标的SQL文本的前1000个字符SQL_FULLTEXTCLOB以<CLOB>方式展示的SQL语句全文。可使用该列检索SQL语句全文,而无需连接V$SQLTEXT动态性能视图。SQL_IDVARCHAR2(13)类库缓存中父游标的SQL标识。SHARABLE_MEMNUMBER子游标使用的共享内存量(字节为单位)PERSISTENT_MEMNUMBER子游标生存周期内使用的固定内存量(字节为单位)RUNTIME_MEMNUMBER子游标运行期间所需的固定内存量(字节为单位)SORTSNUMBER子游标完成的排序次数LOADED_VERSIONSNUMBER指示是否已加载上下文堆,1表示已加载,0表示未加载。OPEN_VERSIONSNUMBER指示子游标是否被锁定,1表示被锁定,0表示未被锁定USERS_OPENINGNUMBER任意子游标打开的用户数。FETCHESNUMBER与SQL语句关联的FETCHES的次数EXECUTIONSNUMBER自从对象被加载到类库缓存后,该对象被执行次数。PX_SERVERS_EXECUTIONSNUMBER并行执行服务器执行的总次数 ( 当语句从未被并行执行时为0)END_OF_FETCH_COUNTNUMBER游标被加载到类库缓存后,被完整执行的次数。当游标部分执行时,此统计值不会增加,不管是因为在执行过程中失败,还是在关闭或重新执行游标之前只提取了此游标生成的前几行。根据定义,END_of_FETCH_COUNT列的值应小于或等于EXECUTIONS列的值。USERS_EXECUTINGNUMBER执行语句的用户数LOADSNUMBER对象被加载或者重新加载的次数FIRST_LOAD_TIMEVARCHAR2(19)父游标的创建时间INVALIDATIONSNUMBER子游标无效的次数PARSE_CALLSNUMBER子游标的解析调用次数DISK_READSNUMBER子游标的磁盘读取次数DIRECT_WRITESNUMBER子游标的直接写次数BUFFER_GETSNUMBER子游标的获取缓存区次数APPLICATION_WAIT_TIMENUMBER应用等待时间(微秒为单位)CONCURRENCY_WAIT_TIMENUMBER并发等待时间(微秒为单位)CLUSTER_WAIT_TIMENUMBER集群等待时间(微秒为单位)USER_IO_WAIT_TIMENUMBER用户I/O等待时间(微秒为单位)PLSQL_EXEC_TIMENUMBERPL/SQL执行时间(微秒为单位)JAVA_EXEC_TIMENUMBERJava执行时间(微秒为单位)ROWS_PROCESSEDNUMBER已解析SQL语句返回的总行数COMMAND_TYPENUMBEROracle命令类型定义OPTIMIZER_MODEVARCHAR2(10)SQL语句执行模式(优化器模型)OPTIMIZER_COSTNUMBER优化器给出的查询成本PARSING_USER_IDNUMBER最初构建此子游标的用户IDPARSING_SCHEMA_IDNUMBER最初构建子游标使用的模式IDPARSING_SCHEMA_NAMEVARCHAR2(30)最初构建子游标使用的模式名称SERVICEVARCHAR2(64)服务名称SERVICE_HASHNUMBERSERVICE列展示的服务名称的哈希值MODULEVARCHAR2(64)SQL语句第一次被解析时正在执行的模块名称,该名称可通过调用DBMS_APPLICATION_INFO.SET_MODULE进行设置。MODULE_HASHNUMBERMODULE列展示的模块名称的哈希值SERIALIZABLE_ABORTSNUMBER每个游标事务序列化失败并产生ORA-08177错误的次数CPU_TIMENUMBER此游标用于解析,执行,获取(fetch)的CPU耗时 (以微秒为单位)ELAPSED_TIMENUMBER此游标用于解析,执行,获取的占用时间(以微秒为单位)。如果游标采用并行执行,则ELAPSED_TIME 为查询协调器及所有并行查询slave进程的累计占用时间。OBJECT_STATUSVARCHAR2(19)游标的状态:VALID - 有效,无错误授权 VALID_AUTH_ERROR - 有效,有授权错误授权。VALID_COMPILE_ERROR - 有效, 有编译错误授权。VALID_UNAUTH - 有效,未授权。INVALID_UNAUTH - 无效,未授权。INVALID - 无效,未授权,但保留时间戳。LAST_LOAD_TIMEVARCHAR2(19)查询计划被加载到类库缓存的时间IS_OBSOLETEVARCHAR2(1)指示游标是否已过时,是(Y) 或者否(N)。如果子游标的数量太大,则可能发生这种情况。LAST_ACTIVE_TIMEDATE查询计划最后活跃时间(即完成SQL解析的时间,可以当做SQL最后执行的时间)IO_INTERCONNECT_BYTESNUMBEROracle 数据库和存储系统之间交换的I/O字节数。PHYSICAL_READ_REQUESTSNUMBER被监控SQL发起的物理读I/O请求PHYSICAL_READ_BYTESNUMBER被监控SQL从磁盘读取的字节数。PHYSICAL_WRITE_REQUESTSNUMBER被监控SQL发起的物理写I/O请求。PHYSICAL_WRITE_BYTESNUMBER被监控SQL写入磁盘的字节数OPTIMIZED_PHY_READ_REQUESTSNUMBER被监控SQL从数据库智能闪存缓存发起的物理读取I/O请求数LOCKED_TOTALNUMBER子游标被锁定的总次数V$SQLAREA显示共享SQL区域的统计信息,每条SQL字符串为一行。它提供内存中、已解析并准备执行的SQL语句的统计信息。V$SQLAREA和V$SQL两个视图的不同之处在于,V$SQL中为每一条SQL保留一个条目,而V$SQLAREA中根据SQL_TEXT进行GROUP BY,通过VERSION_COUNT计算子指针的个数V$SESSIONV$SESSION 显示当前会话的会话信息,常见视图字段及字段描述说明如下:列描述SID会话IDSERIAL#会话序列号。用于唯一标识会话的对象。如果会话结束,而另一个会话以相同的会话ID开始,则保证将会话级命令应用于当前会话的对象。USER#Oracle用户IDUSERNAMEOracle用户名称COMMAND正在执行的命令(解析的最后一条语句)。可以通过运行以下SQL查询来查找此COMMAND列中返回的任何值 n 的命令名:SELECT Command_name FROM v$sqlcommand WHERE command_type=n COMMAND”列值为 0,则表示该命令未记录在V$SESSION中。LOCKWAIT会话正在等待的锁的地址。NULL值表示没有等待锁。STATUS会话状态:ACTIVE-会话当前正在执行SQL,INACTIVE-处于非活动状态且没有配置限制或尚未超过配置的限制的会话。KILLED-标记为被终止的会话。CACHED-为Oracle XA临时缓存的会话。SNIPED-超出某些配置限制(例如,为资源管理器消费者组指定的资源限制或用户配置文件中指定的idle_time)的非活动会话。此类会话将不允许再次激活。SCHEMA#Schema用户IDSCHEMANAMESchema用户名称OSUSER操作系统客户端用户名称PROCESS操作系统客户端进程IDMACHINE操作系统机器名称PORT客户端进程端口号TERMINAL操作系统终端名称PROGRAM操作系统进程名称TYPE会话类型SQL_HASH_VALUE配合 SQL_HASH_VALUE使用,用于标识当前正在执行的SQL语句。SQL_ID当前正在执行的SQL语句的IDSQL_EXEC_START会话当前执行的SQL开始执行的时间;如果SQL_ID为NULL,则为NULLSQL_EXEC_IDSQL执行标识。 如果SQL_ID为NULL或者该SQL执行还未启动,则为NULLLAST_CALL_ET如果会话STATUS当前为ACTIVE,则该值表示自会话变为活动状态以来经过的时间(以秒为单位),如果会话STATUS当前为INACTIVE,则该值表示自会话变为非活动状态以来经过的时间(以秒为单位)EVENT如果会话当前正在等待,则为会话正在等待的资源或事件。如果会话不在等待中,则为会话最近等待的资源或事件。查阅: "Oracle Wait Events"V$LOCKED_OBJECTV$LOCKED_OBECT列出了系统上每个事务获取的所有锁。它显示了哪些会话在什么对象上以及在什么模式下持有DML锁(即TM类型的队列)。视图常见字段及描述如下:列描述OBJECT_ID正被锁住的对象IDSESSION_ID会话IDORACLE_USERNAMEOracle用户名OS_USER_NAME操作系统用户名PROCESS操作系统进程IDLOCKED_MODE锁模式。此列的数值映射到表锁的锁模式的这些文本值:0-无:请求但尚未获得的锁;1-NULL;2-ROWS_S(SS):行共享锁;3-Row_X(SX):行排它锁;4-Share(S):共享表锁;5-S/Row-X(SSX):共享行排它锁;6-独占(X):排它表锁。另请参阅:Oracle数据库概念,以获取有关表锁锁模式的更多信息SELECT object_id "被锁住的对象ID", locked_mode "锁模式", session_id "会话ID", oracle_username "Oracle用户名", os_user_name "操作系统用户名", process "操作系统进程ID" FROM V$LOCKED_OBJECT;参考连接https://docs.oracle.com/database/121/REFRN/GUID-2B9340D7-4AA8-4894-94C0-D5990F67BE75.htm#REFRN30246https://docs.oracle.com/database/121/REFRN/GUID-09D5169F-EE9E-4297-8E01-8D191D87BDF7.htm#REFRN30259https://docs.oracle.com/en/database/oracle/oracle-database/21/refrn/V-SESSION.htmlhttps://docs.oracle.com/en/database/oracle/oracle-database/21/refrn/V-LOCKED_OBJECT.html#GUID-3F9F26AA-197F-4D36-939E-FAF1EFD8C0DD
测试环境Python 3.6.2代码实现非多线程场景下使用新建并保存EXCELimport win32com.client from win32api import RGB def save_something_to_excel(result_file_path): excel_app = win32com.client.Dispatch('Excel.Application') excel_app.Visible = False # 设置进程界面是否可见 False表示后台运行 excel_app.DisplayAlerts = False # 设置是否显示警告和消息框 book = excel_app.Workbooks.Add() # 添加Excel工作簿 sheet = excel_app.Worksheets(1) # 获取第一个Sheet sheet.name = '汇总统计' # 设置Sheet名称 sheet.Columns.ColumnWidth = 10 # 设置所有列列宽 sheet.Columns(1).ColumnWidth = 20 # 设置第1列列宽 sheet.Rows.RowHeight = 15 # 设置所有行高 sheet.Rows(1).RowHeight = 20 # 设置第一行行高 usedRange = sheet.UsedRange # 获取sheet的已使用范围 rows = usedRange.Rows.Count # 获取已使用范围的最大行数,初始值为 1 cols = usedRange.Columns.Count # 获取已使用范围的最大列数,初始值为 1 print(rows, cols) # 输出 1 1 usedRange.Rows.RowHeight = 30 # 设置已使用范围内的行高 usedRange.Columns.ColumnWidth = 30 # 设置已使用范围内的列宽 # do something ... row_index = 1 for index, item in enumerate(['日期', '请求方法', 'URL', '调用次数']): # 单元格赋值 sheet.Cells(row_index, col_index).Value = 目标值 row_index, col_index 起始值为1 sheet.Cells(row_index, index + 1).Value = item row_index += 1 # do something else ... usedRange = sheet.UsedRange rows = usedRange.Rows.Count cols = usedRange.Columns.Count print(rows, cols) # 输出 1 4 sheet.Cells(1, 2).Font.Size = 29 # 设置单元格字体大小 sheet.Cells(1, 2).Font.Bold = True # 字体是否加粗 True 表示加粗,False 表示不加粗 sheet.Cells(2, 2).Font.Name = "微软雅黑" # 设置字体名称 # sheet.Cells(2, 2).Font.Color = RGB(0, 0, 255) # 设置字体颜色 # 不起作用 sheet2 = excel_app.Worksheets.Add() # 添加Sheet页 sheet2.Activate # 设置默认选中的sheet为sheet2 sheet3 = excel_app.Worksheets.Add() #注意,Move操作,会将被移动的表单(本例中的sheet)设置为默认选中状态,也就是说覆盖 sheet.Activate所做的变更 sheet.Move(sheet3, None) # 将sheet移动到sheet3之前 book.SaveAs(result_file_path) # 注意:结果文件路径必须是绝对路径 book.Close() # 关闭工作簿 excel_app.Quit() # 退出 if __name__ == '__main__': save_something_to_excel('D:\\codePojects\\logStatistics\\result\\result.xlsx')了解更多API,可以查看参考连接读取现有EXCELimport win32com.client def read_something_from_excel(excel_file_path): excel_app = win32com.client.Dispatch('Excel.Application') excel_app.Visible = False excel_app.DisplayAlerts = False book = excel_app.Workbooks.Open(result_file_path, False, True, None, None) # 打开工作簿 # do something ... sheet = excel_app.Worksheets(1) print(sheet.name) print(sheet.Cells(1, 1).Value) book.SaveAs(result_file_path) # 注意:结果文件路径必须是绝对路径 book.Close() # 关闭工作簿 excel_app.Quit() # 退出 if __name__ == '__main__': read_something_from_excel('D:\\codePojects\\logStatistics\\result\\result.xlsx')多线程场景下使用import threading import win32com.client import pythoncom def save_something_to_excel(result_file_path): pythoncom.CoInitialize() excel_app = win32com.client.DispatchEx('Excel.Application') # excel_app = win32com.client.Dispatch('Excel.Application') excel_app.Visible = False excel_app.DisplayAlerts = False book = excel_app.Workbooks.Add() sheet = excel_app.Worksheets(1) sheet.name = '汇总统计' row_index = 1 for index, item in enumerate(['日期', '请求方法', 'URL', '调用次数']): sheet.Cells(row_index, index + 1).Value = item row_index += 1 book.SaveAs(result_file_path) book.Close() excel_app.Quit() pythoncom.CoUninitialize() # 释放资源 if __name__ == '__main__': for i in range(3): file_path = 'D:\\codePojects\\logStatistics\\result\\result%s.xlsx' % i thread = threading.Thread(target=save_something_to_excel, args=(file_path,)) thread.start()说明:如果不添加以下代码行:pythoncom.CoInitialize()会报错,如下:pywintypes.com_error: (-2147221008, '尚未调用 CoInitialize。', None, None)建议使用excel_app = win32com.client.DispatchEx('Excel.Application')替代# excel_app = win32com.client.Dispatch('Excel.Application')实践发现,多线程的情况下,使用Dispatch会出现报错,原因似乎是Dispatch若发现进程已经存在的话,就不会创建新的进程。若不创建新的进程,有些操作会有冲突,可能会影响到已经打开的文件。参考连接https://learn.microsoft.com/zh-cn/office/vba/api/excel.font.colorhttps://blog.csdn.net/qq_25176745/article/details/125085819
实践环境Odoo 14.0-20221212 (Community Edition)需求描述如下图(非实际项目界面截图,仅用于介绍本文主题),打开记录详情页(form视图),点击某个按钮(图中的"选取ffers"按钮),弹出一个向导(wizard)界面,并将详情页中内联tree视图("Offers" Tab页)的列表记录展示到向导界面,且要支持复选框,用于选取目标记录,然执行目标操作。详情页所属模型EstatePropertyclass EstateProperty(models.Model): _name = 'estate.property' _description = 'estate property table' # ... 略 offer_ids = fields.One2many("estate.property.offer", "property_id", string="PropertyOffer") def action_do_something(self, args): # do something print(args)OffersTab页Tree列表所属模型EstatePropertyOfferclass EstatePropertyOffer(models.Model): _name = 'estate.property.offer' _description = 'estate property offer' # ... 略 property_id = fields.Many2one('estate.property', required=True)代码实现代码组织结构为了更好的介绍本文主题,下文给出了项目文件大致组织结构(为了让大家看得更清楚,仅保留关键文件)odoo14 ├─custom │ ├─estate │ │ │ __init__.py │ │ │ __manifest__.py │ │ │ │ │ ├─models │ │ │ estate_property.py │ │ │ estate_property_offer.py │ │ │ __init__.py │ │ │ │ │ ├─security │ │ │ ir.model.access.csv │ │ │ │ │ ├─static │ │ │ │ │ │ │ └─src │ │ │ │ │ │ │ └─js │ │ │ list_renderer.js │ │ │ │ │ ├─views │ │ │ estate_property_offer_views.xml │ │ │ estate_property_views.xml │ │ │ webclient_templates.xml │ │ │ │ │ └─wizards │ │ demo_wizard.py │ │ demo_wizard_views.xml │ │ __init__.py │ │ ├─odoo │ │ api.py │ │ exceptions.py │ │ ...略 │ │ __init__.py │ │ │ ├─addons │ │ │ __init__.py │ ...略 ...略wizard简介wizard(向导)通过动态表单描述与用户(或对话框)的交互会话。向导只是一个继承TransientModel而非model的模型。TransientModel类扩展Model并重用其所有现有机制,具有以下特殊性:wizard记录不是永久的;它们在一定时间后自动从数据库中删除。这就是为什么它们被称为瞬态(transient)。wizard可以通过关系字段(many2one或many2many)引用常规记录或wizard记录,但常规记录不能通过many2one字段引用wizard记录详细代码注意:为了更清楚的表达本文主题,代码文件中部分代码已略去wizard实现odoo14\custom\estate\wizards\demo_wizard.py实现版本1#!/usr/bin/env python # -*- coding:utf-8 -*- import logging from odoo import models,fields,api from odoo.exceptions import UserError _logger = logging.getLogger(__name__) class DemoWizard(models.TransientModel): _name = 'demo.wizard' _description = 'demo wizard' property_id = fields.Many2one('estate.property', string='property') offer_ids = fields.One2many(related='property_id.offer_ids') def action_confirm(self): '''选中记录后,点击确认按钮,执行的操作''' #### 根据需要对获取的数据做相应处理 # ... 获取数据,代码略(假设获取的数据存放在 data 变量中) record_ids = [] for id, value_dict in data.items(): record_ids.append(value_dict.get('data', {}).get('id')) if not record_ids: raise UserError('请选择记录') self.property_id.action_do_something(record_ids) return True @api.model def action_select_records_via_checkbox(self, args): '''通过wizard窗口界面复选框选取记录时触发的操作 @params: args 为字典 ''' # ...存储收到的数据(假设仅存储data部分的数据),代码略 return True # 注意,执行成功则需要配合前端实现,返回True @api.model def default_get(self, fields_list): '''获取wizard 窗口界面默认值,包括记录列表 #因为使用了@api.model修饰符,self为空记录集,所以不能通过self.fieldName = value 的方式赋值''' res = super(DemoWizard, self).default_get(fields_list) record_ids = self.env.context.get('active_ids') # 获取当前记录ID列表(当前记录详情页所属记录ID列表) # self.env.context.get('active_id') # 获取当前记录ID property = self.env['estate.property'].browse(record_ids) res['property_id'] = property.id offer_ids = property.offer_ids.mapped('id') res['offer_ids'] = [(6, 0, offer_ids)] return res说明:注意,不能使用类属性来接收数据,因为类属性供所有对象共享,会相互影响,数据错乱。action_select_records_via_checkbox函数接收的args参数,其类型为字典,形如以下,其中f412cde5-1e5b-408c-8fc0-1841b9f9e4de为UUID,供web端使用,用于区分不同页面操作的数据,'estate.property.offer_3'为供web端使用的记录ID,'data'键值代表记录的数据,其id键值代表记录在数据库中的主键id,context键值代表记录的上下文。arg数据格式为:{'uuid':{'recordID1':{'data': {}, 'context':{}}, 'recordID2': {'data': {}, 'context':{}}}}{'f412cde5-1e5b-408c-8fc0-1841b9f9e4de': {'estate.property.offer_3': {'data': {'price': 30000, 'partner_id': {'context': {}, 'count': 0, 'data': {'display_name': 'Azure Interior, Brandon Freeman', 'id': 26}, 'domain': [], 'fields': {'display_name': {'type': 'char'}, 'id': {'type': 'integer'}}, 'id': 'res.partner_4', 'limit': 1, 'model': 'res.partner', 'offset': -1, 'ref': 26, 'res_ids': [], 'specialData': {}, 'type': 'record', 'res_id': 26}, 'validity': 7, 'date_deadline': '2022-12-30', 'status': 'Accepted', 'id': 21}, 'context': {'lang': 'en_US', 'tz': 'Europe/Brussels', 'uid': 2, 'allowed_company_ids': [1], 'params': {'action': 85, 'cids': 1, 'id': 41, 'menu_id': 70, 'model': 'estate.property', 'view_type': 'form'}, 'active_model': 'estate.property', 'active_id': 41, 'active_ids': [41], 'property_pk_id': 41}}}}实现版本2#!/usr/bin/env python # -*- coding:utf-8 -*- import uuid import logging from odoo import models, fields, api from odoo.exceptions import UserError, ValidationError, MissingError _logger = logging.getLogger(__name__) class DemoWizard(models.TransientModel): _name = 'demo.wizard' _description = 'demo wizard' property_id = fields.Many2one('estate.property', string='property') property_pk_id = fields.Integer(related='property_id.id') # 用于action_confirm中获取property offer_ids = fields.One2many(related='property_id.offer_ids') @api.model def action_confirm(self, data:dict): '''选中记录后,点击确认按钮,执行的操作''' #### 根据需要对获取的数据做相应处理 record_ids = [] for id, value_dict in data.items(): record_ids.append(value_dict.get('data', {}).get('id')) if not record_ids: raise UserError('请选择记录') property_pk_id = None for id, value_dict in data.items(): property_pk_id = value_dict.get('context', {}).get('property_pk_id') break if not property_pk_id: raise ValidationError('do something fail') property = self.env['estate.property'].browse([property_pk_id]) # 注意,,所以,这里不能再通过self.property_id获取了 if property.exists(): property.action_do_something(record_ids) else: raise MissingError('do something fail:当前property记录(id=%s)不存在' % property_pk_id) return True @api.model def default_get(self, fields_list): '''获取wizard 窗口界面默认值,包括记录列表''' res = super(DemoWizard, self).default_get(fields_list) record_ids = self.env.context.get('active_ids') property = self.env['estate.property'].browse(record_ids) res['property_id'] = property.id res['property_pk_id'] = property.id offer_ids = property.offer_ids.mapped('id') res['offer_ids'] = [(6, 0, offer_ids)] return resodoo14\custom\estate\wizards\__init__.py#!/usr/bin/env python # -*- coding:utf-8 -*- from . import demo_wizardodoo14\custom\estate\__init__.py#!/usr/bin/env python # -*- coding:utf-8 -*- from . import models from . import wizardsodoo14\custom\estate\wizards\demo_wizard_views.xml实现版本1对应demo_wizard.py实现版本1<?xml version="1.0" encoding="UTF-8"?> <odoo> <data> <record id="demo_wizard_view_form" model="ir.ui.view"> <field name="name">demo.wizard.form</field> <field name="model">demo.wizard</field> <field name="arch" type="xml"> <form> <field name="offer_ids"> <tree hasCheckBoxes="true" modelName="demo.wizard" modelMethod="action_select_records_via_checkbox" jsMethodOnModelMethodDone="enableActionConfirmButton()" jsMethodOnToggleCheckbox="disableActionConfirmButton()"> <field name="price" string="Price"/> <field name="partner_id" string="partner ID"/> <field name="validity" string="Validity(days)"/> <field name="date_deadline" string="Deadline"/> <button name="action_accept_offer" string="" type="object" icon="fa-check" attrs="{'invisible': [('status', 'in', ['Accepted','Refused'])]}"/> <button name="action_refuse_offer" string="" type="object" icon="fa-times" attrs="{'invisible': [('status', 'in', ['Accepted','Refused'])]}"/> <field name="status" string="Status"/> </tree> </field> <footer> <button name="action_confirm" type="object" string="确认(do something you want)" class="oe_highlight"/> <button string="取消" class="oe_link" special="cancel"/> </footer> </form> </field> </record> <record id="action_demo_wizard" model="ir.actions.act_window"> <field name="name">选取offers</field> <field name="res_model">demo.wizard</field> <field name="type">ir.actions.act_window</field> <field name="view_mode">form</field> <field name="target">new</field> </record> </data> </odoo>说明:<tree hasCheckBoxes="true" modelName="demo.wizard" modelMethod="action_select_records_via_checkbox" jsMethodOnModelMethodDone="enableActionConfirmButton()" jsMethodOnToggleCheckbox="disableActionConfirmButton()">hasCheckBoxes 设置"true",则显示复选框。以下属性皆在hasCheckBoxes 为"true"的情况下起作用。modelName 点击列表复选框时,需要访问的模型名称,需要配合modelMethod方法使用,缺一不可。可选modelMethod 点击列表复选框时,需要调用的模型方法,通过该方法收集列表勾选记录的数据。可选。jsMethodOnModelMethodDone 定义modelMethod方法执行完成后,需要调用的javascript方法(注意,包括参数,如果没有参数则写成(),形如 jsMethod())。可选。jsMethodOnToggleCheckbox 定义点击列表复选框时需要调用的javascript方法,比modelMethod优先执行(注意,包括参数,如果没有参数则写成(),形如 jsMethod())。可选。以上参数同下文saveSelectionsToSessionStorage 参数可同时共存如果需要将action绑定到指定模型指定视图的Action,可以在ir.actions.act_window定义中添加binding_model_id和binding_view_types字段,如下:<record id="action_demo_wizard" model="ir.actions.act_window"> <field name="name">选取offers</field> <field name="res_model">demo.wizard</field> <field name="type">ir.actions.act_window</field> <field name="view_mode">form</field> <field name="target">new</field> <!-- 添加Action菜单 --> <field name="binding_model_id" ref="estate.model_estate_property"/> <field name="binding_view_types">form</field> </record>效果如下参考连接:https://www.odoo.com/documentation/14.0/zh_CN/developer/reference/addons/actions.html实现版本2对应demo_wizard.py实现版本2<?xml version="1.0" encoding="UTF-8"?> <odoo> <data> <record id="demo_wizard_view_form" model="ir.ui.view"> <field name="name">demo.wizard.form</field> <field name="model">demo.wizard</field> <field name="arch" type="xml"> <form> <field name="property_pk_id" invisible="1"/> <field name="offer_ids" context="{'property_pk_id': property_pk_id}"> <tree string="List" hasCheckBoxes="true" saveSelectionsToSessionStorage="true"> <field name="price" string="Price"/> <field name="partner_id" string="partner ID"/> <field name="validity" string="Validity(days)"/> <field name="date_deadline" string="Deadline"/> <button name="action_accept_offer" string="" type="object" icon="fa-check" attrs="{'invisible': [('status', 'in', ['Accepted','Refused'])]}"/> <button name="action_refuse_offer" string="" type="object" icon="fa-times" attrs="{'invisible': [('status', 'in', ['Accepted','Refused'])]}"/> <field name="status" string="Status"/> </tree> </field> <footer> <button name="action_confirm" onclick="do_confirm_action('demo.wizard','action_confirm')" string="确认(do something you want)" class="oe_highlight"/> <button string="取消" class="oe_link" special="cancel"/> </footer> </form> </field> </record> <record id="action_demo_wizard" model="ir.actions.act_window"> <field name="name">选取offers</field> <field name="res_model">demo.wizard</field> <field name="type">ir.actions.act_window</field> <field name="view_mode">form</field> <field name="target">new</field> </record> </data> </odoo>说明:saveSelectionsToSessionStorage 为"true"则表示点击复选框时,将当前选取的记录存到浏览器sessionStorage中,可选odoo14\custom\estate\security\ir.model.access.csvid,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink # ...略 access_demo_wizard_model,access_demo_wizard_model,model_demo_wizard,base.group_user,1,1,1,1注意:wizard模型也是需要添加模型访问权限配置的复选框及勾选数据获取实现大致思路通过继承web.ListRenderer实现自定义ListRenderer,进而实现复选框展示及勾选数据获取。odoo14\custom\estate\static\src\js\list_renderer.js注意:之所以将uuid函数定义在list_renderer.js中,是为了避免因为js顺序加载问题,可能导致加载list_renderer.js时找不到uuid函数定义问题。function uuid() { var s = []; var hexDigits = "0123456789abcdef"; for (var i = 0; i < 36; i++) { s[i] = hexDigits.substr(Math.floor(Math.random() * 0x10), 1); } s[14] = "4"; // bits 12-15 of the time_hi_and_version field to 0010 s[19] = hexDigits.substr((s[19] & 0x3) | 0x8, 1); // bits 6-7 of the clock_seq_hi_and_reserved to 01 s[8] = s[13] = s[18] = s[23] = "-"; var uuid = s.join(""); return uuid; } odoo.define('estate.ListRenderer', function (require) { "use strict"; var ListRenderer = require('web.ListRenderer'); ListRenderer = ListRenderer.extend({ init: function (parent, state, params) { this._super.apply(this, arguments); this.hasCheckBoxes = false; if ('hasCheckBoxes' in params.arch.attrs && params.arch.attrs['hasCheckBoxes']) { this.objectID = uuid(); $(this).attr('id', this.objectID); this.hasCheckBoxes = true; this.hasSelectors = true; this.records = {}; // 存放当前界面记录 this.recordsSelected = {}; // 存放选取的记录 this.modelName = undefined; // 定义点击列表复选框时需要访问的模型 this.modelMethod = undefined; // 定义点击列表复选框时需要调用的模型方法 this.jsMethodOnModelMethodDone = undefined; // 定义modelMethod方法执行完成后,需要调用的javascript方法 this.jsMethodOnToggleCheckbox = undefined; // 定义点击列表复选框时需要调用的javascript方法,比modelMethod优先执行 if ('modelName' in params.arch.attrs && params.arch.attrs['modelName']) { this.modelName = params.arch.attrs['modelName']; } if ('modelMethod' in params.arch.attrs && params.arch.attrs['modelMethod']) { this.modelMethod = params.arch.attrs['modelMethod']; } if ('jsMethodOnModelMethodDone' in params.arch.attrs && params.arch.attrs['jsMethodOnModelMethodDone']){ this.jsMethodOnModelMethodDone = params.arch.attrs['jsMethodOnModelMethodDone']; } if ('jsMethodOnToggleCheckbox' in params.arch.attrs && params.arch.attrs['jsMethodOnToggleCheckbox']) { this.jsMethodOnToggleCheckbox = params.arch.attrs['jsMethodOnToggleCheckbox']; } if ('saveSelectionsToSessionStorage' in params.arch.attrs && params.arch.attrs['saveSelectionsToSessionStorage']) { this.saveSelectionsToSessionStorage = params.arch.attrs['saveSelectionsToSessionStorage']; } } }, // _onToggleSelection: function (ev) { // 点击列表表头的全选/取消全选复选框时会调用该函数 // this._super.apply(this, arguments); // }, _onToggleCheckbox: function (ev) { if (this.hasCheckBoxes) { var classOfEvTarget = $(ev.target).attr('class'); /* cstom-control-input 刚好点中复选框input, custom-control custom-checkbox 刚好点中复选框input的父元素div o_list_record_selector 点击到复选框外上述div的父元素*/ if (['custom-control custom-checkbox', 'custom-control-input', 'o_list_record_selector'].includes(classOfEvTarget)){ if (this.jsMethodOnToggleCheckbox) { eval(this.jsMethodOnToggleCheckbox) } var id = $(ev.currentTarget).closest('tr').data('id'); // 'custom-control-input' == classOfEvTarget var checked = !this.$(ev.currentTarget).find('input').prop('checked') // 获取复选框是否框选 'custom-control-input' != classOfEvTarget if ('custom-control-input' == classOfEvTarget) { checked = this.$(ev.currentTarget).find('input').prop('checked') } if (id == undefined) { if (checked == true) { // 全选 this.recordsSelected = JSON.parse(JSON.stringify(this.records)); } else { // 取消全选 this.recordsSelected = {}; } } else { if (checked == true) { // 勾选单条记录 this.recordsSelected[id] = this.records[id]; } else { // 取消勾选单条记录 delete this.recordsSelected[id]; } } if (this.saveSelectionsToSessionStorage) { window.sessionStorage[this.objectID] = JSON.stringify(this.recordsSelected); } // 通过rpc请求模型方法,用于传输界面勾选的记录数据 if (this.modelName && this.modelMethod) { self = this; this._rpc({ model: this.modelName, method: this.modelMethod, args: [this.recordsSelected], }).then(function (res) { if (self.jsMethodOnModelMethodDone) { eval(self.jsMethodOnModelMethodDone); } }); } } } this._super.apply(this, arguments); }, _renderRow: function (record) { // 打开列表页时会渲染行,此时存储渲染的记录 if (this.hasCheckBoxes) { this.records[record.id] = {'data': record.data, 'context': record.context}; } return this._super.apply(this, arguments); } }); odoo.__DEBUG__['services']['web.ListRenderer'] = ListRenderer; //覆盖原有的ListRender服务 });实践过程中,有尝试过以下实现方案,视图通过指定相同服务ID web.ListRenderer来覆盖框架自带的web.ListRenderer定义,这种实现方案只能在非Debug模式下正常工作,且会导致无法开启Debug模式,odoo.define实现中会对服务是否重复定义做判断,如果重复定义则会抛出JavaScript异常。odoo.define('web.ListRenderer', function (require) { "use strict"; //...略,同上述代码 // odoo.__DEBUG__['services']['web.ListRenderer'] = ListRenderer; return ListRenderer; });笔者后面发现,可以使用include替代extend方法修改现有的web.ListRenderer,如下odoo.define('estate.ListRenderer', function (require) { "use strict"; var ListRenderer = require('web.ListRenderer'); ListRenderer = ListRenderer.include({//...略,同上述代码}); // odoo.__DEBUG__['services']['web.ListRenderer'] = ListRenderer; //不需要添加这行代码了 });odoo14\custom\estate\static\src\js\demo_wizard_views.js实现版本1供demo_wizard_views.xml实现版本1使用function disableActionConfirmButton(){ // 禁用按钮 $("button[name='action_confirm']").attr("disabled", true); } function enableActionConfirmButton(){ // 启用按钮 $("button[name='action_confirm']").attr("disabled", false); }这里的设计是,执行复选框操作时,先禁用按钮,不允许执行确认操作,因为执行复选框触发的请求可能没那么快执行完成,前端数据可能没完全传递给后端,此时去执行操作,可能会导致预期之外的结果。所以,等请求完成再启用按钮。实现版本2供demo_wizard_views.xml实现版本2使用function do_confirm_action(modelName, modelMethod, context){ $("button[name='action_confirm']").attr("disabled", true); // 点击按钮后,禁用按钮状态,比较重复点击导致重复发送请求 var wizard_dialog = $(event.currentTarget.offsetParent.parentElement.parentElement); var dataUUID = $(event.currentTarget.parentElement.parentElement.parentElement.parentElement).find('div.o_list_view').prop('id'); var rpc = odoo.__DEBUG__.services['web.rpc']; rpc.query({ model: modelName, method: modelMethod, args: [JSON.parse(window.sessionStorage.getItem(dataUUID) || '{}')] }).then(function (res) if (res == true) { wizard_dialog.css('display', 'none'); // 隐藏对话框 window.sessionStorage.removeItem(dataUUID); } else { $("button[name='action_confirm']").attr("disabled", false); } }).catch(function (err) { $("button[name='action_confirm']").attr("disabled", false); }); }odoo14\odoo\addons\base\rng\tree_view.rng可选操作。如果希望hasCheckBoxes,modelName,modelMethod等也可作用于非内联tree视图,则需要编辑该文件,添加hasCheckBoxes,modelName,modelMethod等属性,否则,更新应用的时候会报错。<?xml version="1.0" encoding="UTF-8"?> <rng:grammar xmlns:rng="http://relaxng.org/ns/structure/1.0" xmlns:a="http://relaxng.org/ns/annotation/1.0" datatypeLibrary="http://www.w3.org/2001/XMLSchema-datatypes"> <!-- ...此处内容已省略 --> <rng:define name="tree"> <rng:element name="tree"> <!-- ...此处内容已省略 --> <rng:optional><rng:attribute name="decoration-warning"/></rng:optional> <rng:optional><rng:attribute name="banner_route"/></rng:optional> <rng:optional><rng:attribute name="sample"/></rng:optional> <!--在此处添加新属性> <rng:optional><rng:attribute name="hasCheckBoxes"/></rng:optional> <rng:optional><rng:attribute name="modelName"/></rng:optional> <rng:optional><rng:attribute name="modelMethod"/></rng:optional> <rng:optional><rng:attribute name="jsMethodOnModelMethodDone"/></rng:optional> <rng:optional><rng:attribute name="jsMethodOnToggleCheckbox"/></rng:optional> <rng:optional><rng:attribute name="saveSelectionsToSessionStorage"/></rng:optional> <!-- ...此处内容已省略 --> </rng:element> </rng:define> <!-- ...此处内容已省略 --> </rng:grammar>odoo14\custom\estate\views\webclient_templates.xml用于加载自定义js<?xml version="1.0" encoding="utf-8"?> <odoo> <template id="assets_common" inherit_id="web.assets_common" name="Backend Assets (used in backend interface)"> <xpath expr="//script[last()]" position="after"> <script type="text/javascript" src="/estate/static/src/js/list_renderer.js"></script> <script type="text/javascript" src="/estate/static/src/js/demo_wizard_views.js"></script> </xpath> </template> </odoo>odoo14\custom\estate\__manifest__.py加载自定义模板文件,进而实现自定义js文件的加载#!/usr/bin/env python # -*- coding:utf-8 -*- { 'name': 'estate', 'depends': ['base'], 'data':[ 'views/webclient_templates.xml', 'security/ir.model.access.csv', #...略 'wizards/demo_wizard_views.xml' 'views/estate_property_views.xml', 'views/estate_property_offer_views.xml', ] }记录详情页视图实现odoo14\custom\estate\views\estate_property_views.xml<?xml version="1.0"?> <odoo> <!--...略--> <record id="estate_property_view_form" model="ir.ui.view"> <field name="name">estate.property.form</field> <field name="model">estate.property</field> <field name="arch" type="xml"> <form string="estate property form"> <header> <button name="%(action_demo_wizard)d" type="action" string="选取offers" class="oe_highlight"/> <!--...略--> </header> <sheet> <!--...略--> <notebook> <!--...略--> <page string="Offers"> <field name="offer_ids" attrs="{'readonly': [('state', 'in', ['Offer Accepted','Sold','Canceled'])]}"/> </page> <!--...略--> </notebook> </sheet> </form> </field> </record> </odoo>说明:class="oe_highlight" 设置按钮高亮显示参考连接https://blog.csdn.net/CBGCampus/article/details/128196983
QWeb简史到目前为止,我们的房地产模块的界面设计相当有限。构建列表视图很简单,因为只需要字段列表。表单视图也是如此:尽管使用了一些标记,如<group>或<page>,但在设计方面几乎没有什么可做的。然而,如果我们想给我们的应用程序一个独特的外观,就必须更进一步,能够设计新的视图。此外,PDF报告或网站页面等其他功能需要另一个更灵活的工具:模板引擎。您可能已经熟悉现有的引擎,如Jinja(Python)、ERB(Ruby) 或Twig(PHP)。Odoo自带内置引擎:QWeb模板。QWeb是Odoo使用的主要模板引擎。它是一个XML模板引擎,主要用于生成HTML片段和页面。你可能已经在Odoo见过 看板,其中的记录以卡片状结构显示。我们将为我们的房地产模块构建这样的视图。一个具体的示例: 一个看板视图参考: 本主题关联文档可以查看Kanban.目标: 本节结束时创建一个房产的看板视图在我们的地产应用程序中,我们希望添加一个看板视图来显示我们的房产。看板视图是标准的Odoo视图(如表单和列表视图),但其结构更灵活。事实上,每张卡片的结构是表单元素(包括基本HTML)和QWeb的混合。看板视图的定义与列表视图和表单视图的定义相似,只是它们的根元素是kanban。看板视图最简单的形式如下:<kanban> <templates> <t t-name="kanban-box"> <div class="oe_kanban_global_click"> <field name="name"/> </div> </t> </templates> </kanban>让我们分解一下这个例子:<templates>:定义QWeb 模板列表。看板视图必须至少定义一个根模板kanban-box,每个记录将呈现一次。<t t-name="kanban-box">:<t>是QWeb指令的占位符元素。在本例中,它用于将模板的name设置为kanban-box<div class="oe_kanban_global_click">:oe_kanban_global_click让<div>可点击,以打开记录<field name="name"/>:这向视图中添加name字段。练习--制作一个最小的看版视图根据上述提供的简单例子,为房产创建一个最小化的看板视图。唯一展示的字段为name.提示: 必须在ir.actions.act_window对应的view_mode中添加 kanban修改odoo14\custom\estate\views\estate_property_views.xml(注意:以下未展示文件中的所有内容,其它内容保持不变)<record id="link_estate_property_action" model="ir.actions.act_window"> <field name="name">Properties</field> <field name="res_model">estate.property</field> <field name="view_mode">kanban,tree,form</field><--本次改动新增kanban--> <field name="context">{'search_default_state': True}</field> </record> <!-- 本次新增 --> <record id="estate_property_kanban" model="ir.ui.view"> <field name="model">estate.property</field> <field name="arch" type="xml"> <kanban> <templates> <t t-name="kanban-box"> <div class="oe_kanban_global_click"> <field name="name"/> </div> </t> </templates> </kanban> </field> </record>重启服务验证一旦看板视图起作用, 我们可以开始改进它。如果我们想有条件的展示元素,可以使用 t-if指令(查看 Conditionals).<kanban> <field name="state"/> <templates> <t t-name="kanban-box"> <div class="oe_kanban_global_click"> <field name="name"/> </div> <div t-if="record.state.raw_value == 'new'"> This is new! </div> </t> </templates> </kanban>我们添加了几个东西:t-if: 如果条件为真,渲染<div>元素record: 拥有所有请求字段作为其属性的对象。每个字段都有两个属性 value 和raw_value。前者是根据当前用户参数格式化的,后者则是直接通过read()读取的。在上面的示例中,字段name被添加到<templates>元素中,但state在它之外。当我们需要字段的值但不想在视图中显示它时,可以将其添加到<templates>元素之外。练习--改善看板视图添加以下字段到看板视图:expected price, best price, selling price 和tags。注意:best price仅在收到报价时展示,而selling price仅在接受报价时展示修改odoo14\custom\estate\views\estate_property_views.xml estate_property_kanban<record id="estate_property_kanban" model="ir.ui.view"> <field name="model">estate.property</field> <field name="arch" type="xml"> <kanban> <field name="state"/> <templates> <t t-name="kanban-box"> <div class="oe_kanban_global_click"> <field name="name"/> <field name="expected_price"/> <!-- <field name="best_price" t-if="record.state.value == 'Offer Received'"/>--> <div t-if="record.state.value == 'Offer Received'"> <field name="best_price"/> </div> <div t-if="record.state.value == 'Offer Accepted'"> <field name="selling_price" /> </div> <field name="tag_ids"/> </div> </t> </templates> </kanban> </field> </record>注意:这里必须添加<field name="state"/>,否则界面会报类似以下错误:odoo TypeError: Cannot read properties of undefined (reading 'value')验证效果让我们对视图做最后的修改:默认情况下,财产必须按类型分组。您可能想看看Kanban中描述的各种选项。练习--添加默认分组使用合适的属性对房产分组,默认按类型分组。你必须阻止拖拽和删除。修改odoo14\custom\estate\views\estate_property_views.xml estate_property_kanban,给<kanban>增加属性<kanban default_group_by="state" records_draggable="false">验证效果看板视图是一个典型的例子,说明从现有视图开始并对其进行微调而不是从头开始总是一个好主意。参考链接https://www.odoo.com/documentation/14.0/zh_CN/developer/howtos/rdtraining.htmlhttps://fontawesome.dashgame.com/
模块交互在上一章中,我们使用继承来修改模块的行为。在我们的房地产场景中,我们希望更进一步,能够为客户生成发票。Odoo提供了一个开发票模块,因此直接从我们的房地产模块创建发票是很简单的,也就是说,一旦某个房产设置为“已售出”,就会在Invoicing应用程序中创建发票一个具体示例: 记账凭证(Account Move)目标: 本节结束时:创建一个estate_account 模块创建房产时,为购买者开发票预期效果动画地址:https://www.odoo.com/documentation/14.0/zh_CN/_images/create_inv.gif每当我们与另一个模块交互时,我们都需要记住模块化。如果我们打算将我们的应用程序卖给房地产代理,有些人可能想要发票功能,但有些人可能不想要。链接模块(Link Module)此类使用案例的常见方法是创建“链接”模块。在我们的案例中,该模块依赖estate 和account,包括房产的发票创建逻辑。采用这种方式,estate和account模块可以独立安装。当两者都安装后,链接模块将提供新功能。练习--创建链接模块创建依赖estate 和account 的 estate_account 空壳模块,创建以后安装该模块。你可能会注意到,Invoicing 应用也被安装了。这是意料之中的,因为你的模块依赖它。 如果你卸载Invoicing模块,你的模块也会被卸载。说明:__init__.py为空重启服务,安装模块创建发票是时候生成发票了。我们希望为estate.property模型添加功能,即我们希望在出售房产时添加一些额外的逻辑。第一步,我们需要扩点击“Sold”按钮时调用的操作。为此,我们需要在estate_account模块中为创建一个模型,继承estate.property模型。现在,重写操作,仅返回super调用,拿个例子来说可能更清楚:from odoo import models class InheritedModel(models.Model): _inherit = "inherited.model" def inherited_action(self): return super().inherited_action()可以在这找个具体的示例https://github.com/odoo/odoo/blob/f1f48cdaab3dd7847e8546ad9887f24a9e2ed4c1/addons/account/models/account_move.pyclass AccountMove(models.Model): _name = "account.move" _inherit = ['portal.mixin', 'mail.thread', 'mail.activity.mixin', 'sequence.mixin'] _description = "Journal Entry" #... 略 def action_invoice_paid(self): ''' Hook to be overrided called when the invoice moves to the paid state. ''' passclass AccountMove(models.Model): _inherit = 'account.move' def action_invoice_paid(self): """ When an invoice linked to a sales order selling registrations is paid confirm attendees. Attendees should indeed not be confirmed before full payment. """ res = super(AccountMove, self).action_invoice_paid() self.mapped('line_ids.sale_line_ids')._update_registrations(confirm=True, mark_as_paid=True) return res练习--添加创建发票的第一步在estate_account模块中的正确目录创建 estate_property.py 文件_inherit estate.property 模块重写 action_sold 方法(你可能已经将该方法命名为不同的名称了) 以返回 super 调用提示: 为了确保它正常工作,添加一个print 或者调试断点到重写的方法中。新增以下文件:odoo14\custom\estate_account\models\__init__.py#!/usr/bin/env python # -*- coding:utf-8 -*- from . import estate_propertyodoo14\custom\estate_account\models\estate_property.py#!/usr/bin/env python # -*- coding:utf-8 -*- from odoo import models class InheritedEstateProperty(models.Model): _inherit = "estate.property" def set_property_sold(self): return super().set_property_sold()修改odoo14\custom\estate_account\__init__.py#!/usr/bin/env python # -*- coding:utf-8 -*- from . import models它有效吗?如果没有,请检查是否正确导入了所有Python文件。如果重写生效,我们可以继续创建发票。不幸的是,没有一种简单的方法可以知道如何在Odoo中创建任何给定的对象。大多数时候,有必要查看其模型,以找到所需的字段并提供适当的值。学习的一个好方法是看看其他模块是如何完成你想做的事情的。例如,销售的一个基本流程是从销售订单创建发票。这看起来是一个很好的起点,因为它正是我们想要做的。花一些时间思考和理解创建发票方法。为了创建了发票,我们需要以下信息:一个 partner_id: 顾客一个move_type: 它有几个可能的值journal_id: the accounting journal这足够创建一个张空发票。练习--添加发票创建第二步重写action_sold,并创建一个空的 account.move :从当前的estate.property获取 partner_idmove_type 应该和Customer Invoice对应提示:使用 self.env[model_name].create(values)创建一个对象, 其中values 为一个字典。create 方法不接受结果集作为字段值。修改odoo14\custom\estate_account\models\estate_property.pydef set_property_sold(self): self.env['account.move'].create({}) return super().set_property_sold()当房产设置为“已售出”时,你现在应该在Invoiceing/customer/Invoices中创建一个新的客户发票。显然,到目前为止,我们没有任何发票行。要创建发票行,我们需要以下信息:name:发票行的描述quantityprice_unit此外,发票行需要链接到发票。将发票行链接到发票的最简单、最有效的方法是在创建发票时包含所有行。为此在account.move创建中包含invoice_line_ids字段,这是一个One2many字段。One2many和Many2many使用通用ORM方法中描述的特殊“commands”。这种格式是一个按顺序执行的三元组列表,其中每个三元组都是要对结果集执行的命令。下面是一个在创建test.model时包含一个One2many字段line_ids的简单示例:def inherited_action(self): self.env["test.model"].create( { "name": "Test", "line_ids": [ ( 0, 0, { "field_1": "value_1", "field_2": "value_2", }, ) ], } ) return super().inherited_action()练习--添加创建发票的第三步创建account.move时添加两个发票行。每个售出的房产都将按照以下条件开具发票:售价的6%额外100.00行政费提示:按照上面的示例在创建时添加invoice_line_ids。对于每个发票行,我们需要一个 name, quantity 和price_unit#!/usr/bin/env python # -*- coding:utf-8 -*- from odoo import models from odoo.exceptions import UserError class InheritedEstateProperty(models.Model): _inherit = "estate.property" def set_property_sold(self): print('override set_property_sold') journal = self.env['account.move'].with_context(default_move_type='out_invoice')._get_default_journal() if not journal: raise UserError('Please define an accounting sales journal for the company') self.env['account.move'].create({ 'move_type': 'out_invoice', 'partner_id': self.buyer_id, 'journal_id': journal.id, # company comes from the journal 'invoice_line_ids': [{ 'name': 'Avaliable house 01', 'quantity': 1, 'price_unit': 0.6 * self.best_price },{ 'name': ' Administrative fees', 'quantity': 1, 'price_unit': 100 }] }) return super().set_property_sold()重启服务,验证效果
继承(Inheritance)Odoo的一个强大方面是它的模块化。模块专用于业务需求,但模块也可以相互交互。这对于扩展现有模块的功能非常有用。例如,在我们的房地产场景中,我们希望在常规用户视图中直接显示销售人员的财产列表。在介绍特定的Odoo模块继承之前,让我们看看如何更改标准CRUD(创建、检索,更新或删除)方法的行为Python继承(Python Inheritance)目标:不能删除状态不为New、Canceled的房产预期效果动画地址:https://www.odoo.com/documentation/14.0/zh_CN/_images/unlink.gif房产收到报价时,房产状态应该改成‘Offer Received’不能以低于现有报价的价格创建报价预期效果动画地址:https://www.odoo.com/documentation/14.0/zh_CN/_images/create.gif在我们的房地产模块中,我们从不需要开发任何特定的东西来执行标准的CRUD操作。Odoo框架提供了实现这些操作的必要工具。事实上,多亏经典的Python继承,我们的模型中已经包含了这样的操作:from odoo import fields, models class TestModel(models.Model): _name = "test.model" _description = "Test Model" ...我们的 TestModel 类继承与Model,该Model类提供了 create(), read(), write() 和unlink()方法。这些方法(和其它在Model中定义的任何方法)可被扩展以添加指定业务逻辑:from odoo import fields, models class TestModel(models.Model): _name = "test.model" _description = "Test Model" ... @api.model def create(self, vals): # Do some business logic, modify vals... ... # Then call super to execute the parent method return super().create(vals)model()装饰器对于create() 方法来说是必需的,因为结果集self的内容和创建(creation)的上下文无关,但该装饰器对于其它CRUD方法来说不是必需的。Python 3中, super() 等价于 super(TestModel, self)。当你需要使用一条被修改后的结果集调用父方法时,可能需要使用后者。危险提示总是调用 super()以避免中断流非常重要。只有少数非常特殊的情况才无需调用它。总是返回和父方法一致的数据。例如父方法返回一个dict(),你重写父方法时也要返回一个dict()练习--添加业务逻辑到CRUD方法如果房产记录状态不是New,Canceled,则不让删除提示:重写unlink() ,并记住self可以是一个包含多条记录的结果集。创建报价时,设置房产状态为‘Offer Received’,如果用户试图以低于已存在报价的金额创建报价时抛出错误。提示: 可在vals中获取property_id 字段,但是它是一个int型。要实例化一个estate.property 对象,请使用self.env[model_name].browse(value) (示例)@api.model def create(self, vals): self.env['gamification.badge'].browse(vals['badge_id']).check_granting() return super(BadgeUser, self).create(vals)修改odoo14\custom\estate\views\estate_property_views.xml 去掉estate_property_view_tree 中<tree>元素的editable="top"属性(说明:为了方便执行报价创建操作)修改odoo14\custom\estate\models\estate_property.py@api.constrains('selling_price', 'expected_price') def _check_selling_price(self): # if record.selling_price < self.expected_price * 0.9: # raise ValidationError("selling price can`t not lower then 90 percent of expected price") pass说明:为了方便实践操作,暂且不做售价校验最末尾新增以下代码def unlink(self): for record in self: if record.state not in ['New', 'Canceled']: raise UserError('can`t delete property which status is New or Canceled') return super().unlink()修改odoo14\custom\estate\models\estate_property_offer.py,导入UserErrorfrom odoo.exceptions import UserError最末尾添加一下代码@api.model def create(self, vals): property = self.env['estate.property'].browse(vals['property_id']) if vals.get('price') < property.best_price: raise UserError('不能低于现有报价') property.state = 'Offer Received' return super().create(vals)重启服务,刷新浏览器验证删除非New、Canceled状态的房产,提示如下:模块继承(Model Inheritance)引用: 查看主题相关文档继承和扩展我们希望在“Settings/Users & Companies/Users”表单视图中直接显示与销售人员关联的房产列表。为此,我们需要向res.users模型添加一个字段,并调整其视图以显示它。Odoo提供了两种继承机制来以模块化的方式扩展现有模型。第一继承机制允许模块通过以下方式修改在另一个模块中定义的模型的行为:向模型添加字段覆盖模型中字段的定义给模型添加约束给模型添加方法重写模型中的现有方法第二种继承机制(委托)允许将模型的每个记录链接到父模型的记录,并提供对该父记录的字段的透明访问。odoo中,第一种机制最常用。在我们的例子中,我们希望向现有模型添加一个字段,这意味着我们将使用第一种机制。例如:from odoo import fields, models class InheritedModel(models.Model): _inherit = "inherited.model" new_field = fields.Char(string="New Field")这里可以找到将两个字段添加到模型中的示例class AccountMoveLine(models.Model): _inherit = 'account.move.line' vehicle_id = fields.Many2one('fleet.vehicle', string='Vehicle') need_vehicle = fields.Boolean(compute='_compute_need_vehicle', help="Technical field to decide whether the vehicle_id field is editable") def _compute_need_vehicle(self): self.need_vehicle = False按照惯例,每个继承的模型都在其自己的Python文件中定义。在我们的示例中为“models/inherited_model.py”。练习--添加字段到用户模型添加一下字段到res.users:FieldTypeproperty_idsOne2many inverse of salesman_id to estate.property添加一个domain到该字段,这样以便仅显示可获取房产。新增odoo14\custom\estate\models\estate_res_user.py#!/usr/bin/env python # -*- coding:utf-8 -*- from odoo import models, fields class EstateResUser(models.Model): _inherit = 'res.users' property_ids = fields.One2many('estate.property', 'salesman_id', domain="[('salesman_id', '=', active_id)]")修改odoo14\custom\estate\models\__init__.py#!/usr/bin/env python # -*- coding:utf-8 -*- from . import estate_property_type from . import estate_property_tag from . import estate_property_offer from . import estate_property from . import estate_res_user # 本次新增视图继承(View Inheritance)参考: 主题关联文档可查看Inheritance.目标: 在用户表单视图中显示与销售人员关联的avaliable房产列表其用户表单视图Odoo提供了视图继承,其中子“扩展”视图应用于根视图之上,而不是就地修改现有视图(通过重写它们)。这些扩展既可以添加内容,也可以从父视图中删除内容。扩展视图使用inherit_id字段引用其父视图。它的arch字段包含多个xpath元素,用于选择和更改父视图的内容,而不是单个视图:<record id="inherited_model_view_form" model="ir.ui.view"> <field name="name">inherited.model.form.inherit.test</field> <field name="model">inherited.model</field> <field name="inherit_id" ref="inherited.inherited_model_view_form"/> <field name="arch" type="xml"> <!-- find field description and add the field new_field after it --> <xpath expr="//field[@name='description']" position="after"> <field name="new_field"/> </xpath> </field> </record>expr一个用于选择父视图中单个元素的XPath表达式。如果不匹配任何元素或者匹配多个元素,则抛出错误position应用于匹配元素的操作:inside将xpath的主体附加到匹配元素的末尾(个人理解,添加为匹配元素的子元素)replace将匹配元素替换为xpath的主体,将新主体中出现的任何$0节点替换为原始元素before在匹配元素之前插入xpath的主体作为同级元素after在匹配的元素之后插入xpaths的主体,作为同级元素attributes使用xpath主体中的特定属性元素更改匹配元素的属性当匹配单个元素时,可以直接在要查找的元素上设置position属性。以下两种继承都有相同的结果<xpath expr="//field[@name='description']" position="after"> <field name="idea_ids" /> </xpath> <field name="description" position="after"> <field name="idea_ids" /> </field>在这里可以找到视图继承扩展的示例<?xml version='1.0' encoding='utf-8'?> <odoo> <record id="view_move_form" model="ir.ui.view"> <field name="name">account.move.form</field> <field name="model">account.move</field> <field name="inherit_id" ref="account.view_move_form"/> <field name="arch" type="xml"> <xpath expr="//field[@name='line_ids']//field[@name='account_id']" position="after"> <field name='need_vehicle' invisible='1'/> <field name='vehicle_id' attrs="{'required': [('need_vehicle', '=', True), ('parent.move_type', '=', 'in_invoice')], 'column_invisible': [('parent.move_type', '!=', 'in_invoice')]}" optional='hidden'/> </xpath> <xpath expr="//field[@name='invoice_line_ids']//field[@name='account_id']" position="after"> <field name='need_vehicle' invisible='1'/> <field name='vehicle_id' attrs="{'required': [('need_vehicle', '=', True), ('parent.move_type', '=', 'in_invoice')], 'column_invisible': [('parent.move_type', '!=', 'in_invoice')]}" optional='hidden'/> </xpath> </field> </record> </odoo>练习--添加字段到用户视图添加property_ids字段到 base.view_users_form 中新建的notebook页提示: 可以在 这里找到继承用户视图的示例。<?xml version="1.0" encoding="utf-8"?> <odoo> <data> <record id="res_users_view_form" model="ir.ui.view"> <field name="name">res.users.view.form.inherit.gamification</field> <field name="model">res.users</field> <field name="inherit_id" ref="base.view_users_form"/> <field name="arch" type="xml"> <group name="messaging" position="inside"> <field name="karma"/> </group> </field> </record> </data> </odoo>新增odoo14\custom\estate\views\estate_res_users_views.xml<?xml version="1.0" encoding="utf-8"?> <odoo> <data> <record id="estate_res_users_view_form" model="ir.ui.view"> <field name="name">estate.res.users.view.form</field> <field name="model">res.users</field> <field name="inherit_id" ref="base.view_users_form"/> <field name="arch" type="xml"> <xpath expr="//page[@name='references']" position="after"> <page string="Real Estate Properties" name="RealEstateProperties"> <field name='property_ids'/> </page> </xpath> </field> </record> </data> </odoo>修改odoo14\custom\estate\__manifest__.py#!/usr/bin/env python # -*- coding:utf-8 -*- { 'name': 'estate', 'depends': ['base'], 'data':['security/ir.model.access.csv', 'views/estate_property_views.xml', 'views/estate_property_type_views.xml', 'views/estate_property_tag_views.xml', 'views/estate_property_offer_views.xml', 'views/estate_menus.xml', 'views/estate_res_users_views.xml' # 本次新增 ] }重启服务,验证效果
添加修饰我们的房地产模块现在从商业角度来看是有意义的。我们创建了特定的视图,添加了几个操作按钮和约束。然而,我们的用户界面仍然有点粗糙。我们希望为列表视图添加一些颜色,并使一些字段和按钮有条件地消失。例如,当房产已出售或取消时,“已售出”和“取消”按钮应消失,因为此时不再允许更改状态。参考: 文档关联的主题可以查看 Views.内联视图(Inline Views)在房地产模块中,我们为房产添加了一个报价列表。我们通过以下代码简单地添加了offer_ids字段:<field name="offer_ids"/>该字段使用estate.properties.offer的特定视图。在某些情况下,我们希望定义一个仅在表单视图上下文中使用的特定列表视图。例如,我们希望显示链接到房产类型的房产列表。然而,为了清楚起见,我们只想显示3个字段:名称、预期价格和状态。为此,我们可以定义内联列表视图。内联列表视图直接在表单视图中定义。例如:from odoo import fields, models class TestModel(models.Model): _name = "test.model" _description = "Test Model" description = fields.Char() line_ids = fields.One2many("test.model.line", "model_id") class TestModelLine(models.Model): _name = "test.model.line" _description = "Test Model Line" model_id = fields.Many2one("test.model") field_1 = fields.Char() field_2 = fields.Char() field_3 = fields.Char()<form> <field name="description"/> <field name="line_ids"> <tree> <field name="field_1"/> <field name="field_2"/> </tree> </field> </form>在test.model的表单视图中,我们使用 field_1 和field_2为 test.model.line 定义了列表视图一个简单的示例<record id="event_tag_category_view_form" model="ir.ui.view"> <field name="name">event.tag.category.view.form</field> <field name="model">event.tag.category</field> <field name="arch" type="xml"> <form string="Event Category"> <sheet> <div class="oe_title"> <h1><field nolabel="1" name="name"/></h1> </div> <group> <field name="tag_ids" context="{'default_category_id': active_id}"> <tree string="Tags" editable="bottom"> <field name="sequence" widget="handle"/> <field name="name"/> <field name="color" widget="color_picker"/> </tree> </field> </group> </sheet> </form> </field> </record>练习--添加一个内联视图添加 One2many 字段property_ids到 estate.property.type 模型在 estate.property.type 表单视图中添加字段,如下图修改odoo14\custom\estate\models\estate_property_type.pyproperty_ids = fields.One2many('estate.property', 'property_type_id')修改odoo14\custom\estate\views\estate_property_type_views.xml,添加estate_property_type_view_form<record id="estate_property_type_view_form" model="ir.ui.view"> <field name="name">estate.property.type.form</field> <field name="model">estate.property.type</field> <field name="arch" type="xml"> <form string="Property Type"> <sheet> <div class="oe_title"> <h1><field nolabel="1" name="name"/></h1> </div> <field name="property_ids"> <tree string="Properties" editable="bottom"> <field name="name" string="Title"/> <field name="expected_price" string="Expected Price"/> <field name="state" string="Status"/> </tree> </field> </sheet> </form> </field> </record>重启服务,验证效果组件(Widget)参考: 查看本节主题关联文档Field Widgets.每当我们将字段添加到模型中时,我们(几乎)从来不用担心这些字段在用户界面中会是什么样子。例如,为Date字段提供的日期选择器,One2many字段自动显示为列表。Odoo根据字段类型选择正确的“widget”。然而,在某些情况下,我们需要某个字段的特定表示,这种特定表示的实现,归功于widget属性。在使用widget=“many2many_tags”属性时,我们已经将其用于tag_ids字段。如果我们没有使用它,那么该字段将显示为列表。每个字段类型都有一系列组件,可用于微调其显示。一些组件也有额外的选项。在Field Widgets中可以找到详尽的列表。练习--使用状态栏组件使用 statusbar 组件来展示的 estate.property 的state ,如下图:提示: 一个简单的示例.<field name="state" widget="statusbar" statusbar_visible="open,posted,confirm"/>警告相同字段,只能在列表或表单视图中只添加一次,不支持多次添加。同一个字段,如果展示多次,会以最后一次的样式统一展示。编辑odoo14\custom\estate\views\estate_property_views.xml修改estate_property_view_form表单视图的<header>元素<header> <button name="set_property_sold" type="object" string="SOLD"></button> <button name="set_property_canceled" type="object" string="CANCEL"></button> <!-- <field>元素为本次新增内容 --> <field name="state" widget="statusbar" statusbar_visible="New,Offer Received,Offer Accepted,Sold,Canceled"/> </header>去掉<sheet>元素中的state字段<field name="state" string="Status"></field>注意:如果不去掉上述代码,这里的样式将会覆盖statusbar的state字段样式,如下:说明:statusbar_visible属性值为state字段可选值(字段值的selection列表中二元组、单元组中的value,即元组第一个元素)字符串列表,控制状态栏显示那些状态,如果statusbar_visible值不为空字符串,则仅显示位于statusbar_visible属性值中指定的状态,以及视图归属模型中对应字段(例中为state)的default属性指定的状态(不管默认值是否在statusbar_visible属性值中),否则展示全部状态。此外,属性值在视图中的展示顺序,取决于字段可选值在public.ir_model_fields_selection表中对应sequence字段值大小,按该字段大小从左到右升序排序属性值刷新浏览器,验证效果:列表排序参考: 本节主题关联文档Models.在前面的练习中,我们创建了几个列表视图。然而,我们没有指定默认情况下记录必须按哪个顺序展示。对于许多业务案例来说,这是一件非常重要的事情。例如,在我们的房地产模块中,我们希望在列表顶部显示最高报价Modelodoo提供了几种设置默认顺序的方法。最常见的方法是直接在模型中定义_order属性。这样,检索到的记录将遵循确定性顺序,该顺序在所有视图中都是一致的,包括以编程方式搜索记录时。默认情况下,没有指定顺序,因此将根据不确定的顺序检索记录,取决于PostgreSQL。_order属性接收一个字符串,该字符串包含将用于排序的字段列表。它将转换为SQL中的order_by子句。例如:from odoo import fields, models class TestModel(models.Model): _name = "test.model" _description = "Test Model" _order = "id desc" description = fields.Char()如上,记录将按id降序排序,意味着最高的排在最上面。练习--添加模型排序在对应模型中添加一下排序ModelOrderestate.property按 ID降序estate.property.offer按Price降序estate.property.tagNameestate.property.typeName此处练习比较简单,我就不贴实践代码了,参考上述示例重启服务,验证效果View可以在模型级别进行排序,它有个优点,即即在检索记录列表的任何地方都有一致的顺序。也可以通过default_order直接在视图中定义指定排序顺序 (示例)。<record id="crm_activity_report_view_tree" model="ir.ui.view"> <field name="name">crm.activity.report.tree</field> <field name="model">crm.activity.report</field> <field name="arch" type="xml"> <tree default_order="date desc"> <field name="date"/> <field name="author_id"/> <field name="mail_activity_type_id"/> <field name="body"/> <field name="company_id" groups="base.group_multi_company"/> </tree> </field> </record>手工(Manual)模型排序和视图排序都允许在排序记录时具有灵活性,但仍有一种情况需要考虑:手动排序。用户可能希望根据业务逻辑对记录进行排序。例如,在我们的房地产模块中,我们希望手动对房产类型进行排序。将最常用的类型显示在列表的顶部确实很有用。如果我们的房地产经纪公司主要销售房子,那么在“公寓(Apartment)”之前出现“房子(House)”会更方便。为此,将sequence字段与handle组件结合使用。显然,sequence字段必须是_order属性中的第一个字段。练习--添加手工排序添加以下排序字段ModelFieldTypeestate.property.typeSequenceInteger使用正确的组件,添加sequence到 estate.property.type 列表视图提示: 可在 model 和view中查找示例。sequence = fields.Integer('Sequence', default=1, help="Used to order stages. Lower is better.")<record id="crm_stage_tree" model="ir.ui.view"> <field name="name">crm.stage.tree</field> <field name="model">crm.stage</field> <field name="arch" type="xml"> <tree string="Stages" multi_edit="1"> <field name="sequence" widget="handle"/> <field name="name" readonly="1"/> <field name="is_won"/> <field name="team_id"/> </tree> </field> </record>修改odoo14\custom\estate\models\estate_property_type.py#!/usr/bin/env python # -*- coding:utf-8 -*- from odoo import models, fields class EstatePropertyType(models.Model): _name = 'estate.property.type' _description = 'estate property type' _order = 'sequence,name' name = fields.Char(string='name', required=True) property_ids = fields.One2many('estate.property', 'property_type_id') sequence = fields.Integer('Sequence', default=1, help="Used to order type") _sql_constraints = [('check_name', 'unique(name)', 'Type name must be unique !')]修改odoo14\custom\estate\views\estate_property_type_views.xml中estate_property_type_view_tree<record id="estate_property_type_view_tree" model="ir.ui.view"> <field name="name">estate.property.type.tree</field> <field name="model">estate.property.type</field> <field name="arch" type="xml"> <tree string="PropertyTypes"> <field name="sequence" widget="handle"/> <field name="name"/> </tree> </field> </record>重启服务,验证效果(可手工拖动记录排序)属性和选项(Attributes and options)详细说明所有允许对视图外观进行微调的可用特性是令人望而却步的。因此,我们将挑选最常见的特性进行说明。表单(Form)目标: 本节末尾中,地产表单视图将拥有以下:有条件的显示按钮和字段标签颜色预期效果动画地址:https://www.odoo.com/documentation/14.0/zh_CN/_images/form.gif在我们的房地产模块中,我们希望修改某些字段的行为。例如,我们不希望能够从表单视图创建或编辑房产类型。相反,我们希望在其相应的菜单中处理类型。我们还想给标签增加一种颜色。为了添加这些定制化行为,我们可以将options属性添加到几个字段组件中。练习--添加组件选项添加合适的选项到property_type_id 字段,避免在房产表单视图中创建活编辑房产类型。查看Many2one组件文档 获取更多信息添加以下字段:ModelFieldTypeestate.property.tagColorInteger然后添加合适的选项到 tag_ids 字段以便在标签上添加颜色选择器。查看FieldMany2ManyTags组件文档 获取更多详细信息编辑odoo14\custom\estate\models\estate_property.py修改property_type_id = fields.Many2one("estate.property.type", string="PropertyType")为property_type_id = fields.Many2one("estate.property.type", string="PropertyType", options="{'no_create_edit': True}")重启服务,验证效果如下,看不到创建和编辑入口了编辑odoo14\custom\estate\models\estate_property_tag.py,新增color字段:color = fields.Integer(string='Color')修改odoo14\custom\estate\views\estate_property_views.xml estate_property_view_form中<field name="tag_ids" widget="many2many_tags"/>为<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/>重启服务,验证效果在"一些用户界面"章节中,我们看到保留字段用于特定行为。例如,active字段用于自动筛选出非活动记录。我们还添加了state作为保留字段。现在是使用它的时候了!state字段与视图中的states属性结合使用,以有条件地显示按钮。练习--有条件的显示按钮使用states属性来显示有条件的显示头部按钮,如本节目标中所述(注意修改状态时“已售出”和“取消”按钮的变化)提示: 请不要犹豫在Odoo XML文件中搜索states=以获得一些示例修改odoo14\custom\estate\views\estate_property_views.xml 表单视图中的<header><header> <button name="set_property_sold" type="object" states="Offer Accepted" string="SOLD"></button> <button name="set_property_canceled" type="object" states="New,Offer Received,Offer Accepted" string="CANCEL"></button> <field name="state" widget="statusbar" statusbar_visible="New,Offer Received,Offer Accepted,Sold,Canceled"/> </header>说明:第一个按钮的states配置,意为仅在当前记录state的值Offer Accepted时显示该按钮第一个按钮的states配置,意为仅在当前记录state的值New、Offer Received、Offer Accepted时显示该按钮刷新浏览器验证(可通过修改数据库中对应记录的state值来观察按钮的显示变化)更普遍的,多亏attrs属性,可以根据其他字段的值使字谋个字段 不可见(invisible)、只读(readonly)或必需(required 。注意, invisible也可以应用于视图的其他元素,如按钮( button )或组( group)。attrs 为一个以属性为key,以domain为值的字典。 domain给出了应用该属性的条件。例如:<form> <field name="description" attrs="{'invisible': [('is_partner', '=', False)]}"/> <field name="is_partner" invisible="1"/> </form>这意味着当 is_partner 为 False 时description字段不可见。需要注意的是,attrs中使用的字段必须出现在视图中。如果它不应该显示给用户,我们可以使用invisible属性来隐藏它。练习--使用 attrs当没有花园(garden)时,设置 estate.property 表单视图中的花园面积(garden area)和朝向(garden orientation)不可见一单设置了报价状态,设置’Accept’ 和‘Refuse’ 按钮不可见当房产状态为 Offer Accepted, Sold 或 Canceled时,不允许添加报价。为此使用readonly attrs.警告在视图中使用(条件)readonly属性可能有助于防止数据输入错误,但请记住,它不会提供任何级别的安全性!服务器端没有进行检查,因此始终可以通过RPC调用在字段上进行写入。修改odoo14\custom\estate\views\estate_property_views.xml表单视图<page string="Description"> <group> <field name="description"></field> <field name="bedrooms"></field> <field name="living_area"></field> <field name="facades"></field> <field name="garage"></field> <field name="garden"></field> <field name="garden_area" attrs="{'invisible': [('garden', '=', False)]}"></field> <field name="garden_orientation" attrs="{'invisible': [('garden', '=', False)]}"></field> <field name="total_area" string="Total Area"></field> </group> </page>修改offer_ids属性<page string="Offers"> <field name="offer_ids" attrs="{'readonly': [('state', 'in', ['Offer Accepted','Sold','Canceled'])]}"/> </page>说明: in 表示在列表中,反之使用 not in修改odoo14\custom\estate\views\estate_property_offer_views.xml 给button新增属性<tree string="PropertyOffers"> <field name="price" string="Price"/> <field name="partner_id" string="partner ID"/> <field name="validity" string="Validity(days)"/> <field name="date_deadline" string="Deadline"/> <button name="action_accept_offer" string="" type="object" icon="fa-check" attrs="{'invisible': [('status', 'in', ['Accepted','Refused'])]}"/> <button name="action_refuse_offer" string="" type="object" icon="fa-times" attrs="{'invisible': [('status', 'in', ['Accepted','Refused'])]}"/> <field name="status" string="Status"/> </tree>刷新浏览器,验证效果列表(List)当模型只有几个字段时,可以通过列表视图直接编辑记录,而不必打开表单视图。在房地产示例中,不需要打开窗体视图来添加报价或创建新标签。这可以通过editable 属性实现。练习--使列表视图可编辑让estate.properties.offer和estate.properties.tag列表视图可编辑。此外,当一个模型有很多字段时,很可能会在列表视图中添加太多字段,使其变得不清晰。另一种方法是添加字段,并让这些字段可以有选择的被隐藏。这可以通过optional 属性实现。练习-使字段成为可选字段默认情况下,将estate.properties列表视图中的字段date_availability设置为可选,默认隐藏。修改odoo14\custom\estate\views\estate_property_offer_views.xml中的tree视图中的<tree>元素,增加editable属性:<tree string="PropertyOffers" editable="top">刷新浏览器查看修改odoo14\custom\estate\views\estate_property_views.xml estate_property_view_tree中的<tree>元素<tree string="estate property" editable="top"><!--editable属性为本次新增--> <field name="name" string="Title"/> <field name="postcode" string="Postcode"/> <field name="tag_ids" string="Tags" widget="many2many_tags" options="{'color_field': 'color'}"/><!--本次新增字段--> <field name="bedrooms" string="Bedrooms"/> <field name="living_area" string="Living Area"/> <field name="expected_price" string="Expected Price"/> <field name="selling_price" string="Selling Price"/> <field name="date_availability" string="Avalilable Form" optional="hide"/> <field name="property_type_id" string="Property Type"/> </tree>说明:editable="value",其中value可选值为top|bottom,表示点击创建记录时,待创建记录出现在列表的顶部(value=top)还是底部(value=bottom)。optional="value",value可选值为hide (隐藏),show(显示)刷新浏览器验证最后,颜色代码有助于直观地强调记录。例如,在房地产模块中,我们希望以红色显示拒绝的报价,以绿色显示接受的报价。这可以通过 decoration-{$name}属性实现(有关完整列表,请参阅 decorations):<tree decoration-success="is_partner==True"> <field name="name"> <field name="is_partner" invisible="1"> </tree>is_partner为True的记录将显示为绿色。练习--添加一些装饰在 estate.property 列表视图中:收到报价的房产显示为绿色已接受报价的房产显示为绿色,并加粗显示已出售房产显示为禁用(muted)修改odoo14\custom\estate\views\estate_property_views.xml,给列表视图<tree>元素增加decoration-x属性:<tree string="estate property" editable="top" decoration-success="state in ['Offer Received','Offer Accepted']" decoration-bf="state == 'Offer Accepted'" decoration-muted="state == 'Sold'">刷新浏览器验证搜索(Search)Reference: 查看主题关联文档Search 和 Search defaults本章目标:在本节结束时,默认情况下将过滤可用的属性,搜索居住区域将返回面积大于给定数字的结果。预期效果动画地址:https://www.odoo.com/documentation/14.0/zh_CN/_images/search.gif最后但并非最不重要的是,我们希望在搜索时应用一些调整。首先,我们希望在访问房产列表时默认应用“Avaliable”筛选器。为了实现这一点,我们需要使用search_default_{$name}操作上下文,其中{$name}为过滤器名称,即搜索视图中定义的<field>、<filter>元素的name属性值。这意味着我们可以在操作级别定义默认激活的过滤器。这里是一个带有 相应过滤器的操作 示例。<!-- Opportunities by user and team Search View --> <record id="crm_opportunity_report_view_search" model="ir.ui.view"> <field name="name">crm.lead.search</field> <field name="model">crm.lead</field> <field name="priority">32</field> <field name="arch" type="xml"> <search string="Opportunities Analysis"> ... <filter name="opportunity" string="Opportunity" domain="[('type','=','opportunity')]" help="Show only opportunity"/> ...<record id="crm_opportunity_report_action" model="ir.actions.act_window"> <field name="name">Pipeline Analysis</field> <field name="res_model">crm.lead</field> <field name="view_mode">pivot,graph,tree,form</field> <field name="search_view_id" ref="crm.crm_opportunity_report_view_search"/> <field name="context">{'search_default_opportunity': True, 'search_default_current': True}</field> ...练习--添加默认过滤器在estate.properties action中,默认选择‘Available’筛选器。修改odoo14\custom\estate\views\estate_property_views.xml link_estate_property_action<record id="link_estate_property_action" model="ir.actions.act_window"> <field name="name">Properties</field> <field name="res_model">estate.property</field> <field name="view_mode">tree,form</field> <field name="context">{'search_default_state': True}</field><!--新增内容--> </record>重启服务,刷新浏览器验证我们模块的另一个有用的改进是能够按居住面积高效搜索。实际上,用户需要搜索“至少”给定面积的房产。期望用户能够找到一个精确居住面积的房产是不现实的。总是可以进行自定义搜索,但这很不方便。搜索视图的<field>元素可以包含一个filter_domain,它会覆盖为搜索给定字段而生成的domain。在给定domain中,self表示用户输入的值。在下面的示例中,它用于搜索 name 和 description 字段。<search string="Test"> <field name="description" string="Name and description" filter_domain="['|', ('name', 'ilike', self), ('description', 'ilike', self)]"/> </group> </search>练习--改变居住面积搜索添加一个 filter_domain 到居住面积,以搜索面积大于等于给值的房产。修改odoo14\custom\estate\views\estate_property_views.xml estate_property_search_view,增加living_area字段<search string="Estate Property"> <!-- 搜索 --> <field name="name" string="Title" /> <field name="postcode" string="Postcode"></field> <field name="living_area" string="LivingArea" filter_domain="[('living_area', '>=', self)]"/> <!--本次新增--> <separator/> <!-- 筛选 --> <filter string="Available" name="state" domain="['|',('state', '=', 'New'),('state', '=', 'Offer Received')]"></filter> <filter name="bedrooms" domain="[('bedrooms', '>', 3)]"></filter> <filter name="bedrooms and selling_price" domain="[('bedrooms', '>', 2),('selling_price', '>=', 1000)]"></filter> <!-- 分组 --> <group expand="1" string="Group By"> <filter string="朝向" name="garden_orientation" context="{'group_by':'garden_orientation'}"/> </group> </search>重启服务,刷新页面后验证统计按钮(Stat Buttons)在本节的末尾,房产类型表单视图上会有一个统计按钮,当单击该按钮时,它会显示与给定类型的房产相关的所有报价的列表。预期效果动画地址:https://www.odoo.com/documentation/14.0/zh_CN/_images/stat_button.gif在我们的房地产模块中,我们希望快速链接到与给定房产类型相关的报价,正如目标描述中展示的那样。提示:通过在Odoo代码库中查找“oe_stat_button”,以获取一些示例。本次练习将引入Related fields的概念。理解它的最简单方法是将其视为计算的字段的特殊情况。以下description字段的定义:... partner_id = fields.Many2one("res.partner", string="Partner") description = fields.Char(related="partner_id.name")等价于:... partner_id = fields.Many2one("res.partner", string="Partner") description = fields.Char(compute="_compute_description") @api.depends("partner_id.name") def _compute_description(self): for record in self: record.description = record.partner_id.name每当partner的name改变时,description也会被改变。练习--添加统计按钮到房产类型添加 property_type_id 到estate.property.offer。 我们可以将其定义为property_id.property_type_id上的关联字段,并将其设置为存储。因为此字段,报价将在创建时链接到房产类型。您可以将该字段添加到报价列表视图中,以确保其正常工作。.添加offer_ids 到estate.property.type ,该字段为前面步骤定义的字段的One2many inverse添加 offer_count 到 estate.property.type。该字段为一个计算的字段,用于统计给定房产类型的报价的数量 (使用offer_ids 进行计算)。此时,你已经掌握了了解有多少报价链接到一个房产类型的所有必要的信息。如果有疑问,请将offer_ids和offer_count直接添加到视图中。下一步是在单击统计按钮时显示列表。在estate.properties.type上创建一个统计按钮,指向estate.property.offer action。这意味着你应该使用type=“action”属性此时,点击统计按钮,应该显示所有报价。我们仍然需要过滤的报价。在 estate.property.offer action中添加一个domain, 将 property_type_id 定义为等于active_id (=当前记录, 这里是一个示例)<record id="act_event_registration_from_event" model="ir.actions.act_window"> <field name="res_model">event.registration</field> <field name="name">Attendees</field> <field name="view_mode">kanban,tree,form,calendar,graph</field> <field name="domain">[('event_id', '=', active_id)]</field> ... </record>编辑odoo14\custom\estate\models\estate_property_offer.py,修改from odoo import models, fields为from odoo import models, fields, api新增以下字段:property_type_id = fields.Many2one(related="property_id.property_type_id", store=True)修改odoo14\custom\estate\models\estate_property_type.py,新增offer_ids,offer_count字段,新增_compute_offer_count函数#!/usr/bin/env python # -*- coding:utf-8 -*- from odoo import models, fields, api class EstatePropertyType(models.Model): _name = 'estate.property.type' _description = 'estate property type' _order = 'sequence, name' sequence = fields.Integer('Sequence', default=1, help="Used to order type") name = fields.Char(string='name', required=True) property_ids = fields.One2many('estate.property', 'property_type_id') offer_ids = fields.One2many('estate.property.offer', 'property_type_id') offer_count = fields.Integer(compute='_compute_offer_count') _sql_constraints = [('check_name', 'unique(name)', 'Type name must be unique !')] @api.depends('offer_ids.price') def _compute_offer_count(self): for record in self: record.offer_count = sum(record.mapped('offer_ids.price'))修改odoo14\custom\estate\views\estate_property_type_views.xml,新增display_offers_for_given_estate_property_action,button_box div元素<?xml version="1.0"?> <odoo> <record id="estate_property_type_action" model="ir.actions.act_window"> <field name="name">Property Types</field> <field name="res_model">estate.property.type</field> <field name="view_mode">tree,form</field> </record> <!--display_offers_for_given_estate_property_action为本次新增元素--> <record id="display_offers_for_given_estate_property_action" model="ir.actions.act_window"> <field name="name">Property Offers</field> <field name="type">ir.actions.act_window</field> <field name="res_model">estate.property.offer</field> <field name="view_mode">tree</field> <field name="domain">[('property_type_id', '=', active_id)]</field> <field name="context">{'default_event_id': active_id}</field> </record> <record id="estate_property_type_view_tree" model="ir.ui.view"> <field name="name">estate.property.type.tree</field> <field name="model">estate.property.type</field> <field name="arch" type="xml"> <tree> <field name="sequence" widget="handle"></field> <field name="name" string="Property Type"/> </tree> </field> </record> <record id="estate_property_type_view_form" model="ir.ui.view"> <field name="name">estate.property.type.form</field> <field name="model">estate.property.type</field> <field name="arch" type="xml"> <form string="Property Type"> <sheet> <!--button_box为本次新增元素--> <div class="oe_button_box" name="button_box" > <button class="oe_stat_button" name="%(display_offers_for_given_estate_property_action)d" string="" type="action" icon="fa-money"> <field string="Offers" widget="statinfo" name="offer_count"></field> </button> </div> <div class="oe_title"> <h1><field nolabel="1" name="name"/></h1> </div> <field name="property_ids"> <tree string="Properties" editable="bottom"> <field name="name" string="Title"/> <field name="expected_price" string="Expected Price"/> <field name="state" string="Status"/> </tree> </field> </sheet> </form> </field> </record> </odoo>重启服务,刷新浏览器验证通过按钮跳转后带来的问题点击浏览器回退键,面包屑显重复显示了,如下,暂时未找到解决方案
一些用户界面数据文件 (XML)参考: 该主题关联文档可以查看Data Files.上一章,我们通过CSV文件添加了数据。当需要添加数据格式简单时,用CSV格式还是很方便的,当数据格式更复杂时(比如视图架构或者一个邮件模板),我们使用XML格式。比如包含HTML tags的 help field。虽然可以通过CSV文件加载这样的数据,但是使用XML更方便。类似CSV文件,XML文件也必须按约定添加到合适的目录,并在 __manifest__.py中进行定义。数据文件中的内容也是在模块安装或者更新时按序加载。因此,对CSV文件所做的所有说明对XML文件都适用。当数据链接到视图时,我们将它们添加到views文件夹中本章,我们将通过XML文件加载我们第一个action和菜单。Actions 和菜单为数据库中的标准记录。注解:当程序很注重性能时,CSV格式优先于XML格式。这是因为,在odoo中加载CSV文件比加载XML文件更快。odoo中,用户接口(action,菜单和视图)大部分是通过创建和组装XML文件中的记录来定义的。常见的模式为 菜单> action > 视图。为了访问记录,用户在几个菜单级中导航。最深层是触发打开记录列表的action。操作(Actions)参考: 主题相关文档可以查看 Actions.动作可以通过三种方式触发 :点击菜单项目(链接接到指定动作)点击视图按钮(如果与action关联)对象的上下文action本章仅涵盖第一种情况。 我们Real Estate例子中,希望将一个菜单连接到 estate.property model, 以便创建一个新记录。 action可以视为菜单和model之间的链接test.model 的基本action:<record id="test_model_action" model="ir.actions.act_window"> <field name="name">Test action</field> <field name="res_model">test.model</field> <field name="view_mode">tree,form</field> </record>id 外部标识。它可以用于引用记录(不需要知道其在数据库中的标识符)。model ir.actions.act_window (Window Actions (ir.actions.act_window))的一个固定值name action名称res_model action应用的范围。view_mode 可获取的视图。本例中为列表(树)和表格视图。odoo中到处都可以找到例子,但是这个 简单action的好例子。关注XML 数据文件结构,因为你在后续的练习中会用到。<?xml version="1.0"?> <odoo> <record id="crm_lost_reason_view_search" model="ir.ui.view"> <field name="name">crm.lost.reason.view.search</field> <field name="model">crm.lost.reason</field> <field name="arch" type="xml"> <search string="Search Opportunities"> <field name="name"/> <filter string="Include archived" name="archived" domain="['|', ('active', '=', True), ('active', '=', False)]"/> <separator/> <filter string="Archived" name="inactive" domain="[('active', '=', False)]"/> </search> </field> </record> <record id="crm_lost_reason_view_form" model="ir.ui.view"> <field name="name">crm.lost.reason.form</field> <field name="model">crm.lost.reason</field> <field name="arch" type="xml"> <form string="Lost Reason"> <sheet> <div class="oe_button_box" name="button_box"> <button name="action_lost_leads" type="object" class="oe_stat_button" icon="fa-star"> <div class="o_stat_info"> <field name="leads_count" class="o_stat_value"/> <span class="o_stat_text"> Leads</span> </div> </button> </div> <widget name="web_ribbon" title="Archived" bg_color="bg-danger" attrs="{'invisible': [('active', '=', True)]}"/> <div class="oe_title"> <div class="oe_edit_only"> <label for="name"/> </div> <h1 class="mb32"> <field name="name" class="mb16"/> </h1> <field name="active" invisible="1"/> </div> </sheet> </form> </field> </record> <record id="crm_lost_reason_view_tree" model="ir.ui.view"> <field name="name">crm.lost.reason.tree</field> <field name="model">crm.lost.reason</field> <field name="arch" type="xml"> <tree string="Channel" editable="bottom"> <field name="name"/> </tree> </field> </record> <!-- Configuration/Lead & Opportunities/Lost Reasons Menu --> <record id="crm_lost_reason_action" model="ir.actions.act_window"> <field name="name">Lost Reasons</field> <field name="res_model">crm.lost.reason</field> <field name="view_mode">tree,form</field> <field name="help" type="html"> <p class="o_view_nocontent_smiling_face"> Define a new lost reason </p><p> Use lost reasons to explain why an opportunity is lost. </p><p> Some examples of lost reasons: "We don't have people/skill", "Price too high" </p> </field> </record> <record id="menu_crm_lost_reason" model="ir.ui.menu"> <field name="action" ref="crm.crm_lost_reason_action"/> </record> </odoo>练习为 estate.property model 创建action。在适当的位置(本例中为odoo14/custom/estate/models/views)创建 estate_property_views.xml<?xml version="1.0"?> <odoo> <record id="link_estate_property_action" model="ir.actions.act_window"> <field name="name">Properties</field> <field name="res_model">estate.property</field> <field name="view_mode">tree,form</field> </record> </odoo>修改odoo14/custom/estate/__manifest__.py#!/usr/bin/env python # -*- coding:utf-8 -*- { 'name': 'estate', 'depends': ['base'], 'data':['security/ir.model.access.csv', 'views/estate_property_views.xml' ] }重启服务并观察文件加载日志。菜单(Menus)参考: 和本主题关联文档可以查看Shortcuts.为了减少菜单(ir.ui.menu)定义和链接到对应action的复杂性,我们可以使用 shortcuttest_model_action 一个的基础菜单:<menuitem id="test_model_menu_action" action="test_model_action"/>test_model_menu_action 菜单被链接到 test_model_action ,action链接到model test.model。正如前面所述, action可以看做是菜单和model之间的连接。注意:这里的id的值和action的值不能设置成一样,否则会报错。然而,菜单总是遵循一种体系结构,实际上有三个层次的菜单:根菜单,显示在App切换器中(Odoo社区版切换器是一个下拉菜单)第一级菜单,显示在顶部栏中动作菜单最容易的方式是在XML文件中定义结构来创建菜单。为 test_model_action 定义的一个基础菜单结构:<menuitem id="test_menu_root" name="Test"> <menuitem id="test_first_level_menu" name="First Level"> <menuitem id="test_model_menu_action" action="test_model_action"/> </menuitem> </menuitem>第三级菜单的名称,直接从action获取,即为action属性值练习添加菜单在合适的目录(本例中为odoo14/custom/estate/models/views)创建 estate_menus.xml 文件<?xml version="1.0"?> <odoo> <menuitem id="test_menu_root" name="Test"> <menuitem id="test_first_level_menu" name="First Level"> <menuitem id="estate_property_menu_action" action="link_estate_property_action"/> </menuitem> </menuitem> </odoo>修改odoo14/custom/estate/__manifest__.py#!/usr/bin/env python # -*- coding:utf-8 -*- { 'name': 'estate', 'depends': ['base'], 'data':['security/ir.model.access.csv', 'views/estate_property_views.xml', 'views/estate_menus.xml' ] }重启odoo服务,查看效果字段,属性和视图(Fields, Attributes And View)到目前为止,我们只对房产广告使用了通用视图,但在大多数情况下,我们希望对视图进行微调。Odoo有许多微调方式,但通常第一步是确保:某些字段有默认值某些字段只读当记录重复时,某些字段不能被拷贝在我们的房产业务案例中,我们希望::售价只读(往后将自动填充)当记录重复时,可用日期和售价不能被拷贝卧室数量应该默认为2默认可用日期应该为3个月一些新属性在进一步进行视图设计之前,让我们回到模型定义。我们看到一些属性,如required=True,会影响数据库中的表模式。其他属性也将影响视图或提供默认值。练习 -- 添加一些属性到字段。查找一些合适的属性 (查看字段) 来:设置售价为只读阻止复制可用日期和售价修改 odoo14\custom\estate\models\estate_property.py 中EstateProperty类属性expected_price,selling_price的值如下:expected_price = fields.Float('expected price', digits=(8, 2), required=True) selling_price = fields.Float('selling price', digits=(8, 2), readonly=True, copy=False)重启服务和并刷新浏览器界面,我们可以看到无法设置任何售价。复制记录时,可用日期应为空。预期效果可参考该动画连接:https://www.odoo.com/documentation/14.0/zh_CN/_images/attribute_and_default.gif默认值可以为任何字段设置默认值。字段定义中,添加 default=X, 其中的X 可以是Python文本值(boolean, integer, float, string) ,也可以是一个以model对象自身为入参并返回一个值的函数:name = fields.Char(default="Unknown") last_seen = fields.Datetime("Last Seen", default=lambda self: fields.Datetime.now())例中name字段默认值为‘Unknown’,而last_seen 字段默认值为当前时间练习 -- 设置默认值添加适当的默认值:卧室数量默认值为 2可用日期默认为3个月内修改 odoo14\custom\estate\models\estate_property.py 中EstateProperty类属性bedrooms,selling_price的值如下:bedrooms = fields.Integer(default=2) date_availability = fields.Datetime('Availability Date', copy=False, default= lambda self: fields.Datetime.today())重启服务和并刷新浏览器界面验证保留字段参考: 主题相关文档可参考 保留字段名称.odoo为预定义行为保留了一些字段名称。当需要相关行为时,需要在模型中定义这些保留字段。练习 -- 添加active字段添加一个 active 字段到estate.property 模型。修改 odoo14\custom\estate\models\estate_property.py 中EstateProperty类,增加active属性active = fields.Boolean('Active')重启服务,刷新浏览器界面,新增一条记录,新增时勾选Active复选框,即active=True,验证效果。预期效果可参考该动画链接:https://www.odoo.com/documentation/14.0/zh_CN/_images/inactive.gif注意,已存在的记录的active字段默认值为False练习--为active字段添加设置为active字段设置默认值为 active 字段设置适当的属性值,让它不再出现在页面。练习 -- 添加state字段为estate.property model添加state 字段(字段名可自定义),一个选择列表。可选值: New, Offer Received, Offer Accepted, Sold 和Canceled。必选字段,且不能被拷贝,默认值New修改 odoo14\custom\estate\models\estate_property.py 中EstateProperty类,修改active字段,增加state字段active = fields.Boolean('Active', default=True, invisible=True) # 注意:实践发现,invisible字段不起作用 state = fields.Selection( string='State', selection=[('New','New'), ('Offer Received','Offer Received'), ('Offer Accepted', 'Offer Accepted'), ('Sold','Sold'), ('Canceled', 'Canceled')], copy=False )重启服务,验证效果
一个新应用房地产广告模块假设需要开发一个房地产模块,该模块覆盖未包含在标准模块集中特定业务领域。以下为包含一些广告的主列表视图form视图顶层区域概括了房产的重要信息,比如name,Property Type, Postcode等等。列表记录详情页中,第一个tab包含了房产的描述信息,比如:bedrooms, Living area, Garage,Garden第二个tab页,列出了房产的报价。我们可以在这里看到,潜在买家可以提供高于或低于预期售价的报价,取决于卖方是否接受报价。准备插件目录参考: 和该主题相关的文档可参考 manifest.Goal: 该小节的目标是让odoo识别我们的新模块,一个空壳。它将显示在Apps中创建模块的第一步:新建一个目录。为了让开发更轻松,建议首先创建目录 /home/$USER/src/custom,然后在该目录中添加待创建的新模块对应的目录(本例为 estate)。一个模块至少包含两个文件: 一个__manifest__.py 文件和一个 __init__.py 文件。__init__.py 目前可以保持为空,下一章我们在回过头理它。而 __manifest__.py 文件必须描述模块,且不能保持为空。其必不可少的字段为 name, 但通常会包含更多信息。以CRM file(如果打不开,可参见下文)为例,为了提供模块描述信息 (name, category, summary, website…), 它列出了它的依赖(depends)。odoo框架会确保depends中配置的依赖模块在我们的模块被安装之前安装。 而且,如果这些模块中的某个依赖被卸载,我们的模块及其它任何依赖它的模块都会被卸载。 Odoo采用和Linux发行包管理一样的工作方式。创建以下目录及文件odoo14/custom/estate/__init__.py(官方推荐路径:/home/$USER/src/custom/estate/__init__.py,注意这里的包名estate即为模块的Technical Name)odoo14/custom/estate/__manifest__.py(官方推荐路径:/home/$USER/src/custom/estate/__manifest__.py)__manifest__.py 文件只定义name和模块依赖,目前唯一必要的框架模块为 base。如下:#!/usr/bin/env python # -*- coding:utf-8 -*- { 'name': 'estate', 'depends': ['base'] }添加 custom 目录路径到 addons-path,重启Odoo服务:python odoo-bin --addons-path=custom,odoo/addons -r myodoo -w test123 -d odoo验证浏览器页面中访问Apps, 点击搜索estateCRM file# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. { 'name': 'CRM', 'version': '1.2', 'category': 'Sales/CRM', 'sequence': 15, 'summary': 'Track leads and close opportunities', 'description': "", 'website': 'https://www.odoo.com/page/crm', 'depends': [ 'base_setup', 'sales_team', 'mail', 'calendar', 'resource', 'fetchmail', 'utm', 'web_tour', 'contacts', 'digest', 'phone_validation', ], 'data': [ 'security/crm_security.xml', 'security/ir.model.access.csv', 'data/crm_lead_prediction_data.xml', 'data/crm_lost_reason_data.xml', 'data/crm_stage_data.xml', 'data/crm_team_data.xml', 'data/digest_data.xml', 'data/mail_data.xml', 'data/crm_recurring_plan_data.xml', 'wizard/crm_lead_lost_views.xml', 'wizard/crm_lead_to_opportunity_views.xml', 'wizard/crm_lead_to_opportunity_mass_views.xml', 'wizard/crm_merge_opportunities_views.xml', 'views/assets.xml', 'views/calendar_views.xml', 'views/crm_recurring_plan_views.xml', 'views/crm_menu_views.xml', 'views/crm_lost_reason_views.xml', 'views/crm_stage_views.xml', 'views/crm_lead_views.xml', 'views/digest_views.xml', 'views/mail_activity_views.xml', 'views/res_config_settings_views.xml', 'views/res_partner_views.xml', 'views/utm_campaign_views.xml', 'report/crm_activity_report_views.xml', 'report/crm_opportunity_report_views.xml', 'views/crm_team_views.xml', ], 'demo': [ 'data/crm_team_demo.xml', 'data/mail_activity_demo.xml', 'data/crm_lead_demo.xml', ], 'css': ['static/src/css/crm.css'], 'installable': True, 'application': True, 'auto_install': False }
环境odoo-14.0.post20221212.tarbase_user_role-12.0.2.1.2.zip下载地址:https://apps.odoo.com/apps/modules/12.0/base_user_role/权限管理简介为了更好的熟悉权限,我们先来了解下用户,odoo中的用户分为三类:内部用户(Internal User): 企业内部的用户,拥有对系统内部的访问权限,也就是说有odoo后端的访问权限。门户用户(Portal): 非企业内部用户,通常为业务合作伙伴用户,拥有有限的资源访问权限。公共用户(Public): 面向公众的权限,可以理解为游客权限。提示:管理员登录系统,激活开发者模式,即可在设置-用户详情页对用户类型进行编辑(Settings -> Users & Companies -> Users)以上三类用户的信息都存在res_user与res_partner表中,那么在odoo中如何区分用户类型以及如何做权限控制的呢?为了解决上述问题,odoo采用了用户组机制。将用户划分为不同的组(一个用户可以归属多个用户组,一个用户组也可以拥有多个用户),然后给组分配权限,从而实现用户权限的管控及用户类型识别。以上三种用户分别归属以下用户组:内部用户:base.group_user门户用户:base.group_portal公共用户:base.group_publicodoo也支持自定义用户组(Settings -> Users & Companies -> Groups),并为用户分配不同的用户组,及设置相关权限(菜单权限,视图权限,访问权限,记录规则)此外,为了更方便的管理用户组,odoo还支持对用户组(group)进行分类:将多个用户组划分为一个用户组分类(category)。用户组和用户组分类:一个用户组分类可以拥有多个用户组,一个用户组仅归属一个用户组分类,属于1对多的关系。用户组和用户组的关系:用户组可以被用户组继承(伪继承),当继承某个用户组时,本组用户也会自动加入继承的用户组。如果一个用户属于多个用户组,那么该用户权限为用户组权限的并集,因此设计用户组权限时一定要考虑好组与组之间权限是否会发生冲突。定义用户组(权限组)示例:xml数据文件的方式定义菜单权限用户组<odoo> <data noupdate="1"> <record id="estate_property_menu_groups" model="ir.module.category"><!-- id:供代码或者xml中引用,model:odoo的category模型--> <field name="name">[房地产]模块菜单权限</field><!--用户组分类名称--> <field name="sequence">1</field><!--组分类显示顺序、优先级--> </record> <!--######################## [房地产]模块菜单 ########################--> <record id="group_estate_property_root_menu" model="res.groups"> <field name="name">Real Estate</field><!--用户组名称,阐明组的角色/目的--> <field name="category_id" ref="estate_property_menu_groups"/><!--指定用户组所属组分类--> <field name="implied_ids" eval="[(4, ref('base.group_user'))]"/><!--定义用户组继承自哪些组,也就是说该用户组也拥有这些继承组的权限--> <field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/><!--为用户组添加用户 base.user_root root用户 --> </record> </data> </odoo>说明:noupdate:如果数据文件的内容预期只应用一次(只加载一次,安装或者更新模块时),则可以将noupdate设置为1。如果文件中的部分数据需要应用一次,则可以将文件的这部分放在<data-noupdate="1">中,如下:<odoo> <data noupdate="1"> <!-- Only loaded when installing the module (odoo-bin -i module) --> <operation/> </data> <!-- (Re)Loaded at install and update (odoo-bin -i/-u) --> <operation/> </odoo>参考连接:https://www.odoo.com/documentation/14.0/zh_CN/developer/reference/addons/data.html?highlight=noupdatebase.user_admin :admin用户(ID为2的用户,用户数据定义在odoo\addons\base\data\res_users_data.xml中base.user_root: __system__用户(ID为1的用户,technical admin )category定义相关数据存储在ir_module_category表中添加的group,可以在Settings -> Users & Groups -> Groups界面看到,组定义相关数据存储在res_groups表中eval语法说明(0, 0, values) 从提供的valueS字典创建新记录,形如(0, 0, {'author': user_root.id, 'body': 'one'})。(2, ID, values) 使用values字典中的值更新id值=ID的现有记录(2, ID) 删除id=ID这条记录(调用unlink方法,删除数据及整个主从数据链接关系)(3, ID) 删除主从数据的链接关系但是不删除这个记录(4, ID) 为id=ID的数据添加主从链接关系(5) 去除所有的链接关系,也就是循环所有的从数据且调用(3,ID)(6, 0, [IDs]) 用IDs中的记录替换原来链接的记录(相当于先执行(5)再循环执行(4, ID))拓展:odoo中有个特殊的组base.group_no_one,需要开启Debug模式才可获取该组权限。可以利用该特性实现隐藏对象需求,比如针对一些常规下不需要显示的特殊字段,为其设置属性groups = "base.group_no_one",可以实现在非Debug模式下隐藏字段在视图中的显示。菜单访问权限应用实例estate/security/security_estate_property_menu_groups.xml<odoo> <data noupdate="1"> <record id="estate_property_menu_groups" model="ir.module.category"> <field name="name">[房地产]模块菜单权限</field> <field name="sequence">1</field> </record> <!--######################## [房地产]模块菜单 ########################--> <record id="group_estate_property_root_menu" model="res.groups"> <field name="name">Real Estate</field> <field name="category_id" ref="estate_property_menu_groups"/> <field name="implied_ids" eval="[(4, ref('base.group_user'))]"/> <field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/> </record> <record id="group_estate_property_advertisements_menu" model="res.groups"> <field name="name">Real Estate -> Advertisements</field> <field name="category_id" ref="estate_property_menu_groups"/> <field name="implied_ids" eval="[(4, ref('base.group_user'))]"/> <field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/> </record> <record id="group_estate_property_advertisements_properties_menu" model="res.groups"> <field name="name">Real Estate -> Advertisements -> Properties</field> <field name="category_id" ref="estate_property_menu_groups"/> <field name="implied_ids" eval="[(4, ref('base.group_user'))]"/> <field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/> </record> </data> </odoo>estate/__manifest__.py#!/usr/bin/env python # -*- coding:utf-8 -*- { 'name': 'estate', 'depends': ['base'], 'data':[ 'security/security_estate_property_menu_groups.xml', //...略 ] }菜单配置views/estate_menus.xml<?xml version="1.0"?> <odoo> <menuitem id="test_menu_root" name="Real Estate" web_icon="estate,static/img/icon.png" groups="group_estate_property_root_menu"> <menuitem id="test_first_level_menu" name="Advertisements" groups="group_estate_property_advertisements_menu"> <menuitem id="estate_property_menu_action" action="link_estate_property_action" groups="group_estate_property_advertisements_properties_menu"/> <!--略--> </menuitem> <!--略--> </menuitem> </odoo>查看效果注意:实践时发现,通过界面点击,访问一些菜单界面时,会在菜单访问URL(参见菜单访问自动生成的URL)中自动添加model,view_type等参数,也就是说会自动访问模块相关模型,如果此时没有对应模型的访问权限(至少需要 read权限),那么即便拥有对应菜单的访问权限,界面上也看不到对应的菜单,笔者尝试过在浏览器中直接通过菜单链接(形如二级导航菜单http://localhost:8888/web#action=85&cids=1&menu_id=127)访问菜单,发现界面上不会显示任何菜单。菜单访问自动生成的URLhttp://localhost:8888/web#action=85&model=estate.property&view_type=kanban&cids=1&menu_id=70通过上述方式实现的菜单访问权限控制,实际是通过控制是否隐藏菜单实现的,也就说,如果知道未授权菜单ID,还是可以通过菜单ID拼接菜单URL进行未授权访问。模型访问权限(Access Rights,表级别)当模型中没有定义任何访问权限时,odoo会认为没有任何用户可以访问数据,并在日志中打印:2022-12-14 09:01:38,994 32508 WARNING odoo odoo.modules.loading: The model estate.property has no access rules, consider adding one. E.g. access_estate_property,access_estate_property,model_estate_property,base.group_user,1,0,0,0访问权限被定义为ir.model.access 模型记录。每个访问权限关联一个模型,一个group(针对全局访问,没有组) 和一系列权限:create, read, write 和unlink(等同于delete)。这些访问权限通常定义在security/ir.model.access.csv文件中。test.model模型访问权限配置示例id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink access_test_model,access_test_model,model_test_model,base.group_user,1,0,0,0id 自定义外部标识,模块中保持唯一,一般命名为 access_模型名称_用户组名称name 自定义ir.model.access的名称,一般命名沿用id取值即可model_id/id 、model_id:id 代指需要应用访问权限的模型。标准格式为 model_<model_name>,其中, <model_name>为模块中_name 替换.为_后的_name 的值group_id/id 、group_id:id 代指需应用访问权限的组,即指定哪个组拥有如下访问权限,如果指定组不是在当前模块中定义的组,需要指定模块名称,形如module_name.groupName。组名一般命名为group_模型名称_权限,形如group_estate_property_read 。如果 group_id为空,则意味着授权给所有用户(非雇员(employees) ,比如 portal 或者public用户).perm_read,perm_write,perm_create,perm_unlink: 分别代表create(创建), read(只读/查询), write (编辑/更新)和unlink(删除)权限,1表示有访问权限,0-表示无权限具体到实际应用时,为了更灵活的权限管理,一般会为模型的增删改查操作分别定义权限。授权给用户的模型访问权限,可通过点击Settings -> Users & Groups -> Users用户详情页Access Rights按钮查看。应用实例xml数据文件的方式定义房地产模型访问权限estate/security/security_estate_property_model_groups.xml<odoo> <data noupdate="1"> <record id="estate_property_model_groups" model="ir.module.category"> <field name="name">[房地产]模型权限</field> <field name="sequence">1</field> </record> <!--######################## [房地产]模型 ########################--> <!-- [房地产]模型 增删改查 --> <record id="group_estate_property_read" model="res.groups"> <field name="name">[房地产]模型 只读</field> <field name="category_id" ref="estate_property_model_groups"/> <field name="implied_ids" eval="[(4, ref('base.group_user'))]"/> <field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/> </record> <record id="group_estate_property_write" model="res.groups"> <field name="name">[房地产]模型 更新</field> <field name="category_id" ref="estate_property_model_groups"/> <field name="implied_ids" eval="[(4, ref('base.group_user'))]"/> <field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/> </record> <record id="group_estate_property_create" model="res.groups"> <field name="name">[房地产]模型 创建</field> <field name="category_id" ref="estate_property_model_groups"/> <field name="implied_ids" eval="[(4, ref('base.group_user'))]"/> <field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/> </record> <record id="group_estate_property_delete" model="res.groups"> <field name="name">[房地产]模型 删除</field> <field name="category_id" ref="estate_property_model_groups"/> <field name="implied_ids" eval="[(4, ref('base.group_user'))]"/> <field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/> </record> </data> </odoo>estate/security/ir.model.access.csvid,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink access_estate_property_group_estate_property_read,access_estate_property_group_estate_property_read,model_estate_property,group_estate_property_read,1,0,0,0 access_estate_property_group_estate_property_write,access_estate_property_group_estate_property_write,model_estate_property,group_estate_property_write,1,1,0,0 access_estate_property_group_estate_property_create,access_estate_property_group_estate_property_create,model_estate_property,group_estate_property_create,1,0,1,0 access_estate_property_group_estate_property_delete,access_estate_property_group_estate_property_delete,model_estate_property,group_estate_property_delete,1,0,0,1estate/__manifest__.py#!/usr/bin/env python # -*- coding:utf-8 -*- { 'name': 'estate', 'depends': ['base'], 'data':[ 'security/security_estate_property_menu_groups.xml', 'security/security_estate_property_model_groups.xml', 'security/ir.model.access.csv', //...略 ] }查看效果打开用户编辑界面记录规则(Record Rules,记录级别)记录规则是允许某个操作必须满足的条件。记录规则按照访问权限逐条记录评估。默认允许的记录规则:如果授予模型访问权限(Access Rights),并且没有规则适用于用户的操作和模型,则授予访问权限记录规则保存在ir.rule模型表里,我们通过管理ir_rule表中的记录,即可控制记录的访问权限定义规则示例:xml数据文件的方式定义房地产模型记录访问规则<odoo> <data noupdate="0"> <!--######################## [房地产]模型记录规则 ########################--> <record id="estate_property_record_read_rule" model="ir.rule"><!--id:外部规则id,供代码或者xml中引用 --> <field name="name">[房地产]模型记录规则</field> <field name="model_id" ref="model_estate_property"/> <field name="domain_force">[('create_uid', '=', user.id)]</field><!--仅显示用户自己创建的记录--> <field name="groups" eval="[(4, ref('group_estate_property_record_read'))]"/> <!--操作权限(仅作用于经domain_force滤后的记录)--> <field name="perm_read" eval="1"/> <field name="perm_write" eval="1"/> <field name="perm_create" eval="1"/> <field name="perm_unlink" eval="1"/> </record> </data> </odoo>name规则名称model_id需要应用规则的模型,标准格式为 model_<model_name>,其中, <model_name>为模块中_name 替换.为_后值groups指定规则需要作用、不作用于哪些组(res.groups)。可以指定多个组。如果未指定组,规则为gobal规则。规则与组的关联关系存在rule_group_rel表中global根据“groups”计算,提供了对规则是否全局状态的轻松访问。eval="True"、eval="1"则表示全局规则,eval="False"、eval="0"则表示非全局规则domain_force指定为domain的谓词,如果该domain与记录匹配,则规则允许所选操作,否则禁止。可以简单的理解为指定过滤条件,用户只能访问符合本过滤条件的记录,配置为 [(1,'=',1)]则表示匹配所有记录。domain是一个可以使用以下变量的python表达式:timePython的 time 模块user以单例记录集(singleton recordset)表示的当前用户company_id当前用户,当前所选的公司的公司id(非记录集)。company_ids当前用户可以访问的公司ID列表(非记录集)。 查看Security rules 获取更多详细信息。官方文档:The perm_method have completely different semantics than for ir.model.access: for rules, they specify which operation the rules applies for. If an operation is not selected, then the rule is not checked for it, as if the rule did not exist.All operations are selected by default译文:perm_method 具有与 ir.model.access完全不同的语义:对于规则,它们指定规则需要应用的操作。如果(规则)未选择某个操作,则不会为该操作检查规则,就像该规则不存在一样。规则默认适用所有操作。笔者实践发现:如果创建了规则,但是没有授权给用户,那对于该用户来说,该规则不起作用,就像该规则不存在一样。perm_method的eval值不能同时为"False"、"0",否则会违反 ir_rule表的检查约束ir_rule_no_access_rights:CHECK (perm_read!=False or perm_write!=False or perm_create!=False or perm_unlink!=False)将任意一个perm_method设置为eval="True"、eval="1" ,并将规则授权给用户,规则生效,所以我个人理解,目前记录规则,就是用于过滤记录的,通过domain_force控制哪些记录可以显示给用户规则默认适用所有操作。perm_create``perm_read`perm_writeperm_unlink授权给用户的记录访问规则,可通过点击Settings -> Users & Groups -> Users用户详情页Record Rules按钮查看。全局规则(Global rules) VS 组规则(group rules)全局规则和组规则在组成和组合方式上存在很大差异:全局规则和全局规则之间取交集,如果两个全局规则都生效,则必须满足两者才能授予访问权限,这意味着添加全局规则总是会进一步限制访问。组规则和组规则之间取并集,如果两个组规则都生效,则满足其中之一就可以授予访问权限。这意味着添加组规则可以扩展访问,但不能超出全局规则定义的范围。全局规则集和组规则集之间取交集,这意味着添加到给定全局规则集的第一个组规则将限制访问。危险提示创建多个全局规则是有风险的,因为可能创建不重叠的规则集,这将删除所有访问权限应用实例estate/security/security_estate_property_model_groups.xml,新增group_estate_property_record_query组<odoo> <data noupdate="1"> <record id="estate_property_model_groups" model="ir.module.category"> <field name="name">[房地产]模型权限</field> <field name="sequence">1</field> </record> <!--######################## [房地产]模型 ########################--> <!-- [房地产]模型 增删改查 --> <record id="group_estate_property_read" model="res.groups"> <field name="name">[房地产]模型 只读</field> <field name="category_id" ref="estate_property_model_groups"/> <field name="implied_ids" eval="[(4, ref('base.group_user'))]"/> <field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/> </record> <record id="group_estate_property_write" model="res.groups"> <field name="name">[房地产]模型 更新</field> <field name="category_id" ref="estate_property_model_groups"/> <field name="implied_ids" eval="[(4, ref('base.group_user'))]"/> <field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/> </record> <record id="group_estate_property_create" model="res.groups"> <field name="name">[房地产]模型 创建</field> <field name="category_id" ref="estate_property_model_groups"/> <field name="implied_ids" eval="[(4, ref('base.group_user'))]"/> <field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/> </record> <record id="group_estate_property_delete" model="res.groups"> <field name="name">[房地产]模型 删除</field> <field name="category_id" ref="estate_property_model_groups"/> <field name="implied_ids" eval="[(4, ref('base.group_user'))]"/> <field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/> </record> </data> <data noupdate="0"> <!--######################## [房地产]模型记录 ########################--> <record id="group_estate_property_record_query" model="res.groups"> <field name="name">[房地产]模型记录 查询</field> <field name="category_id" ref="estate_property_model_groups"/> <field name="implied_ids" eval="[(4, ref('base.group_user'))]"/> <field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/> </record> </data> </odoo>estate/security/security_estate_property_model_record_rules.xml<odoo> <data noupdate="0"> <!--######################## [房地产]模型记录规则 ########################--> <record id="estate_property_record_read_rule" model="ir.rule"> <field name="name">[房地产]模型记录规则</field> <field name="model_id" ref="model_estate_property"/> <field name="domain_force">[('create_uid', '=', user.id)]</field> <field name="groups" eval="[(4, ref('group_estate_property_record_read'))]"/> <field name="perm_read" eval="1"/> <field name="perm_write" eval="1"/> <field name="perm_create" eval="1"/> <field name="perm_unlink" eval="1"/> </record> </data> </odoo>estate/__manifest__.py#!/usr/bin/env python # -*- coding:utf-8 -*- { 'name': 'estate', 'depends': ['base'], 'data':[ 'security/security_estate_property_menu_groups.xml', 'security/security_estate_property_model_groups.xml', 'security/security_estate_property_model_record_rules.xml', 'security/ir.model.access.csv', //...略 ] }查看效果参考连接:https://www.odoo.com/documentation/14.0/zh_CN/developer/reference/addons/security.html#record-rules字段权限(Field Access,字段级别)ORM字段可以具有提供组列表的groups属性(值为逗号分隔的组XML ID列表,如groups='base.group_user,base.group_system')注意:groups属性值格式:moduleName.groupName,其中moduleName为groupName组所在模块名称,必不可少。如果当前用户不在列出的组中,他将无权访问该字段:将自动从请求的视图中删除受限制的字段从fields_get()响应中删除受限制的字段尝试(显式的)读取或写入受限字段会导致访问错误修改estate\security\security_estate_property_model_groups.xml,添加group_estate_property_selling_price_field组<data noupdate="0"> <!--######################## [房地产]模型记录 ########################--> <record id="group_estate_property_selling_price_field" model="res.groups"> <field name="name">[房地产]模型 售价字段</field> <field name="category_id" ref="estate_property_model_groups"/> <field name="implied_ids" eval="[(4, ref('base.group_user'))]"/> <field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/> </record> <record id="group_estate_property_record_query" model="res.groups"> <field name="name">[房地产]模型记录 查询</field> <field name="category_id" ref="estate_property_model_groups"/> <field name="implied_ids" eval="[(4, ref('base.group_user'))]"/> <field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/> </record> </data>查看效果修改estate\views\estate_property_views.xml视图selling_price字段,添加groups属性<field name="selling_price" string="Selling Price" groups="estate.group_estate_property_selling_price_field"/>验证,发现界面上,未授权上述框选权限的用户已经看不到上述字段了注意:通过为当前视图中目标字段添加groups属性实现的权限控制仅作用于当前视图,如果希望当前视图模型(Model)的所有视图中,对该字段实现统一的权限控制话,需要在模型定义中,为目标字段添加groups属性,如下:selling_price = fields.Float('selling price', digits=(8, 2), readonly=True, copy=False, groups="estate.group_estate_property_selling_price_field")参考连接:https://www.odoo.com/documentation/14.0/zh_CN/developer/reference/addons/security.html#field-access扩展:在页面从数据库加载视图时,会通过load_view接口,会调用fields_view_get方法,可以重写此方法以控制xml显示的效果(参考网络资料,未实践验证)按钮权限(按钮级别)类似字段权限控制,仅需在在对应视图中,为目标按钮<button>元素,添加groups属性即可。角色定义新增并安装base_user_role模块base_user_role模块的作用可以简单理解为,按自定义维度将所需权限组组合在一起,组成角色,实现批量授权的功能。解压下载的base_user_role-12.0.2.1.2.zip文件,对解压后的部分文件做如下修改:base_user_role\models\user.py,base_user_role\models\role.py去除上述两个文件中的所有@api.multi修饰符,解决安装报错问题:AttributeError: module 'odoo.api' has no attribute 'multi'说明:Odoo 13.0开始,移除multi,multi作为默认实现。base_user_role/views/role.xml修改<record model="ir.actions.act_window" id="action_res_users_role_tree"> <field name="name">Roles</field> <field name="type">ir.actions.act_window</field> <field name="res_model">res.users.role</field> <field name="view_type">form</field> <field name="view_id" ref="view_res_users_role_tree"/> </record>为<record model="ir.actions.act_window" id="action_res_users_role_tree"> <field name="name">Roles</field> <field name="type">ir.actions.act_window</field> <field name="res_model">res.users.role</field> <field name="view_mode">form</field> <field name="view_id" ref="view_res_users_role_tree"/> </record>解决安装报错问题:odoo.tools.convert.ParseError: while parsing file:/d:/codepojects/odoo14/custom/base_user_role/views/role.xml:63, near然后,将解压目录下base_user_role整个文件夹拷贝odoo14\custom目录下,最后,重启服务并安装该模块。安装成功后,Settings -> Users & Companies菜单下,将新增Roles子菜单(笔者实践发现无法通过该页面新增角色并关联用户),Settings -> Users & Companies -> Users 用户记录详情页将新增Roles Tab页新增并安装estate_role模块为了统一管理权限组,考虑新增一个单独的应用模块estate_role,模块文件组织结构如下custom/estate_role │ __init__.py │ __manifest__.py │ ├─security │ security_estate_property_menu_groups.xml │ security_estate_property_model_groups.xml │ security_roles.xml │ └─viewsodoo14\custom\estate_role\security\security_roles.xml<?xml version="1.0" encoding="utf-8"?> <odoo> <data noupdate="1"> <record id="group_role_base_user" model="res.users.role"> <field name="name">基础用户</field> <field name="implied_ids" eval="[ (4, ref('base.group_user')), ]"/> </record> <record id="group_role_multi_company" model="res.users.role"> <field name="name">多公司</field> <field name="implied_ids" eval="[ (4, ref('base.group_multi_company')), ]"/> </record> <record id="group_role_devops" model="res.users.role"> <field name="name">运维</field> <field name="implied_ids" eval="[ (4, ref('estate_role.group_estate_property_root_menu')), (4, ref('estate_role.group_estate_property_advertisements_menu')), (4, ref('estate_role.group_estate_property_advertisements_properties_menu')), (4, ref('estate_role.group_estate_property_read')), (4, ref('estate_role.group_estate_property_write')), (4, ref('estate_role.group_estate_property_create')) ]"/> </record> </data> </odoo>odoo14\custom\estate_role\__init__.py文件内容为空odoo14\custom\estate_role\__manifest__.py{ "name": "Estate Roles", "license": "LGPL-3", "depends": ["base_user_role"], "data": [ "security/security_estate_property_menu_groups.xml", "security/security_estate_property_model_groups.xml", "security/security_roles.xml" ], "installable": True, }说明:odoo14\custom\estate\__manifest__.py data列表中已去除上述两个groups文件重启服务并安装estate_role模块查看效果用户详情页面,查看用户权限,发现新增 User Roles编辑用户,勾选图中的角色,保存,发现和角色关联的权限组都会被自动勾选了。注意:取消勾选已授予的角色,并保存,不会自动取消勾选角色关联的权限组,即取消授予角色操作,不会取消通过授予角色授予给用户的权限组已授予角色给用户的情况下,取消勾选某个权限组并保存,如果该权限组和授予给用户的角色关联,则无法取消勾选的权限组,因为角色关联了该权限组权限页面勾选并保存的角色,不会在用户详情页的Roles Tab页中显示除了通过在用户详情页-权限(Access Rights)Tab页面,选取角色为用户批量授权外,还可以在用户详情页的Roles Tab页中为用户添加角色来实现批量授权。参考连接https://www.odoo.com/documentation/14.0/zh_CN/developer/reference/addons/security.html
修改环境Model.with_context([context][, **overrides]) -> records[源代码]返回附加到扩展上下文的此记录集的新版本。扩展上下文是提供的合并了overrides的context,或者是合并了overrides当前context# current context is {'key1': True} r2 = records.with_context({}, key2=True) # -> r2._context is {'key2': True} r2 = records.with_context(key2=True) # -> r2._context is {'key1': True, 'key2': True}需要注意的是,上下文是和记录集绑定的,修改后的上下文并不会在其它记录集中共享。Model.with_user(user)[源代码]以非超级用户模式返回附加到给定用户的此记录集的新版本,即传入一条用户记录并返回该用户的环境,除非user是超级用户(按照约定,超级用户始终处于超级用户模式)Model.with_company(company)[源代码]返回具有已修改上下文的此记录集的新版本,这样:result.env.company = company result.env.companies = self.env.companies | company参数company (res_company 或者 int) – 新环境的主公司警告当当前用户使用未经授权的公司时,如果不是在sudoed环境中访问该公司,则可能会触发AccessErrorModel.with_env(env)[源代码]返回附加到所提供环境的此记录集的新版本。参数env (Environment) –警告新环境将不会从当前环境的数据缓存中受益,因此稍后的数据访问可能会在从数据库重新获取数据时产生额外的延迟。返回的记录集具有与self相同的预取对象。Model.sudo([flag=True])[源代码]根据flag,返回启用或禁用超级用户模式的此记录集的新版本。超级用户模式不会更改当前用户,只是绕过访问权限检查。警告使用sudo可能会导致数据访问跨越记录规则的边界,可能会混淆要隔离的记录(例如,多公司环境中来自不同公司的记录)。这可能会导致在多条记录中选择一条记录的方法产生不直观的结果,例如获取默认公司或选择物料清单。注解因为必须重新评估记录规则和访问控制,所以新的记录集将不会从当前环境的数据缓存中受益,因此以后的数据访问可能会在从数据库重新获取时产生额外的延迟。返回的记录集具有与self相同的预取对象。SQL执行环境上的cr属性是当前数据库事务的游标,允许直接执行SQL,无论是对于难以使用ORM表达的查询(例如复杂join),还是出于性能原因self.env.cr.execute("some_sql", params)由于模型使用相同的游标,并且Environment保存各种缓存,因此当在原始SQL中更改数据库时,这些缓存必须失效,否则模型的进一步使用可能会变得不连贯。在SQL中使用CREATE、UPDATE或DELETE,但不使用SELECT(只读取数据库)时,必须清除缓存。注解可以使用 invalidate_cache()执行缓存的清理Model.invalidate_cache(fnames=None, ids=None)[源代码]修改某些记录后,使记录缓存无效。如果fnames和ids都为None,则清除整个缓存。参数:fnames–已修改字段的列表,None表示所有字段ids–修改的记录ID的列表,None表示所有记录警告执行原始SQL绕过ORM,从而绕过Odoo安全规则。请确保在使用用户输入时对查询进行了清洗,如果确实不需要使用SQL查询,请使用ORM实用程序。常用ORM方法Common ORM methods创建/更新(Create/update)Model.create(vals_list)→ records[源代码]为模型创建新记录使用字典列表vals_list中的值初始化新记录,如果需要,使用default_get()中的值参数vals_list (list) --模型字段的值,作为字典列表:[{'field_name':field_value,…},…]为了向后兼容,vals_list可以是一个字典。它被视为单个列表[vals],并返回一条记录。有关详细信息请参见write()返回创建的记录引发AccessError–如果用户对请求的对象没有创建权限如果用户尝试绕过访问规则在请求的对象上创建ValidationError – 如果用户尝试为字段输入不在选择范围内的无效值UserError–如果将在对象层次结构中创建循环,操作的一个结果(例如将对象设置为其自己的父对象)Model.copy(default=None)[源代码]使用默认值更新拷贝的记录self参数default (dict) – 用于覆盖复制记录的原始值的字段值的字典,形如: {'field_name': overridden_value, ...}返回新记录Model.default_get(fields_list)→ default_values[源代码]返回fields_list中字段的默认值。默认值由上下文、用户默认值和模型本身决定参数fields_list (list) – 需要获取其默认值的字段名称返回将字段名映射到相应的默认值(如果它们具有的话)的字典。返回类型dict注解不考虑未请求的默认值,不需要为名称不在fields_list中的字段返回值。Model.name_create(name)→ record[源代码]通过调用create()创建新记录,调用时create()时只提供一个参数值:新记录的显示名称。新记录将使用适用于此模型的任何默认值初始化,或通过上下文提供。create()的通常行为适用参数name – 要创建记录的显示名称返回类型元组返回创建的记录的name_get() 成对值Model.write(vals)[源代码]使用提供的值更新当前记录集中的所有记录参数:vals(dict) –需要更新的字段及对应的值,比如:{'foo': 1, 'bar': "Qux"} ,将设置foo值为1,bar为"Qux",如果那些为合法的话,否则将触发错误。需要特别注意的是,需要更新的字段越多,更新速度越慢(笔者实践时发现的,但是没验证是否和字段类型有关,特别是关系字段,关系字段的更新可能会调用对应模型的write方法,该方法如果被重写了,也可能会导致耗时的增加,总的来说,遵守一个原则,仅更新需要更新的字段)引发AccessError–如果用户对请求的对象没有创建权限如果用户尝试绕过访问规则在请求的对象上创建ValidationError – 如果用户尝试为字段输入不在选择范围内的无效值UserError–如果将在对象层次结构中创建循环,操作的一个结果(例如将对象设置为其自己的父对象)(官方原文:if a loop would be created in a hierarchy of objects a result of the operation (such as setting an object as its own parent)对于数字型字段(odoo.fields.Integer,odoo.fields.Float) ,值必须为对应类型对于 odoo.fields.Boolean, 值必须为bool类型对于odoo.fields.Selection, 值必须匹配选择值(通常为str,有时为int)对于odoo.fields.Many2one,值必须为记录的数据库标识其它非关系字段,使用字符串值危险出于历史和兼容性原因,odoo.fields.Date和odoo.fields.Datetime字段使用字符串作为值(写入和读取),而不是date或datetime。这些日期字符串仅为UTC格式,并根据odoo.tools.misc.DEFAULT_SERVER_DATE_FORMAT和odoo.tools.miisc.DEFAULT_SERVER _DATETIME_FORMAT进行格式化odoo.fields.One2many和odoo.fields.Many2many使用特殊的“命令”格式来操作存储在字段中/与字段关联的记录集。这种格式是一个按顺序执行的三元组列表,其中每个三元组都是要对记录集执行的命令。并非所有命令都适用于所有情况。可能的命令有:(0, 0, values)从提供的values字典创建新记录,形如 (0, 0, {'author': user_root.id, 'body': 'one'})。(1, id, values)使用values字典中的值更新id值为给定id值的现有记录。不能在 create()中使用。(2, id, 0)从记录集中删除id为指定id的记录,然后(从数据库中)删除它不能在 create()中使用。(3, id, 0)从记录集中删除id为指定id的记录,但不删除它。不能在 create()中使用。(4, id, 0)添加一条id为指定id的已存在记录到记录集(5, 0, 0)从结果集移除所有记录, 等价于显示的对每条记录使用命令3。 不能在 create()中使用。(6, 0, ids)根据ids列表,替换所有已存在记录, 等价于使用命令(5, 0, 0),随后对ids中的每个id使用命令(4, id, 0)。实践发现,针对One2many字段,如果ids对应记录的Many2one字段没存储当前模型主键ID值时,无法使用该命令。实际使用时,这些命令可以组合使用,如下,给fieldName设置值时,会先指定命令5,在执行命令 0Model.write({'fieldName': [(5, 0, 0), (0, 0, dict_value)]})Model.flush(fnames=None, records=None)[源代码]处理所有待定的计算(在所有模型上),并将所有待定的更新刷新到数据库中(Process all the pending computations (on all models), and flush all the pending updates to the database)。参数fnames – 需要刷新的字段名称列表。如果给定,则将处理范围限制为当前模型的给定字段。records – 如果给定 (协同 fnames), 限制处理范围为给定的记录搜索/读取(Search/Read)Model.browse([ids])→ records[源代码]在当前环境中查询ids参数指定的记录并返回记录结果集,如果为提供参数,或者参数为[],则返回空结果集self.browse([7, 18, 12]) res.partner(7, 18, 12)参数ids (int 或者 list(int) 或 None) – id(s)返回recordsetModel.search(args[, offset=0][, limit=None][, order=None][, count=False])[源代码]基于args搜索域搜索记录参数args – 搜索域。使用[]代表匹配所有记录。offset (int) – 需要忽略的结果记录数 (默认: 0)limit (int) – 最大返回记录数 (默认返回所有)order (str) – 排序字符串count (bool) – 如果为True,仅计算并返回匹配的记录数 (默认: False)返回最多limit条符合搜索条件的记录引发AccessError –如果用户尝试绕过访问规则读取请求的对象Model.search_count(args) → int[源代码]返回当前模型中匹配提供的搜索域args的记录数.Model.name_search(name='', args=None, operator='ilike', limit=100)→ records[源代码]搜索比较显示名称与给定name匹配(匹配方式为给定operator),且匹配搜索域args的记录例如,这用于基于关系字段的部分值提供建议。有时被视为name_get()的反函数,但不能保证是。此方法等效于使用基于display_name的搜索域调用search(),然后对搜索结果执行“name_get()”关于搜索结果参数name (str) – 需要匹配的名称args (list) – 可选的搜索域, 进一步指定限制operator (str) – 用于匹配name的域操作,比如 'like' 或者 '='limit (int) – 可选参数,返回最大记录数返回类型list返回所有匹配记录的对值(id, text_repr)列表Model.read([fields])[源代码]读取self中记录的指定字段, 低阶/RPC方法。Python代码中,优选browse().参数fields – 需要返回的字段名称(默认返回所有字段)返回字典的列表,该字典为字段名称同其值映射,每条记录一个字典引发AccessError – 如果用户没有给定记录的读取权限Model.read_group(domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True)[源代码]获取列表视图中按给定groupby字段分组的记录列表。参数domain (list) – 搜索域。使用[]表示匹配所有fields (list) – 对象上指定的列表视图中存在的字段列表。每个元素要么是“field”(字段名,使用默认聚合),要么是“field:agg”(使用聚合函数“agg”聚合字段),要么就是“name:agg(field)”(使用“agg'聚合字段并将其当做“name”返回)。可能的聚合函数为PostgreSQL提供的函数(https://www.postgresql.org/docs/current/static/functions-aggregate.html),且“count_distict”,具有预期含义。groupby (list) – 记录分组依据的分组依据描述列表。groupby描述要么是字段(然后将按该字段分组),要么是字符串“field:groupby_function”。目前,唯一支持的函数是day、week、month、quarter或year,它们只适用于date/datetime字段offset (int) – 需要跳过的记录数,可选参数。limit (int) – 需要返回的最大记录数,可选参数orderby (str) – 排序字符串(当前仅支持Many2one字段)。可选参数。lazy (bool) – 如果为True,则结果只按第一个groupby分组,其余groupby放入__context键中。如果为False,则在一个调用中完成所有groupby。返回字典列表(每条记录一个字典)。包含:按groupby参数中指定字段分组后的字段的值__domain: 指定搜索条件的元组的列表__context: 拥有类似groupby参数的字典返回类型[{‘field_name_1’: value, …]引发AccessError –如果用户对所请求的对象没有读取权限,如果用户尝试绕过对访问规则读取所请求对象Model.copy_data()拷贝当前模型记录的数据,返回一个字典,字典key为模型字段名称,key值为对应的字段值。注意:返回字典key不包含Odoo系统自动生成的模型表字段:create_uid,create_date,write_date,write_uid,id字段/视图(Fields/Views)sModel.fields_get([fields][, attributes])[源代码]返回每个字段的定义返回的值是包含字典的字典(按字段名索引)。包括继承字段。将转换string、help和selection(如果存在)属性参数fields – 字段列表, 如果未提供或者为[]则表示所有attributes – 每个字段需要返回的属性描述列表。 如果未提供或者为[]则表示所有Model.fields_view_get([view_id | view_type='form'])[源代码]获取所请求视图的详细组成,如字段、模型、视图架构参数view_id (int) – 视图的ID或者Noneview_type (str) – 返回视图的类型,如果view_id为None的话(‘form’, ‘tree’, …)toolbar (bool) – 设置为True以包含上下文操作submenu – 已弃用返回请求视图的组成(包括继承的视图和扩展)返回类型dict引发AttributeError –如果继承的视图具有除“before”、“after”、“inside”、“replace”以外的未知位置则如果在父视图中找到除“position”以外的标记Invalid ArchitectureError – 如果框架中有定义form, tree, calendar, search 等以外的视图搜索域(Search domains)域是一个标准列表,每个标准都是(field_name,operator,value)的三元组(一个“列表”或“元组”),其中:field_name (str)当前模块的字段名称 或通过Many2one,使用点符号的关系遍历,例如 'street' 或者'partner_id.country'operator(str)用于比较field_name与value的运算符。有效运算符为:=等于!=不等于>大于>=大于等于<小于<=小于等于=?未设置或者等于(如果value为None或者False则返回True,否则与=一样)=like将field_name同value模式匹配。模式中的下划线_匹配任何单个字符;百分号%匹配任何零个或多个字符的字符串like将field_name同%value%模式匹配。类似=like,但是匹配前使用%包装valuenot like不匹配 %value% 模式ilike大小写敏感的likenot ilike大小写敏感的 not like=ilike大小写敏感的 =likein等于value中的任意项,value应该为项列表not in不等于value中的任意项child_of是value记录的child(后代)(value可以是一个项或一个项列表)。考虑模型的语义(即遵循由_parent_name命名的关系字段)。parent_of是value记录的parent(祖先)(value可以是一个项或一个项列表)。考虑模型的语义(即遵循由_parent_name命名的关系字段)value变量类型,必须可同命名字段比较(通过 operator)可以使用前缀形式的逻辑运算符组合域条件:'&'逻辑 AND, 默认操作,以将条件相互结合。Arity 2 (使用下2个标准或组合)'|'逻辑 OR arity 2'!'逻辑 *NOT * arity 1例子:搜索来自比利时或德国名为ABC,且语言不为英语的合作伙伴:[('name','=','ABC'), ('language.code','!=','en_US'), '|',('country_id.code','=','be'), ('country_id.code','=','de')]该域被解释为:(name is 'ABC') AND (language is NOT english) AND (country is Belgium OR Germany)unlinkModel.unlink()[源代码]删除当前记录集中的记录引发AccessError –如果用户没有所请求对象的unlink权限如果用户尝试绕过访问规则对请求对象执行unlinkUserError –如果记录为其它记录的默认属性记录(集)信息Model.ids返回与self对应的真实记录IDodoo.models.env返回给定记录集的环境。类型EnvironmentModel.exists() → records[源代码]返回self中存在的记录子集并将删除的记录标记为缓存中的记录. 可用作对记录的测试:if record.exists(): ...按约定,将新记录作为现有记录返回Model.ensure_one()[源代码]验证当前记录集只拥有一条记录引发odoo.exceptions.ValueError – len(self) != 1Model.name_get()→ [id, name, ...][源代码]返回self中记录的文本表示形式。默认情况下,为display_name字段的值。返回每个记录的 (id, text_repr) 对值列表返回类型list(tuple)Model.get_metadata()[源代码]返回关于给定记录的元数据返回每个请求记录的所有权字典列表 list of ownership dictionaries for each requested record返回类型具有以下关键字的字典列表:id: 对象IDcreate_uid: 创建记录的用户create_date: 创建记录的日期write_uid: 上次更改记录的用户write_date: 上次更改记录的日期xmlid: 用于引用此记录的XML ID(如果有),格式为module.namenoupdate: 一个布尔值,指示记录是否将被更新操作记录集是不可变的,但可以使用各种集合操作组合同一模型的集合,从而返回新的记录集record in set 返回 record (必须为只包含一个元素的记录集) 是否在 set中。 record not in set 则刚好相反set1 <= set2 andset1 < set2 返回set1是否是set2的子集set1 >= set2 and set1 > set2 返回set1是否是set2的超集set1 | set2 返回两个记录集的并集。一个包含出现在两个源记录集中的所有记录的记录集set1 & set2 返回两个记录集的交集。一个只包含同时存在两个源记录集中的记录的记录集。set1 - set2 返回一个包含仅出现在set1中的记录的记录集记录集是可迭代的,因此通常的Python工具可用于转换(map(),sorted(),ifilter(),…),然后这些函数返回list或iterator,删除对结果调用方法或使用集合操作的能力。因此,记录集提供以下返回记录集本身的操作(如果可能):FilterModel.filtered(func)[源代码]参数func (可调用对象 或者 str) – 一个函数或者点分字段名称序列返回满足func的记录集,可能为空。# only keep records whose company is the current user's records.filtered(lambda r: r.company_id == user.company_id) # only keep records whose partner is a company records.filtered("partner_id.is_company")Model.filtered_domain(domain)[源代码]MapModel.mapped(func)[源代码]对self中的所有记录应用func,并将结果作为列表或记录集返回(如果func返回记录集)。后者返回的记录集的顺序是任意的。参数func (可调用对象 或 str) – 一个函数或者点分字段名称序列返回如果func为False则返回self 作用于所有self中记录的func的返回结果返回类型list 或 recordset# returns a list of summing two fields for each record in the set records.mapped(lambda r: r.field1 + r.field2)提供的函数可以是获取字段值的字符串:# returns a list of names records.mapped('name') # returns a recordset of partners records.mapped('partner_id') # returns the union of all partner banks, with duplicates removed records.mapped('partner_id.bank_ids')注解V13开始, 支持多多关系字段访问,像mapped调用那样工作:records.partner_id # == records.mapped('partner_id') records.partner_id.bank_ids # == records.mapped('partner_id.bank_ids') records.partner_id.mapped('name') # == records.mapped('partner_id.name')SortModel.sorted(key=None, reverse=False)[源代码]返回按key排序的记录集self参数key (可调用对象或者str 或者 None) – 一个参数的函数,为每个记录返回一个比较键,或字段名,或None,如果为None,记录按照默认模型的顺序排序reverse (bool) – 如果为True, 返回逆序排序的结果# sort records by name records.sorted(key=lambda r: r.name)继承与扩展(Inheritance and extension)Odoo提供三种不同的机制,以模块化方式扩展模型:从现有模型创建新模型,向副本中添加新信息,但保留原始模块扩展其他模块中定义的模型,替换以前的版本将模型的一些字段委派给它包含的记录经典继承当同时使用_inherit和 _name 属性时,Odoo使用现有模型(通过_inherit提供)作为base创建新模型。新模型从其base中获取所有字段、方法和元信息(默认值等)。class Inheritance0(models.Model): _name = 'inheritance.0' _description = 'Inheritance Zero' name = fields.Char() def call(self): return self.check("model 0") def check(self, s): return "This is {} record {}".format(s, self.name) class Inheritance1(models.Model): _name = 'inheritance.1' _inherit = 'inheritance.0' _description = 'Inheritance One' def call(self): return self.check("model 1")使用它们:a = env['inheritance.0'].create({'name': 'A'}) b = env['inheritance.1'].create({'name': 'B'}) a.call() b.call()输出:“This is model 0 record A” “This is model 1 record B”第二个模型继承了第一个模型的check方法及其name字段,但重写了call方法,就像使用标准Python继承一样。说明:以上为官方文档给出的案例,笔者实践发现是无法直接运行的。模型继承会继承父类中的所有属性,会拷贝字段、属性和方法。可以同时继承多个模型,比如:_inherit = ['res.partner', 'md.status.mixin']扩展当使用_inherit但省略_name时,新模型将替换现有模型,实质上就是在原有模型上扩展。这对于将新字段或方法添加到现有模型(在其他模块中创建)或自定义或重新配置它们(例如更改其默认排序顺序)非常有用:class Extension0(models.Model): _name = 'extension.0' _description = 'Extension zero' name = fields.Char(default="A") def func(): print('test a') class Extension1(models.Model): _inherit = 'extension.0' description = fields.Char(default="Extended") def func(): # 重写函数 print('test b')record = env['extension.0'].create({}) record.read()[0]返回:{'name': "A", 'description': "Extended"}注解它还会返回各种自动生成的字段,除非它们被禁用了。env['extension.0'].func({})返回:test b注意:如果同时继承抽象模块和非抽象模块,并把_name配置为非抽象模块,抽象模块的字段也会添加到非抽象模块对应的表委托(Delegation)第三种继承机制提供了更大的灵活性(可以在运行时更改),但威力更小:使用_inherits模型,将当前模型中未找到的任何字段的查找委托给“children”模型。委托通过Reference执行在父模型上自动设置的字段。主要区别在于意义。使用委托时,模型has one而不是is one,从而将关系转换为组合而不是继承:class Screen(models.Model): _name = 'delegation.screen' _description = 'Screen' size = fields.Float(string='Screen Size in inches') class Keyboard(models.Model): _name = 'delegation.keyboard' _description = 'Keyboard' layout = fields.Char(string='Layout') class Laptop(models.Model): _name = 'delegation.laptop' _description = 'Laptop' _inherits = { 'delegation.screen': 'screen_id', 'delegation.keyboard': 'keyboard_id', } name = fields.Char(string='Name') maker = fields.Char(string='Maker') # a Laptop has a screen screen_id = fields.Many2one('delegation.screen', required=True, ondelete="cascade") # a Laptop has a keyboard keyboard_id = fields.Many2one('delegation.keyboard', required=True, ondelete="cascade")record = env['delegation.laptop'].create({ 'screen_id': env['delegation.screen'].create({'size': 13.0}).id, 'keyboard_id': env['delegation.keyboard'].create({'layout': 'QWERTY'}).id, }) record.size record.layout将产生结果:13.0 'QWERTY'可以直接修改委托字段:record.write({'size': 14.0})警告使用委托继承时,方法不是被继承的,只有字段警告_inherits 或多或少已实现,如果可以的话避免用它(_inherits is more or less implemented, avoid it if you can)链式的_inherits基本上没有实现,我们不对最终行为做任何保证。(chained _inherits is essentially not implemented, we cannot guarantee anything on the final behavior)字段增量定义字段定义为模型类的类属性。如果扩展了模型,还可以通过在子类上重新定义具有相同名称和类型的字段来扩展字段定义。在这种情况下,字段的属性取自父类,并由子类中给定的属性覆盖。例如,下面的第二个类仅在state字段上添加工具提示:class First(models.Model): _name = 'foo' state = fields.Selection([...], required=True) class Second(models.Model): _inherit = 'foo' state = fields.Selection(help="Blah blah blah")入门实践模型定义odoo14\custom\estate\models\estate_property_tag.py#!/usr/bin/env python # -*- coding:utf-8 -*- from odoo import models, fields class EstatePropertyTag(models.Model): _name = 'estate.property.tag' _description = 'estate property tag' _order = 'name' name = fields.Char(string='tag', required=True) color = fields.Integer(string='Color')odoo14\custom\estate\models\estate_property_offer.py#!/usr/bin/env python # -*- coding:utf-8 -*- from odoo import models, fields class EstatePropertyOffer(models.Model): _name = 'estate.property.offer' _description = 'estate property offer' property_id = fields.Many2one('estate.property', required=True) price = fields.Integer()odoo14\custom\estate\models\estate_property_type.py#!/usr/bin/env python # -*- coding:utf-8 -*- from odoo import models, fields class EstatePropertyType(models.Model): _name = 'estate.property.type' _description = 'estate property type' name = fields.Char(string='name', required=True)odoo14\custom\estate\models\estate_property.py#!/usr/bin/env python # -*- coding: utf-8 -*- from odoo import models, fields class EstateProperty(models.Model): _name = 'estate.property' _description = 'estate property table' _order = 'id desc' name = fields.Char(required=True) property_type_id = fields.Many2one("estate.property.type", string="PropertyType") tag_ids = fields.Many2many("estate.property.tag") offer_ids = fields.One2many("estate.property.offer", "property_id", string="PropertyOffer")ORM操作实践>>> self.env['estate.property.type'] estate.property.type() # 创建单条记录 >>> self.env['estate.property.type'].create({'name':'house'}) estate.property.type(1,) # 按id查询记录 >>> self.env['estate.property.type'].browse([1]) estate.property.type(1,) # 未给定id列表,或者未提供参数的情况下,返回空记录集 >>> self.env['estate.property.type'].browse() estate.property.type() >>> self.env['estate.property.type'].browse([]) estate.property.type() # 复制记录 >>> self.env['estate.property.type'].browse([1]).copy({'name':'garden'}) estate.property.type(2,) # 针对仅获取单条记录的记录集,可通过 records.fieldName 的方式引用对应字段(读取字段值,或者给字段赋值) >>> self.env['estate.property.type'].browse([2]).name 'garden' # 更新记录 >>> self.env['estate.property.type'].browse([1]).name 'house' >>> self.env['estate.property.type'].browse([1]).write({'name':'garden'}) True >>> self.env['estate.property.type'].browse([1]).name 'garden' # 针对仅获取单条记录的记录集,可通过 records.fieldName 的方式引用对应字段(读取字段值,或者给字段赋值) >>> self.env['estate.property.type'].browse([1]).name = 'house' >>> self.env['estate.property.type'].browse([1]).name 'house' # 不能直接通过以下方式,试图在write函数指定id的方式来更新记录 # 不会修改任何记录,也未新增任何记录 >>> self.env['estate.property.type'].write({'id':1, 'name':'apartment'}) True >>> self.env['estate.property.type'].browse([1]).name 'house' # 通过search api查询记录集 >>> self.env['estate.property.type'].search([]) estate.property.type(1, 2) # 批量创建记录 # 创建测试用数据 >>> self.env['estate.property.tag'].create([{'name': 'tag1', 'color': 1}, {'name': 'tag1', 'color': 2}, {'name': 'tag1', 'color': 3}]) estate.property.tag(1, 2, 3) # 注意:Many2one类型字段的值,必须设置为对应记录的主键id >>> self.env['estate.property'].create({'name': 'house in beijing', 'property_type_id': 1, 'tag_ids':[(0,0, {'name': 'tag1', 'color': 3})]}) estate.property(1,) >>> self.env['estate.property'].search([]) estate.property(1,) # 查询关系字段值 >>> self.env['estate.property'].browse([1]).property_type_id # Many2one estate.property.type(1,) >>> self.env['estate.property'].browse([1]).tag_ids # Many2many estate.property.tag(4,) # 更新Many2many关系字段值 >>> self.env['estate.property'].browse([1]).tag_ids.write({'name': 'tag4', 'color': 4}) True >>> self.env['estate.property'].browse([1]).tag_ids.color 4 >>> self.env['estate.property.tag'].search([]) estate.property.tag(1, 2, 3, 4) # 查询关系字段值 >>> self.env['estate.property'].browse([1]).offer_ids # One2many estate.property.offer() ## 更新One2many关系字段值 # 为关系字段创建关联记录 # (0, 0, values) # 从提供的`values`字典创建新记录。 >>> self.env['estate.property'].browse([1]).offer_ids = [(0, 0, {'property_id':1})] >>> self.env['estate.property'].browse([1]).offer_ids estate.property.offer(1,) >>> self.env['estate.property'].browse([1]).offer_ids.property_id estate.property(1,) # 更新关系字段所代表记录对象的属性值 # (1, id, values) # 使用 values 字典中的值更新id值为给定 id 值的现有记录。不能在create()中使用。 >>> self.env['estate.property'].browse([1]).offer_ids = [(1, 1, {'price': 30000})] >>> self.env['estate.property'].browse([1]).offer_ids.price 30000 # 删除关系字段关联记录 # (3, id, 0) # 从记录集中删除id为id的记录,但不从数据库中删除它,可以理解为仅解除关联。不能在create()中使用。 >>> self.env['estate.property'].browse([1]).offer_ids = [(3,1,0)] >>> self.env['estate.property'].browse([1]).offer_ids estate.property.offer() # 将已存在记录同关系字段关联 # (4, id, 0) # 添加一条id为id已存在记录到记录集 >>> self.env['estate.property.offer'].browse([1]) estate.property.offer(1,) >>> self.env['estate.property'].browse([1]).offer_ids = [(4,1,0)] >>> self.env['estate.property'].browse([1]).offer_ids estate.property.offer(1,) # 为关系字段一次创建多条关联记录 >>> self.env['estate.property'].browse([1]).offer_ids = [(0, 0, {'property_id':1, 'price': 100000}),(0, 0, {'property_id':1, 'price': 200000}), (0, 0, {'property_id':1, 'price': 200000}), (0, 0, {'property_id':1, 'price': 300000})] >>> self.env['estate.property'].browse([1]).offer_ids estate.property.offer(1, 2, 3, 4, 5) # 替换关系字段关联的记录 # (6, 0, ids) # 根据ids列表,替换所有已存在记录, 等价于使用命令(5, 0, 0),随后对ids中的每个id使用命令(4, id, 0)。 >>> self.env['estate.property'].browse([1]).offer_ids = [(3,1,0),(3,2,0)] >>> self.env['estate.property'].browse([1]).offer_ids estate.property.offer(3, 4, 5) >>> self.env['estate.property'].browse([1]).offer_ids = [(6, 0, [1,2])] # 报错, 因为ID 1,2 对应的记录,其Many2one字段值为null # 为Many2many关系字段创建多条关联记录 >>> self.env['estate.property'].create({'name': 'house in shanghai'}) estate.property(2,) >>> self.env['estate.property'].browse([2]) estate.property(2,) >>> self.env['estate.property'].browse([2]).tag_ids estate.property.tag() >>> self.env['estate.property'].browse([2]).tag_ids = [(0, 0, {'name': 'tag5', 'color': 5}), (0, 0, {'name': 'tag6', 'color': 6}), (0, 0, {'name': 'tag7', 'color': 7})] >>> self.env['estate.property'].browse([2]).tag_ids estate.property.tag(5, 6, 7) # 删除关系字段关联的记录 # (2, id, 0) # 从记录集中删除id为id的记录,然后(从数据库中)删除它,不能在create()中使用 >>> self.env['estate.property'].browse([2]).tag_ids = [(2, 5, 0)] 2023-01-29 08:48:25,491 15984 INFO odoo odoo.models.unlink: User #1 deleted estate.property.tag records with IDs: [5] >>> print( self.env['estate.property.tag'].browse([5]).exists()) estate.property.tag() >>> if self.env['estate.property.tag'].browse([5]).exists(): ... print('exists record with id equal 5') ... >>> # 创建测试用数据 >>> self.env['estate.property.tag'].create({'name': 'tag8', 'color': 8}) estate.property.tag(8,) >>> self.env['estate.property.tag'].create({'name': 'tag9', 'color': 9}) estate.property.tag(9,) >>> self.env['estate.property'].browse([2]) estate.property(2,) # 替换关系字段关联的记录 # (6, 0, ids) # 根据ids列表,替换所有已存在记录, 等价于使用命令(5, 0, 0),随后对ids中的每个id使用命令(4, id, 0)。 >>> self.env['estate.property'].browse([2]).tag_ids estate.property.tag(6, 7) >>> self.env['estate.property'].browse([2]).tag_ids = [(6, 0 , [8, 9])] >>> self.env['estate.property'].browse([2]).tag_ids estate.property.tag(8, 9) >>> # 通过mapped获取记录字段值(关联记录的属性值)列表 >>> self.env['estate.property'].browse([2]).tag_ids.mapped('name') ['tag8', 'tag9'] >>> self.env['estate.property'].browse([2]).mapped('tag_ids') estate.property.tag(8, 9) >>> self.env['estate.property'].browse([2]).mapped('tag_ids').mapped('id')) [8, 9] # search api 应用 # 搜索域 >>> self.env['estate.property.tag'].search(args=[('id', '>', 5)]) estate.property.tag(6, 7, 8, 9) # 偏移 >>> self.env['estate.property.tag'].search(args=[('id', '>', 5)], offset=1) estate.property.tag(7, 8, 9) # 限制返回记录集中的最大记录数 >>> self.env['estate.property.tag'].search(args=[('id', '>', 5)], offset=1, limit=2) estate.property.tag(7, 8) # 返回记录集中的记录排序 # 降序 >>> self.env['estate.property.tag'].search(args=[('id', '>', 5)], offset=1, limit=2, order = 'id desc') estate.property.tag(8, 7) # 升序 >>> self.env['estate.property.tag'].search(args=[('id', '>', 5)], offset=1, limit=2, order = 'id') estate.property.tag(7, 8) >>> self.env['estate.property.tag'].search(args=[('id', '>', 5)], offset=1, limit=2, order = 'id asc') estate.property.tag(7, 8) # 仅返回记录数 >>> self.env['estate.property.tag'].search(args=[('id', '>', 5)], count=True) 4 # 利用search_count api实现等价效果 >>> self.env['estate.property.tag'].search_count(args=[('id', '>', 5)]) 4 # 搜索域条件组合 >>> self.env['estate.property.tag'].search(args=[('id', '>', 5),('color', '<', 8)]) estate.property.tag(6, 7) # 获取记录(集)信息 # ids >>> self.env['estate.property.tag'].search(args=[('id', '>', 5)]).ids [6, 7, 8, 9] # env >>> self.env['estate.property.tag'].search(args=[('id', '>', 5)]).env <odoo.api.Environment object at 0x0000020E31C80080> # name_get api 使用 >>> self.env['estate.property.tag'].search(args=[('id', '>', 5)]).name_get() [(6, 'tag6'), (7, 'tag7'), (8, 'tag8'), (9, 'tag9')] # get_metadata api 使用 >>> self.env['estate.property.tag'].search(args=[('id', '>', 5)]).get_metadata() [{'id': 6, 'create_uid': (1, 'OdooBot'), 'create_date': datetime.datetime(2023, 1, 29, 8, 41, 10, 551001), 'write_uid': (1, 'OdooBot'), 'write_date': datetime.datetime(2023, 1, 29, 8,41, 10, 551001), 'xmlid': False, 'noupdate': False}, {'id': 7, 'create_uid': (1, 'OdooBot'), 'create_date': datetime.datetime(2023, 1, 29, 8, 41, 10, 551001), 'write_uid': (1, 'OdooBot'), 'write_date': datetime.datetime(2023, 1, 29, 8, 41, 10, 551001), 'xmlid': False, 'noupdate': False}, {'id': 8, 'create_uid': (1, 'OdooBot'), 'create_date': datetime.datetime(2023,1, 29, 8, 41, 10, 551001), 'write_uid': (1, 'OdooBot'), 'write_date': datetime.datetime(2023, 1, 29, 8, 41, 10, 551001), 'xmlid': False, 'noupdate': False}, {'id': 9, 'create_uid': (1, 'OdooBot'), 'create_date': datetime.datetime(2023, 1, 29, 8, 41, 10, 551001), 'write_uid': (1, 'OdooBot'), 'write_date': datetime.datetime(2023, 1, 29, 8, 41, 10, 551001), 'xmlid': False, 'noupdate': False}] # 利用 read_group 实现按组读取 >>> self.env['estate.property.tag'].create({'name': 'tag10', 'color': 9}) estate.property.tag(10,) >>> self.env['estate.property.tag'].read_group([], fields=['color'], groupby=['color']) [{'color_count': 1, 'color': 6, '__domain': [('color', '=', 6)]}, {'color_count': 1, 'color': 7, '__domain': [('color', '=', 7)]}, {'color_count': 1, 'color': 8, '__domain': [('color', '=', 8)]}, {'color_count': 2, 'color': 9, '__domain': [('color', '=', 9)]}] # 获取字段定义 >>> self.env['estate.property.tag'].fields_get(['name']) {'name': {'type': 'char', 'change_default': False, 'company_dependent': False, 'depends': (), 'manual': False, 'readonly': False, 'required': True, 'searchable': True, 'sortable': True , 'store': True, 'string': 'tag', 'translate': False, 'trim': True}} # 回滚 >>> self.env.cr.rollback() >>> self.env['estate.property.tag'].search(args=[('id', '>', 5)], offset=1, limit=2, order = 'id') estate.property.tag() # 执行 sql self.env.cr.execute('TRUNCATE TABLE estate_property_tag_test CASCADE;') self.env.cr.commit() # 重置自增主键ID 为1(每个表的主键ID存在名为 tableName_id_seq 的序列中) self.env.cr.execute('ALTER SEQUENCE estate_property_tag_test_id_seq RESTART WITH 1;') self.env.cr.commit() >>> self.env['estate.property.tag'].create([{'name': 'tag1', 'color': 1}, {'name': 'tag2', 'color': 2}, {'name': 'tag3', 'color': 3}]) estate.property.tag(1, 2, 3) # 批量更新记录字段值 #记录集存在多条记录的情况下,不能通过 records.fieldName = 目标值 实现批量更新 >>> self.env['estate.property.tag'].browse([1,3]).write({'color':1}) True >>> self.env['estate.property.tag'].browse([1,3]).mapped('color') [1, 1] # 修改查询记录集context >>> self.env['estate.property.tag'].browse([]).env.context {'lang': 'en_US', 'tz': 'Europe/Brussels'} >>> self.env['estate.property.tag'].with_context(is_sync=False).browse([]).env.context {'lang': 'en_US', 'tz': 'Europe/Brussels', 'is_sync': False} # with_context和sudo共存时的使用方式 >>> self.env['estate.property.tag'].with_context(is_sync=False).sudo().browse([]).env.context {'lang': 'en_US', 'tz': 'Europe/Brussels', 'is_sync': False} >>> self.env['estate.property.tag'].sudo().with_context(is_sync=False).browse([]).env.context {'lang': 'en_US', 'tz': 'Europe/Brussels', 'is_sync': False} # 修改创建记录时返回记录的context(更新记录(write)也是一样的用法) # 如此,可以通过重写对应模型的create或者write方法,并在方法中通过self.env.context获取目标key值,进而执行需求实现需要采取的动作,参见下文 >>> self.env['estate.property.tag'].with_context(is_sync=False).create({'name': 'tag4', 'color': 4}).env.context {'lang': 'en_US', 'tz': 'Europe/Brussels', 'is_sync': False} # 删除记录 >>> self.env['estate.property.tag'].search([]) estate.property.tag(1, 2, 3, 4) >>> self.env['estate.property.tag'].search([('id', '>', 2)]).unlink() 2023-01-29 09:55:47,796 15984 INFO odoo odoo.models.unlink: User #1 deleted estate.property.tag records with IDs: [3, 4] True # 遍历记录集 >>> for record_set in self. self.env['estate.property.tag.test'].search([]): ... print(record_set) ... estate.property.tag.test(1,) estate.property.tag.test(2,)获取context上下文目标key值示例#!/usr/bin/env python # -*- coding:utf-8 -*- from odoo import models, fields,api class EstatePropertyTag(models.Model): _name = 'estate.property.tag' _description = 'estate property tag' _order = 'name' name = fields.Char(string='tag', required=True) color = fields.Integer(string='Color') @api.model def create(self, vals_list): res = super(EstatePropertyTag, self).create(vals_list) # 获取上下文目标key值 if not self.env.context.get('is_sync', True): # do something you need return res参考连接https://www.odoo.com/documentation/14.0/zh_CN/developer/reference/addons/orm.html#
环境odoo-14.0.post20221212.tarcontext用法总结获取上下文>>> self.env.context # 返回字典数据,等价于 self._context {'lang': 'en_US', 'tz': 'Europe/Brussels'} >>> self._context {'lang': 'en_US', 'tz': 'Europe/Brussels'} >>> recordSet.env.context # 注意,上下文是和记录集绑定的,上述的self也代表记录集设置上下文Model.with_context([context][, **overrides]) -> records[源代码]返回附加到扩展上下文的此记录集的新版本。扩展上下文是提供的合并了overrides的context,或者是合并了overrides当前context# current context is {'key1': True} r2 = records.with_context({}, key2=True) # -> r2._context is {'key2': True} r2 = records.with_context(key2=True) # -> r2._context is {'key1': True, 'key2': True}需要注意的是,上下文是和记录集绑定的,修改后的上下文并不会在其它记录集中共享应用场景示例用于action,为关联视图添加默认搜索、过滤条件视图定义为设置action打开的tree列表视图,添加默认搜索,搜索条件为 state字段值等于True<?xml version="1.0"?> <odoo> <record id="link_estate_property_action" model="ir.actions.act_window"> <field name="name">Properties</field> <field name="res_model">estate.property</field> <field name="view_mode">tree,form</field> <field name="context">{'search_default_state': True}</field> </record> <record id="estate_property_search_view" model="ir.ui.view"> <field name="name">estate.property.search</field> <field name="model">estate.property</field> <field name="arch" type="xml"> <search> <!-- 搜索 --> <field name="name" string="Title" /> <separator/> <!-- 筛选 --> <filter string="Available" name="state" domain="['|',('state', '=', 'New'),('state', '=', 'Offer Received')]"></filter> </search> </field> </record> <!--此处代码略...--> </odoo>说明:<field name="context">{'search_default_fieldName': content}</field>search_default_fieldName,其中fieldName 表示过滤器名称,即搜索视图中定义的<field>、<filter>元素的name属性值content 如果fieldName为搜索字段<field>的name属性值,那么content表示需要搜索的内容,输入内容是字符串,则需要添加引号,形如'test';如果fieldName为搜索过滤器<filter>的name属性值,那么content表示布尔值,该值为真,则表示默认开启name所代表的过滤器,否则不开启。用于搜索视图,添加分组查询条件视图设计<?xml version="1.0"?> <odoo> <!--此处代码略...--> <record id="estate_property_search_view" model="ir.ui.view"> <field name="name">estate.property.search</field> <field name="model">estate.property</field> <field name="arch" type="xml"> <search> <!-- 分组 --> <group expand="1" string="Group By"> <filter string="朝向" name="garden_orientation" context="{'group_by':'garden_orientation'}"/> </group> </search> </field> </record> <!--此处代码略...--> </odoo>说明:'group_by': '分组字段名称'用于视图对象按钮,传递数据给模型方法模型设计#!/usr/bin/env python # -*- coding:utf-8 -*- from odoo import models, fields, api class EstatePropertyType(models.Model): _name = 'estate.property.type' _description = 'estate property type' name = fields.Char(string='name', required=True, help='help text') property_ids = fields.One2many('estate.property', 'property_type_id') offer_ids = fields.One2many('estate.property.offer', 'property_type_id') offer_count = fields.Integer(compute='_compute_offer_count') @api.depends('offer_ids.price') def _compute_offer_count(self): for record in self: record.offer_count = len(record.mapped('offer_ids.price')) @api.model def action_confirm(self, *args): print(self, self.env.context, args) # ... do something else视图设计<?xml version="1.0"?> <odoo> <!--此处代码略...--> <record id="estate_property_type_view_form" model="ir.ui.view"> <field name="name">estate.property.type.form</field> <field name="model">estate.property.type</field> <field name="arch" type="xml"> <form string="Property Type"> <sheet> <!--此处代码略...--> <field name="offer_count"> <field name="property_ids"> <tree string="Properties"> <field name="name"/> <field name="expected_price" string="Expected Price"/> <field name="state" string="Status"/> </tree> </field> <footer> <button name="action_confirm" type="object" context="{'currentRecordID': active_id, 'offer_count':offer_count, 'property_ids': property_ids}" string="确认" class="oe_highlight"/> </footer> </sheet> </form> </field> </record> </odoo>说明:context属性值中的字典的键值如果为模型中定义的字段名称,则该字段名称必须以<field>元素的形式,出现在模型对应的视图(即不能是内联视图,比如内联Tree列表)中,否则会出现类似错误提示:Field offer_count used in context.offerCount ({'offerCount': offer_count}) must be present in view but is missing.点击界面按钮后,服务端打印日志如下estate.property.type() {'lang': 'en_US', 'tz': 'Europe/Brussels', 'uid': 2, 'allowed_company_ids': [1], 'params': {'action': 165, 'cids': 1, 'id': 1, 'menu_id': 70, 'model': 'estate.property.type', 'view_type': 'form'}, 'currentRecordID': 1, 'offer_count': 4, 'property_ids': [[4, 49, False], [4, 48, False]]} ([1],)说明:args 从日志来看,args接收了当前记录ID注意:如果将def action_confirm(self, *args) 改成def action_confirm(self, arg),服务端控制台会收到类似如下告警(虽然点击按钮后,服务端不会抛异常):2023-02-06 01:28:53,848 28188 WARNING odoo odoo.addons.base.models.ir_ui_view: action_confirm on demo.wizard has parameters and cannot be called from a button如果将def action_confirm(self, *args)改成def action_confirm(self),则点击页面确认按钮时,服务端会报错误,如下:TypeError: action_confirm2() takes 1 positional argument but 2 were given用于视图动作按钮,传递数据给动作关联的视图视图设计<?xml version="1.0"?> <odoo> <!--此处代码略...--> <record id="estate_property_view_form" model="ir.ui.view"> <field name="name">estate.property.form</field> <field name="model">estate.property</field> <field name="arch" type="xml"> <form string="estate property form"> <header> <button name="%(action_demo_wizard)d" type="action" string="选取offers" context="{'is_force':True}" class="oe_highlight"/> <!--此处代码略...--> </sheet> </form> </field> </record> </odoo>传递数据给视图按钮action_demo_wizard action关联视图设计<?xml version="1.0" encoding="UTF-8"?> <odoo> <data> <!--此处代码略...--> <record id="demo_wizard_view_form" model="ir.ui.view"> <field name="name">demo.wizard.form</field> <field name="model">demo.wizard</field> <field name="arch" type="xml"> <form> <!--此处代码略...--> <footer> <button name="action_confirm" context="{'is_force':context.get('is_force')}" string="确认" class="oe_highlight"/> <button string="关闭" class="oe_link" special="cancel"/> </footer> </form> </field> </record> <!-- 通过动作菜单触发 --> <record id="action_demo_wizard" model="ir.actions.act_window"> <field name="name">选取offers</field> <field name="res_model">demo.wizard</field> <field name="type">ir.actions.act_window</field> <field name="view_mode">form</field> <field name="target">new</field> <field name="binding_model_id" ref="estate.model_estate_property"/> <field name="binding_view_types">form</field> </record> </data> </odoo>传递数据给视图关系字段<?xml version="1.0" encoding="UTF-8"?> <odoo> <data> <!--此处代码略...--> <record id="demo_wizard_view_form" model="ir.ui.view"> <field name="name">demo.wizard.form</field> <field name="model">demo.wizard</field> <field name="arch" type="xml"> <form> <field name="offer_ids" context="{'is_force':context.get('is_force')}" > <tree> <!--此处代码略...--> </tree> </field> <!--此处代码略...--> </form> </field> </record> <!-- 通过动作菜单触发 --> <record id="action_demo_wizard" model="ir.actions.act_window"> <field name="name">选取offers</field> <field name="res_model">demo.wizard</field> <field name="type">ir.actions.act_window</field> <field name="view_mode">form</field> <field name="target">new</field> <field name="binding_model_id" ref="estate.model_estate_property"/> <field name="binding_view_types">form</field> </record> </data> </odoo>用于视图关系字段,传递数据给模型方法模型设计#!/usr/bin/env python # -*- coding: utf-8 -*- from odoo import models, fields class EstateProperty(models.Model): _name = 'estate.property' _description = 'estate property table' name = fields.Char(required=True) property_type_id = fields.Many2one("estate.property.type", string="PropertyType", options="{'no_create_edit': True}") offer_ids = fields.One2many("estate.property.offer", "property_id", string="PropertyOffer") # ...此处代码略 # 重写父类read方法 def read(self, fields=None, load='_classic_read'): print(self.env.context) property_type_id = self.env.context.get('propertyTypeId') if property_type_id: print('do something you want') return super(EstateProperty, self).read(fields, load)视图设计<?xml version="1.0"?> <odoo> <!--此处代码略...--> <record id="estate_property_type_view_form" model="ir.ui.view"> <field name="name">estate.property.type.form</field> <field name="model">estate.property.type</field> <field name="arch" type="xml"> <form string="Property Type"> <sheet> <!--此处代码略...--> <field name="property_ids" context="{'propertyTypeId': active_id}"> <tree string="Properties"> <field name="name"/> </tree> </field> <!--此处代码略...--> </sheet> </form> </field> </record> </odoo>打开上述视图(即加载内联Tree视图)时,会自动调用estate.property模型的read方法,服务端控制台输出如下:{'lang': 'en_US', 'tz': 'Europe/Brussels', 'uid': 2, 'allowed_company_ids': [1], 'params': {'action': 165, 'cids': 1, 'id': 1, 'menu_id': 70, 'model': 'estate.property.type', 'view_type': 'form'}, 'propertyTypeId': 1} do something you want更多示例可参考文档:[odoo 为可编辑列表视图字段搜索添加查询过滤条件](odoo 为可编辑列表视图字段搜索添加查询过滤条件.md)用于记录集,传递数据给模型方法模型设计#!/usr/bin/env python # -*- coding:utf-8 -*- from odoo import models, fields,api class EstatePropertyTag(models.Model): _name = 'estate.property.tag' _description = 'estate property tag' name = fields.Char(string='tag', required=True) color = fields.Integer(string='Color') @api.model def create(self, vals_list): # 通过重写模型的create或者write方法,调用该方法前修改上下文,然后在方法中通过self.env.context获取上下文中的目标key值,进而实现目标需求 res = super(EstatePropertyTag, self).create(vals_list) # 获取上下文目标key值 if not self.env.context.get('is_sync', True): # do something you need return res>>> self.env['estate.property.tag'].with_context(is_sync=False).create({'name': 'tag4', 'color': 4}).env.context {'lang': 'en_US', 'tz': 'Europe/Brussels', 'is_sync': False} PYTHON 复制 全屏参考连接https://www.odoo.com/documentation/14.0/zh_CN/developer/reference/addons/actions.html
实践环境Odoo 14.0-20221212 (Community Edition)需求描述如下图,列表网仓记录详情页面(form视图),编辑内联视图中的货主记录,为货主和仓库字段搜索,添加过滤条件,具体如下:添加、编辑货主时,下拉列表中只展示选取和当网仓记录所属公司关联的货主,点击搜索更多,仅展示和当前网仓记录所属公司关联的货主添加、编辑货主时,下拉列表中只展示选取和当网仓记录关联的仓库(到 “仓库” Tab页中添加的仓库),点击搜索更多,仅展示和当前网仓记录关联的仓库。模型设计说明:为了更好的体现本文主题,部分非关键代码已省略,即做了适当的模型简化处理# 网仓 class OmsNetwork(models.Model): _name = 'oms.network' _description = 'OMS Network' company_id = fields.Many2one('res.company', '公司', index=True, tracking=3, readonly=True, default=lambda self: self.env.company) warehouse_ids = fields.Many2many('stock.warehouse', string="仓库") line_ids = fields.One2many('oms.network.line', 'network_id', string='货主') # 货主 class OmsNetworkLine(models.Model): _name = 'oms.network.line' _description = 'OMS Network Line' network_id = fields.Many2one('oms.network', string='仓网', required=True) partner_id = fields.Many2one('res.partner', string='货主', required=True) warehouse_id = fields.Many2one('stock.warehouse', string="仓库", required=True) company_id = fields.Many2one('res.company', '公司', store=True, related='network_id.company_id') class ResPartner(models.Model): _inherit = ['res.partner'] _name = 'res.partner' #...略 def _get_default_company_id(self): if self.env.context.get('set_default_company', False): return self.env.company return False company_id = fields.Many2one( # 注意,这个字段和OmsNetwork.company_id关联了相同模型,所以下文可用这个字段进行搜索过滤 'res.company', 'Company', index=True, check_company=False, tracking=3, default=lambda self: self._get_default_company_id()) # 仓库 class StockWarehouse(models.Model): _inherit = 'stock.warehouse' # ...略 # 注:没有类似ResPartner的company_id字段视图设计<?xml version="1.0" encoding="UTF-8" ?> <odoo> <data> <!--此处代码已省略--> <record id="view_oms_network_form" model="ir.ui.view"> <field name="name">oms.network.form</field> <field name="model">oms.network</field> <field name="priority" eval="0"/> <field name="arch" type="xml"> <form string="OMS Network"> <!--此处代码已省略--> <sheet> <group> <group> <!--此处代码已省略--> </group> <group> <field name="company_id"/> </group> </group> <notebook> <page string="货主" name="line_ids" > <field name="line_ids"> <tree editable="bottom"> <field name="partner_id"/> <field name="partner_code"/> <field name="warehouse_id"/> <!--此处代码已省略--> </tree> </field> </page> <!--此处代码已省略--> </notebook> </sheet> <!--此处代码已省略--> </form> </field> </record> <!--此处代码已省略--> </data> </odoo>添加过滤条件代码实现修改视图,给视图添加context<?xml version="1.0" encoding="UTF-8" ?> <odoo> <data> <!--此处代码已省略--> <record id="view_oms_network_form" model="ir.ui.view"> <field name="name">oms.network.form</field> <field name="model">oms.network</field> <field name="priority" eval="0"/> <field name="arch" type="xml"> <form string="OMS Network"> <!--此处代码已省略--> <sheet> <group> <group> <!--此处代码已省略--> </group> <group> <field name="company_id"/> </group> </group> <notebook> <page string="货主" name="line_ids" > <field name="line_ids" context="{'oms_network_id':active_id, 'oms_network_company_id': company_id}"> <tree editable="bottom"> <field name="partner_id" context="{'oms_network_company_id':context.get('oms_network_company_id')}"/> <field name="partner_code"/> <field name="warehouse_id" context="{'oms_network_id':context.get('oms_network_id')}"/> <!--此处代码已省略--> </tree> </field> </page> <!--此处代码已省略--> </notebook> </sheet> <!--此处代码已省略--> </form> </field> </record> <!--此处代码已省略--> </data> </odoo>修改ResPartner,重写模型name_search(编辑货主字段,弹出下拉列表时,会请求该模型函数),search_read(编辑货主字段,点击下拉列表时 搜索更多打开界面时,会请求该模型函数)提示:分析OmsNetworkLine模型定义可知道,货主字段(partner_id)为多对一字段,关联ResPartner 模型class ResPartner(models.Model): _inherit = ['res.partner'] _name = 'res.partner' @api.model def name_search(self, name='', args=None, operator='ilike', limit=100): company_id = self.env.context.get('oms_network_company_id') if company_id: args = args or [] args.append(('company_id', '=', company_id)) res = super(ResPartner, self).name_search(name, args, operator, limit) return res @api.model def search_read(self, domain=None, fields=None, offset=0, limit=None, order=None): company_id = self.env.context.get('oms_network_company_id') if company_id: domain = domain or [] domain.append(('company_id', '=', company_id)) return super(ResPartner, self).search_read(domain, fields, offset, limit, order)修改StockWarehouse,重写模型name_search,search_read提示:分析OmsNetworkLine模型定义可知道,仓库字段(warehouse_id)为多对多字段,关联stock.warehouse 模型class StockWarehouse(models.Model): _inherit = 'stock.warehouse' # ...略 def _check_multiwarehouse_group(self): pass @api.model def name_search(self, name='', args=None, operator='ilike', limit=100): oms_network_id = self.env.context.get('oms_network_id') warehouse_ids = self.env['oms.network'].browse([oms_network_id]).warehouse_ids.ids if oms_network_id: args = args or [] args.append(('id', 'in', warehouse_ids)) res = super(StockWarehouse, self).name_search(name, args, operator, limit) return res @api.model def search_read(self, domain=None, fields=None, offset=0, limit=None, order=None): oms_network_id = self.env.context.get('oms_network_id') warehouse_ids = self.env['oms.network'].browse([oms_network_id]).warehouse_ids.ids if warehouse_ids: domain = domain or [] domain.append(('id', 'in', warehouse_ids)) return super(StockWarehouse, self).search_read(domain, fields, offset, limit, order)
实践环境Odoo 14.0-20221212 (Community Edition)代码实现在js脚本函数中调用模型中自定义方法:this._rpc({ model: 'demo.wizard', // 模型名称,即模型类定义中 _name 的值 method: 'action_select_records_via_checkbox', // 模型中自定义名称 args: ['arg_value'] // 传递给模型方法参数列表,列表中每个元素对应模型方法的一个位置参数 }).then(function (res) { // res为模型方法返回值 console.log(res); // do something });模型方法定义#!/usr/bin/env python # -*- coding:utf-8 -*- from odoo import models,fields,api class DemoWizard(models.TransientModel): _name = 'demo.wizard' _description = 'demo wizard' #...此处代码已省略 @api.model def action_select_records_via_checkbox(self, *args): '''通过wizard窗口界面复选框选取记录时触发的操作 @params: args 为tuple元组,如果方法不采用位置参数,则传递的是啥,参数就是啥 ''' # do something return True注意:this._rpc函数不能在非普通函数中使用,其使用范围可参考以下示例odoo.define('estate.ListRenderer', function (require) { "use strict"; var ListRenderer = require('web.ListRenderer'); ListRenderer = ListRenderer.extend({ _onToggleCheckbox: function (ev) { //// ...此处代码已省略 this._rpc({ model: this.modelName, method: this.modelMethod, args: [this.recordsSelected] }).then(function (res) { // ...此处代码已省略 }); ... this._super.apply(this, arguments); } }); // ...此处代码已省略 });那问题来了,如果希望在普通的javascript函数中使用上述请求功能,咋办?参考如下示例代码示例代码function do_confirm_action(modelName, modelMethod){ $("button[name='action_confirm']").attr("disabled", true); var wizard_dialog = $(event.currentTarget.offsetParent.parentElement.parentElement); var dataUUID = $(event.currentTarget.parentElement.parentElement.parentElement.parentElement).find('div.o_list_view').prop('id'); var rpc = odoo.__DEBUG__.services['web.rpc']; rpc.query({ model: modelName, method: modelMethod, args: [JSON.parse(window.sessionStorage.getItem(dataUUID) || '{}')] }).then(function (res) { if (res == true) { wizard_dialog.css('display', 'none'); window.sessionStorage.removeItem(dataUUID); } else { $("button[name='action_confirm']").attr("disabled", false); } }).catch(function (err) { $("button[name='action_confirm']").attr("disabled", false); }); }
实践环境Odoo 14.0-20221212 (Community Edition)代码实现方案1通过研究发现,点击odoo form表单按钮时,会调用odoo14\odoo\addons\web\static\src\js\views\form\form_controller.js文件中的_onButtonClicked函数,在该函数中响应点击事件。所以,我们可以通过重写该方法来实现自定义响应点击事件。示例如下表单视图定义codePojects\odoo14\custom\estate\wizards\demo_wizard_views.xml<?xml version="1.0" encoding="UTF-8"?> <odoo> <data> <record id="demo_wizard_view_form" model="ir.ui.view"> <field name="name">demo.wizard.form</field> <field name="model">demo.wizard</field> <field name="arch" type="xml"> <form> //...代码略 <footer> <button name="action_confirm" special="other" type="object" string="确认" class="oe_highlight"/> <button string="关闭" class="oe_link" special="cancel"/> </footer> </form> </field> </record> //...代码略 </data> </odoo>重定义web.FormController以实现重写_onButtonClickedcodePojects\odoo14/estate/static/src/js/views/form_controller.jsodoo.define('customModule.FormController', function (require) { "use strict"; var formController = require('web.FormController'); var CustomFormController = formController.extend({ //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * @private * @param {OdooEvent} ev */ _onButtonClicked: function (ev) { // stop the event's propagation as a form controller might have other // form controllers in its descendants (e.g. in a FormViewDialog) ev.stopPropagation(); var self = this; var def; this._disableButtons(); function saveAndExecuteAction () { return self.saveRecord(self.handle, { stayInEdit: true, }).then(function () { // we need to reget the record to make sure we have changes made // by the basic model, such as the new res_id, if the record is // new. var record = self.model.get(ev.data.record.id); return self._callButtonAction(attrs, record); }); } var attrs = ev.data.attrs; if (attrs.confirm) { def = new Promise(function (resolve, reject) { Dialog.confirm(self, attrs.confirm, { confirm_callback: saveAndExecuteAction, }).on("closed", null, resolve); }); } else if (attrs.special === 'cancel') { def = this._callButtonAction(attrs, ev.data.record); } else if (attrs.special == 'other') { // 新增自定义事件处理 self._enableButtons(); // 启用按钮(点击后会自动禁用按钮) self.trigger_up('close_dialog'); // 关闭对话框 return; } else if (!attrs.special || attrs.special === 'save') { // save the record but don't switch to readonly mode def = saveAndExecuteAction(); } else { console.warn('Unhandled button event', ev); return; } // Kind of hack for FormViewDialog: button on footer should trigger the dialog closing // if the `close` attribute is set def.then(function () { self._enableButtons(); if (attrs.close) { self.trigger_up('close_dialog'); } }).guardedCatch(this._enableButtons.bind(this)); }, }); odoo.__DEBUG__['services']['web.FormController'] = CustomFormController; });codePojects\odoo14\custom\estate\views\webclient_templates.xml<?xml version="1.0" encoding="utf-8"?> <odoo> <template id="assets_common" inherit_id="web.assets_common" name="Backend Assets (used in backend interface)"> <xpath expr="//script[last()]" position="after"> <script type="text/javascript" src="/estate/static/src/js/views/form_controller.js"></script> </xpath> </template> </odoo>codePojects\odoo14\custom\estate\__manifest__.py#!/usr/bin/env python # -*- coding:utf-8 -*- { 'name': 'estate', 'depends': ['base'], 'data':[ # ...略 'views/webclient_templates.xml', 'wizards/demo_wizard_views.xml', # ...略 ] }方案2研究发现,在不为按钮设置type属性的情况下,可以为按钮添加onclick属性,指定点击按钮时需要调用的javascript函数,不过,此时点击按钮,不会再调用web.FormController中定义的_onButtonClicked函数。示例如下:<?xml version="1.0" encoding="UTF-8"?> <odoo> <data> <record id="demo_wizard_view_form" model="ir.ui.view"> <field name="name">demo.wizard.form</field> <field name="model">demo.wizard</field> <field name="arch" type="xml"> <form> //...代码略 <footer> <button name="action_confirm" do_confirm_action('demo.wizard','action_confirm') string="确认" class="oe_highlight"/> <button string="关闭" class="oe_link" special="cancel"/> </footer> </form> </field> </record> //...代码略 </data> </odoo>codePojects\odoo14/estate/static/src/js/demo_wizard_views.jsfunction do_confirm_action(modelName, modelMethod){ // do something //... $("button[name='action_confirm']").attr("disabled", true); }codePojects\odoo14\custom\estate\views\webclient_templates.xml<?xml version="1.0" encoding="utf-8"?> <odoo> <template id="assets_common" inherit_id="web.assets_common" name="Backend Assets (used in backend interface)"> <xpath expr="//script[last()]" position="after"> <script type="text/javascript" src="/estate/static/src/js/demo_wizard_views.js"></script> </xpath> </template> </odoo>
odoo菜单定义和修改学习总结环境odoo-14.0.post20221212.tar定义菜单方式1:<?xml version="1.0"?> <odoo> <menuitem id="root_menu_id" name="TopMenu" web_icon="estate,static/img/icon.png"> <menuitem id="second_level_menu" name="SecondLevelMenu"> <menuitem id="third_level_menu1" action="third_level_menu1_action" sequence ="10"/> <menuitem id="third_level_menu2" action="third_level_menu2_action" sequence ="20"/> </menuitem> </menuitem> </odoo>说明:id 菜单外部IDname如果不指定name,则:如果为菜单设置了action,则获取action record定义中name字段的值作为菜单name属性的值如果未设置菜单action,则获取菜单外部ID为值作为菜单name属性的值action打开菜单时需要执行的action的外部IDweb_icon指定菜单图标,格式:模块名称,图标路径,形如estate,static/img/icon.png 意为estate模块下的static/img/icon.png图标。其中图标路径,一般是相对于模块根目录的相对路径sequence设置菜单展示顺序。该属性值越大,越靠右、靠下方展示。也就是说,菜单从左往右,从上到下,对应sequence属性值从小到大。<menuitem>元素也可以放在<data>元素中,形如<?xml version="1.0"?> <odoo> <data> <menuitem id="root_menu_id" name="TopMenu" web_icon="estate,static/img/icon.png"> ... </menuitem> </data> </odoo>groups逗号分隔的res.groups模型的外部ID序列,形如groups="group_account_user,group_account_manager,group_account_invoice",表示菜单只能被group_account_user,group_account_manager,group_account_invoice 三个用户组访问。用于指定可以访问菜单的用户组。如果外部ID以-为前缀,则从菜单组中移除该ID代表的组,注意,如果指定用户组不是在当前模块中定义的,需要指定所在模块名称,形如estate.group_estate_property_root_menu。示例:<?xml version="1.0"?> <odoo> <data> <menuitem id="root_menu_id" name="TopMenu" web_icon="estate,static/img/icon.png" groups="group_estate_property_root_menu"> ... </menuitem> </data> </odoo>方式2:通过parent来设置菜单层级<?xml version="1.0"?> <odoo> <menuitem id="root_menu_id" name="TopMenu" web_icon="estate,static/img/icon.png"/> <menuitem id="second_level_menu" name="SecondLevelMenu" parent="root_menu_id"/> <menuitem id="third_level_menu1" action="third_level_menu1_action" sequence ="10" parent="second_level_menu"/> <menuitem id="third_level_menu2" action="third_level_menu2_action" sequence ="20" parent="second_level_menu"/> </odoo>说明:parent指定父级菜单外部ID,如果上级菜单不在当前模块中,则需要指定上级菜单所在模块,形如parent="moduleName.parent_menu_id"修改菜单本节要介绍的是一种特殊的修改方式,并不是直接修改原有菜单定义。这种修改方式之所以特殊,是因为它不修改原有菜单的定义,而是通过重新定义菜单来修改,可以简单的类比为“继承”,具体做法如下:定义一个新的菜单<menuitem>,将其id属性值设置为原有菜单所在模块名称及其id属性值的组合,形如:moduleName.source_menu_id(参见下述示例),如果新的菜单和原有菜单在同一个模块,可以省略moduleName.。修改相关菜单属性值为目标值(如果需要的话)示例:重新定义purchase.menu_purchase_root菜单名称<menuitem id="purchase.menu_purchase_root" name="omsPurchase"/>以新定义的菜单为父级菜单,添加子菜单(如果需要的话),添加方式可以参考上文所述,需要注意的是,不管采用哪种方式,原有菜单的子菜单依然存在,并显示为新定义菜单的子菜单隐藏原有菜单的子菜单(如果需要的话)定义一个<record>,将其id设置为要隐藏的子菜单所在模块名称及id属性值的组合,形如:moduleName.source_menu_id,如果新的菜单和原有菜单在同一个模块,可以省略moduleName.。将其model设置为"ir.ui.menu"添加子元素 <field name="active" eval="False"></field>,其中eval=False则表示隐藏,eval=True表示显示<?xml version="1.0"?> <odoo> ... <record id="purchase.sub_menu_purchase" model="ir.ui.menu"> <field name="active" eval="False"></field> </record> </odoo>参考连接https://www.odoo.com/documentation/14.0/zh_CN/developer/reference/addons/data.html#shortcuts
实践环境Odoo 14.0-20221212 (Community Edition)Odoo Web Login Screen 14.0https://apps.odoo.com/apps/modules/14.0/odoo_web_login/#操作步骤1、把下载的odoo web login screen压缩包解压后,放自定义插件目录,如下2、登录odoo,打开Apps,使用关键词"web_login"搜索模块,安装odoo_web_login注意:在安装website模块之后安装odoo_web_login模块或者安装website模块之后更新odoo_web_login模块3、修改%PROJECT_HOME%\odoo\addons\web\controllers\main.py添加'disable_footer', 'disable_database_manager', 'background_src'到SIGN_UP_REQUEST_PARAMS变量# Shared parameters for all login/signup flows SIGN_UP_REQUEST_PARAMS = {'db', 'login', 'debug', 'token', 'message', 'error', 'scope', 'mode', 'redirect', 'redirect_hostname', 'email', 'name', 'partner_id', 'password', 'confirm_password', 'city', 'country_id', 'lang', 'disable_footer', 'disable_database_manager', 'background_src'}4、重启服务,查看效果登录系统,查看系统参数Settings->Technical->Parameters->System Parameters,如下,我们可以根据实际需要修改对应参数配置
实践环境win10Python 3.6.2odoo_14.0.latest.tar.gz下载地址:https://download.odoocdn.com/download/14/src?payload=MTY3MDg1MTM3Ni4xNC5zcmMud0tZRWZLX2I5UVF0Tm51UUVqT1lQVE5PbGRyYW5zWTc4dHhuOWxfclM4UT0%3D引用页面:https://www.odoo.com/zh_TW/page/downloadMicrosoft Visual C++ Build Tool下载地址:https://download.microsoft.com/download/5/f/7/5f7acaeb-8363-451f-9425-68a90f98b238/visualcppbuildtools_full.exepostgresql-12.13-1-windows-x64.exe下载地址:https://get.enterprisedb.com/postgresql/postgresql-12.13-1-windows-x64.exewkhtmltox-0.12.5-1.msvc2015-win64.exe引用页面:https://wkhtmltopdf.org/downloads.html操作步骤下载上述相关软件安装Python安装后将Python安装路径(本例中安装路径为D:\Program Files (x86)\python36\Scripts\,D:\Program Files (x86)\python36\)添加到PATH系统环境变量、用户环境变量创建Python虚拟运行环境主要是和其它项目开发环境进行隔离,具体操作过程可参考文章"virtualenv虚拟环境配置与使用",安装Microsoft Visual C++ Build Tools说明:安装odoo依赖时,可能会提示要求Microsoft Visual C++ Build Tool,提示Microsoft Visual C++ 14.0 is required. Get it with "Microsoft Visual C++ Build Tools",此时则需要停下来,先安装编译工具,然后再试,否则可以跳过这个步骤。参考文档 "解决安装软件包提示Unable to find vcvarsall.bat的问题.md"安装odoo依赖解压下载后的odoo_14.0.latest.tar,并对解压结果目录中dist目录下的odoo-14.0.post20221212.tar进行二次解压,得到odoo-14.0.post20221212目录文件,将其重命名odoo14,作为项目根目录修改odoo14/requirements.txt 文件,将gevent==1.4.0 ; sys_platform == 'win32' and python_version < '3.7' greenlet==0.4.10 ; python_version < '3.7'改成gevent==22.10.2 ; sys_platform == 'win32' and python_version < '3.7' greenlet==2.0.1 ; python_version < '3.7'以解决依赖冲突问题依次、轮换执行以下安装命令(注意:在虚拟运行环境中执行)pip install -r requirements.txt --trusted-host pypi.org --trusted-host files.pythonhosted.org pip install -r requirements.txt -i https://pypi.doubanio.com/simple --trusted-host pypi.doubanio.com pip install -r requirements.txt -i http://mirrors.aliyun.com/pypi/simple --trusted-host mirrors.aliyun.com说明:安装过程中会报找不到依赖包问题,此时可以尝试通过切换包源来解决找不到包的问题。安装PostgreSQLexe安装比较简单,中途按要求选择合理的安装路径,数据存储路径,按要求输入并记录postgres用户密码,其它保持默认,下一步...下一步即可。安装结束时会提示安装Stack Builder,如果不需要,可以不勾选,不安装。创建PostgreSQL登录/组角色(Login/Group Roles)安装完成,进入%PostgreSQL_INSTALL_HOME%\pgAdmin 4\bin目录下,双击 pgAdmin4.exe打开PostgreSQL管理界面,双击默认服务,PostgreSQL 12最后点击 Save 保存安装rtlcss说明:这个步骤是官方文档上给出的,笔者未实践。对于具有从右到左界面的语言(例如阿拉伯语或希伯来语),需要包“rtlcss”:下载并安装 nodejs。安装 rtlcss:C:\> npm install -g rtlcss添加“rtlcss.cmd”所在的文件夹(通常为:“C:\Users<user>\AppData\Roaming\npm\”)到系统系统环境变量“PATH”安装wkhtmltopdf安装后,将wkhtmltopdf.exe所在目录(例中"D:\Program Files\wkhtmltopdf\bin")添加到系统环境变量PATH中。如果安装配置没问题,则启动时可以看到类似如下日志:2022-12-13 11:20:24,989 21476 INFO ? odoo.addons.base.models.ir_actions_report: Will use the Wkhtmltopdf binary at D:\Program Files\wkhtmltopdf\bin\wkhtmltopdf.exe否则,会看到类似如下日志:2022-12-13 07:19:39,280 31332 INFO ? odoo.addons.base.models.ir_actions_report: You need Wkhtmltopdf to print a pdf version of the reports.测试运行启动复制上述odoo14\setup\odoo文件为odoo-bin(本质为一个py文件),并移动到setup同级目录下,即例中的odoo14目录下虚拟运行环境下,输入以下命令后回车,可以看到类似如下输出python odoo-bin --addons-path=./odoo/addons -r myodoo -w test123 -d odoo或者将部分命令行参数放到配置文件中python odoo-bin -c odoo.conf其中,odoo.conf为手动创建的配置文件,位于odoo14目录下,内容如下:[options] addons_path = odoo/addons db_name = odoo db_host = localhost db_user = myodoo db_password = test123 db_port = 5432常见命令行参数说明-d指定将要使用的自定义数据库名称,可以是不存在的,odoo启动时会自动初始化并创建数据库。-r ,--db_user数据库用户名称,用于连接PostgreSQL-w , --db_password数据库密码,如果使用 密码验证的话。--db_host数据库主机地址,windows上,默认为localhost,UNIX则使用socket,形如/var/run/postgresql--db_port数据库监听端口,默认5432--addons-path指定存储模块的插件目录,目录之间用逗号分隔。--limit-time-cpu针对每个请求,阻止worker使用多余 CPU 时间,单位 秒--limit-time-real阻止worker使用多余 CPU 时间处理单个请求,单位 秒-c , --config提供可选的配置文件。-s,--save 将当前程序运行配置回写到默认配置文件中。-i运行服务前,安装某些模块,模块之间用逗号分隔-u运行服务前,更新某些模块,模块之间用逗号分隔,注意配合-d使用小技巧The --limit-time-cpu and --limit-time-real 参数可以用于在调试源码时,阻止worker被kill掉配置文件简介大多数命令行选项可通过配置文件指定。大多数时候,将命令行参数名称前缀-移除,其它-替换_即为对应的配置文件参数名称。比如 --db-template 转换为db_template。但是也有特例:--db-filter 转换为dbfilter--no-http 转换为 http_enable日志预设(所有以 --log-开头( --log-handler 和--log-db除外)的选项,只需要添加内容到 log_handler, 并在配置文件中直接使用(官方原文:logging presets (all options starting with ()--log-handler 和[--log-db) just add content to log_handler, use that directly in the configuration file)--smtp 转换为 smtp_server--database 转换为 db_name--i18n-import 和--i18n-export 不能从配置文件获取默认配置文件位于 *$HOME*/.odoorc,可以通过 --config指定配置文件。另外使用-s/--save参数将当前配置回写到当前配置文件中。(oodo14) D:\codePojects\odoo14>python odoo-bin -r myodoo -w test123 --addons-path=./odoo/addons -d odoo 2022-12-13 07:19:23,208 31332 INFO ? odoo: Odoo version 14.0-20221212 2022-12-13 07:19:23,211 31332 INFO ? odoo: addons paths: ['D:\\codePojects\\odoo14\\odoo\\addons', 'c:\\users\\01367599\\appdata\\local\\openerp s.a\\odoo\\addons\\14.0', 'd:\\codepojects\\odoo14\\odoo\\addons'] 2022-12-13 07:19:23,211 31332 INFO ? odoo: database: myodoo@default:default 2022-12-13 11:20:24,989 21476 INFO ? odoo.addons.base.models.ir_actions_report: Will use the Wkhtmltopdf binary at D:\Program Files\wkhtmltopdf\bin\wkhtmltopdf.exe 2022-12-13 07:20:29,861 31332 INFO ? odoo.service.server: HTTP service (werkzeug) running on SF0001367599LA.sf.com:8069 2022-12-13 07:20:31,467 31332 INFO ? odoo.modules.loading: init db 2022-12-13 07:20:43,433 31332 INFO odoo odoo.modules.loading: loading 1 modules... 2022-12-13 07:20:59,492 31332 INFO odoo odoo.modules.loading: Loading module base (1/1) 2022-12-13 07:20:59,634 31332 INFO odoo odoo.modules.registry: module base: creating or updating database tables 2022-12-13 07:21:08,242 31332 INFO odoo odoo.models: Storing computed values of ir.module.module.menus_by_module 2022-12-13 07:21:08,274 31332 INFO odoo odoo.models: Storing computed values of ir.module.module.reports_by_module 2022-12-13 07:21:08,276 31332 INFO odoo odoo.models: Storing computed values of ir.module.module.views_by_module 2022-12-13 07:21:08,294 31332 INFO odoo odoo.models: Storing computed values of res.partner.display_name 2022-12-13 07:21:08,309 31332 INFO odoo odoo.models: Storing computed values of res.partner.partner_share 2022-12-13 07:21:08,310 31332 INFO odoo odoo.models: Storing computed values of res.partner.commercial_partner_id 2022-12-13 07:21:08,331 31332 INFO odoo odoo.models: Storing computed values of res.partner.commercial_company_name 2022-12-13 07:21:08,340 31332 INFO odoo odoo.models: Storing computed values of res.currency.decimal_places 2022-12-13 07:21:08,362 31332 INFO odoo odoo.models: Storing computed values of res.company.logo_web 2022-12-13 07:21:08,398 31332 INFO odoo odoo.models: Storing computed values of res.users.share 2022-12-13 07:21:17,359 31332 INFO odoo odoo.modules.loading: loading base/data/res.lang.csv 2022-12-13 07:21:17,605 31332 INFO odoo odoo.modules.loading: loading base/data/res_lang_data.xml 2022-12-13 07:21:17,875 31332 INFO odoo odoo.modules.loading: loading base/data/res_partner_data.xml 2022-12-13 07:21:18,248 31332 INFO odoo odoo.modules.loading: loading base/data/res_company_data.xml 2022-12-13 07:21:18,396 31332 INFO odoo odoo.modules.loading: loading base/data/res_users_data.xml 2022-12-13 07:21:18,782 31332 INFO odoo odoo.modules.loading: loading base/data/report_paperformat_data.xml 2022-12-13 07:21:18,831 31332 INFO odoo odoo.modules.loading: loading base/data/res_currency_data.xml 2022-12-13 07:21:20,130 31332 INFO odoo odoo.modules.loading: loading base/data/res_country_data.xml 2022-12-13 07:21:22,092 31332 INFO odoo odoo.modules.loading: loading base/data/ir_demo_data.xml 2022-12-13 07:21:23,140 31332 INFO odoo odoo.modules.loading: loading base/security/base_groups.xml 2022-12-13 07:21:23,657 31332 INFO odoo odoo.modules.loading: loading base/security/base_security.xml 2022-12-13 07:21:24,313 31332 INFO odoo odoo.modules.loading: loading base/views/base_menus.xml 2022-12-13 07:21:24,882 31332 INFO odoo odoo.modules.loading: loading base/views/decimal_precision_views.xml 2022-12-13 07:21:24,989 31332 INFO odoo odoo.modules.loading: loading base/views/res_config_views.xml 2022-12-13 07:21:25,059 31332 INFO odoo odoo.modules.loading: loading base/data/res.country.state.csv 2022-12-13 07:21:28,094 31332 INFO odoo odoo.modules.loading: loading base/views/ir_actions_views.xml 2022-12-13 07:21:28,590 31332 INFO odoo odoo.modules.loading: loading base/views/ir_config_parameter_views.xml 2022-12-13 07:21:28,681 31332 INFO odoo odoo.modules.loading: loading base/views/ir_cron_views.xml 2022-12-13 07:21:28,807 31332 INFO odoo odoo.modules.loading: loading base/views/ir_filters_views.xml 2022-12-13 07:21:28,899 31332 INFO odoo odoo.modules.loading: loading base/views/ir_mail_server_views.xml 2022-12-13 07:21:29,002 31332 INFO odoo odoo.modules.loading: loading base/views/ir_model_views.xml 2022-12-13 07:21:29,537 31332 INFO odoo odoo.modules.loading: loading base/views/ir_attachment_views.xml 2022-12-13 07:21:29,638 31332 INFO odoo odoo.modules.loading: loading base/views/ir_rule_views.xml 2022-12-13 07:21:29,771 31332 INFO odoo odoo.modules.loading: loading base/views/ir_sequence_views.xml 2022-12-13 07:21:29,897 31332 INFO odoo odoo.modules.loading: loading base/views/ir_translation_views.xml 2022-12-13 07:21:30,038 31332 INFO odoo odoo.modules.loading: loading base/views/ir_ui_menu_views.xml 2022-12-13 07:21:30,122 31332 INFO odoo odoo.modules.loading: loading base/views/ir_ui_view_views.xml 2022-12-13 07:21:30,345 31332 INFO odoo odoo.modules.loading: loading base/views/ir_default_views.xml 2022-12-13 07:21:30,432 31332 INFO odoo odoo.modules.loading: loading base/data/ir_cron_data.xml 2022-12-13 07:21:30,469 31332 INFO odoo odoo.modules.loading: loading base/report/ir_model_report.xml 2022-12-13 07:21:30,499 31332 INFO odoo odoo.modules.loading: loading base/report/ir_model_templates.xml 2022-12-13 07:21:30,546 31332 INFO odoo odoo.modules.loading: loading base/views/ir_logging_views.xml 2022-12-13 07:21:30,631 31332 INFO odoo odoo.modules.loading: loading base/views/ir_qweb_widget_templates.xml 2022-12-13 07:21:30,696 31332 INFO odoo odoo.modules.loading: loading base/views/ir_module_views.xml 2022-12-13 07:21:30,933 31332 INFO odoo odoo.modules.loading: loading base/data/ir_module_category_data.xml 2022-12-13 07:21:31,099 31332 INFO odoo odoo.modules.loading: loading base/data/ir_module_module.xml 2022-12-13 07:21:31,249 31332 INFO odoo odoo.modules.loading: loading base/report/ir_module_reports.xml 2022-12-13 07:21:31,267 31332 INFO odoo odoo.modules.loading: loading base/report/ir_module_report_templates.xml 2022-12-13 07:21:31,352 31332 INFO odoo odoo.modules.loading: loading base/wizard/base_module_update_views.xml 2022-12-13 07:21:31,400 31332 INFO odoo odoo.modules.loading: loading base/wizard/base_language_install_views.xml 2022-12-13 07:21:31,481 31332 INFO odoo odoo.modules.loading: loading base/wizard/base_import_language_views.xml 2022-12-13 07:21:31,577 31332 INFO odoo odoo.modules.loading: loading base/wizard/base_module_upgrade_views.xml 2022-12-13 07:21:31,808 31332 INFO odoo odoo.modules.loading: loading base/wizard/base_module_uninstall_views.xml 2022-12-13 07:21:31,903 31332 INFO odoo odoo.modules.loading: loading base/wizard/base_export_language_views.xml 2022-12-13 07:21:31,984 31332 INFO odoo odoo.modules.loading: loading base/wizard/base_update_translations_views.xml 2022-12-13 07:21:32,087 31332 INFO odoo odoo.modules.loading: loading base/wizard/base_partner_merge_views.xml 2022-12-13 07:21:32,276 31332 INFO odoo odoo.modules.loading: loading base/data/ir_actions_data.xml 2022-12-13 07:21:32,345 31332 INFO odoo odoo.modules.loading: loading base/data/ir_demo_failure_data.xml 2022-12-13 07:21:32,464 31332 INFO odoo odoo.modules.loading: loading base/views/res_company_views.xml 2022-12-13 07:21:32,621 31332 INFO odoo odoo.modules.loading: loading base/views/res_lang_views.xml 2022-12-13 07:21:32,763 31332 INFO odoo odoo.modules.loading: loading base/views/res_partner_views.xml 2022-12-13 07:21:33,466 31332 INFO odoo odoo.modules.loading: loading base/views/res_bank_views.xml 2022-12-13 07:21:33,706 31332 INFO odoo odoo.modules.loading: loading base/views/res_country_views.xml 2022-12-13 07:21:33,983 31332 INFO odoo odoo.modules.loading: loading base/views/res_currency_views.xml 2022-12-13 07:21:34,241 31332 INFO odoo odoo.modules.loading: loading base/views/res_users_views.xml 2022-12-13 07:21:34,947 31332 INFO odoo odoo.modules.loading: loading base/views/ir_property_views.xml 2022-12-13 07:21:35,074 31332 INFO odoo odoo.modules.loading: loading base/views/res_config_settings_views.xml 2022-12-13 07:21:35,146 31332 INFO odoo odoo.modules.loading: loading base/views/report_paperformat_views.xml 2022-12-13 07:21:35,311 31332 INFO odoo odoo.modules.loading: loading base/views/onboarding_views.xml 2022-12-13 07:21:35,530 31332 INFO odoo odoo.modules.loading: loading base/security/ir.model.access.csv 2022-12-13 07:21:36,499 31332 INFO odoo odoo.modules.loading: Module base: loading demo 2022-12-13 07:21:36,500 31332 INFO odoo odoo.modules.loading: loading base/data/res_company_demo.xml 2022-12-13 07:21:36,574 31332 INFO odoo odoo.modules.loading: loading base/data/res_users_demo.xml 2022-12-13 07:21:36,997 31332 INFO odoo odoo.modules.loading: loading base/data/res_partner_bank_demo.xml 2022-12-13 07:21:37,072 31332 INFO odoo odoo.modules.loading: loading base/data/res_currency_rate_demo.xml 2022-12-13 07:21:38,035 31332 INFO odoo odoo.modules.loading: loading base/data/res_bank_demo.xml 2022-12-13 07:21:38,064 31332 INFO odoo odoo.modules.loading: loading base/data/res_partner_demo.xml 2022-12-13 07:21:39,067 31332 INFO odoo odoo.modules.loading: loading base/data/res_partner_image_demo.xml 2022-12-13 07:21:43,308 31332 INFO odoo odoo.modules.loading: Module base loaded in 43.82s, 11200 queries 2022-12-13 07:21:43,309 31332 INFO odoo odoo.modules.loading: 1 modules loaded in 43.82s, 11200 queries (+0 extra) 2022-12-13 07:21:43,365 31332 INFO odoo odoo.modules.loading: updating modules list 2022-12-13 07:21:43,368 31332 INFO odoo odoo.addons.base.models.ir_module: ALLOW access to module.update_list on [] to user __system__ #1 via n/a 2022-12-13 07:21:48,032 31332 INFO odoo odoo.modules.loading: loading 7 modules... 2022-12-13 07:21:48,032 31332 INFO odoo odoo.modules.loading: Loading module web (2/7) 2022-12-13 07:21:48,196 31332 INFO odoo odoo.modules.registry: module web: creating or updating database tables 2022-12-13 07:21:49,139 31332 INFO odoo odoo.modules.loading: loading web/security/ir.model.access.csv 2022-12-13 07:21:49,219 31332 INFO odoo odoo.modules.loading: loading web/views/webclient_templates.xml 2022-12-13 07:21:49,591 31332 INFO odoo odoo.modules.loading: loading web/views/report_templates.xml 2022-12-13 07:21:49,834 31332 INFO odoo odoo.modules.loading: loading web/views/base_document_layout_views.xml 2022-12-13 07:21:49,901 31332 INFO odoo odoo.modules.loading: loading web/data/report_layout.xml 2022-12-13 07:21:50,024 31332 INFO odoo odoo.modules.loading: Module web: loading demo 2022-12-13 07:21:50,064 31332 INFO odoo odoo.modules.loading: Module web loaded in 2.03s, 1297 queries 2022-12-13 07:21:50,065 31332 INFO odoo odoo.modules.loading: Loading module auth_totp (3/7) 2022-12-13 07:21:50,290 31332 INFO odoo odoo.modules.registry: module auth_totp: creating or updating database tables 2022-12-13 07:21:50,430 31332 INFO odoo odoo.modules.loading: loading auth_totp/security/security.xml 2022-12-13 07:21:50,465 31332 INFO odoo odoo.modules.loading: loading auth_totp/views/user_preferences.xml 2022-12-13 07:21:50,642 31332 INFO odoo odoo.modules.loading: loading auth_totp/views/templates.xml 2022-12-13 07:21:50,682 31332 INFO odoo odoo.modules.loading: Module auth_totp: loading demo 2022-12-13 07:21:50,721 31332 INFO odoo odoo.modules.loading: Module auth_totp loaded in 0.66s, 204 queries 2022-12-13 07:21:50,721 31332 INFO odoo odoo.modules.loading: Loading module base_import (4/7) 2022-12-13 07:21:51,176 31332 INFO odoo odoo.modules.registry: module base_import: creating or updating database tables 2022-12-13 07:21:53,328 31332 INFO odoo odoo.modules.loading: loading base_import/security/ir.model.access.csv 2022-12-13 07:21:53,417 31332 INFO odoo odoo.modules.loading: loading base_import/views/base_import_templates.xml 2022-12-13 07:21:53,455 31332 INFO odoo odoo.modules.loading: Module base_import: loading demo 2022-12-13 07:21:53,555 31332 INFO odoo odoo.modules.loading: Module base_import loaded in 2.83s, 864 queries 2022-12-13 07:21:53,556 31332 INFO odoo odoo.modules.loading: Loading module web_editor (5/7) 2022-12-13 07:21:54,363 31332 INFO odoo odoo.modules.registry: module web_editor: creating or updating database tables 2022-12-13 07:21:54,726 31332 INFO odoo odoo.modules.loading: loading web_editor/security/ir.model.access.csv 2022-12-13 07:21:54,763 31332 INFO odoo odoo.modules.loading: loading web_editor/views/editor.xml 2022-12-13 07:21:55,029 31332 INFO odoo odoo.modules.loading: loading web_editor/views/snippets.xml 2022-12-13 07:21:55,132 31332 INFO odoo odoo.modules.loading: Module web_editor: loading demo 2022-12-13 07:21:55,174 31332 INFO odoo odoo.modules.loading: Module web_editor loaded in 1.62s, 484 queries 2022-12-13 07:21:55,174 31332 INFO odoo odoo.modules.loading: Loading module web_kanban_gauge (6/7) 2022-12-13 07:21:55,327 31332 INFO odoo odoo.modules.loading: loading web_kanban_gauge/views/web_kanban_gauge_templates.xml 2022-12-13 07:21:55,405 31332 INFO odoo odoo.modules.loading: Module web_kanban_gauge: loading demo 2022-12-13 07:21:55,428 31332 INFO odoo odoo.modules.loading: Module web_kanban_gauge loaded in 0.25s, 63 queries 2022-12-13 07:21:55,429 31332 INFO odoo odoo.modules.loading: Loading module web_tour (7/7) 2022-12-13 07:21:55,589 31332 INFO odoo odoo.modules.registry: module web_tour: creating or updating database tables 2022-12-13 07:21:55,692 31332 INFO odoo odoo.modules.loading: loading web_tour/security/ir.model.access.csv 2022-12-13 07:21:55,719 31332 INFO odoo odoo.modules.loading: loading web_tour/security/ir.rule.csv 2022-12-13 07:21:55,762 31332 INFO odoo odoo.modules.loading: loading web_tour/views/tour_templates.xml 2022-12-13 07:21:55,860 31332 INFO odoo odoo.modules.loading: loading web_tour/views/tour_views.xml 2022-12-13 07:21:55,947 31332 INFO odoo odoo.modules.loading: Module web_tour: loading demo 2022-12-13 07:21:55,948 31332 INFO odoo odoo.modules.loading: loading web_tour/data/web_tour_demo.xml 2022-12-13 07:21:56,027 31332 INFO odoo odoo.modules.loading: Module web_tour loaded in 0.60s, 232 queries 2022-12-13 07:21:56,028 31332 INFO odoo odoo.modules.loading: 7 modules loaded in 7.99s, 3144 queries (+0 extra) 2022-12-13 07:21:56,377 31332 INFO odoo odoo.modules.loading: Modules loaded. 2022-12-13 07:22:53,273 31332 INFO ? odoo.http: HTTP Configuring static files 2022-12-13 07:22:56,514 31332 INFO odoo odoo.addons.base.models.ir_cron: Starting job `Base: Auto-vacuum internal data`. 2022-12-13 07:23:01,251 31332 INFO odoo odoo.addons.base.models.ir_http: Generating routing map for key None 2022-12-13 07:23:01,486 31332 INFO odoo werkzeug: 127.0.0.1 - - [13/Dec/2022 07:23:01] "GET / HTTP/1.1" 303 - 1 0.005 4.080 2022-12-13 07:23:01,524 31332 INFO odoo werkzeug: 127.0.0.1 - - [13/Dec/2022 07:23:01] "GET /web HTTP/1.1" 303 - 1 0.000 0.026 2022-12-13 07:23:01,830 31332 INFO odoo odoo.addons.base.models.ir_attachment: filestore gc 56 checked, 2 removed 2022-12-13 07:23:01,929 31332 INFO odoo odoo.addons.base.models.res_users: GC'd 0 user log entries 2022-12-13 07:23:02,053 31332 INFO odoo odoo.addons.base.models.ir_cron: Job `Base: Auto-vacuum internal data` done. 2022-12-13 07:23:01,251 31332 INFO odoo odoo.addons.base.models.ir_http: Generating routing map for key None 2022-12-13 07:23:01,486 31332 INFO odoo werkzeug: 127.0.0.1 - - [13/Dec/2022 07:23:01] "GET / HTTP/1.1" 303 - 1 0.005 4.080 2022-12-13 07:23:01,524 31332 INFO odoo werkzeug: 127.0.0.1 - - [13/Dec/2022 07:23:01] "GET /web HTTP/1.1" 303 - 1 0.000 0.026 2022-12-13 07:23:01,830 31332 INFO odoo odoo.addons.base.models.ir_attachment: filestore gc 56 checked, 2 removed 2022-12-13 07:23:01,929 31332 INFO odoo odoo.addons.base.models.res_users: GC'd 0 user log entries 2022-12-13 07:23:02,053 31332 INFO odoo odoo.addons.base.models.ir_cron: Job `Base: Auto-vacuum internal data` done.站点访问验证浏览器中打开http://localhost:8069/web/login,成功的话,可以看到如下界面输入默认登录账号/密码:admin/admin,点击登录,可以看到如下界面Pycharm中运行配置假设全部采用命令行参数参考连接https://www.odoo.com/documentation/14.0/zh_CN/administration/install/install.htmlhttps://www.enterprisedb.com/docs/supported-open-source/postgresql/installer/02_installing_postgresql_with_the_graphical_installation_wizard/01_invoking_the_graphical_installer/https://www.odoo.com/documentation/14.0/zh_CN/developer/cli.html#reference-cmdline-serverhttps://www.odoo.com/documentation/14.0/zh_CN/applications/general/developer_mode.html#developer-mode
大数据量文本文件高效解析方案代码实现测试环境Python 3.6.2Win 10 内存 8G,CPU I5 1.6 GHz背景描述这个作品来源于一个日志解析工具的开发,这个开发过程中遇到的一个痛点,就是日志文件多,日志数据量大,解析耗时长。在这种情况下,寻思一种高效解析数据解析方案。解决方案描述1、采用多线程读取文件2、采用按块读取文件替代按行读取文件由于日志文件都是文本文件,需要读取其中每一行进行解析,所以一开始会很自然想到采用按行读取,后面发现合理配置下,按块读取,会比按行读取更高效。按块读取来的问题就是,可能导致完整的数据行分散在不同数据块中,那怎么解决这个问题呢?解答如下:将数据块按换行符\n切分得到日志行列表,列表第一个元素可能是一个完整的日志行,也可能是上一个数据块末尾日志行的组成部分,列表最后一个元素可能是不完整的日志行(即下一个数据块开头日志行的组成部分),也可能是空字符串(日志块中的日志行数据全部是完整的),根据这个规律,得出以下公式,通过该公式,可以得到一个新的数据块,对该数据块二次切分,可以得到数据完整的日志行上一个日志块首部日志行 +\n + 尾部日志行 + 下一个数据块首部日志行 + \n + 尾部日志行 + ...3、将数据解析操作拆分为可并行解析部分和不可并行解析部分数据解析往往涉及一些不可并行的操作,比如数据求和,最值统计等,如果不进行拆分,并行解析时势必需要添加互斥锁,避免数据覆盖,这样就会大大降低执行的效率,特别是不可并行操作占比较大的情况下。对数据解析操作进行拆分后,可并行解析操作部分不用加锁。考虑到Python GIL的问题,不可并行解析部分替换为单进程解析。4、采用多进程解析替代多线程解析采用多进程解析替代多线程解析,可以避开Python GIL全局解释锁带来的执行效率问题,从而提高解析效率。5、采用队列实现“协同”效果引入队列机制,实现一边读取日志,一边进行数据解析:日志读取线程将日志块存储到队列,解析进程从队列获取已读取日志块,执行可并行解析操作并行解析操作进程将解析后的结果存储到另一个队列,另一个解析进程从队列获取数据,执行不可并行解析操作。代码实现#!/usr/bin/env python # -*- coding:utf-8 -*- import re import time from datetime import datetime from joblib import Parallel, delayed, parallel_backend from collections import deque from multiprocessing import cpu_count import threading class LogParser(object): def __init__(self, chunk_size=1024*1024*10, process_num_for_log_parsing=cpu_count()): self.log_unparsed_queue = deque() # 用于存储未解析日志 self.log_line_parsed_queue = deque() # 用于存储已解析日志行 self.is_all_files_read = False # 标识是否已读取所有日志文件 self.process_num_for_log_parsing = process_num_for_log_parsing # 并发解析日志文件进程数 self.chunk_size = chunk_size # 每次读取日志的日志块大小 self.files_read_list = [] # 存放已读取日志文件 self.log_parsing_finished = False # 标识是否完成日志解析 def read_in_chunks(self, filePath, chunk_size=1024*1024): """ 惰性函数(生成器),用于逐块读取文件。 默认区块大小:1M """ with open(filePath, 'r', encoding='utf-8') as f: while True: chunk_data = f.read(chunk_size) if not chunk_data: break yield chunk_data def read_log_file(self, logfile_path): ''' 读取日志文件 这里假设日志文件都是文本文件,按块读取后,可按换行符进行二次切分,以便获取行日志 ''' temp_list = [] # 二次切分后,头,尾行日志可能是不完整的,所以需要将日志块头尾行日志相连接,进行拼接 for chunk in self.read_in_chunks(logfile_path, self.chunk_size): log_chunk = chunk.split('\n') temp_list.extend([log_chunk[0], '\n']) temp_list.append(log_chunk[-1]) self.log_unparsed_queue.append(log_chunk[1:-1]) self.log_unparsed_queue.append(''.join(temp_list).split('\n')) self.files_read_list.remove(logfile_path) def start_processes_for_log_parsing(self): '''启动日志解析进程''' with parallel_backend("multiprocessing", n_jobs=self.process_num_for_log_parsing): Parallel(require='sharedmem')(delayed(self.parse_logs)() for i in range(self.process_num_for_log_parsing)) self.log_parsing_finished = True def parse_logs(self): '''解析日志''' method_url_re_pattern = re.compile('(HEAD|POST|GET)\s+([^\s]+?)\s+',re.DOTALL) url_time_taken_extractor = re.compile('HTTP/1\.1.+\|(.+)\|\d+\|', re.DOTALL) while self.log_unparsed_queue or self.files_read_list: if not self.log_unparsed_queue: continue log_line_list = self.log_unparsed_queue.popleft() for log_line in log_line_list: #### do something with log_line if not log_line.strip(): continue res = method_url_re_pattern.findall(log_line) if not res: print('日志未匹配到请求URL,已忽略:\n%s' % log_line) continue method = res[0][0] url = res[0][1].split('?')[0] # 去掉了 ?及后面的url参数 # 提取耗时 res = url_time_taken_extractor.findall(log_line) if res: time_taken = float(res[0]) else: print('未从日志提取到请求耗时,已忽略日志:\n%s' % log_line) continue # 存储解析后的日志信息 self.log_line_parsed_queue.append({'method': method, 'url': url, 'time_taken': time_taken, }) def collect_statistics(self): '''收集统计数据''' def _collect_statistics(): while self.log_line_parsed_queue or not self.log_parsing_finished: if not self.log_line_parsed_queue: continue log_info = self.log_line_parsed_queue.popleft() # do something with log_info with parallel_backend("multiprocessing", n_jobs=1): Parallel()(delayed(_collect_statistics)() for i in range(1)) def run(self, file_path_list): # 多线程读取日志文件 for file_path in file_path_list: thread = threading.Thread(target=self.read_log_file, name="read_log_file", args=(file_path,)) thread.start() self.files_read_list.append(file_path) # 启动日志解析进程 thread = threading.Thread(target=self.start_processes_for_log_parsing, name="start_processes_for_log_parsing") thread.start() # 启动日志统计数据收集进程 thread = threading.Thread(target=self.collect_statistics, name="collect_statistics") thread.start() start = datetime.now() while threading.active_count() > 1: print('程序正在努力解析日志...') time.sleep(0.5) end = datetime.now() print('解析完成', 'start', start, 'end', end, '耗时', end - start) if __name__ == "__main__": log_parser = LogParser() log_parser.run(['access.log', 'access2.log'])注意:需要合理的配置单次读取文件数据块的大小,不能过大,或者过小,否则都可能会导致数据读取速度变慢。笔者实践环境下,发现10M~15M每次是一个比较高效的配置。
前提Tomcat 10.1.xTomcat线程池介绍Tomcat线程池,源于JAVA JDK自带线程池。由于JAVA JDK线程池策略,比较适合处理 CPU 密集型任务,但是对于 I/O 密集型任务,如数据库查询,rpc 请求调用等,不是很友好,所以Tomcat在其基础上进行了扩展。任务处理流程扩展线程池相关源码简析Tomcat中定义了一个StandardThreadExecutor类,该类实现了org.apache.catalina.Executor,org.apache.tomcat.util.threads.ResizableExecutor接口该类内部定义了namePrefix(创建的线程名称前缀,默认值tomcat-exec-),maxThreads(最大线程数,默认值 200),minSpareThreads(最小线程数,即核心线程数,默认值 25),maxIdleTime(线程最大空闲时间,毫秒为单位,默认值60秒),maxQueueSize(最大队列大小,默认值 Integer.MAX_VALUE)等属性,此外,还定义了一个org.apache.tomcat.util.threads.ThreadPoolExecutor类型的执行器对象,一个execute(Runnable command) 方法当execute(Runnable command) 方法被调用时,会调用上述ThreadPoolExecutor类对象的execute方法org.apache.catalina.core.StandardThreadExecutor.javaimport org.apache.catalina.Executor; import org.apache.catalina.LifecycleException; import org.apache.catalina.LifecycleState; import org.apache.catalina.util.LifecycleMBeanBase; import org.apache.tomcat.util.res.StringManager; import org.apache.tomcat.util.threads.ResizableExecutor; import org.apache.tomcat.util.threads.TaskQueue; import org.apache.tomcat.util.threads.TaskThreadFactory; import org.apache.tomcat.util.threads.ThreadPoolExecutor; public class StandardThreadExecutor extends LifecycleMBeanBase implements Executor, ResizableExecutor { protected static final StringManager sm = StringManager.getManager(StandardThreadExecutor.class); // ---------------------------------------------- Properties /** * Default thread priority */ protected int threadPriority = Thread.NORM_PRIORITY; /** * Run threads in daemon or non-daemon state */ protected boolean daemon = true; /** * Default name prefix for the thread name */ protected String namePrefix = "tomcat-exec-"; /** * max number of threads */ protected int maxThreads = 200; /** * min number of threads */ protected int minSpareThreads = 25; /** * idle time in milliseconds */ protected int maxIdleTime = 60000; /** * The executor we use for this component */ protected ThreadPoolExecutor executor = null; /** * the name of this thread pool */ protected String name; /** * The maximum number of elements that can queue up before we reject them */ protected int maxQueueSize = Integer.MAX_VALUE; /** * After a context is stopped, threads in the pool are renewed. To avoid * renewing all threads at the same time, this delay is observed between 2 * threads being renewed. */ protected long threadRenewalDelay = org.apache.tomcat.util.threads.Constants.DEFAULT_THREAD_RENEWAL_DELAY; private TaskQueue taskqueue = null; // ---------------------------------------------- Constructors public StandardThreadExecutor() { //empty constructor for the digester } //....此处代码已省略 @Override public void execute(Runnable command) { if (executor != null) { // Note any RejectedExecutionException due to the use of TaskQueue // will be handled by the o.a.t.u.threads.ThreadPoolExecutor executor.execute(command); } else { throw new IllegalStateException(sm.getString("standardThreadExecutor.notStarted")); } } //....此处代码已省略 }当org.apache.tomcat.util.threads.ThreadPoolExecuto类对象的execute(Runnable command) 方法被调用时,会调用该类定义的一个executeInternal方法,并在捕获到RejectedExecutionException异常时,尝试再次将任务放入工作队列中。executeInternal方法中,通过代码可知,当前线程数小于核心线程池大小时,会创建新线程,否则,会调用workQueue对象(org.apache.tomcat.util.threads.TaskQueue类型)的offer方法,将任务进行排队。Tomcat通过控制workQueue.offer()方法的返回值,实现了当前线程数超过核心线程池大小时,优先创建线程,而不是让任务排队。org.apache.tomcat.util.threads.ThreadPoolExecutorpublic class ThreadPoolExecutor extends AbstractExecutorService { //...此处代码已省略 @Override public void execute(Runnable command) { submittedCount.incrementAndGet(); try { executeInternal(command); } catch (RejectedExecutionException rx) { if (getQueue() instanceof TaskQueue) { // If the Executor is close to maximum pool size, concurrent // calls to execute() may result (due to Tomcat's use of // TaskQueue) in some tasks being rejected rather than queued. // If this happens, add them to the queue. final TaskQueue queue = (TaskQueue) getQueue(); if (!queue.force(command)) { submittedCount.decrementAndGet(); throw new RejectedExecutionException(sm.getString("threadPoolExecutor.queueFull")); } } else { submittedCount.decrementAndGet(); throw rx; } } } /** * Executes the given task sometime in the future. The task * may execute in a new thread or in an existing pooled thread. * * If the task cannot be submitted for execution, either because this * executor has been shutdown or because its capacity has been reached, * the task is handled by the current {@link RejectedExecutionHandler}. * * @param command the task to execute * @throws RejectedExecutionException at discretion of * {@code RejectedExecutionHandler}, if the task * cannot be accepted for execution * @throws NullPointerException if {@code command} is null */ private void executeInternal(Runnable command) { if (command == null) { throw new NullPointerException(); } /* * Proceed in 3 steps: * * 1. If fewer than corePoolSize threads are running, try to * start a new thread with the given command as its first * task. The call to addWorker atomically checks runState and * workerCount, and so prevents false alarms that would add * threads when it shouldn't, by returning false. * * 2. If a task can be successfully queued, then we still need * to double-check whether we should have added a thread * (because existing ones died since last checking) or that * the pool shut down since entry into this method. So we * recheck state and if necessary roll back the enqueuing if * stopped, or start a new thread if there are none. * * 3. If we cannot queue task, then we try to add a new * thread. If it fails, we know we are shut down or saturated * and so reject the task. */ int c = ctl.get(); if (workerCountOf(c) < corePoolSize) { // 当前线程数小于核心线程数时, if (addWorker(command, true)) { // 创建线程 return; } c = ctl.get(); } if (isRunning(c) && workQueue.offer(command)) { //workQueue.offer(command)为false时,会走以下的else if分支,创建线程 int recheck = ctl.get(); if (! isRunning(recheck) && remove(command)) { reject(command); } else if (workerCountOf(recheck) == 0) { addWorker(null, false); } } else if (!addWorker(command, false)) { reject(command); } } //...此处代码已省略 }org.apache.tomcat.util.threads.TaskQueue继承于java.util.concurrent.LinkedBlockingQueue,并重写了offer(排队任务的方法),该方法中,当当前线程数大于核心线程数,小于最大线程数时,返回false,导致上述executeInternal方法中workQueue.offer(command)为false,进而导致该分支代码不被执行,执行addWorker(command, false)方法,创建新线程。org.apache.tomcat.util.threads.TaskQueueimport java.util.Collection; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; import org.apache.tomcat.util.res.StringManager; /** * As task queue specifically designed to run with a thread pool executor. The * task queue is optimised to properly utilize threads within a thread pool * executor. If you use a normal queue, the executor will spawn threads when * there are idle threads and you won't be able to force items onto the queue * itself. */ public class TaskQueue extends LinkedBlockingQueue<Runnable> { //...此处代码已省略 /** * Used to add a task to the queue if the task has been rejected by the Executor. * * @param o The task to add to the queue * * @return {@code true} if the task was added to the queue, * otherwise {@code false} */ public boolean force(Runnable o) { if (parent == null || parent.isShutdown()) { throw new RejectedExecutionException(sm.getString("taskQueue.notRunning")); } return super.offer(o); //forces the item onto the queue, to be used if the task is rejected } @Override public boolean offer(Runnable o) { //we can't do any checks if (parent==null) { return super.offer(o); } //we are maxed out on threads, simply queue the object if (parent.getPoolSize() == parent.getMaximumPoolSize()) { return super.offer(o); } //we have idle threads, just add it to the queue if (parent.getSubmittedCount()<=(parent.getPoolSize())) { return super.offer(o); } //if we have less threads than maximum force creation of a new thread if (parent.getPoolSize()<parent.getMaximumPoolSize()) { return false; } //if we reached here, we need to add it to the queue return super.offer(o); } //...此处代码已省略 }参考链接https://gitee.com/apache/tomcat/blob/10.1.x/java/org/apache/catalina/core/StandardThreadExecutor.java
Java 线程池之Jetty 线程池学习总结前提Jetty 11.0.x为什么是Jetty?Java提供4中创建线程池的快捷方式Executors.newFixedThreadPool(); Executors.newCachedThreadPool(); Executors.newSingleThreadExecutor(); Executors.newScheduledThreadPool();但通常我们很少用这4个工厂方法去创建线程池,而是直接使用ThreadPoolExecutor类构造线程池,因为这些工厂方法最终也是调用这个类来创建线程池的。众所周知,虽然ThreadPoolExecutor提供了corePoolSize和maximumPoolSize两个参数来控制线程池的基本大小和最大大小,但是这两个参数并不是那么好用:当任务队列采用SynchronousQueue时,通常需要无界的maximumPoolSize;当任务队列采用无界队列时,maximumPoolSize的值又相当于不起作用;当任务队列采用有界队列时,仅在任务队列已满,且未达到maximumPoolSize时才会扩充线程池大小。既然如此,那有没有一种更简单的实现方案呢?使用该方案,使用者只需要简单的配置下线程池的基本大小和最大大小,程序就可以根据任务的繁忙程度自动调整当前线程数量。答案是有的:Jetty--一个基于Java的web容器,和Tomcat齐名Jetty线程池介绍任务处理流程初始化线程池程序初始化运行时,会先创建线程池,线程池大小默认为minThreads,也就是说会预先创建minThreads个线程,线程名称格式形如“qtp1076496284-13”创建线程池时:如果未指定最大线程数(maxThreads),则默认为 200;如果未指定最小线程数(minThreads),则默认为 8如果未指定线程空闲超时时间(idleTimeout),则默认为 60000,即60秒保留线程数(reservedThreads)默认为 -1如果未指定任务队列,则默认创建BlockingArrayQueue任务队列,容量大小为 8 x 1024如果指定的最大线程数小于最小线程数,则抛出异常线程池扩缩容当前线程数比最小线程数小,或者没有空闲的线程,且当前线程数(threads )小于最大线程数,则创建线程;idleTimout大于0且当前线程数大于最小线程数,且线程空闲时间超过idleTimeout,则停止线程注意:程序判断是否存在空闲线程的逻辑是这样的:Net空闲线程数 = 空闲线程数 - 任务队列大小,如果“Net空闲线程数”为负数,则表示不存在空闲线程,即需要更多的线程来处理任务。任务队列及线程相关定义queueSize 任务队列大小,即队列中等待被线程执行的任务数。可通过getQueueSize()函数获取。threads 当前线程池中的线程数,包括已租给内部组件的线程、空闲线程、保留线程,以及正在执行临时作业的线程。threads = readyThreads + leasedThreads + utilizedThreads。 可通过getThreads() 函数获取。readyThreads 准备执行临时任务的线程数。readyThreads = idleThreads + availableReservedThreads。可通过getReadyThreads()函数获取。idleThreads 未被保留的空闲线程数。idleThreads = readyThreads - availableReservedThreads。可通过getIdleThreads()函数获取。reservedThreads 保留的线程数,默认值为-1。可通过getReservedThreads()函数获取。availableReservedThreads 可用的保留线程。可通过getAvailableReservedThreads()函数获取。leasedThreads 供内部组件使用,用于执行内部任务的线程。需要线程的Jetty组件(比如网络acceptors和selector)可能会使用ThreadPoolBudget从线程池中租用线程。站在线程池的角度来看,这些被租用的线程是活跃的,但是不能用于执行临时任务,比如一个HTTP请求,或者一个WebSocket帧。QueuedThreadPool有一个ReservedThreadExecutor,该组件会从线程池租用线程,但会让这些线程可用,就像它们是“idle”线程一样。线程池启动后,该值一般是恒定的。可通过getLeasedThreads()函数获取。minThreads 线程池中的最小线程数。可通过getMinThreads() 函数获取。maxThreads 线程池中的最大线程数。可通过getMaxThreads() 函数获取。maxAvailableThreads 可用于执行临时任务的最大线程数。maxAvailableThreads = maxThreads - leasedThreads 可通过getMaxAvailableThreads()函数获取。utilizedThreads执行临时任务的线程数,可通过getUtilizedThreads()函数获取。utilizedThreads = threads - leasedThreads - readyThreadsutilizationRate = utilizedThreads / maxAvailableThreads 执行临时任务的线程利用率。该值为0.0D则表示线程池未被利用,如果为1.0D则表示线程池被充分利用于执行临时任务。可通过getUtilizationRate() 函数获取。busyThreads 正在执行内部任务和临时任务的线程数。 busyThreads = utilizedThreads + leasedThreads。可通过getBusyThreads()函数获取。参考链接https://www.eclipse.org/jetty/javadoc/jetty-11/org/eclipse/jetty/util/thread/QueuedThreadPool.htmlhttps://gitee.com/Tedgar156/jetty.project/blob/jetty-11.0.x/jetty-util/src/main/java/org/eclipse/jetty/util/thread/QueuedThreadPool.java
逻辑控制之IF条件控制器测试环境JMeter-5.4.1循环控制器介绍添加While Controller右键线程组->添加->逻辑控制器->While控制器控制器面板介绍添加后,面板如下仅Expression值为true,才会执行位于其下的操作最好勾选(默认配置)Interpret Condition as Variable Expression?,这样Expression输入框可以有两种输入选择:输入一个值为true 或者false的变量比如,如果你想测试,最后一个采样器执行是否成功,可以输入${JMeterThread.last_sample_ok}输入对bool表达式求值的函数(建议使用${__jexl3()},当然也可以用支持__groovy) ,形如${__jexl3(${COUNT} < 10 && "${VAR}" == "abcd",)}例如,没勾选上述配置之前,使用条件:${__jexl3(${VAR} == 23)},该条件计算结果(true或者false)会被传递给JavaScript,最后由JavaScript反回该结果值。勾选上述配置之后,会将该条件计算结果直接与true比较,不需要使用JavaScript.检测变量是否为定义或者为null,可以采用以下表达式,假设变量命名为 myVar:${__jexl3("${myVar}" == "\${myVar}")}或者:${__jexl3("${myVar}" != "\${myVar}")}如果不勾选 Interpret Condition as Variable Expression? 控制器会使用javascript计算表达式,这会带来很大的性能消耗,并且降低测试的扩展性。Evaluate for all children如果勾选,则执行其下所有子组件时都会重新计算条件值,否则仅在进入控制器时时进行计算示例:如下图,If Controller条件为${__jexl3("${myVar}" == "\${myVar}")},执行HTTP Request1之前没有设置myVar变量。没勾选Evaluate for all children之前,运行时,HTTP Request2也会被执行,反之,HTTP Request2不会被执行。例子(JavaScript)${COUNT} < 10"${VAR}" == "abcd"如果在解释代码时出错,那么条件结果值会被设置为false当使用__groovy时,注意不要在字符串中使用变量替换,形如${__groovy("${myVar}" == 1)},否则使用了改变脚本的变量不能被缓存。取而代之,使用vars.get("myVar"),参见下文例子(Variable Expression)${__groovy(vars.get("myVar") != "Invalid" )} (检查myVar变量是否等于Invalid)${__groovy(vars.get("myInt").toInteger() <=4 )} (检查myInt变量是否小于等于4)${__groovy(vars.get("myMissing") != null )} (检查是否设置了myMissing变量)${__jexl3(${COUNT} < 10)}${RESULT}${JMeterThread.last_sample_ok} (检查最后一个采样器采样是否成功)
后置处理器之JSON提取器测试环境JMeter 5.4.1插件介绍JSON后置处理器(PostProcessor)允许使用 JSON Path 语法从JSON格式的响应中提取数据。类似正则表达式提取器,必须位于HTTP采样器、或者其它可以返回JSON数据的采样器下,作为子结点。插件参数Name显示在脚本树结构中的名称Apply to:这用于可以生成子采样器的采样器,比如携带嵌套资源的HTTP采样器、邮件读取器或者由事务控制器控制的采样。Main sample only仅适用于主采样Sub-samples only仅适用于子采样Main sample and sub-samples适用于主采样和子采样JMeter Variable Name to use提取适用于命名变量的内容。Names of created variables由英文分号 ;分隔的变量名称,这些变量分别用于存储对应JSON-PATH表达提取的结果(必须匹配JSON-PATH表达式数量)。JSON Path Expressions由分号分号 ;分割的JSON-PATH表达式 (必须匹配变量的数量)Default Values如果对应变量的JSON-PATH表达式不返回任何结果时,对应变量的默认值,由英文分号 ;分隔(必须匹配变量的数量)Match Numbers对于每个JSON-PATH表达式,如果表达式查询到多个结果时,你可以选择提取那个值作为变量值。0 表示随机(匹配数字的默认值)-1 提取所有结果,这些结果将存储到名为<variable name>_N的变量(N取值从1到提取结果的数量)X 表示提取第X个结果。如果第X个结果不存在,则不会返回任何值,直接使用对应默认值作为变量。这些匹配数字必须使用英文分号相隔,且匹配JSON-PATH表达式的数量。如果不配置匹配数字,默认使用0作为每个表达式的默认值。注意:不管对应的JSON-PATH表达式能否查询到结果,程序都会将结果数(如果查询不到结果则设置为 0 )存储到变量 <variable name>_matchNr中Compute concatenation var如果勾选,则表示如果对应表达式查询到多个结果,插件将使用 , 连接这些值并存储为命名为<variable name>_ALL的变量中。插件使用示例其中登录请求返回类似如下信息{"token":"73ab6c33c39a46c1b27ae314b7a7eb1e","userName":"测试","warehouseList":[{"warehouseCode":"001DSC","warehouseName":"测试仓库","areas":[{"zonegroupCode":"A1","zonegroupdescr":"A1区"},{"zonegroupCode":"A2","zonegroupdescr":"A2区"},{"zonegroupCode":"A3","zonegroupdescr":"A3区"},{"zonegroupCode":"A4","zonegroupdescr":"A4区"}]}],errorMsgPrams":["SUCCESS"]}通过Debug PostProcessor观察到提取的相关变量值如下JMeterVariables: areas=[{"zonegroupCode":"A1","zonegroupdescr":"A1区"},{"zonegroupCode":"A2","zonegroupdescr":"A2区"},{"zonegroupCode":"A3","zonegroupdescr":"A3区"},{"zonegroupCode":"A4","zonegroupdescr":"A4区"}] areas_matchNr=1 token=d50350c345824a95ba8e1e4d43270fff token_matchNr=1 zonegroupCode_1=A1 zonegroupCode_2=A2 zonegroupCode_3=A3 zonegroupCode_4=A4 zonegroupCode_5=A5 zonegroupCode_matchNr=5JSON-PATH表达式介绍JsonPath表达式可以使用点标记$.store.book[0].title或者括号标记$['store']['book'][0]['title']操作符操作符描述$需要查找的根元素。所有JSON PATH表达式都以这个开头@正被某个过滤谓词处理的当前节点(The current node being processed by a filter predicate)*通配符。可以表示一个名称或者数字..深度扫描。可以表示一个名称.<name>获取子节点。['<name>' (, '<name>')]括号标记的子结点或者子孙结点[<number> (, <number>)]单个或多个数组索引。[start:end]数组切片操作符。注意,不含end[?(<expression>)]过滤表达式,必须为boolean表达式函数可以在path表达式末尾调用函数--表达式输出即为函数的输入。常见函数如下函数描述输出类型min()获取数字数组的最小值。Doublemax()获取数字数组的最大值。Doubleavg()获取数字数组的平均值。Doublestddev()获取数字数组的标准方差。Doublelength()获取数组长度Integersum()获取数字数组的总和。Doubleappend(X)添加一个元素到JSON-PATH表达式输出数组中同输入过滤器操作符过滤器为用于过滤数组的逻辑表达式,一个典型的过滤器 [?(@.age > 18)] ,这里 @代表正被处理的当前项。可以使用逻辑操作符 && 和 ||创建更复杂的过滤器。字符串文字必须用单引号或者双引号引起来,形如 ([?(@.color == 'blue')] 或者 [?(@.color == "blue")])操作符描述==等于!=不等于<小于<=小于等于>大于>=大于等于=~匹配正则表达式,形如[?(@.name =~ /foo.*?/i)]in包含于,形如 [?(@.size in ['S', 'M'])]nin不包含于subsetof子集,形如 [?(@.sizes subsetof ['S', 'M', 'L'])]anyof操作符左侧值必须和右侧有交集(left has an intersection with right),形如[?(@.sizes anyof ['M', 'L'])]noneof操作符左侧值和右侧无交集size操作符左侧数组或者字符串长度必须匹配右侧empty操作符左侧必须为空数组或者字符串JSON PATH示例给定如下json{ "store": { "book": [ { "category": "reference", "author": "Nigel Rees", "title": "Sayings of the Century", "price": 8.95 }, { "category": "fiction", "author": "Evelyn Waugh", "title": "Sword of Honour", "price": 12.99 }, { "category": "fiction", "author": "Herman Melville", "title": "Moby Dick", "isbn": "0-553-21311-3", "price": 8.99 }, { "category": "fiction", "author": "J. R. R. Tolkien", "title": "The Lord of the Rings", "isbn": "0-395-19395-8", "price": 22.99 } ], "bicycle": { "color": "red", "price": 19.95 } }, "expensive": 10 }JsonPath结果$.store.book[*].author表示所有书籍的作者。$..author表示所有作者$.store.*所有东西--所有书籍和自行车。$.store..price所有东西的价格$..book[2]第三本书$..book[-2]倒数第二本书$..book[0,1]The first two books$..book[:2]索引为0到2(不含2)的所有书籍$..book[1:2]索引为1到2(不含2)的所有书籍$..book[-2:]最后两本书$..book[2:]索引为2及其往后的所有书籍。$..book[?(@.isbn)]携带isbn号的所有书籍 an ISBN number$.store.book[?(@.price < 10)]商店中价格低于10的所有书籍。$..book[?(@.price <= $['expensive'])]所有非 "expensive"的书籍$..book[?(@.author =~ /.*REES/i)]所有匹配正则表达式(忽略大小写)的书籍$..*返回所有东西$..book.length()书籍数量参考连接https://github.com/json-path/JsonPathhttps://jmeter.apache.org/usermanual/component_reference.html#JSON_Extractor
华为云OSS建桶与文件上传下载删除及检索示例实践环境运行环境:Python 3.5.4CentOS Linux release 7.4.1708 (Core)/Win10需要安装以下类库:https://github.com/huaweicloud/huaweicloud-sdk-python-obs/blob/master/release/huaweicloud-obs-sdk-python_3.22.2.zip示例import traceback try: from obs import ObsClient from obs import StorageClass from obs import CreateBucketHeader from obs import DeleteObjectsRequest, Object obs_client = None obs_client = ObsClient( access_key_id='15K7IJ2GKROGEEEFGG51', secret_access_key='HVern8SOfk7SAzCEqDoVXySXZgcP7rpf0iJqiyKd', server='https://obs.cn-south-1.myhuaweicloud.com' ) bucket_name = 'mybucket' # 必须全局唯一 -- 如果该名称被其它账户使用了,那么不能再用该名称创建通过桶 location = 'cn-south-1' # 检查桶是否存在 resp = obs_client.headBucket(bucket_name) if resp.status < 300: print('Bucket %s 已存在' % bucket_name) elif resp.status == 404: print('Bucket %s 不存在' % bucket_name) # 创建桶 resp = obs_client.createBucket(bucketName=bucket_name, header=CreateBucketHeader(aclControl='private', storageClass=StorageClass.WARM), location=location) if resp.status < 300: print('requestId:', resp.requestId) else: print('errorCode:', resp.errorCode) print('errorMessage:', resp.errorMessage) # 上传本地文件 resp = obs_client.putFile(bucket_name, 'f2b/artifacts/artifacts-0bb40b.tar.gz', 'd:\\artifacts-17a86f.tar.gz') if resp.status < 300: print('requestId:', resp.requestId) print('etag:', resp.body.etag) print('versionId:', resp.body.versionId) print('storageClass:', resp.body.storageClass) else: print('errorCode:', resp.errorCode) print('errorMessage:', resp.errorMessage) # 下载文件 # downloadPath: 下载的文件本地存储路径,如果不存在,会自动创建。注意:如果要将文件存在到当前路径下,不能仅写文件,会报错,需要写成 ./文件名称,形如 ./artifacts.tar.gz' # resp = obs_client.getObject(bucket_name, 'f2b/artifacts/artifacts-0bb40b.tar.gz', downloadPath='./artifacts.tar.gz') resp = obs_client.getObject(bucket_name, 'f2b/artifacts/artifacts-0bb40b.tar.gz', downloadPath='d:\\testdir\\artifacts.tar.gz') if resp.status < 300: print('requestId:', resp.requestId) print('url:', resp.body.url) else: print('errorCode:', resp.errorCode) print('errorMessage:', resp.errorMessage) #列举桶内对象 # resp = obs_client.listObjects(bucket_name, prefix = None, max_keys=2) # 如果不需要过滤,则prefix可以设置为None、空字符串 # 如果查询到的文件对象数量超过max_keys则 resp.body.next_marker为下轮查询结果列表中某个对象的key,否则为None resp = obs_client.listObjects(bucket_name, prefix='f2b/artifacts', max_keys=5) while resp.status < 300 and resp.body.contents: print('requestId:', resp.requestId) print('name:', resp.body.name) print('prefix:', resp.body.prefix) print('max_keys:', resp.body.max_keys) print('is_truncated:', resp.body.is_truncated) index = 1 print(resp.body.next_marker) for content in resp.body.contents: print('object [' + str(index) + ']') print('key:', content.key) print('lastModified:', content.lastModified) print('etag:', content.etag) print('size:', content.size) print('storageClass:', content.storageClass) print('owner_id:', content.owner.owner_id) print('owner_name:', content.owner.owner_name) index += 1 if resp.body.next_marker: resp = obs_client.listObjects(bucket_name, prefix='f2b/artifacts', marker=resp.body.next_marker, max_keys=5) else: resp.body.contents = [] else: if resp.status > 300: print('errorCode:', resp.errorCode) print('errorMessage:', resp.errorMessage) # 删除对象 object1 = Object(key='f2b/artifacts/artifacts-0bb40b.tar.gz', versionId=None) resp = obs_client.deleteObjects(bucket_name, DeleteObjectsRequest(quiet=False, objects=[object1])) if resp.status < 300: print('requestId:', resp.requestId) if resp.body.deleted: index = 1 for delete in resp.body.deleted: print('delete[' + str(index) + ']') print('key:', delete.key, ',deleteMarker:', delete.deleteMarker, ',deleteMarkerVersionId:', delete.deleteMarkerVersionId) print('versionId:', delete.versionId) index += 1 if resp.body.error: index = 1 for err in resp.body.error: print('err[' + str(index) + ']') print('key:', err.key, ',code:', err.code, ',message:', err.message) print('versionId:', err.versionId) index += 1 else: print('errorCode:', resp.errorCode) print('errorMessage:', resp.errorMessage) except Exception: print('%s' % traceback.format_exc()) finally: if obs_client: obs_client.close()参考连接https://support.huaweicloud.com/sdk-python-devg-obs/obs_22_0803.htmlhttps://support.huaweicloud.com/sdk-python-devg-obs/obs_22_0801.htmlhttps://support.huaweicloud.com/sdk-python-devg-obs/obs_22_0904.htmlhttps://support.huaweicloud.com/sdk-python-devg-obs/obs_22_0911.htmlhttps://support.huaweicloud.com/sdk-python-devg-obs/obs_22_0805.htmlhttps://support.huaweicloud.com/sdk-python-devg-obs/obs_22_0919.htmlhttps://support.huaweicloud.com/api-obs/obs_04_0022.html
阿里云OSS文件上传下载与文件删除及检索示例实践环境运行环境:Python 3.5.4CentOS Linux release 7.4.1708 (Core)/Win10需要安装以下类库:pip3 install setuptools_rust1.1.2pip3 install Crypto1.4.1 # Win10下,安装后,需要更改 site-packages下crypto包名称为Cryptopip3 install cryptography3.3.2 # 注意,如果不指定版本,安装oss2时会报错:error: can't find Rust compilerpip3 install oss22.15.0上传本地文件到阿里云OSS示例#!/usr/bin/env python # -*- coding: utf-8 -*- import traceback import os # 批量上传文件到OSS def upload_files(bucket, target_dir_path, exclusion_list=[]): oss_objects_path = [] target_dir_path = os.path.normpath(target_dir_path).replace('\\', '/') for root, dirs, files in os.walk(target_dir_path): for file in files: target_file_path = os.path.normpath(os.path.join(root, file)) target_file_relative_path = target_file_path.replace('\\', '/').replace(target_dir_path, '').lstrip('/') if target_file_relative_path in exclusion_list: continue object_path = 'f2b/artifacts/web-admin-react/%s' % target_file_relative_path upload_file(bucket, target_file_path, object_path) oss_objects_path.append(object_path) return oss_objects_path # 上传文件到OSS def upload_file(bucket, target_file_path, object_path): with open(target_file_path, 'rb') as fileobj: res = bucket.put_object(object_path, fileobj) # object_path为Object的完整路径,路径中不能包含Bucket名称。 if res.status != 200: raise Exception('upload %s error,status:%s' % (target_file_path, res.status)) if __name__ == '__main__': try: import oss2 auth = oss2.Auth('ossAccessKeyId', 'ossAccessKeySecret') # oss2.Bucket(auth, endpoint, bucket_name) # endpoint填写Bucket所在地域对应的endpoint,bucket_name为Bucket名称。以华东1(杭州)为例,填写为https://oss-cn-hangzhou.aliyuncs.com。 bucket = oss2.Bucket(auth, 'https://oss-cn-shenzhen.aliyuncs.com', 'exampleBucket') oss_objects_path = [] # 存放上传成功文件对应的OSS对象相对路径 target_path = 'D:\\artifact-eb34ea94.tar.gz' if not os.path.exists(target_path): print('success:false,待上传路径(%s)不存在' % target_path) exit(0) if os.path.isdir(target_path): # 如果为目录 oss_objects_path = upload_files(bucket, target_path) else: object_path = 'f2b/artifacts/web-admin-react/artifact-eb34ea94.tar.gz' upload_file(bucket, target_path, object_path) oss_objects_path.append(object_path) print(','.join(oss_objects_path)) except Exception: print('success:false,%s' % traceback.format_exc())参考连接:https://help.aliyun.com/document_detail/88426.htm?spm=a2c4g.11186623.0.0.9e7e7dbbsOWOh6#t22317.htmlhttps://help.aliyun.com/document_detail/31848.html下载阿里云OSS文件对象到本地文件示例#!/usr/bin/env python # -*- coding: utf-8 -*- import traceback if __name__ == '__main__': try: import oss2 auth = oss2.Auth('ossAccessKeyId', 'ossAccessKeySecret') # oss2.Bucket(auth, endpoint, bucket_name) # endpoint填写Bucket所在地域对应的endpoint,bucket_name为Bucket名称。以华东1(杭州)为例,填写为https://oss-cn-hangzhou.aliyuncs.com。 bucket = oss2.Bucket(auth, 'https://oss-cn-shenzhen.aliyuncs.com', 'exampleBucket') target_file_local_path = 'D:\\artifacts-17a86f.tar.gz' # 本地文件路径 oss_object_path = 'f2b/artifacts/cloud-f2b-web-admin-react/artifact-eb34ea94.tar.gz' # bucket.get_object_to_file('object_path', 'object_local_path') # object_path 填写Object完整路径,完整路径中不包含Bucket名称,例如testfolder/exampleobject.txt。 # object_local_path 下载的Object在本地存储的文件路径,形如 D:\\localpath\\examplefile.txt。如果指定路径的文件存在会覆盖,不存在则新建。 try: res = bucket.get_object_to_file(oss_object_path, target_file_local_path) if res.status != 200: print('success:false,download fail, unknow exception, status:%s' % res.status) except Exception: print('success:false,%s' % traceback.format_exc()) except Exception: print('success:false,%s' % traceback.format_exc())参考连接:https://help.aliyun.com/document_detail/88442.html列举指定前缀的所有文件#!/usr/bin/env python # -*- coding: utf-8 -*- import traceback if __name__ == '__main__': try: import oss2 auth = oss2.Auth('ossAccessKeyId', 'ossAccessKeySecret') bucket = oss2.Bucket(auth, 'https://oss-cn-shenzhen.aliyuncs.com', 'exampleBucket') result_file_list = [] for obj in oss2.ObjectIteratorV2(bucket, prefix='f2b/www/alpha/f2b/icec-cloud-f2b-mobile'): result_file_list.append(obj.key) print(obj.key) print(','.join(result_file_list)) except Exception: print('success:false,%s' % traceback.format_exc())参考连接:https://help.aliyun.com/document_detail/88458.html批量删除OSS对象#!/usr/bin/env python # -*- coding: utf-8 -*- import sys import traceback if __name__ == '__main__': try: import oss2 auth = oss2.Auth('ossAccessKeyId', 'ossAccessKeySecret') bucket = oss2.Bucket(auth, 'https://oss-cn-shenzhen.aliyuncs.com', 'exampleBucket') oss_object_path_list = ''.join(sys.argv[1:2]).split(',') index = 0 oss_objects_to_delete = oss_object_path_list[index: index+1000] # API限制,每次最多删除1000个文件 while oss_objects_to_delete: result = bucket.batch_delete_objects(oss_object_path_list[index: index+1000]) # 打印成功删除的文件名。 print(result.deleted_keys) print('批量删除以下OSS对象成功') print(''.join(result.deleted_keys)) index += 1000 oss_objects_to_delete = oss_object_path_list[index: index+1000] except Exception: print('success:false,%s' % traceback.format_exc())参考连接:https://help.aliyun.com/document_detail/88463.html
配合Pipeline使用Docker许多组织使用Docker跨机器统一构建和测试环境,并为部署应用程序提供高效机制。从Pipeline 2.5及更高版本开始,Pipeline内置了从Jenkinsfile中与Docker交互的支持。下文将介绍从Jenkinsfile中使用Docker的基础知识定制执行环境Pipeline的设计可以轻松地使用Docker镜像作为单个Stage或整个 Pipeline 的执行环境。这意味着用户可以定义管道所需的工具,而无需手动配置代理。pipeline { agent { docker { image 'node:16.13.1-alpine' } } stages { stage('Test') { steps { sh 'node --version' } } } }当Pipeline执行时,Jenkins将自动启动指定的容器并在其中执行预定义的步骤:...略 + docker inspect -f . node:16.13.1-alpine Error: No such object: node:16.13.1-alpine [Pipeline] isUnix [Pipeline] sh + docker pull node:16.13.1-alpine 16.13.1-alpine: Pulling from library/node 59bf1c3509f3: Pulling fs layer ...略 503741069fa0: Pull complete 88b2b4880461: Pull complete Digest: sha256:0e071f3c5c84cffa6b1035023e1956cf28d48f4b36e229cef328772da81ec0c5 Status: Downloaded newer image for node:16.13.1-alpine [Pipeline] withDockerContainer Jenkins does not seem to be running inside a container $ docker run -t -d -u 0:0 -w /var/lib/jenkins/workspace/CI-Builder_testBranch -v /var/lib/jenkins/workspace/CI-Builder_testBranch:/var/lib/jenkins/workspace/CI-Builder_testBranch2:rw,z -v /var/lib/jenkins/workspace/CI-Builder_testBranch2@tmp:/var/lib/jenkins/workspace/CI-Builder_testBranch2@tmp:rw,z -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** node:16.13.1-alpine cat $ docker top 90c6a3a70e38ad03c8318cedcf78df34e5b8533d634ec2356e8babc32742a74c -eo pid,comm [Pipeline] { [Pipeline] stage [Pipeline] { (Test) [Pipeline] sh + node --version v16.13.1 [Pipeline] } [Pipeline] // stage [Pipeline] } $ docker stop --time=1 90c6a3a70e38ad03c8318cedcf78df34e5b8533d634ec2356e8babc32742a74c $ docker rm -f 90c6a3a70e38ad03c8318cedcf78df34e5b8533d634ec2356e8babc32742a74c [Pipeline] // withDockerContainer [Pipeline] } [Pipeline] // withEnv [Pipeline] } [Pipeline] // node [Pipeline] End of Pipeline Finished: SUCCESS从输出可知,Jenkins自动创建了指定镜像的容器,并且在容器中执行指定Step,最后,停止并强制删除创建的容器工作空间同步如果保持工作区与其他Stage同步很重要,请使用reuseNode true。否则,除了临时工作空间,Docker化的Stage还可以在任何其他任意代理或同一代理上运行默认的,对于容器化的Stage, Jenkin会执行以下动作:任选一个代理创建临时工作空间克隆pipeline代码到该工作空间加载该工作空间到容器如果你有多个Jenkins代理,你的容器化Stage可以在其中任何一个代理上启动当设置reuseNode设置为true时:不会创建新的工作区,当前代理的当前工作区将被装入容器,且将在同一节点上启动该容器,所以整体数据将被同步pipeline { agent any stages { stage('Build') { agent { docker { image 'gradle:6.7-jdk11' // Run the container on the node specified at the top-level of the Pipeline, in the same workspace, rather than on a new node entirely: reuseNode true } } steps { sh 'gradle --version' } } } }为容器缓存数据许多构建工具会下载外部依赖项并为了后续重用,会在本地缓存它们,以备将来重用。由于容器最初是用“干净”的文件系统创建的,这可能会导致Pipeline运行速度变慢,因为它们可能无法利用后续Pipeline运行之间的磁盘缓存。Pipeline支持添加传递给Docker的自定义参数,允许用户指定要加载的自定义Docker 卷,该卷可用于在Pipeline运行之间缓存agent上的数据。下面的示例将在Pipeline运行之间为maven容器缓存~/.m2,从而避免了为后续Pipeline运行重新下载依赖项的需要pipeline { agent { docker { image 'maven:3.8.1-adoptopenjdk-11' args '-v $HOME/.m2:/root/.m2' } } stages { stage('Build') { steps { sh 'mvn -B' } } } }使用多个容器代码库依赖多种不同的技术已经变得越来越普遍。例如,源码库可能既有基于Java的后端API实现,也有基于JavaScript的前端实现。Docker和Pipeline的结合允许Jenkinsfile通过在不同stage使用不同的 agent {}指令来使用多种技术。pipeline { agent none stages { stage('Back-end') { agent { docker { image 'maven:3.8.1-adoptopenjdk-11' } } steps { sh 'mvn --version' } } stage('Front-end') { agent { docker { image 'node:16.13.1-alpine' } } steps { sh 'node --version' } } } }使用Dockerfile对于需要更定制的执行环境的项目,Pipeline还支持从源码库中的Dockerfile构建和运行容器。与之前使用“现成”容器的方法不同,使用代理 agent { dockerfile true }语法将从Dockerfile中构建新镜像,而不是从Docker Hub中拉取镜像。在上面的示例的基础上增加一个自定义的Dockerfile:FROM node:16.13.1-alpine RUN apk add -U subversion通过将上述文件提交到源存储库的根目录,可以将Jenkins文件更改为基于此Dockerfile构建一个容器,然后使用该容器运行定义的步骤pipeline { agent { dockerfile true } stages { stage('Test') { steps { sh 'node --version' sh 'svn --version' } } } }agent { dockerfile true } 语法支持许多其它选项,在 Pipeline 语法中 有更多关于这些选项的更详细介绍。脚本化Pipeline运行“sidecar”容器的高级用法在Pipeline中使用Docker是运行构建或一组测试可能依赖的服务的有效方法。与sidecar模式类似,Docker Pipeline可以“在后台”运行一个容器,同时在另一个容器中执行工作。利用这种sidecar方法,PIpeline可以为每次PIpeline运行准备一个“干净”的容器备注:将本将属于应用程序的功能拆分成单独的进程,这个进程可以被理解为Sidecar假设有一个集成测试套件,它依赖于本地MySQL数据库运行。使用Docker Pipeline插件为支持脚本化Pipeline实现的withRun方法,Jenkinsfile可以将MySQL作为一个sidecar运行:node { checkout scm /* * In order to communicate with the MySQL server, this Pipeline explicitly * maps the port (3306) to a known port on the host machine. */ docker.image('mysql:5').withRun('-e "MYSQL_ROOT_PASSWORD=my-secret-pw" -p 3306:3306') { c -> /* Wait until mysql service is up */ sh 'while ! mysqladmin ping -h0.0.0.0 --silent; do sleep 1; done' /* Run some tests which require MySQL */ sh 'make check' } }这个例子可以更进一步,同时使用两个容器。一个sidecar运行MySQL,另一个通过使用Docker容器链接提供 执行环境node { checkout scm docker.image('mysql:5').withRun('-e "MYSQL_ROOT_PASSWORD=my-secret-pw"') { c -> docker.image('mysql:5').inside("--link ${c.id}:db") { /* Wait until mysql service is up */ sh 'while ! mysqladmin ping -hdb --silent; do sleep 1; done' } docker.image('centos:7').inside("--link ${c.id}:db") { /* * Run some tests which require MySQL, and assume that it is * available on the host name db */ sh 'make check' } } }上面的示例使用withRun暴露的对象,该对象通过id属性提供运行容器的ID。使用容器的ID,Pipeline 可以通过向inside()方法传递自定义Docker参数来创建链接。id属性还可用于在管道退出之前检查正在运行的Docker容器中的日志:sh "docker logs ${c.id}"注意:withRun块内的shell步骤不是在容器内运行的,但它们可以使用本地TCP端口连接到容器构建容器为了创建Docker镜像,Docker Pipeline插件还提供了一个build()方法,用于在PIpeline运行期间根据源码库中的Dockerfile创建新镜像。使用docker.build("my-image-name")语法的一个主要好处是脚本化Pipeline可以在后续Docker Pipeline调用中使用返回值,例如:node { checkout scm def customImage = docker.build("my-image:${env.BUILD_ID}") customImage.inside { sh 'make test' } }返回值还可用于通过push()方法将Docker镜像发布到Docker Hub或自定义注册中心, 例如:node { checkout scm def customImage = docker.build("my-image:${env.BUILD_ID}") customImage.push() }镜像“tags”的一个常见用法是为最近有效的Docker镜像版本指定一个latest标签。push()方法接收一个可选的tag参数,允许Pipeline推送携带不同标签的自定义镜像,例如:node { checkout scm def customImage = docker.build("my-image:${env.BUILD_ID}") customImage.push() customImage.push('latest') }默认情况下,build()方法根据当前目录中的 Dockerfile进行构建 。可以通过提供包含Dockerfile的目录路径作为 build()方法的第二个参数来覆盖这一点,例如:node { checkout scm def testImage = docker.build("test-image", "./dockerfiles/test") // 从./dockerfiles/test/Dockerfile构建test-image testImage.inside { sh 'make test' } }可以通过将其他参数添加到 build()方法的第二个参数并将其传递给docker构建。但是需要注意的是,以这种方式传递参数时,字符串中的最后一个值必须是Dockerfile的路径,并且该路径必须以用作构建上下文的文件夹结尾。下例通过传递-f参参数覆盖默认的Dockerfilenode { checkout scm def dockerfile = 'Dockerfile.test' def customImage = docker.build("my-image:${env.BUILD_ID}", "-f ${dockerfile} ./dockerfiles") // 根据./dockerfiles/Dockerfile.test构建 my-image:${env.BUILD_ID} }使用远程Docker服务默认情况下,Docker Pipeline插件会与本地Docker守护进程通信,通常通过/var/run/Docker访问。如果要选择非默认Docker服务器,例如使用 Docker Swarm,应使用withServer()方法。通过将URI和在Jenkins中预先配置的Docker服务器证书身份验证的凭据ID(可选)传递给方法:node { checkout scm docker.withServer('tcp://swarm.example.com:2376', 'swarm-certs') { docker.image('mysql:5').withRun('-p 3306:3306') { /* do things */ } } }注意:inside()和build()无法直接与Docker Swarm服务器一起正常工作为了让inside()工作,Docker服务器和Jenkins代理必须使用相同的文件系统,这样才能装载工作空间。目前,Jenkins插件和Docker CLI都不会自动检测远程运行的服务器的文件系统;典型的症状是嵌套的sh命令出错,例如cannot create /…@tmp/durable-…/pid: Directory nonexistent当Jenkins检测到代理本身正在Docker容器中运行时,它会自动将--volumes from参数传递给inside容器,确保它可以与代理共享一个工作空间。此外,Docker Swarm的一些版本不支持自定义注册中心。使用自定义注册中心默认情况下,Docker Pipeline假定了Docker Hub的默认Docker注册中心。为了使用自定义Docker注册中心,脚本化Pipeline的用户可以使用withRegistry()方法包装步骤,传递自定义注册中心 URL,例如:node { checkout scm docker.withRegistry('https://registry.example.com') { docker.image('my-custom-image').inside { sh 'make test' } } }对于需要身份验证的Docker注册中心,从Jenkins主页添加“用户名/密码”凭据项,并将凭据ID用作withRegistry()的第二个参数node { checkout scm docker.withRegistry('https://registry.example.com', 'credentials-id') { def customImage = docker.build("my-image:${env.BUILD_ID}") /* Push the container to the custom Registry */ customImage.push() } }在容器内运行构建步骤Jenkins项目通常要求在构建过程中提供特定的工具集或库。如果Jenkins中的许多项目都有相同的要求,并且代理很少,那么相应地预先配置这些代理并不困难。其他情况下,也可以将此类文件保存在项目源代码控制中。最后,对于一些工具,尤其是那些具有独立于平台的自包含下载的工具,比如Maven,可以使用Jenkins工具安装程序系统和Pipeline tool步骤来按需检索工具。然而,在许多情况下,这些技术不适用。对于可以在Linux上运行的构建,Docker为这个问题提供了一个理想的解决方案。每个项目只需要选择一个包含它所需的所有工具和库的镜像(这可能是像maven这样的公开镜像,也可能是由这个或另一个Jenkins项目创建的)有两种方法可以在镜像中运行Jenkins构建步骤。一种需要在镜像中包含它所需的所有工具、运行环境,然后在镜像中运行整个构建,另一种借助插件inside()方法,实现在任意镜像中运行构建,和前者的区别在于后者可以不用提前在镜像中包含所需要工具、运行环境,在运行时提供即可。,例如:1docker.image('maven:3.3.3-jdk-8').inside { git '…your-sources…' sh 'mvn -B clean install' }以上是一个完整的Pipeline脚本,inside将:自动获取代理和工作区(不需要额外的node块)将请求的镜像拉取到Docker服务器(如果尚未缓存的话)启动一个运行该镜像的容器使用相同的文件路径,将Jenkins工作区作为“volume”装入容器中。运行构建步骤。像sh这样的外部进程将被包装在docker exec中,以便在容器中运行。其他步骤(如测试报告)未经修改即可运行:它们仍然可以访问由构建步骤创建的工作区文件。运行完上述代码块结束时,停止容器并释放其消耗的所有存储。Record the fact that the build used the specified image。这将解锁其他Jenkins插件中的功能:您可以使用镜像跟踪所有项目,或者将此项目配置为在更新的镜像推送到Docker注册表时自动触发。如果您使用Docker Traceability plugin插件,还可以查看Docker服务器上镜像的历史记录。注意:如果你正在运行一个像Maven这样有一个大的下载缓存的工具,在其镜像中运行每次构建将意味着从网络下载大量数据,这通常是不可取的。避免这种情况的最简单方法是将缓存重定向到代理工作区,这样,如果在同一个代理上运行另一个构建,它将运行得更快。就Maven而言:docker.image('maven:3.3.3-jdk-8').inside { git '…your-sources…' writeFile file: 'settings.xml', text: "<settings><localRepository>${pwd()}/.m2repo</localRepository></settings>" sh 'mvn -B -s settings.xml clean install' }(如果希望在代理上的其他位置使用缓存位置,则需要传递一个额外的--volume选项给inside,以便容器可以看到该路径)其它解决方案是传递一个参数给inside以加载共享卷,比如 -v m2repo:/m2repo,并使用该路径作为 localRepository。要注意的是,Maven中默认的本地存储库管理对于并发构建来说并不是线程安全的,nstall:install 安装可能会跨构建甚至跨Job污染本地存储库。最安全的解决方案是使用仓库镜像作为缓存。
Sonar扫描之分析参数介绍强制参数服务器Key描述默认sonar.host.url服务器网址http://localhost:9000项目配置Key描述默认sonar.projectKey项目的唯一标识。允许的字符是:字母,数字,-,_,.和:,与至少一个非数字字符。对于 Maven 项目,这默认为 <groupId>:<artifactId>可选参数项目标识Key描述默认sonar.projectName将显示在 Web 界面上的项目名称。针对 Maven 项目,默认为<name>,否则为projectKey。如果未提供且数据库中已有名称,则不会被覆盖sonar.projectVersion项目版本。针对 Maven 项目,默认为<version> ,否则“not provided”认证默认情况下,需要用户身份验证以防止匿名用户浏览和分析您实例上的项目,所以运行分析时需要传递这些参数。身份验证在全局安全 (/instance-administration/security/) 配置中强制执行。当需要身份验证或归属pseudo-group的“任何人”没有执行分析的权限时,需要提供具有执行分析权限的用户凭据,以便运行分析。Key描述默认sonar.login对项目具有执行分析权限的 SonarQube 用户的身份验证令牌或登录名。sonar.password如果您使用身份验证令牌,该配置项保持为空,如果您使用登录名,则这是与您的sonar.login用户名一起使用的密码。网页服务Key描述默认sonar.ws.timeout等待 Web 服务调用响应的最长时间(以秒为单位)。仅当在分析期间等待服务器响应 Web 服务调用超时时才需要修改该参数配置。60项目配置Key描述默认sonar.projectDescription项目描述。对于 Maven 项目,默认为<description>sonar.links.homepage项目主页。对于 Maven 项目,默认为<url>sonar.links.ci持续集成。对于 Maven 项目,默认为<ciManagement><url>sonar.links.issue问题跟踪器。对于 Maven 项目,默认为<issueManagement><url>sonar.links.scm项目源代码库。对于 Maven 项目,默认为<scm><url>sonar.sources包含主要源文件,由逗号分隔(如果有多个的话)的目录路径。从 Maven、Gradle、MSBuild 项目的构建系统读取。当既不提供sonar.sources也不sonar.tests提供时,默认为项目根目录。sonar.tests包含测试源文件,由逗号分隔(如果有多个的话)的目录路径。从 Maven、Gradle、MSBuild 项目的构建系统中读取。否则默认为空。sonar.sourceEncoding源文件的编码。例如:UTF-8, MacRoman, Shift_JIS。Maven 项目中,这个属性可以替换为project.build.sourceEncoding标准属性。可用编码列表取决于 JVM。系统编码sonar.externalIssuesReportPaths通用问题报告的逗号分隔路径列表。sonar.projectBaseDir当您需要在不同于启动目录的目录中进行分析时,请使用此属性。该路径可以是相对的或绝对的。注意,不是指定源代码目录,而是指定源代码目录的某个父目录。注意分析过程中需要这个目录的写权限;这是sonar.working.directory被创建的地方。sonar.working.directory为使用 SonarScanner 或 SonarScanner for Ant(大于 2.0 的版本)触发的分析设置工作目录。此属性与 MSBuild 的 SonarScanner 不兼容。路径必须是相对的,并且对于每个项目都是唯一的。注意:每次分析前都会删除该配置指定的目录。.scannerwork质量门钥匙描述默认sonar.qualitygate.wait强制分析步骤轮询 SonarQube 实例并等待 Quality Gate 状态。如果没有其他选项,当Quality Gate 失败时,可以使用该配置让管道构建失败。更多相关信息,请参阅CI 集成页面。sonar.qualitygate.timeout设置scanner应等待处理报告的秒数。300更多参数配置说明,请查阅参考链接。参考连接https://docs.sonarqube.org/latest/analysis/analysis-parameters/
Sonar扫描之SonarScanner介绍SonarScanner用于在构建系统没有指定scanner时使用。项目配置在你的项目根目录中创建一个名为 sonar-project.properties的配置文件# 在给定的SonarQube实例中必须保持唯一 sonar.projectKey=my:project # --- 可选属性 --- # 默认值为projectKey #sonar.projectName=My project # 默认值为'not provided' #sonar.projectVersion=1.0 # 默认为 . 路径相对于sonar-project.properties而言 #sonar.sources=. # 源代码文件编码. 默认为系统默认编码 #sonar.sourceEncoding=UTF-8从 zip 文件运行 SonarScanner要从 zip 文件运行 SonarScanner,遵循下列步骤操作:将下载的文件解压到你选择的目录,暂且假设该目录路径为:$install_directory通过编辑$install_directory/conf/sonar-scanner.properties,更新全局配置以指向 SonarQube 服务器:#----- 设置默认的SonarQube服务器 #sonar.host.url=http://localhost:9000将$install_directory/bin目录添加到PATH环境变量中。通过打开一个新的 shell 并执行命令sonar-scanner -h( Windows 上 sonar-scanner.bat -h)来验证安装是否正确,看到类似如下输出则表示成功:usage: sonar-scanner [options] Options: -D,--define <arg> Define property -h,--help Display help information -v,--version Display version information -X,--debug Produce execution debug output如果您需要查看更多的调试信息,您可以添加 -X,--verbose或-Dsonar.verbose=true命令选项在项目根目录下运行以下命令以启动分析并传递身份验证令牌:sonar-scanner -Dsonar.login=myAuthenticationToken从 Docker 镜像运行 SonarScanner使用以下命令,使用 SonarScanner Docker 镜像进行扫描:docker run \ --rm \ -e SONAR_HOST_URL="http://${SONARQUBE_URL}" \ -e SONAR_LOGIN="myAuthenticationToken" \ -v "${YOUR_REPO}:/usr/src" \ sonarsource/sonar-scanner-cli扫描 C、C++ 或 ObjectiveC 项目扫描包含 C、C++ 或 ObjectiveC 代码的项目需要一些额外的分析步骤。查看完整信息C/C++/Objective-C示例项目GitHub 上提供了适用于大多数语言的简单项目示例。点击浏览或下载示例项目。sonar-project.properties 的替代品如果在项目的根目录下无法创建 sonar-project.properties 文件,有以下几种选择:可以通过命令行直接指定属性。例子:sonar-scanner -Dsonar.projectKey=myproject -Dsonar.sources=src1属性 project.settings 可用于指定项目配置文件的路径(此选项与sonar.projectBaseDir属性不兼容)。例子:sonar-scanner -Dproject.settings=../myproject.properties从 SonarScanner 2.4 开始,可以通过sonar.projectBaseDir属性设置要分析项目的根文件夹。如果未在命令行中指定sonar.projectKey,则该文件夹必须包含sonar-project.properties文件。可以在此项目配置文件中或通过命令行参数定义其他参数。注意:命令行参数优先于sonar-project.properties配置,也就是说,当命令行和sonar-project.properties存在相同参数配置的情况下,以命令行的参数配置为准可选分析目录如果要分析的文件不在运行sonar-scanner程序时所在目录,那么需要使用sonar.projectBaseDir属性将分析移动到待分析文件所在目录,否则会导致分析失败,因为程序默认在当前目录下执行扫描。例如,在jenkins/jobs/myjob/workspace目录下运行sonar-scanner,但要分析的文件存在/home/ftpdrop/cobol/project1目录,sonar-project.properties配置如下:sonar.projectBaseDir=/home/ftpdrop/cobol/project1 sonar.sources=src高级Docker配置以下部分提供了使用Docker运行sonar-scanner时的高级配置选项使用--user选项,以非root用户运行Docker镜像,例如:docker run \ --rm \ --user="$(id -u):$(id -g)" \ -e SONAR_HOST_URL="http://${SONARQUBE_URL}" \ -v "${YOUR_REPO}:/usr/src" \ sonarsource/sonar-scanner-cli注意:以非root用户运行容器时,确保该用户对挂载的目录(比如代码目录或者scanner缓存目录)有读写权限,否则可能会遇到权限相关问题。为了防止SonarScanner在每次运行扫描时重新下载语言分析器,可以挂载一个供scanner存储下载内容的目录,以便在scanner运行期间可以重用下载的内容。在某些CI系统上,还需要将此目录添加到CI缓存配置中。以下命令将在运行之间存储和使用缓存:docker run \ --rm \ -v ${YOUR_CACHE_DIR}:/opt/sonar-scanner/.sonar/cache \ -v ${YOUR_REPO}:/usr/src \ -e SONAR_HOST_URL="http://${SONARQUBE_URL}" \ sonarsource/sonar-scanner-cli还可以使用SONAR_USER_HOME环境变量更scanner存储下载内容的位置。故障排除Java heap space error or java.lang.OutOfMemoryError通过SONAR_SCANNER_OPTS环境变量增加内存Linux:export SONAR_SCANNER_OPTS="-Xmx512m"Windows:set SONAR_SCANNER_OPTS=-Xmx512mUnsupported major.minor version升级用于分析的Java版本,或使用一个本机包(嵌入自己的Java运行时)Property missing: `sonar.cs.analyzer.projectOutPaths'. No protobuf files will be loaded for this project.Scanner CLI无法分析.NET项目。请用SonarScanner for .NET。参考连接https://docs.sonarqube.org/latest/analysis/scan/sonarscanner/
基于flock命令实现多进程并发读写文件控制需求描述实际项目中,需要在Linux下通过shell脚本并发读写同一个文件,但是希望同一时刻,只有一个进程可以在读、写目标文件。解决方案使用flock命令。flock命令介绍语法# flock --help 用法: flock [options] <file|directory> <command> [command args] flock [options] <file|directory> -c <command> flock [options] <file descriptor number> 常用选项: -s --shared 获取一个共享锁 -x --exclusive 获取一个排他锁(默认情况) -u --unlock 移除一个锁 -n --nonblock 非阻塞模式,当获取锁失败时,返回1而非等待。 -w --timeout <secs> 阻塞模式,当获取锁失失败时,等待secs秒,超时后退出。默认情况下,会一直等待直到获取锁 -E --conflict-exit-code <number> 冲突或者超时导致程序退出时的退出状态码 -o --close 运行命令前,关闭文件描述符,会自动释放锁。 -c --command <command> 通过shell运行command,命令运行完成,也会自动释放锁(如果已上锁的话)原理flock命令通过给某个文件、目录上锁来告诉其它进程自己的状态,也就是说基于文件锁实现程序控制。支持的文件锁有两种:共享锁(shared lock)当文件被上了共享锁之后,其他进程可以继续为此文件加共享锁,但不能添加排他锁。被上锁的文件会有一个共享锁计数,每添加一个共享锁,计数 +1,每解锁一个共享锁,计数 -1,只有当共享锁计数为0时,才可以为其添加排他锁。排他锁(exclusive lock )当文件被上了排他锁之后,在解锁之前,其它进程不能为该文件添加共享锁和排他锁具体实践新建test_file_lock.sh文件,内容如下#!/bin/bash echo "----------------------------------" echo "start at `date '+%Y-%m-%d %H:%M:%S'`" sleep 30s echo "finished at `date '+%Y-%m-%d %H:%M:%S'`"打开3个Linux终端,分别在其中两个终端的相同路径下,执行以下命令# flock -x LOCK-FILE -c "sh test_file_lock.sh >out.log"执行上述命令以后,马上在第三个终端的相同路径下,执行tail -f out.log查看输出,结果如下#tail -f out.log ---------------------------------- start at 2021-12-29 09:17:21 finished at 2021-12-29 09:17:51 tail: out.log: file truncated ---------------------------------- start at 2021-12-29 09:17:51 finished at 2021-12-29 09:18:21实践结果表明:锁文件(例中为LOCK-FILE)如果不存在,会自动创建;基于flock在第2个终端上执行的shell命令,在第一个终端上执行的shell命令执行完成后才开始运行,验证了flock排它锁的有效性。
实践环境Apache Maven 3.0.5 (Red Hat 3.0.5-17)maven构建生命周期学习Maven构建命令之前,我们不烦先简单了解下Maven构建生命周期。Maven基于构建生命周期的核心概念。构建生命周期由phase(形如clean,compile, install等)组成。每个phase由插件目标Plugin goal(形如sonar:sonar)组成。也就是说,每个phase负责构建生命周期中的特定步骤,并且通过绑定到该phase的的插件来实现这些步骤的具体执行。每个插件目标代表一个特定的任务(比phase更精细),可能绑定到0个或多个构建phase。未绑定到任何phase的目标可以通过直接调用在构建生命周期之外执行。执行顺序取决于插件目标和phase的顺序默认的生命周期由以下phase组成( 点击查看完整的phase列表)validate - 校验项目是否是正确,并且是否可获取所有必要信息compiletestpackageverify - 对集成测试结果进行检查以确保满足关键质量。installdeploymaven构建命令mvn [选项] [<goal(s)>] [<phase(s)>]常用选项: -f,--file <arg> 强制使用指定的POM文件-U,--update-snapshots 强制检查缺少的release版和远程仓库已更新snapshots版(Forces a check for missing releases and updated snapshots on remote repositories)。个人理解:如果构建依赖的release版软件包在本地仓库不存在,则强制从远程仓库下载最新release版依赖包,否则不下载,使用本地仓库已有的release版依赖包不管构建依赖的snapshots版软件包在本地仓库是否存在,都强制检查远程仓库对应版本的软件包是否存在更新,如果存在则下载更新。-N,--non-recursive 不递归到子项目(子模块)。说明:多个goal、phase之间使用空格分隔。示例:# mvn clean -Dautoconfig.skip=true -Dmaven.test.skip=true install常用内置phase介绍clean 删除前一次构建生成的文件,包括classes目录中的.class文件,但不会删除classes, generated-sources, maven-status目录。compile 编译项目源代码,会生成.class文件和对应软件包,注意:*.class以及软件包(比如*.jar)不存在,或者源代码有变动的情况下,执行编译,才会重新生成*.class及对应软件包,package,install,deploy等皆如此。test 使用合适的单元测试框架(默认为Junit)运行测试。这些测试不应要求打包或部署代码。可使用-Dmaven.test.skip=true、-DskipTests参数跳过测试。这两者的区别在于:-DskipTests 不执行测试用例,但编译测试用例类生成相应的.class文件到target/test-classes下。-Dmaven.test.skip=true,不执行测试用例,也不编译测试用例类。package 获取编译后的代码,并将其打包为可分发的格式,例如jarinstall 将打包的软件包安装至本地仓库,为本地其它项目提供依赖。实践表名,执行install命令,可能会生成在compile阶段未生成的软件包。deploy 在集成或发布环境中完成,将最终软件包复制到远程存仓库,以便与其他开发人员和项目共享。注意:1、phase之间,phase和goal之间是有顺序区分的,按从左到右的顺序执行,如下两个命令,看似相同,执行效果是不一样的。# mvn clean install # 先执行clean,再执行install # mvn install clean # 先执行install,再执行clean2、maven执行某个phase之前,会优按顺序执行该phase所属生命周期内,位于其之前的所有phase,比如执行默认生命周期的install,会优先执行validate —> compile -> test -> package -> verify(假设未使用其它会跳过phase的选项参数)插件目标应用举例sonar扫描# mvn clean -Dautoconfig.skip=true -Dmaven.test.skip=true compile org.jacoco:jacoco-maven-plugin:prepare-agent sonar:sonar问题:这里为啥需要用org.jacoco:jacoco-maven-plugin:prepare-agent插件目标呢?答案:因为仅靠SonarQube本身是不知道实际上执行了哪些测试以及它们如何覆盖代码的,要获取此信息,它依赖于第三方测试覆盖率工具,对于Java,它依赖于JaCoCo收集和提供的数据关于父POM构建假设项目中包含子项目、模块,那么构建父POM时,会按序构建所有子项目、子模块,可以简单理解为批量构建。参考连接https://maven.apache.org/guides/introduction/introduction-to-the-lifecycle.htmlhttps://docs.sonarqube.org/latest/analysis/scan/sonarscanner-for-maven/https://maven.apache.org/run.html
使用Python推送指标数据到Pushgateway需求描述实践环境Python 3.6.5Django 3.0.6prometheus-client 0.11.0代码实现!/usr/bin/env python -*- coding:utf-8 -*- from prometheus_client import CollectorRegistry, Gauge, push_to_gateway if __name__ == '__main__': registry = CollectorRegistry() labels = ['req_status', 'req_method', 'req_url'] g_one = Gauge('requests_total', 'url请求次数', labels, registry=registry) g_two = Gauge('avg_response_time_seconds', '1分钟内的URL平均响应时间', labels, registry=registry) g_one.labels('200','GET', '/test/url').set(1) #set设定值 g_two.labels('200','GET', '/test/api/url/').set(10) #set设定值 push_to_gateway('http://162.13.0.83:9091', job='SampleURLMetrics', registry=registry)注意:采用这种方式是无法为指标数据提供数据生成时间戳的,具体下文说明查看运行结果浏览器访问推送网关地址http://162.13.0.83:9091,如下关于时间戳(timestamp)如果你在 t1 时刻推送Metric,你可能认为普罗米修斯会“刮取(scrap)”这些指标,并使用相同时间戳 t1 作为对应时序数据的时间戳,然而,普罗米修斯不会这样做,它会把从推送网关(Pushgateway)“刮取”数据时的时间戳当作指标数据对应的时间戳。为什么会这样?在普罗米修斯的世界观中,一个Metric可以在任何时候被刮取,一个无法被”刮取”的Metric基本上是不存在了。对此,普罗米修斯多少还是有点“容忍”的,但是如果它不能在 5 分钟内获得一个Metric的任何样本,那么它就会表现得好像该Metric不再存在一样。为了防止这种情况发生,实际上是使用Pushgateway的原因之一。Pushgateway将使你的临时job在任何时候都可以被刮取,也就是说任何时刻都可以采集到你推送的数据。将推送时间附加为时间戳将无法达到这一目的,因为在最后一次推送5分钟之后,普罗米修斯会认为你的Metric已经过时,就好像它再也不能被“刮取”一样。(普罗米修斯只能识别每个样本的一个时间戳,无法区分“推送时间”和“刮取时间”。)由于没有任何让附加不同的时间戳有意义的场景,并且许多用户试图错误地这样做(尽管没有客户端库支持),Pushgateway拒绝任何带有时间戳的推送。为了更容易对失败的推送器或最近未运行的Pusher发出警报,Pushgateway将在push_time_seconds和push_failure_time_seconds Metric中给每个组添加最后一次成功和失败的POST、PUT的Unix时间戳。这将覆盖使用该名称推送的任何Metric。两个Metric的值均为零表示该组从未见过成功或失败的POST、PUT。
$remote_addr代表客户端IP。注意,这里的客户端指的是直接请求Nginx的客户端,非间接请求的客户端。假设用户请求过程如下:用户客户端--发送请求->Nginx1 --转发请求-->Nginx2->后端服务器那么,默认情况下,针对Nginx1而言,$remote_addr为用户客户端IP,对Nginx2而言,$remote_addr则为Nginx1的IP。此时如果希望Nginx2也可以获取用户客户端IP,那要怎么处理呢?答案如下:在Nginx1配置文件中使用proxy_set_header为转发请求设置请求头proxy_set_header X-Real-IP $remote_addr; // X-Real-IP 为请求头名称,可自定义然后,在Nginx2 配置文件中通过$http_x_real_ip来获取X-Real-IP请求头的值来获取真实客户端IP.此时,如果要求“后端服务器”也要获取用户客户端IP,咋处理呢?做法和上述类似,在Nginx2配置文件中,使用proxy_set_header做同样的配置,即:proxy_set_header X-Real-IP $remote_addr;然后,“后端服务器”获取请求头“X-Real-IP”的值即为用户客户端IP。很多HTTP代理会在HTTP协议头中添加X-Forwarded-For头,用来追踪请求的来源。X-Forwarded-For的格式如下:X-Forwarded-For包含多个IP地址,每个值通过逗号+空格分开,最左边(client1)是最原始客户端的IP地址,中间如果有多层代理,每一层代理会将连接它的客户端IP追加在X-Forwarded-For右边。$proxy_add_x_forwarded_for代表附加$remote_addr变量的客户端请求头X-Forwarded-For ,其值如果包含多个地址,用逗号+空格分隔,标准格式如下:X-Forwarded-For: clientIP, proxyIP1, proxyIP2 # 最左边的clientIp即为客户端真实IP如果X-Forwarded-For字段没出现在客户端请求头,$proxy_add_x_forwarded_for 等同于$remote_addr 变量。这里,假设用户请求过程和上文所述一样,如下:用户客户端--发送请求->Nginx1 --转发请求-->Nginx2->后端服务器假设,仅在Nginx1配置文件中进行了以下配置proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;那么,Nginx2配置的X-Forwarded-For请求头的值即为clientIP,当然,这个结论的前提是,客户端IP没有配置X-Forwarded-For请求头,因为如上所述,客户端没有出现这个请求头时,$proxy_add_x_forwarded_for 的值即为$remote_addr 变量的值,否则,则是客户端为X-forwarded-for请求头的实际值。接着,假设Nginx2配置文件也进行以下配置proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;那么,“后端服务器”获取的X-Forwarded-For请求头的值将为clientIP, Nginx1IP。也就是说以上命令的执行是个叠加的过程(类似Python的列表的append方法的处理过程),可以简单理解为如果存在上级代理,执行以上命令时,会把上级代理IP追加到X-Forwarded-For请求头总,否则把客户端IP $remote_addr、或者客户端X-Forwarded-For请求头的值(如果有的话)追加到X-Forwarded-For请求头中。参考连接:https://nginx.org/en/docs/http/ngx_http_core_module.html#var_remote_addrhttps://nginx.org/en/docs/http/ngx_http_proxy_module.html#var_proxy_add_x_forwarded_for
实践环境CentOS-7-x86_64-DVD-1810Docker 19.03.9Kubernetes version: v1.20.5开始之前1台Linux操作或更多,兼容运行deb,rpm确保每台机器2G内存或以上确保当控制面板的结点机,其CPU核数为双核或以上确保集群中的所有机器网络互连目标安装一个Kubernetes集群控制面板基于集群安装一个Pod network以便集群之间可以相互通信安装指导安装Docker安装过程略注意,安装docker时,需要指Kubenetes支持的版本(参见如下),如果安装的docker版本过高导致,会提示以下问题WARNING SystemVerification]: this Docker version is not on the list of validated versions: 20.10.5. Latest validated version: 19.03安装docker时指定版本sudo yum install docker-ce-19.03.9 docker-ce-cli-19.03.9 containerd.io如果没有安装docker,运行kubeadm init时会提示以下问题cannot automatically set CgroupDriver when starting the Kubelet: cannot execute 'docker info -f {{.CgroupDriver}}': executable file not found in $PATH [preflight] WARNING: Couldn't create the interface used for talking to the container runtime: docker is required for container runtime: exec: "docker": executable file not found in $PATH安装kubeadm如果没有安装的话,先安装kubeadm,如果已安装,可通过apt-get update && apt-get upgrade或yum update命令更新kubeadm最新版注意:更新kubeadm过程中,kubelet每隔几秒中就会重启,这个是正常现象。其它前置操作关闭防火墙# systemctl stop firewalld && systemctl disable firewalld运行上述命令停止并禁用防火墙,否则运行kubeadm init时会提示以下问题[WARNING Firewalld]: firewalld is active, please ensure ports [6443 10250] are open or your cluster may not function correctly修改/etc/docker/daemon.json文件编辑/etc/docker/daemon.json文件,添加以下内容{ "exec-opts":["native.cgroupdriver=systemd"] }然后执行systemctl restart docker命令重启docker如果不执行以上操作,运行kubeadm init时会提示以下问题[WARNING IsDockerSystemdCheck]: detected "cgroupfs" as the Docker cgroup driver. The recommended driver is "systemd". Please follow the guide at https://kubernetes.io/docs/setup/cri/安装socat,conntrack等依赖软件包# yum install socat conntrack-tools如果按未安装上述依赖包,运行kubeadm init时会提示以下问题[WARNING FileExisting-socat]: socat not found in system path error execution phase preflight: [preflight] Some fatal errors occurred:` [ERROR FileExisting-conntrack]: conntrack not found in system path`设置net.ipv4.ip_forward值为1设置net.ipv4.ip_forward值为1,具体如下# sysctl -w net.ipv4.ip_forward=1 net.ipv4.ip_forward = 1说明:net.ipv4.ip_forward如果为0,则表示禁止转发数据包,为1则表示允许转发数据包,如果net.ipv4.ip_forward值不为1,运行kubeadm init时会提示以下问题ERROR FileContent--proc-sys-net-ipv4-ip_forward]: /proc/sys/net/ipv4/ip_forward contents are not set to 1以上配置临时生效,为了避免重启机器后失效,进行如下设置# echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf注意:网上有推荐以下方式进行永久配置的,但是笔者试过,实际不起作用# echo "sysctl -w net.ipv4.ip_forward=1" >> /etc/rc.local # chmod +x /etc/rc.d/rc.local设置net.bridge.bridge-nf-call-iptables值为1做法参考 net.ipv4.ip_forward设置注意:上文操作,在每个集群结点都要实施一次初始化控制面板结点控制面板组件运行的机器,称之为控制面板结点,包括 etcd (集群数据库) 和 API Server (供 kubectl 命令行工具调用)(推荐)如果打算升级单个控制面板kubeadm集群为高可用版(high availability),应该为kubeadm init指定--control-plane-endpoint参数选项以便为所有控制面板结点设置共享endpont。该endpont可以是DNS名称或者本地负载均衡IP地址。选择一个网络插件,并确认该插件是否需要传递参数给 kubeadm init,这取决于你所选插件,比如使用flannel,就必须为kubeadm init指定--pod-network-cidr参数选项(可选)1.14版本开始, kubeadm会自动检测容器运行时,如果需要使用不同的容器运行时,或者有多于1个容器运行时的情况下,需要为kubeadm init指定--cri-socket参数选项(可选)除非指定了其它的,kubeadm使用与默认网关关联的网络接口为指定控制面板结点API服务器设置advertise地址。如果需要指定其它的网络接口,需要为kubeadm init指定apiserver-advertise-address=<ip-address>参数选项。发布IPV6 Kubernetes集群,需要为kubeadm init指定--apiserver-advertise-address参数选项,以设置IPv6地址,形如 --apiserver-advertise-address=fd00::101(可选)运行kubeadm init之前,先运行kubeadm config images pull,以确认可连接到gcr.io容器镜像注册中心如下,带参数运行kubeadm init以便初始化控制面板结点机,运行该命令时会先执行一系列的预检,以确保机器满足运行kubernetes。如果预检发现错误,则自动退出程序,否则继续执行,下载并安装集群控制面板组件。这可能会花费几分钟# kubeadm init --image-repository=registry.aliyuncs.com/google_containers --kubernetes-version stable --pod-network-cidr=10.244.0.0/16 [init] Using Kubernetes version: v1.20.5 [preflight] Running pre-flight checks [preflight] Pulling images required for setting up a Kubernetes cluster [preflight] This might take a minute or two, depending on the speed of your internet connection [preflight] You can also perform this action in beforehand using 'kubeadm config images pull' [certs] Using certificateDir folder "/etc/kubernetes/pki" [certs] Generating "ca" certificate and key [certs] Generating "apiserver" certificate and key [certs] apiserver serving cert is signed for DNS names [kubernetes kubernetes.default kubernetes.default.svc kubernetes.default.svc.cluster.local localhost.localdomain] and IPs [10.96.0.1 10.118.80.93] [certs] Generating "apiserver-kubelet-client" certificate and key [certs] Generating "front-proxy-ca" certificate and key [certs] Generating "front-proxy-client" certificate and key [certs] Generating "etcd/ca" certificate and key [certs] Generating "etcd/server" certificate and key [certs] etcd/server serving cert is signed for DNS names [localhost localhost.localdomain] and IPs [10.118.80.93 127.0.0.1 ::1] [certs] Generating "etcd/peer" certificate and key [certs] etcd/peer serving cert is signed for DNS names [localhost localhost.localdomain] and IPs [10.118.80.93 127.0.0.1 ::1] [certs] Generating "etcd/healthcheck-client" certificate and key [certs] Generating "apiserver-etcd-client" certificate and key [certs] Generating "sa" key and public key [kubeconfig] Using kubeconfig folder "/etc/kubernetes" [kubeconfig] Writing "admin.conf" kubeconfig file [kubeconfig] Writing "kubelet.conf" kubeconfig file [kubeconfig] Writing "controller-manager.conf" kubeconfig file [kubeconfig] Writing "scheduler.conf" kubeconfig file [kubelet-start] Writing kubelet environment file with flags to file "/var/lib/kubelet/kubeadm-flags.env" [kubelet-start] Writing kubelet configuration to file "/var/lib/kubelet/config.yaml" [kubelet-start] Starting the kubelet [control-plane] Using manifest folder "/etc/kubernetes/manifests" [control-plane] Creating static Pod manifest for "kube-apiserver" [control-plane] Creating static Pod manifest for "kube-controller-manager" [control-plane] Creating static Pod manifest for "kube-scheduler" [etcd] Creating static Pod manifest for local etcd in "/etc/kubernetes/manifests" [wait-control-plane] Waiting for the kubelet to boot up the control plane as static Pods from directory "/etc/kubernetes/manifests". This can take up to 4m0s [kubelet-check] Initial timeout of 40s passed. [apiclient] All control plane components are healthy after 89.062309 seconds [upload-config] Storing the configuration used in ConfigMap "kubeadm-config" in the "kube-system" Namespace [kubelet] Creating a ConfigMap "kubelet-config-1.20" in namespace kube-system with the configuration for the kubelets in the cluster [upload-certs] Skipping phase. Please see --upload-certs [mark-control-plane] Marking the node localhost.localdomain as control-plane by adding the labels "node-role.kubernetes.io/master=''" and "node-role.kubernetes.io/control-plane='' (deprecated)" [mark-control-plane] Marking the node localhost.localdomain as control-plane by adding the taints [node-role.kubernetes.io/master:NoSchedule] [bootstrap-token] Using token: 1sh85v.surdstc5dbrmp1s2 [bootstrap-token] Configuring bootstrap tokens, cluster-info ConfigMap, RBAC Roles [bootstrap-token] configured RBAC rules to allow Node Bootstrap tokens to get nodes [bootstrap-token] configured RBAC rules to allow Node Bootstrap tokens to post CSRs in order for nodes to get long term certificate credentials [bootstrap-token] configured RBAC rules to allow the csrapprover controller automatically approve CSRs from a Node Bootstrap Token [bootstrap-token] configured RBAC rules to allow certificate rotation for all node client certificates in the cluster [bootstrap-token] Creating the "cluster-info" ConfigMap in the "kube-public" namespace [kubelet-finalize] Updating "/etc/kubernetes/kubelet.conf" to point to a rotatable kubelet client certificate and key [addons] Applied essential addon: CoreDNS [addons] Applied essential addon: kube-proxy Your Kubernetes control-plane has initialized successfully! To start using your cluster, you need to run the following as a regular user: mkdir -p $HOME/.kube sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config sudo chown $(id -u):$(id -g) $HOME/.kube/config Alternatively, if you are the root user, you can run: export KUBECONFIG=/etc/kubernetes/admin.conf You should now deploy a pod network to the cluster. Run "kubectl apply -f [podnetwork].yaml" with one of the options listed at: https://kubernetes.io/docs/concepts/cluster-administration/addons/ Then you can join any number of worker nodes by running the following on each as root: kubeadm join 10.118.80.93:6443 --token ap4vvq.8xxcc0uea7dxbjlo \ --discovery-token-ca-cert-hash sha256:c4493c04d789463ecd25c97453611a9dfacb36f4d14d5067464832b9e9c5039a如上,命令输出Your Kubernetes control-plane has initialized successfully!及其它提示,告诉我们初始化控制面板结点成功。注意:如果不使用--image-repository选项指定阿里云镜像,可能会报类似如下错误failed to pull image "k8s.gcr.io/kube-apiserver:v1.20.5": output: Error response from daemon: Get https://k8s.gcr.io/v2/: net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers) , error: exit status 1因为使用flannel网络插件,必须指定--pod-network-cidr配置选项,否则名为 coredns-xxxxxxxxxx-xxxxx的Pod无法启动,一直处于ContainerCreating状态,查看详细信息,可见类似如下错误信息networkPlugin cni failed to set up pod "coredns-7f89b7bc75-9vrrl_kube-system" network: open /run/flannel/subnet.env: no such file or directory--pod-network-cidr选项参数,即Pod网络不能和宿主主机网络相同,否则安装flannel插件后会导致路由重复,进而导致XShell等工具无法ssh宿主机,如下:实践宿主主机网络 10.118.80.0/24,网卡接口 ens33--pod-network-cidr=10.118.80.0/24另外,需要特别注意的是,``--pod-network-cidr的选项参数,必须和kube-flannel.yml文件中的net-conf.json.Network键值保持一致(本例中,键值如下所示,为10.244.0.0/16,所以运行kubeadm init命令时,--pod-network-cidr选项参数值设置为10.244.0.0/16`)# cat kube-flannel.yml|grep -E "^\s*\"Network" "Network": "10.244.0.0/16",初次实践时,设置--pod-network-cidr=10.1.15.0/24,未修改kube-flannel.yml中Network键值,新加入集群的结点,无法自动获取pod cidr,如下# kubectl get pods --all-namespaces NAMESPACE NAME READY STATUS RESTARTS AGE kube-system kube-flannel-ds-psts8 0/1 CrashLoopBackOff 62 15h ...略 # kubectl -n kube-system logs kube-flannel-ds-psts8 ...略 E0325 01:03:08.190986 1 main.go:292] Error registering network: failed to acquire lease: node "k8snode1" pod cidr not assigned W0325 01:03:08.192875 1 reflector.go:424] github.com/coreos/flannel/subnet/kube/kube.go:300: watch of *v1.Node ended with: an error on the server ("unable to decode an event from the watch stream: context canceled") has prevented the request from succeeding I0325 01:03:08.193782 1 main.go:371] Stopping shutdownHandler...后面尝试修改kube-flannel.yml中``net-conf.json.Network键值为10.1.15.0/24还是一样的提示(先下载kube-flannel.yml`,然后进行配置修改,再安装网络插件)针对上述 node "xxxxxx" pod cidr not assigned的问题,网上也有临时解决方案(笔者未验证),即为结点手动分配podCIDR,命令如下:kubectl patch node <NODE_NAME> -p '{"spec":{"podCIDR":"<SUBNET>"}}'参照输出提示,为了让非root用户也可以正常执行kubectl,运行以下命令# mkdir -p $HOME/.kube # sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config # sudo chown $(id -u):$(id -g) $HOME/.kube/config可选的,如果是root用户,可运行以下命令export KUBECONFIG=/etc/kubernetes/admin.conf记录kubeadm init输出中的kubeadm join,后面需要用该命令添加结点到集群中token用于控制面板结点和加入集群的结点之间的相互认证。需要安全保存,因为任何拥有该token的人都可以添加认证结点到集群中。 可用 kubeadm token展示,创建和删除该token。命令详情参考kubeadm reference guide.安装Pod网络插件**必须基于Pod网络发布一个 Container Network Interface (CNI) ,以便Pod之间可相互通信。Pod网络安装之前,不会启动Cluster DNS (CoreDNS) **注意Pod 网络不能和主机网络重叠,如果重叠,会出问题(如果发现网络发现网络插件的首选Pod网络与某些主机网络之间发生冲突,则应考虑使用合适的CIDR块,然后在执行kubeadm init时,增加--pod-network-cidr选项替换网络插件YAML中的网络配置.默认的, kubeadm 设置集群强制使用 RBAC (基于角色访问控制)。确保Pod网络插件及用其发布的任何清单支持RBAC如果让集群使用IPv6--dual-stack,或者仅single-stack IPv6 网络,确保往插件支持IPv6. CNI v0.6.0中添加了IPv6的支持。好些项目使用CNI提供提供Kubernetes网络支持,其中一些也支持网络策略,以下是实现了Kubernetes网络模型的插件列表查看地址:https://kubernetes.io/docs/concepts/cluster-administration/networking/#how-to-implement-the-kubernetes-networking-model可在控制面板结点机上或者拥有kubeconfig 凭据的结点机上通过执行下述命令安装一个Pod网络插件,该插件直接以daemonset的方式安装,并且会把配置文件写入/etc/cni/net.d目录:kubectl apply -f <add-on.yaml>flannel网络插件安装手动发布flannel(Kubernetes v1.17+)# kubectl apply -f https://raw.githubusercontent.com/coreos/flannel/master/Documentation/kube-flannel.yml podsecuritypolicy.policy/psp.flannel.unprivileged created clusterrole.rbac.authorization.k8s.io/flannel created clusterrolebinding.rbac.authorization.k8s.io/flannel created serviceaccount/flannel created configmap/kube-flannel-cfg created daemonset.apps/kube-flannel-ds created参考连接:https://github.com/flannel-io/flannel#flannel每个集群只能安装一个Pod网络,Pod网络安装完成后,可通过执行kubectl get pods --all-namespaces命令,查看命令输出中coredns-xxxxxxxxxx-xxx Pod是否处于Running来判断网络是否正常查看flannel子网环境配置信息# cat /run/flannel/subnet.env FLANNEL_NETWORK=10.244.0.0/16 FLANNEL_SUBNET=10.244.0.1/24 FLANNEL_MTU=1450 FLANNEL_IPMASQ=trueflannel网络插件安装完成后,宿主机上会自动增加两个虚拟网卡:cni0 和 flannel.1# ifconfig -a cni0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1450 inet 10.244.0.1 netmask 255.255.255.0 broadcast 10.244.0.255 inet6 fe80::705d:43ff:fed6:80c9 prefixlen 64 scopeid 0x20<link> ether 72:5d:43:d6:80:c9 txqueuelen 1000 (Ethernet) RX packets 312325 bytes 37811297 (36.0 MiB) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 356346 bytes 206539626 (196.9 MiB) TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0 docker0: flags=4099<UP,BROADCAST,MULTICAST> mtu 1500 inet 172.17.0.1 netmask 255.255.0.0 broadcast 172.17.255.255 inet6 fe80::42:e1ff:fec3:8b6a prefixlen 64 scopeid 0x20<link> ether 02:42:e1:c3:8b:6a txqueuelen 0 (Ethernet) RX packets 0 bytes 0 (0.0 B) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 3 bytes 266 (266.0 B) TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0 ens33: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500 inet 10.118.80.93 netmask 255.255.255.0 broadcast 10.118.80.255 inet6 fe80::6ff9:dbee:6b27:1315 prefixlen 64 scopeid 0x20<link> ether 00:0c:29:d3:3b:ef txqueuelen 1000 (Ethernet) RX packets 2092903 bytes 1103282695 (1.0 GiB) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 969483 bytes 253273828 (241.5 MiB) TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0 flannel.1: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1450 inet 10.244.0.0 netmask 255.255.255.255 broadcast 10.244.0.0 inet6 fe80::a49a:2ff:fe38:3e4b prefixlen 64 scopeid 0x20<link> ether a6:9a:02:38:3e:4b txqueuelen 0 (Ethernet) RX packets 0 bytes 0 (0.0 B) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 0 bytes 0 (0.0 B) TX errors 0 dropped 8 overruns 0 carrier 0 collisions 0 lo: flags=73<UP,LOOPBACK,RUNNING> mtu 65536 inet 127.0.0.1 netmask 255.0.0.0 inet6 ::1 prefixlen 128 scopeid 0x10<host> loop txqueuelen 1000 (Local Loopback) RX packets 30393748 bytes 5921348235 (5.5 GiB) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 30393748 bytes 5921348235 (5.5 GiB) TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0重新初始化控制面板结点实践过程中因选项配置不对,在网络插件安装后才发现需要,需要重新执行kubeadm init命令。具体实践操作如下:# kubeadm reset [reset] Reading configuration from the cluster... [reset] FYI: You can look at this config file with 'kubectl -n kube-system get cm kubeadm-config -o yaml' [reset] WARNING: Changes made to this host by 'kubeadm init' or 'kubeadm join' will be reverted. [reset] Are you sure you want to proceed? [y/N]: y [preflight] Running pre-flight checks [reset] Removing info for node "localhost.localdomain" from the ConfigMap "kubeadm-config" in the "kube-system" Namespace [reset] Stopping the kubelet service [reset] Unmounting mounted directories in "/var/lib/kubelet" [reset] Deleting contents of config directories: [/etc/kubernetes/manifests /etc/kubernetes/pki] [reset] Deleting files: [/etc/kubernetes/admin.conf /etc/kubernetes/kubelet.conf /etc/kubernetes/bootstrap-kubelet.conf /etc/kubernetes/controller-manager.conf /etc/kubernetes/scheduler.conf] [reset] Deleting contents of stateful directories: [/var/lib/etcd /var/lib/kubelet /var/lib/dockershim /var/run/kubernetes /var/lib/cni] The reset process does not clean CNI configuration. To do so, you must remove /etc/cni/net.d The reset process does not reset or clean up iptables rules or IPVS tables. If you wish to reset iptables, you must do so manually by using the "iptables" command. If your cluster was setup to utilize IPVS, run ipvsadm --clear (or similar) to reset your system's IPVS tables. The reset process does not clean your kubeconfig files and you must remove them manually. Please, check the contents of the $HOME/.kube/config file. # rm -rf /etc/cni/net.d # rm -f $HOME/.kube/config #执行完上述命令后,需要重新执行 初始化控制面板结点操作,并且重新安装网络插件遇到的问题总结重新执行kubeadm init命令后,执行kubectl get pods --all-namespaces查看Pod状态,发现coredns-xxxxxxxxxx-xxxxxx 状态为ContainerCreating,如下# kubectl get pods --all-namespaces NAMESPACE NAME READY STATUS RESTARTS AGE kube-system coredns-7f89b7bc75-pxvdx 0/1 ContainerCreating 0 8m33s kube-system coredns-7f89b7bc75-v4p57 0/1 ContainerCreating 0 8m33s kube-system etcd-localhost.localdomain 1/1 Running 0 8m49s ...略执行kubectl describe pod coredns-7f89b7bc75-pxvdx -n kube-system命令查看对应Pod详细信息,发现如下错误:Warning FailedCreatePodSandBox 98s (x4 over 103s) kubelet (combined from similar events): Failed to create pod sandbox: rpc error: code = Unknown desc = failed to set up sandbox container "04434c63cdf067e698a8a927ba18e5013d2a1a21afa642b3cddedd4ff4592178" network for pod "coredns-7f89b7bc75-pxvdx": networkPlugin cni failed to set up pod "coredns-7f89b7bc75-pxvdx_kube-system" network: failed to set bridge addr: "cni0" already has an IP address different from 10.1.15.1/24如下,查看网卡信息,发现 cni0已分配了IP地址(网络插件上次分配的),导致本次网络插件给它设置IP失败。# ifconfig -a cni0: flags=4099<UP,BROADCAST,MULTICAST> mtu 1500 inet 10.118.80.1 netmask 255.255.255.0 broadcast 10.118.80.255 inet6 fe80::482d:65ff:fea6:32fd prefixlen 64 scopeid 0x20<link> ether 4a:2d:65:a6:32:fd txqueuelen 1000 (Ethernet) RX packets 267800 bytes 16035849 (15.2 MiB) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 116238 bytes 10285959 (9.8 MiB) TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0 ...略 flannel.1: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1450 inet 10.1.15.0 netmask 255.255.255.255 broadcast 10.1.15.0 inet6 fe80::a49a:2ff:fe38:3e4b prefixlen 64 scopeid 0x20<link> ether a6:9a:02:38:3e:4b txqueuelen 0 (Ethernet) RX packets 0 bytes 0 (0.0 B) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 0 bytes 0 (0.0 B) TX errors 0 dropped 8 overruns 0 carrier 0 collisions 0 ...略解决方法如下,删除配置错误的cni0网卡,删除网卡后会自动重建,然后就好了$ sudo ifconfig cni0 down $ sudo ip link delete cni0控制面板结点Toleration(可选)默认的,出于安全考虑,集群不会在控制面板结点机上调度(schedule) Pod。如果希望在控制面板结点机上调度Pod,比如用于开发的单机Kubernetes集群,需要运行以下命令kubectl taint nodes --all node-role.kubernetes.io/master- # 移除所有Labels以node-role.kubernetes.io/master打头的结点的污点(Taints)实践如下# kubectl get nodes NAME STATUS ROLES AGE VERSION localhost.localdomain Ready control-plane,master 63m v1.20.5 # kubectl taint nodes --all node-role.kubernetes.io/master- node/localhost.localdomain untainted添加结点到集群修改新结点的hostname# hostname localhost.localdomain # hostname k8sNode1以上通过命令修改主机名仅临时生效,为了避免重启失效,需要编辑/etc/hostname文件,替换默认的localhost.localdomain为目标名称(例中为k8sNode),如果不添加,后续操作会遇到一下错误[WARNING Hostname]: hostname "k8sNode1" could not be reached [WARNING Hostname]: hostname "k8sNode1": lookup k8sNode1 on 223.5.5.5:53: read udp 10.118.80.94:33293->223.5.5.5:53: i/o timeout修改/ect/hosts配置,增加结点机hostname到结点机IP(例中为 10.118.80.94)的映射,如下# vi /etc/hosts 127.0.0.1 localhost localhost.localdomain localhost4 localhost4.localdomain4 ::1 localhost localhost.localdomain localhost6 localhost6.localdomain6 10.118.80.94 k8sNode1ssh登录目标结点机,切换至root用户(如果非root用户登录),然后运行控制面板机器上执行kubeadm init命令输出的kubeadm join命令,录入:kubeadm join --token <token> <control-plane-host>:<control-plane-port> --discovery-token-ca-cert-hash sha256:<hash>可在控制面板机上通过运行一下命令查看已有且未过期token# kubeadm token list如果没有token,可在控制面板机上通过以下命令重新生成token# kubeadm token create实践如下# kubeadm join 10.118.80.93:6443 --token ap4vvq.8xxcc0uea7dxbjlo --discovery-token-ca-cert-hash sha256:c4493c04d789463ecd25c97453611a9dfacb36f4d14d5067464832b9e9c5039a [preflight] Running pre-flight checks [preflight] Reading configuration from the cluster... [preflight] FYI: You can look at this config file with 'kubectl -n kube-system get cm kubeadm-config -o yaml' [kubelet-start] Writing kubelet configuration to file "/var/lib/kubelet/config.yaml" [kubelet-start] Writing kubelet environment file with flags to file "/var/lib/kubelet/kubeadm-flags.env" [kubelet-start] Starting the kubelet [kubelet-start] Waiting for the kubelet to perform the TLS Bootstrap... This node has joined the cluster: * Certificate signing request was sent to apiserver and a response was received. * The Kubelet was informed of the new secure connection details. Run 'kubectl get nodes' on the control-plane to see this node join the cluster.控制面板节点机即master机器上查看是否新增结点# kubectl get nodes NAME STATUS ROLES AGE VERSION k8snode1 NotReady <none> 74s v1.20.5 localhost.localdomain Ready control-plane,master 7h24m v1.20.5如上,新增了一个k8snode1结点遇到问题总结问题1:运行]kubeadm join时报错,如下# kubeadm join 10.118.80.93:6443 --token ap4vvq.8xxcc0uea7dxbjlo --discovery-token-ca-cert-hash sha256:c4493c04d789463ecd25c97453611a9dfacb36f4d14d5067464832b9e9c5039a [preflight] Running pre-flight checks error execution phase preflight: couldn't validate the identity of the API Server: could not find a JWS signature in the cluster-info ConfigMap for token ID "ap4vvq" To see the stack trace of this error execute with --v=5 or higher解决方法:token过期,运行kubeadm token create命令重新生成token问题1:运行]kubeadm join时报错,如下# kubeadm join 10.118.80.93:6443 --token pa0gxw.4vx2wud1e7e0rzbx --discovery-token-ca-cert-hash sha256:c4493c04d789463ecd25c97453611a9dfacb36f4d14d5067464832b9e9c5039a [preflight] Running pre-flight checks error execution phase preflight: couldn't validate the identity of the API Server: cluster CA found in cluster-info ConfigMap is invalid: none of the public keys "sha256:8e2f94e2f4f1b66c45d941c0a7f72e328c242346360751b5c1cf88f437ab854f" are pinned To see the stack trace of this error execute with --v=5 or higher解决方法:discovery-token-ca-cert-hash失效,运行以下命令,重新获取discovery-token-ca-cert-hash值# openssl x509 -pubkey -in /etc/kubernetes/pki/ca.crt | openssl rsa -pubin -outform der 2>/dev/null | openssl dgst -sha256 -hex | sed 's/^.* //' 8e2f94e2f4f1b66c45d941c0a7f72e328c242346360751b5c1cf88f437ab854f使用输出的hash值--discovery-token-ca-cert-hash sha256:8e2f94e2f4f1b66c45d941c0a7f72e328c242346360751b5c1cf88f437ab854f问题2: cni config uninitialized错误问题通过k8s自带UI查看新加入结点状态为KubeletNotReady,提示信息如下,[container runtime status check may not have completed yet, PLEG is not healthy: pleg has yet to be successful, runtime network not ready: NetworkReady=false reason:NetworkPluginNotReady message:docker: network plugin is not ready: cni config uninitialized, CSINode is not yet initialized, missing node capacity for resources: ephemeral-storage]解决方法: 重新安装CNI网络插件(实践时采用了虚拟机,可能是因为当时使用的快照没包含网络插件),然后重新清理结点,最后再重新加入结点# CNI_VERSION="v0.8.2" # mkdir -p /opt/cni/bin # curl -L "https://github.com/containernetworking/plugins/releases/download/${CNI_VERSION}/cni-plugins-linux-amd64-${CNI_VERSION}.tgz" | sudo tar -C /opt/cni/bin -xz清理如果在集群中使用一次性服务器进行测试,则可以直接关闭这些服务器,不需要进行进一步的清理。可以使用kubectl config delete cluster删除对集群的本地引用(笔者未试过)。但是,如果您想更干净地清理集群,则应该首先清空结点数据,确保节点数据被清空,然后再删除结点移除结点控制面板结点机上的操作先在控制面板结点机上运行以下命令,告诉控制面板结点机器强制删除待删除结点数据kubectl drain <node name> --delete-emptydir-data --force --ignore-daemonsets实践如下:# kubectl get nodes NAME STATUS ROLES AGE VERSION k8snode1 Ready <none> 82m v1.20.5 localhost.localdomain Ready control-plane,master 24h v1.20.5 # kubectl drain k8snode1 --delete-emptydir-data --force --ignore-daemonsets node/k8snode1 cordoned WARNING: ignoring DaemonSet-managed Pods: kube-system/kube-flannel-ds-4xqcc, kube-system/kube-proxy-c7qzs evicting pod default/nginx-deployment-64859b8dcc-v5tcl evicting pod default/nginx-deployment-64859b8dcc-qjrld evicting pod default/nginx-deployment-64859b8dcc-rcvc8 pod/nginx-deployment-64859b8dcc-rcvc8 evicted pod/nginx-deployment-64859b8dcc-qjrld evicted pod/nginx-deployment-64859b8dcc-v5tcl evicted node/k8snode1 evicted # kubectl get nodes NAME STATUS ROLES AGE VERSION localhost.localdomain Ready control-plane,master 24h v1.20.5目标结点机上的操作登录到目标结点机上,执行以下命令# kubeadm reset上述命令不会重置、清理iptables、IPVS表,如果需要重置iptables还需要手动运行以下命令:iptables -F && iptables -t nat -F && iptables -t mangle -F && iptables -X如果需要重置IPVS,必须运行以下命令。ipvsadm -C注意:如果无特殊需求,不要去重置网络删除结点配置文件# rm -rf /etc/cni/net.d # rm -f $HOME/.kube/config控制面板结点机上的操作通过执行命令删除结点kubectl delete node <node name>###删除未删除的pod # kubectl delete pod kube-flannel-ds-4xqcc -n kube-system --force # kubectl delete pod kube-proxy-c7qzs -n kube-system --force # kubectl delete node k8snode1 node "k8snode1" deleted删除后,如果需要重新加入结点,可通过 kubeadm join 携带适当参数运行加入清理控制面板可以在控制面板结点机上,使用kubeadm reset 命令。点击查看 kubeadm reset 命令参考参考连接https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/create-cluster-kubeadm/
实践环境Jenkins 2.304jdk-8u131-linux-x64.rpmcentos-release-7-9.2009.1.el7.centos.x86_64操作步骤安装JDK在预新建结点机上安装JDK,并配置好相关环境变量(过程略)新建结点如下,访问Dashboard >> 系统管理 >> 节点管理 >> 新建结点(New Node),打开页面中,正确填写 结点名称(例中把名称设置为IP地址),勾选”固定节点(Permanent Agent)“,点击 确定 按钮,提交配置结点配置好后,保存,启动结点即可。结点配置说明Number of executors执行器数量,即该节点支持的最大并发构建数。建议配置成和结点逻辑CPU数一样代理节点(非 master 节点)必须至少拥有一个执行器。如需暂时阻止其执行构建,请使用其页面右上方的临时断开此节点按钮。对于 master 节点,设置执行器的数目为零将会阻止在其上执行构建工作目录代理节点需要为Jenkins提供一个专门的目录。可以在这里配置该目录在节点机上的本地路径,最好是绝对路径,比如 /var/jenkins or c:\jenkins。如果使用相对路径,比如 ./jenkins-agent,该路径相对于由launch方法提供的工作目录针对由Jenkins控制启动代理进程的启动器,比如SSH,当前工作目录都是通常是一致的,比如用户主目录。对于不由Jenkins控制启动的代理进程,比如通过命令行启动的内置代理, 当前工作目录可能因启动器而异。使用相对路径可能会引发问题。注意:建议如果无特殊情况,配置为用户主目录。如果SSH连接使用的用户,无权限访问该工作目录时,会导致Jenkins无法拷贝必要文件,报类似如下错误:java.io.IOException: Could not copy remoting.jar into '/root/jenkins' on agent说明:错误提示中的root/jenkins为配置的工作目录标签标签用来对多节点分组,标记之间用空格分隔.例如refression java6将会把一个节点标记上regression和java6.举例来说,如果你有多个Windows系统的构建节点并且你的Job也需要在Windows系统上运行,那么你可以配置所有的Windows系统节点都标记为windows, 然后限制Job只能在label为windows的机器上执行,这样的话你的Job就不会运行在除了Windows节点以外的其它节点之上了.用法控制Jenkins如何在这台机器上安排构建.尽可能的使用这个节点这是默认和常用的设置。 在这种模式下,Jenkins会尽可能的使用这个节点。任何时候如果一个构建能使用这个节点构建,那么Jenkins就会使用它.只允许运行绑定到这台机器的Job这种模式下,Jenkins只会构建哪些分配到这台机器的Job。环境变量此处定义的环境变量将可用于该代理执行的每次构建,并将覆盖与“系统管理>>系统配置>>全局配置”定义的环境变量任何同名环境变量。LInux上变量使用语法:$NAME或${NAME}, WIndows上变量使用语法:%NAME%,这些变量可以在Jenkins Job配置中使用,也可以在由构建启动的进程中使用。Jenkins还支持一种特殊的语法BASE+EXTRA,该语法允许在这里添加多个键值对,这些键值对将被添加到现有的环境变量中。如下,如果你有一台PATH=/usr/bin的机器,你可以在这里定义一个键为PATH+LOCAL_BIN和值为/usr/LOCAL/bin的环境变量来添加到标准PATH中。这将导致PATH=/usr/local/bin:/usr/bin在该节点在执行构建期间被导出,同时PATH+LOCAL_BIN=/usr/local/bin也会被导出。根据环境变量名称(即上述配置的“”键”)中的“EXTRA”部分的字母顺序,在“BASE”变量前面加上多个条目。注意:如果如果该键值为空或仅为空白,则不会将其添加到环境中,也不会覆盖或重置可能已存在的同名环境变量(例如,系统定义的变量)。
实践环境Centos7.8先决条件已安装Docker Engine安装Docker Compose运行以下命令下载稳定版本Docker Compose$ sudo curl -L "https://github.com/docker/compose/releases/download/1.28.5/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose实践如下# curl -L "https://github.com/docker/compose/releases/download/1.28.5/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 633 100 633 0 0 38 0 0:00:16 0:00:16 --:--:-- 148 100 11.6M 100 11.6M 0 0 75813 0 0:02:41 0:02:41 --:--:-- 61868注意:要等待下载完成,下载完成后会自动退出命令给下载的二进制文件增加可执行权限$ sudo chmod +x /usr/local/bin/docker-compose为方便执行命令,增加软连接$ sudo ln -s /usr/local/bin/docker-compose /usr/bin/docker-compose测试验证是否安装成功$ docker-compose --version docker-compose version 1.28.5, build c4eb3a1f如果软件包没下载完全,执行上述命令时可能报错:[110532] Cannot open self /usr/local/bin/docker-compose or archive /usr/local/bin/docker-compose.pkg卸载Docker Compose如果使用curl安装的docker compose,则采用以下方式卸载:$ sudo rm /usr/local/bin/docker-compose如果使用pip安装的docker compose,则采用以下方式卸载:$ pip uninstall docker-compose
JWT简介JWT(Json web token),是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准。JWT提供了一种简单、安全的身份认证方法,特别适合分布式站点单点登录、或者是签名。JWT构成JWT是由3部分信息组成,分别为header,payload,signature,组合形式为:header.payload.signature(注意:这里的header,payload,signature都是经过base64加密的值)header格式如下:{ 'typ': 'JWT', # 声明类型 'alg': 'RS256' # 声明加密算法 # RSA Signature withSHA-256 }要构成JWT组成部分之前,需要对其进行base64加密,得到一字符串,形如:eyJ0eXAiOiAiSldUIiwgImFsZyI6ICJSUzI1NiJ9payloadpayload用于存放有效信息,可划分为三部分。标准声明公共声明私有声明标准声明(建议但不强制使用)iss:issue,JWT签发者sub:subject,主题aud:audience,受众,该JWT所面向的用户exp: JWT过期时间戳,单位秒,这个过期时间必须要大于签发时间nbf:定义在什么时间之前,该JWT都是不可用的iat:JWT签发时间jti:JWT的唯一身份标识,主要用来作为一次性token,从而避免重放攻击。公共声明公共声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息。一般不建议添加敏感信息,因为该部分在客户端可解密。私有声明私有声明是提供者和消费者所共同定义的声明,一般不建议添加敏感信息,因为该部分在客户端也是可解密。格式如下{ "iss":"shouke", "sub":"test_subject", "aud":"tester", "iat":1624499492, "exp":1624535491, "jti":"8NLazrgnXpAvmHA6eybETH7RT8sUWbag", "username":"shouke", "hobby":"unknow" }同header一样,要构成JWT组成部分之前,需要对其进行base64加密,得到一字符串,形如:eyJpc3MiOiAiY2Fzc21hbGwuY29tIiwgInN1YiI6ICJtYW5keSIsICJhdWQiOiAiY2Fzc21hbGwiLCAiaWF0IjogMTYyNTI4NzIzNSwgImV4cCI6IDE2NTY4MjMyMzUsICJqdGkiOiAiSmVRbUxqUlpaR0hjVEh1ZE5FdWRiUyIsICJ1c2VybmFtZSI6ICJzaG91a2UiLCAiaG9iYnkiOiAidW5rbm93In0=signatureheader,payload构成了signature基础信息,格式为:header.payload,其中header和payload,也是base64加密后的值。构成JWT组成部分之前,需要采用header中alg配置对应的算法,对上述基础信息进行加密,然后对加密结果进行base64编码,得到最终的signature。L1THOR4+gsksnDzwjGDsVCjvwlO7NBRdC6cVHAy1pycUGBugE6UM6mj/So1QRivVOyzk/OafHg9KpsR3/93SJ4SJXIyYhLaJXfIH+6tvi9Z72h6A2ko2AT//gfdtAtTJEMAF8rlsuu58FgYSQn2GjCIgn8oRNyX5S4w5Zmz+cJk=最后,将以上三部分用.连接起来,得到JWT,如下eyJ0eXAiOiAiSldUIiwgImFsZyI6ICJSUzI1NiJ9.eyJpc3MiOiAiY2Fzc21hbGwuY29tIiwgInN1YiI6ICJtYW5keSIsICJhdWQiOiAiY2Fzc21hbGwiLCAiaWF0IjogMTYyNTI4NzIzNSwgImV4cCI6IDE2NTY4MjMyMzUsICJqdGkiOiAiSmVRbUxqUlpaR0hjVEh1ZE5FdWRiUyIsICJ1c2VybmFtZSI6ICJzaG91a2UiLCAiaG9iYnkiOiAidW5rbm93In0=.L1THOR4+gsksnDzwjGDsVCjvwlO7NBRdC6cVHAy1pycUGBugE6UM6mj/So1QRivVOyzk/OafHg9KpsR3/93SJ4SJXIyYhLaJXfIH+6tvi9Z72h6A2ko2AT//gfdtAtTJEMAF8rlsuu58FgYSQn2GjCIgn8oRNyX5S4w5Zmz+cJk=代码实现import rsa import base64 import json import shortuuid from datetime import datetime, timedelta def make_jwt(): header = { 'typ': 'JWT', # 令牌类型 'alg': 'RS256' # 使用的算法 # RSA Signature withSHA-256 } header = base64.b64encode(json.dumps(header).encode()).decode() # encode decode 默认使用utf-8 print(header) payload = { "iss":"cassmall.com", "sub":"mandy", "aud":"cassmall", "iat":int(datetime.now().timestamp()), "exp":int((datetime.now()+ timedelta(seconds=31536000)).timestamp()), # JWT过期时间戳,单位秒 "jti":shortuuid.uuid(), "username":"shouke", "hobby":"unknow" } payload = base64.b64encode(json.dumps(payload).encode()).decode() print(payload) signature = genrate_signature(1024, '{header}.{payload}'.format(header=header, payload=payload).encode('utf-8'), 'SHA-256') print(signature) return '{header}.{payload}.{signature}'.format(header=header, payload=payload, signature=signature) def genrate_signature(nbits, message, hash_method): (pubkey, privkey) = rsa.newkeys(nbits) if not isinstance(message, bytes): message = message.encode('utf-8') hash = rsa.compute_hash(message, hash_method) return base64.b64encode(rsa.sign(hash, privkey, hash_method)).decode() if __name__ == '__main__': print(make_jwt())
Sampler-http请求之KeepAlive使用总结测试环境apache-jmeter-2.13KeepAlive使用介绍说明:1、Use KeepAlive 勾上,则表示为求连接设置请求头Connection: keep-alive,该配置对默认的HTTP实现不起作用,因为连接重用不受用户控制,对Apache HTTP组件HttpClient起作用。这个配置到底有啥用呢?我们都知道,发起HTTP请求时,需要建立TCP连接,对于普通非keep-alive请求,即不包含请求头Connection: keep-alive的请求,请求完成后,会关闭该TCP连接,再次发起同类请求时,需要再次建立TCP连接,高并发的情况下,会增加服务器资源消耗,对于keep-alive请求,则会告诉服务器,HTTP请求结束后,在条件允许的情况下,保持TCP连接,下次发送请求时,使用上次建立的TCP连接进行数据传输。至此,这个配置有啥用,就不用我说了吧2、注意,这里KeepAlive是针对同一个线程而言的,其使用效果受到多方面影响,如下:客户端Http实现HTTPClient4 使用Apache Http组件 HttpClient 4.x(推荐使用)Java 使用JVM提供的HTTP实现。空值 如果HTTP请求默认值配置元件中,Advanced选项卡中有设置非空请求客户端实现,则使用该客户端实现,否则使用jmter.properties文件中jmeter.httpsampler属性定义的客户端实现,否则使用默认的 HttpClient4。(原文:Blank value does not set implementation on HTTP Samplers, so relies on HTTP Request Defaults if present or on jmeter.httpsampler property defined in jmeter.properties注意:Java HTTP实现有以下限制:因为没有对连接重用做控制。所以,当连接被JMeter释放时,该连接可能被相同的线程重用,也可能不被重用。最适合单线程使用—各种设置都通过系统属性定义,因此作用于所有连接。通过代理处理HTTPS时存在bug(CONNECT未正确处理)。请参阅Java Bug 6226610和6208335。不支持虚拟主机。仅支持这些方法:GET,POST,HEAD,OPTIONS,PUT,DELETE和TRACE不支持使用密钥库配置进行基于客户端的证书测试。JMeter配置使用HTTPClient4实现时,连接重用还受到JMeter自身参数配置文件httpjmeter.properties中httpclient4.time_to_live参数配置的影响,如下,该参数值以毫秒为单位,默认为2000,无论如何,http的生存时间,超过该参数值的连接,不会被重用。httpclient4.time_to_live=2000服务端JMeter Use KeepAlive使用效果可能受到服务器相关配置影响,不同类型的服务器配置不一样,以Nginx为例子,和以下配置相关keepalive_requests number 设置通过一条keep-alive连接可以服务的最大请求数。当请求数超过该指令设置的最大值时,连接将被关闭。keepalive_time time 限制通过keep-alive连接发起的请求,可以被处理的最大时间,达到该时间,则关闭该连接keepalive_timeout timeout;timeout 设置keep-alive客户端连接在服务器端保持open状态时间,超过这个时间服务器将关闭连接。如果设置为0,那么禁用keep-alive客户端连接。
使用Docker创建MySQL容器实践环境Docker version 20.10.5MySQL5.7Centos 7.8创建步骤1、拉取MySQL镜像docker pull mysql:5.7说明:如果不执行该步骤,执行创建MySQL容器时会自动拉取镜像:docker pull mysql:latest。3、创建mysql数据文件,日志文件,配置文件挂载目录# mkdir -p /usr/local/mysql/data /usr/local/mysql/logs /usr/local/mysql/conf # chmod -R 755 /usr/local/mysql/data # chmod -R 755 /usr/local/mysql/logs # chmod -R 755 /usr/local/mysql/conf2、编写MySQL数据库配置文件my.cnf[mysqld] basedir=/usr/local/mysql datadir=/usr/local/mysql/data port=3306 character_set_database=utf8 character_set_server=utf8 user=mysql slow_query_log=on slow_query_log_file=/usr/local/mysql/logs/slow.log long_query_time=0.3 default-storage-engine=INNODB innodb_buffer_pool_size=64M innodb_purge_threads = 1 innodb_log_buffer_size=2M innodb_log_file_size = 64M innodb_thread_concurrency=8 innodb_lock_wait_timeout = 120 innodb_flush_log_at_trx_commit=1 max_connections=512 query_cache_size=0 tmp_table_size=18M thread_cache_size=8 myisam_max_sort_file_size=64G myisam_sort_buffer_size=35M key_buffer_size=25M read_buffer_size=64K read_rnd_buffer_size=256K sort_buffer_size=16M log-error=/usr/local/mysql/logs/mysql.log sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION [mysql] default-character-set=utf8 character_set_database=utf8 [client] port=3306 default-character-set=utf8说明:该配置文件存放在上文创建的/usr/local/mysql/conf目录下3、创建MySQL容器数据库# sudo docker run --restart=always -p 3306:3306 --name db.mysql -v /usr/local/mysql/conf:/etc/mysql -v /usr/local/mysql/my.cnf:/etc/mysql/my.cnf -v /usr/local/mysql/logs:/var/log/mysql -v /usr/local/mysql/data:/var/lib/mysql -e MYSQL_ROOT_PASSWORD=mFF!Wmh\& -d mysql:5.7MySQL环境变量配置说明:MYSQL_ROOT_PASSWORD : 指定root用户初始密码,例中为 mFF!Wmh&,还可以配置其它 实践时发现,以下3个配置不起作用 MYSQL_DATABASE : 运行时需要创建的数据库名称; MYSQL_USER : 运行时需要创建用户名,与MYSQL_PASSWORD一起使用; MYSQL_PASSWORD : 运行时需要创建的用户名对应的密码,与MYSQL_USER一起使用; 以下配置未验证过 MYSQL_ALLOW_EMPTY_PASSWORD : 是否允许root用户的密码为空,该参数对应的值为:yes; MYSQL_RANDOM_ROOT_PASSWORD:为root用户生成随机密码; MYSQL_ONETIME_PASSWORD : 设置root用户的密码必须在第一次登陆时修改(只对5.6以上的版本支持)。 MYSQL_ROOT_PASSWORD 和 MYSQL_RANDOM_ROOT_PASSWORD 两者必须有且只有一个。4、进入容器,登录MySQL# docker exec -it db.mysql /bin/bash root@0b023eb3b811:/# root@0b023eb3b811:/# mysql -uroot -pmH1FF\!Kemh\& mysql> exit Bye root@0b023eb3b811:/# exit # SHELL 复制 全屏如上,成功了注意:登录时,密码如果包含特殊字符,需要进行转义,否则会报类似如下错误bash: !Kemh: event not found
实现Gitlab事件自动触发Jenkins构建及钉钉消息推送实践环境GitLab Community Edition 12.6.4Jenkins 2.284Post build task 1.9(Jenkins插件)Generic Webhook Trigger Plugin 1.72(Jenkins插件)GitLab 1.5.13(Jenkins插件)实现步骤钉钉机器人配置选择要推送的钉钉群 -> 点击群设置按钮 -> 点击智能群助手 -> 点击添加机器人 -> 点击添加机器人+号按钮 -> 点击自定义->填写机器人名字,用于匹配推送消息请求体内容的的关键词然后,复制出Webhook地址,供下文钉钉消息推送Shell脚本中使用,完成安装Jenkins插件新建并配置Jenkins项目Build Triggers配置如下,勾选Generic Webhook TriggerPost content parameters(因为Gitlab触发的请求为post请求,需要基于请求体内容来判断是否执行Jenkins构建)关键配置项说明:Variable 自定义变量名称Expression 用于提取变量值的表达式(支持JSONPath、XPath),提取的值赋值给上述自定义变量(例中为event_name)。Option Filter关键配置项说明:Expression 用于匹配下述Text的正则表达式,如果匹配则执行构建请求,否则不执行。这里配置为^push$,是因为Gitlab merge合并代码操作触发的请求,其请求体为json格式数据,其中包含名为event_name的键,其值为 pushText 用于匹配上述正则表达式的文本,例中设置为自定义变量$event_name。以上配置大意为,如果收到构建请求,使用JSONPath表达式从JSON格式的请求体获取键为event_name的值,存储到名为event_name变量,然后取该变量值同正则表达式^push$匹配,如果匹配,则触发Jenkins构建当前项目,否则不构建。Token:自定义token值,用于请求http://JENKINS_URL/generic-webhook-trigger/invoke触发构建使用,如下,可以用于查询参数、请求头参数/invoke?token=TOKEN_HERE token: TOKEN_HERE Authorization: Bearer TOKEN_HEREgeneric-webhook-trigger配置参考连接https://plugins.jenkins.io/generic-webhook-trigger/Post-build Actions配置点击Add post-build action按钮,弹出界面中选择Post build task可新增以下配置界面。如下,可在Script输入框中编写构建完成后需要执行的Shell命令(该插件会先根据填写的shell命令生成一个临时sh脚本,然后执行该脚本),例中为钉钉推送命令,具体代码参见下文如上图,如果只希望构建成功才执行Script,可以勾选Run script only if all previous steps were successful钉钉消息推送Shell#!/bin/bash ################################################################# # 作者:shouke # 日期:2021-03-07 # 作用:机器人通知 ################################################################# # 钉钉消息变量定义 ################################################################# # 当前时间 TIME_NOW=`date "+%Y年%m月%d日 %H:%M:%S"` BUILD_STATUS="失败" LAST_BUILD_BUILD_XML=`curl http://ops.dev.xxxx.com/view/testarch/job/$JOB_NAME/lastBuild/api/xml --user juser_name:123456` BUILD_RESULT=$(echo $LAST_BUILD_BUILD_XML | grep "<result>SUCCESS</result>") if [ "${BUILD_RESULT}" ];then BUILD_STATUS="成功" else BUILD_RESULT=$(echo $LAST_BUILD_BUILD_XML | grep "<result>FAILURE</result>") if [ "${BUILD_RESULT}" ];then BUILD_STATUS="失败" else BUILD_STATUS="无法获取" fi fi # 机器人 webhook 地址(上文添加钉钉机器人结束时复制的webhook地址) DINGTALK_WEBHOOK_URL='https://oapi.dingtalk.com/robot/send?access_token=903fcd6c56f301d0a57bee243792a11bb1e42cae89af5a9071bdba890c0a3d2' # 消息标题 # 实际不起作用,但是不能少,否则发送失败 DINGTALK_TITLE="XX平台有新的构建,请及时查阅" # 消息正文 # Jenkins Job构建日志地址 JENKINS_JOB_BUILD_LOG_URL="http://ops.dev.xxxx.com/view/testarch/job/${JOB_NAME}/${BUILD_NUMBER}/console" DINGTALK_TEXT="## xx平台有新的构建,请及时查阅\n\n>\ **【通知时间】**:${TIME_NOW}\n\n>\ **【构建ID】**:${BUILD_DISPLAY_NAME}\n\n>\ **【构建项目】**:${JOB_NAME}\n\n>\ **【构建状态】**:${BUILD_STATUS}\n\n>\ **[点击查看更多](${JENKINS_JOB_BUILD_LOG_URL})**\n " # # 发送钉钉消息通知函数 ################################################################# function SEND_MESSAGE_TO_DINGTALK() { /usr/bin/curl "$1" -H 'Content-Type: application/json' -d " { \"markdown\": { \"title\": \"$2\", \"text\": \"$3\" }, \"at\": { \"atMobiles\": [], \"isAtAll\": false }, \"msgtype\": \"markdown\" } " } # 发送钉钉消息 ################################################################# SEND_MESSAGE_TO_DINGTALK "${DINGTALK_WEBHOOK_URL}" "${DINGTALK_TITLE}" "${DINGTALK_TEXT}"说明:curl http://ops.dev.xxxx.com/view/testarch/job/$JOB_NAME/lastBuild/api/xml --user juser_name:123456`以名为juser_name的用户,使用密码123456访问指定项目的最后一次构建相关的信息,返回xml文档注意:钉钉聊天窗口中要实现消息换行必须使用两个\nGitlab自动触发配置Settings -> Integration,打开如下页面,1)填写URL(http://ops.dev.xxxx.com/generic-webhook-trigger/invoke?token=0771826b93bbd566266bce34f5123ebb),这里的token值即为generic-webhook-trigger插件中配置在定义token值2)勾选Push events触发器(这里以push、合并代码操作为例子,所以仅勾选该事件)3)勾选 Enable SSL verification 复选框(如果没有勾选的话,默认就是勾选的)最后点击 Add webhook按钮添加的配置,会自动显示在下方,可以对其进行事件触发测试触发的记录会自动在配置编辑页面下方显示,点击 View details按钮,可以查看请求明细注意:自动触发时Jenkins项目构建时,如果Jenkins使用了参数化构建插件Build With Parameters Plugin,并且使用插件实现的参数有设置默认值,则自动触发时也会自动使用对应参数的默认值进行构建。钉钉消息推送效果图
Dialog 结合Vue实现对话框body“二分”布局需求描述如下图,把对话框body内容部分,分成上下两部分,其中上部分高度根据窗口大小动态调整,如果内容过多,则出现滚动条,以便滚动查阅被遮挡内容,下部分内容(即关闭|保存按钮所在容器)高度固定。对话框高度不固定,随窗口高度变化而变化代码实现<template> <el-dialog title="负载配置" width="60%" :visible="dialogVisible" custom-class="dialog-settings" > <load-settings-form :loadSettingsForm="loadSettingsForm" ref="loadSettingsForm"></load-settings-form> <div class="dialog-footer"> <el-button @click="closeDialog">关闭</el-button> <el-button type="primary" @click="saveLoadSettings('loadSettingsForm')">保存</el-button> </div> </el-dialog> </template> <script> // 略 </script> <style lang="scss"> .dialog-settings { height: 70%; .el-dialog__body { height: auto; flex-direction: column; display: flex; height: calc(100% - 54px); padding: 0px 20px 20px 20px; overflow: none; .load-settings-form { flex: 1; overflow: auto; } .dialog-footer { flex-shrink: 0; text-align: center; } } } </style>说明:height: calc(100% - 54px); // 设置对话框body高度为对话框高度-对话框标题栏高度这里的54px为对话框标题栏(即class=".el-dialog__header"的div)的高度,
结合Vue实现横向排列表单项前言默认的,ElementUI的Form表单项(控件)是垂直排列,即一行显示一个表单项。但是在实际应用中,有时候会需要一行显示多个表单项。针对这类需求,笔者提供以下解决方案解决方案1、修改表单项.el-form-item样式如下,增加display属性,设置值为inline-block !important; 因为默认的,表单项为块元素,这样设置以后,可以让表单项变成内联元素,这样表单项就可以横向排列在一起了.el-form-item { display: inline-block !important; }2、修改表单项.el-form-item__label样式如下,设置display为none,即隐藏自带的表单项标签,然后设置width为0px !important;,避免被隐藏 标签继续占用空间.el-form-item__label { display: none; width: 0px !important; }这样以后,使用<span>labelName</span>作为自定义标签项3、修改表单项.el-form-item__content样式如下,避免表单项在视觉上看上去两头占了很大空白.el-form-item__content { margin-left: 3px !important; margin-right: 3px !important; }4、使用row和col组件控制哪些表单项归属同一行、同一列应用举例<el-form :model="loadSettingsForm" :rules="loadSettingsFormRules" ref="loadSettingsForm" label-width="100px" class="load-settings-form" > <el-row> <el-col> <span>场景名称</span> <el-form-item prop="name"> <el-input v-model="loadSettingsForm.name" maxlength="50" /> </el-form-item> </el-col> </el-row> <el-row> <el-col> <span>是否梯度加压</span> <el-form-item> <el-radio v-model="loadSettingsForm.isSteppingPressure" :label="true">是</el-radio> <el-radio v-model="loadSettingsForm.isSteppingPressure" :label="false">否</el-radio> </el-form-item> </el-col> </el-row> <div class="el-form-row-wrapper" v-if="loadSettingsForm.isSteppingPressure"> <el-row> <el-col> <span>初始启动</span> <el-form-item prop="initialUserNum"> <el-input v-model="loadSettingsForm.initialUserNum" maxlength="2" oninput="value=value.replace(/[^\d]/g,'')" /> </el-form-item> <span>个用户,每隔</span> <el-form-item prop="interval"> <el-input v-model="loadSettingsForm.interval" maxlength="4" oninput="value=value.replace(/[^\d]/g,'')" /> </el-form-item> <span>秒,</span> </el-col> </el-row> <el-row> <el-col> <span>按每秒启动</span> <el-form-item prop="spawnRate"> <el-input v-model="loadSettingsForm.spawnRate" maxlength="2" oninput="value=value.replace(/[^\d]/g,'')" /> </el-form-item> <span>个用户的速率增加</span> <el-form-item prop="add"> <el-input v-model="loadSettingsForm.add" maxlength="2" oninput="value=value.replace(/[^\d]/g,'')" /> </el-form-item> <span>个用户,直至增加到</span> <el-form-item prop="targetUserCount"> <el-input v-model="loadSettingsForm.targetUserCount" maxlength="6" oninput="value=value.replace(/[^\d]/g,'')" /> </el-form-item> <span>个用户</span> </el-col> </el-row> </div> <div class="el-form-row-wrapper" v-else> <el-row> <el-col> <span>按每秒启动</span> <el-form-item prop="spawnRate"> <el-input v-model="loadSettingsForm.spawnRate" maxlength="2" oninput="value=value.replace(/[^\d]/g,'')" /> </el-form-item> <span>个用户的速率启动</span> <el-form-item prop="targetUserCount"> <el-input v-model="loadSettingsForm.targetUserCount" maxlength="6" oninput="value=value.replace(/[^\d]/g,'')" /> </el-form-item> <span>个用户</span> </el-col> </el-row> </div> <div> <el-row> <el-col> <el-radio v-model="loadSettingsForm.loadRunMode" :label="1" style="margin-right:0px" >每个用户运行</el-radio> <el-form-item prop="iterationNumEachUser"> <el-input v-model="loadSettingsForm.iterationNumEachUser" maxlength="18" oninput="value=value.replace(/[^\d]/g,'')" /> </el-form-item> <span>次</span> <el-radio v-model="loadSettingsForm.loadRunMode" :label="2" style="margin-left:20px; margin-right:0px;" >持续运行</el-radio> <el-form-item prop="holdLoadTime"> <el-input v-model="loadSettingsForm.holdLoadTime" maxlength="18" oninput="value=value.replace(/[^\d]/g,'')" /> </el-form-item> <span>秒</span> </el-col> </el-row> </div> <div class="el-form-row-wrapper"> <el-row> <el-col> <span>在开始下一轮迭代之前</span> <el-form-item style="width:80px"> <el-radio v-model="loadSettingsForm.iterationWaitModel" :label="1">固定等待</el-radio> </el-form-item> <el-form-item prop="iterationWaitTime"> <el-input v-model="loadSettingsForm.iterationWaitTime" maxlength="6" oninput="value=value.replace(/[^\d]/g,'')" /> </el-form-item> <span>秒</span> <el-form-item style="width:110px"> <el-radio v-model="loadSettingsForm.iterationWaitModel" :label="2" style="margin-left:30px" >随机等待</el-radio> </el-form-item> <el-form-item prop="iterationMinWait"> <el-input v-model="loadSettingsForm.iterationMinWait" maxlength="6" oninput="value=value.replace(/[^\d]/g,'')" /> </el-form-item> <span>到</span> <el-form-item prop="iterationMaxWait"> <el-input v-model="loadSettingsForm.iterationMaxWait" maxlength="6" oninput="value=value.replace(/[^\d]/g,'')" /> </el-form-item> <span>秒</span> </el-col> </el-row> <el-row> <el-col> <span>最后每秒停止</span> <el-form-item prop="stopRate"> <el-input v-model="loadSettingsForm.stopRate" maxlength="2" oninput="value=value.replace(/[^\d]/g,'')" /> </el-form-item> <span>个用户</span> </el-col> </el-row> </div> <el-row> <el-col> <span>迭代模式</span> <el-form-item> <el-radio v-model="loadSettingsForm.iterationMode" :label="1">用例串行</el-radio> <el-radio v-model="loadSettingsForm.iterationMode" :label="2">用例权重</el-radio> <el-radio v-model="loadSettingsForm.iterationMode" :label="3">用例随机</el-radio> </el-form-item> </el-col> </el-row> </el-form> </template> <script> export default { data() { return { loadSettingsForm: { name: "", host: "", isSteppingPressure: false, // 是否梯度加压 true-是 否-false loadRunMode: 1, // 运行方式 1-按次数运行 2-按时间运行 initialUserNum: 10, interval: 180, spawnRate: 5, add: 10, targetUserCount: 1, holdLoadTime: 1800, stopRate: 5, iterationNumEachUser: 1, iterationWaitModel: 1, // 1-固定等待 2-随机等待 iterationWaitTime: 0, iterationMinWait: 0, iterationMaxWait: 5, iterationMode: 1 //迭代模式 1 - 用例串行 2 - 用例权重 3 - 用例随机 }, loadSettingsFormRules: { name: [{ required: true, trigger: "blur", message: "此配置项必填" }, { min: 1, max: 50, message: "长度在 1 到 50 个字符", trigger: "blur" } ], targetUserCount: [ { required: true, message: "此配置项必填", trigger: "blur" } ], spawnRate: [{ required: true, message: "此配置项必填" }], iterationNumEachUser: [ { required: this.loadSettingsForm.loadRunMode == 1, message: "此配置项必填", trigger: "blur" } ], holdLoadTime: [ { required: !(this.loadSettingsForm.loadRunMode == 1), message: "此配置项必填", trigger: "blur" } ], stopRate: [ { required: true, message: "此配置项必填", trigger: "blur" } ] } }; }, watch: { "loadSettingsForm.loadRunMode": { handler(newValue, oldValue) { this.loadSettingsFormRules.iterationNumEachUser[0].required = !this .loadSettingsFormRules.iterationNumEachUser[0].required; this.loadSettingsFormRules.holdLoadTime[0].required = !this .loadSettingsFormRules.holdLoadTime[0].required; this.$nextTick(() => { this.$refs.loadSettingsForm.clearValidate(); }) }, }, "loadSettingsForm.isSteppingPressure": { handler(newValue, oldValue) { this.$nextTick(() => { this.$refs.loadSettingsForm.clearValidate(); }) }, } } }; </script> <style lang="scss"> .load-settings-form { padding-right: 20px; padding-left: 20px; .el-form-item__label { display: none; width: 0px !important; } .el-form-item__content { margin-left: 3px !important; margin-right: 3px !important; } .el-form-item { display: inline-block !important; } .el-form-row-wrapper { .el-form-item__content { width: 85px; } } } </style>效果截图
基于VSCode结合Vetur+ESlint+Prettier统一Vue代码风格插件安装安装Vetur,ESlint, Prettier - Code formatter插件安装方法(安装ESlint插件为例):File -> Preferences -> Extensions,打开如下界面,搜索目标插件,点击安装按钮安装settings配置配置文件所在路径User配置:%HOMEPATH%\AppData\Roaming\Code\User\settings.jsonWorkerspace配置:PROJECT_HOME\.vscode\settings.json说明:User配置为全局配置, 适用于所有的打开的实例,而Workspace配置储存在工作区之下并仅适用于本工作区的配置,显然,Workspace配置优先于User配置被使用settings.json配置File -> Preferences -> Settings,选择User、Workspace 配置tab标签后,点击图示按钮,即可打开settings.json配置文件然后,把以下配置黏贴到文件,保存即可。{ // 界面配置路径 Text Editor "editor.wordWrap": "bounded", // 设置 超过word Wrap Column设置的字符数、达到视口最小宽度,时自动换行 "editor.wordWrapColumn": 120, // editor.wordWrap 配置为wordWrapColumn或者bounded时起作用 "editor.insertSpaces": true, // 设置输入tab键时是否自动转为插入空格(默认ture,即自动转空格),当editor.detectIndentation配置为 true 时,该配置项将被自动覆盖 "editor.detectIndentation": false, // 设置是否自动检测对齐,控制打开文件时是否基于文件内容,自动检测editor.tabSize 和editor.insertSpaces // 界面配置路径 Text Editor -> Font "editor.fontSize": 14, // 设置字体大小, 默认 14 //界面配置路径 Text Editor -> Files "files.autoSave": "afterDelay", //设置延迟一定的时间后自动保存文件 "files.autoSaveDelay": 1000, // 设置自动保存文件前需要延迟的时间,单位毫秒 默认1000 "files.enableTrash": true, // 设置删除文件、目录时是否允许删除到操作系统回收站,默认为true,即允许 "files.encoding": "utf8", // 设置读写文件时所用编码 默认UTF-8,可针对每种语言进行设置 "files.autoGuessEncoding": false, // 设置打开文件时,是否自动猜测字符编码,默认false,即不自动猜测,可针对每种语言进行设置 // 界面配置路径 Text Editor -> Formatting "editor.formatOnPaste": true, // 设置黏贴内容时是否自动格式化,true表示自动格式化,需要配置格式化器(formatter)才可使用 "editor.formatOnSave": true, // 设置保存文件时是否自动格式化,true表示自动格式化,需要配置格式化器(formatter)才可使用 "editor.formatOnSaveMode": "file", // 设置保存文件时格式化整个文件还是仅被修改处。该配置项仅在 "editor.formatOnPaste" 为 true时生效 "editor.formatOnType": true, // 设置输入完成后是否自动格式化当前行 // 界面配置路径 Text Editor -> Minimap "editor.minimap.maxColumn": 120, // 设置minimap的宽度以设置可渲染的最大列数,默认120 // 界面配置路径 Text Editor -> Suggestions "editor.quickSuggestions": null, // 默认选项为on 设置回车时是否接受默认建议选项 // Eslint插件配置 "editor.codeActionsOnSave": { "source.fixAll.eslint": true // 设置保存时是否自动修复代码 }, // 界面配置路径 Extensiosn -> ESlint "eslint.alwaysShowStatus": true, // 设置状态栏是否一直显示ESlint图标项,true表示一直显示 "eslint.format.enable": true, // 设置是否把ESlint作为一个格式化器,true表示启用 // Prettier插件配置 // 界面配置路径 Extensiosn -> Prettier "prettier.enable": true, // 设置是否开启prettier插件,默认为true,即开启 "prettier.semi": false, // 设置是否在每行末尾添加分号,默认为 true "prettier.singleQuote": true, // 设置格式化时,保持单引号,如果设置为true,则单引号会自动变成双引号 "prettier.tabWidth": 2, // 设置每个tab占用多少个空格 "prettier.printWidth": 120, // 设置每行可容纳字符数 "prettier.useTabs": false, // 设置是否使用tab键缩进行,默认为false,即不使用 "prettier.bracketSpacing": true, // 在对象,括号与文字之间加空格 true - Example: { foo: bar } false - Example: {foo: bar}, 默认为true "prettier.jsxBracketSameLine": true, // 设置在jsx中,是否把'>' 单独放一行,默认为false,即单独放一行 // 设置各种代码的默认格式化器//以下为默认配置 "[html]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[css]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[less]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[jsonc]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[vue]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, // Vetur插件配置 "vetur.format.enable": true, // 设置是否禁用插件格式化功能 // 默认为true,即开启 "vetur.format.defaultFormatter.css": "prettier", // 设置css代码(<style>包含的代码块)默认格式化器 "vetur.format.defaultFormatter.sass": "sass-formatter", "vetur.format.defaultFormatter.postcss": "prettier", "vetur.format.defaultFormatter.scss": "prettier", "vetur.format.defaultFormatter.less": "prettier", "vetur.format.defaultFormatter.stylus": "stylus-supremacy", "vetur.format.defaultFormatter.html": "prettier", // 设置html代码(<template>包含的代码块)默认格式化器 "vetur.format.defaultFormatter.js": "prettier-eslint", // 设置js代码<script>包含的代码块)默认格式化器 "vetur.format.defaultFormatter.ts": "prettier", // 设置vetur默认使用 prettier格式化代码 "vetur.format.options.tabSize": 2, // 设置tab键占用的空格数,该配置将被所有格式化器继承 "vetur.format.options.useTabs": false, // 设置是否使用tab键缩进 默认false,即不使用,该配置将被所有格式化器继承 //"vetur.ignoreProjectWarning": true // 控制是否忽略关于vscode项目配置错误的告警,默认为false,即不忽略 }设置默认格式化插件右键代码编辑区,选择Format Document WIth 弹出提示框如下,选择Configure Default Formatter...进一步选择默认格式化器(这里选Vetur)即可。或者选中要格式化的代码,按Alt+Shift+F,未设置默认格式化器的时候,会弹出配置默认格式化器的提示,然后按提示操作即可格式化代码按上述配置,按Ctrl + S手动保存文件时会自动化使用Vetur格式化器格式化代码。补充说明ESlint插件主要用于识别和报告ECMAScript/JavaScript代码中的语法模式是否存在错误Vetur插件这里Vetur的主要用途是语法高亮,其次是代码格式化,支持以下格式化器(formatter):prettier: For css/scss/less/js/ts.prettier: For pug.prettier-eslint : For js. 运行prettier 和eslint --fix.stylus-supremacy : For stylus.vscode-typescript: 针对 js/ts. 与VS Code自带的的 js/ts formatter相同sass-formatter: For the .sass section of the files.prettyhtml [已被弃用] For html.虽然Vetur已内置上述格式化器,但是当Vetur检测到本地已经安装对应的格式化器时,会优先使用本地安装的格式化器。如下,可以为不同语言指定其默认的格式化器,Vetur默认配置如下,如果想禁用某种语言的格式化器,可以将其格式化器设置为null。{ "vetur.format.defaultFormatter.html": "prettier", "vetur.format.defaultFormatter.pug": "prettier", "vetur.format.defaultFormatter.css": "prettier", "vetur.format.defaultFormatter.postcss": "prettier", "vetur.format.defaultFormatter.scss": "prettier", "vetur.format.defaultFormatter.less": "prettier", "vetur.format.defaultFormatter.stylus": "stylus-supremacy", "vetur.format.defaultFormatter.js": "prettier", "vetur.format.defaultFormatter.ts": "prettier", "vetur.format.defaultFormatter.sass": "sass-formatter" }两个特殊的格式化配置项Vetur支持两个特殊的tabSize和useTabs格式化配置,如下{ "vetur.format.options.tabSize": 2, "vetur.format.options.useTabs": false }为啥说是特殊呢,因为这两个配置项,可以被所有格式化器继承,但是也有例外,如下:当存在本地配置(比如.prettierrc)时,Vetur会优先使用本地配置。例如:.prettierrc 文件存在,但是没有显示设置tabWidth ,则Vetur默认使用 vetur.format.options.tabSize 作为prettier格式化器的 tabWidth 配置。.prettierrc 文件存在,并且显示设置了 tabWidth ,则Vetur 自动忽略 vetur.format.options.tabSize配置项目,总是使用.prettierrc中配置的值。useTabs 的使用规则也是如此Prettier - Code formatter插件类似Vetur,:Prettier并不具有ESlint检查语法能力,主要用于代码格式化,统一代码风格(最大长度、混合标签和空格、引用样式等),包括JavaScript,Flow,TypeScript,CSS,SCSS,Less,JSX,GraphQL,JSON,Markdown。jsxBracketSameLine配置项该配置项主要用于控制 jsx中,是否把'>' 单独放一行,默认为false,即单独放一行prettier.jsxBracketSameLine:true - 格式化效果举例:<button className="prettier-class" id="prettier-id" onClick={this.handleClick}> Click Here </button>prettier.jsxBracketSameLine:false - 格式化效果举例:<button className="prettier-class" id="prettier-id" onClick={this.handleClick} > Click Here </button>参考连接https://code.visualstudio.com/docs/editor/codebasics#_save-auto-savehttps://eslint.org/docs/user-guide/getting-startedhttps://vuejs.github.io/vetur/guide/formatting.html#formattershttps://prettier.io/docs/en/options.html
Pytest源码分析测试环境pytest 5.4.3测试脚本mytest.py#!/usr/bin/env python # -*- coding:utf-8 -*- import pytest def test_func(): # test开头的测试函数 print("test_func") assert 1 # 断言成功 if __name__ == '__main__': pytest.main() # 执行测试源码分析测试脚本mytest.pyimport pytest运行pytest/__init__.py,主要做了两件事情从_pytest导入后续需要用的依赖包通过_pytest/compat.py模块的_setup_collect_fakemodule()建立一个伪模块pytest.collectpytest/__init__.py# PYTHON_ARGCOMPLETE_OK """ pytest: unit and functional testing with Python. """ from _pytest import __version__ from _pytest.assertion import register_assert_rewrite from _pytest.compat import _setup_collect_fakemodule from _pytest.config import cmdline from _pytest.config import ExitCode from _pytest.config import hookimpl from _pytest.config import hookspec from _pytest.config import main from _pytest.config import UsageError from _pytest.debugging import pytestPDB as __pytestPDB from _pytest.fixtures import fillfixtures as _fillfuncargs from _pytest.fixtures import fixture from _pytest.fixtures import yield_fixture from _pytest.freeze_support import freeze_includes from _pytest.main import Session from _pytest.mark import MARK_GEN as mark from _pytest.mark import param from _pytest.nodes import Collector from _pytest.nodes import File from _pytest.nodes import Item from _pytest.outcomes import exit from _pytest.outcomes import fail from _pytest.outcomes import importorskip from _pytest.outcomes import skip from _pytest.outcomes import xfail from _pytest.python import Class from _pytest.python import Function from _pytest.python import Instance from _pytest.python import Module from _pytest.python import Package from _pytest.python_api import approx from _pytest.python_api import raises from _pytest.recwarn import deprecated_call from _pytest.recwarn import warns from _pytest.warning_types import PytestAssertRewriteWarning from _pytest.warning_types import PytestCacheWarning from _pytest.warning_types import PytestCollectionWarning from _pytest.warning_types import PytestConfigWarning from _pytest.warning_types import PytestDeprecationWarning from _pytest.warning_types import PytestExperimentalApiWarning from _pytest.warning_types import PytestUnhandledCoroutineWarning from _pytest.warning_types import PytestUnknownMarkWarning from _pytest.warning_types import PytestWarning set_trace = __pytestPDB.set_trace __all__ = [ "__version__", "_fillfuncargs", "approx", "Class", "cmdline", "Collector", "deprecated_call", "exit", "ExitCode", "fail", "File", "fixture", "freeze_includes", "Function", "hookimpl", "hookspec", "importorskip", "Instance", "Item", "main", "mark", "Module", "Package", "param", "PytestAssertRewriteWarning", "PytestCacheWarning", "PytestCollectionWarning", "PytestConfigWarning", "PytestDeprecationWarning", "PytestExperimentalApiWarning", "PytestUnhandledCoroutineWarning", "PytestUnknownMarkWarning", "PytestWarning", "raises", "register_assert_rewrite", "Session", "set_trace", "skip", "UsageError", "warns", "xfail", "yield_fixture", ] _setup_collect_fakemodule() # 建立一个伪模块`pytest.collect` del _setup_collect_fakemodule_pytest/compat.py_setup_collect_fakemodule函数COLLECT_FAKEMODULE_ATTRIBUTES = ( "Collector", "Module", "Function", "Instance", "Session", "Item", "Class", "File", "_fillfuncargs", ) def _setup_collect_fakemodule() -> None: from types import ModuleType import pytest # Types ignored because the module is created dynamically. pytest.collect = ModuleType("pytest.collect") # type: ignore pytest.collect.__all__ = [] # type: ignore # used for setns for attr_name in COLLECT_FAKEMODULE_ATTRIBUTES: setattr(pytest.collect, attr_name, getattr(pytest, attr_name)) # type: ignore测试脚本myptest.pypytest.main()这里的main函数为从_pytest/config/__init__.py定义的全局函数--main函数_pytest/config/__init__.py_pytest/config/__init__.py main函数定义主要用于获取Config对象config,然后通过config.hook.pytest_cmdline_main执行测试def main(args=None, plugins=None) -> Union[int, ExitCode]: """ return exit code, after performing an in-process test run. :arg args: list of command line arguments. :arg plugins: list of plugin objects to be auto-registered during initialization. """ try: try: config = _prepareconfig(args, plugins) except ConftestImportFailure as e: exc_info = ExceptionInfo(e.excinfo) tw = TerminalWriter(sys.stderr) tw.line( "ImportError while loading conftest '{e.path}'.".format(e=e), red=True ) exc_info.traceback = exc_info.traceback.filter(filter_traceback) exc_repr = ( exc_info.getrepr(style="short", chain=False) if exc_info.traceback else exc_info.exconly() ) formatted_tb = str(exc_repr) for line in formatted_tb.splitlines(): tw.line(line.rstrip(), red=True) return ExitCode.USAGE_ERROR else: try: ret = config.hook.pytest_cmdline_main( config=config ) # type: Union[ExitCode, int] try: return ExitCode(ret) except ValueError: return ret finally: config._ensure_unconfigure() except UsageError as e: tw = TerminalWriter(sys.stderr) for msg in e.args: tw.line("ERROR: {}\n".format(msg), red=True) return ExitCode.USAGE_ERROR_pytest/config/__init__.py _prepareconfig函数定义主要是获取并返回Config对象config,该对象通过函数pluginmanager.hook.pytest_cmdline_parse返回def _prepareconfig( args: Optional[Union[py.path.local, List[str]]] = None, plugins=None ): if args is None: args = sys.argv[1:] elif isinstance(args, py.path.local): args = [str(args)] elif not isinstance(args, list): msg = "`args` parameter expected to be a list of strings, got: {!r} (type: {})" raise TypeError(msg.format(args, type(args))) config = get_config(args, plugins) pluginmanager = config.pluginmanager try: if plugins: for plugin in plugins: if isinstance(plugin, str): pluginmanager.consider_pluginarg(plugin) else: pluginmanager.register(plugin) return pluginmanager.hook.pytest_cmdline_parse( pluginmanager=pluginmanager, args=args ) except BaseException: config._ensure_unconfigure() raise_pytest/config/__init__.py get_config函数定义主要是构造Config对象# Plugins that cannot be disabled via "-p no:X" currently. essential_plugins = ( "mark", "main", "runner", "fixtures", "helpconfig", # Provides -p. ) default_plugins = essential_plugins + ( "python", "terminal", "debugging", "unittest", "capture", "skipping", "tmpdir", "monkeypatch", "recwarn", "pastebin", "nose", "assertion", "junitxml", "resultlog", "doctest", "cacheprovider", "freeze_support", "setuponly", "setupplan", "stepwise", "warnings", "logging", "reports", "faulthandler", ) builtin_plugins = set(default_plugins) builtin_plugins.add("pytester") def get_config(args=None, plugins=None): # subsequent calls to main will create a fresh instance pluginmanager = PytestPluginManager() # PytestPluginManager 继承于 PluginManager config = Config( pluginmanager, invocation_params=Config.InvocationParams( args=args or (), plugins=plugins, dir=Path().resolve() ), ) if args is not None: # Handle any "-p no:plugin" args. pluginmanager.consider_preparse(args, exclude_only=True) for spec in default_plugins: pluginmanager.import_plugin(spec) # 为对象导入插件 return config_pytest/config/__init__.py Config构造函数定义构造函数参数pluginmanager接收了外部传入的PytestPluginManager实例对象,该参数被赋值给 self.pluginmanager,同时初始化self.hook值为self.pluginmanager.hook,这样Config对象就具备了pluggy的插件管理及hook能力,可通过Config对象.hook.hook函数class Config: # ... 略 def __init__( self, pluginmanager: PytestPluginManager, *, invocation_params: Optional[InvocationParams] = None, ) -> None: from .argparsing import Parser, FILE_OR_DIR if invocation_params is None: invocation_params = self.InvocationParams( args=(), plugins=None, dir=Path.cwd() ) self.option = argparse.Namespace() """Access to command line option as attributes. :type: argparse.Namespace """ self.invocation_params = invocation_params """The parameters with which pytest was invoked. :type: InvocationParams """ _a = FILE_OR_DIR self._parser = Parser( usage=f"%(prog)s [options] [{_a}] [{_a}] [...]", processopt=self._processopt, ) self.pluginmanager = pluginmanager # 增加插件管理器 """The plugin manager handles plugin registration and hook invocation. :type: PytestPluginManager """ self.trace = self.pluginmanager.trace.root.get("config") self.hook = self.pluginmanager.hook # 增加hook属性 self._inicache: Dict[str, Any] = {} self._override_ini: Sequence[str] = () self._opt2dest: Dict[str, str] = {} self._cleanup: List[Callable[[], None]] = [] # A place where plugins can store information on the config for their # own use. Currently only intended for internal plugins. self._store = Store() self.pluginmanager.register(self, "pytestconfig") self._configured = False self.hook.pytest_addoption.call_historic( kwargs=dict(parser=self._parser, pluginmanager=self.pluginmanager) ) if TYPE_CHECKING: from _pytest.cacheprovider import Cache self.cache: Optional[Cache] = None_pytest/config/__init__.py PytestPluginManager类初始化时,通过self.add_hookspecs(_pytest.hookspec) 添加hook函数声明(接口),同时通过self.register(self)把自己注册为插件实现;import_plugin 中拼接_pytest/config/__init__.py中定义的模块,拼接后的形式,形如_pytest.python,然后导入并注册插件@final class PytestPluginManager(PluginManager): """A :py:class:`pluggy.PluginManager <pluggy.PluginManager>` with additional pytest-specific functionality: * Loading plugins from the command line, ``PYTEST_PLUGINS`` env variable and ``pytest_plugins`` global variables found in plugins being loaded. * ``conftest.py`` loading during start-up. """ def __init__(self) -> None: import _pytest.assertion super().__init__("pytest") # The objects are module objects, only used generically. self._conftest_plugins: Set[types.ModuleType] = set() # State related to local conftest plugins. self._dirpath2confmods: Dict[Path, List[types.ModuleType]] = {} self._conftestpath2mod: Dict[Path, types.ModuleType] = {} self._confcutdir: Optional[Path] = None self._noconftest = False self._duplicatepaths: Set[Path] = set() # plugins that were explicitly skipped with pytest.skip # list of (module name, skip reason) # previously we would issue a warning when a plugin was skipped, but # since we refactored warnings as first citizens of Config, they are # just stored here to be used later. self.skipped_plugins: List[Tuple[str, str]] = [] self.add_hookspecs(_pytest.hookspec) self.register(self) if os.environ.get("PYTEST_DEBUG"): err: IO[str] = sys.stderr encoding: str = getattr(err, "encoding", "utf8") try: err = open( os.dup(err.fileno()), mode=err.mode, buffering=1, encoding=encoding, ) except Exception: pass self.trace.root.setwriter(err.write) self.enable_tracing() # Config._consider_importhook will set a real object if required. self.rewrite_hook = _pytest.assertion.DummyRewriteHook() # Used to know when we are importing conftests after the pytest_configure stage. self._configured = False def parse_hookimpl_opts(self, plugin: _PluggyPlugin, name: str): # pytest hooks are always prefixed with "pytest_", # so we avoid accessing possibly non-readable attributes # (see issue #1073). if not name.startswith("pytest_"): return # Ignore names which can not be hooks. if name == "pytest_plugins": return method = getattr(plugin, name) opts = super().parse_hookimpl_opts(plugin, name) # Consider only actual functions for hooks (#3775). if not inspect.isroutine(method): return # Collect unmarked hooks as long as they have the `pytest_' prefix. if opts is None and name.startswith("pytest_"): opts = {} if opts is not None: # TODO: DeprecationWarning, people should use hookimpl # https://github.com/pytest-dev/pytest/issues/4562 known_marks = {m.name for m in getattr(method, "pytestmark", [])} for name in ("tryfirst", "trylast", "optionalhook", "hookwrapper"): opts.setdefault(name, hasattr(method, name) or name in known_marks) return opts def parse_hookspec_opts(self, module_or_class, name: str): opts = super().parse_hookspec_opts(module_or_class, name) if opts is None: method = getattr(module_or_class, name) if name.startswith("pytest_"): # todo: deprecate hookspec hacks # https://github.com/pytest-dev/pytest/issues/4562 known_marks = {m.name for m in getattr(method, "pytestmark", [])} opts = { "firstresult": hasattr(method, "firstresult") or "firstresult" in known_marks, "historic": hasattr(method, "historic") or "historic" in known_marks, } return opts def register( self, plugin: _PluggyPlugin, name: Optional[str] = None ) -> Optional[str]: if name in _pytest.deprecated.DEPRECATED_EXTERNAL_PLUGINS: warnings.warn( PytestConfigWarning( "{} plugin has been merged into the core, " "please remove it from your requirements.".format( name.replace("_", "-") ) ) ) return None ret: Optional[str] = super().register(plugin, name) if ret: self.hook.pytest_plugin_registered.call_historic( kwargs=dict(plugin=plugin, manager=self) ) if isinstance(plugin, types.ModuleType): self.consider_module(plugin) return ret # ...略 def import_plugin(self, modname: str, consider_entry_points: bool = False) -> None: """Import a plugin with ``modname``. If ``consider_entry_points`` is True, entry point names are also considered to find a plugin. """ # Most often modname refers to builtin modules, e.g. "pytester", # "terminal" or "capture". Those plugins are registered under their # basename for historic purposes but must be imported with the # _pytest prefix. assert isinstance(modname, str), ( "module name as text required, got %r" % modname ) if self.is_blocked(modname) or self.get_plugin(modname) is not None: return importspec = "_pytest." + modname if modname in builtin_plugins else modname self.rewrite_hook.mark_rewrite(importspec) if consider_entry_points: loaded = self.load_setuptools_entrypoints("pytest11", name=modname) if loaded: return try: __import__(importspec) except ImportError as e: raise ImportError( 'Error importing plugin "{}": {}'.format(modname, str(e.args[0])) ).with_traceback(e.__traceback__) from e except Skipped as e: self.skipped_plugins.append((modname, e.msg or "")) else: mod = sys.modules[importspec] self.register(mod, modname)这里重写了父类的register,如下,重写函数中也调用了父类的register函数父类的register函数中,调用了self.parse_hookimpl_opts(plugin, name),这个函数在当前类即PytestPluginManager类中重写了,所以,运行时调用的是重写后的PytestPluginManager.parse_hookimpl_opts(plugin, name),该函数中,也会调用PluginManager.parse_hookimpl_opts函数,如果调用该父类函数获取返回值为None,并且函数名称以pytest__开头,则标记返回结果值为 {},这样PluginManager.register函数中,hookimpl_opts is not None表达式值为真,会继续往下执行代码,将没有使用hookimpl标记的,以pytest__打头的函数添加为对应hook函数的函数实现体。pluggy/manage.py PluginManager类class PluginManager(object): # ...略 def register(self, plugin, name=None): """ Register a plugin and return its canonical name or None if the name is blocked from registering. Raise a ValueError if the plugin is already registered. """ plugin_name = name or self.get_canonical_name(plugin) if plugin_name in self._name2plugin or plugin in self._plugin2hookcallers: if self._name2plugin.get(plugin_name, -1) is None: return # blocked plugin, return None to indicate no registration raise ValueError( "Plugin already registered: %s=%s\n%s" % (plugin_name, plugin, self._name2plugin) ) # XXX if an error happens we should make sure no state has been # changed at point of return self._name2plugin[plugin_name] = plugin # register matching hook implementations of the plugin self._plugin2hookcallers[plugin] = hookcallers = [] for name in dir(plugin): hookimpl_opts = self.parse_hookimpl_opts(plugin, name) if hookimpl_opts is not None: normalize_hookimpl_opts(hookimpl_opts) method = getattr(plugin, name) hookimpl = HookImpl(plugin, plugin_name, method, hookimpl_opts) hook = getattr(self.hook, name, None) if hook is None: hook = _HookCaller(name, self._hookexec) setattr(self.hook, name, hook) elif hook.has_spec(): self._verify_hook(hook, hookimpl) hook._maybe_apply_history(hookimpl) hook._add_hookimpl(hookimpl) hookcallers.append(hook) return plugin_name_pytest/config/__init__.py main函数定义获取Config对象config后,通过调用config.hook.pytest_cmdline_main,从上到下,执行以下.py脚本中的pytest_cmdline_main函数_pytest/setupplan.py _pytest/setuponly.py _pytest/mark/__init__.py _pytest/cacheprovider.py _python/python _python/helpconfig _python/main.py_python/main.py该文件中的 pytest_cmdline_main函数,负责执行测试def pytest_cmdline_main(config): return wrap_session(config, _main) def wrap_session( config: Config, doit: Callable[[Config, "Session"], Optional[Union[int, ExitCode]]] ) -> Union[int, ExitCode]: """Skeleton command line program""" session = Session.from_config(config) session.exitstatus = ExitCode.OK initstate = 0 try: try: config._do_configure() initstate = 1 config.hook.pytest_sessionstart(session=session) initstate = 2 session.exitstatus = doit(config, session) or 0 except UsageError: session.exitstatus = ExitCode.USAGE_ERROR raise except Failed: session.exitstatus = ExitCode.TESTS_FAILED except (KeyboardInterrupt, exit.Exception): excinfo = _pytest._code.ExceptionInfo.from_current() exitstatus = ExitCode.INTERRUPTED # type: Union[int, ExitCode] if isinstance(excinfo.value, exit.Exception): if excinfo.value.returncode is not None: exitstatus = excinfo.value.returncode if initstate < 2: sys.stderr.write( "{}: {}\n".format(excinfo.typename, excinfo.value.msg) ) config.hook.pytest_keyboard_interrupt(excinfo=excinfo) session.exitstatus = exitstatus except: # noqa session.exitstatus = ExitCode.INTERNAL_ERROR excinfo = _pytest._code.ExceptionInfo.from_current() try: config.notify_exception(excinfo, config.option) except exit.Exception as exc: if exc.returncode is not None: session.exitstatus = exc.returncode sys.stderr.write("{}: {}\n".format(type(exc).__name__, exc)) else: if excinfo.errisinstance(SystemExit): sys.stderr.write("mainloop: caught unexpected SystemExit!\n") finally: # Explicitly break reference cycle. excinfo = None # type: ignore session.startdir.chdir() if initstate >= 2: try: config.hook.pytest_sessionfinish( session=session, exitstatus=session.exitstatus ) except exit.Exception as exc: if exc.returncode is not None: session.exitstatus = exc.returncode sys.stderr.write("{}: {}\n".format(type(exc).__name__, exc)) config._ensure_unconfigure() return session.exitstatus def _main(config: Config, session: "Session") -> Optional[Union[int, ExitCode]]: """ default command line protocol for initialization, session, running tests and reporting. """ config.hook.pytest_collection(session=session) config.hook.pytest_runtestloop(session=session) if session.testsfailed: return ExitCode.TESTS_FAILED elif session.testscollected == 0: return ExitCode.NO_TESTS_COLLECTED return None def pytest_collection(session): return session.perform_collect() def pytest_runtestloop(session): if session.testsfailed and not session.config.option.continue_on_collection_errors: raise session.Interrupted( "%d error%s during collection" % (session.testsfailed, "s" if session.testsfailed != 1 else "") ) if session.config.option.collectonly: return True for i, item in enumerate(session.items): # session.items 获取值为为pytest测试脚本中的测试函数 nextitem = session.items[i + 1] if i + 1 < len(session.items) else None item.config.hook.pytest_runtest_protocol(item=item, nextitem=nextitem) if session.shouldfail: raise session.Failed(session.shouldfail) if session.shouldstop: raise session.Interrupted(session.shouldstop) return Trueitem.config.hook.pytest_runtest_protocol执行顺序如下,从上到下执行各个脚本中对应的函数pytest_runtest_protocol warnings.py pytest_runtest_protocol assertion/__init__.py pytest_runtest_protocol faulthandler pytest_runtest_protocol unittest.py pytest_runtest_protocol runner.py_pytest/runner.pypytest_runtest_protocol 负责执行pytest协议def pytest_runtest_protocol(item, nextitem): item.ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location) runtestprotocol(item, nextitem=nextitem) item.ihook.pytest_runtest_logfinish(nodeid=item.nodeid, location=item.location) return True def runtestprotocol(item, log=True, nextitem=None): hasrequest = hasattr(item, "_request") if hasrequest and not item._request: item._initrequest() rep = call_and_report(item, "setup", log) reports = [rep] if rep.passed: if item.config.getoption("setupshow", False): show_test_item(item) if not item.config.getoption("setuponly", False): reports.append(call_and_report(item, "call", log)) reports.append(call_and_report(item, "teardown", log, nextitem=nextitem)) # after all teardown hooks have been called # want funcargs and request info to go away if hasrequest: item._request = False item.funcargs = None return reports
vue中引用第三方js总结实践环境win10Vue 2.9.6本文以引用jsmind为例,讲解怎么在vue中引用第三方js类库基础示例1、把下载好的js类库放在src/static目录下2、在src/index.html入口文件中通过script引用需要使用的js(参见以下第8-10行代码)<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1.0"> <title>测试管理平台</title> <link rel="icon" type="image/x-icon" href="/static/favicon.png"> <script src="./static/jsmind0.4.6/js/jsmind.js"></script> <script src="./static/jsmind0.4.6/js/jsmind.draggable.js"></script> <script src="./static/jsmind0.4.6/js/jsmind.screenshot.js"></script> </head> <body> <div id="app"></div> <!-- built files will be auto injected --> </body> </html>3、在webpack.base.config.js(webpack基础配置文件,包含开发和生产环境的通用配置)中,增加一个externals配置(参见以下带背景色,加粗,倾斜的内容)3、在webpack.base.config.js(webpack基础配置文件,包含开发和生产环境的通用配置)中,增加一个externals配置(参见以下第17-19行代码) module.exports = { ...略 node: { // prevent webpack from injecting useless setImmediate polyfill because Vue // source contains it (although only uses it if it's native). setImmediate: false, // prevent webpack from injecting mocks to Node native modules // that does not make sense for the client dgram: "empty", fs: "empty", net: "empty", tls: "empty", child_process: "empty" }, externals: { jsmind: "jsMind" // 属性名称:字符串 // 该字符串将用于检索一个同该字符串同名的变量,本例中 将用jsMind来检索一个全局的jsMind变量,即需要的类库 } }; 注意:之所以配置在externals中,是因为该配置项配置提供了「从输出的 bundle 中排除依赖」的方法,即防止程序将 import 的包(package) 打包到 bundle 中。这里,我们不需要打包第三方库,仅需要在运行时(runtime)从外部获取这些扩展依赖(external dependencies)。4、引用通过import关键字根据实际需要进行全局、局部引用,如下,进行局部引用<template> <div class="jm-div"> <div id="jsmindContainer"></div> </div> </template> <script> import jsMind from "jsmind" // from 类库名称 import 属性名称 </script> 5、导入css文件一般情况下,引用第三方js的同时,还需要引入对应的css文件,引入方式分以下两种:全局引入在main.js文件中通过import引入,例如import "../static/jsmind0.4.6/style/jsmind.css"局部引入非全局引入,比如在.vue等组件(例中组件存放路径为src/views/example.vue)<style scoped> @import "../../static/jsmind0.4.6/style/jsmind.css"; // 这个分号一定要写,要不会报错 </style>参考链接https://webpack.docschina.org/configuration/externals
日志打印之自定义logger handler#实践环境WIN 10Python 3.6.5 #实践代码 handler.py#!/usr/bin/env python # -*- coding:utf-8 -*- ''' @Author : shouke ''' import logging import logging.config class MyLogHandler(logging.Handler, object): """ 自定义日志handler """ def __init__(self, name, other_attr=None, **kwargs): logging.Handler.__init__(self) print('初始化自定义日志处理器:', name) print('其它属性值:', other_attr) def emit(self, record): """ emit函数为自定义handler类时必重写的函数,这里可以根据需要对日志消息做一些处理,比如发送日志到服务器 发出记录(Emit a record) """ try: msg = self.format(record) print('获取到的消息为:', msg) for item in dir(record): if item in ['process', 'processName', 'thread', 'threadName']: print(item, ':', getattr(record, item)) except Exception: self.handleError(record) # 测试 logging.basicConfig() logger = logging.getLogger("logger") logger.setLevel(logging.INFO) my_log_handler = MyLogHandler('LoggerHandler') logger.addHandler(my_log_handler) logger.info('hello,shouke') 运行handler.py,结果输出如下 初始化自定义日志处理器: LoggerHandler 其它属性值: None 获取到的消息为: hello,shouke process : 27932 processName : MainProcess thread : 45464 threadName : MainThread INFO:logger:hello,shouke#通过字典配置添加自定义handlermytest.py(与handler.py位于同一层级目录)#!/usr/bin/env python # -*- coding:utf-8 -*- # # # ''' # @CreateTime: 2020/12/29 14:08 # @Author : shouke # ''' # import logging import logging.config LOGGING_CONFIG = { "version": 1, "disable_existing_loggers": False, "formatters": { "default": { "format":"%(asctime)s %(filename)s %(lineno)s %(levelname)s : %(message)s", }, "plain": { "format": "%(message)s", } }, "handlers": { "customer_handler":{ "class":"study.MyLogHandler", "formatter":"default", # 注意,class,formatter,level,filters之外的参数将默认传递给由class指定类的构造函数 "name":"LoggerHandler", "other_attr":"something others" }, "console": { "class": "logging.StreamHandler", "formatter": "default", }, }, "loggers": { "customer_logger":{ "handlers": ["customer_handler", "console"], "level": logging.INFO, "propagate": False, } } } logging.config.dictConfig(LOGGING_CONFIG) logger = logging.getLogger('customer_logger') logger.info('hello,shouke') 运行mytest.py,输出结果如下 初始化自定义日志处理器: LoggerHandler 其它属性值: something others 获取到的消息为: 2021-01-01 17:51:54,661 mytest.py 48 INFO : hello,shouke process : 36280 processName : MainProcess thread : 37316 threadName : MainThread INFO:logger:hello,shouke 2021-01-01 17:51:54,661 mytest5.py 48 INFO : hello,shouke。##问题:为什么mytest.py中的代码,不能放在study.py中? 如下,在study.py模块,MyLogHandler类之后追加下述代码LOGGING_CONFIG = { "version": 1, "disable_existing_loggers": False, "formatters": { "default": { "format":"%(asctime)s %(filename)s %(lineno)s %(levelname)s : %(message)s", } }, "handlers": { "customer_handler":{ "class":"study.MyLogHandler", "formatter":"default", "name":"LoggerHandler", "other_attr":"something others" } }, "loggers": { "customer_logger":{ "handlers": ["customer_handler"], "level": logging.INFO, "propagate": False, } } } logging.config.dictConfig(LOGGING_CONFIG) logger = logging.getLogger('customer_logger') logger.info('hello,shouke') 运行mytest.py,输出结果如下 初始化自定义日志处理器: LoggerHandler 其它属性值: something others 获取到的消息为: 2021-01-09 10:48:24,090 study.py 66 INFO : hello,shouke process : 17276 processName : MainProcess thread : 14516 threadName : MainThread 初始化自定义日志处理器: LoggerHandler 其它属性值: something others 获取到的消息为: 2021-01-09 10:48:24,090 study.py 66 INFO : hello,shouke process : 17276 processName : MainProcess thread : 14516 threadName : MainThread如上,可以看到,自定义类构造函数被重复执行,日志消息被重复处理 ##原因分析logging.config.dictConfig(config)函数内部调用了DictConfigurator(config).configure(),而configure函数内DictConfigurator部,根据incremental,handlers等当前日志配置,被执行的分支代码中,会调用DictConfigurator类实例的configure_handler()方法,该方法中,根据当前配置,又会再次调用DictConfigurator类实例的resolve(self, s)方法,参数s接收handler中class配置项目的值。具体代码如下:LOGGING_CONFIG = { "version": 1, "disable_existing_loggers": False, "formatters": { "default": { "format":"%(asctime)s %(filename)s %(lineno)s %(levelname)s : %(message)s", } }, "handlers": { "customer_handler":{ "class":"study.MyLogHandler", "formatter":"default", "name":"LoggerHandler", "other_attr":"something others" } }, "loggers": { "customer_logger":{ "handlers": ["customer_handler"], "level": logging.INFO, "propagate": False, } } } logging.config.dictConfig(LOGGING_CONFIG) logger = logging.getLogger('customer_logger') logger.info('hello,shouke') 运行mytest.py,输出结果如下 初始化自定义日志处理器: LoggerHandler 其它属性值: something others 获取到的消息为: 2021-01-09 10:48:24,090 study.py 66 INFO : hello,shouke process : 17276 processName : MainProcess thread : 14516 threadName : MainThread 初始化自定义日志处理器: LoggerHandler 其它属性值: something others 获取到的消息为: 2021-01-09 10:48:24,090 study.py 66 INFO : hello,shouke process : 17276 processName : MainProcess thread : 14516 threadName : MainThread至此,logging.config.dictConfig(config)放自定义日志处理类模块中,导致自定义日志处理类重复被执行的原因已经清楚了。 configure函数内部,根据incremental,handlers等当前日志配置,被执行的分支代码中,也可能执行DictConfigurator类实例的configure_formatter()方法,类似的,该方法中也会调用一个名为_resolve的方法,具体代码如下def _resolve(name): """Resolve a dotted name to a global object.""" name = name.split('.') used = name.pop(0) found = __import__(used) for n in name: used = used + '.' + n try: found = getattr(found, n) except AttributeError: __import__(used) found = getattr(found, n) return found如果自定义Formatter,把logging.config.dictConfig(config)放自定义日志格式化类模块中,也可能导致重复执行
Django 多数据库配置与使用总结 #实践环境Win 10 Python 3.5.4 Django-2.0.13.tar.gz官方下载地址:https://www.djangoproject.com/download/2.0.13/tarball/ #需求描述项目开发中,部分业务功能的实现,需要跨数据库查询,并且想通过Django自带ORM来实现 #解决方案为Django配置多数据库,具体操作步骤如下: 1、修改项目settings.py DATABASES配置打开settings.py ,修改DATABASES配置—-为需要连接的数据库新增配置(本例中以mysql数据库配置为例,假设需要链接两个数据库) # ...略 DATABASES = { # 默认数据库配置 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'database_name', # 自定义数据库名称 'USER': 'db_username', 'PASSWORD': 'db_user_password', 'HOST': '127.0.0.1', 'PORT': '3306', 'CONN_MAX_AGE': 30, 'OPTION': { 'init_command': 'SET default_storage_engine=INNODB' } }, 'secondDb': { #secondDb代表第二个数据库的配置#该名称可自定义 'ENGINE': 'django.db.backends.mysql', 'NAME': 'second_db_name', 'USER': 'db_username ', 'PASSWORD': 'db_user_password', 'HOST': '127.0.0.1', 'PORT': '3306', 'CONN_MAX_AGE': 30, 'OPTION': { 'init_command': 'SET default_storage_engine=INNODB' }, }, #...略 }为了方便描述下文内容,这里暂且把上述的 default,secondDb等称为“数据库配置结点” 2、修改项目settings.py DATABASE_ROUTERS路由配置 打开settings.py ,修改DATABASE_ROUTERS配置(如果不存在,则新增该配置项) DATABASES = {#...略} DATABASE_ROUTERS = ['Package.database_routers.DatabaseRouters'] 说明:Package: 路由规则文件所在包(一般是项目根目录下,与项目同名的包目录,或者app根目录(包目录)) database_routers: 定义路由规则的.py文件名称,该文件名称可以自定义 DatabaseRouters:上述.py中,定义路由规则的类名称,该类名可自定义 DATABASE_ROUTERS为列表,所以,可以配置多个不同的路由 3、建立app应用和数据库的映射关系在settings.py中新增app和数据库的映射关系(如果没有的话),即针对指定app,配置其需要连接的数据库 APP_DATABASE_MAPPING = { # 映射配置名称,可自定义'mysite': ' defualt', # 格式说明 'app名称':'数据库配置结点' # 注意,这里的“app名称”,必须在settings.INSTALLED_APPS中已注册,“数据库配置结点”要同 settings.DATABASE 保持对应,两者皆不能随便自定义'myblog': 'secondDb',} 4、配置不允许执行migration操作的app(确切的说是app的model)APPS_NOT_ALLOW_MIGRATE = ['myblog'] # 配置 app-label 为 myblog 的 model 不允许执行migration操作 5、创建数据库路由规则在项目根目录下,与项目同名的目录下(与 settings.py 文件位于同一目录)创建路由规则.py文件(例中为 database_routers.py ) 项目代码工程结构目录如下TMP|--TMP |--settings.py |--database_routers.py|--manage.py|--...略|--mysite|--myblog database_routers.py内容如下: #!/usr/bin/env python # -*- coding:utf-8 -*- ''' @Author :shouke ''' from django.conf import settings DATABASE_MAPPING = settings.APP_DATABASE_MAPPING DATABASES_NOT_ALLOW_MIGRATE = settings.APPS_NOT_ALLOW_MIGRATE class DatabaseRouters(object): def db_for_read(self, model, **hints): """"指定mode进行读取操作时应使用的数据库, 如果返回None则表示使用默认数据库""" if model._meta.app_label in DATABASE_MAPPING: return DATABASE_MAPPING[model._meta.app_label] return None def db_for_write(self, model, **hints): """指定mode进行写入操作时应使用的数据库, 如果返回None则表示使用默认数据库""" if model._meta.app_label in DATABASE_MAPPING: return DATABASE_MAPPING[model._meta.app_label] return None def allow_relation(self, obj1, obj2, **hints): """控制是否允许obj1和obj2建立关联关系,供外键和多对多操作使用,如果返回True则表示允许,如果返回False则阻止建立关联关系,如果返回None则表示仅允许在相同数据库内的对象建立关联关系(备注:笔者亲测,执行save()保存包含关联外键对象,或者通过某个对象获取关联外键对象,该函数都不会被执行 """ db_obj1 = DATABASE_MAPPING.get(obj1._meta.app_label) db_obj2 = DATABASE_MAPPING.get(obj2._meta.app_label) if db_obj1 and db_obj2: if db_obj1 == db_obj2: return True else: return False return None def allow_migrate(self, db, app_label, model=None, **hints): """指定是否允许在别名为db的数据库上运行迁移操作。如果允许运行,则返回True;否则返回False、None""" if app_labelin DATABASES_NOT_ALLOW_MIGRATE: return False else: return True6、创建mode表在对应app中,创建对应数据表的models,不过,需要注意的是,需要根据上述路由规则,及实际需求,考虑是否为model指定app_label,如果不指定,在默认数据库上执行相关操作。 以下为样例数据表 modeclass BugType(models.Model): id = models.AutoField(primary_key=True, verbose_name='自增id') bug_type = models.CharField(max_length=50, verbose_name='bug类型') class Meta: db_table = 'tb_bug_type' app_label = 'mysite' verbose_name = 'bug类型表' verbose_name_plural = verbose_name class SprintBug(models.Model): id = models.AutoField(primary_key=True, verbose_name='自增id') name = models.CharField(max_length=10, verbose_name='bug名称') but_type_id = models.IntegerField() class Meta: db_table = 'tb_sprint_bug' app_label = 'myblog' verbose_name = '迭代bug表' verbose_name_plural = verbose_name 说明:这里假设SprintBug Model对应数据表为项目中需要跨数据库查询的且已存在的数据表,所以,希望在当前项目中执行migrate操作操作时,不对它进行创建、或者修改其数据表,仅供ORM操作使用,为了达到这个目的,需要显示指定 db_table 为该据表在数据库中的表名,并且显示指定app_label值,并确保该 app_label 值存在上述settings.APPS_NOT_ALLOW_MIGRATE列表中(根据上述路由规则,app_label值存在settings.APPS_NOT_ALLOW_MIGRATE列表中的mode不允许执行migration操作)。 7、执行数据库迁移操作如果还没执行迁移操作,需要先执行迁移操作,以便创建、修改model对应的数据库表python manage.py makemigrationsappNamepython manage.pymigrate 说明:如果希望执行migrate操作时,对应app对应model的migrations操作,在指定数据库中执行,则需要使用 --database 选项,否则,没指定app_label的model对应数据表相关操作将在默认数据库中执行。 例子:把mysite的数据库迁移操作放到 myAppDb1 代表的数据库中执行。python manage.py makemigrationsmysitepython manage.py--database=myAppDb1 # 注意,--database选项值实为settings.py中目标数据库的“数据库配置结点”,且该选项值不能加引号、双引号,否则会报错 这样以后,其它所有的创建、查询、删除等操作就和普通一样操作就可以了,无需再使用类似models.User.objects.using(dbname).all()这样的方式来操作。 #参考链接https://docs.djangoproject.com/en/2.1/topics/db/multi-db/
跨域访问POST请求需预先发送option请求问题处理方案 实践环境Win 10 Python 3.5.4 Django-2.0.13.tar.gz官方下载地址:https://www.djangoproject.com/download/2.0.13/tarball/ 问题描述使用POST请求访问Django后端API时自动先发送option请求,然后才执行POST请求 原因分析跨域资源共享(CORS)机制导致。 浏览器将CORS请求分成两类:简单请求(simple request)和非简单请求(not-so-simple request)。只要同时满足以下两大条件,就属于简单请求,否则就是非简单请求。1、请求方法是以下三种方法之一:HEADGETPOST 2、HTTP的头信息不超出以下几种字段:AcceptAccept-LanguageContent-LanguageLast-Event-IDContent-Type(其值只限于application/x-www-form-urlencoded、multipart/form-data、text/plain) 当请求存在跨域资源共享(CORS),并且是非简单请求,就会触发CORS的预检请求(preflight);"预检"请求用的请求方法是OPTIONS,如果请求OK,才会再次发送目标操作请求。 实际开发过程中,后台采用token检验机制,前台发送请求必须将token放到Request Header中,那么就需要传输自定义Header信息、或则请求头中的Content-Type设置为"application/json",就会形成非简单请求。 但是很多时候,我们并不希望浏览器这么做,重复的请求,一方面是增加服务器压力,另一方面,相当于增加了请求响应时间。 解决方法 一种比较合适的解决方法就是增加响应头“Access-Control-Max-Age”来控制浏览器在多长时间内(单位为秒)无需在请求时发送预检请求,从而减少不必要的预检请求。 中间件代码如下:#!/usr/bin/env python# -*- coding:utf-8 -*- __author__ = '授客' from django.utils.deprecation import MiddlewareMixin class PublicAccessControlMiddleware(MiddlewareMixin): def process_request(self, request): pass def process_response(self, request, response): response['Access-Control-Max-Age'] = 86400 #3600*24h = 86400秒,即告诉浏览器,缓存预检结果24小时,即针对同一URL请求,发送第一个OPTION请求往后24小时内不再发送OPTION请求。 return response 参考链接https://juejin.im/post/5c889e136fb9a049d37ff768
注意:html中需要在head元素中添加<meta charset="UTF-8">,以防生成的pdf中文乱码,另外,确保系统中有中文字体,否则也会出现乱码,如下: 5、 后端接口仅保留关键代码#!/usr/bin/env python# -*- coding:utf-8 -*- __author__ = '授客' from rest_framework.views import APIViewfrom rest_framework.response import Responsefrom rest_framework import status from backend.models import SprintTestReport from django.utils import timezonefrom django.http import FileResponse from django.conf import settingsimport pdfkitimport jsonimport base64import uuidimport osimport logging logger = logging.getLogger('mylogger')class SprintTestreportPDFAPIView(APIView):'''迭代测试报告pdf文件下载''' @staticmethoddef convert_related_plans_to_html(self, related_plans):'''转换报告相关联的测试计划数据格式为html格式数据,返回转换后的数据''' result = ''tr = '''<tr><td><div>{id}</div></td><td><div>{name}</div></td><td><div>{begin_time}</div></td><td><div>{start_time}</div></td><td><div>{end_time}</div></td><td><div>{finish_time}</div></td><td><div>{groups}</div></td><td><div>{environment}</div></td></tr>''' for related_plan in related_plans:result += tr.format(**related_plan) return result ...略 def post(self, request, format=None):'''下载pdf格式报告''' result = {}try:data = request.datareport_id = data.get('report_id') echart_base64_info_dict = data.get('echart_base64_info') # 读取迭代测试报告html模板report_html_str = '' # 存放html格式的迭代测试报告 current_dir, tail = os.path.split(os.path.abspath(__file__))template_filepath = os.path.normpath(os.path.join(current_dir, 'sprint_test_report/sprint_test_report_template.html'))with open(template_filepath, 'r', encoding='utf-8') as f:for line in f:report_html_str += line # 读取报告数据sprint_report = SprintTestReport.objects.filter(id=report_id)if sprint_report.first():try:...略report_data = sprint_report.values('title','introduction', 'related_plans', 'test_scope', 'individual_test_statistics', 'individual_dev_statistics', 'product_test_statistics', 'conclusion', 'suggestion', 'risk_analysis')[0] # 替换测试计划related_plans = json.loads(report_data['related_plans'])related_plans = self.convert_related_plans_to_html(related_plans)report_html_str = report_html_str.replace('${relatedPlans}', related_plans) ...略 # 生成echart图表图片time_str = timezone.now().strftime('%Y%m%d')uuid_time_str = str(uuid.uuid1()).replace('-', '') + time_str file_name_dict = {}for key, value in echart_base64_info_dict.items():data_type, base64_data = value.split(',') # value 数据格式 data:image/png;base64,base64编码数据file_suffix = '.' + data_type.split('/')[1].split(';')[0]file_name = key + uuid_time_str + file_suffixfile_name_dict[key] = file_name file_path = os.path.normpath(os.path.join(current_dir, 'sprint_test_report/%s' % file_name))with open(file_path, 'wb') as f:imgdata = base64.b64decode(base64_data)f.write(imgdata) # 替换 echart图表for key in echart_base64_info_dict.keys():# report_html_str = report_html_str.replace('${%s}' % key, '%s/sprint_test_report/%s' % (current_dir, file_name_dict[key])) # 注意,这里,迭代测试报告模板中的变量名称被设置为和key一样的值,所以可以这么操作report_html_str = report_html_str.replace('${%s}' % key,os.path.normpath(os.path.join(current_dir, 'sprint_test_report/%s' % file_name_dict[key]))) # 生成pdf文档time_str = timezone.now().strftime('%Y%m%d')file_name = str(uuid.uuid1()).replace('-', '') + time_str + '.pdf'config = pdfkit.configuration(wkhtmltopdf=settings.WKHTMLTOPDF)file_dir = settings.MEDIA_ROOT + '/sprint/testreport'options = {'dpi': 300, 'image-dpi':600, 'page-size':'A3', 'encoding':'UTF-8', 'page-width':'1903px'} pdfkit.from_string(report_html_str, '%s/%s' % (file_dir, file_name), configuration=config, options=options) file_absolute_path = '%s/%s' % (file_dir, file_name)# 删除生成的图片文件for key in echart_base64_info_dict.keys():os.remove('%s/sprint_test_report/%s' % (current_dir, file_name_dict[key])) # 返回数据给前端if os.path.exists(file_absolute_path) and os.path.isfile(file_absolute_path):file = open(file_absolute_path, 'rb')file_response = FileResponse(file)file_response['Content-Type']='application/octet-stream'file_response['Content-Disposition']='attachment;filename={}.pdf'.format(report_data['title'] ) # 不知道为啥,前端获取不到请求头Content-Dispositionreturn file_responseelse:result['msg'] = '生成pdf报告失败'result['success'] = Falsereturn Response(result, status.HTTP_400_BAD_REQUEST)except Exception as e:result['msg'] = '%s' % eresult['success'] = Falsereturn Response(result, status.HTTP_500_INTERNAL_SERVER_ERROR)else:result['msg'] = '生成迭代测试报告失败,报告不存在'result['success'] = Falsereturn Response(result, status.HTTP_400_BAD_REQUEST)except Exception as e:result['msg'] = '%s' % eresult['success'] = Falsereturn Response(result, status.HTTP_500_INTERNAL_SERVER_ERROR) 导出效果(部分截图)
Django不通过外键实现多表关联查询 测试环境Win 10 Python 3.5.4 Django-2.0.13.tar.gz 需求不通过外键,使用django orm语法实现多个表之间的关联查询,类似如下sql的查询效果:SELECT tb_project_version.*, tb_sprint.name, tb_project.nameFROM tb_project_versionJOIN tb_sprint ON tb_sprint.id=tb_project_version.sprint_idJOIN tb_project ON tb_project.id=tb_project_version.project_id 数据表Model设计 class Sprint(models.Model): id = models.AutoField(primary_key=True, verbose_name='自增id') name = models.CharField(max_length=50, verbose_name='迭代名称') ...略 class Meta: db_table = 'tb_sprint' verbose_name = '产品迭代表' verbose_name_plural = verbose_name class Project(models.Model): id = models.AutoField(primary_key=True, verbose_name='自增id') name = models.CharField(max_length=50, verbose_name='项目名称') ...略 class Meta: db_table = 'tb_project' verbose_name = '项目表' verbose_name_plural = verbose_name class ProjectVersion(models.Model): id = models.AutoField(primary_key=True, verbose_name='自增id') name = models.CharField(max_length=50, verbose_name='版本名称') project_id = models.IntegerField(verbose_name='关联的项目ID') sprint_id = models.IntegerField(verbose_name='关联的迭代ID') ...略 class Meta: db_table = 'tb_project_version' verbose_name = '项目版本表' verbose_name_plural = verbose_name 实现方法1-通过extra api函数实现 如下,带背景色部分的内容为核心 serializers.py#!/usr/bin/env python# -*- coding:utf-8 -*- from rest_framework import serializersfrom backend.models import ProjectVersion # ProjectVersion model 序列化器class ProjectVersionSerializer(serializers.ModelSerializer): project = serializers.CharField(required=True) sprint = serializers.CharField(required=True) class Meta: model = ProjectVersion fields = '__all__' read_only_fields = ['project', 'sprint'] 说明:如上,如果使用了django rest framework序列化,则需要为其序列化器添加model中不存在的字段,否则序列化后还是看不到对应的目标字段 project_version_views.py#!/usr/bin/env python# -*- coding:utf-8 -*- __author__ = '授客' from rest_framework.views import APIViewfrom rest_framework.response import Responsefrom rest_framework import status from backend.models import ProjectVersionfrom backend.serializers import ProjectVersionSerializer class ProjectVersionListAPIView(APIView): ''' 项目视图-版本管理 ''' # 查询列表数据 def get(self, request, format=None): result = {} try: params = request.GET page_size = int(params.get('pageSize')) page_no = int(params.get('pageNo')) name = params.get('name') project_id = params.get('projectId') sort = params.get('sort') if sort: sort_list = sort.split(',') else: sort_list = ['-id'] startIndex = (page_no - 1) * page_size endIndex = startIndex + page_size filters = {'is_delete':0} if name: filters['name__startswith'] = name if project_id: filters['project_id'] = project_id projectVersions = ProjectVersion.objects.filter(**filters).extra( select={'project': 'SELECT tb_project.name FROM tb_project WHERE tb_project.id = tb_project_version.project_id', 'sprint':'SELECT tb_sprint.name FROM tb_sprint WHERE tb_sprint.id = tb_project_version.sprint_id'}, )rows = projectVersions.order_by(*sort_list)[startIndex:endIndex] rows = ProjectVersionSerializer(rows, many=True).data total = projectVersions.count() result['msg'] = '获取成功' result['success'] = True result['data'] = {} result['data']['rows'] = rows result['data']['total'] = total return Response(result, status.HTTP_200_OK) except Exception as e: result['msg'] = '%s' % e result['success'] = False return Response(result, status.HTTP_500_INTERNAL_SERVER_ERROR) 说明:projectVersions.order_by(*sort_list)[startIndex:endIndex] 等价于 SELECT (SELECT tb_project.name FROM tb_project WHERE tb_project.id = tb_project_version.project_id) AS `project`,(SELECT tb_sprint.name FROM tb_sprint WHERE tb_sprint.id = tb_project_version.sprint_id) AS `sprint`,`tb_project_version`.`id`,`tb_project_version`.`name`,`tb_project_version`.`project_id`,`tb_project_version`.`sprint_id`,...略FROM `tb_project_version`WHERE `tb_project_version`.`is_delete` = 0ORDER BY `tb_project`.`id` DESC LIMIT 10 # 假设startIndex=0, endIndex=10 projectVersions.count()等价于SELECT COUNT(*) AS `__count` FROM `tb_project_version`WHERE `tb_project_version`.`is_delete` = 0 上述查询代码的另一种实现projectVersions = Project.objects.filter(**filters).extra(select={'project:'tb_project.name', 'sprint':' tb_sprint.name',tables=['tb_project', 'tb_sprint'],where=['tb_project.id=tb_project_version.project_id', 'tb_sprint.id = tb_project_version.sprint_id'])rows = projectVersions.order_by(*sort_list)[startIndex:endIndex]rows = ProjectVersionSerializer(rows, many=True).datatotal = projectVersions.count() projectVersions.order_by(*sort_list)[startIndex:endIndex] 等价于 SELECT (tb_project.name) AS `project`,(tb_sprint.name) AS `sprint`,`tb_project_version`.`id`,`tb_project_version`.`name`,`tb_project_version`.`project_id`,`tb_project_version`.`sprint_id`,...略FROM `tb_project_version`WHERE `tb_project_version`.`is_delete` = 0 AND (tb_project.id=tb_project_version.project_id) AND (tb_sprint.id = tb_project_version.sprint_id)ORDER BY `tb_project`.`id` DESC LIMIT 10 # 假设startIndex=0, endIndex=10 projectVersions.count()等价于SELECT COUNT(*) AS `__count` FROM `tb_project_version` , `tb_project` , `tb_sprint` WHERE `tb_project_version`.`is_delete` = 0 AND (tb_project.id=tb_project_version.project_id) AND (tb_sprint.id = tb_project_version.sprint_id) 实现方法2-通过django rest framework实现serializers.py#!/usr/bin/env python# -*- coding:utf-8 -*- from rest_framework import serializersfrom backend.models import ProjectVersionfrom backend.models import Sprintfrom backend.models import Project # ProjectVersion model 序列化器class ProjectVersionSerializer(serializers.ModelSerializer): project = serializers.SerializerMethodField() sprint = serializers.SerializerMethodField() def get_sprint(self, obj): """ :param obj: 当前ProjectVersion的实例 """ current_project_version = obj sprint = Sprint.objects.filter(id=current_project_version.sprint_id).first() if sprint: return sprint.name else: return '--' def get_project(self, obj): """ :param obj: 当前ProjectVersion的实例 """ current_project_version = obj project = Project.objects.filter(id=current_project_version.project_id).first() if project: return project.name else: return '--' class Meta: model = ProjectVersion fields = '__all__' read_only_fields = ['project', 'sprint'] project_version_views.py#!/usr/bin/env python# -*- coding:utf-8 -*- __author__ = '授客' from rest_framework.views import APIViewfrom rest_framework.response import Responsefrom rest_framework import status from backend.models import ProjectVersionfrom backend.serializers import ProjectVersionSerializer class ProjectVersionListAPIView(APIView): ''' 项目视图-版本管理 ''' # 查询列表数据 def get(self, request, format=None): result = {} try: params = request.GET page_size = int(params.get('pageSize')) page_no = int(params.get('pageNo')) name = params.get('name') project_id = params.get('projectId') sort = params.get('sort') if sort: sort_list = sort.split(',') else: sort_list = ['-id'] startIndex = (page_no - 1) * page_size endIndex = startIndex + page_size filters = {'is_delete':0} if name: filters['name__startswith'] = name if project_id: filters['project_id'] = project_id rows = ProjectVersion.objects.filter(**filters).order_by(*sort_list)[startIndex:endIndex] rows = ProjectVersionSerializer(rows, many=True).data total = ProjectVersion.objects.filter(**filters).count() result['msg'] = '获取成功' result['success'] = True result['data'] = {} result['data']['rows'] = rows result['data']['total'] = total return Response(result, status.HTTP_200_OK) except Exception as e: result['msg'] = '%s' % e result['success'] = False return Response(result, status.HTTP_500_INTERNAL_SERVER_ERROR) 方法3-通过raw函数执行原生sql以下是项目中的一个实例,和本文上述内容没有任何关联,关键部分背景已着色,笔者偷懒,不做过多解释了,简单说下下面这段代码对用途: 主要是实现类似以下查询,获取指定分页对数据以及满足条件的记录记录总数。 SELECT tb_project.*, project_name_associated, project_id_associated, platform FROM tb_projectLEFT JOIN tb_project_associated ON tb_project.id=tb_project_associated.project_idORDER BY id DESCLIMIT 0,10 from rest_framework.views import APIViewfrom rest_framework.response import Responsefrom rest_framework import statusfrom backend.models import Projectfrom backend.serializers import ProjectSerializer import logging logger = logging.getLogger('mylogger') class ProjectListAPIView(APIView): ''' 项目视图-项目管理-项目列表 ''' # 查询列表数据 def get(self, request, format=None): result = {} try: params = request.GET page_size = int(params.get('pageSize')) page_no = int(params.get('pageNo')) name = params.get('name') project_status = params.get('status') sort = params.get('sort') order_by = 'id desc' if sort: order_by = sort startIndex = (page_no - 1) * page_size where = 'WHERE tb_project.is_delete=0 ' filters = {'is_delete':0} if name: filters['name__startswith'] = name where += 'AND locate("%s", name) ' % name if project_status: where += "AND status='%s'" % project_status sql = 'SELECT tb_project.id, COUNT(1) AS count FROM tb_project LEFT JOIN tb_project_associated ON tb_project.id=tb_project_associated.project_id ' query_rows = Project.objects.raw(sql) total = query_rows[0].__dict__.get('count') if query_rows else 0 sql = 'SELECT tb_project.*,project_name_associated, project_id_associated, platform FROM tb_project LEFT JOIN tb_project_associated ON tb_project.id=tb_project_associated.project_id ' \ '%s ORDER BY %s ' \ 'LIMIT %s,%s ' % (where,order_by, startIndex, page_size) query_rows = Project.objects.raw(sql) rows = [] for item in query_rows: item.__dict__.pop('_state') item.__dict__['create_time'] = item.__dict__['create_time'].strftime('%Y-%m-%d %H:%M:%S') item.__dict__['update_time'] = item.__dict__['update_time'].strftime('%Y-%m-%d %H:%M:%S') item.__dict__['begin_time'] = item.__dict__['begin_time'].strftime('%Y-%m-%d') item.__dict__['end_time'] = item.__dict__['end_time'].strftime('%Y-%m-%d') rows.append(item.__dict__) result['msg'] = '获取成功' result['success'] = True result['data'] = {} result['data']['rows'] = rows result['data']['total'] = total return Response(result, status.HTTP_200_OK) except Exception as e: result['msg'] = '%s' % e result['success'] = False return Response(result, status.HTTP_500_INTERNAL_SERVER_ERROR) 参考链接https://docs.djangoproject.com/en/1.11/ref/models/querysets/#django.db.models.query.QuerySet.extrahttps://www.jianshu.com/p/973971880da7
2023年06月
2023年04月