发布于 2026-01-06 1 阅读
0

我做过的蠢事以及我打算如何弥补它们

我做过的蠢事以及我打算如何弥补它们

我在这网站上发表的第一篇帖子之一就是关于我开发的用于考勤的Web应用程序:

我写那篇文章的时候,距离我完成应用程序的编写已经有一段时间了。它运行良好,中心每天都在使用它,已经快一年了,自从我离开那份工作后,我几乎没怎么干预过。我打算坐下来好好完善一下它,毕竟已经很久没碰过了。我决定“完善”的意思是把大部分后端和很多前端代码都重写一遍。好了,画面定格,你肯定想知道我是怎么走到这一步的。

现在回想起来,这个应用的编写原因……嗯,确实有点傻,但并非因为当时这是我能做到的最好程度,也不是因为我后来吸取了教训。不,这完全是迫不得已才做的,而我写这篇文章主要是为了自己吸取教训。如果我们不记住未来的错误,就注定要重蹈覆辙,或者说,某种程度上来说是这样。

对于我列出的每一个问题,我都完全清楚更好的做法,但我为了尽快做出一个能用的东西,选择了偷懒快捷的方式。我有点儿生过去的Ben的气。未来的Ben,希望这篇文章能给你一个警示。

应用程序位于此处

背景

我曾在一家非营利性幼儿园的行政办公室工作。那份工作有点忙乱。当然,幼儿园很可爱,但有时也难以预料,毕竟有孩子、老师、家长,你知道的,幼儿园里各种事情都有。说是行政工作可能有点轻描淡写了,但“杂务”这个职位名称也实在不太贴切。我经常因为各种原因在幼儿园里到处跑,电话也接踵而至,想要不受打扰地专心做一件事,哪怕只有十五分钟,都是一种奢侈。因此,时间非常宝贵,每一分每一秒的组织安排都弥足珍贵。

那时我已经业余写博客一段时间了,所以我不喜欢做那些我认为“应该用电脑完成”的任务,尽管有时候迫不得已,用纸笔确实是最简单、最便宜的办法。但有一次,情况显然并非如此。

问题

我的日常工作之一是收集全校学生的出勤情况并记录在电子表格中。然后,在记录所有缺勤情况后,我会计算“延时班名册”,并通过电子邮件发送给所有人,以便确定下午的教职工安排。

下午4点,大多数孩子都回家了。这是基本合同中包含的内容。家长可以选择让孩子留园到下午6点,但需按小时付费。由于只有少数家庭选择这项服务,白天的15个教室就减少到只有5个。为了确保师生比例符合要求,园长(和老师)需要知道在延长的时间段内会有多少孩子,包括任何缺勤的孩子。能安全减少的教职工越多越好!

所有这些都记录在一个小的螺旋笔记本里。教室分散在整栋楼里,所以为了避免绕着教室跑,万一有人需要我,我就挨个打电话,问老师有没有人缺席。我会把每个教室的缺席情况记下来。然后,在联系上所有十五个教室之后(说起来容易做起来难),我会把缺席人数和那些合同规定要留校的学生以及临时报名参加的学生进行核对,在预计人数上进行加减。最后,我会把结果整理成一封格式化的电子邮件,包括所有新增和移除学生的姓名。

这占用了我一天中相当大一部分时间。有时,由于电话时间安排不当以及各种干扰,从第一次通话到最后一封邮件,可能要花整整两个小时。

解决方案

我开发的应用程序会向用户展示一系列按钮,每个按钮对应一个孩子,并按班级进行组织。点击孩子的名字即可切换其“到场”或“缺席”状态;此外,每个尚未安排参加课后延时服务的孩子都会获得一个按钮,用于临时添加。实际的课后延时服务人数统计邮件仅取决于哪些孩子参加了,哪些孩子是额外参加的,因此页面顶部会始终保留一个更新后的版本,方便用户复制/粘贴,或者下载为文本文件:

应用截图

临时报名表打印在粉色复印纸上,因此被称为“粉色表格”。用户可以通过简单的用户界面指定核心房间如何与扩展房间连通:

房间选择器

那个“下载”按钮只是将文本编码为 base64 格式,并将其直接嵌入到链接中:


let make = (~school, ~refreshClicked, ~resetClicked, _children) => {
  ...component,
  render: _self => {
    let dload =
      "data:application/octet-stream;charset=utf8;base64,"
      ++ btoa(Report.school(school));
    <div>
      // ..
      <a href=dload> <button> {ReasonReact.string("Download")} </button> </a>
      // ..
    </div>;
  },
};
Enter fullscreen mode Exit fullscreen mode

一团糟

每天做这件事真的让我精疲力竭。我讨厌做这件事,所以急于找到一个能减少耗时的解决方案。因此,为了尽快开始工作,我尽可能地走了捷径。

刮擦声,哦,刮擦声

第一个问题是如何将当天的考勤表导入应用程序。遗憾的是,尽管我尽力争取,但我仍然无法直接查询组织的数据库,只能使用预先创建的 Crystal Reports 报表来提取数据。然而,这些报表并非专为数据抓取而设计,而且我完全无法控制它们具体提取哪些数据。它们是为方便人阅读而设计的,格式美观,可以直接打印或导出为 PDF 文件。Crystal Reports 确实提供了导出到 Excel 的选项,但这已经是能做到的极限了。最终得到的表格格式很奇怪,有一些奇怪的行和奇怪的数据,这是报表原本要生成的格式精美的 PDF 文件遗留下来的。不过,这份原本设计用于打印并分发给教室作为每日考勤表的报表,确实包含了我需要的所有信息,可以用来填充这个应用程序。

从这个电子表格中提炼出一个简洁的 Rust 数据结构并不复杂,但我当时比较赶时间:

pub fn scrape_enrollment(
    day: Weekday,
    extended_config: ExtendedDayConfig,
    config: &Config,
) -> Result<School> {
    lazy_static! {
        // Define patterns to match
        static ref KID_RE: Regex =
            Regex::new(r"((@|#|&) )?(?P<last>[A-Z]+), (?P<first>[A-Z]+)").unwrap();
        static ref CLASS_RE: Regex = Regex::new(r"CLASSROOM: ([A-Z])").unwrap();
        static ref CAPACITY_RE: Regex = Regex::new(r"CLASS MAXIMUM: (\d+)").unwrap();
    }

    info!("Loading {:?} from {:?}", day, &config.roster);
    let mut school = School::new(day, extended_config);

    // Use calamine to read in the input sheet
    let mut excel: Xls<_> = open_workbook(&config.roster).unwrap();

    let mut headcount = 0;
    let mut classcount = 0;

    // Try to get "Sheet1" as `r` - it should always exist
    if let Some(Ok(r)) = excel.worksheet_range("Sheet1") {
        // Process each row
        for row in r.rows() {
            use calamine::DataType::*;
            // Column A is either a Class or a Kid
            let column_a = &row[0];
            match column_a {
                String(s) => {
                    // If it's a class, open up a new class
                    // If its a kid, push it to the open class
                    // If it's anything else, ignore it.
                    if CLASS_RE.is_match(&s) {
                        debug!("MATCH CLASS: {}", &s);
                        let caps = CLASS_RE.captures(&s).unwrap();
                        // the capacity is found in Column B
                        let capacity: u8;
                        match &row[1] {
                            String(s2) => {
                                let capacity_caps = CAPACITY_RE.captures(&s2).unwrap();
                                capacity = (&capacity_caps[1])
                                    .parse::<u8>()
                                    .chain_err(|| "Unable to parse capacity as u8")?;
                            }
                            _ => {
                                bail!("Column B of Classroom declaration contained unexpected data")
                            }
                        }

                        // Display the previous class headcount  -this needs to happen once againa the end, and not the first time
                        if !school.classrooms.is_empty() {
                            let last_class = school.classrooms[school.classrooms.len() - 1].clone();
                            let prev_headcount = last_class.kids.len();
                            debug!("Room {} headcount: {}", last_class.letter, prev_headcount);
                        }

                        // create a new Classroom and push it to the school
                        let new_class = Classroom::new(classcount, caps[1].to_string(), capacity);
                        debug!(
                            "FOUND CLASS: {} (max {})",
                            &new_class.letter, &new_class.capacity
                        );
                        school.classrooms.push(new_class);
                        classcount += 1;
                    } else if KID_RE.is_match(&s) {
                        let caps = KID_RE.captures(&s).unwrap();

                        // Reformat name from LAST, FIRST to FIRST LAST
                        let mut name = ::std::string::String::from(&caps["first"]);
                        name.push_str(" ");
                        name.push_str(&caps["last"]);

                        // init Kid datatype

                        // Add schedule day
                        let sched_idx = match day {
                            schema::Weekday::Monday => 6,
                            schema::Weekday::Tuesday => 7,
                            schema::Weekday::Wednesday => 8,
                            schema::Weekday::Thursday => 9,
                            schema::Weekday::Friday => 10,
                        };
                        let sched = &row[sched_idx];
                        let new_kid = Kid::new(headcount, name, &format!("{}", sched));
                        debug!(
                            "FOUND KID: {} - {} ({:?})",
                            new_kid.name, sched, new_kid.schedule.expected
                        );
                        // If the kid is scheduled, push the kid to the latest open class
                        if new_kid.schedule.expected == Expected::Unscheduled {
                            debug!(
                                "{} not scheduled on {:?} - omitting from roster",
                                &new_kid.name, day
                            );
                        } else {
                            let mut classroom = school.classrooms.pop().expect(
                                "Kid found before classroom declaration - input file malformed",
                            );
                            classroom.push_kid(new_kid);
                            school.classrooms.push(classroom);
                            headcount += 1;
                            debug!("Adding to response");
                        }
                    }
                }
                _ => continue,
            }
        }
    }

    // Print out the status info
    let last_class = school.classrooms[school.classrooms.len() - 1].clone();
    info!(
        "Room {} headcount: {}",
        last_class.letter,
        last_class.kids.len(),
    );
    warn!(
        "Successfully loaded {:?} enrollment from {:?} - total headcount {}, total classcount {}",
        day, config.roster, headcount, classcount
    );

    Ok(school)
}
Enter fullscreen mode Exit fullscreen mode

看着就让我生气。这玩意儿简直是赶工出来的,只用了一个下午就拼凑出来了,根本没法维护。格式随时可能莫名其妙地变,到时候重构可就麻烦了其实每个部分都可以拆分成辅助函数,这样就能单独编辑每个函数了。

启动器

此应用程序的设计与标准的 Web 应用类似。它包含一个后端应用程序,该程序提供网页并附带一个 HTTP API,用于操作应用程序状态。用户通过 Web 浏览器访问此页面,并使用 UI 元素与这些 API 端点进行交互。

不过,我一直没能把它集中化,部分原因是IT部门工作量太大,根本没时间管我的这个业余实验。他们忙着做“正事”什么的。因此,要使用这个程序,用户必须先自己启动网络服务器。然后它就运行在……上localhost。为了方便理解,我写了一个小的批处理文件,把它叫做“启动器”:

:: Suppress command output
ECHO OFF
:: Launch server
start mifkad.exe
:: Launch client
start chrome http://127.0.0.1:8080
Enter fullscreen mode Exit fullscreen mode

没错,它确实能用,但是,这玩意儿也太不靠谱了。这也意味着每个用户都在运行一个本地的、隔离的应用实例。虽然这个实例操作的持久化数据存储确实是共享的,但每个应用都是独立访问的。更离谱的是,这个应用的架构其实设计得很好,可以避免写入冲突——只要有多个连接连接到同一个实例!这样运行完全绕过了这种内置的安全机制。真是糟糕透了。以下是实际调整应用状态的代码:

pub fn adjust_school(
    (path, state): (Path<(String, u32)>, State<AppState>),
) -> Box<Future<Item = Json<School>, Error = actix_web::Error>> {
    use self::Action::*;
    let action = Action::from_str(&path.0).unwrap();
    let id = path.1;

    {
        // Grab a blocking write lock inside inner scope
        let mut a = state.school.write().unwrap();

        // Perform the mutation
        match action {
            Toggle => (*a).toggle_kid(id),
            AddExt => (*a).addext_kid(id),
            Collect => (*a).collect_room(id),
            Reset => {
                reset_db(&state.config).unwrap();
                (*a) = init_db(&state.config).unwrap();
            }
        }
        // blocking lock is dropped here
    }

    // grab a new non-blocking reader
    let a = state.school.read().unwrap();

    // Sync the on-disk DB and return the json
    write_db(&*a).unwrap();
    result(Ok(Json((*a).clone()))).responder()
}
Enter fullscreen mode Exit fullscreen mode

为了安全地处理并发写入,开发者做了诸多周全的考虑,而且这个应用程序始终只用于与本地实例的单个连接。干得漂亮,本!

JSON

另一个愚蠢的捷径是数据持久化。由于多个用户会从不同的工作站(运行不同的应用程序实例)访问相同的数据,我决定将每次更改都写入共享的网络驱动器。应用程序启动时,会先检查是否已存在应用程序状态,并从中填充数据,而不是重新读取花名册电子表格。这本来应该一开始就使用关系型数据库来实现,但我没有。我只是将应用程序状态序列化为 JSON 格式,并将其存储为带有日期戳的文本文件,例如“20190621.json”。应用程序只需查找日期即可查看是否存在对应的文件。

这方法勉强能用,但这些文件没什么用。它们所在的文件夹莫名其妙地不断增长,一天结束后这些文件基本就没用了。我可以清理它们,但除了AR(课后延时班)之外,它们也是记录哪些孩子参加了延时班的唯一途径。有额外的备份当然有用,但要翻阅这些文件就意味着要从一堆没有空格的JSON数据中筛选。这很麻烦,而且除了我以外,其他人几乎不可能这么做。

它也很容易出问题——如果当天的文件丢失或被篡改,就无法读取,必须重新开始。更完善的数据接口管理可以避免这类问题。

复兴

我现在重新开始这个项目,是因为我想尝试将其推广到网络中的其他位置。我觉得我或许可以把这个推广应用到当前的版本上,但是再次深入研究代码让我感觉浑身难受,需要好好洗个澡,我不能就这么算了。

此外,自从我写完这篇文章后,无论是新的后端框架还是新的前端框架,都进行了重大更新。通常我不会太在意这些,但后端框架已经actix_web从 0.7 稳定升级到了 1.0。考虑到我最终(大部分情况下)希望它能长期稳定运行,这确实很不错。ReasonReact 还稳定了更简洁的组件语法,我认为这让代码更易读——对于一个我最多几个月才会修改一次的项目来说,这简直完美。

前端迁移我预计不会很复杂。实际上,我对它基本满意,尽管我认为还有一些可以改进的地方。例如,这个应用程序的核心功能在于能够查看 15 个核心教室,并从中提取出 5 个精简后的教室。目前,这是通过 Reason 中一个不太透明的折叠操作来实现的:

let add_extended_room = (school, classroom) => {
  /* This is our folding fn from get_extended_rooms below.
     It should take a room and a school and either add the new room
     or if a room already exists with the same letter, just add those kids */
  let target = ref(school.classrooms);

  if (Array.length(target^) == 0) {
    target := Array.append(target^, Array.make(1, classroom));
  } else {
    let already_included =
      Array.map((oldr: classroom) => oldr.letter, school.classrooms);
    let found = ref(false);
    let idx = ref(0) /* This will only be read later if found is toggled to true*/;
    Array.iteri(
      (i, l) =>
        if (classroom.letter == l) {
          found := true;
          idx := i;
        },
      already_included,
    );
    if (found^) {
      /* We've already seen this letter - mash the new kid list into the matching existing kid list */
      let old_classroom = school.classrooms[idx^];
      let new_classroom = {
        ...old_classroom,
        capacity:
          get_extended_capacity(classroom.letter, school.extended_day_config)
          |> int_of_string,
        kids: ref(Array.append(old_classroom.kids^, classroom.kids^)),
      };
      target^[idx^] = new_classroom;
    } else {
      /* This is a new extended day room - add it as-is, grabbing the extended day capacity */
      target :=
        Array.append(
          target^,
          Array.make(
            1,
            {
              ...classroom,
              capacity:
                get_extended_capacity(
                  classroom.letter,
                  school.extended_day_config,
                )
                |> int_of_string,
            },
          ),
        );
    };
  };

  {...school, classrooms: target^};
};

let get_extended_rooms = school => {
  /* Returns a `school` of the extended kids */
  let s = get_extended_kids(school);
  Array.fold_left(
    add_extended_room,
    {...school, classrooms: [||]},
    s.classrooms,
  );
};
Enter fullscreen mode Exit fullscreen mode

说实话,它能这么稳定运行,我已经很佩服了。但这简直就是在说“我只是自以为懂函数式编程而已”。我完全不记得当初是怎么想到这个解决方案的,而且有点不敢再碰它了。这可不好。

不过,后端才是重中之重。新的稳定版本与actix_web我目前使用的版本相比有重大变更,所以我很可能至少要重写我的处理程序。趁此机会,我打算移除 JSON 序列化机制,转而使用 SQLite 存储。这样仍然具有可移植性,因为它可以以非常独立的方式存储在操作系统上的一个文件中,而且更不容易意外损坏。它还有一个额外的好处,那就是可以进行查询——如果你想知道阿尔伯特·戈尔在 1 月 2 日到 2 月 12 日期间何时报名加班,你可以直接使用 SQL 查询。在确保基础应用程序运行正常后,我想添加一个用户界面来展示其中的一些功能。

另一个可以基于更完善的基础架构开发的新功能是允许班级自行录入考勤。如果这款应用按预期使用,即作为一个始终运行的进程,用户远程连接而非本地启动,那么教师完全可以直接在应用上记录考勤。这样就省去了他们单独收集考勤,然后反复打电话确认的麻烦。如果教师能够通过教室里的 iPad 访问专门为学生设计的网页,标记学生的出勤情况,那么网站的考勤基本上就能自动完成。不过,要实现这个功能,我需要采用集中式应用模式。

我还想详细阐述一下测试方面的情况。后端各个部分都编写了一些测试用例,但我不会称其为一个经过充分测试的应用程序。如果能尽可能地实现产品日常维护的自动化,那就更好了。

最后,很明显,这个用户界面是由一个对CSS极度恐惧的人设计的,他除了最基本的CSS之外,对其他任何CSS都一窍不通。它没必要这么丑。我应该借此机会练习一下这方面的技能。

看来过了这么久,我申请这个职位还有很长的路要走。当初萌生尝试申请的想法时,我真没想到会这么难。看来我得赶紧行动了!

文章来源:https://dev.to/decidously/the-dumb-things-i-did-and-how-im-going-to-fix-them-1o1j