栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > Python

Django Oracle后端 --- cx

Python 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

Django Oracle后端 --- cx

背景

使用Django3.2.5作为后端开发框架,数据库为Oracle11.2。据Django官方文档说明,要求Oracle Database的最低版本为12c。阅读过Django中ORM(对象关系模型)的实现,ORM中常用的自增id、sql查询分页等数据库特性在Oracle11.2均没有实现,从Oracle12c版本开始才有这些特性,这也是Django框架要求Oracle版本最低为12c的主要原因。但是项目的数据库Oracle11.2已不可能更换。这也为后面各种业务的开发在某些具体方面带来了技术问题。本文涉及的cx_Oracle字符集问题就是其中之一。此外,后续我将就此背景分享一些其他问题及解决方案,因为期间遇到的问题在网上很少有涉及,自己花费了不少精力去解决。

问题

Django页面在保存中文字符时出现乱码。

分析 知识背景

根据以往的经验,中文字符保存后出现乱码基本上是字符集转换出错引起。

Django后端对不同的数据库客户端进行了一次封装,以保证上层ORM调用数据库访问接口时的统一。Oracle的python客户端为cx_Oracle,其本质就是将Oracle客户端库进行python封装。这里为了简化Django里的问题排查过程,我将直接使用cx_Oracle访问Oracle11.2,并使用一个单独的表“test”用于测试。

SQL> create table test(id number(9), txt nvarchar2(128), txt2 varchar2(128));

这里创建varchar2和nvarchar2的原因是Oracle数据库中有数据库字符集和国家字符集的概念,这两种字符集用途不一样,受服务端和客户端字符集的影响,这个我稍后具体介绍。

这里额外啰嗦下字符集及python下的字节串和字符串。

字符集可以这么理解,就是将某些字符汇总在一起,然后给它们编个号,这个号就是码位(code point),一个码位与一个字符对应,计算机在处理字符时,实际就是处理这些码位值。比如美国使用的英文字母及符号汇总在一起,然后编号,最后为了能让计算机方便存储,用一个字节(8个比特位)的空间按码位存储,这里的编号就是为字符分配码位,用一个字节存储就是对码位进行编码,只不过刚好码位跟编码的结果一样,这就是我们熟知的ASCII码(美国标准信息交换码)。再比如将中国使用的汉字及符号汇总起来,编号,这就是中国国标字符集,最后为了方便计算机存储和运算,使用两个字节的空间按码位直接存储,这就是我们熟知的GBK编码。很多国家按照这个思路提供了自己的字符集标准。

互联网的崛起,为了方便各个地区的人访问,需要统一字符集,于是就有了Unicode字符集。Uicode字符集囊括世界几乎所有国家的字符,而且每年还有增加,显然用一个字节的空间存储是不行的,通常情况下使用两个字节就能够涵盖大部分各个国家常用的字符。两个字节对于字母地区的人来说,显然又比较浪费存储空间,于是就有了大家熟知utf-8编码方案,该方案用一个字符存储ASCII字符集的字符,三个字节存储汉字等字符集。

python(这里指python3)的默认编码方案为utf-8,即python代码从磁盘文件被加载值虚拟机时,按utf-8编码进行解码。比如磁盘存储的字符串“你”,字节序列为0xe4 0xbd 0xa0,被加载至虚拟机内存时,字节序列为0x4f 0x60,实际上0x4f 0x60就是“你”的Unicode码位值,也就是说python中的字符串实际是Unicode的码位串。我们对这个码位进行utf-8编码后,生成一个字节串,也就是utf-8编码后的字节序列,这个字节序列通常用于存储和传输,这也是utf(Unicode格式传输转换编码)名称的由来。

>>> a = '你'
>>> a
'你'
>>> ord(a)
20320
>>> hex(ord(a))
'0x4f60'
>>> b = a.encode(encoding='utf-8')
>>> b
b'xe4xbdxa0'
实验一

言归正传,我们现在直接往test表插入数据,并查询数据,看看能得到什么结果。

import cx_Oracle
import os


def str_to_hex(s):
    if s is None:
        return ''
    return r' '.join([hex(ord(c)) for c in s])


db = cx_Oracle.connect(dsn='orcl', user='xx', password='xx')
cursor = db.cursor()

cursor.execute("update test set txt=:param, txt2=:param2", param='你', param2='你')
db.commit()


rows = cursor.execute('select txt,txt2 from test')

for row in rows:
    print(type(row[0]), type(row[1]))
    print('txt=', row[0], 'txt2=', row[1].decode(encoding='utf-8') if isinstance(row[1], bytes) else row[1])
    print('txt=', str_to_hex(row[0]), 'txt2=', row[1] if isinstance(row[1], bytes) else str_to_hex(row[1]))

db.close()

执行上述python代码后,从表test获取到的值在终端显示为倒问号的乱码,对应的十六进制的值为0xbf。

 
txt= ¿ txt2= ¿
txt= 0xbf txt2= 0xbf

我们再看看数据库中真实存储的值。dump函数将值以十六进制的形式显示出来,具体参考这个链接《oracle dump函数的使用》。我们发现“你”在字段txt(nvarchar2类型)中存储为2个字节,在字段txt2(varchar2类型)中存储为1个字节,而且这两个字段的值都不是正确的,说明我们在插入数据时就已经发生错误了。这里解释下下图中的CharacterSet。

SQL> select dump(txt,1016),dump(txt2,1016) from test;

DUMP(TXT,1016)
--------------------------------------------------------------------------------
DUMP(TXT2,1016)
--------------------------------------------------------------------------------
Typ=1 Len=2 CharacterSet=AL16UTF16: 0,bf
Typ=1 Len=1 CharacterSet=WE8ISO8859P1: bf

在Oracle数据库中有数据库字符集和国家字符。数据库字符集影响的是VARCHAR2、VARCHAR、CHAR、CLOB等数据类型的存储,这个是数据库对于字符串处理的基础字符集,同其他数据库一样。国家字符集影响的是NVARCHAR2、NVARCHAR、NCHAR、NCLOB等数据类型的存储,与其他数据库可能不一样。Oracle11.2版本的数据库实例默认使用数据库字符集是WE8ISO8859P1,默认国家字符集是AL16UTF16。我们可以查看数据库实例的参数(select * from nls_database_parameters;)。NLS_CHARACTERSET表示数据库字符集,NLS_NCHAR_CHARACTERSET表示国家字符集。平时,我们在配置Oracle客户端环境变量NLS_LANG(NLS--National Language Support)时,主要影响的就是实例中的NLS_LANGUAGE、NLS_TERRITORY、NLS_CHARACTERSET这三个参数。客户端环境变量NLS_LANG的格式为“语言_地区.字符集”,如,我使用的客户端环境变量NLS_LANG的值为AMERICAN_AMERICA.WE8ISO8859P1。这里需要注意的是环境变量NLS_LANG会影响Oracle客户端库的字符处理,尽管cx_Oracle封装了Oracle客户端库,但是cx_Oracle会忽略这个环境变量,且由cx_Oracle自己的配置决定字符集,所以环境变量NLS_LANG还是会影响sqlplus等工具的。更多关于oracle字符集请参考《oracle字符集与乱码》《Database Globalization Support Guide》《Character Sets and Globalization》。

 通过了解相关资料,当我们通过客户与Oracle服务端进行字符串交互时,如果客户端字符集与服务端字符集不一致,Oracle会自动进行字符集转换,对于转换失败的字符会以问号等字符进行替代。所以上述出现数据库中存储值错误的根本原因是发生了字符集转换。cx_Orale默认使用的是utf-8编码的Unicode字符集,而服务端使用的是WE8ISO8859P1字符集,WE8ISO8859P1字符集(对应标准名称iso-8859-1)是西欧字母字符集,编码时使用一个字节存储,且两者之间不存在严格超集关系。我们知道“你”的Unicode值是0x4f 0x60,数据送至服务端时,客户端根据对应字段的数据类型(varchar2)确认要转换成目标字符集WE8ISO8859P1,由于Unicode码位0x4f 0x60在WE8ISO8859P1字符集中没有对应的映射,所以客户端就转换成替代字符倒问号0xbf《ISO 8859-1 对照表 (扩展ASCII码表)》。

实验二

跟之前的实验相比,这次我将输入模式改为绑定变量的方式,并为每个变量指定了对应字段的类型。代码如下:

import cx_Oracle
import os


def str_to_hex(s):
    if s is None:
        return ''
    return r' '.join([hex(ord(c)) for c in s])


db = cx_Oracle.connect(dsn='orcl', user='xx', password='xx')
cursor = db.cursor()

# 创建一个绑定变量,并指定数据类型和初识值
var = cursor.var(cx_Oracle.DB_TYPE_NVARCHAR)
var.setvalue(0, '你')

# 创建一个绑定变量,并指定数据类型和初识值
var2 = cursor.var(cx_Oracle.DB_TYPE_VARCHAR)
var2.setvalue(0, '你')

cursor.execute("update test set txt=:param, txt2=:param2", param=var, param2=var2)
db.commit()

rows = cursor.execute('select txt,txt2 from test')

for row in rows:
    print(type(row[0]), type(row[1]))
    print('txt=', row[0], 'txt2=', row[1].decode(encoding='utf-8') if isinstance(row[1], bytes) else row[1])
    print('txt=', str_to_hex(row[0]), 'txt2=', row[1] if isinstance(row[1], bytes) else str_to_hex(row[1]))

db.close()

 终端显示的第一个字段正常,第二个字段仍为乱码。

 
txt= 你 txt2= ¿
txt= 0x4f60 txt2= 0xbf

查看数据库存储的值,发现nvarchar2字段的值符合预期,因为AL16UTF16字符集使用的是2个字节来编码Unicode,也就是说客户端和服务端在nvarchar2类型的数据上都是Unicode字符集,客户端没有转换进行字符集转换,而是直接将数据0x4f 0x60发送给服务端,服务端认为是Unicode字符集的数据,就直接按UFT16编码存储。反过来,客户端在读取nvarchar2类型的数据时,由于客户端默认使用的是utf-8编码的Unicode字符集(参考 《Character Sets and Globalization》),并客户端没有发生字符集转换,直接将传输过来的字节流解码为Unicode码位值,所以客户终端侧没有出现nvarchar2类型字段的乱码。而对于varchar类型的字段,我们知道它对应的字符集是WE8ISO8859P1,客户端根据绑定变量设定的数据类型将Unicode码位值转换成WE8ISO8859P1字符集的码位值,显然没有对应的码位值映射,所以转换失败使用0xbf来替代转换后的字符。当获取varchar2类型的数据时,由于客户端和服务端的字符集不一致,客户端将WE8ISO8859P1字符集的码位值0xbf转换成Unicode的码位0x00bf,所以在终端显示的是Unicode字符集的倒问号。

SQL> select dump(txt,1016),dump(txt2,1016) from test;

DUMP(TXT,1016)
--------------------------------------------------------------------------------
DUMP(TXT2,1016)
--------------------------------------------------------------------------------
Typ=1 Len=2 CharacterSet=AL16UTF16: 4f,60
Typ=1 Len=1 CharacterSet=WE8ISO8859P1: bf
实验三

那么当前的varchar2类型的字段就不能存储unicode字符集的数据吗?

答案是否定,当然可以存储过。

数据库存储数据本质是存储过字节串,只是在客户端读写数据时,如何去解释这个字节串,说白了就是编/解码的问题。对于WE8ISO8859P1字符集的varchar2,导致乱码的原因是发生了字符集转换,如果我们能阻止这一行为,那么就可以达到我们的目的。

跟之前的python代码相比,我在连接函数增加了配置参数encoding='ios-8859-1',同时给绑定变量var2赋值'你'的字节串,而不是Unicode字符串。这就告诉cx_Oracle客户端,当前数据使用的是ios-8859-1字符集,与服务端的WE8ISO8859P1字符集是一致的,理论上不发生字符集转换,也就是说字节串0xe4 0xbd 0xa0应该原样的发给服务端,服务端认为是WE8ISO8859P1字符集的三个字符。

import cx_Oracle
import os


def str_to_hex(s):
    if s is None:
        return ''
    return r' '.join([hex(ord(c)) for c in s])

# varchar类型按encoding的字符集进行编解码
db = cx_Oracle.connect(dsn='orcl', user='xx', password='xx', encoding='iso-8859-1')
cursor = db.cursor()


var = cursor.var(cx_Oracle.DB_TYPE_NVARCHAR)
var.setvalue(0, '你')

var2 = cursor.var(cx_Oracle.DB_TYPE_VARCHAR)
# 传入的值为字节串,0xe4 0xbd 0xa0
var2.setvalue(0, '你'.encode(encoding='utf-8'))

cursor.execute("update test set txt=:param, txt2=:param2", param=var, param2=var2)
db.commit()

rows = cursor.execute('select txt,txt2 from test')

for row in rows:
    print(type(row[0]), type(row[1]))
    print('txt=', row[0], 'txt2=', row[1].decode(encoding='utf-8') if isinstance(row[1], bytes) else row[1])
    print('txt=', str_to_hex(row[0]), 'txt2=', row[1] if isinstance(row[1], bytes) else str_to_hex(row[1]))

db.close()

 终端显示的varchar2类型字段仍为乱码,但是十六进制的值是正确的。

 
txt= 你 txt2= ä½ 
txt= 0x4f60 txt2= 0xe4 0xbd 0xa0

 查看数据库中存储的值也是符合预期的,那么现在的问题仅仅是解读获取的数据的问题,即解码的问题。

SQL>  select dump(txt,1016),dump(txt2,1016) from test;

DUMP(TXT,1016)
--------------------------------------------------------------------------------
DUMP(TXT2,1016)
--------------------------------------------------------------------------------
Typ=1 Len=2 CharacterSet=AL16UTF16: 4f,60
Typ=1 Len=3 CharacterSet=WE8ISO8859P1: e4,bd,a0
实验四 

这次我们增加了手动解码的过程,主要是为cursor对象增加了输出类型处理回调函数。具体使用方法请参考《Changing Fetched Data Types with Output Type Handlers》。

import cx_Oracle
import os


def str_to_hex(s):
    if s is None:
        return ''
    return r' '.join([hex(ord(c)) for c in s])

# varchar类型按encoding的字符集进行编解码
db = cx_Oracle.connect(dsn='orcl', user='xx', password='xx', encoding='iso-8859-1')
cursor = db.cursor()

# 输出类型的处理函数
def output_type_handler(cursor, name, default_type, size, precision, scale):
    # 如果获取的是DB_TYPE_VARCHAR类型,那么就需要进行utf-8解码
    if default_type == cx_Oracle.DB_TYPE_VARCHAR:
        # 解码函数
        def convert_db_to_python_varchar(val):
            return val.decode(encoding='utf-8')

        # 返回一个绑定变量,cx_Oracle通过该该绑定变量获取数据,bypass_decode=True表示忽略内部的自动解码,outconverter=convert_db_to_python_varchar表示自定义解码
        return cursor.var(cx_Oracle.DB_TYPE_VARCHAR, arraysize=cursor.arraysize, bypass_decode=True, outconverter=convert_db_to_python_varchar)

    return None

cursor.outputtypehandler = output_type_handler

var = cursor.var(cx_Oracle.DB_TYPE_NVARCHAR)
var.setvalue(0, '你')

var2 = cursor.var(cx_Oracle.DB_TYPE_VARCHAR)
# 传入的值为字节串,0xe4 0xbd 0xa0
var2.setvalue(0, '你'.encode(encoding='utf-8'))

cursor.execute("update test set txt=:param, txt2=:param2", param=var, param2=var2)
db.commit()

rows = cursor.execute('select txt,txt2 from test')

for row in rows:
    print(type(row[0]), type(row[1]))
    print('txt=', row[0], 'txt2=', row[1].decode(encoding='utf-8') if isinstance(row[1], bytes) else row[1])
    print('txt=', str_to_hex(row[0]), 'txt2=', row[1] if isinstance(row[1], bytes) else str_to_hex(row[1]))

db.close()

 从终端显示的结果来看,varchar类型的数据也能正常显示中文字符了。

 
txt= 你 txt2= 你
txt= 0x4f60 txt2= 0x4f60

 数据库中存储的值也是符合预期的。

SQL> select dump(txt,1016),dump(txt2,1016) from test;

DUMP(TXT,1016)
--------------------------------------------------------------------------------
DUMP(TXT2,1016)
--------------------------------------------------------------------------------
Typ=1 Len=2 CharacterSet=AL16UTF16: 4f,60
Typ=1 Len=3 CharacterSet=WE8ISO8859P1: e4,bd,a0

通过上述一系列的分析以及实验,我们基本确定和理解了cx_Oracle客户端在发生字符集转换时的机制。相应地,我们也知道了在客户端字符集与服务端字符集不一致时,如何解决乱码问题。问题的核心就是即使发生了字符集转换也应该符合预期地去转换,否则就会使得储存的数据异常,读取的数据乱码。

解决

有了前面的分析过程,我们再来考虑如何在Django框架中完善Oracle后端关于字符集的处理,毕竟开头提出的问题还没有解决。因为当前版本的Django(3.2.5)默认使用的Oracle后端要求Oracle版本最低为12c,而我所使用的Oracle版本为11.2g,Django强大的ORM将无法使用,主要是因为Oracle11.2g缺少自增id特性。所以为了解决这个问题我们需要自己定义一个Oracle后端,来覆盖Django默认的Oracle后端,这篇博文将不讨论如何使用自定义的Oracle后端(请参考《官方文档Django自定义数据库后端》),后续我将在另一篇文章中具体介绍。

 当前的解决方案是在自定义的数据库后端base.py模块内增加自定义的类FormatStylePlaceholderCursor,并继承自Django框架中默认的base.FormatStylePlaceholderCursor。主要是在self.cursor对象中覆盖inputtypehandler属性,即对于含有ios-8859-1字符集之外的字符进行nvarchar类型存储,因为Django默认的CharField字段使用的是Oracle的nvarchar2类型,即国家字符字符集类型。ios-8859-1字符集内的字符由于在Unicode中可以自由转换,所以不存在问题。

class FormatStylePlaceholderCursor(base.FormatStylePlaceholderCursor):
    """
    Django uses "format" (e.g. '%s') style placeholders, but Oracle uses ":var"
    style. This fixes it -- but note that if you want to use a literal "%s" in
    a query, you'll need to use "%%s".
    """

    def __init__(self, connection):
        super().__init__(connection)
        self.cursor.inputtypehandler = self._input_type_handler

    @staticmethod
    def _input_type_handler(cursor, value, arraysize):
        def inconverter(v):
            return v.encode(encoding='utf-8')

        def big_unicode_char_exist(val):
            for c in val:
                if ord(c) > 255:
                    return True
            return False

        import cx_Oracle
        if isinstance(value, str):
            if big_unicode_char_exist(value):
                if len(value) > 4000:
                    db_type = cx_Oracle.DB_TYPE_NCLOB
                else:
                    db_type = cx_Oracle.DB_TYPE_NVARCHAR
            else:
                return None

            in_var = cursor.var(db_type, arraysize=arraysize, inconverter=inconverter)
            in_var.setvalue(0, value)
            return in_var
        return None
小提示 1. 查看Oracle服务端版本

SQL> select * from v$version;

BANNER
--------------------------------------------------------------------------------
Oracle Database 11g Enterprise Edition Release 11.2.0.4.0 - 64bit Production
PL/SQL Release 11.2.0.4.0 - Production
CORE    11.2.0.4.0      Production
TNS for Linux: Version 11.2.0.4.0 - Production
NLSRTL Version 11.2.0.4.0 - Production

2. 查看Oracle客户端版本

# sqlplus -v

SQL*Plus: Release 11.2.0.4.0 Production

转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/444879.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号