watermint.org - Takayuki Okazaki's note

GolangでBOM付きCSVファイルを読み込む

先行事例・解決策は山ほどあるかと思いますが、はまったのでメモがてら解決方法を。BOM付きUTF16などでエンコードされたテキストを扱うに当たって、ライブラリなどが対応してくれているといいのですが、GolangのCSVReaderは対応していないようです。このため、次のような関数を準備してエンコーディングを変換してやります。

func NewBomAwareCsvReader(r io.Reader) *csv.Reader {
  var (
    bomUtf8    = []byte{0xef, 0xbb, 0xbf}
    bomUtf16BE = []byte{0xfe, 0xff}
    bomUtf16LE = []byte{0xff, 0xfe}
  )
  br := bufio.NewReader(r)
  mark, err := br.Peek(3)
  if err != nil {
    panic(err)
  }

  // decoder
  d := unicode.UTF8.NewDecoder()

  if bytes.HasPrefix(mark, bomUtf8) {
    br.Discard(len(bomUtf8))
  } else if bytes.HasPrefix(mark, bomUtf16BE) {
    d = unicode.UTF16(unicode.BigEndian, 
      unicode.UseBOM).NewDecoder()
  } else if bytes.HasPrefix(mark, bomUtf16LE) {
    d = unicode.UTF16(unicode.LittleEndian,
     unicode.UseBOM).NewDecoder()
  }

  return csv.NewReader(transform.NewReader(br, d))
}

あとはファイルを読み込めば変換できます。ひとまず必要が無かったので対応しませんでしたが、utf32パッケージを使って同様処理を追加してやればUTF32も対応できます。

Googleマップのスターが消える問題と、Day One 2に移行した話

Google Mapsのスターとは、お気に入りのレストランなどにスターをつけてメモしておけるというものです。旅行先で行こうと考えている観光スポット、知人から教わったおすすめレストランなど今まで記録はすべてGoogle Mapsでスターをつけることでメモしていました。 ただこの便利な機能にも不満が2点。一つはスターが勝手に消えてしまうことで。二つ目があまり追加情報が書き込めないことです。

Google Mapsでのスター

今回はGoogle Mapsでスターをつけた位置をDay One 2の記事として移行した際のお話です。なお各種仕様は変わるかもしれませんので試される際には自己責任で。

Google Mapsでの2つの課題

課題1: スターが消える問題

まず一つ目のスターが勝手に消えてしまう問題について。少し検索してみると下記のように詳細に調査されている記事が見つかりました。

いろいろ丁寧に検証されているので大変参考になりました。結局、保存はされているが、どうやら表示数に制限があり、古いものから表示されなくなるのではとのこと。データが保存されていても簡単に取り出せないとするとあまり保存している意味もありません。

課題2: 追加情報が書き込めない

この中華料理屋さんの担々麺がおいしいとか、この焼き鳥屋さんは塩よりタレがおすすめとか些細ではあるものの、教えてもらった情報は書き留めておきたいものです。Google Mapsでは最近スターの種類が増えたのでカテゴリごとにスターを分けたりできますが、それでも追加情報を書き込むという段階まではたどり着きません。

Google+などSNSを使って管理する方法もあるのですが、各所に情報が分散しても使いづらいですし、自分で投稿したものにもかかわらず検索が難しいので数年前からこの目的のためにSNSは利用していません。

Day One 2へ移行

Day One 2はもともと日記などライフログをつけるためのアプリです。写真やメモに対して位置情報やタグなどを追加して情報を管理することができます。

Day Oneは2013年頃から使い始めていて、ようやく最近Day One 2へ移行を完了しました。Day One 2では基本的な使い勝手はそのままにかなり機能が追加されています。よく見ると、Day One 2ではJSON Zip Fileという形式でデータをエクスポートしたり、インポートすることができます。これを使えば、Google Mapsから一括してデータを読み込みできそうです。

Day One 2のJSON Zip File形式

JSON Zip File形式のドキュメントは見つかりませんでしたが、データ形式としてはごく単純なものです。ZIPファイルの中にJournalごとのJSONファイルと、写真データが格納されています。Day One 2にデータをインポートするにはこのJSONファイルをインポートすれば良さそうです。

JSONファイルは次のようなフォーマットです。Day One 2 version 2.5.10にて幾つかテストをしてみたところ、インポートの際には記事ごとのuuidは振らなくても自動的に発番されるので問題ありません。locationデータについても緯度(longitude)・経度(latitude)のみあればよく他は省略してもインポートは成功します。

{
"metadata" : {
  "version" : "1.0"
},
"entries" : [
{
  "starred" : false,
  "location" : {
    "region" : {
      "center" : {
        "longitude" : 120.1973037719727,
        "latitude" : 22.99810981750488
      },
      "radius" : 75
    },
    "longitude" : 120.1973,
    "placeName" : "有方公寓 Your Fun Apartment",
    "latitude" : 22.99811
  },
  "creationDate" : "2015-03-14T11:35:05Z",
  "text" : "[有方公寓 Your Fun Apartment](http:\/\/maps.google.com\/?cid=1337703935590192274)\n\nNo. 9, Lane 269, Section 2, Hai'an Road, West Central District, Tainan City, Taiwan 700",
  "timeZone" : "Asia\/Tokyo",
  "uuid" : "345585B421AF47DEA98B497C66CFBF8F",
  "duration" : 0
}
]}

Google Takeoutでスター一覧を取得

Google TakeoutでスターをつけたデータをGeoJSONとして取り出します。手順については先ほど掲載させていただいたGoogleマップの「スター」(お気に入りの場所)が、古いものから消えて表示されなくなる問題に詳しく書かれています。

GeoJSONの中身をみてみると以下のように緯度、経度、タイトル、Google Maps上でのURLなどが得られることがわかりました。

{
  "type" : "FeatureCollection",
  "features" : [ {
    "geometry" : {
      "coordinates" : [ 120.1972925, 22.9981111 ],
      "type" : "Point"
    },
    "properties" : {
      "Google Maps URL" : "http://maps.google.com/?cid=1337703935590192274",
      "Location" : {
        "Address" : "No. 9, Lane 269, Section 2, Hai'an Road, West Central District, Tainan City, Taiwan 700",
        "Business Name" : "有方公寓 Your Fun Apartment",
        "Country Code" : "TW",
        "Geo Coordinates" : {
          "Latitude" : "22.9981111",
          "Longitude" : "120.1972925"
        }
      },
      "Published" : "2015-03-14T11:35:05Z",
      "Title" : "有方公寓 Your Fun Apartment",
      "Updated" : "2017-02-22T12:03:22Z"
    },
    "type" : "Feature"
  },
]}

Day One 2のJSON Zip File形式に変換

あとは形式を変更するだけですが、JSON同士の変換となるので簡単です。今回はRubyで書きました。ライセンスはMITライセンスとしておきます。

# MIT License
# 
# Copyright (c) 2018 Takayuki Okazaki
# 
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
# 
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

require 'json'

def feature2entry(feature)
  def geo(f, l)
    if f.key?('Geo Coordinates')
      f['Geo Coordinates'][l]
    elsif f.key?(l)
      f[l]
    else
      p feature
      exit
    end
  end
  if feature['Location'].key?('Address')
    address = "\n\n#{feature['Location']['Address']}"
  else
    address = ''
  end
  if feature['Location'].key?('Business Name')
    place_name = feature['Location']['Business Name']
  else
    place_name = feature['Title']
  end

  {
    :location => {
      :placeName => place_name,
      :longitude => geo(feature['Location'], 'Longitude').to_f,
      :latitude  => geo(feature['Location'], 'Latitude').to_f,
    },
    :text => "[#{feature['Title']}](#{feature['Google Maps URL']})#{address}",
    :creationDate => feature['Published'],
  }
end

def dayone(entries)
  journal = {
    :metadata => {
      :version => "1.0"
    },
    :entries => entries
  }
  JSON.generate(journal)
end

geojson = JSON.load(open('Saved Places.json'))
entries = Hash[geojson['features'].map { |f| [f['properties']['Google Maps URL'], f] }]
journal = dayone(entries.values.map { |f| feature2entry(f['properties']) })

puts journal

あとはこの出力を Location.json など新しいジャーナル名として保存し、zipファイルにしてインポートします。これで無事にDay One 2に読み込み完了です。あとは、ゴミデータを削除したり、中華料理とか和食とかタグをつけて整理すれば完了です。

記事として読み込まれた位置情報

地図表示に切り替えればGoogle Mapsと同じように周辺の地図といままでメモした場所を一覧して確認することができます。

地図表示

Dropbox API: ネームスペースとパスの表現

Dropbox APIファイル操作をするときに必ず必要になってくるのがネームスペースの概念とファイルやフォルダへのパスです。実際にはほとんど意識することなく使えるのですが、知っていると見えてくる仕様もあるのでそのあたりを詳しく見てみましょう。

Dropboxがどのようにファイルを管理しているか

Dropboxがどのようにファイルを管理しているかみていきます。まずは次の記事を参照してみます。

これはStreaming同期という機能を紹介したブログエントリですがDropboxファイルシステムについても言及しています。Dropboxでは、ファイルシステムを抽象化するにあたってネームスペースという概念を定義しています。ネームスペースはすべてのユーザーのルートフォルダにあたり、また共有フォルダのように他のユーザーと共有しているスペースについては別のネームスペースが与えられます。

ユーザーがいくつかの共有フォルダを持っている場合、それぞれの共有フォルダのネームスペースは特定のパス以下にマウントされている状態となります。

パスの書式

APIドキュメントのPath formatsというセクションに詳しく書かれていますがいくつか特徴をピックアップしてみます。

  • ”” (空文字列)はルートフォルダを示す (ルートフォルダは “/” ではない)
  • すべてのファイルやフォルダはIDを持っていて、このIDでも表現できる (例: "id:abc123xyz")
  • IDで表現したフォルダからさらに相対パスを指定できる (例: "id:abc123xyz/hello.txt")
  • ネームスペースIDを指定して、相対パスを指定できる (例: "ns:12345/cupcake.png")
  • アルファベット大文字小文字の違いは無視されて、/A/B/c.txtも/a/b/c.txtも同一視される
  • パスの区切り文字は “/”. Windowsで利用されている “" ではありませんのでご注意

なお、ネームスペースIDやフォルダのIDを指定した場合、当然ながら該当フォルダなどへのアクセス権限が必要となります。

実行例

下図はルートフォルダからのファイル一覧を取得する例です。ルートフォルダから一覧を取得するためにpathには”“を指定しています。

curl -X POST https://api.dropboxapi.com/2/files/list_folder \
    --header "Authorization: Bearer <access token>" \
    --header "Content-Type: application/json" \
    --data "{\"path\": \"\"}"

ルートフォルダを示すためにもし、”/” のようにスラッシュを入れてしまうと次のようなエラーが返されます。

Error in call to API function “files/list_folder”: request body: path: Specify the root folder as an empty string rather than as “/”.

ネームスペース

ネームスペースについてもう少し詳しく見てみましょう。Namespace Guideにいくつか例がありますが、契約しているプランなどによって共有フォルダなどの種類に若干違いがあります。

ネームスペースとなるものとしては下記5つが列挙されています。いずれの場合も、ネームスペース = アクセス制御の範囲と考えると良いでしょう。

  • ユーザーのホームフォルダ
  • 共有フォルダ
  • チームフォルダ
  • チームルートフォルダ
  • アプリケーションフォルダ

ユーザーのホームフォルダはユーザー毎に作成されるものです。共有フォルダは共有フォルダとして新しくフォルダを共有した段階でそのフォルダが独立した別のネームスペースとして管理されます。

チームフォルダにはいくつか種類があるのですがどの場合でも独立したネームスペースとして管理されます。チームフォルダではチームフォルダ以下に作成したフォルダに追加のメンバーを設定することができるなど、共有フォルダにはできない設定が可能です。この場合、追加のメンバーを設定した下位のフォルダも実装上は別のネームスペースとして管理されることになります。

アプリケーションフォルダはアクセス範囲を制限した、Dropbox APIを利用するアプリケーション向けのフォルダです。各ユーザーのルートフォルダ以下「/アプリ/<アプリ名>」のようなフォルダにアプリケーション専用のデータを格納することができます。アプリケーションからみるとこのフォルダがルートフォルダとなり、ユーザーのホームフォルダなど他のネームスペースとなる部分にはアクセスできません。

Dropbox用ユーティリティ toolbox

Dropbox APIを使ったプログラムを昨年から幾つか作っています。いまこぢんまりと作っているのがtoolboxというプログラムで今回はこれについて少し紹介させてください。

toolboxはGoで書いているプログラムで、ファイルの操作やDropbox Businessチーム管理などの機能をコマンドラインから実行できるようにしたい。というものです。 Dropbox APIのようなAPIを直接呼び出すのは面倒でも、コマンドラインになっていればシェルスクリプトなどからコマンドを組み合わせて簡単に使えるからです。

このtoolboxには類似のプロジェクトとしてdbxcliというものがあります。これも、コマンドラインからファイル管理やDropbox Businessチーム管理を実現するようなものです。

車輪の再発明的ですが解決しようとしている問題がすこし違うのでその点について紹介できればと思っています。

toolboxのねらい

ファイルの移動やコピー操作などはエクスプローラーやFinderなどでやれば簡単ですが、大量のファイルとなるとそうはいかないことが多いです。たとえば4TBのハードディスクから、4TB空きのあるNASへコピーするというのは意外とやっかいです。ネットワーク接続が切れて途中で失敗したり、パソコンがフリーズしたり、何かとトラブルにぶつかります。

NASからDropboxへ移行したり、前職で大量のファイルやサーバを管理していた経験から、こういったときにはrsyncなどのように信頼がおけ、帯域制御、再実行などもよく考えられたコマンドを利用するようにしています。

toolboxは単にコマンドラインで簡単に実行できるようにするというよりは、実運用において信頼性のおけるコマンドになることを目指しています。

大量ファイル・フォルダへの対応

Dropboxへアップロードしたファイル数が増加してくると、場合によってはパフォーマンスに影響がでると言われています。Dropbox で保存可能なファイル件数によれば30万件を超えるとパフォーマンスが低下してくるそうです。

実際に、自分のアカウントにNASから移行した70〜80万ファイルを置いていたときには、同期処理完了までの時間が気になることがありました。これらのファイルを別のフォルダに移動したり、保存しているフォルダ名を変更するとかなりの時間がかかってしまいます。

処理が遅いだけでいずれは終了するのですがもう少し早く解決できないかということで大量のファイル向けの処理をコマンドラインから実行できるようにしました。

利用方法

現在の実装では移動とコピーを実装しています。(まだちゃんと自動テストなど整備できていませんし、動作を保証するものではありませんのでご利用の際には自己責任で)

バイナリはGoでコンパイルしたものでWindows版(32bit)、Linux版(32bit)、macOS版(64bit)を用意しています。下図はmacOSでの実行例ですがコマンドラインから展開したファイルのうち、OSや環境に合わせたものを実行してもらうと利用方法が出力されます。macOS版はdarwinと名前のついたものです。

% ./tbx-32.1.0.0-darwin-10.6-amd64

Usage: ./tbx-32.1.0.0-darwin-10.6-amd64 COMMAND

Available commands:
  file       File operation


Run './tbx-32.1.0.0-darwin-10.6-amd64 COMMAND' for more information on a command.

please specify sub command

プログラムの引数にコマンド、サブコマンド、パラメータなどを指定していきます。ファイルを移動する場合には次のように実行します。

% ./tbx-32.1.0.0-darwin-10.6-amd64 file move /移動もとパス /移動先のパス

実行すると初回は認証のためのURLが表示されるので、Dropboxにログインしているブラウザに貼り付けて認可して、トークンをコピペします。このトークンは$HOME/.tbx以下に保存しているので2度目以降は聞かれません。

トークンを保存しているファイルの権限は他者に読み取られないよう設定していますが、それでも様々な攻撃方法があるかと思うので、ある程度コマンドの体裁が整ってきたら、トークンを保存しないように仕様変更しようかと思っています。

toolboxの設計

最初はひさしぶりにDDDなんかでモデリングして作ろうかと考えましたが、あきらめてほとんどコマンドをべた書きしています。いずれリファクタリングするかもしれません。

というのも「ファイルを移動する」という簡単なユースケースでも実際には様々な要件があったり、エラー対応が必要となります。そういったエラー処理をすべて抽象化しながら書いていくにはかなり手間がかかりますし、「ファイルを移動する」で使えていた経験が「ファイルをコピーする」では通用しないといった場合もありますので抽象化に時間をかけるよりは、ソースコードコピペは許容しつつ、自動テストに力を入れようと考えています。

いまのところ、まだ自動テストのカバレッジもほとんどなくて寂しい限りですが、コマンドがある程度揃ってきたらブラックボックステストを幾つか最初に追加する予定です。

動作仕様

最終的にはrsyncや、mv/cp/rmなどUNIXライクOSで見かけるコマンドと同じような操作感になるよう仕上げたいと思っているので仕様はまだころころ変えていく予定です。あしからずご了承ください。

また野望としてはwebサーバを立ち上げてWebベースのGUIてきなツールも提供できればと考えていますが、セキュリティーをどう担保するかと言った課題もありますし、まだ大分先になりそうです。

Dropbox API: ファイルのアップロード

今回はDropbox APIを使ってファイルをアップロードをする流れをご紹介します。ファイルアップロード処理は/files/uploadを使えば良いのですが、ファイルサイズが大きい場合には一つのリクエストで処理するのではなく分割してアップロードすることが求められます。

分割アップロードする場合には、次のように3つの手順をたどります。

  1. 分割した最初のチャンクをアップロード (戻り値としてセッションIDが得られます) - /files/upload_session/start
  2. セッションIDをもとに最後の一つ手前チャンクまでを追記アップロードします - /files/upload_session/append_v2
  3. セッションIDと最後のチャンクをアップロードします - /files/upload_session/finish

分割の閾値

分割するかどうかの閾値はAPIドキュメントに下記のように記載があります。

A single request should not upload more than 150 MB.

150MB以上であれば分割してアップロードすべし。とのことなのですが、気になるのはこの1MBが 1,000,000バイトなのか、1,048,576バイトなのか。です。 気になるので試してみたところ次の通りでした。

  • 150,000,000バイト … アップロード成功
  • 157,286,399バイト (150 * 1,048,576 - 1)… アップロード成功
  • 157,286,400バイト (150 * 1,048,576) … アップロード成功
  • 160,000,000バイト … アップロード成功

予想外のことですがとりあえず150MBを軽く超えてもエラーなど出ず、アップロードができるようです。 ただ、あまり一度にアップロードするサイズが大きくなりすぎると、途中で通信エラーが出た場合に再送するならばロスが大きくなるのでそこそこに分割した方が良いでしょう。

Goでの実装例

前回と同様にdropbox-sdk-go-unofficialを使った実装例です。まずはソースをご覧ください。

uploadSrc := "data.zip"
// 分割してアップロードするかどうかの閾値
var chunkedUploadThreshold, chunkSize int64
chunkedUploadThreshold = 150 * 1048576
chunkSize = chunkedUploadThreshold
config := dropbox.Config{
  Token: "トークン文字列",
}
client := files.New(config)
// ローカルファイルの情報取得
info, err := os.Lstat(uploadSrc)
if err != nil {
  return err
}
f, err := os.Open(uploadSrc)
if err != nil {
  return err
}
defer f.Close()
ci := files.NewCommitInfo("/アップロード先/data.zip")
// ファイルの日付、UTCで秒未満の単位は丸める
ci.ClientModified = info.ModTime().UTC().Round(time.Second)
if info.Size() < chunkedUploadThreshold {
  _, err = client.Upload(ci, f)
  return err
} else {
  // セッションを開始、最初のチャンクサイズ分だけアップロード
  r := io.LimitReader(f, chunkSize)
  s, err := client.UploadSessionStart(files.NewUploadSessionStartArg(), r)
  if err != nil {
    return err
  }
  // 最後から一つ手前のチャンクまで分割アップロード
  var uploaded int64 // アップロード済みサイズ
  uploaded = chunkSize
  for (info.Size() - uploaded) > chunkSize {
    cursor := files.NewUploadSessionCursor(s.SessionId, uint64(uploaded))
    arg := files.NewUploadSessionAppendArg(cursor)
    r = io.LimitReader(f, chunkSize)
    err = client.UploadSessionAppendV2(arg, r)
    if err != nil {
      return err
    }
    uploaded += chunkSize
  }
  // 最後のチャンクをアップロード
  cursor := files.NewUploadSessionCursor(s.SessionId, uint64(uploaded))
  arg := files.NewUploadSessionFinishArg(cursor, ci)
  _, err = client.UploadSessionFinish(arg, f)
  return err
}

アップロード先

CommitInfo構造体にパスやファイルの日付などを設定します。アップロード先パスですが、アップロード先ディレクトリ名だけでなく、アップロード先ファイル名も含めたパスを指定します。

autorenameというパラメータがありますが、これをtrueにすると同じパスにすでにファイルがある場合は新しくアップロードするファイルがdata (1).zipのように重複しないファイル名でアップロードされます。

ファイル更新日付

ファイル更新日付を指定する場合にはclient_modifiedパラメータを指定します。指定しない場合にはDropboxへファイルが保存された際のサーバ日時になります。指定はISO 8601フォーマットで、UTCとします。

Goの例ではUTCに変換した上で、下記のように秒以下を丸めています。

ci.ClientModified = info.ModTime().UTC().Round(time.Second)

分割アップロード

Goの場合には分割アップロードに便利なio.LimitReaderというクラスがありますのでこれを利用するといいでしょう。分割したいチャンクサイズを指定するとそこでEOFになってくれます。

/files/upload_session/startで最初のチャンクをアップロードしたら、セッションIDが得られます。

これを使って、以降は/files/upload_session/append_v2を呼び出してチャンクをアップロードしていきます。なお、ファイルは0バイト目から連続している必要があり、cursorで飛び飛びのオフセットを指定した場合にはincorrect_offsetエラーとなります。

このoffsetパラメータの目的はAPIドキュメントにあるとおり、通信エラーなどで重複送信や部分的なロスが発生した場合にデータの整合性を検出するためです。

We use this to make sure upload data isn’t lost or duplicated in the event of a network error.