WEBアプリからExcelファイルを操作(アップロード・ダウンロードなど)する必要があったため、そのときに調べた内容の備忘メモ。
イントロ
Cloud Functions for Firebase を使って、Excelファイル内のデータをCloud Firestoreへ投入したり、FirestoreのデータをExcelファイルとしてダウンロードしたりするサンプルコードです。
以下のことができるようになります。
- Excelデータを読み込んで、Firestoreへ保存
- Firestoreデータを読み出して、Excelへ流し込んでダウンロード
- Excelファイルを、Storageへアップロード(上記で用いるExcelテンプレートをアップロード)
それぞれ、図的には下記のようになります。
Excelデータを読み込んで、Firestoreへ保存
ローカルのExcelファイルを、Cloud Functionsへアップロード。FunctionsはExcelファイルを読み込んでJSONデータを生成し、Firestoreにデータを書き込みます。
Firestoreデータを読み出して、Excelへ流し込んでダウンロード
Cloud Functionsを呼び出すとFunctionsがFirestoreからデータを取得。またCloud Storageに置いたテンプレートExcelファイルを取り出してそこにデータを書き込み、Excelファイルをダウンロードします。
Excelファイルを、Storageへアップロード(上記で用いるExcelテンプレートをアップロード)
ついでに、テンプレートのExcelをCloud Functions経由で、Cloud Storageへアップロードします。
前提、事前準備(メモ)
Node.js はインストールされてる前提で、firebase-toolsのインストールから。
$node --versionv10.18.1
$npm i -g firebase-tools
+ firebase-tools@7.12.1
added 516 packages from 325 contributors in 20.769s
$firebase --version7.12.1
続いてFirebaseへのログイン。下記のコマンドを実行するとブラウザが起動するので、そちらでFirebaseにログインしておきます。
$firebase login
✔ Success! Logged in as xxxx@example.com
今回のサンプルのコードをGitHubからダウンロードして、使用するFirebaseのプロジェクトを設定しておきます。
$git clone https://github.com/masatomix/excel2firestore.git
$cd excel2firestore/
$firebase use --add? Which project do you want to add? slackapp-sample
? What alias do you want to use for this project? (e.g. staging) default
Created alias default for slackapp-sample.
Now using alias default (slackapp-sample)
$
その他Firebase上で
- Cloud Functions for Firebase が利用可能な状態
- Cloud Storage が利用可能な状態
- Cloud Firestore が利用可能な状態
にしておきましょう1。
環境設定
サービスアカウント設定
FunctionsからFirestoreへ読み書きを行うために「サービスアカウントJSONファイル」が必要です。
Firebaseのプロジェクトの左メニューの歯車アイコンから「プロジェクトの設定」を選択 >> サービスアカウント 画面でJSONファイルを生成・取得しておいてください。
その後、ソースコード上の ./functions/src/firebase-adminsdk.json
という名前で配置しておいてください。
Storageの設定
StorageのURLを指定します。Firebaseのプロジェクトの左メニュー >> Storage を選択。
gs://slackapp-sample.appspot.com
がStorageのURLなのでそれを設定します。
$cd functions/
$cat ./src/firebaseConfig.ts
export default {
apiKey: '',
authDomain: '',
databaseURL: 'https://slackapp-sample.firebaseio.com', ←今回使いません
projectId: 'slackapp-sample', ←今回使いません
storageBucket: 'slackapp-sample.appspot.com', ← 正しいStorage名に。
messagingSenderId: '',
appId: ''
}
以上で準備は完了です。
Functionsを起動し、実行する
$npm i
...
found 0 vulnerabilities
$npm run serve
>functions@0.0.6-SNAPSHOT serve /Users/xxx/excel2firestore/functions
>npm run build && firebase serve --only functions
>functions@0.0.6-SNAPSHOT build /Users/xxx/excel2firestore/functions
>tsc
⚠ Your requested "node" version "8" doesn't match your global version "10"
✔ functions: Emulator started at http://localhost:5000
i functions: Watching "/Users/xxx/excel2firestore/functions" for Cloud Functions...
✔ functions[api]: http function initialized (http://localhost:5000/slackapp-sample/us-central1/api).
起動したので、別のターミナルから。。
$pwd/Users/xxx/excel2firestore/functions
$
- Excelデータを、Firestoreへ
$ curl http://localhost:5000/slackapp-sample/us-central1/api/samples/upload -F file=@samples.xlsx -X POST
- Firestoreデータを、整形されたExcelへ
$ curl http://localhost:5000/slackapp-sample/us-central1/api/samples/download -o result.xlsx
- Excelファイルを、Storageへ
$ curl http://localhost:5000/slackapp-sample/us-central1/api/samples/templateUpload -F file=@samples.xlsx -X POST
コード説明
基本的なFunctionsのコード(Expressを使った部分とか)は省略します。興味があればCloneしたコードをご確認ください:-)
「Excelデータを読み込んで、Firestoreへ保存」のサンプルコード
HTTPでFormからアップロードされてくるデータを取り扱うための「busboy」を用いてファイルのデータを取得し、一旦ファイルとして書き出します。次のそのファイルから「xlsx-populate-wrapper」を使ってExcelファイルを読み込み、Firestore へデータを書き込んでいます。内容的には Google Cloud内のドキュメント#マルチパートデータの内容ほぼそのままですね。
また xlsx-populate-wrapper は「xlsx-populate」のWrapperですが、ファイルの読み書きで変更したい箇所があったので、forkしてすこしだけ改変させてもらいました。
オリジナル: https://github.com/juniorCitizen/xlsx-populate-wrapper
import{Request,Response}from'express'import*asadminfrom'firebase-admin'import{excel2Sample4}from'./sample4'import*aspathfrom'path'import*asosfrom'os'import*asBusboyfrom'busboy'import*asfsfrom'fs'constSAMPLE4:string='sample4'exportconstupload=async(request:Request,response:Response)=>{// https://cloud.google.com/functions/docs/writing/http?hl=ja// https://qiita.com/rubytomato@github/items/11c7f3fcaf60f5ce3365console.log('start.')// Node.js doesn't have a built-in multipart/form-data parsing library.// Instead, we can use the 'busboy' library from NPM to parse these requests.constbusboy=newBusboy({headers:request.headers})consttmpdir=os.tmpdir()// This object will accumulate all the uploaded files, keyed by their name.constuploads:{[key:string]:string}={}constfileWrites:Array<Promise<any>>=[]busboy.on('file',(fieldname,file,filename,encoding,mimetype)=>{// file: NodeJS.ReadableStreamconsole.log('busboy.on.file start.')console.log(`File [${fieldname}]: filename: ${filename}, encoding: ${encoding} , mimetype: ${mimetype}`)// Note: os.tmpdir() points to an in-memory file system on GCF// Thus, any files in it must fit in the instance's memory.constfilepath=path.join(tmpdir,filename)uploads[fieldname]=filepathconstwriteStream=fs.createWriteStream(filepath)file.pipe(writeStream)// File was processed by Busboy; wait for it to be written to disk.constpromise=newPromise((resolve,reject)=>{file.on('end',()=>{writeStream.end()excel2Sample4(filepath).then((datas:any[])=>{for(constinstanceofdatas){admin.firestore().doc(`${SAMPLE4}/${instance.operationId}`).set(instance)}resolve(datas)}).catch(err=>reject(err))})// writeStream.on('finish', resolve)// writeStream.on('error', reject)})fileWrites.push(promise)})// Triggered once all uploaded files are processed by Busboy.// We still need to wait for the disk writes (saves) to complete.busboy.on('finish',async()=>{console.log('busboy.on.finish start.')constresults:any[]=awaitPromise.all(fileWrites)for(constfileofObject.values(uploads)){fs.unlinkSync(file)}constlength=results.map(result=>result.length).reduce((acc,value)=>acc+value)// response.status(200).send(`${Object.keys(uploads).length} file executed.`)response.status(200).send(`${length}件処理しました。`)})constreqex:any=requestbusboy.end(reqex.rawBody)}
下記では、Excelから取得した行データを、Firestoreに書き込む前にJSONデータにしています。JSON生成をゴニョゴニョやってますが、開発してるWEBアプリ向けのデータ構造に変換しているだけで、記事観点での本質的な意味はありません。
Excel上日付のデータについては、Excelのシリアル値(number)が取得されるので、Date型への変換などを行っています。
import*asadminfrom'firebase-admin'import{xlsx2json,dateFromSn,toBoolean}from'./commonUtils'constSAMPLE1:string='sample1'constSAMPLE4:string='sample4'exportconstexcel2Sample4=(path:string):Promise<Array<any>>=>{constformat_func=(instance:any):any=>{constnow=admin.firestore.Timestamp.now()constdata:any={operationId:instance.operationId,driver:{ref:admin.firestore().doc(`${SAMPLE1}/${instance.driverId}`)},opeType:String(instance.opeType),opeDateFrom:dateFromSn(instance.opeDateFrom),opeDateTo:dateFromSn(instance.opeDateTo),opeStatus:String(instance.opeStatus),destinationDate:dateFromSn(instance.destinationDate),isUnplanned:toBoolean(instance.isUnplanned),createdAt:now,updatedAt:now,}returndata}returnxlsx2json(path,SAMPLE4,format_func)}
下記は、実際にExcelファイルから行データを生成する処理です。
importxPopWrapper=require('xlsx-populate-wrapper')/**
* Excelファイルを読み込み、各行をデータとして配列で返すメソッド。
* @param path Excelファイルパス
* @param sheet シート名
* @param format_func フォーマット関数。instanceは各行データが入ってくるので、任意に整形して返せばよい
*/exportconstxlsx2json=asyncfunction(path:string,sheet:string,format_func?:(instance:any)=>any):Promise<Array<any>>{constworkbook=newxPopWrapper(path)awaitworkbook.init()constinstances:Array<any>=workbook.getData(sheet)if(format_func){returninstances.map(instance=>format_func(instance))}returninstances}/**
* Excelのシリアル値を、Dateへ変換します。
* @param serialNumber シリアル値
*/exportconstdateFromSn=(serialNumber:number):Date=>{returnXlsxPopulate.numberToDate(serialNumber)}exportconsttoBoolean=function(boolStr:string|boolean):boolean{if(typeofboolStr==='boolean'){returnboolStr}returnboolStr.toLowerCase()==='true'}
「Firestoreデータを読み出して、Excelへ流し込んでダウンロード」のサンプルコード
Cloud StorageからテンプレートとなるExcelファイルを取得します。またFirestoreからはExcelに書き込むデータを取得し、再び「xlsx-populate-wrapper」を使ってExcelファイルへデータを書き込んで、ユーザへのResponseへExcelデータとして返却します。データをExcelへ書き込みつつ、ある程度の書式設定・罫線の描画も行っています。
import{Request,Response}from'express'import*asadminfrom'firebase-admin'import{getSample4Promise}from'./sample4'import*aspathfrom'path'import*asosfrom'os'constSAMPLE4:string='sample4'importxPopWrapper=require('xlsx-populate-wrapper')exportconstdownload=async(request:Request,response:Response)=>{constbucket=admin.storage().bucket()constfileName='output.xlsx'constfullPath=path.join(os.tmpdir(),fileName)try{awaitbucket.file(fileName).download({destination:fullPath,})// ファイル読み込みconsole.log(fullPath)constworkbook=newxPopWrapper(fullPath)awaitworkbook.init()constrowCount=awaitaddRow(workbook)applyStyles(workbook,rowCount)constnewFileName='download.xlsx'constnewFilePath=path.join(os.tmpdir(),newFileName)// 書き込んだファイルを保存awaitworkbook.commit(newFilePath)console.log(newFilePath)response.download(newFilePath,newFileName)}catch(error){console.log(error)response.status(500).send(error)}}constaddRow=async(workbook:any):Promise<number>=>{constdatas=awaitgetSample4Promise()constconvertedDatas=datas.map(data=>Object.assign(data,{isUnplanned:String(data.isUnplanned)// Booleanだけは、Excelでfalseが表示出来ず。文字列化することにした。}))workbook.update(SAMPLE4,convertedDatas)// 更新returndatas.length}// https://www.npmjs.com/package/xlsx-populate#style-reference// https://support.office.com/en-us/article/Number-format-codes-5026bbd6-04bc-48cd-bf33-80f18b4eae68?ui=en-US&rs=en-US&ad=US// https://www.tipsfound.com/vba/07015constapplyStyles=(workbook:any,rowCount:number)=>{constsheet=workbook.getWorkbook().sheet(SAMPLE4)sheet.range(`D2:D${rowCount+1}`).style('numberFormat','@')// 書式: 文字(コレをやらないと、見かけ上文字だが、F2で抜けると数字になっちゃう)sheet.range(`G2:G${rowCount+1}`).style('numberFormat','@')// 書式: 文字(コレをやらないと、見かけ上文字だが、F2で抜けると数字になっちゃう)sheet.range(`E2:F${rowCount+1}`).style('numberFormat','yyyy/mm/dd')// 書式: 日付sheet.range(`H2:H${rowCount+1}`).style('numberFormat','yyyy/mm/dd hh:mm')// 書式: 日付+時刻// データのある行に、罫線を引くsheet.range(`A2:I${rowCount+1}`).style('border',{top:{style:'thin'},left:{style:'thin'},bottom:{style:'thin'},right:{style:'thin'}})}
import*asadminfrom'firebase-admin'constSAMPLE4:string='sample4'typeQuerySnapshot=admin.firestore.QuerySnapshottypeDocumentSnapshot=admin.firestore.DocumentSnapshotexportconstgetSample4Promise=async():Promise<Array<any>>=>{constreturnArray:any=[]constsnapshot:QuerySnapshot=awaitadmin.firestore().collection(SAMPLE4).get()snapshot.forEach((docref:DocumentSnapshot)=>{constorgData=docref.data()!// nullはない、と仮定// プロパティを再定義。constdata=Object.assign(orgData,{opeDateFrom:orgData.opeDateFrom.toDate(),opeDateTo:orgData.opeDateTo.toDate(),destinationDate:orgData.destinationDate.toDate(),createdAt:orgData.createdAt.toDate(),updatedAt:orgData.updatedAt.toDate(),driverId:orgData.driver.ref.id,driver:orgData.driver.ref,})})returnreturnArray}
「Excelファイルを、Storageへアップロード(上記で用いるExcelテンプレートをアップロード)」のサンプルコード
登り電文のExcelファイルを受けとるのは、先ほどもでてきた「busboy」で。先ほどはファイルとして一時的に書き出しましたが、今回は受けとったデータをそのまま、Cloud Storage へ保存しています。
import{Request,Response}from'express'import*asadminfrom'firebase-admin'import*asBusboyfrom'busboy'exportconsttemplateUpload=async(request:Request,response:Response)=>{// https://qiita.com/rubytomato@github/items/11c7f3fcaf60f5ce3365console.log('start.')constbusboy=newBusboy({headers:request.headers})constbucket=admin.storage().bucket()// This object will accumulate all the uploaded files, keyed by their name.constuploads:{[key:string]:string}={}busboy.on('file',(fieldname,file,filename,encoding,mimetype)=>{console.log('busboy.on.file start.')console.log(`File [${fieldname}]: filename: ${filename}, encoding: ${encoding} , mimetype: ${mimetype}`)uploads[fieldname]=filenamefile.on('data',async(data)=>{console.log(`File [${fieldname}] got ${data.length} bytes`)try{awaitbucket.file(filename).save(data,{contentType:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'})}catch(error){console.log(error)response.status(500).send(error)}})file.on('end',()=>{console.log('file.on.end start.')console.log(`File [${fieldname}]: filename: ${filename} Finished.`)})})// Triggered once all uploaded files are processed by Busboy.// We still need to wait for the disk writes (saves) to complete.busboy.on('finish',()=>{console.log('busboy.on.finish start.')response.status(200).send(`${Object.keys(uploads).length} file(s) uploaded.`)})constreqex:any=requestbusboy.end(reqex.rawBody)}
細かい説明は省略してしまいましたが、だいたいこんな感じです。。
以上、おつかれさまでしたー。
関連リンク
- busboy HTTPでアップロードされたファイルを処理するライブラリ
- xlsx-populate Excelのパーサ
- xlsx-populate-wrapperそのWrapper
- Google Cloud内のドキュメント#マルチパートデータ busboyのサンプル
- FirebaseのHostingとCloud Functionsを利用してStorageへファイルをアップロードするデモアプリケーション busboyとStorageの連係処理の参考にさせてもらいました!感謝です。また、Firebaseの環境設定から丁寧に書いてあります。
- CloudFunctionsからCloudStorageへ画像をアップロードする上記は一度ファイルに書き出してましたが、こちらは直接Storageへ。感謝です
- xlsx-populateのStyle Reference xlsx-populate で指定出来る書式設定の一覧
- Number format codes Excelの数値フォーマット指定方法
- VBA セルに表示形式を設定する
Functionsからの処理なので、Security Rules の設定は影響がない、はず。もちろん本運用時は適切な設定で。 ↩