パーフェクトな言語であるRustでGoogle spreadsheetをJson APIに変換してみる
タイトルにはやや釣り要素が混じっています。
概要
私が今まで所属していた開発チームでは、非エンジニアとエンジニアで気軽にデータを共有する方法としてGoogle Spreadsheetがよく使われていました。
Spreadsheetの優れている点の1つとしてAPIを経由してデータの取り込みを自動化できる事が挙げられるかと思いますが、そのAPIの呼び出し周りの実装はやや手間がかかる(し、それほど面白いものではない、)のが悩みどころです。
なのでシンプルなフォーマットのSpreadsheetをNo-Code or Low-CodeでJson API化できたら便利です。
SpreadsheetのAPI化のサービスとしては、SaaSとして提供されている使い勝手の良いものがいくつかありますが、外部と共有できないデータを扱う場合は自前で用意した環境内だけでSpreadsheetを共有する必要がでてきます。
ということでGCPのサービスアカウントさえ用意すれば、docker上で実行できるSpreadsheet Json APIサーバを自作しました。
Rustで実装したのは、後述するJson構造の変換処理が速く実行できるすかなという予想もあったのですが、現時点で私が一番好きな言語だからというのが主な理由です。
サンプルアプリを使った機能の説明
こちら
から実行できます。(サンプルアプリはpaging limit 100,列はA:DZの範囲しか読めないように制限されています)
Response
PaneのAPI url
にgetリクエストを送ると結果が返却されます。
全体に公開されているシート、またはシートをsa-app@sandbox-taco.iam.gserviceaccount.com
と共有(参照のみ)しているシートのURLを入力してもらえれば、内容がjsonとして返ってきます。
シートの1行目のA列からセルが空白の列までをヘッダとし、2行目以降をデータ行としてjsonのarrayで返却しています
サンプルのSpreadsheetのfavcorite
列の様に、
同じヘッダが複数ある場合はarrayに変換しますし、address.{city|zipcode}
列のようにピリオドを含むヘッダはobjectに変換しています。
このサンプルアプリはCloud run上に構築ししています。ちなみにフロント部分はNextjsで雑に作成しています。
Spreadsheet APIのlatencyが意外に良く、ローカル環境で日本の郵便番号
10000件を一度に取得しても5秒程度で返ってくるので、batch処理である程度のデータ量を扱う場合でも使えそうです。
今はvalidationが全然足りていませんので、ちょっとしたことでエラーになってしまうかもしれませんが、なにかあればgithub issueなどで教えていただけるとありがたいです
ユースケース
- JamstackなどでLP、または静的サイトに動的なコンテンツを取り込みたい場合SpreadsheetのデータをCMSのように取得できます。
- 機械学習の教師データをSpreadsheet上で追加していき、内容をjsonデータとしてバッチ処理から取得できます。取得できる行数に制限はないので大量のデータも扱えます。
実装について
実装の中でちょっと工夫した点をいくつか書きます。
Google Spreat Sheet APIをRustで使う
RustでGoogle Spreadsheetを扱うSDKは非公式のものが存在していますが、こちらはAPI定義から(?)自動生成されているため、やや取り扱いが直感的でないという欠点があります(個人的に、特に認証周りがややこしい様に感じました)。
googleが用意しているdocumentにもある通り、Spreadsheet APIはoauth2認証が使用でき、Restful APIもそんなに難しくなさそうだったので自作しました。
oauth2のaccess tokenは1時間程度で執行するのでバックグラウンドで定期的にリフレッシュをかけます。
またそのtokenは複数のthread間で共有します。structTokenManager
を用意しこれらを担当させる事にしました。
oauth2の実装自体はService accountファイルにも対応しているyup-oauth2を使用させてもらいました。
Oauth2 Tokenの自動リフレッシュ
pub async fn token_manager_from_service_account_file(
scopes: &'static [&'static str],
service_account_cred_file: PathBuf, //TODO(tacogips) PathBuf to reference type
stop_refreshing_notifyer_rx: broadcast::Receiver<()>,
token_refresh_period: Option<Duration>,
) -> Result<TokenManager<<DefaultHyperClient as HyperClientBuilder>::Connector>> {
let sa_key = oauth::read_service_account_key(&service_account_cred_file)
.await
.map_err(|e| {
GoogleTokenManagerError::ServiceAccountFileLoadError(
service_account_cred_file.clone(),
e,
)
})?;
let authenticator = oauth::ServiceAccountAuthenticator::builder(sa_key)
.build()
.await
.map_err(|e| {
GoogleTokenManagerError::InvalidServiceAccountFileError(service_account_cred_file, e)
})?;
TokenManager::start(
authenticator,
scopes,
stop_refreshing_notifyer_rx,
token_refresh_period,
)
.await
}
// ...
pub struct TokenManager<HttpConnector> {
authenticator: Arc<Authenticator<HttpConnector>>,
scopes: &'static [&'static str],
inner_current_token: Arc<ArcSwap<AccessToken>>,
token_refreshing_loop_jh: JoinHandle<()>,
}
impl<HttpConnector> TokenManager<HttpConnector>
where
HttpConnector: hyper::client::connect::Connect + Clone + Send + Sync + 'static,
{
pub async fn start(
authenticator: Authenticator<HttpConnector>,
scopes: &'static [&'static str],
stop_refreshing_notifyer_rx: broadcast::Receiver<()>,
token_refresh_period: Option<Duration>,
) -> Result<Self> {
let access_token = authenticator.token(scopes.as_ref()).await?;
let current_token = Arc::new(ArcSwap::from(Arc::new(access_token)));
let authenticator = Arc::new(authenticator);
let token_refreshing_loop_jh = Self::periodically_refreshing_token(
authenticator.clone(),
current_token.clone(),
scopes,
stop_refreshing_notifyer_rx,
token_refresh_period,
)
.await;
let result = Self {
authenticator,
scopes,
inner_current_token: current_token,
token_refreshing_loop_jh,
};
Ok(result)
}
async fn periodically_refreshing_token(
authenticator: Arc<Authenticator<HttpConnector>>,
shared_token: Arc<ArcSwap<AccessToken>>,
scopes: &'static [&'static str],
mut stop_refreshing_notifyer_rx: broadcast::Receiver<()>,
token_refresh_period: Option<Duration>,
) -> JoinHandle<()> {
let shared_token_current = shared_token.clone();
let refresh_token_loop_jh = tokio::spawn(async move {
let refresh_period = token_refresh_period
.map(|p| p.to_std().unwrap())
.unwrap_or_else(|| std::time::Duration::from_secs(30));
loop {
let has_stop_notified =
timeout(refresh_period, stop_refreshing_notifyer_rx.recv()).await;
if has_stop_notified.is_ok() {
log::info!("exiting from auth token refreshing loop");
break;
}
let current_token = shared_token_current.load();
let need_refresh = (**current_token)
.expiration_time()
.map(|expiration_time| {
expiration_time - *get_token_buffer_duraiton_to_expire() <= Local::now()
})
.unwrap_or(false);
if need_refresh {
let new_token = Self::get_new_token(&authenticator, &scopes).await;
match new_token {
Ok(access_token) => shared_token.store(Arc::new(access_token)),
Err(e) => {
log::error!("failed to refresh token :{}", e);
}
}
}
}
log::info!("exit from refreshing token loop")
});
refresh_token_loop_jh
}
#[allow(dead_code)]
pub fn authenticator(&self) -> Arc<Authenticator<HttpConnector>> {
Arc::clone(&self.authenticator)
}
#[allow(dead_code)]
pub async fn force_refresh_token(&mut self) -> Result<()> {
let new_token = Self::get_new_token(&self.authenticator, &self.scopes).await;
match new_token {
Ok(access_token) => {
self.current_token().store(Arc::new(access_token));
Ok(())
}
Err(e) => {
log::error!("failed to refresh token :{}", e);
return Err(e);
}
}
}
async fn get_new_token(
authenticator: &Authenticator<HttpConnector>,
scopes: &'static [&'static str],
) -> Result<AccessToken> {
let new_token = authenticator.force_refreshed_token(scopes).await?;
Ok(new_token)
}
pub async fn wait_until_refreshing_finished(self: Self) -> Result<()> {
self.token_refreshing_loop_jh.await?;
Ok(())
}
}
特記する点としてはfn periodically_refreshing_token()
内で定期的にtokenをrefresh するtokio taskを作成していますが、token refreshの終了をstop_refreshing_notifyer_rx
というchannelで受け取っており、またrefresh taskのJoinHandleをstructで保持している点でしょうか。
プロセスが停止するSignalを受け取ったとき、呼び出し元でstop_refreshing_notifyer_rxに通知を飛ばして、JoinHandleの終了を待つこと(fn wait_until_refreshing_finished()を呼ぶ
)でGracefulにtoken managerを終了させる事ができます。
Spreadsheet APIの呼び出し
SpreadSheetの値を取得するときは、上記のoauth tokenをAuthorizationヘッダに付けてリクエストを投げるだけです。
use reqwest::{header, Client as ReqClient, StatusCode};
pub async fn get_sheet_value<HttpConnector>(
client: &ReqClient,
token_manager: Arc<TokenManager<HttpConnector>>,
spread_sheet_id: &SpreadSheetId,
ranges: &str,
_major_dimension: Option<MajorDimension>,
_value_render_option: Option<ValueRenderOption>,
_date_time_render_option: Option<DateTimeRenderOption>,
) -> Result<SheetValues> {
let url = SheetOperation::BatchGet.endpoint(spread_sheet_id);
let req_header = {
let auth_token = token_manager.current_token().load();
request_header(auth_token.as_str()).await
};
let query_param = vec![("ranges", ranges)];
let response = client
.get(&url)
.headers(req_header)
.query(&query_param)
.send()
.await?;
let result = if response.status() == StatusCode::NOT_FOUND {
return Err(SheetApiError::SpreadSheetNotFoundError(format!(
"{}",
spread_sheet_id
)));
} else if response.status() == StatusCode::BAD_REQUEST {
let json_value: JsonValue = response.json().await?;
log::error!("sheet apid error :{}", json_value);
return Err(SheetApiError::BadReqestError(format!("{}", json_value)));
} else {
response.json().await?
};
Ok(result)
}
Spreadsheet列表記やRange表記とindexの変換
Spreadsheetの列はアルファベット表記ですが、これは列のindexとして数値で扱えたほうが便利なので変換処理をかませます。
"A"を0に、"AA"26を変換したりします。ただの26進数かと思いきや1桁目の"A"は"0"扱いであり2桁目の"A"は"1"扱いになるのでその点を考慮します
また、Spreadsheetの範囲は"'sheet title'!A1:D3"
のように表記されますがこちらもindexへの変換ができると便利です。
ココらへんは地道にやっているだけなのでリンクのみ
Json構造の変換
同じ値のヘッダを持つ列の値をArrayにしたり、ピリオド区切りの値をObjectに変換したりします。
変換するJsonの構造をStructure
というツリー構造に落とし込みます。このツリーは変換されるjsonのfield名をキーに持ち、値に列のindexを持ちます。
Structure
のfn build_json()
メソッドに行の値をsliceとして渡すことでjsonの構造を表すJsonValueRef
に変換されます。
(ここでserde_json::Value
に変換してもいいのですが、不要なメモリアロケーションを避ける意図でValueへの参照をもつJsonValueRef
に変換しています。ただしJsonValueRef
は最終的にserde_json::Value
に変換されますのであまり意味はないかも、、)
use serde_json::Value as JsonValue;
#[derive(Debug, PartialEq)]
pub enum Structure<'a> {
Object(Object<'a>),
Array(Key<'a>, Vec<usize>),
Value(Key<'a>, usize),
}
impl<'a> Structure<'a> {
pub fn new_obj(obj: Object<'a>) -> Self {
Self::Object(obj)
}
pub fn new_arr(k: Key<'a>, v: Vec<usize>) -> Self {
Self::Array(k, v)
}
pub fn new_value(k: Key<'a>, v: usize) -> Self {
Self::Value(k, v)
}
pub fn build_json<'v>(&'a self, values: &'v [&JsonValue]) -> Result<JsonValueRef<'v, 'a>> {
match self {
Structure::Object(obj) => obj.build_json(values),
Structure::Array(key, indices) => {
let mut result = Vec::<&JsonValue>::new();
for index in indices {
match values.get(*index) {
None => {
return Err(JsonStructureError::ValueOutOfRange(
key.to_string(),
*index,
))
}
Some(value) => result.push(value),
}
}
Ok(JsonValueRef::Array(result))
}
Structure::Value(key, index) => match values.get(*index) {
None => return Err(JsonStructureError::ValueOutOfRange(key.to_string(), *index)),
Some(value) => Ok(JsonValueRef::Value(value)),
},
}
}
}
pub(crate) fn split_keys<'a>(keys: &'a str) -> Vec<Key<'a>> {
keys.split(".").map(|e| e.trim()).collect()
}
fn key_seq_to_key_str<'a>(keys: &[Key<'a>]) -> String {
keys.join(".")
}
fn key_seq_to_sub_key_str<'a>(keys: &[Key<'a>], end_idx: usize) -> String {
keys[0..end_idx].join(".")
}
#[derive(Debug, PartialEq)]
pub struct Object<'a> {
pub keys: Vec<Key<'a>>,
pub values: HashMap<Key<'a>, Structure<'a>>,
}
impl<'a> Object<'a> {
pub fn new() -> Self {
Self {
keys: Vec::new(),
values: HashMap::new(),
}
}
pub fn from_strs(strs: &'a [&str]) -> Result<Object<'a>> {
let mut obj = Self::new();
for (idx, each) in strs.iter().enumerate() {
obj.add_value(each, idx)?;
}
Ok(obj)
}
pub fn contains_key(&self, key: Key<'a>) -> bool {
self.values.contains_key(key)
}
pub fn get_mut(&mut self, key: Key<'a>) -> Option<&mut Structure<'a>> {
self.values.get_mut(key)
}
fn add_value(&mut self, key: &'a str, idx: usize) -> Result<()> {
let key_seq = split_keys(key);
if key_seq.is_empty() {
return Err(JsonStructureError::InvalidKey(format!(
"invalid key:{}",
key
)));
}
if let Some(_) = key_seq.iter().find(|e| e.is_empty()) {
return Err(JsonStructureError::InvalidKey(format!(
"invalid key:{}",
key
)));
}
self.add_value_with_key_seq(0, key_seq.as_slice(), idx)
}
/// about key_seq: key_strings "obj1.key1.key2" turns into vec!["obj1","key1","key2"]
///
pub fn add_value_with_key_seq(
&mut self,
key_idx: usize,
key_seq: &[Key<'a>],
idx: usize,
) -> Result<()> {
if key_seq.len() <= key_idx {
panic!(
"keys seq is out of range.this must be a bug key_seq.len() = {}, key_idx={} ",
key_seq.len(),
key_idx
)
}
// key_seq never be empty here
let current_key = unsafe { key_seq.get_unchecked(key_idx) };
let is_last_key_of_seq = (key_seq.len() - 1) == key_idx;
if is_last_key_of_seq {
let overwrite = match self.get_mut(current_key) {
None => {
self.inner_add(*current_key, Structure::new_value(*current_key, idx));
None
}
Some(existing) => match existing {
Structure::Object(_) => {
return Err(JsonStructureError::InvalidJsonStructureDef(format!(
"key:`{}` is a value but also a object",
key_seq_to_key_str(key_seq)
)))
}
Structure::Array(_, arr) => {
arr.push(idx);
None
}
Structure::Value(key, existing_idx) => {
let new_arr = Structure::new_arr(key, vec![*existing_idx, idx]);
Some(new_arr)
}
},
};
if let Some(overwrite) = overwrite {
self.inner_add(current_key, overwrite)
}
} else {
match self.get_mut(current_key) {
None => {
let mut new_obj = Object::new();
new_obj.add_value_with_key_seq(key_idx + 1, key_seq, idx)?;
self.inner_add(current_key, Structure::Object(new_obj))
}
Some(existing) => match existing {
Structure::Object(obj) => {
obj.add_value_with_key_seq(key_idx + 1, key_seq, idx)?
}
Structure::Array(_, _arr) => {
return Err(JsonStructureError::InvalidJsonStructureDef(format!(
"key:`{}` supposed to be a object but array",
key_seq_to_sub_key_str(key_seq, key_idx)
)))
}
Structure::Value(_, _idx) => {
return Err(JsonStructureError::InvalidJsonStructureDef(format!(
"key:`{}` supposed to be a object but value",
key_seq_to_sub_key_str(key_seq, key_idx)
)))
}
},
}
}
Ok(())
}
fn inner_add(&mut self, key: Key<'a>, v: Structure<'a>) {
if self.contains_key(key) {
self.values.insert(key, v); //override
} else {
self.values.insert(key, v);
self.keys.push(key);
}
}
pub fn build_json<'v>(&'a self, values: &'v [&JsonValue]) -> Result<JsonValueRef<'v, 'a>> {
let mut value_map = Vec::with_capacity(self.values.len());
for each_key in &self.keys {
match self.values.get(each_key) {
None => {
return Err(JsonStructureError::InvalidStructureState(format!(
"key {} is not exists",
each_key
)))
}
Some(index) => {
let json_value = index.build_json(values)?;
value_map.push((*each_key, json_value));
}
}
}
Ok(JsonValueRef::Object(value_map))
}
}
pub enum JsonValueRef<'v, 'k: 'v> {
Object(Vec<(Key<'k>, JsonValueRef<'v, 'k>)>),
Array(Vec<&'v JsonValue>),
Value(&'v JsonValue),
}
impl JsonValueRef<'_, '_> {
pub fn into_json_value(self) -> JsonValue {
match self {
JsonValueRef::Object(key_values) => {
let mut obj = JMap::with_capacity(key_values.len());
for (k, v) in key_values.into_iter() {
obj.insert(k.to_string(), v.into_json_value());
}
JsonValue::Object(obj)
}
JsonValueRef::Array(arr) => {
let arr_values: Vec<JsonValue> = arr.into_iter().map(|each| each.clone()).collect();
JsonValue::Array(arr_values)
}
JsonValueRef::Value(v) => v.clone(),
}
}
}
impl<'v, 'k> Serialize for JsonValueRef<'v, 'k> {
#[inline]
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: ::serde::Serializer,
{
match *self {
JsonValueRef::Value(ref v) => v.serialize(serializer),
JsonValueRef::Array(ref vs) => vs.serialize(serializer),
JsonValueRef::Object(ref vec) => {
let mut obj: HashMap<&str, &JsonValueRef> = HashMap::with_capacity(vec.len());
for (k, v) in vec {
obj.insert(k, v);
}
obj.serialize(serializer)
}
}
}
}
web サーバはAxum
TokenManagerをExtensionとしてhandlerで使用できるようにしています。
Axum公式(tower-http公式)でCorsの対応もされており問題なく使えます
将来的にCorsのoriginを絞れるようにしたほうが良さそうです。
Graceful shutdown にはsignal_hookを使用しています
const REQUEST_TIMEOUT_SEC: u64 = 60;
pub async fn run_server<HttpConnector>(
config: Config,
host: IpAddr,
port: u16,
token_manager: Arc<TokenManager<HttpConnector>>,
) -> Result<(), hyper::Error>
where
HttpConnector: Clone + Send + Sync + 'static,
{
let app = Router::new()
.route("/meta", get(metadata))
.route(
"/sheet/:spread_sheet_id",
get(spread_sheet_handler::get_spread_sheet_value::<HttpConnector>),
)
.route("/sheet_meta", get(spread_sheet_meta::get_spread_sheet_meta))
.route(
"/",
get_service(ServeFile::new(format!(
"{}/index.html",
config.playground_file_dir
)))
.handle_error(|error: std::io::Error| async move {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Unhandled internal error: {}", error),
)
}),
)
.nest(
"/_next",
get_service(ServeDir::new(format!(
"{}/_next",
config.playground_file_dir
)))
.handle_error(|error: std::io::Error| async move {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Unhandled internal error: {}", error),
)
}),
)
.layer(
CorsLayer::new()
.allow_origin(any())
.allow_methods(vec![Method::GET]),
)
.layer(AddExtensionLayer::new(token_manager))
.layer(AddExtensionLayer::new(config.clone()))
.layer(
ServiceBuilder::new()
.layer(HandleErrorLayer::new(|error: BoxError| async move {
if error.is::<tower::timeout::error::Elapsed>() {
Ok(StatusCode::REQUEST_TIMEOUT)
} else {
Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Unhandled internal error: {}", error),
))
}
}))
.timeout(Duration::from_secs(REQUEST_TIMEOUT_SEC))
.into_inner(),
);
let addr = SocketAddr::from((host, port));
axum::Server::bind(&addr)
.serve(app.into_make_service())
.with_graceful_shutdown(server_shutdown_signal())
.await
}
pub async fn server_shutdown_signal() {
let (handle, signals) = quit_signal_handler();
let signals_task = tokio::spawn(handle_quit_signals(signals));
signals_task.await.unwrap();
handle.close();
log::info!("quit singal received. starting graceful shutdown the web server")
}
pub fn quit_signal_handler() -> (iterator::backend::Handle, SignalsInfo) {
let signals = Signals::new(&[SIGHUP, SIGTERM, SIGINT, SIGQUIT]).unwrap();
(signals.handle(), signals)
}
pub async fn handle_quit_signals(signals: Signals) {
let mut signals = signals.fuse();
while let Some(signal) = signals.next().await {
match signal {
SIGHUP | SIGTERM | SIGINT | SIGQUIT => {
// Shutdown the system;
log::info!("shutdown signal has receipt");
break;
}
_ => unreachable!(),
}
}
}
Docker化
Dockerfileは以下のようになっています。GCP上で動かすDocker imageを作成するときService AccountのファイルをDocker image内に平で置いていてこれはセキュリティ的にどうなのといつも思うのですが、なにかいい方法あるでしょうか?(私が全然知らないだけかも)
Imageを軽量化するためにalpineにしたいところですが、alpineのimageを作る時にはいろいろつまずきそうで気力が持たなかったです。gcr上ではDocker imageは46MBほどです。
ARG RUST_VER=1.57
# --- prepare ------------------------------------------------
From rust:${RUST_VER} as prepare
RUN cargo install cargo-chef && rm -rf $CARGO_HOME/registry/
WORKDIR /app
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
# --- cacher -----------------------------------------------
From rust:${RUST_VER} as cacher
RUN cargo install cargo-chef && rm -rf $CARGO_HOME/registry/
WORKDIR /app
COPY /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
# --- builder -----------------------------------------------
From rust:${RUST_VER} as builder
ARG BUILD_FLAG=--release
RUN rustup component add rustfmt
WORKDIR /app
COPY . .
COPY /app/target target
COPY $CARGO_HOME $CARGO_HOME
WORKDIR /app/api-everywhere
RUN cargo build ${BUILD_FLAG}
# --- bin -----------------------------------------------
From debian:buster-slim as runtime
ARG BUILD_TARGET=release
ARG SERVICE_ACCOUNT_FILE=./dev-secret/test-sa-key.json
ARG PLAYGROUND_DIR=./src/playground_html
# to fix:
# error while loading shared libraries: libssl.so.1.1: cannot open shared object file: No such file or directory
# TODO(tacogips) いらないかも? 試していない
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
ca-certificates
#RUN apt-get -y update && apt-get install -y libssl-dev
WORKDIR /app
COPY /app/target/${BUILD_TARGET}/api-everywhere api-everywhere
COPY ${SERVICE_ACCOUNT_FILE} app_service_account.json
COPY ${PLAYGROUND_DIR} playground_html
RUN ["chmod","+x","./api-everywhere"]
RUN ["ls"]
ENV SERVICE_ACCONT_FILE=/app/app_service_account.json
ENV PLAYGROUND_DIR=/app/playground_html
CMD ["./api-everywhere","--host","0.0.0.0"]
実行時のService Accountの準備
GCPコンソール上でサービスアカウントを作成し、Google Spreadsheet APIを実行できるように権限を与えます。下記リンクが参考になるかと思います
Using OAuth 2.0 for Server to Server Applications
How to read from and write into Google Sheets from your robots
(japanese)サービスアカウントで認証してGoogleSpreadsheetからデータを取得
まとめ
実はもともとJson構造の変換
で行っているツリーのデータ構造をBox,CellRef,unsafe辺りを使ってガッツリ実装してみたくて着手したのですが、やっていることが簡単だったためかシンプルな実装で実現できてしまいました。
validationが全然入っていなかったり、列の範囲を指定する機能があるもののAxum側で実装する気力が萎えたりで足りない点が多くあります。
機能はシンプルですが、個人的にSpreadsheetは公私ともに頻繁に使っているので、将来の忘れた頃に過去に準備していたことを思い出してほっとするガムテープのようなツールとしてゆるく使っていこうかと思います。
Discussion