字节顺序标记在Java中读取文件时会出现问题

127

我正在尝试使用Java读取CSV文件。其中一些文件可能在开头有字节顺序标记,但不是所有文件都有。当存在字节顺序标记时,它会随着第一行的其他内容一起被读取,从而在字符串比较中引起问题。

是否有一种简便方法可以在存在字节顺序标记时跳过它?


дєЯиЃЄеПѓдї•е∞ЭиѓХдљњзФ®дї•дЄЛйУЊжО•жЭ•е§ДзРЖеЄ¶жЬЙBOMзЪДUTF-8жЦЗдїґпЉЪhttp://www.rgagnon.com/javadetails/java-handle-utf8-file-with-bom.html - Chris
11个回答

126

编辑: 我已在GitHub上发布了一个正式版本: https://github.com/gpakosz/UnicodeBOMInputStream


这是我一段时间前编写的一个类,我只是在粘贴之前编辑了包名。没什么特别的,它与SUN的bug数据库中发布的解决方案非常相似。将其纳入您的代码中,您就可以了。

/* ____________________________________________________________________________
 * 
 * File:    UnicodeBOMInputStream.java
 * Author:  Gregory Pakosz.
 * Date:    02 - November - 2005    
 * ____________________________________________________________________________
 */
package com.stackoverflow.answer;

import java.io.IOException;
import java.io.InputStream;
import java.io.PushbackInputStream;

/**
 * The <code>UnicodeBOMInputStream</code> class wraps any
 * <code>InputStream</code> and detects the presence of any Unicode BOM
 * (Byte Order Mark) at its beginning, as defined by
 * <a href="http://www.faqs.org/rfcs/rfc3629.html">RFC 3629 - UTF-8, a transformation format of ISO 10646</a>
 * 
 * <p>The
 * <a href="http://www.unicode.org/unicode/faq/utf_bom.html">Unicode FAQ</a>
 * defines 5 types of BOMs:<ul>
 * <li><pre>00 00 FE FF  = UTF-32, big-endian</pre></li>
 * <li><pre>FF FE 00 00  = UTF-32, little-endian</pre></li>
 * <li><pre>FE FF        = UTF-16, big-endian</pre></li>
 * <li><pre>FF FE        = UTF-16, little-endian</pre></li>
 * <li><pre>EF BB BF     = UTF-8</pre></li>
 * </ul></p>
 * 
 * <p>Use the {@link #getBOM()} method to know whether a BOM has been detected
 * or not.
 * </p>
 * <p>Use the {@link #skipBOM()} method to remove the detected BOM from the
 * wrapped <code>InputStream</code> object.</p>
 */
public class UnicodeBOMInputStream extends InputStream
{
  /**
   * Type safe enumeration class that describes the different types of Unicode
   * BOMs.
   */
  public static final class BOM
  {
    /**
     * NONE.
     */
    public static final BOM NONE = new BOM(new byte[]{},"NONE");

    /**
     * UTF-8 BOM (EF BB BF).
     */
    public static final BOM UTF_8 = new BOM(new byte[]{(byte)0xEF,
                                                       (byte)0xBB,
                                                       (byte)0xBF},
                                            "UTF-8");

    /**
     * UTF-16, little-endian (FF FE).
     */
    public static final BOM UTF_16_LE = new BOM(new byte[]{ (byte)0xFF,
                                                            (byte)0xFE},
                                                "UTF-16 little-endian");

    /**
     * UTF-16, big-endian (FE FF).
     */
    public static final BOM UTF_16_BE = new BOM(new byte[]{ (byte)0xFE,
                                                            (byte)0xFF},
                                                "UTF-16 big-endian");

    /**
     * UTF-32, little-endian (FF FE 00 00).
     */
    public static final BOM UTF_32_LE = new BOM(new byte[]{ (byte)0xFF,
                                                            (byte)0xFE,
                                                            (byte)0x00,
                                                            (byte)0x00},
                                                "UTF-32 little-endian");

    /**
     * UTF-32, big-endian (00 00 FE FF).
     */
    public static final BOM UTF_32_BE = new BOM(new byte[]{ (byte)0x00,
                                                            (byte)0x00,
                                                            (byte)0xFE,
                                                            (byte)0xFF},
                                                "UTF-32 big-endian");

    /**
     * Returns a <code>String</code> representation of this <code>BOM</code>
     * value.
     */
    public final String toString()
    {
      return description;
    }

    /**
     * Returns the bytes corresponding to this <code>BOM</code> value.
     */
    public final byte[] getBytes()
    {
      final int     length = bytes.length;
      final byte[]  result = new byte[length];

      // Make a defensive copy
      System.arraycopy(bytes,0,result,0,length);

      return result;
    }

    private BOM(final byte bom[], final String description)
    {
      assert(bom != null)               : "invalid BOM: null is not allowed";
      assert(description != null)       : "invalid description: null is not allowed";
      assert(description.length() != 0) : "invalid description: empty string is not allowed";

      this.bytes          = bom;
      this.description  = description;
    }

            final byte    bytes[];
    private final String  description;

  } // BOM

  /**
   * Constructs a new <code>UnicodeBOMInputStream</code> that wraps the
   * specified <code>InputStream</code>.
   * 
   * @param inputStream an <code>InputStream</code>.
   * 
   * @throws NullPointerException when <code>inputStream</code> is
   * <code>null</code>.
   * @throws IOException on reading from the specified <code>InputStream</code>
   * when trying to detect the Unicode BOM.
   */
  public UnicodeBOMInputStream(final InputStream inputStream) throws  NullPointerException,
                                                                      IOException

  {
    if (inputStream == null)
      throw new NullPointerException("invalid input stream: null is not allowed");

    in = new PushbackInputStream(inputStream,4);

    final byte  bom[] = new byte[4];
    final int   read  = in.read(bom);

    switch(read)
    {
      case 4:
        if ((bom[0] == (byte)0xFF) &&
            (bom[1] == (byte)0xFE) &&
            (bom[2] == (byte)0x00) &&
            (bom[3] == (byte)0x00))
        {
          this.bom = BOM.UTF_32_LE;
          break;
        }
        else
        if ((bom[0] == (byte)0x00) &&
            (bom[1] == (byte)0x00) &&
            (bom[2] == (byte)0xFE) &&
            (bom[3] == (byte)0xFF))
        {
          this.bom = BOM.UTF_32_BE;
          break;
        }

      case 3:
        if ((bom[0] == (byte)0xEF) &&
            (bom[1] == (byte)0xBB) &&
            (bom[2] == (byte)0xBF))
        {
          this.bom = BOM.UTF_8;
          break;
        }

      case 2:
        if ((bom[0] == (byte)0xFF) &&
            (bom[1] == (byte)0xFE))
        {
          this.bom = BOM.UTF_16_LE;
          break;
        }
        else
        if ((bom[0] == (byte)0xFE) &&
            (bom[1] == (byte)0xFF))
        {
          this.bom = BOM.UTF_16_BE;
          break;
        }

      default:
        this.bom = BOM.NONE;
        break;
    }

    if (read > 0)
      in.unread(bom,0,read);
  }

  /**
   * Returns the <code>BOM</code> that was detected in the wrapped
   * <code>InputStream</code> object.
   * 
   * @return a <code>BOM</code> value.
   */
  public final BOM getBOM()
  {
    // BOM type is immutable.
    return bom;
  }

  /**
   * Skips the <code>BOM</code> that was found in the wrapped
   * <code>InputStream</code> object.
   * 
   * @return this <code>UnicodeBOMInputStream</code>.
   * 
   * @throws IOException when trying to skip the BOM from the wrapped
   * <code>InputStream</code> object.
   */
  public final synchronized UnicodeBOMInputStream skipBOM() throws IOException
  {
    if (!skipped)
    {
      in.skip(bom.bytes.length);
      skipped = true;
    }
    return this;
  }

  /**
   * {@inheritDoc}
   */
  public int read() throws IOException
  {
    return in.read();
  }

  /**
   * {@inheritDoc}
   */
  public int read(final byte b[]) throws  IOException,
                                          NullPointerException
  {
    return in.read(b,0,b.length);
  }

  /**
   * {@inheritDoc}
   */
  public int read(final byte b[],
                  final int off,
                  final int len) throws IOException,
                                        NullPointerException
  {
    return in.read(b,off,len);
  }

  /**
   * {@inheritDoc}
   */
  public long skip(final long n) throws IOException
  {
    return in.skip(n);
  }

  /**
   * {@inheritDoc}
   */
  public int available() throws IOException
  {
    return in.available();
  }

  /**
   * {@inheritDoc}
   */
  public void close() throws IOException
  {
    in.close();
  }

  /**
   * {@inheritDoc}
   */
  public synchronized void mark(final int readlimit)
  {
    in.mark(readlimit);
  }

  /**
   * {@inheritDoc}
   */
  public synchronized void reset() throws IOException
  {
    in.reset();
  }

  /**
   * {@inheritDoc}
   */
  public boolean markSupported() 
  {
    return in.markSupported();
  }

  private final PushbackInputStream in;
  private final BOM                 bom;
  private       boolean             skipped = false;

} // UnicodeBOMInputStream

而您是这样使用它的:

import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.InputStreamReader;

public final class UnicodeBOMInputStreamUsage
{
  public static void main(final String[] args) throws Exception
  {
    FileInputStream fis = new FileInputStream("test/offending_bom.txt");
    UnicodeBOMInputStream ubis = new UnicodeBOMInputStream(fis);

    System.out.println("detected BOM: " + ubis.getBOM());

    System.out.print("Reading the content of the file without skipping the BOM: ");
    InputStreamReader isr = new InputStreamReader(ubis);
    BufferedReader br = new BufferedReader(isr);

    System.out.println(br.readLine());

    br.close();
    isr.close();
    ubis.close();
    fis.close();

    fis = new FileInputStream("test/offending_bom.txt");
    ubis = new UnicodeBOMInputStream(fis);
    isr = new InputStreamReader(ubis);
    br = new BufferedReader(isr);

    ubis.skipBOM();

    System.out.print("Reading the content of the file after skipping the BOM: ");
    System.out.println(br.readLine());

    br.close();
    isr.close();
    ubis.close();
    fis.close();
  }

} // UnicodeBOMInputStreamUsage

2
抱歉页面需要长时间滚动,很遗憾没有附件功能。 - Gregory Pakosz
3
应该将此功能包含在Java核心API中。 - Denys Kniazhev-Support Ukraine
为什么不在UnicodeBOMInputStream中添加javaCharset键作为成员,其值可以用于根据找到的BOM相应地读取文件,在InputStreamReader isr = new InputStreamReader(ubis, ubis.getCharsetKey())中使用getCharsetKey返回Java.charset值。 - Varun Bhatia
10
已经过去了10年,但我仍在为此收到业力:D 我看着你,Java! - Gregory Pakosz
1
因为答案提供了关于为什么文件输入流默认不提供丢弃BOM选项的历史,所以被点赞。 - MxLDevs
显示剩余7条评论

108

Apache Commons IO库提供了一个InputStream,可以检测并且删除BOM:BOMInputStream (javadoc)

BOMInputStream bomIn = new BOMInputStream(in);
int firstNonBOMByte = bomIn.read(); // Skips BOM
if (bomIn.hasBOM()) {
    // has a UTF-8 BOM
}
如果您需要检测不同的编码方式,该工具还可以识别各种不同的字节顺序标记,例如UTF-8 vs. UTF-16大端+小端 - 详细信息请参见上面的文档链接。然后,您可以使用检测到的 ByteOrderMark 来选择一个 Charset 解码流。(如果您需要所有这些功能,则可能有更简化的方法,也许是BalusC答案中的UnicodeReader?)请注意,一般来说,很难检测出一些字节的编码方式,但如果流以BOM开头,这似乎会有所帮助。
编辑:如果您需要检测UTF-16、UTF-32等中的BOM,则构造函数应为:
new BOMInputStream(is, ByteOrderMark.UTF_8, ByteOrderMark.UTF_16BE,
        ByteOrderMark.UTF_16LE, ByteOrderMark.UTF_32BE, ByteOrderMark.UTF_32LE)

点赞@martin-charlesworth的评论 :)


只需跳过BOM即可。对于99%的使用情况应该是完美解决方案。 - atamanroman
7
我成功地使用了这个答案。然而,我想恭敬地提出一个建议,即增加一个布尔型参数,用于指定是否包含BOM。例如:BOMInputStream bomIn = new BOMInputStream(in, false); // 不包含BOM - Kevin Meredith
21
我还要补充一点,这只能检测出UTF-8 BOM。如果你想检测所有的utf-X BOM,那么你需要在BOMInputStream构造函数中传递它们。BOMInputStream bomIn = new BOMInputStream(is, ByteOrderMark.UTF_8, ByteOrderMark.UTF_16BE, ByteOrderMark.UTF_16LE, ByteOrderMark.UTF_32BE, ByteOrderMark.UTF_32LE); - Martin Charlesworth
关于@KevinMeredith的评论,我想强调布尔构造函数更清晰,但是默认构造函数已经摆脱了UTF-8 BOM,就像JavaDoc所建议的那样:“BOMInputStream(InputStream delegate) 构造一个新的BOM InputStream,其中包括一个ByteOrderMark.UTF_8 BOM.” - WesternGun
跳过解决了我大部分的问题。如果我的文件以BOM UTF_16BE开头,我可以通过跳过BOM并将文件作为UTF_8读取来创建InputReader吗?到目前为止它运行良好,我想知道是否存在任何边缘情况?提前致谢。 - Bhaskar

40
更简单的解决方案:
public class BOMSkipper
{
    public static void skip(Reader reader) throws IOException
    {
        reader.mark(1);
        char[] possibleBOM = new char[1];
        reader.read(possibleBOM);

        if (possibleBOM[0] != '\ufeff')
        {
            reader.reset();
        }
    }
}

使用示例:

BufferedReader input = new BufferedReader(new InputStreamReader(new FileInputStream(file), fileExpectedCharset));
BOMSkipper.skip(input);
//Now UTF prefix not present:
input.readLine();
...

它可以与所有5个UTF编码一起使用!


1
非常好的Andrei。但是你能解释一下它为什么有效吗?模式0xFEFF如何成功匹配看起来具有不同模式和3个字节而不是2个字节的UTF-8文件?并且这种模式如何匹配UTF16和UTF32的两种字节序? - Vahid Pazirandeh
2
正如您所看到的 - 我不使用字节流,而是使用打开了预期字符集的字符流。因此,如果该流中的第一个字符是BOM,则我会跳过它。BOM可以有不同的字节表示形式,但这只是一个字符。请阅读这篇文章,它对我很有帮助:http://www.joelonsoftware.com/articles/Unicode.html - user1092126
1
看看我对Vahid的回答:我打开的不是字节流而是字符流,并从中读取一个字符。不用担心文件使用了什么utf编码 - bom前缀可以由不同数量的字节表示,但在字符方面它只是一个字符。 - user1092126
mark() 方法标记输入中的一个位置,通过调用 reset() 方法可以将流“重置”到该位置。在跳过 BOM 后需要进行未来读取。 - user1092126
如果你正在尝试标记第二个索引,你应该在读取后调用它。 - shmosel
显示剩余3条评论

26

Google Data API提供了一个UnicodeReader,它可以自动检测编码。

你可以使用它来代替InputStreamReader。下面是其源代码的摘录(稍微压缩过),非常简单明了:

public class UnicodeReader extends Reader {
    private static final int BOM_SIZE = 4;
    private final InputStreamReader reader;

    /**
     * Construct UnicodeReader
     * @param in Input stream.
     * @param defaultEncoding Default encoding to be used if BOM is not found,
     * or <code>null</code> to use system default encoding.
     * @throws IOException If an I/O error occurs.
     */
    public UnicodeReader(InputStream in, String defaultEncoding) throws IOException {
        byte bom[] = new byte[BOM_SIZE];
        String encoding;
        int unread;
        PushbackInputStream pushbackStream = new PushbackInputStream(in, BOM_SIZE);
        int n = pushbackStream.read(bom, 0, bom.length);

        // Read ahead four bytes and check for BOM marks.
        if ((bom[0] == (byte) 0xEF) && (bom[1] == (byte) 0xBB) && (bom[2] == (byte) 0xBF)) {
            encoding = "UTF-8";
            unread = n - 3;
        } else if ((bom[0] == (byte) 0xFE) && (bom[1] == (byte) 0xFF)) {
            encoding = "UTF-16BE";
            unread = n - 2;
        } else if ((bom[0] == (byte) 0xFF) && (bom[1] == (byte) 0xFE)) {
            encoding = "UTF-16LE";
            unread = n - 2;
        } else if ((bom[0] == (byte) 0x00) && (bom[1] == (byte) 0x00) && (bom[2] == (byte) 0xFE) && (bom[3] == (byte) 0xFF)) {
            encoding = "UTF-32BE";
            unread = n - 4;
        } else if ((bom[0] == (byte) 0xFF) && (bom[1] == (byte) 0xFE) && (bom[2] == (byte) 0x00) && (bom[3] == (byte) 0x00)) {
            encoding = "UTF-32LE";
            unread = n - 4;
        } else {
            encoding = defaultEncoding;
            unread = n;
        }

        // Unread bytes if necessary and skip BOM marks.
        if (unread > 0) {
            pushbackStream.unread(bom, (n - unread), unread);
        } else if (unread < -1) {
            pushbackStream.unread(bom, 0, 0);
        }

        // Use given encoding.
        if (encoding == null) {
            reader = new InputStreamReader(pushbackStream);
        } else {
            reader = new InputStreamReader(pushbackStream, encoding);
        }
    }

    public String getEncoding() {
        return reader.getEncoding();
    }

    public int read(char[] cbuf, int off, int len) throws IOException {
        return reader.read(cbuf, off, len);
    }

    public void close() throws IOException {
        reader.close();
    }
}

看起来链接上说 Google Data API 已经被弃用了?现在应该去哪里寻找 Google Data API 呢? - SOUser
1
@XichenLi:GData API因其预期目的而被弃用。我并不打算建议直接使用GData API(OP没有使用任何GData服务),但我打算将源代码作为示例用于您自己的实现。这也是为什么我在我的答案中包含它,以便可以直接复制粘贴。 - BalusC
这里有一个错误。UTF-32LE的情况是无法到达的。为了使(bom[0] == (byte) 0xFF) && (bom[1] == (byte) 0xFE) && (bom[2] == (byte) 0x00) && (bom[3] == (byte) 0x00)成立,那么UTF-16LE的情况((bom[0] == (byte) 0xFF) && (bom[1] == (byte) 0xFE))已经匹配过了。 - Joshua Taylor
由于这段代码来自Google Data API,我在GitHub上发布了问题471 - Joshua Taylor

16
Apache Commons IO库的BOMInputStream已被@rescdsk提到,但我没有看到如何获取没有 BOM 的InputStream
以下是我在Scala中的实现方法。
 import java.io._
 val file = new File(path_to_xml_file_with_BOM)
 val fileInpStream = new FileInputStream(file)   
 val bomIn = new BOMInputStream(fileInpStream, 
         false); // false means don't include BOM

单参数构造函数实现:public BOMInputStream(InputStream delegate) { this(delegate, false, ByteOrderMark.UTF_8); }。默认情况下,它会排除 UTF-8 BOM - Vladimir Vagaytsev
很好的观点,弗拉基米尔。我看到在它的文档中 - https://commons.apache.org/proper/commons-io/javadocs/api-2.2/org/apache/commons/io/input/BOMInputStream.html#BOMInputStream(java.io.InputStream): 构造一个新的 BOM 输入流,可以排除 ByteOrderMark.UTF_8 BOM。 - Kevin Meredith

5

为了简单地从您的文件中删除BOM字符���我建议使用Apache Common IO

public BOMInputStream(InputStream delegate,
              boolean include)
Constructs a new BOM InputStream that detects a a ByteOrderMark.UTF_8 and optionally includes it.
Parameters:
delegate - the InputStream to delegate to
include - true to include the UTF-8 BOM or false to exclude it

将include设置为false,BOM字符将被排除在外。


2

非常遗憾,你必须自我识别和跳过。 此页面 详细介绍了你需要注意的事项。另外,请参阅此SO问题 获取更多详细信息。


2

这是我用来读取大多数字符集的csv文件的代码。它应该可以涵盖99%的情况。

        try(InputStream inputStream = new FileInputStream(csvFile);){
            BOMInputStream bomInputStream = new BOMInputStream(inputStream ,ByteOrderMark.UTF_8, ByteOrderMark.UTF_16LE, ByteOrderMark.UTF_16BE, ByteOrderMark.UTF_32LE, ByteOrderMark.UTF_32BE);
            Charset charset;
            if(!bomInputStream.hasBOM()) charset = StandardCharsets.UTF_8;
            else if(bomInputStream.hasBOM(ByteOrderMark.UTF_8)) charset = StandardCharsets.UTF_8;
            else if(bomInputStream.hasBOM(ByteOrderMark.UTF_16LE)) charset = StandardCharsets.UTF_16LE;
            else if(bomInputStream.hasBOM(ByteOrderMark.UTF_16BE)) charset = StandardCharsets.UTF_16BE;
            else { throw new Exception("The charset of the file " + csvFile + " is not supported.");}
            
            try(Reader streamReader = new InputStreamReader(bomInputStream, charset);
                BufferedReader bufferedReader = new BufferedReader(streamReader);) {
                for(String line; (line = bufferedReader.readLine()) != null; ) {
                    String[] columns = line.split(",");
             //read csv columns
            }
        }

在我看来,这是最好的答案(和编码示例),除非没有BOM,否则它会回退到UTF-8。请参见下面的通用答案。 - Heri

2
我认为给出的答案都不是很令人满意。只是跳过BOM,然后使用当前平台的默认编码读取流是明显错误的。请记住:Unix/Linux和Windows上的默认编码不同:前者是UTF-8,后者是ANSI。这种解决方案仅在流(BOM之后)中只包含7位ASCII字符时有效(我承认,在大多数程序员接触的文件中,如配置文件中,这是正确的)。但是,一旦出现非ASCII字符,您将无法使用此方法。
这就是为什么所有可以将字节数组/流转换为字符串(反之亦然)的Java类/方法都有第二个参数,指示要使用的编码(Reader、Writer、Scanner、String.getBytes()等)。
世界上有这么多字符编码,不仅仅是UTF-xx。而且,即使在2021年,终端用户应用程序之间仍然存在许多编码问题,特别是如果它们在不同的平台(iOS、Windows、Unix)上运行。所有这些问题的存在仅因为程序员懒得学习字符编码的工作原理。
因此,首先评估要使用的编码,然后使用找到的编码执行字符串/流转换是绝对必要的。查阅相应的规范是第一步。只有在读取流时不能确定遇到的编码时,才需要自行评估它。但是请注意:这样的评估始终只是“最佳猜测”,没有算法可以涵盖所有可能性。
在这个意义上,我认为Lee在2021年2月6日给出的答案(和编码示例)是最好的,除非没有BOM,他会回退到UTF-8。

1
我曾经遇到过同样的问题,因为我没有读取一堆文件,所以我采用了更简单的解决方案。我认为我的编码是UTF-8,因为当我使用这个页面Get unicode value of a character帮助打印出问题字符时,我发现它是\ufeff。我使用代码System.out.println( "\\u" + Integer.toHexString(str.charAt(0) | 0x10000).substring(1) );来打印出有问题的Unicode值。
一旦我有了有问题的Unicode值,在继续阅读之前,我将其替换为文件的第一行。该部分的业务逻辑:
String str = reader.readLine().trim();
str = str.replace("\ufeff", "");

这解决了我的问题。然后我能够继续处理文件,没有任何问题。我添加了trim(),以防有前导或尾随空格,您可以根据您的具体需求选择是否使用它。

1
那对我没用,但我使用了.replaceFirst("\u00EF\u00BB\u00BF", ""),它有效。 - StackUMan

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接