标签归档:c#

c#中如何正确处理 utf8-with-bom 的读写问题

C# 中如何正确处理 UTF-8 with BOM 的读写问题

昨天把项目中的打包工具做了一些调整,原本正常工作的代码因为新增的部分代码执行流程出现了问题,而且问题比较隐晦。最终通过调试跟踪,发现问题出在解析一个 JSON 文件的时候,该文件中只是存放了一个 JSON 数组而已。之前的版本中解析的文件是直接从服务器下载下来的,而服务器上的文件是在另一个工程中生成的,调整后的项目中,该 JSON 文件是自己生成的。

这个问题的关键在于我们通过以下的代码读取出来的字符串中有特殊字符:

string filePathUTF8 = "/Users/helihua/Temp/poerty_utf8.txt";
byte[] bytesUTF8 = File.ReadAllBytes(filePathUTF8);
string decodedPoetryUTF8 = System.Text.Encoding.UTF8.GetString(bytesUTF8);

直接使用 JSON 解析库解析读取出来的文本会出错,因为这段文本的第一个字符实际上是一个特殊不可见字符(表达零宽度非换行空格的意义,是不是很牛逼,调试的时候输出日志是发现不了的,调试的时候直接查看整个字符串的内容也是查看不了的,只能通过判断字符串长度和字符串第一个 char 才可以分辨噢),是 UTF-8 的 BOM,也就是 U+FEFF 这货,在 UTF-8 编码的文件中表现为前三个字节为: 239 187 191。

仔细分析后,我发现问题出在新调整的代码中,生成新的 JSON 文件使用了 File.WriteAllText(string path, string contents,Encoding encoding) 方法,传入的 encoding 为 System.Text.Encoding.UTF8。而这货默认是开启 BOM 的,那么这就意味着我们新创建的 JSON 文件是 UTF-8 with BOM 编码格式的(其实就是在写入文本的字节数据之前,添加了一个 BOM 块,也就是文件头部多了 3 个字节)。

然而后续读取该 JSON 文件的时候我并没有使用 File.ReadAllText 方法来进行文本内容的读取,而是先通过 File.ReadAllBytes 方法将 JSON 文本文件的所有数据读取为字节数组,后续通过了 UTF8Encoding.GetString 方法将字节数组转化为字符串。而 Encoding.GetString 方法是不会自己去过滤我们获得到的文件字节数组中的 BOM 头对应的 3 个字节的,所以就将其解析成了一个 零宽度非换行空格 了,最终导致解析 JSON 失败,整个程序流程出错了。

那么后续我们应该如何来规避类似的问题,正确地处理 UTF-8 with BOM 的读写问题呢?我的建议是:

  1. 尽可能让写文件和读文件采用对应的方法,例如写入文本文件的时候,使用 File.WriteAllText 方法,那么在读取文本文件的时候,就应该使用 File.ReadAllText 方法(C# 默认的实现非常鸡贼,在写入文件的时候会按照你传入的 Encoding 中声明是否需要写入 BOM 来写入文件,但是在读取文本的时候,不论传入的 Encoding 中是否声明带有 BOM,它都会检测 BOM,并且会把 BOM 从读取出来的字符串中移除掉,也就是说我们通过 File.ReadAllText 读取出来的文本字符串肯定是干净的,不会出现 BOM 这种奇怪的捣乱分子的);
  2. 在使用上面的方法的同时,使用相同的编码方式,并且建议使用不带 BOM 的编码方式(BOM 更多是为了给文本编辑器检测文件编码用的,对于 BOM 的各种争端,就如同编辑器 VIM 和 Emacs 之间的圣战般激烈,在此我表个态,我认为尽可能不用 BOM),如此一来所有的 UTF-8 都是不带 BOM 的,也就不存在在某些场景下读取文件的人没意识到文件头部可能会有 BOM ,编码过程中完全没有考虑到该问题,最终读取出来的文本中含有特殊字符与预期结果不一致的可能性;
  3. 如果在实际开发中,无法确定文件是什么类型,或者写文件和读文件的代码模块不由同一个人来开发维护,那么就需要非常明确地通过文档规范来声明读写文件应该采用什么方式,通用的方式当然就是通过 StreamWriter 来写入字节,通过 StreamReader 来读取字节,至于需要如何应用这些字节,可以交给上层的应用自行来处理。

C# Socket 如何判断连接已断开

相信很多 Unity3D 的程序员都会遇到网络编程的情况,虽然目前可能大部分的手游都会优先选择使用非长连接技术来实现,也就是我们常说的直接使用 Http 来完成跟服务器通信的部分,但是还是难免会有遇到需要使用实时网络连接的时候。那么这时候,我们通常还是需要回到网络编程的根源,那就是直接使用 Socket 进行编程,.NET Framework 中的 Socket 已经封装得非常的完备了,真的很好使。

但是在使用的过程中难免还是会碰到一丢丢问题的,对吧。今天我要记录的就是在处理连接已断开的这种情况。

在手游的使用场景中,以下情况可能会出现客户端与服务器出现无法连接的情况:

  1. 客户端无法连接到网络,例如:客户端设备当前接入的 Wi-Fi 热点无法连接到互联网,客户端设备进入无线网络信号很差的地方(例如某些大厦的电梯,某些地铁线路等等),这个时候其实客户端和服务器都没有主动关闭过这个 TCP 连接,但是网络已经无法正常连接了;
  2. 服务器主动关闭了这条 TCP 连接,服务器为什么要主动关闭掉我的这条 TCP 连接呢?设想一下,客户端跟服务器建立 TCP 连接之后,客户端在 10 分钟内都没有向服务器发送过任何数据,那么服务器还有必要保留这条连接吗?如果当前在线的玩家较多,服务器可用的连接数已经达到一个容限值,那么是否需要及时将某些已经不活跃(例如 10 分钟以上没有发送任何消息)的连接主动关闭,回收这些连接资源,以备其他新登录游戏的玩家设备连接使用呢?所以,当服务器主动关闭了 TCP 连接的时候,10 分钟我上完厕所回来了,我重新拿起我的手机,想继续玩我的游戏,那么就需要处理客户端如何重新连接到服务器的问题了;
  3. 服务器网络出现问题,例如服务器所在机房链路出现问题,通过某些网络就是无法访问服务器,例如服务器网线不小心被挖断了或者网线被网管拔了,服务器电源烧了,主板坏了,等等诸如此类的物理设备出现故障的情况下,客户端是无法连接到服务器的,服务器也不可能通过 TCP 协议给客户端发送什么 FIN,ACK,SYN 等等状态码啦,这个时候客户端也需要有正常的逻辑来处理这个情况。

其实说了这么多就是想表达一下,我们的客户端可能在任何情况下与服务器失去连接,而这个时候客户端需要合理地来处理这种连接已断开的情况。

我们可以查看一下.NET Framework 的文档,看看 Socket 中的一些相关的属性和方法:

Socket.Connected Property

Gets a value that indicates whether a Socket is connected to a remote host as of the last Send or Receive operation.

The Connected property gets the connection state of the Socket as of the last I/O operation. When it returns false, the Socket was either never connected, or is no longer connected.

The value of the Connected property reflects the state of the connection as of the most recent operation. If you need to determine the current state of the connection, make a nonblocking, zero-byte Send call. If the call returns successfully or throws a WAEWOULDBLOCK error code (10035), then the socket is still connected; otherwise, the socket is no longer connected.

If you call Connect on a User Datagram Protocol (UDP) socket, the Connected property always returns true; however, this action does not change the inherent connectionless nature of UDP.

这个是 Connected 属性的相关描述,大体的意思就是这个 Connected 属性会根据当前这个 Socket 最后一次的 I/O 操作来进行设定,也就是说这个属性为 True 还是为 False 会在 Socket 最后一次 I/O 操作完毕之后进行设定,应该也是根据错误返回码 SocketError 来进行设定的。如果我们使用的是阻塞的 Socket 的话,那么这个状态会在最后一次 I/O 操作出错后将这个属性设置为 False,如果使用的是非阻塞的 Socket 就需要我们自己通过发送一个零字节的数据包,然后根据错误返回码来判断 Socket 连接是否断开。


 

Socket.Receive Method (Byte[], Int32, Int32, SocketFlags, SocketError)

Receives data from a bound Socket into a receive buffer, using the specified SocketFlags.

在我们的项目中我们使用了这个方法来读取 Socket 中的数据 ,这个方法中的最后一个参数是一个 out 参数,也就是如果我们在读取数据的时候出现了一些异常,错误码会通过这个参数返回。


Socket.Send Method (Byte[], Int32, Int32, SocketFlags, SocketError)

Sends the specified number of bytes of data to a connected Socket, starting at the specified offset, and using the specified SocketFlags

我们使用这个 Send 方法来进行数据的发送,同样也是通过最后一个 out 参数 SocketError 来进行错误码的返回。

这个 SocketError 类型中呢有很多很多预定义的错误码,这个需要我们仔细查阅看看有哪些是我们需要使用到的,这个因项目类型和需求不同,这里不一一阐述了。

那么在我们实际的开发过程中呢,我们肯定可以通过判断 SocketError 错误码来进行一些异常情况的处理,不过有些时候我们其实并不想在出现异常了之后才处理,例如我有一个发送消息的队列,有一条消息发送出错了,我还得把这条消息再放回队列,可是如果直接入队到消息队列的队尾可能会有消息先后顺序的问题等等。所以有些时候我们就需要在异常出现之前就想好解决方法,也就是在我们读取或者发送数据之前就先检查一下连接是否正常消息是否能够成功发送出去,那么这又要肿么搞呢?

先看看 Poll 方法的描述吧:

Socket.Poll Method

Determines the status of the Socket.

Remarks

The Poll method will check the state of the Socket. Specify SelectMode.SelectRead for the selectMode parameter to determine if the Socket is readable. Specify SelectMode.SelectWrite to determine if the Socket is writable. Use SelectMode.SelectError to detect an error condition. Poll will block execution until the specified time period, measured in microseconds, elapses. Set the microSeconds parameter to a negative integer if you would like to wait indefinitely for a response. If you want to check the status of multiple sockets, you might prefer to use the Select method.

 看上去这个方法貌似就是我们想要的呢,对吧?如果你这么想那就太 Naive 了,在实际使用的过程中,我就犯了一个不仔细看文档,导致栽了一个大跟头的错误。我写了一段代码用来判断当前 Socket 是否已经跟服务器断开连接。

Socket socket;
// 省去初始化和连接服务器代码若干
if (!socket.Poll(1000, SelectMode.SelectRead)) {
// 做错误提示等相关的操作
}

事实证明这样是瞎搞,因为.NET Framework 的文档关于 SelectMode.SelectRead 这样描述的。

true if Listen has been called and a connection is pending;
-or-
true if data is available for reading;
-or-
true if the connection has been closed, reset, or terminated;
otherwise, returns false.

你说这不是坑爹是啥?这个意思就是说,如果我们是一个服务器端的 Socket,调用了 Listen 方法,并且有一个外部连接在等待我们处理的时候,这个 Poll 方法会返回 true,如果当前 Socket 中有数据可以读取时,这个 Poll 方法也会返回 true,或者当这个 Socket 连接被关闭、重置、强制终止了也会返回 true,否则返回 false。

实际测试的时候,发现这个值只在 Socket 缓冲区中有数据可以读取和连接被服务器关闭了之后才会返回 true,好吧,我承认自己是猪吧。那么回到我们的初衷上来,我们还是希望能在读取数据之前就能知道这个连接是否已经被关闭了(是否与服务器断开连接了)。那么可以通过下面这段代码来进行判断。

// 省去初始化和连接服务器代码若干
if (socket.Available == 0 && socket.Poll(1000, SelectMode.SelectRead)) {
// 因为根据文档的描述,如果连接正常的话,这两个条件不可能同时成立
// 只有在连接已断开的时候,才可能会出现这两个条件同时成立的情况
}

写到这里的时候,突然之间很想看看 Available 这个属性又是怎么一回事,好吧,我们再来看看.NET Framework 的文档吧。

Socket.Available Property

Gets the amount of data that has been received from the network and is available to be read.

Remarks

If you are using a non-blocking Socket, Available is a good way to determine whether data is queued for reading, before calling Receive. The available data is the total amount of data queued in the network buffer for reading. If no data is queued in the network buffer, Available returns 0.

If the remote host shuts down or closes the connection, Available can throw a SocketException. If you receive a SocketException, use the SocketException.ErrorCode property to obtain the specific error code. After you have obtained this code, refer to the Windows Sockets version 2 API error code documentation in the MSDN library for a detailed description of the error.

虽然文档中提到如果我们使用的是非阻塞的 Socket 的话,那么 Available 属性将会是一个用于检测 Socket 缓冲区中是否有数据可以 Receive 的好选择,这样我们如果使用阻塞的 Socket 难道就不能用了吗?当然也不是,肯定还是可以用的,只是说非阻塞的 Socket 如果一直循环调用 Receive 很有可能会做无用功,因为如果当前 Socket 缓冲区中没有数据可以读取的话,Receive 方法会很快速的结束调用并且抛出一个 SocketException,而如果每次 Receive 之前检查一下变量的成本相对于调用一次 Receive 方法可能开销会更小一些吧。而对于阻塞的 Socket 来说,每次 Receive 调用都是阻塞直到有数据返回,或者超时后抛出一个超时异常。所以我们阻塞的 Socket 也是可以用 Available 这个属性来进行提前判断的。但是实际测试中,Unity3D 中使用的 Mono 中的 Socket 实现并非和.NET Framework 的文档描述一致。关于如果 Remote Host 关闭连接之后,再调用这个 Available 属性会抛出异常的行为就没有。

 关于 SelectMode 中另外两个 SelectWrite 和 SelectError 类型,显然我们也不能放过啊。一一测试之后发现,在服务器主动断开连接之后,通过 Socket.Poll(1000, SelectMode.SelectWrite) 返回一直都是 true。即便我们通过 Send 方法发出去的字节数为 0,返回的 SocketError 中的错误码为 Shutdown,依然不为所动啊,所以对于这个 SelectMode.SelectWrite 我已经凌乱了,希望哪天可以找到合理的解释。

但是 Socket.Poll(1000, SelectMode.SelectError) 在服务器断开连接之后会在第一时间内从 false 变成 true,貌似这个还是蛮靠谱的,但是看了一下文档之后我又有点凌乱了。

true if processing a Connect that does not block, and the connection has failed;
-or-
true if OutOfBandInline is not set and out-of-band data is available;
otherwise, returns false.

这个意思是说,如果正在建立一个非阻塞的连接,然后这个连接失败了会返回 true,或者没有设置 OutOfBandInline 但是又收到了 out-of-band 数据的时候会返回 true,否则就返回 false。

综合上面说的那一大堆东西,最终得出的结论就是 Connected 字段可以用,但是有滞后性,如果想通过这个字段来检测连接是否已经断开的话,就要明白这个字段是在我们通过 Receive 或者 Send 方法成功进行一次 I/O 操作之后,系统根据最后一次 I/O 操作的错误返回码来设定 Connected 字段的值的。

而如果想在进行 I/O 操作之前就检测到连接出现断开的情况的话,是可以通过 Poll 来达成目的的,当然这个会比较纠结。首先 Poll 配合 SelectMode.SelectRead 和 SelectMode.SelectWrite 都不能非常直接的反馈连接是否被断开 。但是貌似这个 SelectMode.SelectError 还是蛮靠谱的,所以呢,从目前测试得到的结果中,可以使用这个来提前判断连接是否已经断开。

不过在实际的应用中,我个人倒是更倾向于在使用 SelectMode.SelectError 做判断的同时,一定还要对在 Receive 和 Send 方法中返回的 SocketError 进行检查,这个返回码是直接从 Native 层返回的 Socket 错误码,非常可靠也很能说明问题。例如”HostDown”、”ShutDown“、”NetworkDown“等等状态码还是很能直观地反应当前 Socket 的状态的,所以还是需要好好地看看.NET Framework 中关于 SocketError 的描述文档。